From e4c2e310a5e25ad387ec8c648bcb4913229c774e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sat, 20 Jun 2026 04:08:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20idea=20source=20=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E7=BA=A7=E6=BA=AF=E6=BA=90=20+=20workspace=5Froot=20?= =?UTF-8?q?=E5=8E=BB=E5=9B=BA=E5=AE=9A=E6=A0=B9=20+=20run=5Fcommand=20?= =?UTF-8?q?=E5=81=8F=E5=A5=BD=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit idea source 结构化(消息级溯源闭环 — 灵感来源 conv_msg:{id}): - 新建 idea_source.rs(IDEA_SOURCE_AUTO_FILL_ENABLED + maybe_fill_idea_source 保守五条件: 开关/create_idea/source 空(不覆盖 AI 填)/message_id 非 None/result.id) - audit/mod low_risk + chat ai_approve 双执行路径接补全(create_idea Medium 审批) workspace_root 去固定根(F-260619-03 收尾): - is_authorized 删 workspace_root 固定放行(代码硬编码),改走 persistent/session 白名单 - default_with_root 保留(默认初始 persistent,首次免授权行为不变) - 用户从白名单删工程根后也需授权(动态白名单完整语义) run_command 偏好修复(db 分析:591ff4a0 run_command 31/56=55% 严重偏好): - P1 描述收紧(明确仅命令执行 + 负向引导:读取 read_file/编辑 patch_file/列目录 list_directory/搜索 search_files) - P2 ToolDomain::Exec(run_command 独立 domain,仅 Debug 含 Exec,Code/File/Search 不含) - P3 注册顺序(run_command 放 File 层最后)+ DEBUG_RULES 加命令执行关键词 自验: df-ai 228(intent Exec)+ devflow 169(idea source)+ workspace EXIT 0 --- crates/df-ai/src/intent.rs | 58 +++- .../src/commands/ai/audit/idea_source.rs | 328 ++++++++++++++++++ src-tauri/src/commands/ai/audit/mod.rs | 22 +- src-tauri/src/commands/ai/commands/chat.rs | 22 ++ src-tauri/src/commands/ai/tool_registry.rs | 146 ++++---- src-tauri/src/state.rs | 10 +- 6 files changed, 496 insertions(+), 90 deletions(-) create mode 100644 src-tauri/src/commands/ai/audit/idea_source.rs diff --git a/crates/df-ai/src/intent.rs b/crates/df-ai/src/intent.rs index d702fe3..ab20b43 100644 --- a/crates/df-ai/src/intent.rs +++ b/crates/df-ai/src/intent.rs @@ -119,6 +119,13 @@ const DEBUG_RULES: &[Rule] = &[ Rule { kw: "栈", weight: 0.5 }, Rule { kw: "panic", weight: 0.9 }, Rule { kw: "traceback", weight: 0.9 }, + // 命令执行场景关键词(run_command 入口引导):用户明确"运行/执行/构建/测试/跑" + // 才该走 Exec domain(run_command)。中等权重(0.7)避免误抢 Code 命中(编译/重构 1.0)。 + Rule { kw: "运行", weight: 0.7 }, + Rule { kw: "执行", weight: 0.7 }, + Rule { kw: "构建", weight: 0.7 }, + Rule { kw: "测试", weight: 0.7 }, + Rule { kw: "跑", weight: 0.6 }, ]; const FILE_RULES: &[Rule] = &[ @@ -225,8 +232,10 @@ const GENERIC_GROUP: &[IntentGroup] = &[ pub enum ToolDomain { /// 数据/业务:项目/任务/灵感/工作流/回收站 Data, - /// 文件:读写/patch/列目录/搜索/命令 + /// 文件:读写/patch/列目录/搜索(不含命令执行) File, + /// 命令执行:run_command(shell 命令,独立 domain 防止被泛 File 意图带出) + Exec, /// 网络:HTTP Http, } @@ -261,12 +270,15 @@ impl ToolDomain { "patch_file", "list_directory", "search_files", - "run_command", "file_info", "append_file", "delete_file", "rename_file", ], + // Exec domain 独立:run_command 从 File 移出。 + // 意图侧仅 Debug 默认带 Exec(用户明确"运行/测试/构建"), + // Code/File/Search 不带 → 减少 LLM 对 run_command 的偏好暴露。 + ToolDomain::Exec => &["run_command"], ToolDomain::Http => &["http_request"], } } @@ -337,11 +349,13 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)> /// /// 设计:Code → [file, http];File → [file];Project/Task/Idea → [data]; /// Http → [http];Search → [file](含 search_files);Conversation → []; -/// Chat → [];Debug → [file, http, data](调试常需读文件+查 API+查任务/工作流状态,CR-25 审查🟡-1 加 data 防"调试任务"丢 Data 工具);Unknown → [](全量)。 +/// Chat → [];Debug → [file, exec, http, data](调试常需跑命令+读文件+查 API+查任务/工作流状态,CR-25 审查🟡-1 加 data 防"调试任务"丢 Data 工具); +/// **仅 Debug 含 Exec**(用户明确"运行/测试/构建/调试"才暴露 run_command), +/// Code/File/Search 不含 Exec → 收紧 run_command 暴露面;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, ToolDomain::Data], + Intent::Debug => &[ToolDomain::File, ToolDomain::Exec, ToolDomain::Http, ToolDomain::Data], Intent::File => &[ToolDomain::File], Intent::Project => &[ToolDomain::Data], Intent::Task => &[ToolDomain::Data], @@ -669,7 +683,9 @@ mod tests { fn tool_domain_names_match_registry() { // 抽样核对与 src-tauri tool_registry 实际注册名一致 assert!(ToolDomain::File.tools().contains(&"patch_file")); - assert!(ToolDomain::File.tools().contains(&"run_command")); + // run_command 已从 File 移出 Exec domain(意图收紧:仅 Debug 默认带 run_command) + assert!(!ToolDomain::File.tools().contains(&"run_command")); + assert_eq!(ToolDomain::Exec.tools(), &["run_command"]); assert!(ToolDomain::Data.tools().contains(&"advance_task")); assert!(ToolDomain::Data.tools().contains(&"run_workflow")); assert!(ToolDomain::Data.tools().contains(&"list_trash")); @@ -838,9 +854,9 @@ mod tests { } #[test] - fn filter_debug_subset_spans_three_domains_complete() { - // 对抗(多 domain):Debug subset 跨 File + Http + Data 三 domain, - // 必须同条消息里能同时命中三个 domain 的工具(防 domain 漏挂) + fn filter_debug_subset_spans_four_domains_complete() { + // 对抗(多 domain):Debug subset 跨 File + Exec + Http + Data 四 domain, + // 必须同条消息里能同时命中四个 domain 的工具(防 domain 漏挂) let all = vec![ // Data domain tool_def("list_tasks"), @@ -849,6 +865,8 @@ mod tests { // File domain tool_def("read_file"), tool_def("patch_file"), + // Exec domain + tool_def("run_command"), // Http domain tool_def("http_request"), ]; @@ -856,8 +874,27 @@ mod tests { let names: Vec = out.iter().map(|d| d.function.name.clone()).collect(); assert!(names.contains(&"list_tasks".to_string()), "Debug 必含 Data(list_tasks), 防 CR-25 🟡-1 丢 Data"); assert!(names.contains(&"read_file".to_string()), "Debug 必含 File"); + assert!(names.contains(&"run_command".to_string()), "Debug 必含 Exec(run_command)"); assert!(names.contains(&"http_request".to_string()), "Debug 必含 Http"); - assert_eq!(names.len(), 6, "三 domain 工具全保留, 实际: {:?}", names); + assert_eq!(names.len(), 7, "四 domain 工具全保留, 实际: {:?}", names); + } + + #[test] + fn run_command_only_exposed_for_debug_intent() { + // 核心修复断言:run_command 仅 Debug 暴露。 + // Code/File/Search 意图 subset 不含 Exec domain → 不含 run_command。 + for intent in [Intent::Code, Intent::File, Intent::Search] { + let s = tool_subset_for(&intent); + assert!( + !s.contains(&"run_command"), + "Intent {:?} subset 不应含 run_command(仅 Debug 暴露), 实际: {:?}", + intent, + s + ); + } + // Debug 必含 run_command(Exec domain) + let dbg = tool_subset_for(&Intent::Debug); + assert!(dbg.contains(&"run_command"), "Debug 必含 run_command"); } #[test] @@ -893,11 +930,12 @@ mod tests { ]; for intent in all_intents { let subset = tool_subset_for(&intent); - // 全 registry 工具名(三 domain 并集) + // 全 registry 工具名(四 domain 并集:Data + File + Exec + Http) let registry: std::collections::HashSet<&str> = ToolDomain::Data .tools() .iter() .chain(ToolDomain::File.tools().iter()) + .chain(ToolDomain::Exec.tools().iter()) .chain(ToolDomain::Http.tools().iter()) .copied() .collect(); diff --git a/src-tauri/src/commands/ai/audit/idea_source.rs b/src-tauri/src/commands/ai/audit/idea_source.rs new file mode 100644 index 0000000..f5d9e55 --- /dev/null +++ b/src-tauri/src/commands/ai/audit/idea_source.rs @@ -0,0 +1,328 @@ +//! F-260619-04 P2(方案 B): 灵感来源(create_idea)消息级溯源补全。 +//! +//! ## 背景 +//! P1 把知识/审计的 `source_ref` 升级到消息级(`conv_msg:{id}`),但**灵感 `create_idea` +//! 漏了**:create_idea handler 是 `Fn(Value)→result` 无 message_id 上下文,source 由 AI +//! 自由填(`args["source"]`)。AI 多数场景不填(空),致灵感丢失溯源——查不到从哪条对话长出来。 +//! +//! ## 方案 B(最小,低侵入 — 不改 AiToolHandler 接口) +//! 工具执行后(process_tool_calls / 审批执行路径)拿到 handler 返回的 result + AI 入参 args, +//! 检测 `tool_name == "create_idea"`: +//! - 仅当 AI 未填 source(空 / null / 缺字段)且 message_id 可得 → 补 `source = conv_msg:{message_id}` +//! - AI 已填(非空)→ **不覆盖**(保留 AI 有意义的自由文本,如"用户口述"/"xxx 文档") +//! - message_id 缺失(None,异常路径/老数据)→ **不补**(无源可溯,保持原值,不伪造) +//! +//! ## 保守原则 +//! 仅 create_idea 专用(其他工具不碰) / 仅 source 空时补(不覆盖 AI 填) / 无 message_id 不补。 +//! 常量开关 `IDEA_SOURCE_AUTO_FILL_ENABLED` 可一键关闭(易迭代,出问题降级)。 + +use std::sync::Arc; + +use df_storage::crud::IdeaRepo; +use df_storage::db::Database; + +/// 一键开关:是否启用 create_idea source 自动补全。出问题改 false 即降级为 noop(不补)。 +const IDEA_SOURCE_AUTO_FILL_ENABLED: bool = true; + +/// 从 AI 入参 args 取 source,返回是否「AI 未填」(空 / null / 缺字段)。 +/// +/// 独立纯函数(便于单测):判定逻辑收敛一处,调用方与测试共享同一语义。 +/// - `{"source": null}` / `{"source": ""}` / 缺 source 键 → true(未填,应补) +/// - `{"source": "用户口述"}` → false(AI 已填,不覆盖) +fn is_source_unfilled(args: &serde_json::Value) -> bool { + match args.get("source") { + None => true, + Some(serde_json::Value::Null) => true, + Some(serde_json::Value::String(s)) if s.trim().is_empty() => true, + _ => false, + } +} + +/// 从 create_idea handler 返回的 result 取 idea_id(定位刚创建的灵感行)。 +/// +/// handler 返回 `{ "id": "...", "title": "...", "status": "draft" }`(tool_registry.rs create_idea)。 +/// id 字段即 idea 表主键,用于 `IdeaRepo.update_field(id, "source", ...)`。 +fn extract_idea_id(result: &serde_json::Value) -> Option { + result.get("id").and_then(|v| v.as_str()).map(|s| s.to_string()) +} + +/// 灵感来源消息级溯源补全(方案 B 核心)。 +/// +/// **保守**:仅在以下条件全部满足时补 source = `conv_msg:{message_id}`: +/// 1. `IDEA_SOURCE_AUTO_FILL_ENABLED == true`(一键开关) +/// 2. `tool_name == "create_idea"`(专用,其他工具不碰) +/// 3. `args.source` 未填(空 / null / 缺字段)—— AI 已填则**不覆盖** +/// 4. `message_id` 为 `Some`(无源可溯的异常/老数据不补,不伪造) +/// 5. result 含 `id`(idea_id,定位刚创建的灵感行) +/// +/// 补全失败(库写失败等)仅 `tracing::warn` 不传播——溯源补全是 best-effort 增益, +/// 不应阻断工具已成功的执行流程(灵感已创建,source 缺失只是少了溯源,非业务失败)。 +/// +/// 单点逻辑(本函数)+ 多调用点(process_tool_calls 各执行路径 + 审批执行 chat.rs): +/// 调用点只管传 (tool_name, args, result, message_id),判定/补全/容错全在本函数。 +pub async fn maybe_fill_idea_source( + db: &Arc, + tool_name: &str, + args: &serde_json::Value, + result: &serde_json::Value, + message_id: Option<&str>, +) { + // 条件 1: 一键开关 + if !IDEA_SOURCE_AUTO_FILL_ENABLED { + return; + } + // 条件 2: 仅 create_idea 专用 + if tool_name != "create_idea" { + return; + } + // 条件 3: AI 未填 source 才补(AI 已填不覆盖) + if !is_source_unfilled(args) { + return; + } + // 条件 4: message_id 缺失不补(无源可溯,不伪造) + let msg_id = match message_id { + Some(id) if !id.is_empty() => id, + _ => return, + }; + // 条件 5: result 含 idea_id(定位刚创建的灵感行) + let idea_id = match extract_idea_id(result) { + Some(id) => id, + None => { + tracing::warn!( + tool = tool_name, + "[idea-source] create_idea result 缺 id 字段,无法定位灵感行补 source(降级跳过)" + ); + return; + } + }; + + let source_val = format!("conv_msg:{}", msg_id); + let repo = IdeaRepo::new(db); + match repo.update_field(&idea_id, "source", &source_val).await { + Ok(true) => { + tracing::info!( + idea_id = %idea_id, + message_id = %msg_id, + "[idea-source] 补全灵感来源消息级溯源: source = conv_msg:{}", + msg_id + ); + } + Ok(false) => { + // update_field 返 false = 影响行数 0(idea_id 不存在,异常,但灵感刚创建理论上必存在) + tracing::warn!( + idea_id = %idea_id, + "[idea-source] update_field 影响 0 行(idea_id 不存在?);source 未补,灵感已创建不受影响" + ); + } + Err(e) => { + // best-effort:溯源补全失败不阻断工具成功流程,仅 warn + tracing::warn!( + idea_id = %idea_id, + error = %e, + "[idea-source] 补全灵感来源失败(降级跳过,灵感已创建不受影响)" + ); + } + } +} + +// ============================================================ +// 单元测试 — 判定纯函数 + 补全全链路(内存 DB) +// ============================================================ + +// ============================================================ +// 测试辅助(仅 cfg(test))— 内存 DB + 插入 IdeaRecord fixture +// ============================================================ +// 独立 #[cfg(test)] 子模块:供本文件 tests 与同 crate 其他测试复用(防 DRY)。 +// 不暴露到非测试构建(避免误用内存 DB 做 prod 路径)。 + +#[cfg(test)] +mod idea_source_test_helpers { + use std::sync::Arc; + + use df_storage::crud::IdeaRepo; + use df_storage::db::Database; + use df_storage::models::IdeaRecord; + + /// 建内存 DB(已跑 migrations,含 ideas 表)+ 插一条 IdeaRecord fixture,返回 db 句柄。 + /// + /// `source`: None → 插 source=NULL(模拟 AI 未填);Some(s) → 插已填值。 + /// 其余字段用合理默认(title/description/status=draft/priority=1)。 + pub async fn idea_source_fixture_record(id: &str, source: Option<&str>) -> Arc { + let db = Database::open_in_memory().await.expect("open_in_memory"); + let repo = IdeaRepo::new(&db); + let now = df_types::now_millis().to_string(); + repo.insert(IdeaRecord { + id: id.to_string(), + title: format!("fixture-{}", id), + description: String::new(), + status: "draft".to_string(), + priority: 1, + score: None, + tags: None, + source: source.map(|s| s.to_string()), + promoted_to: None, + ai_analysis: None, + scores: None, + created_at: now.clone(), + updated_at: now, + }) + .await + .expect("insert fixture idea"); + Arc::new(db) + } +} + +#[cfg(test)] +mod tests { + use super::idea_source_test_helpers::idea_source_fixture_record; + use super::*; + + // ---------- is_source_unfilled 纯函数 ---------- + + #[test] + fn source_missing_key_is_unfilled() { + let args = serde_json::json!({ "title": "t" }); + assert!(is_source_unfilled(&args)); + } + + #[test] + fn source_null_is_unfilled() { + let args = serde_json::json!({ "title": "t", "source": null }); + assert!(is_source_unfilled(&args)); + } + + #[test] + fn source_empty_string_is_unfilled() { + let args = serde_json::json!({ "title": "t", "source": "" }); + assert!(is_source_unfilled(&args)); + } + + #[test] + fn source_whitespace_only_is_unfilled() { + let args = serde_json::json!({ "title": "t", "source": " " }); + assert!(is_source_unfilled(&args)); + } + + #[test] + fn source_non_empty_is_filled() { + let args = serde_json::json!({ "title": "t", "source": "用户口述" }); + assert!(!is_source_unfilled(&args)); + } + + // ---------- extract_idea_id ---------- + + #[test] + fn extract_idea_id_from_result() { + let result = serde_json::json!({ "id": "idea-1", "title": "t", "status": "draft" }); + assert_eq!(extract_idea_id(&result).as_deref(), Some("idea-1")); + } + + #[test] + fn extract_idea_id_missing_returns_none() { + let result = serde_json::json!({ "title": "t" }); + assert!(extract_idea_id(&result).is_none()); + } + + // ---------- maybe_fill_idea_source 全链路(内存 DB)---------- + + /// create_idea + source 空 + message_id 有 → 补 conv_msg:{id} + #[tokio::test] + async fn fills_source_when_unfilled_and_message_id_present() { + let db = idea_source_fixture_record("idea-1", None).await; + let args = serde_json::json!({ "title": "t" }); // source 缺 + let result = serde_json::json!({ "id": "idea-1", "title": "t", "status": "draft" }); + + maybe_fill_idea_source(&db, "create_idea", &args, &result, Some("msg-42")).await; + + let repo = IdeaRepo::new(&db); + let rec = repo.get_by_id("idea-1").await.unwrap().expect("idea-1 存在"); + assert_eq!(rec.source.as_deref(), Some("conv_msg:msg-42")); + } + + /// create_idea + AI 已填 source → 不覆盖 + #[tokio::test] + async fn does_not_overwrite_when_ai_filled_source() { + let db = idea_source_fixture_record("idea-2", Some("用户口述")).await; + let args = serde_json::json!({ "title": "t", "source": "用户口述" }); + let result = serde_json::json!({ "id": "idea-2", "title": "t", "status": "draft" }); + + maybe_fill_idea_source(&db, "create_idea", &args, &result, Some("msg-42")).await; + + let repo = IdeaRepo::new(&db); + let rec = repo.get_by_id("idea-2").await.unwrap().expect("idea-2 存在"); + assert_eq!(rec.source.as_deref(), Some("用户口述"), "AI 已填 source 不被覆盖"); + } + + /// message_id 缺失(None)→ 不补 + #[tokio::test] + async fn does_not_fill_when_message_id_none() { + let db = idea_source_fixture_record("idea-3", None).await; + let args = serde_json::json!({ "title": "t" }); + let result = serde_json::json!({ "id": "idea-3", "title": "t", "status": "draft" }); + + maybe_fill_idea_source(&db, "create_idea", &args, &result, None).await; + + let repo = IdeaRepo::new(&db); + let rec = repo.get_by_id("idea-3").await.unwrap().expect("idea-3 存在"); + assert!(rec.source.is_none(), "无 message_id 不补 source"); + } + + /// message_id 空串("")→ 不补(等同 None 语义,防伪造) + #[tokio::test] + async fn does_not_fill_when_message_id_empty_string() { + let db = idea_source_fixture_record("idea-3b", None).await; + let args = serde_json::json!({ "title": "t" }); + let result = serde_json::json!({ "id": "idea-3b", "title": "t", "status": "draft" }); + + maybe_fill_idea_source(&db, "create_idea", &args, &result, Some("")).await; + + let repo = IdeaRepo::new(&db); + let rec = repo.get_by_id("idea-3b").await.unwrap().expect("idea-3b 存在"); + assert!(rec.source.is_none(), "空串 message_id 不补 source"); + } + + /// 非 create_idea 工具 → 不碰(其他工具不受影响) + #[tokio::test] + async fn does_not_touch_other_tools() { + let db = idea_source_fixture_record("idea-4", None).await; + let args = serde_json::json!({ "title": "t" }); + let result = serde_json::json!({ "id": "idea-4" }); + + maybe_fill_idea_source(&db, "write_file", &args, &result, Some("msg-42")).await; + + let repo = IdeaRepo::new(&db); + let rec = repo.get_by_id("idea-4").await.unwrap().expect("idea-4 存在"); + assert!(rec.source.is_none(), "非 create_idea 不碰 source"); + } + + /// result 缺 id 字段 → 不补(降级 warn,不 panic) + #[tokio::test] + async fn does_not_fill_when_result_missing_id() { + let db = idea_source_fixture_record("idea-5", None).await; + let args = serde_json::json!({ "title": "t" }); + let result = serde_json::json!({ "title": "t", "status": "draft" }); // 无 id + + maybe_fill_idea_source(&db, "create_idea", &args, &result, Some("msg-42")).await; + + let repo = IdeaRepo::new(&db); + let rec = repo.get_by_id("idea-5").await.unwrap().expect("idea-5 存在"); + assert!(rec.source.is_none(), "result 缺 id 时不补,保持原值"); + } + + /// idea_id 不存在于库 → 不 panic(0 行影响 warn) + #[tokio::test] + async fn does_not_panic_when_idea_id_not_in_db() { + let db = idea_source_fixture_record("idea-6", None).await; + let args = serde_json::json!({ "title": "t" }); + // result 指向不存在的 idea-x + let result = serde_json::json!({ "id": "idea-nonexistent", "title": "t", "status": "draft" }); + + // 不应 panic,仅 warn + maybe_fill_idea_source(&db, "create_idea", &args, &result, Some("msg-42")).await; + + // idea-6 原值不变 + let repo = IdeaRepo::new(&db); + let rec = repo.get_by_id("idea-6").await.unwrap().expect("idea-6 存在"); + assert!(rec.source.is_none()); + } +} diff --git a/src-tauri/src/commands/ai/audit/mod.rs b/src-tauri/src/commands/ai/audit/mod.rs index 9a20d9b..ab55840 100644 --- a/src-tauri/src/commands/ai/audit/mod.rs +++ b/src-tauri/src/commands/ai/audit/mod.rs @@ -126,6 +126,12 @@ pub(super) use cache::{find_cached_high_risk_result, PENDING_APPROVAL_PLACEHOLDE mod data_change; pub(crate) use data_change::emit_data_changed; +// idea_source(audit/idea_source.rs):F-260619-04 P2(方案 B) 灵感来源消息级溯源补全。 +// create_idea 工具执行后,若 AI 未填 source 且有 message_id → 补 conv_msg:{id}(低侵入,不改 handler 接口)。 +// pub(crate) use 供本文件 process_tool_calls + chat.rs 审批执行路径调用(单点逻辑,多调用点)。 +mod idea_source; +pub(crate) use idea_source::maybe_fill_idea_source; + /// F-260619-03 Phase B: 提取文件工具的路径参数(用于路径授权预校验)。 /// /// 仅对走 resolve_workspace_path 校验的文件工具返回路径;非文件工具返回空 Vec(不预校验)。 @@ -488,7 +494,8 @@ pub(crate) async fn process_tool_calls( // push tool_result / audit 在 join_all 后串行回填(持锁,与 Med/High 占位拼接)。 // join_all 保序——结果顺序 = low_risk 输入顺序 = tc_list 原始 index 顺序,不额外 sort if !low_risk.is_empty() { - let results: Vec<(ToolCallDraft, Result)> = + // 携带 args + 原始 JSON result(create_idea source 补全需解析 result.id + args.source)。 + let results: Vec<(ToolCallDraft, serde_json::Value, Result)> = futures::future::join_all(low_risk.into_iter().map(|(draft, args)| { let tools = tools_arc.clone(); let app_clone = app_handle.clone(); @@ -505,7 +512,7 @@ pub(crate) async fn process_tool_calls( // AR-11(方案A):数据变更工具执行成功后 emit df-data-changed, // 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。 emit_data_changed(&app_clone, &draft.name); - (draft, Ok(val.to_string())) + (draft, val.clone(), Ok(val.to_string())) } Err(e) => { // AR-6(定向B):Low 工具失败不 emit AiError。 @@ -519,18 +526,25 @@ pub(crate) async fn process_tool_calls( result: serde_json::Value::String(err_msg.clone()), conversation_id: Some(conv_clone), }); - (draft, Err(err_msg)) + (draft, serde_json::Value::Null, Err(err_msg)) } } } })).await; // 串行回填 tool_result + 审计(持 session 锁) - for (draft, outcome) in results { + for (draft, raw_result, outcome) in results { let (status, content) = match outcome { Ok(c) => ("completed", c), Err(c) => ("failed", c), }; + // F-260619-04 P2(方案 B): create_idea source 消息级溯源补全(仅 source 空 + 有 message_id)。 + // 注:低风险路径目前 create_idea 不会进(Medium→pending),此处为防御/未来若调级别覆盖。 + // 仅 Ok(completed) 时补(失败 result 无 idea_id 意义);args 从 draft.args 反解(JSON 原样)。 + if status == "completed" { + let args_val = serde_json::from_str(&draft.args).unwrap_or(serde_json::Value::Null); + maybe_fill_idea_source(db, &draft.name, &args_val, &raw_result, current_message_id).await; + } // F-260616-09 B 批2:写 per_conv.messages。 session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone())); audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, RiskLevel::Low, Some(content), Some("auto"), current_message_id).await; diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index 822bd31..8b0d738 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -500,6 +500,28 @@ pub async fn ai_approve( }); drop(session); + // F-260619-04 P2(方案 B): create_idea source 消息级溯源补全。 + // create_idea 是 Medium risk → 经审批执行(本路径),非 process_tool_calls 内联执行, + // 故补全点放此(工具已 executed,可拿到 result.id + args.source)。 + // message_id 从 per_conv.messages 取末条 assistant id(同 process_tool_calls 的 current_message_id 语义); + // assistant_with_tools 消息持久驻留 messages,审批时仍可读。 + // 仅 executed(成功)且 create_idea 才补;失败/其他工具 noop(helper 内判定,不污染本路径主流程)。 + if audit_status == "executed" { + let message_id = { + let session = state.ai_session.lock().await; + conv_id.as_deref().and_then(|cid| { + session.conv_read(cid).and_then(|c| c.messages.last_assistant_message_id()) + }) + }; + super::super::audit::maybe_fill_idea_source( + &state.db, + &approval.tool_name, + &approval.arguments, + &result_val, + message_id.as_deref(), + ).await; + } + // 审批执行结果立即落库,不依赖后续 agentic loop(避免 loop 异常退出时丢失真实结果) // 含 recovered 积压审批——switch 时已 restore_from_messages 载完整历史,messages 非空, // save 不污染老对话;原 if !recovered 守卫前提不成立已移除。 diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index e813ac3..06f4898 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -881,73 +881,6 @@ fn register_file_tools( // run_command 不走白名单(High risk 靠人工审批兜底,放开目录让 AI 在用户任意项目目录闭环), // 故该工具闭包不捕获 allowed_dirs,维持原行为。 let allowed_dirs = allowed_dirs.clone(); - registry.register( - "run_command", "在指定工作目录执行 shell 命令(跑测试/构建/查看运行结果),返回 stdout/stderr/exit_code。高风险,须人工批准。命令需自包含(非交互式,避免需用户输入的程序)。默认超时 60 秒。用于验证刚写入的代码能否运行、跑测试、看报错迭代修改。", - df_ai::ai_tools::object_schema(vec![ - ("command", "string", true), - ("working_dir", "string", false), - ("timeout_secs", "integer", false), - ]), - RiskLevel::High, - Box::new(|args: serde_json::Value| Box::pin(async move { - let command = args["command"].as_str() - .ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?; - // working_dir 默认 workspace_root()(与 write_file 锚定一致:AI 写代码→同目录跑命令,闭环)。 - // 走 validate_path 黑名单(.. + 敏感系统目录)作基础防线;不走 resolve_workspace_path 越界校验—— - // run_command 是 High risk 靠人工审批兜底(用户在审批卡看清 command+working_dir), - // 放开目录才能让 AI 在用户任意项目目录形成「写→跑→看→改」真闭环。 - let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) { - Some(d) => { - validate_path(d)?; - d.to_string() - } - None => workspace_root().to_string_lossy().to_string(), - }; - // timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可通过 args timeout_secs 覆盖 - // clamp 封顶 MAX:防 LLM 传超大 timeout_secs 冻结会话(需更长命令应拆分而非无限等) - let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS).min(MAX_RUN_COMMAND_TIMEOUT_SECS); - - let request = ShellRequest { - command: command.to_string(), - working_dir: Some(working_dir.clone()), - env: HashMap::new(), - timeout_secs: Some(timeout_secs), - shell_type: Default::default(), - }; - // F-260616-04:超时标注——execute 超时返 Err("命令执行超时: N秒")。 - // 原行为:该 Err 经 ? 上抛 → 人工审批路径(commands.rs ai_approve L256)把 e.to_string() - // 包成 tool_result 回传 LLM → LLM 误判命令失败而非超时 → 盲目重试同命令 → - // 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。 - // 治本:超时根因处拦截,把 Err 内容改写为「明确超时语义 + 勿盲目重试」标注, - // 让 LLM 知进程已终止、非命令失败,确需更长时限才在 args 提高 timeout_secs 重发。 - let result = execute(request).await.map_err(|e| { - let msg = e.to_string(); - if msg.contains("命令执行超时") { - anyhow::anyhow!( - "命令执行超时({}s),进程已终止。勿盲目重试同命令;确需更长时限重发时在 args 提高 timeout_secs。", - timeout_secs - ) - } else { - e - } - })?; - - // 输出截断:防编译输出/find//cat 大文件撑爆 LLM context(各 10KB,尾部保留-报错堆栈在末尾) - const MAX_OUT: usize = 10_000; - let (stdout, stdout_trunc) = truncate_output(&result.stdout, MAX_OUT); - let (stderr, stderr_trunc) = truncate_output(&result.stderr, MAX_OUT); - - Ok(serde_json::json!({ - "command": command, - "working_dir": working_dir, - "exit_code": result.exit_code, - "duration_ms": result.duration_ms, - "stdout": stdout, - "stderr": stderr, - "truncated": stdout_trunc || stderr_trunc, - })) - })), - ); // ── 文件系统 ── registry.register( @@ -1633,6 +1566,76 @@ fn register_file_tools( }) })}, ); + + // ── 命令执行(run_command 放 File 层最后,降低 LLM 偏好:靠前注册易被 LLM 优先选中, + // 移到末位让 read_file/write_file 等高频文件工具在前,run_command 仅命令执行场景才该用) ── + registry.register( + "run_command", "在指定工作目录执行 shell 命令,返回 stdout/stderr/exit_code。仅用于命令执行场景:跑测试套件、构建项目、运行二进制/脚本验证行为。读取文件用 read_file,编辑文件用 patch_file/write_file,列目录用 list_directory,搜索文件名用 search_files——不要用本工具完成这些操作。高风险,须人工批准。命令需自包含(非交互式,避免需用户输入的程序)。默认超时 60 秒。", + df_ai::ai_tools::object_schema(vec![ + ("command", "string", true), + ("working_dir", "string", false), + ("timeout_secs", "integer", false), + ]), + RiskLevel::High, + Box::new(|args: serde_json::Value| Box::pin(async move { + let command = args["command"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?; + // working_dir 默认 workspace_root()(与 write_file 锚定一致:AI 写代码→同目录跑命令,闭环)。 + // 走 validate_path 黑名单(.. + 敏感系统目录)作基础防线;不走 resolve_workspace_path 越界校验—— + // run_command 是 High risk 靠人工审批兜底(用户在审批卡看清 command+working_dir), + // 放开目录才能让 AI 在用户任意项目目录形成「写→跑→看→改」真闭环。 + let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) { + Some(d) => { + validate_path(d)?; + d.to_string() + } + None => workspace_root().to_string_lossy().to_string(), + }; + // timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可通过 args timeout_secs 覆盖 + // clamp 封顶 MAX:防 LLM 传超大 timeout_secs 冻结会话(需更长命令应拆分而非无限等) + let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS).min(MAX_RUN_COMMAND_TIMEOUT_SECS); + + let request = ShellRequest { + command: command.to_string(), + working_dir: Some(working_dir.clone()), + env: HashMap::new(), + timeout_secs: Some(timeout_secs), + shell_type: Default::default(), + }; + // F-260616-04:超时标注——execute 超时返 Err("命令执行超时: N秒")。 + // 原行为:该 Err 经 ? 上抛 → 人工审批路径(commands.rs ai_approve L256)把 e.to_string() + // 包成 tool_result 回传 LLM → LLM 误判命令失败而非超时 → 盲目重试同命令 → + // 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。 + // 治本:超时根因处拦截,把 Err 内容改写为「明确超时语义 + 勿盲目重试」标注, + // 让 LLM 知进程已终止、非命令失败,确需更长时限才在 args 提高 timeout_secs 重发。 + let result = execute(request).await.map_err(|e| { + let msg = e.to_string(); + if msg.contains("命令执行超时") { + anyhow::anyhow!( + "命令执行超时({}s),进程已终止。勿盲目重试同命令;确需更长时限重发时在 args 提高 timeout_secs。", + timeout_secs + ) + } else { + e + } + })?; + + // 输出截断:防编译输出/find//cat 大文件撑爆 LLM context(各 10KB,尾部保留-报错堆栈在末尾) + const MAX_OUT: usize = 10_000; + let (stdout, stdout_trunc) = truncate_output(&result.stdout, MAX_OUT); + let (stderr, stderr_trunc) = truncate_output(&result.stderr, MAX_OUT); + + Ok(serde_json::json!({ + "command": command, + "working_dir": working_dir, + "exit_code": result.exit_code, + "duration_ms": result.duration_ms, + "stdout": stdout, + "stderr": stderr, + "truncated": stdout_trunc || stderr_trunc, + })) + })), + ); } /// 递归列出目录内容(最多 max_depth 层,最多 max_entries 条) @@ -1852,10 +1855,11 @@ mod tests { "run_workflow", "delete_task", "create_idea", "delete_project", "restore_project", "purge_project", "list_trash", "get_project_count", "get_task_count", - // ── file 层 (10) ── - "run_command", "read_file", "list_directory", - "write_file", "patch_file", "file_info", - "append_file", "delete_file", "rename_file", "search_files", + // ── file 层 (10) ──(run_command 注册顺序已移至末位降低 LLM 偏好, + // 集合断言经 sort 后与顺序无关,仅守护工具名不漂移) + "read_file", "list_directory", "write_file", + "patch_file", "file_info", "append_file", + "delete_file", "rename_file", "search_files", "run_command", // ── http 层 (1) ── "http_request", ]; diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 6bb6cf4..780345f 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -341,7 +341,11 @@ impl AllowedDirs { Self { persistent: set, session: HashSet::new() } } - /// 路径是否被授权:workspace_root 始终授权 + persistent 或 session 任一 starts_with 命中即放行。 + /// 路径是否被授权:persistent 或 session 白名单任一 starts_with 命中即放行。 + /// + /// **workspace_root 默认在 persistent**(default_with_root 初始插入,reload_allowed_dirs + /// KV 未配时保持),故首次访问工程根免授权(向后兼容)。用户从白名单删除工程根后, + /// 工程根也需授权(动态白名单完整语义,F-260619-03 收尾:去掉代码硬编码固定放行)。 /// /// **Phase C 黑名单优先**:即使白名单命中,若路径落入系统敏感目录(Windows /// `\Windows\System32` / `\Program Files\`;Unix `/etc /usr /bin /sbin /boot @@ -355,10 +359,6 @@ impl AllowedDirs { 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)) }