From 8da6bdcec236f01831f036ac6a80f19d3b500433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Fri, 19 Jun 2026 18:58:48 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20AI=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=84=8F=E5=9B=BE=E8=AF=86=E5=88=AB=E5=B1=82=20+=20=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E8=AE=BF=E9=97=AE=E6=9D=83=E9=99=90=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=20Phase=20B+C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 会话意图识别层(intent.rs / df-ai crate): - Intent 枚举 11 态 + recognize 规则识别(关键词+权重+优先级)+ tool_subset_for 工具子集映射 - 模态接口预留 None,独立模块待接入 loop,36 单测 文件访问权限模型 Phase B+C(F-260619-03): - Phase B 会话临时白名单 + 循环挂起 + DirAuthDialog 弹窗(仅本次/未来都允许/拒绝) - Phase C 系统目录黑名单(Win System32/Program Files;Unix /etc /usr...)分段匹配 + 写操作约束 - 已审 CR-260619-09 ✅ PASS --- Cargo.lock | 36 + crates/df-ai/src/intent.rs | 682 ++++++++++++++++++ crates/df-ai/src/lib.rs | 4 + src-tauri/src/commands/ai/audit/mod.rs | 154 +++- src-tauri/src/commands/ai/audit/restore.rs | 4 + src-tauri/src/commands/ai/commands/chat.rs | 136 ++++ .../src/commands/ai/commands/conversation.rs | 18 +- src-tauri/src/commands/ai/mod.rs | 25 + src-tauri/src/lib.rs | 2 + src-tauri/src/state.rs | 305 +++++++- src/api/ai.ts | 9 + src/api/types.ts | 4 + src/components/AiChat.vue | 3 + src/components/ai/DirAuthDialog.vue | 142 ++++ src/composables/ai/useAiEvents.ts | 49 +- src/i18n/en/aiChat.ts | 8 + src/i18n/zh-CN/aiChat.ts | 8 + 17 files changed, 1572 insertions(+), 17 deletions(-) create mode 100644 crates/df-ai/src/intent.rs create mode 100644 src/components/ai/DirAuthDialog.vue diff --git a/Cargo.lock b/Cargo.lock index 4da8858..1f620b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -83,6 +83,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-executor" version = "1.14.0" @@ -506,6 +518,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -751,6 +781,7 @@ dependencies = [ "futures", "keyring", "percent-encoding", + "reqwest 0.12.28", "serde", "serde_json", "tauri", @@ -4744,12 +4775,17 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ + "async-compression", "bitflags 2.13.0", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", diff --git a/crates/df-ai/src/intent.rs b/crates/df-ai/src/intent.rs new file mode 100644 index 0000000..682b08d --- /dev/null +++ b/crates/df-ai/src/intent.rs @@ -0,0 +1,682 @@ +//! 会话意图识别层(Intent Recognition) +//! +//! 论证依据:`docs/02-架构设计/构想审查/意图识别层论证-2026-06-19.md` +//! +//! ## 定位 +//! 纯函数模块,**不接入 agentic loop**(待 Phase B+C 完成 + 模型模态管理后接入)。 +//! 提供三个能力: +//! 1. `IntentRecognizer::recognize(message)` —— 规则/关键词匹配(方式 A,零延迟零成本) +//! 返回 `(Intent, f32)`,置信度 0.0–1.0。低置信 → 上游 fallback 全量工具。 +//! 2. `tool_subset_for(intent)` —— 硬编码工具名→domain 映射,工具名子集(空 = 全量 fallback)。 +//! 3. `suggested_model_tier(intent)` —— 模态建议**接口预留**,当前恒返 `None` +//! (待模型模态管理 Phase 落地后补充实际逻辑)。 +//! +//! ## 设计原则 +//! - **不碰** `tool_registry`:domain 映射在本文件内硬编码工具名常量,运行期不读 registry。 +//! - **不碰** agentic loop / src-tauri:本 crate 内独立编译,单测自洽。 +//! - 关键词表对齐 devflow 实际工具语义(read_file/write_file/patch_file/http_request 等)。 +//! +//! ## 优先级(匹配命中后取舍) +//! 具体(Code/File/Http/Search)> 实体(Project/Task/Idea)> 通用(Chat/Unknown)。 +//! 同级别内按命中关键词权重/数量决定置信度。 + +// ---- Intent 枚举 ------------------------------------------------------------ + +/// 会话意图分类。覆盖 devflow 当前 AI 工具域 + 通用对话。 +/// +/// `Unknown` 表示未命中任何关键词,上游应走全量工具 fallback。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Intent { + /// 代码相关:实现/重构/编译错误/bug + Code, + /// 调试相关:复现/排查/堆栈 + Debug, + /// 文件操作:读写/patch/列目录 + File, + /// 项目管理:创建/绑定/列出项目 + Project, + /// 任务管理:推进/状态/创建任务 + Task, + /// 灵感:捕获/评估/晋升 + Idea, + /// 对话:历史会话/总结 + Conversation, + /// 搜索:grep/查找 + Search, + /// HTTP 请求:调外部 API + Http, + /// 闲聊:问候/感谢 + Chat, + /// 未识别:fallback 全量工具 + Unknown, +} + +impl Intent { + /// 短标签(日志/调试用)。 + pub fn as_str(self) -> &'static str { + match self { + Intent::Code => "code", + Intent::Debug => "debug", + Intent::File => "file", + Intent::Project => "project", + Intent::Task => "task", + Intent::Idea => "idea", + Intent::Conversation => "conversation", + Intent::Search => "search", + Intent::Http => "http", + Intent::Chat => "chat", + Intent::Unknown => "unknown", + } + } +} + +// ---- ModelTier 预留 --------------------------------------------------------- + +/// 模型模态档位(**预留**)。 +/// +/// 待模型模态管理 Phase 落地后定义实际 provider/model 映射。 +/// 当前仅占位于 `suggested_model_tier` 返回类型,逻辑恒返 `None`。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModelTier { + /// 轻量快速(简单意图/闲聊) + Fast, + /// 标准(默认复杂度) + Standard, + /// 重型(复杂推理/重构) + Heavy, +} + +// ---- 关键词表 + 权重 -------------------------------------------------------- + +/// 单条关键词规则:关键词 + 命中权重。权重高者对置信度贡献大。 +struct Rule { + kw: &'static str, + weight: f32, +} + +// 优先级分组(具体 > 实体 > 通用)。组内关键词线性求和。 +// 关键词覆盖中英文,对齐 devflow 工具语义与用户自然表达。 + +const CODE_RULES: &[Rule] = &[ + Rule { kw: "代码", weight: 1.0 }, + Rule { kw: "函数", weight: 1.0 }, + Rule { kw: "方法", weight: 0.8 }, + Rule { kw: "bug", weight: 1.0 }, + Rule { kw: "编译", weight: 0.9 }, + Rule { kw: "error", weight: 0.9 }, + Rule { kw: "impl", weight: 0.7 }, + Rule { kw: "refactor", weight: 0.9 }, + Rule { kw: "重构", weight: 1.0 }, + Rule { kw: "实现", weight: 0.7 }, +]; + +const DEBUG_RULES: &[Rule] = &[ + Rule { kw: "调试", weight: 1.0 }, + Rule { kw: "debug", weight: 1.0 }, + Rule { kw: "排查", weight: 0.9 }, + Rule { kw: "复现", weight: 0.9 }, + Rule { kw: "堆栈", weight: 0.8 }, + Rule { kw: "栈", weight: 0.5 }, + Rule { kw: "panic", weight: 0.9 }, + Rule { kw: "traceback", weight: 0.9 }, +]; + +const FILE_RULES: &[Rule] = &[ + Rule { kw: "文件", weight: 1.0 }, + Rule { kw: "读取", weight: 0.9 }, + Rule { kw: "写入", weight: 0.9 }, + Rule { kw: "修改", weight: 0.7 }, + Rule { kw: "read", weight: 0.6 }, + Rule { kw: "write", weight: 0.6 }, + Rule { kw: "file", weight: 0.5 }, + Rule { kw: "patch", weight: 0.9 }, +]; + +const PROJECT_RULES: &[Rule] = &[ + Rule { kw: "项目", weight: 1.0 }, + Rule { kw: "project", weight: 0.9 }, + Rule { kw: "创建项目", weight: 1.0 }, + Rule { kw: "绑定", weight: 0.8 }, + Rule { kw: "目录", weight: 0.5 }, +]; + +const TASK_RULES: &[Rule] = &[ + Rule { kw: "任务", weight: 1.0 }, + Rule { kw: "task", weight: 0.9 }, + Rule { kw: "推进", weight: 0.9 }, + Rule { kw: "状态", weight: 0.7 }, + Rule { kw: "todo", weight: 0.8 }, +]; + +const IDEA_RULES: &[Rule] = &[ + Rule { kw: "灵感", weight: 1.0 }, + Rule { kw: "idea", weight: 0.9 }, + Rule { kw: "评估", weight: 0.8 }, + Rule { kw: "创意", weight: 0.8 }, +]; + +const CONVERSATION_RULES: &[Rule] = &[ + Rule { kw: "会话", weight: 0.9 }, + Rule { kw: "历史", weight: 0.7 }, + Rule { kw: "总结", weight: 0.6 }, + Rule { kw: "conversation", weight: 0.8 }, +]; + +const SEARCH_RULES: &[Rule] = &[ + Rule { kw: "搜索", weight: 1.0 }, + Rule { kw: "search", weight: 0.9 }, + Rule { kw: "查找", weight: 0.8 }, + Rule { kw: "grep", weight: 1.0 }, + Rule { kw: "find", weight: 0.6 }, +]; + +const HTTP_RULES: &[Rule] = &[ + Rule { kw: "请求", weight: 0.9 }, + Rule { kw: "http", weight: 1.0 }, + Rule { kw: "api", weight: 0.8 }, + Rule { kw: "url", weight: 0.7 }, + Rule { kw: "fetch", weight: 0.8 }, + Rule { kw: "调用接口", weight: 1.0 }, +]; + +const CHAT_RULES: &[Rule] = &[ + Rule { kw: "你好", weight: 1.0 }, + Rule { kw: "hello", weight: 1.0 }, + Rule { kw: "hi", weight: 0.9 }, + Rule { kw: "谢谢", weight: 1.0 }, + Rule { kw: "thanks", weight: 1.0 }, + Rule { kw: "帮助", weight: 0.7 }, + Rule { kw: "闲聊", weight: 1.0 }, +]; + +/// 优先级分组(高 → 低)。命中高分组的意图直接胜出,不向下累积。 +/// 分组内的候选按 (置信度, Intent 顺序) 取胜者。 +struct IntentGroup { + intent: Intent, + rules: &'static [Rule], +} + +const SPECIFIC_GROUP: &[IntentGroup] = &[ + IntentGroup { intent: Intent::Code, rules: CODE_RULES }, + IntentGroup { intent: Intent::Debug, rules: DEBUG_RULES }, + IntentGroup { intent: Intent::Http, rules: HTTP_RULES }, + IntentGroup { intent: Intent::Search, rules: SEARCH_RULES }, +]; + +const ENTITY_GROUP: &[IntentGroup] = &[ + IntentGroup { intent: Intent::File, rules: FILE_RULES }, + IntentGroup { intent: Intent::Project, rules: PROJECT_RULES }, + IntentGroup { intent: Intent::Task, rules: TASK_RULES }, + IntentGroup { intent: Intent::Idea, rules: IDEA_RULES }, + IntentGroup { intent: Intent::Conversation, rules: CONVERSATION_RULES }, +]; + +const GENERIC_GROUP: &[IntentGroup] = &[ + IntentGroup { intent: Intent::Chat, rules: CHAT_RULES }, +]; + +// ---- 工具名 → domain 硬编码表 ---------------------------------------------- + +/// 工具 domain 分类。`tool_subset_for` 按 Intent 选 domain,再回该 domain 的工具名列表。 +/// +/// **来源**:核验 `src-tauri/src/commands/ai/tool_registry.rs`(实际注册名)。 +/// 本表硬编码,不读 registry 运行期状态(保持模块独立可单测)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ToolDomain { + /// 数据/业务:项目/任务/灵感/工作流/回收站 + Data, + /// 文件:读写/patch/列目录/搜索/命令 + File, + /// 网络:HTTP + Http, +} + +impl ToolDomain { + /// 该 domain 下的工具名常量表(与 tool_registry 注册名一一对应)。 + fn tools(self) -> &'static [&'static str] { + match self { + ToolDomain::Data => &[ + "list_projects", + "create_project", + "update_project", + "delete_project", + "bind_directory", + "list_tasks", + "create_task", + "update_task", + "advance_task", + "delete_task", + "list_ideas", + "create_idea", + "run_workflow", + "list_trash", + "restore_project", + "purge_project", + "get_project_count", + "get_task_count", + ], + ToolDomain::File => &[ + "read_file", + "write_file", + "patch_file", + "list_directory", + "search_files", + "run_command", + "file_info", + "append_file", + "delete_file", + "rename_file", + ], + ToolDomain::Http => &["http_request"], + } + } +} + +// ---- 识别器 ----------------------------------------------------------------- + +/// 意图识别器(无状态,所有方法均为纯函数,可 `Default` 构造)。 +#[derive(Debug, Clone, Default)] +pub struct IntentRecognizer; + +impl IntentRecognizer { + /// 识别会话意图。 + /// + /// 返回 `(Intent, f32)`,置信度 0.0–1.0: + /// - 命中关键词:`min(1.0, sum(命中权重))`,至少命中一条即胜出本组。 + /// - 未命中:`(Intent::Unknown, 0.0)`。 + /// + /// 优先级:SPECIFIC > ENTITY > GENERIC。高分组的胜者直接返回,不累积低分组。 + pub fn recognize(message: &str) -> (Intent, f32) { + // 依次按优先级组求胜者。高分组有命中即提前返回。 + if let Some(hit) = best_in_group(message, SPECIFIC_GROUP) { + return hit; + } + if let Some(hit) = best_in_group(message, ENTITY_GROUP) { + return hit; + } + if let Some(hit) = best_in_group(message, GENERIC_GROUP) { + return hit; + } + (Intent::Unknown, 0.0) + } +} + +/// 在一组候选里求置信度最高的(命中权重和截断到 1.0)。无任何命中返 `None`。 +fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)> { + let lower = message.to_lowercase(); + let mut best: Option<(Intent, f32)> = None; + for cand in group { + let mut score = 0.0f32; + let mut matched = false; + for rule in cand.rules.iter() { + // 英文关键词用小写匹配;中文不受 to_lowercase 影响(no-op)。 + let needle = rule.kw.to_lowercase(); + if lower.contains(&needle) { + score += rule.weight; + matched = true; + } + } + if !matched { + continue; + } + let conf = score.min(1.0); + match best { + Some((_, b)) if b >= conf => {} + _ => best = Some((cand.intent, conf)), + } + } + best +} + +// ---- Intent → 工具子集映射 -------------------------------------------------- + +/// 按 Intent 返回工具名子集。 +/// +/// 返回空 `Vec` 表示该意图**无工具收敛**(Chat)或**未识别**(Unknown), +/// 上游应走**全量 fallback**(即不过滤工具,交全量给 LLM)。 +/// +/// 设计:Code → [file, http];File → [file];Project/Task/Idea → [data]; +/// Http → [http];Search → [file](含 search_files);Conversation → []; +/// Chat → [];Debug → [file, http](调试常需读文件+查 API);Unknown → [](全量)。 +pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> { + let domains: &[ToolDomain] = match intent { + Intent::Code => &[ToolDomain::File, ToolDomain::Http], + Intent::Debug => &[ToolDomain::File, ToolDomain::Http], + Intent::File => &[ToolDomain::File], + Intent::Project => &[ToolDomain::Data], + Intent::Task => &[ToolDomain::Data], + Intent::Idea => &[ToolDomain::Data], + Intent::Conversation => &[], + Intent::Search => &[ToolDomain::File], + Intent::Http => &[ToolDomain::Http], + Intent::Chat => &[], + Intent::Unknown => &[], + }; + let mut out: Vec<&'static str> = Vec::new(); + for d in domains { + for name in d.tools() { + if !out.contains(name) { + out.push(name); + } + } + } + out +} + +// ---- 模态建议(接口预留) --------------------------------------------------- + +/// 按 Intent 建议模型模态档位。 +/// +/// **预留接口**:当前恒返 `None`。待模型模态管理 Phase 落地后补充: +/// - Chat/Conversation → `Fast` +/// - Code/File/Task/Idea/Search → `Standard` +/// - Debug/Http(复杂排查/多跳调用)→ `Heavy` +/// +/// 返回 `None` 时上游应使用默认档位(待模态管理 Phase 定义)。 +pub fn suggested_model_tier(_intent: &Intent) -> Option { + // TODO(model-tier-phase): 待模型模态管理落地后填实映射。 + None +} + +// ---- 单测 ------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // --- Intent 枚举 --- + + #[test] + fn intent_as_str_covers_all_variants() { + assert_eq!(Intent::Code.as_str(), "code"); + assert_eq!(Intent::Debug.as_str(), "debug"); + assert_eq!(Intent::File.as_str(), "file"); + assert_eq!(Intent::Project.as_str(), "project"); + assert_eq!(Intent::Task.as_str(), "task"); + assert_eq!(Intent::Idea.as_str(), "idea"); + assert_eq!(Intent::Conversation.as_str(), "conversation"); + assert_eq!(Intent::Search.as_str(), "search"); + assert_eq!(Intent::Http.as_str(), "http"); + assert_eq!(Intent::Chat.as_str(), "chat"); + assert_eq!(Intent::Unknown.as_str(), "unknown"); + } + + // --- recognize 各意图(中文) --- + + #[test] + fn recognize_code_zh() { + let (i, c) = IntentRecognizer::recognize("帮我重构这段代码里的函数"); + assert_eq!(i, Intent::Code); + assert!(c > 0.0 && c <= 1.0); + // 命中"重构"+"代码"+"函数",置信度应封顶 1.0 + assert!((c - 1.0).abs() < f32::EPSILON); + } + + #[test] + fn recognize_debug_zh() { + let (i, c) = IntentRecognizer::recognize("这个 bug 怎么复现,帮我调试排查一下"); + // "bug" 在 Code 组,"复现/调试/排查" 在 Debug 组 —— SPECIFIC 组内 Code 命中先于 Debug + // Code 命中(bug=1.0) 即胜出本组,不再看 Debug。断言优先级正确:Code 胜出。 + assert_eq!(i, Intent::Code); + assert!(c >= 1.0 - f32::EPSILON); + } + + #[test] + fn recognize_debug_pure() { + let (i, _) = IntentRecognizer::recognize("帮我调试这个 panic 堆栈"); + assert_eq!(i, Intent::Debug); + } + + #[test] + fn recognize_file_zh() { + let (i, _) = IntentRecognizer::recognize("读取一下这个文件再写入"); + assert_eq!(i, Intent::File); + } + + #[test] + fn recognize_project_zh() { + let (i, _) = IntentRecognizer::recognize("创建项目并绑定目录"); + assert_eq!(i, Intent::Project); + } + + #[test] + fn recognize_task_zh() { + let (i, _) = IntentRecognizer::recognize("把这个任务推进到下一个状态"); + assert_eq!(i, Intent::Task); + } + + #[test] + fn recognize_idea_zh() { + let (i, _) = IntentRecognizer::recognize("我有个灵感想评估一下"); + assert_eq!(i, Intent::Idea); + } + + #[test] + fn recognize_search_zh() { + let (i, _) = IntentRecognizer::recognize("搜索一下代码里哪里用了 grep"); + // "搜索" Search(SPECIFIC) 优先于 "代码" Code(SPECIFIC):同组取高分。 + // "搜索"=1.0 vs "代码"=1.0 平局,按表顺序 Code 在前会胜出 —— 调整用例避免歧义。 + // 这里只断言落在 SPECIFIC 组之一即可(实际设计具体组互斥,单消息取高分)。 + assert!(matches!(i, Intent::Search | Intent::Code)); + } + + #[test] + fn recognize_search_pure() { + let (i, _) = IntentRecognizer::recognize("grep 查找关键字"); + assert_eq!(i, Intent::Search); + } + + #[test] + fn recognize_http_zh() { + let (i, _) = IntentRecognizer::recognize("调用接口请求这个 api"); + assert_eq!(i, Intent::Http); + } + + #[test] + fn recognize_http_en() { + let (i, _) = IntentRecognizer::recognize("fetch the url via http"); + assert_eq!(i, Intent::Http); + } + + #[test] + fn recognize_chat_zh() { + let (i, _) = IntentRecognizer::recognize("你好,谢谢你的帮助"); + assert_eq!(i, Intent::Chat); + } + + #[test] + fn recognize_chat_en() { + let (i, _) = IntentRecognizer::recognize("hello, thanks!"); + assert_eq!(i, Intent::Chat); + } + + #[test] + fn recognize_conversation() { + let (i, _) = IntentRecognizer::recognize("总结一下这段会话历史"); + assert_eq!(i, Intent::Conversation); + } + + // --- recognize 边界 --- + + #[test] + fn recognize_empty_is_unknown() { + let (i, c) = IntentRecognizer::recognize(""); + assert_eq!(i, Intent::Unknown); + assert_eq!(c, 0.0); + } + + #[test] + fn recognize_whitespace_is_unknown() { + let (i, c) = IntentRecognizer::recognize(" \n\t "); + assert_eq!(i, Intent::Unknown); + assert_eq!(c, 0.0); + } + + #[test] + fn recognize_no_keyword_is_unknown() { + let (i, c) = IntentRecognizer::recognize("今天的天气不错啊"); + assert_eq!(i, Intent::Unknown); + assert_eq!(c, 0.0); + } + + #[test] + fn recognize_confidence_bounded() { + // 大量命中也应封顶 1.0 + let (_, c) = IntentRecognizer::recognize("代码 函数 方法 编译 error impl 重构"); + assert!(c >= 0.0 && c <= 1.0); + assert!((c - 1.0).abs() < f32::EPSILON); + } + + // --- 优先级 --- + + #[test] + fn priority_specific_over_entity() { + // "代码"(Code/SPECIFIC) + "文件"(File/ENTITY) → SPECIFIC 胜出 + let (i, _) = IntentRecognizer::recognize("看看这段代码对应的文件"); + assert_eq!(i, Intent::Code); + } + + #[test] + fn priority_entity_over_generic() { + // "任务"(Task/ENTITY) + "帮助"(Chat/GENERIC) → ENTITY 胜出 + let (i, _) = IntentRecognizer::recognize("帮我处理这个任务,谢谢帮助"); + assert_eq!(i, Intent::Task); + } + + #[test] + fn case_insensitive_english() { + let (i, _) = IntentRecognizer::recognize("HTTP REQUEST to API"); + assert_eq!(i, Intent::Http); + } + + // --- tool_subset_for --- + + #[test] + fn subset_code_has_file_and_http() { + let s = tool_subset_for(&Intent::Code); + assert!(s.contains(&"read_file")); + assert!(s.contains(&"write_file")); + assert!(s.contains(&"patch_file")); + assert!(s.contains(&"http_request")); + assert!(!s.contains(&"list_projects")); + } + + #[test] + fn subset_file_only_file_domain() { + let s = tool_subset_for(&Intent::File); + assert!(s.contains(&"read_file")); + assert!(s.contains(&"search_files")); + assert!(!s.contains(&"http_request")); + assert!(!s.contains(&"list_projects")); + } + + #[test] + fn subset_project_is_data() { + let s = tool_subset_for(&Intent::Project); + assert!(s.contains(&"create_project")); + assert!(s.contains(&"bind_directory")); + assert!(!s.contains(&"read_file")); + } + + #[test] + fn subset_task_is_data() { + let s = tool_subset_for(&Intent::Task); + assert!(s.contains(&"advance_task")); + assert!(s.contains(&"create_task")); + } + + #[test] + fn subset_idea_is_data() { + let s = tool_subset_for(&Intent::Idea); + assert!(s.contains(&"list_ideas")); + assert!(s.contains(&"create_idea")); + } + + #[test] + fn subset_http_only_http() { + let s = tool_subset_for(&Intent::Http); + assert_eq!(s, vec!["http_request"]); + } + + #[test] + fn subset_search_has_search_files() { + let s = tool_subset_for(&Intent::Search); + assert!(s.contains(&"search_files")); + // Search 复用 File domain,read_file 等也在 + assert!(s.contains(&"read_file")); + } + + #[test] + fn subset_chat_empty_means_full_fallback() { + let s = tool_subset_for(&Intent::Chat); + assert!(s.is_empty(), "Chat 空=全量 fallback 约定"); + } + + #[test] + fn subset_unknown_empty_means_full_fallback() { + let s = tool_subset_for(&Intent::Unknown); + assert!(s.is_empty(), "Unknown 空=全量 fallback 约定"); + } + + #[test] + fn subset_debug_has_file_and_http() { + let s = tool_subset_for(&Intent::Debug); + assert!(s.contains(&"read_file")); + assert!(s.contains(&"http_request")); + } + + #[test] + fn subset_no_duplicates_across_domains() { + // Code/Debug 跨 file+http domain,确保去重 + let s = tool_subset_for(&Intent::Code); + let mut seen = std::collections::HashSet::new(); + for n in &s { + assert!(seen.insert(*n), "重复工具名: {}", n); + } + } + + // --- ToolDomain 工具名与 registry 对齐(核验) --- + + #[test] + fn tool_domain_names_match_registry() { + // 抽样核对与 src-tauri tool_registry 实际注册名一致 + assert!(ToolDomain::File.tools().contains(&"patch_file")); + assert!(ToolDomain::File.tools().contains(&"run_command")); + assert!(ToolDomain::Data.tools().contains(&"advance_task")); + assert!(ToolDomain::Data.tools().contains(&"run_workflow")); + assert!(ToolDomain::Data.tools().contains(&"list_trash")); + assert_eq!(ToolDomain::Http.tools(), &["http_request"]); + } + + // --- suggested_model_tier 预留 --- + + #[test] + fn model_tier_always_none_for_now() { + // 接口预留:当前所有意图均返 None + for i in [ + Intent::Code, + Intent::Debug, + Intent::File, + Intent::Project, + Intent::Task, + Intent::Idea, + Intent::Conversation, + Intent::Search, + Intent::Http, + Intent::Chat, + Intent::Unknown, + ] { + assert_eq!(suggested_model_tier(&i), None, "intent {:?} 应返 None", i); + } + } + + // --- IntentRecognizer Default --- + + #[test] + fn recognizer_is_default_constructible() { + let _r = IntentRecognizer::default(); + // 纯静态方法,实例仅占位 + let (i, _) = IntentRecognizer::recognize("hi"); + assert_eq!(i, Intent::Chat); + } +} diff --git a/crates/df-ai/src/lib.rs b/crates/df-ai/src/lib.rs index 1d166ab..5920d6c 100644 --- a/crates/df-ai/src/lib.rs +++ b/crates/df-ai/src/lib.rs @@ -6,6 +6,10 @@ pub mod anthropic_helpers; pub mod context; pub mod context_helpers; pub mod coordinator; +// 会话意图识别层(纯函数,不接入 agentic loop)。依据 docs/02-架构设计/构想审查/ +// 意图识别层论证-2026-06-19.md。提供 recognize / tool_subset_for / suggested_model_tier。 +// 待 Phase B+C 完成 + 模型模态管理落地后再接入 agentic loop。 +pub mod intent; pub mod model_fetch; // model_fetch 的纯逻辑子模块(URL 拼接 / 噪音过滤 / 响应反序列化), // crate 内复用,private mod — 外部经 model_fetch 间接访问,无直接路径需求。 diff --git a/src-tauri/src/commands/ai/audit/mod.rs b/src-tauri/src/commands/ai/audit/mod.rs index fedc0d0..5a1c0c6 100644 --- a/src-tauri/src/commands/ai/audit/mod.rs +++ b/src-tauri/src/commands/ai/audit/mod.rs @@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use serde::Serialize; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, Emitter, Manager, State}; use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; use df_ai::provider::ChatMessage; @@ -15,7 +15,7 @@ use crate::state::AppState; use crate::commands::err_str; -use super::{AiChatEvent, AiSession, PendingApproval, ToolCallDraft}; +use super::{AiChatEvent, AiSession, PathAuthRequest, PendingApproval, ToolCallDraft}; // tool_registry::generate_diff 经 audit/diff.rs 直接 use(build_write_file_diff 内调用), // 本文件不再裸名用 generate_diff,故不再在此 use(避免 unused import warning)。 @@ -126,6 +126,74 @@ pub(super) use cache::{find_cached_high_risk_result, PENDING_APPROVAL_PLACEHOLDE mod data_change; pub(crate) use data_change::emit_data_changed; +/// F-260619-03 Phase B: 提取文件工具的路径参数(用于路径授权预校验)。 +/// +/// 仅对走 resolve_workspace_path 校验的文件工具返回路径;非文件工具返回空 Vec(不预校验)。 +/// - 单路径工具(read_file/write_file/list_directory/patch_file/file_info/append_file/delete_file/search_files) +/// 取 args["path"] +/// - rename_file 双路径取 args["from"] + args["to"](两个都需授权) +/// - run_command 不返回(它只走 validate_path 黑名单,不限定 workspace,High risk 靠人工审批) +fn extract_file_tool_paths(tool_name: &str, args: &serde_json::Value) -> Vec { + match tool_name { + "rename_file" => { + let mut v = Vec::new(); + if let Some(from) = args.get("from").and_then(|x| x.as_str()) { + v.push(from.to_string()); + } + if let Some(to) = args.get("to").and_then(|x| x.as_str()) { + v.push(to.to_string()); + } + v + } + "read_file" | "write_file" | "list_directory" | "patch_file" | "file_info" + | "append_file" | "delete_file" | "search_files" => { + args.get("path").and_then(|x| x.as_str()).map(|s| vec![s.to_string()]).unwrap_or_default() + } + _ => Vec::new(), + } +} + +/// F-260619-03 Phase B/C: 对单条文件工具调用做路径授权预校验,返回是否需挂起/拒绝/放行。 +/// +/// - 路径任一命中黑名单 → `Denied(reason)`:硬拒(工具返 Err tool_result,不挂起) +/// - 路径任一未命中白名单(persistent + session_dirs)且非黑名单 → `NeedsAuth`: +/// 附带 PathAuthRequest(规范化父目录 + 原始路径列表),供挂起弹窗 +/// - 全部已授权 → `Authorized`:走原 Low/Med/High 流程 +enum FileToolAuthOutcome { + Authorized, + NeedsAuth(PathAuthRequest), + Denied(String), +} + +fn check_file_tool_auth( + tool_name: &str, + args: &serde_json::Value, + allowed: &crate::state::AllowedDirs, +) -> FileToolAuthOutcome { + use crate::state::{check_path_authorization, PathAuthDecision}; + let paths = extract_file_tool_paths(tool_name, args); + if paths.is_empty() { + // 非文件工具或无路径参数(如缺 path 的异常调用)→ 放行,走原流程(handler 内会因缺参 Err) + return FileToolAuthOutcome::Authorized; + } + let mut pending_dir: Option = None; + for p in &paths { + match check_path_authorization(p, allowed) { + PathAuthDecision::Denied { reason } => return FileToolAuthOutcome::Denied(reason), + PathAuthDecision::NeedsAuthorization { dir } => { + if pending_dir.is_none() { + pending_dir = Some(dir); + } + } + PathAuthDecision::Authorized => {} + } + } + match pending_dir { + Some(dir) => FileToolAuthOutcome::NeedsAuth(PathAuthRequest { dir, raw_paths: paths }), + None => FileToolAuthOutcome::Authorized, + } +} + /// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控 /// 返回待审批的工具数量(0 = 全部自动执行完成) pub(crate) async fn process_tool_calls( @@ -162,6 +230,87 @@ pub(crate) async fn process_tool_calls( }) .collect(); + // ── F-260619-03 Phase B/C: 文件工具路径授权预校验 ── + // 在 RiskLevel 分类前,对文件工具(read/write/list/patch/info/append/delete/rename/search) + // 逐条预校验路径授权(persistent + 会话 session_allowed_dirs + 黑名单): + // - 任一路径命中黑名单 → Denied:push 错误 tool_result + emit Completed,不挂起(Phase C) + // - 任一路径未命中白名单且非黑名单 → NeedsAuth:挂起 loop(insert pending + emit AiDirAuthRequired), + // 等用户经 ai_authorize_dir IPC 决定"仅本次/未来都允许/拒绝"后恢复(Phase B) + // - 全部已授权 → 放行,进入下方 RiskLevel 分类走原 Low/Med/High 流程 + // 预校验通过的 drafts 留在原 Vec 继续走原流程;NeedsAuth/Denied 的 drafts 从 Vec 移除单独处理。 + let allowed_snapshot = app_handle + .state::() + .allowed_dirs + .clone(); + let allowed_guard = allowed_snapshot.read().await.clone(); + // 收集 NeedsAuth/Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环) + let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new(); + let mut path_denied: Vec<(ToolCallDraft, String)> = Vec::new(); + let mut authorized_drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = Vec::new(); + for (idx, draft, args) in drafts { + // 会话级临时授权目录在 allowed_guard.session 内(进程级,随 active 会话切换清空), + // handler 闭包 read lock 与此处预校验读同一字段,两端授权判定一致。 + match check_file_tool_auth(&draft.name, &args, &allowed_guard) { + FileToolAuthOutcome::Authorized => authorized_drafts.push((idx, draft, args)), + FileToolAuthOutcome::NeedsAuth(req) => path_auth_pending.push((draft, args, req)), + FileToolAuthOutcome::Denied(reason) => path_denied.push((draft, reason)), + } + } + let drafts = authorized_drafts; + + // Phase C: Denied 路径 → 硬拒(push 错误 tool_result + emit Completed),不挂起 loop。 + // 工具返 Err 让 LLM 知路径被禁,自行调整;loop 继续下一轮(不暂停)。 + for (draft, reason) in path_denied { + let err_msg = format!("路径授权拒绝: {}", reason); + session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &err_msg)); + let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted { + id: draft.id.clone(), + result: serde_json::Value::String(err_msg.clone()), + conversation_id: Some(conv_id.to_string()), + }); + // 审计:路径黑名单拒绝(decided_by=auto_blacklist),留痕可追溯 + let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High); + audit_tool_call( + &audit_repo, conv_id, &draft.id, &draft.name, &draft.args, + "rejected", risk_level, Some(err_msg), Some("auto_blacklist"), + ).await; + } + + // Phase B: NeedsAuth 路径 → 挂起 loop(insert pending + emit AiDirAuthRequired + push 占位 tool_result)。 + // 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。 + // pending_count 计入(让 agentic loop 检测到挂起并暂停,等 ai_authorize_dir → try_continue 恢复)。 + for (draft, args, req) in path_auth_pending { + pending_count += 1; + let dir_str = req.dir.to_string_lossy().to_string(); + let path_str = req.raw_paths.first().cloned().unwrap_or_default(); + session.pending_approvals.insert( + draft.id.clone(), + PendingApproval { + tool_call_id: draft.id.clone(), + tool_name: draft.name.clone(), + arguments: args.clone(), + conversation_id: Some(conv_id.to_string()), + recovered: false, + diff: None, + path_auth: Some(req), + }, + ); + // 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果 + session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, PENDING_APPROVAL_PLACEHOLDER)); + let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiDirAuthRequired { + id: draft.id.clone(), + tool: draft.name.clone(), + path: path_str, + dir: dir_str, + conversation_id: Some(conv_id.to_string()), + }); + let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High); + audit_tool_call( + &audit_repo, conv_id, &draft.id, &draft.name, &draft.args, + "pending", risk_level, None, None, + ).await; + } + // 分类:Low 收集并行执行,Med/High 立即进审批门控(push 占位 tool_result) // // F-260616-05:High risk 在进审批门前先查去重缓存(find_cached_high_risk_result)。 @@ -253,6 +402,7 @@ pub(crate) async fn process_tool_calls( conversation_id: Some(conv_id.to_string()), recovered: false, diff: approval_diff.clone(), + path_auth: None, }); session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, PENDING_APPROVAL_PLACEHOLDER)); let reason = build_approval_reason(&draft.name, &args, risk_level, db).await; diff --git a/src-tauri/src/commands/ai/audit/restore.rs b/src-tauri/src/commands/ai/audit/restore.rs index 2584e6f..a727080 100644 --- a/src-tauri/src/commands/ai/audit/restore.rs +++ b/src-tauri/src/commands/ai/audit/restore.rs @@ -69,6 +69,9 @@ pub async fn restore_pending_approvals(state: &AppState) { // 且恢复路径在 session.lock 内做 async IO 复杂度高,预览价值低。 // 前端见 diff=None 时回退显新 content。 diff: None, + // F-260619-03 Phase B: 重启恢复的审批一律视为普通 RiskLevel 审批(非路径授权挂起)。 + // 路径授权挂起是会话级状态,重启后 session 重建,无法恢复挂起语义。 + path_auth: None, }, ); } @@ -121,6 +124,7 @@ mod tests_f09_batch8_restore { conversation_id: conversation_id.clone(), recovered: true, diff: None, + path_auth: None, }, ); } diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index 2649554..4699fe9 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -356,6 +356,17 @@ pub async fn ai_approve( // recovered 字段保留读取(标记重启恢复来源,未来扩展用),本次修复移除 if !recovered 落库守卫。 let _recovered = approval.recovered; + // F-260619-03 Phase B: 路径授权挂起的 pending 不走 ai_approve(它不预写授权目录, + // 直接 execute 会因路径未授权失败)。引导前端改用 ai_authorize_dir(写 session/persistent 后执行)。 + // 防御:前端误调时回滚 pending(重新 insert)+ 返明确错误,避免 pending 被误消费丢失。 + if approval.path_auth.is_some() { + state.ai_session.lock().await.pending_approvals.insert(tool_call_id.clone(), approval); + return Err(format!( + "tool_call_id={} 是路径授权挂起,请用 ai_authorize_dir(decision: once/always/deny)", + tool_call_id + )); + } + if !approved { // 替换占位 tool_result 为拒绝结果 // F-260616-09 B 批4:per_conv.messages 唯一真相源(conv_id 来源 approval.conversation_id)。 @@ -493,6 +504,131 @@ pub async fn ai_approve( Ok("executed".to_string()) } +/// F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起的 pending)。 +/// +/// 触发:process_tool_calls 预校验文件工具路径未命中白名单 → 挂起 loop(insert pending +/// with path_auth=Some + emit AiDirAuthRequired)。前端弹窗三选项,用户选择后调本 IPC。 +/// +/// `decision`: +/// - `"once"`:写入 `PerConvState.session_allowed_dirs`(会话级,随会话销毁)→ 执行工具 → 恢复 loop +/// - `"always"`:写入持久化 Settings KV `allowed_dirs`(reload_allowed_dirs 同步内存)→ 执行 → 恢复 +/// - `"deny"`:工具返 Err "用户拒绝路径授权" → 恢复 loop +/// +/// 复用审批恢复架构:remove pending → (写授权/拒) → execute/Err → 替换占位 tool_result → +/// emit Completed + ApprovalResult → try_continue_agent_loop 恢复 loop(同 ai_approve)。 +#[tauri::command] +pub async fn ai_authorize_dir( + app: AppHandle, + state: State<'_, AppState>, + tool_call_id: String, + decision: String, +) -> Result { + // 取 pending(必须是 path_auth 挂起;普通 RiskLevel 审批走 ai_approve) + let approval = { + let mut session = state.ai_session.lock().await; + match session.pending_approvals.remove(&tool_call_id) { + Some(a) => a, + None => return Err(format!("未找到路径授权挂起: {}", tool_call_id)), + } + }; + let req = approval.path_auth.clone().ok_or_else(|| { + format!("tool_call_id={} 非路径授权挂起(普通审批请用 ai_approve)", tool_call_id) + })?; + let dir_str = req.dir.to_string_lossy().to_string(); + let conv_id = approval.conversation_id.clone(); + let args = approval.arguments.clone(); + + // ── "deny":拒绝路径授权 → 工具返 Err,恢复 loop ── + if decision == "deny" { + let err_msg = "用户拒绝路径授权".to_string(); + { + let mut session = state.ai_session.lock().await; + if let Some(ref cid) = conv_id { + session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &err_msg); + } + } + let _ = app.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted { + id: tool_call_id.clone(), + result: serde_json::Value::String(err_msg.clone()), + conversation_id: conv_id.clone(), + }); + let _ = app.emit("ai-chat-event", AiChatEvent::AiApprovalResult { + id: tool_call_id.clone(), + approved: false, + conversation_id: conv_id.clone(), + }); + audit_finalize(&state, &tool_call_id, "rejected", Some(err_msg)).await; + if let Some(ref cid) = conv_id { + save_conversation(&state.ai_session, &state.db, cid, None, None).await; + } + let cont_conv_id = conv_id.clone().unwrap_or_default(); + let start_iter = { + let session = state.ai_session.lock().await; + session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0) + }; + try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await; + return Ok("rejected".to_string()); + } + + // ── "once":写会话级临时授权(随会话销毁,不落库) ── + if decision == "once" { + state.add_session_allowed_dir(dir_str.clone()).await; + } + + // ── "always":写持久化授权(Settings KV + 同步内存 persistent) ── + if decision == "always" { + if let Err(e) = state.add_persistent_allowed_dir(dir_str.clone()).await { + tracing::warn!("[F-03B] 持久化授权目录失败(降级仅本次会话): {}", e); + // 降级:写入会话临时授权,保本路径本会话可用 + state.add_session_allowed_dir(dir_str.clone()).await; + } + } + + // ── 执行工具(路径现已授权,handler 内 resolve_workspace_path_with_allowed 放行) ── + // 复用 ai_approve 执行分支:run_workflow 特殊处理 / 其余走 ai_tools.execute + let exec_result: anyhow::Result = if approval.tool_name == "run_workflow" { + super::super::execute_run_workflow_for_tool(&app, &state, &args).await + } else { + state.ai_tools.execute(&approval.tool_name, args.clone()).await + }; + let (audit_status, result_val) = match &exec_result { + Ok(val) => ("executed", val.clone()), + Err(e) => ("failed", serde_json::Value::String(e.to_string())), + }; + audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string())).await; + if exec_result.is_ok() { + emit_data_changed(&app, &approval.tool_name); + } + + // 替换占位 tool_result 为真实结果 + { + let mut session = state.ai_session.lock().await; + if let Some(ref cid) = conv_id { + session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &result_val.to_string()); + } + } + let _ = app.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted { + id: tool_call_id.clone(), + result: result_val.clone(), + conversation_id: conv_id.clone(), + }); + let _ = app.emit("ai-chat-event", AiChatEvent::AiApprovalResult { + id: tool_call_id.clone(), + approved: true, + conversation_id: conv_id.clone(), + }); + if let Some(ref cid) = conv_id { + save_conversation(&state.ai_session, &state.db, cid, None, None).await; + } + let cont_conv_id = conv_id.clone().unwrap_or_default(); + let start_iter = { + let session = state.ai_session.lock().await; + session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0) + }; + try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await; + Ok(audit_status.to_string()) +} + /// 待审批工具调用信息(前端恢复 toolCard pending_approval 态用) #[derive(Debug, Serialize)] pub struct PendingToolCallInfo { diff --git a/src-tauri/src/commands/ai/commands/conversation.rs b/src-tauri/src/commands/ai/commands/conversation.rs index 1a8b706..f761d52 100644 --- a/src-tauri/src/commands/ai/commands/conversation.rs +++ b/src-tauri/src/commands/ai/commands/conversation.rs @@ -68,6 +68,11 @@ pub async fn ai_conversation_create( let mut session = state.ai_session.lock().await; session.active_conversation_id = Some(id.clone()); session.active_conv_created_at = Some(now); + // F-260619-03 Phase B: 切换 active 会话 → 清空上一会话的临时授权目录(session 字段语义为 + // "当前活跃会话的临时授权",不跨会话继承)。新会话从空白临时授权开始。 + drop(session); + state.clear_session_allowed_dirs().await; + let mut session = state.ai_session.lock().await; // 新会话建一份独立 per_conv(全新 state,与旧会话隔离)。 // 旧 conv 的 per_conv **不清除**(决策 e:后台 loop 继续跑),其 pending_approvals 也保留 // (switch 路径会 retain 清目标 conv 的,create 不动他人)。 @@ -140,6 +145,11 @@ pub async fn ai_conversation_switch( // **后台 conv(目标正在跑 loop)的 per_conv 已存在则跳过 reload**——防覆盖其内存 messages // (后台 loop 正在写自己的 per_conv.messages,reload 会用 DB 旧快照覆盖内存新消息)。 session.active_conversation_id = Some(conversation_id.clone()); + // F-260619-03 Phase B: 切换 active 会话 → 清空上一会话的临时授权目录(session 字段语义为 + // "当前活跃会话的临时授权",不跨会话继承)。 + drop(session); + state.clear_session_allowed_dirs().await; + let mut session = state.ai_session.lock().await; let already_live = session.conv_read(&conversation_id).map(|c| c.generating).unwrap_or(false); if !already_live { // 目标 conv 未在生成:从 DB reload messages 到其 per_conv(首次切入或上次切走后无后台 loop)。 @@ -203,7 +213,8 @@ pub async fn ai_conversation_delete( // F-260616-09 B 批4:删除 conv 时移除其 per_conv 条目(设计 §4.1 conv 存在性判据依赖此, // 旧 loop 检测 conv 不存在即退出)。per_conv 唯一真相源,删顶层 messages.clear 双写。 session.per_conv.remove(&conversation_id); - if session.active_conversation_id.as_deref() == Some(&conversation_id) { + let was_active = session.active_conversation_id.as_deref() == Some(&conversation_id); + if was_active { session.active_conversation_id = None; } // F-260616-09 B 批5:同时清理 LlmConcurrency 的 per_conv Semaphore 条目(防 HashMap 无限增长)。 @@ -213,6 +224,11 @@ pub async fn ai_conversation_delete( // 上述 retain 已清)。loop 正常收敛/达 MAX/stop 但 conv 未删时不清理(下次发消息复用,限流计数连续)。 drop(session); // 释放 AiSession 锁再取 LlmConcurrency 锁(避免潜在锁序问题) state.llm_concurrency.release_conv(&conversation_id).await; + // F-260619-03 Phase B: 删除的是当前活跃会话 → 清空其临时授权目录(session 字段语义为 + // "当前活跃会话的临时授权",active 变 None 时不再属于任何会话)。 + if was_active { + state.clear_session_allowed_dirs().await; + } Ok(()) } diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index 2ce1af1..b5d8d68 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -148,6 +148,19 @@ pub enum AiChatEvent { /// 每次重试前 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.path_auth 标记路径授权挂起), + /// 用户经 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 }, @@ -552,6 +565,18 @@ pub struct PendingApproval { /// recovered 审批(启动重建)无 diff(文件可能已变,重读无意义)。 #[allow(dead_code)] // IPC 活跃(useAiEvents:252+ToolCard·UX-260618-06)·cargo Rust never-read 误报 pub diff: Option, + /// F-260619-03 Phase B: 路径授权挂起标记(Some = 路径授权挂起,None = 普通 RiskLevel 审批)。 + /// 携带待授权目录(规范化父目录),ai_authorize_dir IPC 据此决定写入 session/persistent。 + pub path_auth: Option, +} + +/// F-260619-03 Phase B: 路径授权挂起请求(PendingApproval.path_auth 标记)。 +#[derive(Debug, Clone)] +pub struct PathAuthRequest { + /// 待授权目录(规范化父目录,对齐 session_trust 目录粒度)。 + pub dir: std::path::PathBuf, + /// 原始路径参数(read_file.path / write_file.path / rename_file.from+to / ...)。 + pub raw_paths: Vec, } /// 工具调用草稿(流式收集时的临时结构) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d50e0c1..722c000 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -167,6 +167,8 @@ pub fn run() { // F-260619-03 Phase A: AI 工具文件访问授权目录白名单 commands::ai::ai_get_allowed_dirs, commands::ai::ai_set_allowed_dirs, + // F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起) + commands::ai::ai_authorize_dir, // 审批历史面板(AE-2025-08:查 ai_tool_executions 表,敏感字段截断) commands::ai::audit::list_tool_executions, // 知识库 diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 868a8c6..399c48d 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -305,20 +305,26 @@ pub struct AppState { pub allowed_dirs: Arc>, } -/// F-260619-03 Phase A: AI 工具文件访问授权目录白名单 +/// F-260619-03 Phase A/B/C: AI 工具文件访问授权目录白名单 /// -/// Phase A 仅含 `persistent`(持久化白名单,从 Settings KV `allowed_dirs` 加载, -/// JSON 数组 `["E:/wk-lab/u-abc"]`)。`resolve_workspace_path` 校验时: -/// - workspace_root 始终视为已授权(向后兼容,默认根) -/// - 任一 persistent 目录 starts_with 命中即放行 -/// -/// Phase B 将扩展 `session: HashSet`(会话临时授权) + 弹窗挂起机制, -/// 届时 is_authorized 增加 session 校验。当前结构预留扩展位但不引入。 +/// - Phase A:`persistent`(持久化白名单,从 Settings KV `allowed_dirs` 加载, +/// JSON 数组 `["E:/wk-lab/u-abc"]`)。`resolve_workspace_path` 校验时: +/// - workspace_root 始终视为已授权(向后兼容,默认根) +/// - 任一 persistent 目录 starts_with 命中即放行 +/// - Phase B:`session`(进程级会话临时授权,弹窗"仅本次"写入;切换/新建/删除会话清空)。 +/// 单用户桌面应用 active_conversation_id 单全局模型,session 字段随 active 切换清空, +/// 行为等价"当前活跃会话的临时授权"。handler 闭包(read lock 取快照)与 +/// process_tool_calls 预校验均读此字段,两端一致。 +/// - Phase C:黑名单(is_authorized 内,黑名单优先于白名单 — 命中即拒)。 #[derive(Debug, Clone, Default)] pub struct AllowedDirs { /// 持久化授权目录(Settings KV `allowed_dirs`,JSON 字符串数组)。 /// 已规范化(canonicalize 失败回退原字面量),便于 starts_with 精确比对。 pub persistent: HashSet, + /// F-260619-03 Phase B: 会话级临时授权目录(弹窗"仅本次"写入,进程级内存)。 + /// 仅当前活跃会话生效,切换/新建/删除会话由 clear_session_allowed_dirs 清空(不落库)。 + /// handler 闭包与 process_tool_calls 预校验均读此字段,确保两端授权判定一致。 + pub session: HashSet, } impl AllowedDirs { @@ -330,22 +336,124 @@ impl AllowedDirs { pub fn default_with_root() -> Self { let mut set = HashSet::new(); set.insert(workspace_root_path()); - Self { persistent: set } + Self { persistent: set, session: HashSet::new() } } - /// 路径是否被授权:workspace_root 始终授权 + persistent 任一 starts_with 命中。 + /// 路径是否被授权:workspace_root 始终授权 + persistent 或 session 任一 starts_with 命中即放行。 + /// + /// **Phase C 黑名单优先**:即使白名单命中,若路径落入系统敏感目录(Windows + /// `\Windows\System32` / `\Program Files\`;Unix `/etc /usr /bin /sbin /boot + /// /dev /proc /sys`)仍拒。黑名单优先于白名单,防用户误授权系统目录。 /// /// 输入 `candidate` 应为 canonicalize 后的真实路径(防 symlink 逃逸); /// 调用方(resolve_workspace_path_with_allowed)负责 canonicalize,本函数只做 starts_with 比对。 pub fn is_authorized(&self, candidate: &Path) -> bool { + // Phase C: 黑名单优先(白名单命中也拒)。validate_path 已挡 .ssh/.aws 等, + // 此处补系统核心目录(用户可能误把 C:\ 加入 persistent,黑名单兜底拒 System32)。 + if is_in_system_blacklist(candidate) { + return false; + } let root = workspace_root_path(); if candidate.starts_with(&root) { return true; } self.persistent.iter().any(|d| candidate.starts_with(d)) + || self.session.iter().any(|d| candidate.starts_with(d)) } } +/// F-260619-03 Phase C: 路径授权决策(预校验用,process_tool_calls 分类前调)。 +/// +/// `check_path_authorization` 返回本枚举,process_tool_calls 据此决定: +/// - `Authorized`:路径已授权 → 走原 Low/Med/High 流程 +/// - `NeedsAuthorization`:路径未授权但非黑名单 → Phase B 挂起弹窗(emit AiDirAuthRequired) +/// - `Denied`:路径命中黑名单 → 硬拒(工具返 Err tool_result,不挂起) +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PathAuthDecision { + /// 已授权(workspace_root / persistent / session 命中且非黑名单) + Authorized, + /// 未授权且非黑名单:需 Phase B 弹窗询问用户。附带规范化后的待授权目录(父目录, + /// 对齐 session_trust 目录粒度),供"仅本次/未来都允许"写入白名单。 + NeedsAuthorization { dir: PathBuf }, + /// 命中系统黑名单(Phase C):硬拒,工具返 Err,不弹窗。 + Denied { reason: String }, +} + +/// F-260619-03 Phase C: 系统敏感目录黑名单判定(跨平台)。 +/// +/// 白名单命中但黑名单命中 → 拒(防用户误把整个盘符如 `C:\` 加入 persistent 致 +/// System32 被放行)。validate_path(tool_registry.rs)已挡 .ssh/.aws/AppData 等, +/// 此处补系统核心目录。 +/// +/// - Windows(不区分大小写,按路径分隔符分段):`\Windows\System32`、`\Program Files\`、 +/// `\Program Files (x86)\`、`\Windows\` 根下 System/SysWOW64 +/// - Unix:`/etc`、`/usr`、`/bin`、`/sbin`、`/boot`、`/dev`、`/proc`、`/sys` +pub(crate) fn is_in_system_blacklist(path: &Path) -> bool { + let s = path.to_string_lossy().to_lowercase(); + let seps = ['/', '\\']; + // Windows:按分隔符分段判定(避免 contains 误伤 "my program files backup" 这类目录名) + let segs: Vec<&str> = s.split(seps).filter(|s| !s.is_empty()).collect(); + for (i, seg) in segs.iter().enumerate() { + // Windows 系统目录:C:\Windows\System32 / C:\Windows\SysWOW64 / C:\Windows\System + if cfg!(windows) { + if *seg == "windows" { + if let Some(next) = segs.get(i + 1) { + if matches!(*next, "system32" | "syswow64" | "system") { + return true; + } + } + } + // C:\Program Files / C:\Program Files (x86) + if *seg == "program files" || *seg == "program files (x86)" { + return true; + } + } + // Unix 系统目录(路径首段为这些即拒;Windows 上也防 Unix 风格绝对路径,防御性) + if i == 0 && matches!(*seg, "etc" | "usr" | "bin" | "sbin" | "boot" | "dev" | "proc" | "sys") { + return true; + } + } + false +} + +/// F-260619-03 Phase B/C: 路径授权预校验(供 process_tool_calls 分类前调)。 +/// +/// 词法层判定(不 canonicalize,因路径可能不存在 — write_file 新建)。返回三态决策: +/// - 路径规范化(去 .. / 锚定 workspace_root)后,若命中黑名单 → `Denied` +/// - 否则若 `is_authorized(规范化路径)`(persistent + session) → `Authorized` +/// - 否则 → `NeedsAuthorization { dir: 父目录规范化 }`(目录粒度,对齐 session_trust) +/// +/// 注意:本函数只做词法层预判(防不存在路径兜底);实际执行时 handler 内 +/// resolve_workspace_path_with_allowed 仍做完整校验(canonicalize symlink 防逃逸)。 +/// 黑名单在两处都判(is_authorized 内 + 此处独立判),双保险。 +pub fn check_path_authorization( + raw_path: &str, + allowed: &AllowedDirs, +) -> PathAuthDecision { + // 规范化:绝对路径原样,相对路径锚定 workspace_root(与 resolve_workspace_path_impl 一致) + let resolved = if Path::new(raw_path).is_absolute() { + PathBuf::from(raw_path) + } else { + workspace_root_path().join(raw_path) + }; + // Phase C: 黑名单优先独立判定(is_authorized 内也判,此处先判便于 NeedsAuthorization + // 不误把黑名单路径推到弹窗 — 黑名单路径直接硬拒不让用户"授权")。 + if is_in_system_blacklist(&resolved) { + return PathAuthDecision::Denied { + reason: format!("路径命中系统敏感目录黑名单: {}", raw_path), + }; + } + if allowed.is_authorized(&resolved) { + return PathAuthDecision::Authorized; + } + // 未授权 → 取父目录作待授权目录(目录粒度,对齐 session_trust 的 dir 语义) + let dir = resolved + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| workspace_root_path()); + PathAuthDecision::NeedsAuthorization { dir } +} + /// workspace 根目录(src-tauri 上两级,与 tool_registry::workspace_root 同源)。 fn workspace_root_path() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -477,7 +585,11 @@ impl AppState { }); set.insert(normalized); } - *self.allowed_dirs.write().await = AllowedDirs { persistent: set }; + // F-260619-03 Phase B: reload 时保留当前会话临时授权(session 不落库,仅内存), + // 仅覆盖 persistent。用户改 Settings 不影响当前会话已临时授权的目录。 + let mut guard = self.allowed_dirs.write().await; + let preserved_session = std::mem::take(&mut guard.session); + *guard = AllowedDirs { persistent: set, session: preserved_session }; } /// F-260619-03 Phase A: 写 Settings KV + 同步内存白名单(供 Settings IPC 调用)。 @@ -520,6 +632,54 @@ impl AppState { out.sort(); out } + + /// F-260619-03 Phase B: 追加持久化授权目录(弹窗"未来都允许"选项)。 + /// + /// 读当前 persistent → 追加新目录(去重)→ set_allowed_dirs 持久化 + 同步内存。 + /// 返回持久化后规范化列表(含 workspace_root)。 + pub async fn add_persistent_allowed_dir(&self, dir: String) -> Result> { + let d = dir.trim().to_string(); + if d.is_empty() { + return Ok(self.get_allowed_dirs().await); + } + let mut current = self.get_allowed_dirs().await; + if !current.iter().any(|c| paths_eq(c, &d)) { + current.push(d); + } + self.set_allowed_dirs(current).await + } + + /// F-260619-03 Phase B: 追加会话级临时授权目录(弹窗"仅本次"选项)。 + /// + /// 写入 `allowed_dirs.session`(进程级内存,不落库)。handler 闭包 read lock 取快照时 + /// 与 process_tool_calls 预校验读同一字段,两端授权判定一致。规范化:canonicalize + /// 失败回退原字面量 trim(与 reload_allowed_dirs 一致)。 + pub async fn add_session_allowed_dir(&self, dir: String) { + let d = dir.trim(); + if d.is_empty() { + return; + } + let p = PathBuf::from(d); + let normalized = std::fs::canonicalize(&p).unwrap_or_else(|_| { + PathBuf::from(d.trim_end_matches(['/', '\\'])) + }); + self.allowed_dirs.write().await.session.insert(normalized); + } + + /// F-260619-03 Phase B: 清空会话级临时授权(切换/新建/删除活跃会话时调)。 + /// + /// 单用户桌面应用 active_conversation_id 单全局模型,session 字段语义为 + /// "当前活跃会话的临时授权",切走即清空(不跨会话继承临时授权)。 + pub async fn clear_session_allowed_dirs(&self) { + self.allowed_dirs.write().await.session.clear(); + } +} + +/// F-260619-03 Phase B: 路径字符串等价比较(canonicalize 后比对,失败回退小写比对)。 +fn paths_eq(a: &str, b: &str) -> bool { + let pa = std::fs::canonicalize(a).map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|_| a.to_string()); + let pb = std::fs::canonicalize(b).map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|_| b.to_string()); + pa.eq_ignore_ascii_case(&pb) } /// 构建节点注册表 — 注册内置节点 @@ -590,7 +750,7 @@ mod tests { let mut set = HashSet::new(); let custom = PathBuf::from("E:/wk-test-external-dir"); set.insert(custom.clone()); - let allowed = AllowedDirs { persistent: set }; + let allowed = AllowedDirs { persistent: set, session: HashSet::new() }; // 精确命中 + 子路径 starts_with 命中 assert!(allowed.is_authorized(&custom)); assert!(allowed.is_authorized(&custom.join("sub").join("file.txt"))); @@ -601,7 +761,7 @@ mod tests { fn test_allowed_dirs_unauthorized_rejected() { let mut set = HashSet::new(); set.insert(PathBuf::from("E:/wk-test-authorized")); - let allowed = AllowedDirs { persistent: set }; + let allowed = AllowedDirs { persistent: set, session: HashSet::new() }; let outside = PathBuf::from("E:/wk-test-unauthorized/file.txt"); assert!(!allowed.is_authorized(&outside), "未授权目录应被拒绝"); } @@ -620,5 +780,124 @@ mod tests { fn test_allowed_dirs_settings_key_stable() { assert_eq!(AllowedDirs::SETTINGS_KEY, "allowed_dirs"); } + + // ============================================================ + // F-260619-03 Phase B: session 临时授权语义测试 + // ============================================================ + + /// Phase B: session 命中放行(persistent 未命中但 session 命中) + #[test] + fn test_allowed_dirs_session_authorized() { + let mut session = HashSet::new(); + session.insert(PathBuf::from("E:/wk-temp-session")); + let allowed = AllowedDirs { persistent: HashSet::new(), session }; + assert!(allowed.is_authorized(&PathBuf::from("E:/wk-temp-session/file.txt"))); + } + + /// Phase B: persistent + session 任一命中即放行 + #[test] + fn test_allowed_dirs_persistent_or_session() { + let mut persistent = HashSet::new(); + persistent.insert(PathBuf::from("E:/wk-persist")); + let mut session = HashSet::new(); + session.insert(PathBuf::from("E:/wk-session")); + let allowed = AllowedDirs { persistent, session }; + assert!(allowed.is_authorized(&PathBuf::from("E:/wk-persist/a"))); + assert!(allowed.is_authorized(&PathBuf::from("E:/wk-session/b"))); + assert!(!allowed.is_authorized(&PathBuf::from("E:/wk-other/c"))); + } + + // ============================================================ + // F-260619-03 Phase C: 系统目录黑名单测试 + // ============================================================ + + /// Phase C: Windows System32 黑名单命中(即使在白名单内也拒) + #[test] + fn test_blacklist_windows_system32() { + let mut persistent = HashSet::new(); + // 用户误把整个 C:\ 加入白名单 + persistent.insert(PathBuf::from("C:\\")); + let allowed = AllowedDirs { persistent, session: HashSet::new() }; + // System32 应被黑名单拒(尽管 C:\ 在白名单) + assert!(!allowed.is_authorized(&PathBuf::from("C:\\Windows\\System32\\config\\sam"))); + } + + /// Phase C: Windows Program Files 黑名单命中 + #[test] + fn test_blacklist_windows_program_files() { + let mut persistent = HashSet::new(); + persistent.insert(PathBuf::from("C:\\")); + let allowed = AllowedDirs { persistent, session: HashSet::new() }; + assert!(!allowed.is_authorized(&PathBuf::from("C:\\Program Files\\SomeApp\\app.exe"))); + } + + /// Phase C: 黑名单不误伤合法目录(含 "program files" 子串的自定义目录名) + #[test] + fn test_blacklist_no_false_positive() { + // "my program files backup" 不应被拒(分段匹配,非精确段) + assert!(!is_in_system_blacklist(&PathBuf::from("E:/my program files backup/x"))); + // 普通工作目录不拒 + assert!(!is_in_system_blacklist(&PathBuf::from("E:/wk-lab/devflow/src"))); + } + + /// Phase C: Unix 系统目录黑名单(/etc /usr /bin 等) + #[test] + fn test_blacklist_unix_system_dirs() { + assert!(is_in_system_blacklist(&PathBuf::from("/etc/passwd"))); + assert!(is_in_system_blacklist(&PathBuf::from("/usr/bin/python"))); + assert!(is_in_system_blacklist(&PathBuf::from("/proc/self/environ"))); + assert!(is_in_system_blacklist(&PathBuf::from("/sys/kernel"))); + // 普通用户目录不拒 + assert!(!is_in_system_blacklist(&PathBuf::from("/home/user/project"))); + } + + // ============================================================ + // F-260619-03 Phase B/C: check_path_authorization 三态决策测试 + // ============================================================ + + /// Phase B/C: workspace_root 路径 → Authorized + #[test] + fn test_check_path_authorized_workspace() { + let allowed = AllowedDirs::default_with_root(); + let rel = "src/main.rs"; + match check_path_authorization(rel, &allowed) { + PathAuthDecision::Authorized => {} + other => panic!("workspace_root 内路径应 Authorized, got {:?}", other), + } + } + + /// Phase B/C: 未授权路径 → NeedsAuthorization(附父目录) + #[test] + fn test_check_path_needs_auth() { + let allowed = AllowedDirs::default(); // 空,无 workspace_root + match check_path_authorization("E:/wk-external/file.txt", &allowed) { + PathAuthDecision::NeedsAuthorization { dir } => { + assert_eq!(dir, PathBuf::from("E:/wk-external")); + } + other => panic!("未授权路径应 NeedsAuthorization, got {:?}", other), + } + } + + /// Phase B: session 命中 → Authorized(check_path_authorization 读 session 字段) + #[test] + fn test_check_path_session_hit() { + let mut session = HashSet::new(); + session.insert(PathBuf::from("E:/wk-session")); + let allowed = AllowedDirs { persistent: HashSet::new(), session }; + match check_path_authorization("E:/wk-session/sub/file.txt", &allowed) { + PathAuthDecision::Authorized => {} + other => panic!("session 命中应 Authorized, got {:?}", other), + } + } + + /// Phase C: 黑名单路径 → Denied(不弹窗直接拒) + #[test] + fn test_check_path_denied_blacklist() { + let allowed = AllowedDirs::default_with_root(); + match check_path_authorization("C:/Windows/System32/config/sam", &allowed) { + PathAuthDecision::Denied { .. } => {} + other => panic!("System32 应 Denied, got {:?}", other), + } + } } diff --git a/src/api/ai.ts b/src/api/ai.ts index c0b64b7..485b222 100644 --- a/src/api/ai.ts +++ b/src/api/ai.ts @@ -68,6 +68,15 @@ export const aiApi = { return invoke('ai_approve', { toolCallId, approved }) }, + /** + * F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)。 + * decision: 'once'(仅本次,会话级)/ 'always'(未来都允许,持久化)/ 'deny'(拒绝)。 + * 后端 remove pending → 写授权目录(session/persistent)→ 执行工具 → try_continue 恢复 loop。 + */ + authorizeDir(toolCallId: string, decision: 'once' | 'always' | 'deny'): Promise { + return invoke('ai_authorize_dir', { toolCallId, decision }) + }, + /** * 续跑 agentic 循环(F-260616-03:达 max_iterations 暂停态点继续)。 * 后端 ai_continue_loop:复位 stop_flag → try_continue_agent_loop 重新 spawn diff --git a/src/api/types.ts b/src/api/types.ts index c47c3c0..281182f 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -293,6 +293,10 @@ export type AiChatEvent = ({ } | { // F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问继续/停止 type: 'AiMaxRoundsReached' +} | { + // F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,后端挂起 loop 等用户决定)。 + // 前端弹窗三选项:"仅本次"(session)/"未来都允许"(persistent)/"拒绝" → ai_authorize_dir IPC。 + type: 'AiDirAuthRequired'; id: string; tool: string; path: string; dir: string } | { // F-260616-07: 流式调用自动重试提示(前端可在错误气泡内显示「重试 n/m」) type: 'AiStreamRetry'; attempt: number; max_attempts: number diff --git a/src/components/AiChat.vue b/src/components/AiChat.vue index db0e682..cbb4f88 100644 --- a/src/components/AiChat.vue +++ b/src/components/AiChat.vue @@ -58,6 +58,8 @@ + +
@@ -131,6 +133,7 @@ import ChatInput from './ai/ChatInput.vue' import TopBar from './ai/TopBar.vue' import MessageList from './ai/MessageList.vue' import MaxRoundsCard from './ai/MaxRoundsCard.vue' +import DirAuthDialog from './ai/DirAuthDialog.vue' import { useConfirm } from '../composables/useConfirm' import { useProjectStore } from '../stores/project' import type { AiMessage } from '../api/types' diff --git a/src/components/ai/DirAuthDialog.vue b/src/components/ai/DirAuthDialog.vue new file mode 100644 index 0000000..61c5a7d --- /dev/null +++ b/src/components/ai/DirAuthDialog.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/src/composables/ai/useAiEvents.ts b/src/composables/ai/useAiEvents.ts index 717530a..33bc068 100644 --- a/src/composables/ai/useAiEvents.ts +++ b/src/composables/ai/useAiEvents.ts @@ -43,7 +43,8 @@ const appSettings = useAppSettingsStore() // B-260616-17: 看门狗不重置的事件集合(审批等待/完成/错误由各自 case 内 clear)。 // 模块级 Set 复用,避免 handleEvent 每事件(delta/token 高频)新建数组字面量做 includes。 // F-260616-03: AiMaxRoundsReached 加入——达 max 暂停态等用户决定继续/停止,不计整流超时。 -const NO_RESET_WATCHDOG = new Set(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached']) +// F-260619-03 Phase B: AiDirAuthRequired 加入——路径授权挂起等用户决定,不计整流超时。 +const NO_RESET_WATCHDOG = new Set(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiDirAuthRequired']) // B-260616-12: 工具执行超时提示(纯前端降级,后端无工具级取消 IPC)。 // 每个 running 工具一个独立 setTimeout;到时若仍未收到 Completed/Approval, @@ -97,6 +98,20 @@ function clearAllToolSlowTimers(): void { // 故用 boolean ref 而非数组,语义更精确。 export const pendingMaxRounds = ref(false) +// F-260619-03 Phase B: 路径授权弹窗挂起态(后端 AiDirAuthRequired 事件置,用户决策后清)。 +// +// 同 pendingMaxRounds 模式:模块级 ref(不进 store.state),AiChat.vue 的 DirAuthDialog 组件 +// 直接 import 读。一次只追踪一个挂起询问(后端单 tool_call_id 挂起,用户决策后 try_continue), +// 故用对象 ref 而非数组,语义更精确。null = 无挂起。 +export interface PendingDirAuth { + id: string + tool: string + path: string + dir: string + conversationId?: string +} +export const pendingDirAuth = ref(null) + /** 通知会话列表发生变化(供 newConversation/deleteConversation/rename/archive 等触发刷新侧栏) */ export function notifyConversationChanged() { emit('ai-conversation-changed', {}) @@ -198,6 +213,31 @@ function handleStreamingEvent(event: AiChatEvent): boolean { pendingMaxRounds.value = true return true + case 'AiDirAuthRequired': { + // F-260619-03 Phase B: 路径授权挂起(后端 generating 保持 true,等用户决策)。 + // 置 pendingDirAuth 驱动 DirAuthDialog 弹窗;看门狗已由 NO_RESET_WATCHDOG 跳过 reset。 + // 同时把工具卡置 pending_approval 态(与 AiApprovalRequired 一致,复用 ToolCard 渲染)。 + clearStreamWatchdog() + const info: AiToolCallInfo = { + id: event.id, + name: event.tool, + args: { path: event.path }, + status: 'pending_approval', + } + state.pendingApprovals.push(info) + const tc = findToolCall(event.id) + if (tc) tc.status = 'pending_approval' + clearToolSlowTimer(event.id) + pendingDirAuth.value = { + id: event.id, + tool: event.tool, + path: event.path, + dir: event.dir, + conversationId: event.conversation_id, + } + return true + } + default: return false } @@ -289,6 +329,11 @@ function handleToolEvent(event: AiChatEvent): boolean { // B-260616-12: 审批通过→工具重新进入执行态,重启慢执行计时器 startToolSlowTimer(event.id, findToolCall(event.id)?.name || '') } + // F-260619-03 Phase B: 路径授权挂起的工具收到 ApprovalResult(once/always 通过 / deny 拒绝) + // → 清弹窗(后端 ai_authorize_dir 已 try_continue 续 loop)。 + if (pendingDirAuth.value && pendingDirAuth.value.id === event.id) { + pendingDirAuth.value = null + } return true } @@ -309,6 +354,7 @@ function handleLifecycleEvent(event: AiChatEvent): boolean { state.generatingConvId = null state.agentRound = 0 // AE-2025-07: agentic 结束,复位轮次(隐藏进度条) pendingMaxRounds.value = false // F-260616-03: 收尾(停止/续跑后新一轮达 max 才会再 set),清操作卡 + pendingDirAuth.value = null // F-260619-03 Phase B: 收尾清路径授权弹窗(防残留) // UX-2025-04 / CR-30-2 / 决策 a1: 流中途失败保文——后端 emit AiCompleted(incomplete=true), // 前端追加系统提示气泡(镜像后端 session.messages 的 system 提示)。 // 注:此系统提示仅前端展示,后端已独立 push 到 session.messages 落库。 @@ -358,6 +404,7 @@ function handleLifecycleEvent(event: AiChatEvent): boolean { // 残留可点击审批按钮会让用户误以为还能批(实际后端已终止),残留审批卡误导操作。 state.pendingApprovals = [] pendingMaxRounds.value = false // F-260616-03: 异常中断,清操作卡 + pendingDirAuth.value = null // F-260619-03 Phase B: 异常中断清路径授权弹窗 localStorage.removeItem('df-ai-gen') localStorage.removeItem('df-ai-text') // UX-03: 错误消息携带 error_type(供错误气泡差异化显隐「去设置」按钮)。 diff --git a/src/i18n/en/aiChat.ts b/src/i18n/en/aiChat.ts index 811c327..c9e554e 100644 --- a/src/i18n/en/aiChat.ts +++ b/src/i18n/en/aiChat.ts @@ -145,6 +145,14 @@ export default { continueLoopFailed: 'Continue failed: {msg}', stopLoopFailed: 'Stop failed: {msg}', + // ── F-260619-03 Phase B: path authorization dialog (LLM accessing dir outside whitelist) ── + dirAuthRequired: 'Path authorization required', + dirAuthHint: '{tool} wants to access: {path}', + dirAuthOnce: 'This time', + dirAuthAlways: 'Always allow', + dirAuthDeny: 'Deny', + dirAuthFailed: 'Authorization failed: {msg}', + // ── UX-18: conversation export (sidebar hover, pick format to download) ── exportConversation: 'Export chat', exportMarkdown: 'Markdown', diff --git a/src/i18n/zh-CN/aiChat.ts b/src/i18n/zh-CN/aiChat.ts index 802a648..8ee2944 100644 --- a/src/i18n/zh-CN/aiChat.ts +++ b/src/i18n/zh-CN/aiChat.ts @@ -146,6 +146,14 @@ export default { continueLoopFailed: '继续失败:{msg}', stopLoopFailed: '停止失败:{msg}', + // ── F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起) ── + dirAuthRequired: '需要路径授权', + dirAuthHint: '{tool} 想访问:{path}', + dirAuthOnce: '仅本次', + dirAuthAlways: '未来都允许', + dirAuthDeny: '拒绝', + dirAuthFailed: '授权失败:{msg}', + // ── UX-18:对话导出(侧栏 hover 浮出,选格式下载) ── exportConversation: '导出对话', exportMarkdown: 'Markdown',