//! AI 聊天命令模块 — 流式对话、工具调用、审批门控、提供商管理 //! //! 由原单文件 ai.rs(2663 行)按职责拆分为 11 个子模块。 //! //! 模块布局: //! - [`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 公共函数) //! - [`tool_registry`] — AI 工具注册表构建 + 文件路径校验 //! - [`knowledge_inject`] — 知识库注入 + 提炼 //! //! 事件协议:通过 app.emit("ai-chat-event", payload) 流式推送到前端 //! - AiTextDelta: 流式文本片段 //! - AiToolCallStarted/Completed: 工具调用生命周期 //! - AiApprovalRequired: 需要人工审批 //! - AiCompleted/AiError: 完成/错误 pub mod agentic; pub mod audit; pub mod commands; pub mod compress; pub mod conversation; pub mod knowledge_inject; pub mod prompt; pub mod provider_pool; pub mod secret; pub mod skills; pub mod stream_recv; pub mod title; pub mod tool_registry; use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::sync::atomic::AtomicBool; use serde::{Deserialize, Serialize}; use df_ai::ai_tools::RiskLevel; use df_ai::context::ContextManager; use df_ai::context::ContextConfig; // ============================================================ // 重导出 — 保路径不变(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::{spawn_embedding_for_knowledge, trigger_extraction_now}; #[allow(unused_imports)] pub use self::tool_registry::build_ai_tool_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, }, /// 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-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 }, } // ============================================================ // 会话状态(放 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)。 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 保护) pub struct AiSession { /// 对话历史(ContextManager:唯一消息真相源,裁剪仅影响发送视图,不影响持久化) pub messages: ContextManager, /// 当前提供商 ID pub active_provider_id: Option, /// 当前活跃对话 ID pub active_conversation_id: Option, /// 活跃对话创建时间(懒创建:首条消息落库前仅存内存,upsert 时用作 created_at) pub active_conv_created_at: Option, /// 挂起的审批。 /// /// **结构**:单层 `HashMap`,按 `tool_call_id` /// 路由审批结果(`tool_call_id` 是路由键)。`PendingApproval.conversation_id` /// 是业务语义(哪个对话产生的审批)而非路由键——IPC 端按 `tool_call_id` 精确命中。 /// /// **现状**:`ai_pending_tool_calls` 等读取路径按 `conversation_id` 做 O(n) 线性 /// 过滤。在典型场景(单对话、少量并发审批)下 n 极小,O(n) 可接受,无需二级索引。 /// /// **未来**:若多会话并发、审批量显著增大,可考虑加二级索引 /// (`conversation_id → Vec`)把按会话过滤降到 O(1)。 pub pending_approvals: HashMap, /// 是否正在生成 pub generating: bool, /// 当前 agent 循环的语言设置(用于审批后恢复循环) pub agent_language: Option, /// 停止信号:ai_chat_stop 置位,agentic loop / stream_llm 检测后尽快退出 pub stop_flag: Arc, /// 即时停止唤醒(B-260615-14):stream_llm 阻塞在 `stream.next()` 等 chunk 时, /// `notify_one()` 立即唤醒跳出 select!,不再等 30s 心跳 tick 轮询。 /// /// 语义边界:Notify 仅承载「即时唤醒」这一职责——停止的真值仍由 `stop_flag` /// (AtomicBool)决定。`ai_chat_stop` 置 `stop_flag=true` 后调 `notify_one()`; /// 被唤醒的 select! 分支再判 `stop_flag` 决定退出或继续,避免 Notify 的通知 /// 与「真的该停」耦合(防误唤醒继续跑)。 /// /// `Arc` 可 Clone(Notify 内部用 Atomic 计数,Clone 等价于 Arc 引用 +1), /// 不影响 AiSession 其他字段语义。 pub notify: Arc, /// agentic loop 累计已跑 iteration 计数(F-260616-11 决策 a)。 /// /// **语义**:「下一轮起算值」。run_agentic_loop 每轮写 `iteration + 1`,审批等待/ /// 达 max 暂停退出时即「当前轮+1」。续跑时由调用方决定如何接: /// - **审批续跑**(ai_approve):传本字段作 start_iteration(**累计**,防多次审批反复 /// 跑满 max_iterations 致 token 失控); /// - **达 max 续跑**(ai_continue_loop):重置本字段=0 + 传 0(**重计**,F-260616-03 /// 决策 a,用户点继续=授权重来); /// - **新消息首次发送**(ai_chat_send):重置本字段=0(新对话生命周期从头计数)。 /// /// 单例一份:当前 AiSession 是全局单例(F-09 B 多会话架构落地时改 per-conv)。 pub iteration_used: usize, /// F-01 阶段6: 用户指定模型 override(主对话专用)。 /// /// **语义**:前端顶部下拉「指定」模式选的 model_id;None/空=自动模式(路由器选)。 /// 仅主对话(agentic)生效——标题/扫描/灵感等内部调用仍走路由,不读此字段。 /// /// **兜底原则**(agentic.rs run_agentic_loop 内强制):override 非空且在该 provider /// model_configs 池中 → 用 override;否则用 resolved_model(路由结果)。绝不让 /// override 导致无模型。 /// /// **生命周期**:ai_chat_send/regenerate/edit 写入(随发消息带);新对话/会话切换时清空 /// (防上一对话 override 残留污染新对话)。try_continue_agent_loop 读此字段透传给续跑 /// loop(审批续跑/达 max 续跑保持 override 一致,语义=同一主对话)。 pub model_override: Option, /// AE-2025-04 会话级信任(Session Trust):内存态,随会话销毁,不落库。 /// 同会话已批准过同类操作(同工具+同目录 TrustKey 命中)后自动放行,跳过 pending 审批 + /// 二次确认(AE-05)。换会话(new/switch)必须清空重审(见 ai_conversation_create / /// ai_conversation_switch 清字段)。 pub session_trust: HashSet, } impl AiSession { pub fn new() -> Self { Self { messages: ContextManager::new(ContextConfig::default()), active_provider_id: None, active_conversation_id: None, active_conv_created_at: None, pending_approvals: HashMap::new(), generating: false, agent_language: None, stop_flag: Arc::new(AtomicBool::new(false)), notify: Arc::new(tokio::sync::Notify::new()), iteration_used: 0, model_override: None, session_trust: HashSet::new(), } } /// 取即时停止唤醒器引用(B-260615-14)。 /// /// 供 `stream_llm`(接 `&Notify` 进 select! 监听 `notified()`)与 `ai_chat_stop` /// (置 `stop_flag` 后调 `notify_one()`)等外部持有方获取同一 Notify 实例。 /// 返回 `&Notify` 而非 clone,避免不必要的 Arc 引用计数调整。 pub fn stop_notify(&self) -> &tokio::sync::Notify { &self.notify } /// 推导会话读侧状态视图(只读,不写任何字段)。 /// /// 优先级:`pending_approvals` 非空 → [`SessionState::AwaitingApproval`]; /// 否则 `generating` → [`SessionState::Streaming`];否则 → [`SessionState::Idle`]。 /// /// 收敛点:调用方读「会话处于什么阶段」时统一走本方法,替代散布的 /// `if !pending_approvals.is_empty() / if generating` 三路组合。 /// /// # 待替换调用点(本次仅新增视图,不替换调用点) /// /// - `agentic.rs:283` — agent loop 入口/恢复处对 generating + 审批的组合判断 /// - `commands.rs:250` — `ai_pending_tool_calls` 等读取前的状态门控 /// - `commands.rs:451` — 审批提交/会话复位路径的状态判断 /// /// 上述三处替换由 P0 任务承接,本方法先就位供其切换。 pub fn session_state(&self) -> SessionState { if !self.pending_approvals.is_empty() { SessionState::AwaitingApproval } else if self.generating { SessionState::Streaming } else { SessionState::Idle } } } /// 待审批的工具调用 #[derive(Debug, Clone)] pub struct PendingApproval { pub tool_call_id: String, pub tool_name: String, pub arguments: serde_json::Value, pub risk_level: RiskLevel, pub conversation_id: Option, /// 重启恢复的积压审批:无 live loop 持有 session.messages,审批后不 save(防空 messages 污染老对话)、不续跑 pub recovered: bool, /// AE-2025-03(路径 B):write_file 的行级 unified diff(旧文件 vs 新内容)。 /// 仅挂起审批前预读注入,供前端审批卡预览;旧文件不存在(新建)或非 write_file 为 None。 /// recovered 审批(启动重建)无 diff(文件可能已变,重读无意义)。 pub diff: Option, } /// 工具调用草稿(流式收集时的临时结构) #[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 跟踪进度。" })) }