//! AI 聊天命令模块 — 流式对话、工具调用、审批门控、提供商管理 //! //! 由原单文件 ai.rs(2663 行)按职责拆分为 15 个子模块。 //! //! 模块布局: //! - [`commands`] — 所有 `#[tauri::command]` IPC 函数 //! - [`agentic`] — run_agentic_loop / try_continue_agent_loop / DEFAULT_MAX_AGENT_ITERATIONS //! - [`stream_recv`] — stream_llm 流式接收 //! - [`conversation`] — save_conversation / TokenAccumulator / accumulate_tokens //! - [`title`] — 对话标题生成 //! - [`audit`] — 工具调用审计 + pending 审批恢复 + 工具调用处理 //! - [`skills`] — 本机 Claude 技能扫描 //! - [`prompt`] — 系统提示词构建 //! - [`provider_pool`] — F-260614-04 多 Provider 负载均衡池选择(纯逻辑) //! - [`compress`] — F-15 上下文压缩 LLM 摘要(compress_via_llm 公共函数) //! - [`event_bus`] — L3 全局事件数据总线 pub-sub 骨架(broadcast,渐进第一步,未接入 emit) //! - [`tool_registry`] — AI 工具注册表构建 + 文件路径校验 //! - [`knowledge_inject`] — 知识库注入 + 提炼 //! - [`augmentation`] — 上下文增强 //! - [`http`] — HTTP 调用封装 //! - [`secret`] — 密钥/凭证管理 //! //! 事件协议:通过 app.emit("ai-chat-event", payload) 流式推送到前端 //! - AiTextDelta: 流式文本片段 //! - AiToolCallStarted/Completed: 工具调用生命周期 //! - AiApprovalRequired: 需要人工审批 //! - AiCompleted/AiError: 完成/错误 pub mod agentic; pub mod augmentation; pub mod audit; pub mod code_intel; pub mod commands; pub mod compress; pub mod conversation; pub mod event_bus; pub mod http; pub mod knowledge_inject; pub mod prompt; pub mod provider_pool; pub mod remote_bridge; pub mod secret; pub mod skills; pub mod stream_recv; pub mod title; pub mod tool_registry; use agentic::conv_state::ConvState; use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::sync::atomic::AtomicBool; use serde::{Deserialize, Serialize}; // RiskLevel 不在此 import:PendingApproval.risk_level 删后 mod.rs 不再用(audit 流程在 audit.rs 自 import) use df_ai::context::ContextManager; use df_ai::context::ContextConfig; // F-#95 扩展:AiMessageHistory 事件载荷 Vec(历史消息同步)。 use df_ai::provider::ChatMessage; // ============================================================ // 重导出 — 保路径不变(state.rs / lib.rs / knowledge.rs 在用) // ============================================================ // commands 子模块(glob 重导出) — `#[tauri::command]` 宏生成的命令函数 + // 内部符号(__cmd__xxx / __tauri_command_name_xxx)同源同模块定义, // glob 把它们全部拉到 commands::ai 路径,使 generate_handler! 能解析到。 // 静默 unused_imports:glob 重导出用于跨模块路径解析,本文件不引用这些符号。 #[allow(unused_imports)] pub use self::commands::*; // 子模块的非命令 pub 项,逐个保路径 #[allow(unused_imports)] pub use self::audit::restore_pending_approvals; #[allow(unused_imports)] pub use self::knowledge_inject::{retry_failed_embeddings, spawn_embedding_for_knowledge, trigger_extraction_now}; #[allow(unused_imports)] pub use self::tool_registry::build_ai_tool_registry; // Input Augmentation 层(核心设计2):ResolverRegistry 构建 helper(state.rs init 调用) #[allow(unused_imports)] pub use self::augmentation::build_resolver_registry; // ============================================================ // 事件载荷类型(放 mod.rs,各子文件经 use super::* 拿到) // ============================================================ /// AI 错误分类(B-260615-42):供前端按错误源差异化提示(如 auth 引导填 key, /// network 提示检查连接,provider_config 引导检查 base_url/model)。 /// /// 设计:`Option` 向后兼容——旧 emit 点若难精确分类填 `None`, /// 旧前端消费方忽略未填值;新点尽量精确填并在注释标注映射理由。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ErrorType { /// 鉴权失败:API key 缺失/无效/钥匙串损坏(resolve_provider_secret / ensure_resolved_key 失败) Auth, /// 网络错误:连接失败/DNS 解析失败/流中途断(SSE 传输断) Network, /// 超时:连接超时/idle timeout(120s 无 chunk) Timeout, /// Provider 配置错误:base_url/model/provider_type 错或无可用 provider(配置丢失/全删) ProviderConfig, /// 未分类(达最大轮次/provider 流式错误事件等难精确归类的旧点) Unknown, } /// AI 聊天事件(推送到前端) #[derive(Debug, Clone, Serialize)] #[serde(tag = "type")] pub enum AiChatEvent { /// 流式文本片段 AiTextDelta { delta: String, conversation_id: Option }, /// 工具调用开始 AiToolCallStarted { id: String, name: String, args: serde_json::Value, conversation_id: Option }, /// 工具调用完成 AiToolCallCompleted { id: String, result: serde_json::Value, conversation_id: Option }, /// 会话级信任自动放行(AE-2025-04 Session Trust): /// 同会话已批准过同类操作(同工具+同目录),下次命中自动放行,前端显示轻量 toast。 /// 与 AiToolCallStarted/Completed 正交——这三个事件描述一次信任放行调用生命周期: /// Started → AutoApproved(toast 提示用户已自动放行)→ Completed。 AiToolAutoApproved { id: String, tool: String, dir: String, conversation_id: Option }, /// 需要人工审批 /// diff(AE-2025-03,路径 B):仅 write_file 注入(旧文件 vs 新内容行级 unified diff), /// 供审批卡即时预览。旧文件不存在(新建)或非 write_file 工具为 None。 AiApprovalRequired { id: String, name: String, args: serde_json::Value, reason: String, diff: Option, conversation_id: Option }, /// 审批结果 AiApprovalResult { id: String, approved: bool, conversation_id: Option }, /// AI 响应完成 /// /// `incomplete`(可选,默认 None=false):UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1—— /// 流中途失败(MidStream)保文路径置 Some(true),标记此回复不完整。前端可据此差异化 /// 展示(如附系统提示「⚠ 响应因网络中断不完整」)。正常完成/用户停止均 None(完整)。 AiCompleted { total_tokens: u32, prompt_tokens: u32, completion_tokens: u32, /// 不完整标记(可选):Some(true)=网络中断保文,None 或 Some(false)=完整回复 incomplete: Option, conversation_id: Option, }, /// 错误 /// /// `error_type` 为错误源分类(B-260615-42),`None` 表示未分类(向后兼容旧 emit 点)。 AiError { error: String, /// 错误源分类(可选,便于前端差异化提示);旧点/难精确分类填 None error_type: Option, conversation_id: Option, }, /// F-260622-02 跨端用户消息同步(miniapp→device→桌面 user 气泡渲染): /// /// 触发:remote_bridge `route_send_message` 放行后(调 ai_chat_send 前)`publish_event`, /// 把 miniapp 发来的用户消息广播到 ai_event_bus。语义:远程入口(miniapp)发来的用户消息, /// 专供桌面 useAiEvents 收到并补 user 气泡(桌面本地 sendMessage 是乐观渲染不发此事件)。 /// /// 根因(为何需要此事件):ai_chat_send 内部只 push session.messages,全程不 emit 用户消息, /// 桌面用户气泡是纯本地乐观渲染(useAiSend.sendMessage 本地 push,不依赖事件)。 /// miniapp 发消息时桌面用户没在自己输入框敲字 → 桌面 store 没有这条 user 气泡 → /// 桌面只看到 assistant 响应气泡(经 AiAgentRound/AiTextDelta 流式建),前面缺对应 user prompt /// → **孤立响应气泡**。此事件填补该缺口。 /// /// 去重(防双气泡):前端 handleEvent 收到此事件时,若末条已是同 content 的 user(本地乐观 /// push 的),跳过。miniapp 自身乐观 push 也会收到此事件回灌,同样去重处理。 AiUserMessage { message: String, conversation_id: Option, }, /// Agent 循环新一轮(前端需新建 assistant 消息) AiAgentRound { round: u32, conversation_id: Option }, /// 流式心跳(静默期报活,前端 watchdog reset,区分「LLM 在跑」与「真断」) AiHeartbeat { conversation_id: Option }, /// 达最大轮次仍未收敛(F-260616-03:转暂停态询问用户继续/停止) /// /// 触发时 generating 仍为 true(仿审批等待),前端展示独立操作卡: /// - 点继续 → ai_continue_loop → try_continue_agent_loop 再跑 max_iterations 轮 /// - 点停止 → ai_stop_loop → 走完成流程(save + AiCompleted + generating=false) AiMaxRoundsReached { conversation_id: Option }, /// 流式调用自动重试提示(F-260616-07:流前失败重试中) /// /// 每次重试前 emit,前端可在错误气泡内显示「重试 n/m」。 /// attempt 从 1 开始(首次失败后第一次重试=attempt 1),max_attempts = max_retries + 1。 AiStreamRetry { attempt: u32, max_attempts: u32, conversation_id: Option }, /// F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,挂起 loop 等用户决定)。 /// /// 触发:resolve_workspace_path 预校验路径未命中 persistent/session 白名单(且非黑名单)。 /// 复用审批挂起架构(PendingApproval.kind=Path 标记路径授权挂起,阶段3a 单真相源合并), /// 用户经 ai_authorize_dir IPC 选择"仅本次/未来都允许/拒绝",恢复 loop。 /// `dir`:规范化后的待授权目录(父目录,目录粒度对齐 session_trust)。 AiDirAuthRequired { id: String, tool: String, path: String, dir: String, conversation_id: Option, }, /// F-15 阶段2 手动上下文分段完成:会话归档不删(archived_segment 标记), /// 前端可据此刷新消息视图(归档段折叠/隐藏)。 AiContextCleared { conversation_id: Option }, /// F-15 阶段2 手动 LLM 压缩开始:前端展示压缩中态(spinner/禁用按钮防重入)。 AiCompressing { conversation_id: Option }, /// F-15 阶段2 手动 LLM 压缩完成:摘要已插入消息首位,前端可展示摘要 + 移除压缩中态。 AiCompressed { conversation_id: Option, summary: String }, /// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21): /// /// agent 断路器(§2.2)熔断 / 自省(识别"我反复失败")时发,区别于 prompt 教 AI /// 「失败就问用户」——LLM 不可靠,故用结构化事件 + 前端求助卡(机制优先 prompt 说教)。 /// /// 字段语义: /// - `reason`:求助原因(死循环/环境未知/权限不足等可读摘要,前端直接展示) /// - `context`:已试情况(失败次数/最近错误 sample/卡在哪),供用户判断如何介入 /// - `options`:建议选项(如 [换思路/授权路径/人工接管]),前端渲染为按钮供用户选 /// - `conversation_id`:多对话路由(与其他事件一致) /// /// 与 AiApprovalRequired 区分:审批=工具执行前确认;求助=AI 主动「我搞不定」。 /// 与 AiMaxRoundsReached 区分:达 max 是被动截断;求助是主动识别卡点。 /// 兜底:开关 CIRCUIT_BREAKER_ENABLED=false 或求助消费方未接 → 后端保留 AiError 终态分支兜底。 AiHelpRequired { reason: String, context: String, options: Vec, conversation_id: Option, }, /// F-#95 跨端会话列表同步(Phase3,2026-06-22):device 响应 miniapp `list_conversations` /// 命令,把本地 AiConversationRepo 列表摘要(不含消息体,省带宽)推回 miniapp。 /// /// 触发:remote_bridge `list_conversations` 路由 → `list_all()` → 映射 ConvSummary → /// `publish_event`(跨端透传,经 EventBus→tunnel subscriber→relay→miniapp)。 /// /// 双职责: /// 1. miniapp 会话列表渲染(conversations/index.vue 替换本地空表) /// 2. device 在线探测(miniapp 收到此响应 = device 在线,chat/index.vue 状态文案切「已连接桌面端」) /// /// 兼容:relay 纯透传不广播 device presence,故「device 是否在线」靠此响应隐式判定 /// (device 离线则 miniapp 发 list_conversations 无响应,文案保持「已连接中继」诚实表述)。 AiConversationList { /// 会话摘要列表(已按 updated_at 倒序,不含 messages 全文) conversations: Vec, }, /// F-#95 扩展:miniapp 历史消息同步(device→miniapp 推消息列表)。 /// /// 触发:remote_bridge `load_messages` 路由 → `ai_messages.list_by_conversation` → /// 映射 `ChatMessage`(record_to_message,对齐 ai_conversation_switch 读路径) → /// `publish_event`(跨端透传,经 EventBus→tunnel subscriber→relay→miniapp)。 /// /// 职责:miniapp 点进对话时加载历史消息(替代 switch_conversation 后端忽略致空白)。 /// miniapp 据 conversation_id 匹配 activeConversationId 才替换,防串会话。 AiMessageHistory { /// 对话 ID conversation_id: String, /// 历史消息数组(ORDER BY seq ASC,含 role/content/tool_calls 等完整字段) messages: Vec, }, /// L2 统一状态机(批2 1b,2026-06-22):对话生命周期状态变更通知。 /// /// 后端 `ConvState`(Idle/Generating/Stopping/Error/Compressed)经写收敛 /// (入口/guard reset/drop 迁移)后 emit 此事件,前端 status union 据此更新展示态 /// (替代散布的 generating bool 派生判断,收敛状态机读侧到事件流)。 /// /// 渐进/兜底:`CONV_STATE_ENABLED` 开关门控 —— off 时不 emit(前端按旧 bool 行为派生, /// 向后兼容);on 时此事件与既有 generating 行为双轨,前端可按需切换消费源。 /// `conv_state` 序列化为 snake_case(idle/generating/stopping/error/compressed), /// 供前端 union discriminator 直接使用。 AiConvStateChanged { conv_state: ConvState, conversation_id: Option, }, /// F-#95 跨端技能列表同步(Phase3,2026-06-23):device 响应 miniapp `list_skills` 命令, /// 把本机 Claude 技能(skills/commands/plugins 三类,`ai_list_skills` 进程内缓存)推回 miniapp。 /// /// 触发:remote_bridge `list_skills` 路由 → `ai_list_skills().await` → /// `publish_event`(跨端透传,经 EventBus→tunnel subscriber→relay→miniapp)。 /// /// 职责:miniapp 技能联想(输入框 `/` 触发浮层),对齐桌面端 `/` 联想体验。 /// 内嵌 `SkillInfo` 自身序列化字段(name/description/argument_hint?/source/path/duplicates?), /// 变体整体无额外 rename_all 需求(照 AiConversationList 变体模式)。 AiSkillList { /// 本机技能列表(ai_list_skills 返回的进程内缓存,已按 skills>commands>plugins 优先级去重) skills: Vec, }, /// F-#95 跨端实体列表同步(Phase3,2026-06-23):device 响应 miniapp `list_entities` 命令, /// 把本地项目/任务/灵感全量列表(list_projects/list_tasks/list_ideas 全量等价路径)推回 miniapp。 /// /// 触发:remote_bridge `list_entities` 路由 → 并行调三 Tauri command(query/project_id/status /// 均传 None 走 list_active/list_all 全量) → 合并打包 → `publish_event` 跨端透传。 /// /// 职责:miniapp `@` 实体联想(输入框 `@` 触发浮层选项目/任务/灵感),对齐桌面端 @ 体验。 /// 全量字段(对齐桌面端 src/api/types.ts,带宽敏感裁剪留后续批;Record 类型是 /// #[tauri::command] 返回值,必然 impl Serialize)。 AiEntityList { /// 项目全量列表(list_active,deleted_at IS NULL) projects: Vec, /// 任务全量列表(list_active 等价,未删任务) tasks: Vec, /// 灵感全量列表(list_by_query 空 query,等价 list_all) ideas: Vec, }, } // ============================================================ // F-#95 跨端会话列表同步:会话摘要结构 // ============================================================ /// 会话摘要(miniapp 会话列表渲染用,对齐 apps/df-miniapp/src/types/events.ts:78 Conversation)。 /// /// 与 `AiConversationRecord` 区别:仅含列表展示必需字段,**不含 messages 全文**(省带宽, /// miniapp MVP 不在列表展示历史消息,进入会话时按需同步)。`updated_at` 用 i64(ms 数值) /// 便于前端直接比较排序;record 里是 ms 字符串,routing 层 parse 转。 /// /// `#[serde(rename_all = "camelCase")]` 对齐 miniapp Conversation 字段名 /// (id/title/lastMessage/updatedAt/createdAt),TS 侧无需 snake→camel 转换。 #[derive(Debug, Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct ConvSummary { pub id: String, pub title: String, /// 最近一条消息摘要(MVP 暂不取,留 None;后续批可解析 messages 末条) pub last_message: Option, /// 最近更新时间(ms 数值) pub updated_at: Option, /// 创建时间(ms 数值,可选展示) pub created_at: Option, } // ============================================================ // 会话状态(放 mod.rs,各子文件经 use super::* 拿到) // ============================================================ /// AI 会话读侧状态视图(只读枚举,由 [`AiSession::session_state`] 推导) /// /// 这是 **只读视图**,不替代 `AiSession` 上 `generating` / `pending_approvals` / /// `stop_flag` 等字段定义——字段仍是唯一真相源,调用方写状态时继续写字段, /// 读状态时统一走 `session_state()` 收敛判断逻辑(避免散布的 `if generating` / /// `if !pending_approvals.is_empty()` 三路组合在各调用点各自实现)。 /// /// 优先级:`pending_approvals` 非空(有挂起审批优先报 AwaitingApproval, /// 即使同时在流式)> `generating`(Streaming)> 都不满足(Idle)。 // SW-260618-21 b:原预留读视图(0 消费者)。path_auth 审批链阶段1 接入后有消费者 // (try_continue_agent_loop / ai_chat_stop 经 session_state 替代手写 has_pending),不再 dead_code。 #[derive(Debug, Clone, PartialEq, Eq)] pub enum SessionState { /// 空闲:既未生成、也无挂起审批 Idle, /// 流式生成中:generating 为 true 且无挂起审批 Streaming, /// 等待审批:pending_approvals 非空 AwaitingApproval, } // ============================================================ // AE-2025-04 会话级信任(Session Trust) // // 核心:信任上下文相关。同会话批准过同类操作(同工具+同目录)后自动放行; // 换会话(new/switch)清空重审(session_trust 随会话销毁,不落库)。 // // TrustKey 按 操作 + 目录 粒度(非文件级——文件级粒度过细,LLM 每写一个文件都要重审, // 信任面太窄失去意义;目录级让"批准过同目录写入"覆盖该目录后续写入,对齐"写→跑→看→改" // 闭环最高频场景)。首批:write_file + run_command。 // // FR-S1 安全边界:TrustKey 只含 tool + dir,**绝不**含 api_key / 文件内容 / 命令串等敏感 // 参数。dir 经 resolve_workspace_path/canonicalize 规范化(去 .. / symlink 逃逸)。 // ============================================================ /// 会话级信任键:按「操作类型 + 目录」粒度唯一标识一类已批准操作。 /// /// - `Write { dir }`:write_file 在 `dir` 目录下(路径父目录归一化) /// - `Execute { dir }`:run_command 在 `dir` 工作目录下 /// /// 命中即自动放行,跳过 pending 审批 + 二次确认(AE-05),不写 audit 仍记一条 /// `decided_by=auto_trust` 的审计记录留痕。 #[derive(Debug, Clone, Hash, PartialEq, Eq)] pub enum TrustKey { Write { dir: String }, Execute { dir: String }, } /// 由「工具名 + args」推算 TrustKey(若该工具不在首批信任名单内返 None,走原审批流程)。 /// /// 目录归一化策略(与 tool_registry.rs 各 handler 的路径锚定一致): /// - write_file:取 args.path,resolve_workspace_path → 父目录;解析失败回退 workspace_root /// - run_command:取 args.working_dir;缺省回退 workspace_root;不做越界校验 /// (run_command handler 自身仅 validate_path 不限定 workspace,信任目录用 LLM 传入或 /// workspace_root 默认值作为 key 字面量,与 handler 实际 working_dir 对齐) /// /// FR-S1:不读 api_key / content / command 等敏感字段,只取 path/working_dir 归一化为 dir。 pub fn trust_key_for(tool: &str, args: &serde_json::Value) -> Option { match tool { "write_file" => { let path = args.get("path").and_then(|v| v.as_str())?; let dir = dir_of_path_normalized(path); Some(TrustKey::Write { dir }) } "run_command" => { let dir = match args.get("working_dir").and_then(|v| v.as_str()) { Some(d) => normalize_dir_key(d), None => workspace_root_str(), }; Some(TrustKey::Execute { dir }) } _ => None, } } /// write_file 路径 → 父目录规范化字符串(作 HashSet key 用)。 /// /// resolve_workspace_path 失败(路径越界/含 .. )回退 workspace_root —— 失败的工具调用本身 /// 不会被执行(handler 内校验拦截),但 trust_key 计算不应 panic,失败回退保守默认。 fn dir_of_path_normalized(path: &str) -> String { match self::tool_registry::resolve_workspace_path_pub(path) { Ok(resolved) => { let parent = resolved .parent() .map(|p| p.to_path_buf()) .unwrap_or_else(|| std::path::PathBuf::from(".")); normalize_dir_key(&parent.to_string_lossy()) } Err(_) => workspace_root_str(), } } /// run_command / write_file 共用的目录 key 规范化: /// 尝试 canonicalize(去 symlink / .. / 大小写归一),失败回退原字面量 trim。 fn normalize_dir_key(dir: &str) -> String { let p = std::path::Path::new(dir); match p.canonicalize() { Ok(canon) => canon.to_string_lossy().to_string(), Err(_) => dir.trim_end_matches(['/', '\\']).to_string(), } } /// workspace_root 字符串(作 run_command working_dir 缺省时的 trust key 回退)。 /// /// 与 tool_registry.rs workspace_root() 同源(CARGO_MANIFEST_DIR 上两级),canonicalize 后 /// 保证与 run_command handler 默认 working_dir = workspace_root().to_string() 生成的 key 一致。 fn workspace_root_str() -> String { let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() .and_then(|p| p.parent()) .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")); root.canonicalize() .map(|c| c.to_string_lossy().to_string()) .unwrap_or_else(|_| root.to_string_lossy().to_string()) } // 引入 PathBuf 供 workspace_root_str / dir_of_path_normalized 使用 use std::path::PathBuf; /// AI 会话内状态(Mutex 保护) /// /// F-260616-09 B 批4(决策 e 真并发上线):会话级状态全部迁入 [`per_conv`](Self::per_conv) /// HashMap(按 conversation_id 切分),顶层单例字段(messages/generating/stop_flag/notify/ /// iteration_used/agent_language/model_override/session_trust)已退役删除。批2 双写桥接代码 /// (IPC 写路径的顶层双写行 + 各文件 fallback 读)在批4 全部清理完毕,per_conv 是唯一真相源。 pub struct AiSession { /// 当前提供商 ID pub active_provider_id: Option, /// 当前活跃对话 ID(应用级路由键:当前展示的 conv;切走即变,不影响后台 loop) pub active_conversation_id: Option, /// 活跃对话创建时间(懒创建:首条消息落库前仅存内存,upsert 时用作 created_at) pub active_conv_created_at: Option, /// 挂起的审批(阶段3a 单真相源合并:原 path_auth_pending + risk_pending 双 HashMap)。 /// /// 决策1(状态层合 + 决策层分):状态层把两类挂起合并进单 HashMap, /// `PendingApproval.kind` 字段区分语义(`ApprovalKind::Path` vs `ApprovalKind::Risk`); /// 决策层仍双轨 —— `ai_authorize_dir` 只消费 `kind==Path`(once/always 写持久白名单), /// `ai_approve` 只消费 `kind==Risk`(一次性 approve/reject),入口 kind 校验防误调。 /// 键为 `tool_call_id`(路由键不变),`conversation_id` 是业务语义(哪个对话产生)。 /// /// 兜底/回退:本合并是结构性的,回退走 git revert(plan 风险表「分 3a/3b 各 revert」)。 /// 兼容靠 kind 字段默认 Risk(老构造点不传 kind 即默认 Risk,语义不变)。 pub pending_approvals: HashMap, /// F-260616-09 B 阶段 多会话并发架构 — 会话级状态按 conversation_id 切分。 /// /// **批4 状态(per_conv 唯一真相源)**:批2-批4 已迁移所有调用方(agentic/mod.rs loop + /// GeneratingGuard + try_continue + commands.rs IPC 写路径 + audit.rs process_tool_calls + /// conversation.rs save + title.rs + knowledge_inject.rs + lib.rs L0)读写 /// `session.conv(conv_id).*` / `session.conv_read(conv_id)`。顶层单例会话级字段已全部删除。 pub per_conv: HashMap, } impl AiSession { pub fn new() -> Self { Self { active_provider_id: None, active_conversation_id: None, active_conv_created_at: None, pending_approvals: HashMap::new(), per_conv: HashMap::new(), } } /// 推导指定会话读侧状态视图(只读,不写任何字段)。 /// /// 优先级:该 conv 有 pending 审批 → [`SessionState::AwaitingApproval`]; /// 否则 per_conv.generating → [`SessionState::Streaming`];否则 → [`SessionState::Idle`]。 /// /// 收敛点:调用方读「某会话处于什么阶段」时统一走本方法,替代散布的 /// `if pending / if generating` 三路组合。 /// /// F-260616-09 B 批4:per_conv 化后接 conv_id 参数(顶层 generating 已删除); /// pending 判断改为按 conv_id 过滤(决策 e 真并发下各 conv 独立)。 /// /// path_auth 审批链阶段1(本批)接入:`try_continue_agent_loop`(agentic/mod.rs) + /// `ai_chat_stop`(commands/chat.rs) 改调本方法替代各自手写的 has_pending, /// 收敛两处状态机判定到单一入口。 /// /// 兜底/回退:若本方法判定异常需快速回退手写 has_pending,调用方注释已保留原 /// 三路组合(generating + path_auth + risk)字面量,改一行即可切回。 pub fn session_state(&self, conv_id: &str) -> SessionState { // 阶段3a 单真相源合并后:两类挂起(path_auth + risk)合一进 pending_approvals, // kind 字段区分语义但状态机只看「有无挂起」,单表 any 一次即可(消除原双表 OR 合并判断)。 let has_pending = self.pending_approvals.values() .any(|a| a.conversation_id.as_deref() == Some(conv_id)); if has_pending { SessionState::AwaitingApproval } else if self.conv_read(conv_id).map(|c| c.generating).unwrap_or(false) { SessionState::Streaming } else { SessionState::Idle } } // ============================================================ // F-260616-09 B 阶段批1 PerConvState 访问器(设计 §2.1.2) // // 批4 已迁移完成:所有 `session.messages` / `session.generating` 等读写已全部改走 // `session.conv(conv_id).*`(写)/ `session.conv_read(conv_id)`(读), // per_conv 现为唯一真相源,顶层单例字段已删除。 // ============================================================ /// 取或惰性创建指定会话的可变状态(设计 §2.1.2)。 /// /// 不存在则 `entry().or_insert_with(PerConvState::new)` 创建一份默认状态, /// 初值对齐 [`AiSession::new`] / [`PerConvState::new`]。 /// 已存在则返回同一实例引用(同 conv_id 复用)。 pub fn conv(&mut self, conv_id: &str) -> &mut PerConvState { self.per_conv .entry(conv_id.to_string()) .or_insert_with(PerConvState::new) } /// 只读取指定会话状态(不创建)。 /// /// 未创建的 conv_id 返 `None`,已创建返 `Some(&PerConvState)`。用于读取路径(如 /// 列举/查询)避免误触发惰性创建。 pub fn conv_read(&self, conv_id: &str) -> Option<&PerConvState> { self.per_conv.get(conv_id) } } #[cfg(test)] mod tests_f09_per_conv { use super::*; /// 验证 PerConvState::new() 各字段初值正确(对齐 AiSession::new 基线)。 #[test] fn test_per_conv_state_new() { let s = PerConvState::new(); assert!(!s.generating, "generating 初值应为 false"); assert_eq!(s.iteration_used, 0, "iteration_used 初值应为 0"); assert!(s.agent_language.is_none(), "agent_language 初值应为 None"); assert!(s.model_override.is_none(), "model_override 初值应为 None"); assert!(s.session_trust.is_empty(), "session_trust 初值应为空 HashSet"); assert!(s.created_at.is_none(), "created_at 初值应为 None"); // P1 修复(提炼重复触发):knowledge_extracted 初值应为 false(新会话未提炼) assert!( !s.knowledge_extracted, "knowledge_extracted 初值应为 false(新会话未提炼)" ); // stop_flag 初值 false(与 AiSession::new 对齐) assert!( !s.stop_flag.load(std::sync::atomic::Ordering::SeqCst), "stop_flag 初值应为 false" ); // notify 是 Arc,无可见状态,仅验证不 panic(构造即视为对齐) // messages 是 ContextManager,内部状态;仅验证能取到引用(初值对齐由 ContextManager::new 保证) let _messages_ref: &ContextManager = &s.messages; } /// 验证 conv() 首次创建 + 二次复用同一实例。 #[test] fn test_conv_lazy_create() { let mut session = AiSession::new(); assert!(session.per_conv.is_empty(), "新建 session 的 per_conv 应为空"); // 首次 conv() 创建 let ptr_first = { let s = session.conv("conv-a"); assert!(!s.generating); assert_eq!(s.iteration_used, 0); s as *const PerConvState }; assert_eq!(session.per_conv.len(), 1, "首次 conv() 后 per_conv 应有 1 项"); // 二次 conv(同 conv_id) 返回同一实例(指针相等) let ptr_second = session.conv("conv-a") as *const PerConvState; assert_eq!( ptr_first, ptr_second, "同 conv_id 二次 conv() 应返回同一实例" ); assert_eq!(session.per_conv.len(), 1, "二次 conv(同 id) 不应新增项"); // 不同 conv_id 创建独立实例 let _s_b = session.conv("conv-b"); assert_eq!(session.per_conv.len(), 2, "不同 conv_id 应新建独立实例"); } /// 验证 conv_read() 未创建的 conv_id 返 None(只读,不惰性创建)。 #[test] fn test_conv_read_none() { let session = AiSession::new(); // 未创建的 conv_id assert!( session.conv_read("never-created").is_none(), "未创建的 conv_id conv_read() 应返 None" ); // conv_read 不触发创建(per_conv 仍空) assert!( session.per_conv.is_empty(), "conv_read 不应触发惰性创建" ); // 已创建的 conv_id 返 Some(只读访问) let mut session = session; let _ = session.conv("exists"); assert!( session.conv_read("exists").is_some(), "已创建的 conv_id conv_read() 应返 Some" ); } // ============================================================ // P1 修复(提炼重复触发): knowledge_extracted 去重标志生命周期测试 // // 锁定标志三态语义: // 1) 新会话初值 false → 自动提炼可触发 // 2) 成功提炼后置 true → maybe_spawn_extraction 入口判重跳过 // 3) trigger_extraction_now 清位 → 允许手动重提炼 // ============================================================ /// 新会话 knowledge_extracted 默认 false(自动提炼可触发) #[test] fn test_knowledge_extracted_default_false() { let session = AiSession::new(); // 未创建的 conv_id → conv_read None → maybe_spawn_extraction 视为 false(可触发) assert!( session .conv_read("new-conv") .map(|c| c.knowledge_extracted) .unwrap_or(false) == false, "新会话 knowledge_extracted 应为 false" ); } /// 成功提炼后置位 true(自动路径去重标志) #[test] fn test_knowledge_extracted_set_after_extraction() { let mut session = AiSession::new(); // 模拟提炼成功后置位(extract_knowledge_from_conversation Ok(>0) → maybe_spawn_extraction 置位) session.conv("conv-done").knowledge_extracted = true; assert!( session.conv_read("conv-done").unwrap().knowledge_extracted, "提炼成功后 knowledge_extracted 应为 true" ); } /// maybe_spawn_extraction 入口判重语义:已置位 → 视为已提炼(跳过) #[test] fn test_knowledge_extracted_dedup_guard() { let mut session = AiSession::new(); session.conv("conv-dedup").knowledge_extracted = true; // 模拟 maybe_spawn_extraction 入口判重读(already_extracted) let already_extracted = session .conv_read("conv-dedup") .map(|c| c.knowledge_extracted) .unwrap_or(false); assert!( already_extracted, "已置位会话入口判重应为 true(跳过自动提炼)" ); } /// trigger_extraction_now 清位语义:手动路径强制清位,允许重提炼 #[test] fn test_knowledge_extracted_manual_clear() { let mut session = AiSession::new(); session.active_conversation_id = Some("conv-manual".to_string()); session.conv("conv-manual").knowledge_extracted = true; // 模拟 trigger_extraction_now 入口清位逻辑: // active_conversation_id clone(脱离 immutable borrow)后再 mutable borrow conv() let cid = session.active_conversation_id.clone(); if let Some(cid) = cid.as_deref() { session.conv(cid).knowledge_extracted = false; } assert!( !session .conv_read("conv-manual") .unwrap() .knowledge_extracted, "手动触发后 knowledge_extracted 应被清位(允许重提炼)" ); } /// 不同会话标志互相独立(P1 去重按 conv_id 切分) #[test] fn test_knowledge_extracted_per_conv_independent() { let mut session = AiSession::new(); session.conv("conv-a").knowledge_extracted = true; // conv-b 未提炼,标志应独立为 false assert!( session.conv("conv-a").knowledge_extracted, "conv-a 已提炼标志 true" ); assert!( !session.conv("conv-b").knowledge_extracted, "conv-b 未提炼标志 false(各 conv 独立)" ); } } // ============================================================ // F-260616-09 B 阶段 多会话并发架构 — PerConvState(批1 纯新增) // // 设计文档: docs/02-架构设计/已编号方案/F-09-多会话并发架构设计-2026-06-19.md §2.1.2 // // 批1 范围(纯新增无行为变化): // - 仅新增 PerConvState struct + new() + AiSession.per_conv 字段 + conv()/conv_read() 访问器 // - **不迁移**现有单例字段(messages/generating/stop_flag/notify/iteration_used/ // agent_language/model_override/session_trust 保留原样),不迁移现有调用方(批2 承接) // - per_conv HashMap 初始空,旧调用路径继续读写 AiSession 顶层单例字段,完全无感 // // 共存期(已结束):批1 后顶层单例字段(原唯一真相源)与 per_conv(全空,无写入)曾并存,行为不变。 // 批2-批4 迁移:把所有调用方从 session.messages / session.generating 等改为 session.conv(conv_id).*, // 迁移完成后顶层单例字段已废弃删除(批3)。现 per_conv 为唯一真相源。 // ============================================================ /// 单会话级状态(F-260616-09 B 阶段批1 纯新增,设计 §2.1.2)。 /// /// 持有当前会话(按 conversation_id 切分)私有的可变状态。批4 已迁移完成,本结构现为 /// **唯一真相源**:所有调用方(agentic loop / IPC / audit / conversation / title / /// knowledge_inject 等约 60+ 处)均经 `session.conv(conv_id).*` / `session.conv_read(conv_id)` /// 读写,AiSession 顶层单例字段已全部删除。 /// /// 字段初值逐字对齐 [`AiSession::new`](#method.new)。 pub struct PerConvState { /// 对话历史(ContextManager:会话级消息真相源,裁剪仅影响发送视图) pub messages: ContextManager, /// 是否正在生成 pub generating: bool, /// L2 统一状态机:对话生命周期状态(单一真相源,批2 持久化供读侧/前端消费)。 /// 写收敛:经 GeneratingGuard/入口 transition_to 守卫迁移,非直接赋值。 /// 与 generating bool 双轨过渡(bool 核心复位,enum 视图),批2+ 读侧迁移后 enum 主。 pub conv_state: ConvState, /// 停止信号(会话级):ai_chat_stop 置位,agentic loop / stream_llm 检测后尽快退出 pub stop_flag: Arc, /// 即时停止唤醒(会话级):阻塞在 stream.next() 时 notify_one() 立即唤醒跳出 select! pub notify: Arc, /// agentic loop 累计已跑 iteration 计数(语义同 AiSession.iteration_used) pub iteration_used: usize, /// 当前 agent 循环的语言设置(用于审批后恢复循环) pub agent_language: Option, /// 用户指定模型 override(主对话专用,语义同 AiSession.model_override) pub model_override: Option, /// 会话级信任(Session Trust):随会话销毁,不落库 pub session_trust: HashSet, /// 会话创建时间(懒创建:首条消息落库前仅存内存,预留 upsert 时用作 created_at)。 /// /// 预留字段:构造期写入但当前 upsert 路径未读取(cargo 报 never read),标 allow 保留; /// 待 upsert 接入会话创建时间回填时消费。符合零调用方≠垃圾(预留)。 #[allow(dead_code)] pub created_at: Option, /// 知识提炼去重标志(P1 修复-提炼重复触发):本会话已成功提炼(至少 insert 1 条 candidate)后置位。 /// /// 背景:审批续跑 try_continue → 重 spawn run_agentic_loop → 正常完成块 → /// maybe_spawn_extraction 二次触发,无去重致同知识点重复 candidate 刷屏。 /// /// 守卫语义: /// - maybe_spawn_extraction 入口判重:已置位则跳过 + warn(防重复触发刷 candidate) /// - extract_knowledge_from_conversation 成功(inserted ≥ 1)后由调用方置位 /// - trigger_extraction_now(手动按钮)强制清位:允许用户手动重提炼(语义=手动覆盖自动去重) /// /// 随会话销毁(per_conv 内存,不落库):会话切换/删除后该标志消失,新会话从 false 开始。 pub knowledge_extracted: bool, } impl PerConvState { /// 新建默认会话级状态。 /// /// 各字段初值**逐字对齐 [`AiSession::new`]** —— 批2 迁移调用方时行为不变。 /// - messages: ContextManager::new(ContextConfig::default()) /// - generating: false /// - stop_flag: Arc::new(AtomicBool::new(false)) /// - notify: Arc::new(tokio::sync::Notify::new()) /// - iteration_used: 0 /// - agent_language: None /// - model_override: None /// - session_trust: HashSet::new() /// - created_at: None(批1 新增字段,AiSession 现有 active_conv_created_at 同语义) /// - knowledge_extracted: false(新会话未提炼,P1 去重标志) pub fn new() -> Self { Self { messages: ContextManager::new(ContextConfig::default()), generating: false, conv_state: ConvState::Idle, stop_flag: Arc::new(AtomicBool::new(false)), notify: Arc::new(tokio::sync::Notify::new()), iteration_used: 0, agent_language: None, model_override: None, session_trust: HashSet::new(), created_at: None, knowledge_extracted: false, } } } impl Default for PerConvState { fn default() -> Self { Self::new() } } /// 待审批的工具调用 /// /// 阶段3a(单真相源合并):新增 `kind: ApprovalKind` 字段区分两类挂起(Path/Risk), /// 原顶层 `diff` / `path_auth` 字段下沉进 `ApprovalKind` enum 变体(决策1:状态层合 + /// 决策层分 —— kind 在 PendingApproval 内携带语义,但 ai_approve/ai_authorize_dir /// 各只消费自己 kind,入口校验防误调)。 #[derive(Debug, Clone)] pub struct PendingApproval { pub tool_call_id: String, pub tool_name: String, pub arguments: serde_json::Value, pub conversation_id: Option, /// 重启恢复的积压审批:无 live loop 持有 session.messages,审批后不 save(防空 messages 污染老对话)、不续跑 pub recovered: bool, /// 阶段3a:审批类型(Path 路径授权挂起 / Risk 普通 RiskLevel 审批)。 /// 老构造点不传 kind 时默认 Risk(兼容:原 risk_pending 路径行为不变)。 pub kind: ApprovalKind, /// 阶段4(容错/恢复,开关 `df-ai-approval-retry`):同 tc_id 重试计数。 /// /// 触发:同 tool_call_id 在审计表已有一条 executed/failed/rejected 落定记录(即该 /// tc_id 已被执行/审批过一次),LLM 又用同 id 重试(process_tool_calls 再次 insert pending)。 /// 插入时查审计表落定记录推算 retry_count;≥1 时跳过执行,emit AiToolCallCompleted 带 /// "已跳过重试"提示,防 LLM 死循环重试同卡死工具(超时/权限错等)。 /// /// 兜底/回退:flag 关 → 不查审计表、retry_count 恒 0,等价原行为(单次审批执行)。 // // dead_code 说明:当前断路决策在 insert 点(detect_retry_count 查审计表),不读存储的 // retry_count;字段保留作审计/未来"允许多次重试阈值"扩展(u32 暂仅 0/1),对齐 Risk{diff} // 变体同款 allow 处理。 #[allow(dead_code)] pub retry_count: u32, } /// 阶段3a:审批类型枚举(决策层分离,kind 区分 ai_approve vs ai_authorize_dir 消费)。 /// /// - `Path(req)`:F-260619-03 Phase B 路径授权挂起,由 `ai_authorize_dir` 消费(once/always/deny)。 /// - `Risk { diff }`:普通 Med/High RiskLevel 审批,由 `ai_approve` 消费(一次性 approve/reject)。 /// `diff`:AE-2025-03(路径 B)write_file 行级 unified diff,供前端审批卡预览; /// 旧文件不存在(新建)或非 write_file / recovered 审批为 None。 #[derive(Debug, Clone)] pub enum ApprovalKind { /// F-260619-03 Phase B: 路径授权挂起(携带待授权目录列表)。 Path(PathAuthRequest), /// 普通 RiskLevel 审批(Med/High 风险工具)。 /// diff 仅 write_file 挂起审批前预读注入;其余工具 / recovered 审批为 None。 #[allow(dead_code)] // IPC 活跃(useAiEvents:252+ToolCard·UX-260618-06)·cargo Rust never-read 误报 Risk { diff: Option }, } /// F-260619-03 Phase B: 路径授权挂起请求(ApprovalKind::Path 标记)。 #[derive(Debug, Clone)] pub struct PathAuthRequest { /// 待授权目录列表(规范化父目录,对齐 session_trust 目录粒度)。 /// L1 补丁(rename_file 双路径漏校):收集所有未授权父目录,rename_file 的 from+to /// 若分别属于不同未授权目录,都需挂起授权(原仅收首个致 to 路径漏校)。单路径工具通常 1 项。 pub dirs: Vec, /// 原始路径参数(read_file.path / write_file.path / rename_file.from+to / ...)。 pub raw_paths: Vec, } /// 工具调用草稿(流式收集时的临时结构) #[derive(Debug, Clone, Default)] pub(crate) struct ToolCallDraft { pub(crate) id: String, pub(crate) name: String, pub(crate) args: String, } // ============================================================ // B-260617-01 决策 a(方案 a2):run_workflow 工具经审批后的真实执行入口 // // 背景:run_workflow handler(tool_registry.rs)仅持 db:Arc,无 AppHandle/State // (registry/event_bus/workflows Repo),经 ai_tools.execute 必然 Err。ai_approve 识别 run_workflow // 后改调本函数(同 invoke('run_workflow') IPC 入口 workflow.rs::run_workflow,持有完整 State), // 真正触发工作流引擎执行 + 联动任务推进,把 execution_id 回填 tool_result 让 LLM 收成功结果停止重试。 // // 选后端分支(a2)而非前端拦截(a1):ai_approve 已持 state+app,后端统一更简,无需新增前端 IPC + // 不把工作流知识泄漏到前端。本函数仅在 ai_approve 内被调用(单一调用方)。 // ============================================================ /// 把 run_workflow 工具调用的 AI args 转调工作流执行函数,返回 execution_id(供回填 tool_result)。 /// /// 从 args 取 task_id + target_status(两者必填),传空 DagDef(由 workflow.rs 按 target_status /// 自动选推进链模板,见 task_workflow_templates::template_for),config 传空 Object。 /// 任一参数缺失返 Err(回填 failed tool_result,LLM 据此修参重发)。 /// /// 不在此推进任务状态 —— workflow.rs::run_workflow 内部已按 task_id+target_status 自动联动 /// (成功推进到 target / 失败按 regression_target 退回),此处不重复。 pub(crate) async fn execute_run_workflow_for_tool( app: &tauri::AppHandle, state: &crate::state::AppState, args: &serde_json::Value, ) -> anyhow::Result { let task_id = args .get("task_id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("缺少 task_id 参数"))? .to_string(); let target_status = args .get("target_status") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("缺少 target_status 参数"))? .to_string(); // 空 DagDef + target_status:workflow.rs:84-92 自动按 target_status 选推进链模板 // (AiNode 自审 / HumanNode 核对闸门),无需本函数感知模板知识。 let dag = df_workflow::dag_def::DagDef::new(); let config = serde_json::Value::Object(serde_json::Map::new()); let name = format!("AI 推进任务 {} → {}", task_id, target_status); // 转调工作流执行核心(B-260617-01:run_workflow_inner 是 run_workflow 命令的共用核心, // 同 invoke('run_workflow') IPC 入口的执行逻辑,持 &AppState 非 tauri::State,绕开 // tauri::State 在非命令上下文不可构造的限制)。 // CR-52: 本路径是 AI 工具经 ai_approve 审批后执行 → triggered_by="ai"(区别命令层 manual)。 // 返回 execution_id 字符串;失败(模板不存在/DAG 校验失败/DB 写入失败)上抛 Err。 let execution_id = crate::commands::workflow::run_workflow_inner( app, state, name, dag, config, Some(task_id.clone()), Some(target_status.clone()), "ai", ) .await .map_err(|e| anyhow::anyhow!("工作流执行失败: {}", e))?; // 回填 tool_result:返回 execution_id + 联动语义说明,让 LLM 知工作流已触发(后台异步执行, // 完成后自动推进任务状态),不再重试同 tool_call。 Ok(serde_json::json!({ "task_id": task_id, "target_status": target_status, "execution_id": execution_id, "status": "running", "note": "工作流已触发(后台异步执行,完成后自动推进任务状态,失败按退回态回滚)。前端 workflow-event 跟踪进度。" })) }