From 75c5e23523670dfa934fa3617c31ee1cd520046c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Thu, 1 Oct 2026 19:35:19 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E7=BC=96=E6=8E=92=E6=8B=86=E8=A7=A3=E4=B8=8EDRY=E5=80=BA?= =?UTF-8?q?=E6=94=B6=E6=95=9B=E5=8F=8A=E5=91=BD=E5=90=8D=E7=BA=A0=E5=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 阶段1拆解: stream 系外移 stream_relay.rs,mod.rs 3048→2607,loop_body 502→83 技能收尾: regenerate/edit 回溯补技能,新增 read_skill 工具,项目级技能扫描 DRY: emit 双写 46 处收敛 event_bus 正典,KV 引号归一收口,LOCK-SLOW 33 处单行化 警示骨架 4→1 与压缩兜底 2→1,噪音目录档位收敛,truncate 同名陷阱消除 工具元数据落 ToolMeta 八维,7 张 match 表改查 registry,parity 测试锁等价 命名: is_noise_entry/teardown_round/probe_missed_terminal_event 等 6 项纠偏 --- crates/df-ai/src/ai_tools.rs | 311 ++- crates/df-ai/src/ai_tools_decl.rs | 73 +- .../commands/ai/agentic/approval_timeout.rs | 22 +- .../commands/ai/agentic/context_lifecycle.rs | 131 +- src-tauri/src/commands/ai/agentic/guard.rs | 18 +- src-tauri/src/commands/ai/agentic/helpers.rs | 191 +- src-tauri/src/commands/ai/agentic/mod.rs | 1907 +++++++---------- .../src/commands/ai/agentic/stream_relay.rs | 666 ++++++ src-tauri/src/commands/ai/audit/approval.rs | 15 +- src-tauri/src/commands/ai/audit/cache.rs | 67 +- .../src/commands/ai/audit/data_change.rs | 32 +- src-tauri/src/commands/ai/audit/mod.rs | 93 +- src-tauri/src/commands/ai/audit/path_auth.rs | 30 +- src-tauri/src/commands/ai/command_stream.rs | 6 +- src-tauri/src/commands/ai/commands/chat.rs | 327 ++- src-tauri/src/commands/ai/commands/config.rs | 20 +- src-tauri/src/commands/ai/event_bus.rs | 19 + src-tauri/src/commands/ai/fetch_url.rs | 19 +- src-tauri/src/commands/ai/generate_image.rs | 8 +- src-tauri/src/commands/ai/knowledge_inject.rs | 27 +- src-tauri/src/commands/ai/mod.rs | 7 + src-tauri/src/commands/ai/prompt.rs | 17 +- src-tauri/src/commands/ai/remote_bridge.rs | 25 +- src-tauri/src/commands/ai/skills.rs | 77 +- src-tauri/src/commands/ai/stream_recv.rs | 9 +- src-tauri/src/commands/ai/tool_registry.rs | 463 +++- src-tauri/src/commands/ai/tools/fetch_url.rs | 6 +- src-tauri/src/commands/ai/tools/file.rs | 87 +- src-tauri/src/commands/ai/tools/http.rs | 8 +- src-tauri/src/commands/ai/tools/idea.rs | 19 +- src-tauri/src/commands/ai/tools/mod.rs | 1 + src-tauri/src/commands/ai/tools/project.rs | 39 +- src-tauri/src/commands/ai/tools/read_skill.rs | 48 + src-tauri/src/commands/ai/tools/task.rs | 24 +- src-tauri/src/commands/ai/tools/workflow.rs | 8 +- src-tauri/src/commands/module.rs | 25 +- src-tauri/src/commands/settings.rs | 8 +- src-tauri/src/commands/workflow.rs | 6 +- src-tauri/src/lib.rs | 15 +- src-tauri/src/state.rs | 78 +- 40 files changed, 2988 insertions(+), 1964 deletions(-) create mode 100644 src-tauri/src/commands/ai/agentic/stream_relay.rs create mode 100644 src-tauri/src/commands/ai/tools/read_skill.rs diff --git a/crates/df-ai/src/ai_tools.rs b/crates/df-ai/src/ai_tools.rs index 5fc9e6d..5f4c3bd 100644 --- a/crates/df-ai/src/ai_tools.rs +++ b/crates/df-ai/src/ai_tools.rs @@ -8,6 +8,7 @@ use std::collections::HashMap; use std::future::Future; use std::pin::Pin; +use std::sync::{LazyLock, RwLock}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -40,10 +41,226 @@ pub struct AiTool { pub definition: ToolDefinition, /// 风险级别 pub risk_level: RiskLevel, + /// 工具元数据(DRY 债 #5 收敛:注册点声明,消费方按条目或全局表查询,不再散布 match 表) + pub meta: ToolMeta, /// 执行处理器 pub handler: AiToolHandler, } +// ============================================================ +// 工具元数据(DRY 债 #5) +// +// 此前工具元数据以 5 处手写 match 表散布在调用侧: +// path_auth.rs(路径参数键) / agentic/helpers.rs 两处(目标描述 + 漂移签名) / +// audit/data_change.rs(entity-action) / audit/cache.rs(只读缓存白名单 + 目录级 key) / +// tool_registry.rs(审批文案模板 + 轻量动词标签),新增工具需人肉同步 5-6 处且已漂移。 +// 收敛后元数据仅在 declare_tool! 注册点声明一次,上述消费方全部改查本表; +// 未注册工具走各消费方原保守 default(路径不校验/不缓存/不联动)。 +// +// 维度按 5 处旧表的实际消费盘点,不为用不上的维度加字段: +// - path_args:路径授权预校验提取哪些参数键(rename_file 为 from+to,其余文件工具为 path) +// - readonly:只读幂等工具(结果可缓存) +// - cache_path_key:结果缓存 key 只取 path(目录级去重,同目录换递归参数也判重复) +// - deny_unauthorized:路径未授权时硬拒而非弹窗(search_files 盲搜语义应拒不应询) +// - entity_action:数据变更联动刷新的 (entity, action) +// - approval_hint:审批卡文案模板 + 关键参数键 +// - display_verb:轻量中文动作标签 +// - goal:从工具调用推理目标描述(动词 + 参数键 + 取值风格 + 缺参兜底文案) +// - sig:漂移检测签名取哪些参数组(组内多键为取值回退序,数值键按 JSON 形态序列化) +// ============================================================ + +/// 取值风格(goal 维度):从参数值到展示文本的取法 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GoalStyle { + /// 取路径末段文件名(与旧实现一致按 / 切分) + FileName, + /// 原样 + Raw, + /// 超 25 字节截断(字符边界回退,防多字节字符中间切 panic) + Truncate, +} + +/// 目标描述元数据:infer_goal_from_tool_calls 消费 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct GoalMeta { + /// 动词前缀(如 "修改"/"查看"/"搜索") + pub verb: &'static str, + /// 参数键,按序取第一个存在的键 + pub keys: &'static [&'static str], + /// 取值风格 + pub style: GoalStyle, + /// 键缺失/解析失败时的兜底文案(如 "搜索文件");None 表示返回 None + pub fallback: Option<&'static str>, +} + +/// 签名取值类型:字符串键取原始串,数值键按 JSON 序列化形态 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SigValKind { + /// 字符串取值(as_str,缺失为空串) + Str, + /// Value::to_string 形态(数字 50 -> "50",缺失为空串) + Json, +} + +/// 漂移检测签名元数据:tool_call_signature 消费 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SigMeta { + /// 有序参数组;每组内多键表示取值回退序(如 pattern 缺失落 query) + pub groups: &'static [(&'static [&'static str], SigValKind)], +} + +/// 工具元数据(全部为编译期静态值,Copy 廉价传递) +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct ToolMeta { + /// 路径授权预校验的参数键;空 = 非文件工具(不预校验,保守 default) + pub path_args: &'static [&'static str], + /// 只读幂等工具(结果可安全缓存) + pub readonly: bool, + /// 结果缓存 key 只取 path(目录级去重) + pub cache_path_key: bool, + /// 路径未授权时硬拒(不弹授权窗) + pub deny_unauthorized: bool, + /// 数据变更联动 (entity, action) + pub entity_action: Option<(&'static str, &'static str)>, + /// 审批文案模板 + 关键参数键(模板 {} 按位置填充) + pub approval_hint: Option<(&'static str, &'static [&'static str])>, + /// 轻量中文动作标签 + pub display_verb: Option<&'static str>, + /// 目标描述元数据 + pub goal: Option, + /// 漂移检测签名元数据 + pub sig: Option, +} + +/// 进程级工具元数据表。register_meta 写入(注册点 = 唯一写入口), +/// path_auth / cache / agentic helpers 等无 registry 句柄的自由函数按名查询。 +static GLOBAL_TOOL_META: LazyLock>> = + LazyLock::new(|| RwLock::new(HashMap::new())); + +/// 按工具名查元数据(查全局表,未注册返回 None) +pub fn tool_meta(name: &str) -> Option { + GLOBAL_TOOL_META + .read() + .expect("全局工具元数据表锁") + .get(name) + .copied() +} + +/// 全局元数据表是否为空(测试种子判定用;生产注册先于任何查询,表恒非空) +#[doc(hidden)] +pub fn meta_table_is_empty() -> bool { + GLOBAL_TOOL_META.read().expect("全局工具元数据表锁").is_empty() +} + +/// 路径授权预校验参数键(未注册工具返回空切片,即非文件工具) +pub fn path_arg_keys(name: &str) -> &'static [&'static str] { + tool_meta(name).map(|m| m.path_args).unwrap_or(&[]) +} + +/// 是否只读幂等工具(未注册返回 false,即不缓存) +pub fn is_readonly(name: &str) -> bool { + tool_meta(name).map(|m| m.readonly).unwrap_or(false) +} + +/// 结果缓存 key 是否只取 path(未注册返回 false) +pub fn cache_path_key_only(name: &str) -> bool { + tool_meta(name).map(|m| m.cache_path_key).unwrap_or(false) +} + +/// 路径未授权是否硬拒(未注册返回 false,走弹窗) +pub fn denies_unauthorized(name: &str) -> bool { + tool_meta(name).map(|m| m.deny_unauthorized).unwrap_or(false) +} + +/// 数据变更联动 (entity, action)(未注册返回 None,不 emit) +pub fn entity_action(name: &str) -> Option<(&'static str, &'static str)> { + tool_meta(name).and_then(|m| m.entity_action) +} + +/// 审批文案模板(未注册返回 None,调用方走 fallback) +pub fn approval_hint(name: &str) -> Option<(&'static str, &'static [&'static str])> { + tool_meta(name).and_then(|m| m.approval_hint) +} + +/// 轻量中文动作标签(未注册返回 None) +pub fn display_verb(name: &str) -> Option<&'static str> { + tool_meta(name).and_then(|m| m.display_verb) +} + +/// 目标描述元数据(未注册返回 None) +pub fn goal_of(name: &str) -> Option { + tool_meta(name).and_then(|m| m.goal) +} + +/// 漂移检测签名元数据(未注册返回 None,调用方走全参兜底) +pub fn sig_of(name: &str) -> Option { + tool_meta(name).and_then(|m| m.sig) +} + +/// 按路径参数键从 args 提取路径列表(键序即提取序,缺失键跳过) +pub fn extract_paths(keys: &'static [&'static str], args: &Value) -> Vec { + keys.iter() + .filter_map(|k| args.get(*k).and_then(|x| x.as_str()).map(|s| s.to_string())) + .collect() +} + +/// 由目标元数据渲染目标描述。args 为 None(调用参数 JSON 解析失败)时仅兜底文案可产出。 +pub fn goal_text(goal: GoalMeta, args: Option<&Value>) -> Option { + let value = args + .and_then(|a| goal.keys.iter().find_map(|k| a.get(k))) + .and_then(|v| v.as_str()); + let rendered = value.map(|v| match goal.style { + GoalStyle::FileName => { + // 按旧实现仅按 / 取末段;rsplit 恒有首项,反斜杠路径在此保持原样(行为对齐) + let f = v.rsplit('/').next().unwrap_or(v); + format!("{} {}", goal.verb, f) + } + GoalStyle::Raw => format!("{} {}", goal.verb, v), + GoalStyle::Truncate => { + let mut end = 25.min(v.len()); + while end > 0 && !v.is_char_boundary(end) { + end -= 1; + } + format!("{} {}", goal.verb, &v[..end]) + } + }); + rendered.or_else(|| goal.fallback.map(|f| f.to_string())) +} + +/// 签名体:每组渲染 k=v,组间逗号连接。标签恒为首键(与旧实现 pair("pattern", ...) 一致, +/// 取值来自回退键时标签不变)。Str 组取组内首个字符串值键的值,全非字符串落末键; +/// Json 组按 Value::to_string(缺失为空串)。 +pub fn sig_body(sig: SigMeta, args: &Value) -> String { + let arg_str = |key: &str| -> String { + args.get(key).and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_default() + }; + sig.groups + .iter() + .map(|(keys, kind)| { + let value = match kind { + SigValKind::Json => args.get(keys[0]).map(|v| v.to_string()).unwrap_or_default(), + SigValKind::Str => { + let key = keys + .iter() + .find(|k| args.get(**k).and_then(|v| v.as_str()).is_some()) + .unwrap_or(&keys[keys.len() - 1]); + arg_str(key) + } + }; + format!("{}={}", keys[0], value) + }) + .collect::>() + .join(",") +} + +/// 工具调用签名:有 SigMeta 元数据按组渲染,未注册回退「工具名 + 全参 JSON」保守兜底。 +pub fn tool_signature(name: &str, args: &Value) -> String { + match sig_of(name) { + Some(sig) => format!("{}:{}", name, sig_body(sig, args)), + None => format!("{}:name={},args={}", name, name, args.to_string()), + } +} + /// 工具处理器类型 — 异步函数,接收 JSON 参数,返回 JSON 结果 pub type AiToolHandler = Box Pin> + Send>> + Send + Sync>; @@ -65,7 +282,7 @@ impl AiToolRegistry { } } - /// 注册一个工具 + /// 注册一个工具(无元数据,ToolMeta::default) pub fn register( &mut self, name: impl Into, @@ -73,19 +290,47 @@ impl AiToolRegistry { parameters: Value, risk_level: RiskLevel, handler: AiToolHandler, + ) { + self.register_meta(name, description, parameters, risk_level, ToolMeta::default(), handler); + } + + /// 注册一个工具并写入元数据。 + /// + /// 元数据在同一调用点写入两处,无双源漂移: + /// - 注册表条目 AiTool.meta(持有 registry 实例的消费方查询) + /// - 进程级 GLOBAL_TOOL_META 表(path_auth/cache/agentic helpers 等无 + /// registry 句柄的自由函数按工具名查询) + pub fn register_meta( + &mut self, + name: impl Into, + description: impl Into, + parameters: Value, + risk_level: RiskLevel, + meta: ToolMeta, + handler: AiToolHandler, ) { let name_str = name.into(); let definition = ToolDefinition::function(&name_str, description, parameters); + GLOBAL_TOOL_META + .write() + .expect("全局工具元数据表锁") + .insert(name_str.clone(), meta); self.tools.insert( name_str, AiTool { definition, risk_level, + meta, handler, }, ); } + /// 查询工具元数据条目(持有 registry 实例的消费方用) + pub fn meta(&self, name: &str) -> Option { + self.tools.get(name).map(|t| t.meta) + } + /// 获取所有工具定义(发送给 LLM 的 tools 参数) pub fn tool_definitions(&self) -> Vec { self.tools.values().map(|t| t.definition.clone()).collect() @@ -236,4 +481,68 @@ mod tests { assert_eq!(required.len(), 1); assert_eq!(required[0], "name"); } + + /// register_meta:条目与全局表同步可查,未注册名走保守 default。 + #[test] + fn register_meta_stores_entry_and_global() { + let mut reg = AiToolRegistry::new(); + let meta = ToolMeta { + path_args: &["from", "to"], + readonly: true, + entity_action: Some(("task", "update")), + ..Default::default() + }; + reg.register_meta("meta_probe", "测试", object_schema(vec![]), RiskLevel::Low, meta, dummy_handler()); + assert_eq!(reg.meta("meta_probe"), Some(meta)); + assert_eq!(tool_meta("meta_probe"), Some(meta)); + assert_eq!(path_arg_keys("meta_probe"), &["from", "to"]); + assert!(is_readonly("meta_probe")); + assert_eq!(entity_action("meta_probe"), Some(("task", "update"))); + // 未注册名保守 default:无路径键/非只读/无联动 + assert_eq!(path_arg_keys("never_registered"), &[] as &[&str]); + assert!(!is_readonly("never_registered")); + assert_eq!(entity_action("never_registered"), None); + // 老签名 register 等价 default 元数据 + reg.register("plain_probe", "测试", object_schema(vec![]), RiskLevel::Low, dummy_handler()); + assert_eq!(reg.meta("plain_probe"), Some(ToolMeta::default())); + } + + /// goal_text 三种取值风格 + 键回退 + 兜底文案。 + #[test] + fn goal_text_styles_and_fallback() { + use serde_json::json; + let file_name = GoalMeta { verb: "查看", keys: &["path"], style: GoalStyle::FileName, fallback: None }; + assert_eq!(goal_text(file_name, Some(&json!({"path": "a/b/c.rs"}))), Some("查看 c.rs".to_string())); + let raw = GoalMeta { verb: "浏览", keys: &["path"], style: GoalStyle::Raw, fallback: None }; + assert_eq!(goal_text(raw, Some(&json!({"path": "x/y"}))), Some("浏览 x/y".to_string())); + // Truncate:ASCII 超 25 字节截 25;多字节字符回退到字符边界不 panic + let trunc = GoalMeta { verb: "执行", keys: &["command"], style: GoalStyle::Truncate, fallback: Some("执行命令") }; + let long = json!({"command": "a".repeat(40)}); + assert_eq!(goal_text(trunc, Some(&long)), Some(format!("执行 {}", "a".repeat(25)))); + let multibyte = json!({"command": "构建数据库连接池"}); + let out = goal_text(trunc, Some(&multibyte)).expect("多字节应正常产出"); + assert!(out.starts_with("执行 构建数据库")); + // 键缺失走兜底;无兜底返 None + assert_eq!(goal_text(trunc, Some(&json!({}))), Some("执行命令".to_string())); + assert_eq!(goal_text(file_name, Some(&json!({}))), None); + assert_eq!(goal_text(file_name, None), None); + // 多键回退序:首键缺失取次键 + let multi = GoalMeta { verb: "搜索", keys: &["pattern", "query"], style: GoalStyle::Raw, fallback: Some("搜索文件") }; + assert_eq!(goal_text(multi, Some(&json!({"query": "todo"}))), Some("搜索 todo".to_string())); + } + + /// sig_body:组内字符串键回退 + Json 数值键形态 + 缺省空串。 + #[test] + fn sig_body_groups_and_kinds() { + use serde_json::json; + let grep_sig = SigMeta { groups: &[(&["pattern", "query"], SigValKind::Str), (&["path", "glob"], SigValKind::Str)] }; + assert_eq!(sig_body(grep_sig, &json!({"pattern": "MAX", "path": "src"})), "pattern=MAX,path=src"); + assert_eq!(sig_body(grep_sig, &json!({"query": "TODO", "glob": "*.rs"})), "pattern=TODO,path=*.rs"); + assert_eq!(sig_body(grep_sig, &json!({})), "pattern=,path="); + let read_sig = SigMeta { groups: &[(&["path"], SigValKind::Str), (&["offset"], SigValKind::Json), (&["limit"], SigValKind::Json)] }; + assert_eq!(sig_body(read_sig, &json!({"path": "a", "offset": 50})), "path=a,offset=50,limit="); + // 未注册工具签名回退全参 JSON + let sig = tool_signature("custom_tool", &json!({"x": 1})); + assert!(sig.starts_with("custom_tool:name=custom_tool,args=")); + } } diff --git a/crates/df-ai/src/ai_tools_decl.rs b/crates/df-ai/src/ai_tools_decl.rs index 8377383..f086133 100644 --- a/crates/df-ai/src/ai_tools_decl.rs +++ b/crates/df-ai/src/ai_tools_decl.rs @@ -54,16 +54,47 @@ /// - `$name`: 工具名 `&str`。 /// - `$desc`: 工具描述 `&str`(发给 LLM)。 /// - `$risk`: `RiskLevel`(如 `RiskLevel::Low`)。 +/// - `meta: $meta`(可选段): `ToolMeta` 字面量(DRY 债 #5)。带此段走 `register_meta` 落 +/// 条目 + 全局元数据表;不带则 default 元数据(全空,消费方走保守 default 分支)。 /// - `$schema`: 参数 JSON Schema(`serde_json::Value`),常用 `object_schema(...)`。 /// - `$handler`: 一个 **block 表达式**(花括号体),返回 `anyhow::Result`。 /// 体内在 `$args`(serde_json::Value)与 `$capture`(克隆后的捕获变量)上工作。 /// 宏负责把它包进 `async move { ... }` 并 `Box::pin`——故调用方只写「同步语义的体」, /// 不写 `async move`/`Box::pin`/`Box::new` 三层样板。 /// -/// 注意:此宏不替代 `register`,而是包装它——`register` 仍是 `AiToolRegistry` 的唯一注册入口, -/// 宏仅是语法糖。现有 48 个手写 `register` 调用不动,新工具改用 `declare_tool!`。 +/// 注意:此宏不替代 `register`,而是包装它——`register`/`register_meta` 仍是 +/// `AiToolRegistry` 的唯一注册入口,宏仅是语法糖。 #[macro_export] macro_rules! declare_tool { + ( + $registry:expr, + $capture:ident : $cap_ty:ty, + $name:expr, + $desc:expr, + $risk:expr, + meta: $meta:expr, + schema: $schema:expr, + $args:ident => $handler:block + ) => {{ + let __cap: $cap_ty = $capture.clone(); + $registry.register_meta( + $name, $desc, $schema, $risk, $meta, + { + let $capture = __cap.clone(); + ::std::boxed::Box::new(move |$args: ::serde_json::Value| { + let $capture = $capture.clone(); + ::std::boxed::Box::pin(async move { + let $capture: $cap_ty = $capture; + $handler + }) + as ::std::pin::Pin<::std::boxed::Box< + dyn ::std::future::Future> + + ::std::marker::Send, + >> + }) + }, + ); + }}; ( $registry:expr, $capture:ident : $cap_ty:ty, @@ -74,8 +105,8 @@ macro_rules! declare_tool { $args:ident => $handler:block ) => {{ let __cap: $cap_ty = $capture.clone(); - $registry.register( - $name, $desc, $schema, $risk, + $registry.register_meta( + $name, $desc, $schema, $risk, $crate::ai_tools::ToolMeta::default(), { let $capture = __cap.clone(); ::std::boxed::Box::new(move |$args: ::serde_json::Value| { @@ -151,4 +182,38 @@ mod tests { let out = reg.execute("const_tool", serde_json::json!({})).await.unwrap(); assert_eq!(out["ok"], true); } + + /// 带 meta 段的宏形态:元数据落条目 + 全局表;不带 meta 段落 default。 + #[tokio::test] + async fn declare_tool_with_and_without_meta() { + use crate::ai_tools::ToolMeta; + let mut reg = AiToolRegistry::new(); + let dummy: std::sync::Arc<()> = std::sync::Arc::new(()); + declare_tool!( + reg, + dummy: std::sync::Arc<()>, + "meta_variant_tool", + "带元数据声明的工具", + RiskLevel::Low, + meta: ToolMeta { path_args: &["path"], readonly: true, ..Default::default() }, + schema: object_schema(vec![]), + _args => { Ok(serde_json::json!({ "ok": true })) } + ); + declare_tool!( + reg, + dummy: std::sync::Arc<()>, + "plain_variant_tool", + "不带元数据声明的工具", + RiskLevel::Low, + schema: object_schema(vec![]), + _args => { Ok(serde_json::json!({ "ok": true })) } + ); + let with = reg.meta("meta_variant_tool").expect("meta 变体应已注册"); + assert_eq!(with.path_args, &["path"]); + assert!(with.readonly); + assert_eq!(crate::ai_tools::path_arg_keys("meta_variant_tool"), &["path"]); + let plain = reg.meta("plain_variant_tool").expect("无 meta 变体应已注册"); + assert_eq!(plain, ToolMeta::default()); + assert_eq!(crate::ai_tools::path_arg_keys("plain_variant_tool"), &[] as &[&str]); + } } diff --git a/src-tauri/src/commands/ai/agentic/approval_timeout.rs b/src-tauri/src/commands/ai/agentic/approval_timeout.rs index e373431..43e5009 100644 --- a/src-tauri/src/commands/ai/agentic/approval_timeout.rs +++ b/src-tauri/src/commands/ai/agentic/approval_timeout.rs @@ -10,12 +10,13 @@ use std::time::{Duration, SystemTime}; -use tauri::{AppHandle, Emitter}; +use tauri::AppHandle; use crate::state::AppState; use super::super::{ - audit::audit_finalize, conversation::save_conversation, AiChatEvent, + audit::audit_finalize, conversation::save_conversation, event_bus::emit_ai_chat_event, + AiChatEvent, }; /// 清理指定会话中超时的待审批。 @@ -81,14 +82,15 @@ pub async fn cleanup_expired_approvals(app: &AppHandle, state: &AppState, conv_i .replace_tool_result_content(&tc_id, &format!("审批超时({}分钟)已自动取消,请改换其他方案", timeout_minutes)); } - // 3. emit AiApprovalResult(让前端清除 pending UI) - let ev = AiChatEvent::AiApprovalResult { - id: tc_id.clone(), - approved: false, - conversation_id: Some(conv_id.to_string()), - }; - let _ = app.emit("ai-chat-event", ev.clone()); - let _ = state.ai_event_bus.publish_event(ev); + // 3. emit AiApprovalResult(让前端清除 pending UI,emit+publish 双写经正典 helper) + emit_ai_chat_event( + app, + AiChatEvent::AiApprovalResult { + id: tc_id.clone(), + approved: false, + conversation_id: Some(conv_id.to_string()), + }, + ); // 4. audit_finalize 记入审计表 audit_finalize( diff --git a/src-tauri/src/commands/ai/agentic/context_lifecycle.rs b/src-tauri/src/commands/ai/agentic/context_lifecycle.rs index ed5a1c7..49e8fbb 100644 --- a/src-tauri/src/commands/ai/agentic/context_lifecycle.rs +++ b/src-tauri/src/commands/ai/agentic/context_lifecycle.rs @@ -32,17 +32,18 @@ use std::sync::Arc; -use tauri::{AppHandle, Emitter, Manager}; +use tauri::AppHandle; use tokio::sync::Mutex; use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary}; use df_ai::provider::ChatMessage; use df_storage::models::AiProviderRecord; -use crate::state::{AppState, LlmConcurrency}; +use crate::state::LlmConcurrency; // compress_via_llm 位于 commands::ai::compress(mod.rs 的 super::compress)。 use super::super::compress::compress_via_llm; +use super::super::event_bus::emit_ai_chat_event; use super::{AiChatEvent, AiSession}; /// 压缩保护区条数(对齐 clear/compress IPC 的 PROTECT_COUNT=6)。 @@ -109,13 +110,10 @@ pub(super) async fn maybe_auto_compress( }; if should_compress { - // emit 压缩开始 + 置位防重入 + 读 active 克隆(不改 status)。 - let ev = AiChatEvent::AiCompressing { + // emit 压缩开始(emit+publish 双写经 emit_ai_chat_event)+ 置位防重入 + 读 active 克隆(不改 status)。 + emit_ai_chat_event(app_handle, AiChatEvent::AiCompressing { conversation_id: Some(conv_id.to_string()), - }; - let _ = app_handle.emit("ai-chat-event", ev.clone()); - // L3 emit 双写:tunnel subscriber透传 miniapp - let _ = app_handle.state::().ai_event_bus.publish_event(ev); + }); let (mut active_msgs, lang) = { let mut session = session_arc.lock().await; let conv = session.conv(&conv_id); @@ -220,30 +218,15 @@ pub(super) async fn maybe_auto_compress( session.conv(&conv_id).messages.set_compressing(false); } } - if KEYWORD_FALLBACK_ENABLED { - // 同 LLM 失败兜底:标 compressed 释放 token + 关键词摘要塞首位(若非空)。 - let inserted = { - let mut session = session_arc.lock().await; - if !session.per_conv.contains_key(&conv_id) { - return; - } - let conv = session.conv(&conv_id); - let _compressed = conv.messages.compress_old_messages(protect_start); - if !keyword_fallback.is_empty() { - conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback)); - true - } else { - false - } - }; - if inserted { - tracing::info!( - conv_id = %conv_id, - pre_tokens = pre_compress_tokens, - "[ai] 压缩 provider 重建失败兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)" - ); - } - } + keyword_fallback_commit( + &session_arc, + &conv_id, + protect_start, + &keyword_fallback, + pre_compress_tokens, + "压缩 provider 重建失败", + ) + .await; return; } }; @@ -286,13 +269,10 @@ pub(super) async fn maybe_auto_compress( // 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体), // 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。 // miniapp 仍插摘要气泡(对端发生压缩告知用户)。 - let ev = AiChatEvent::AiAutoCompressed { + emit_ai_chat_event(&app_handle, AiChatEvent::AiAutoCompressed { conversation_id: Some(conv_id.clone()), summary: summary.nl_summary, - }; - let _ = app_handle.emit("ai-chat-event", ev.clone()); - // L3 emit 双写:tunnel subscriber透传 miniapp - let _ = app_handle.state::().ai_event_bus.publish_event(ev); + }); } Ok(None) => { // 保护区外无 active 可压缩(已全 compressed/archived)→ noop,仅复位 is_compressing。 @@ -329,30 +309,15 @@ pub(super) async fn maybe_auto_compress( "[ai] 自动压缩失败(后台),降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})", KEYWORD_FALLBACK_ENABLED, ); - if KEYWORD_FALLBACK_ENABLED { - // 标 compressed 释放 token + 关键词摘要塞首位(若非空)。 - let inserted = { - let mut session = session_arc.lock().await; - if !session.per_conv.contains_key(&conv_id) { - return; - } - let conv = session.conv(&conv_id); - let _compressed = conv.messages.compress_old_messages(protect_start); - if !keyword_fallback.is_empty() { - conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback)); - true - } else { - false - } - }; - if inserted { - tracing::info!( - conv_id = %conv_id, - pre_tokens = pre_compress_tokens, - "[ai] 压缩失败兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)" - ); - } - } + keyword_fallback_commit( + &session_arc, + &conv_id, + protect_start, + &keyword_fallback, + pre_compress_tokens, + "压缩失败", + ) + .await; } } }); @@ -360,3 +325,47 @@ pub(super) async fn maybe_auto_compress( } false } + +/// 关键词兜底提交单点(DRY #8 收敛,2026-10-01)。 +/// +/// 抽自 [`maybe_auto_compress`] 后台 task 内的逐字双胞胎块:压缩 provider 重建失败路径 +/// 与压缩 LLM 失败路径共用「KEYWORD_FALLBACK_ENABLED 门控 → 锁内 check+mutate +/// (标 compressed 释放 token + 关键词摘要插首位,非空才插)→ 成功插入打 info」。 +/// 两条失败路径只差日志正文,经 `log_head` 参数化(拼为 +/// "[ai] {log_head}兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)")。 +/// +/// conv 已删(per_conv 无条目)→ 直接返回不兜底(对齐原 return 语义:防惰性重建污染新 conv)。 +async fn keyword_fallback_commit( + session_arc: &Arc>, + conv_id: &str, + protect_start: usize, + keyword_fallback: &str, + pre_tokens: u32, + log_head: &str, +) { + if !KEYWORD_FALLBACK_ENABLED { + return; + } + let inserted = { + let mut session = session_arc.lock().await; + if !session.per_conv.contains_key(conv_id) { + return; + } + let conv = session.conv(conv_id); + let _compressed = conv.messages.compress_old_messages(protect_start); + if keyword_fallback.is_empty() { + false + } else { + conv.messages.insert_at(0, ChatMessage::system(keyword_fallback)); + true + } + }; + if inserted { + tracing::info!( + conv_id = %conv_id, + pre_tokens, + "[ai] {}兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)", + log_head + ); + } +} diff --git a/src-tauri/src/commands/ai/agentic/guard.rs b/src-tauri/src/commands/ai/agentic/guard.rs index 471a96e..d425139 100644 --- a/src-tauri/src/commands/ai/agentic/guard.rs +++ b/src-tauri/src/commands/ai/agentic/guard.rs @@ -11,8 +11,9 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; -use tauri::{AppHandle, Emitter, Manager}; +use tauri::{AppHandle, Manager}; +use crate::commands::ai::event_bus::emit_ai_chat_event; use crate::commands::ai::AiChatEvent; use super::conv_state::ConvState; @@ -26,7 +27,7 @@ use super::conv_state::ConvState; /// epoch 语义:`loop_epoch` 是 per_conv 的并发 owner token(每 spawn 新 loop 前 fetch_add(1)); /// `my_epoch` 是本 guard 对应 loop 捕获的 epoch。复位只认 owner(epoch 匹配), /// 旧 loop 复位跳过(防 clobber 新 loop 状态)。 -pub(super) struct GeneratingGuard { +pub(crate) struct GeneratingGuard { conv_id: String, done: bool, /// per_conv.loop_epoch 引用(锁无关原子读,owner 判定用)。 @@ -37,7 +38,7 @@ pub(super) struct GeneratingGuard { } impl GeneratingGuard { - pub(super) fn new( + pub(crate) fn new( conv_id: String, loop_epoch: Arc, my_epoch: u64, @@ -58,7 +59,7 @@ impl GeneratingGuard { /// /// 只认 owner:本 loop epoch 匹配当前 loop_epoch 才迁移 Idle; /// 不匹配(旧 loop)则仅置 done(跳过迁移,防 clobber 新 loop 的 Generating)。 - pub(super) async fn reset(&mut self) { + pub(crate) async fn reset(&mut self) { if self.done { return; } @@ -76,7 +77,7 @@ impl GeneratingGuard { } /// 解除 Drop 兜底(审批等待保持 Generating)。 - pub(super) fn disarm(&mut self) { + pub(crate) fn disarm(&mut self) { self.done = true; } @@ -102,12 +103,7 @@ impl GeneratingGuard { } }; if let Some(ev) = ev { - let _ = self.app_handle.emit("ai-chat-event", ev.clone()); - let _ = self - .app_handle - .state::() - .ai_event_bus - .publish_event(ev); + emit_ai_chat_event(&self.app_handle, ev); } } } diff --git a/src-tauri/src/commands/ai/agentic/helpers.rs b/src-tauri/src/commands/ai/agentic/helpers.rs index 194c2e2..a42eb98 100644 --- a/src-tauri/src/commands/ai/agentic/helpers.rs +++ b/src-tauri/src/commands/ai/agentic/helpers.rs @@ -1,14 +1,44 @@ //! 审批后循环恢复 — 从 agentic/mod.rs 拆分。 //! 原代码被嵌套在 run_agentic_loop 内部导致 pub(crate) 不可见。 -use tauri::{AppHandle, Emitter, Manager}; +use std::sync::Arc; + +use tauri::AppHandle; +use tokio::sync::Mutex; + +use df_ai::provider::ChatMessage; + use crate::state::AppState; use super::approval_timeout; +use crate::commands::ai::event_bus::emit_ai_chat_event; use crate::commands::ai::prompt::{build_system_prompt_parts, cache_enabled, get_active_provider}; use crate::commands::ai::knowledge_inject::{build_knowledge_context_for_conv, inject_knowledge_into_prompt}; -use crate::commands::ai::{AiChatEvent, ErrorType, SessionState, GoalEntry}; +use crate::commands::ai::{AiChatEvent, AiSession, ErrorType, SessionState, GoalEntry}; use super::conv_state::ConvState; +/// 警示 system 消息插入单点(DRY #7 收敛,2026-10-01)。 +/// +/// 抽自 4 处同构骨架(insert_destructive_loop_warning / insert_stall_warning / +/// insert_tool_call_over_limit_warning / audit::cache insert_listing_bypass_warning): +/// 「lock → per_conv.contains_key 守卫 → messages.insert_at(0, system) → tracing::info」。 +/// conv 已删(per_conv 无条目)→ 跳过插入与日志(对齐原守卫语义)。 +/// +/// `log_tag` 为日志正文(不带 "[ai] " 前缀,helper 统一补);各站点特有字段 +/// (iteration/loop_count 等)由调用方编入 tag 文本保留定位信息,helper 统一打 conv_id 字段。 +pub(crate) async fn insert_system_notice( + session_arc: &Arc>, + conv_id: &str, + text: &str, + log_tag: &str, +) { + let mut session = session_arc.lock().await; + if session.per_conv.contains_key(conv_id) { + let conv = session.conv(conv_id); + conv.messages.insert_at(0, ChatMessage::system(text)); + tracing::info!(conv_id = %conv_id, "[ai] {}", log_tag); + } +} + /// try_continue_agent_loop 续跑判定所需 session 字段的一次性快照。 struct ContinueSnapshot { is_generating: bool, @@ -59,17 +89,15 @@ pub async fn try_continue_agent_loop( Some(cid) => cid, None => conv_id.to_string(), }; - let ev = AiChatEvent::AiCompleted { + // 零 token 收敛信号,非估算(无真实 LLM 调用)。 + emit_ai_chat_event(app, AiChatEvent::AiCompleted { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0, - // 零 token 收敛信号,非估算(无真实 LLM 调用)。 is_estimated: false, incomplete: None, conversation_id: Some(emit_conv_id), pinned_goals: snap.pinned_goals_snapshot.clone(), - }; - let _ = app.emit("ai-chat-event", ev.clone()); - let _ = app.state::().ai_event_bus.publish_event(ev); + }); } return; } @@ -81,12 +109,11 @@ pub async fn try_continue_agent_loop( if let Err(e2) = state.conv_states.transition(conv_id, ConvState::Idle) { tracing::warn!(conv_id = %conv_id, error = %e2, "conv_states→Idle 非法"); } - let _ = app.emit("ai-chat-event", AiChatEvent::AiError { - error: e.clone(), error_type: Some(ErrorType::ProviderConfig), - conversation_id: Some(conv_id.to_string()), - }); - let _ = app.state::().ai_event_bus.publish_event(AiChatEvent::AiError { - error: e, error_type: None, + // emit/publish 原两端 error_type 不一致(emit=ProviderConfig / publish=None, + // 手抄漂移),收敛 emit_ai_chat_event 后统一 ProviderConfig(对齐 emit_fatal_error 先例)。 + emit_ai_chat_event(app, AiChatEvent::AiError { + error: e, + error_type: Some(ErrorType::ProviderConfig), conversation_id: Some(conv_id.to_string()), }); return; @@ -133,24 +160,19 @@ pub async fn try_continue_agent_loop( // B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争,无需 session lock)。 let still_generating = state.conv_states.is_active(conv_id); if !still_generating { - let ev = AiChatEvent::AiCompleted { + // 零 token 收敛信号,非估算(无真实 LLM 调用)。 + emit_ai_chat_event(app, AiChatEvent::AiCompleted { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0, - // 零 token 收敛信号,非估算(无真实 LLM 调用)。 is_estimated: false, incomplete: None, conversation_id: Some(conv_id_owned.clone()), pinned_goals: snap.pinned_goals_snapshot.clone(), - }; - let _ = app.emit("ai-chat-event", ev.clone()); - let _ = app.state::().ai_event_bus.publish_event(ev); + }); return; } - let _ = app.emit("ai-chat-event", AiChatEvent::AiAgentRound { - round: 0, conversation_id: Some(conv_id_owned.clone()), - }); - let _ = app.state::().ai_event_bus.publish_event(AiChatEvent::AiAgentRound { + emit_ai_chat_event(app, AiChatEvent::AiAgentRound { round: 0, conversation_id: Some(conv_id_owned.clone()), }); @@ -168,63 +190,19 @@ pub async fn try_continue_agent_loop( /// 从 LLM 本轮调用的工具中推理目标描述(本轮可能多个,全部提取)。 /// 工具调用是结构化数据(工具名+参数),比从文本中提取更可靠。 +/// +/// DRY 债 #5 收敛:动词/参数键/取值风格/缺参兜底不再本文件手写 match, +/// 改查注册元数据(goal 维度,在 declare_tool! 注册点声明)。 +/// 未声明 goal 的工具不产生目标描述(与旧 match 的 _ => None 分支一致)。 +/// 旧表中的幽灵分支(edit_file,注册表无此工具)随 match 删除一并清理。 pub(crate) fn infer_goal_from_tool_calls(tool_calls: &std::collections::HashMap) -> Vec { if tool_calls.is_empty() { return vec![]; } + crate::commands::ai::tool_registry::ensure_tool_meta_seeded(); let mut goals: Vec = Vec::new(); for tc in tool_calls.values() { - let goal = match tc.name.as_str() { - "patch_file" | "write_file" | "edit_file" | "append_file" => { - serde_json::from_str::(&tc.args).ok() - .and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| { - let f = p.rsplit('/').next().or_else(|| p.rsplit('\\').next()).unwrap_or(p); - format!("修改 {}", f) - })) - } - "read_file" | "file_info" => { - serde_json::from_str::(&tc.args).ok() - .and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| { - let f = p.rsplit('/').next().or_else(|| p.rsplit('\\').next()).unwrap_or(p); - format!("查看 {}", f) - })) - } - "search_files" | "grep" => { - serde_json::from_str::(&tc.args).ok() - .and_then(|args| args.get("pattern").or_else(|| args.get("query")).and_then(|v| v.as_str()).map(|q| { - let s = if q.len() > 25 { &q[..25] } else { q }; - format!("搜索 {}", s) - })) - .or_else(|| Some("搜索文件".to_string())) - } - "run_command" => { - serde_json::from_str::(&tc.args).ok() - .and_then(|args| args.get("command").and_then(|v| v.as_str()).map(|c| { - let s = if c.len() > 25 { &c[..25] } else { c }; - format!("执行 {}", s) - })) - .or_else(|| Some("执行命令".to_string())) - } - "create_project" => { - serde_json::from_str::(&tc.args).ok() - .and_then(|args| args.get("name").and_then(|v| v.as_str()).map(|n| format!("创建项目 {}", n))) - } - "create_idea" => { - serde_json::from_str::(&tc.args).ok() - .and_then(|args| args.get("title").and_then(|v| v.as_str()).map(|t| format!("捕获灵感 {}", t))) - } - "run_workflow" => Some("执行工作流".to_string()), - "delete_file" | "rename_file" => { - serde_json::from_str::(&tc.args).ok() - .and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| { - let f = p.rsplit('/').next().or_else(|| p.rsplit('\\').next()).unwrap_or(p); - format!("处理 {}", f) - })) - } - "list_directory" => { - serde_json::from_str::(&tc.args).ok() - .and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| format!("浏览 {}", p))) - } - _ => None, - }; + let parsed = serde_json::from_str::(&tc.args).ok(); + let goal = df_ai::ai_tools::goal_of(&tc.name) + .and_then(|g| df_ai::ai_tools::goal_text(g, parsed.as_ref())); if let Some(g) = goal { if !goals.contains(&g) { goals.push(g); } } } goals @@ -259,56 +237,22 @@ fn arg_str(args: &serde_json::Value, key: &str) -> String { /// 选「决定意图」参数(决定这次调用"去哪儿查什么"的参数),非决定参数(如 case_sensitive/ /// show_line_numbers/timeout 等开关/格式选项)忽略——它们变体不构成漂移。 /// -/// 归一规则(参数从 args JSON 取,缺失用空串): -/// - grep/search_files/search: `pattern`(或 `query`)+ `path`(或 `glob`) +/// 归一规则(DRY 债 #5 收敛:参数组查注册元数据 sig 维度,在 declare_tool! 注册点声明): +/// - grep/search_files: `pattern`(或 `query` 回退)+ `path`(或 `glob` 回退) /// — 同 path 换 pattern 是正常换词;同 pattern 同 path 才算重复。 -/// - read_file: `path` + `offset` + `limit` +/// - read_file: `path` + `offset` + `limit`(后两为数值键,按 JSON 形态序列化) /// — **同段反复才算重复**;不同 offset = 正常分段读大文件(不算)。 /// - run_command: `command`(整条命令,含参数)。 -/// - list_dir/list_directory: `path`。 -/// - http_request: `url` + `method`。 -/// - fetch_url: `url`。 -/// - 其他/兜底: `name` + args 全 JSON 序列化(无明确语义时保守全量,避免漏判)。 +/// - list_directory: `path`。 +/// - http_request: `url` + `method`;fetch_url: `url`。 +/// - 其他/未声明: `name` + args 全 JSON 序列化(无明确语义时保守全量,避免漏判)。 /// /// 返回 `"name:k1=v1,k2=v2"` 形式。输入 args 通常来自 LLM 的 tool_call function.arguments /// (JSON 字符串),调用方先 from_str 成 Value 再传入。 +/// 旧表中的幽灵分支(search/list_dir,注册表无此工具)随 match 删除一并清理。 pub(crate) fn tool_call_signature(name: &str, args: &serde_json::Value) -> String { - let pair = |k: &str, v: &str| format!("{}={}", k, v); - let sig = match name { - "grep" | "search_files" | "search" => { - let q = if args.get("pattern").and_then(|v| v.as_str()).is_some() { - arg_str(args, "pattern") - } else { - arg_str(args, "query") - }; - let p = if args.get("path").and_then(|v| v.as_str()).is_some() { - arg_str(args, "path") - } else { - arg_str(args, "glob") - }; - format!("{},{}", pair("pattern", &q), pair("path", &p)) - } - "read_file" => { - // offset/limit 数值字段:as_str 不通,先取再转字符串(缺失→"")。 - let offset = args.get("offset").map(|v| v.to_string()).unwrap_or_default(); - let limit = args.get("limit").map(|v| v.to_string()).unwrap_or_default(); - let path = arg_str(args, "path"); - format!("{},{},{}", pair("path", &path), pair("offset", &offset), pair("limit", &limit)) - } - "run_command" => pair("command", &arg_str(args, "command")), - "list_dir" | "list_directory" => pair("path", &arg_str(args, "path")), - "http_request" => { - let url = arg_str(args, "url"); - let method = arg_str(args, "method"); - format!("{},{}", pair("url", &url), pair("method", &method)) - } - "fetch_url" => pair("url", &arg_str(args, "url")), - _ => { - // 兜底:工具名 + args 全 JSON 序列化(保守,无明确语义时全量比对)。 - format!("{},{}", pair("name", name), pair("args", &args.to_string())) - } - }; - format!("{}:{}", name, sig) + crate::commands::ai::tool_registry::ensure_tool_meta_seeded(); + df_ai::ai_tools::tool_signature(name, args) } /// G2 重复检测纯函数:判定最近 N 个工具调用签名是否构成「卡住反复」。 @@ -479,9 +423,12 @@ mod tests { } #[test] - fn sig_list_dir_takes_path() { + fn sig_list_directory_takes_path() { let args = serde_json::json!({"path": "/tmp"}); - assert_eq!(tool_call_signature("list_dir", &args), "list_dir:path=/tmp"); + assert_eq!(tool_call_signature("list_directory", &args), "list_directory:path=/tmp"); + // 幽灵名 list_dir 已删分支,走未注册全参兜底 + let ghost = tool_call_signature("list_dir", &args); + assert!(ghost.starts_with("list_dir:name=list_dir,args="), "list_dir 应走兜底分支: {}", ghost); } #[test] @@ -531,7 +478,7 @@ mod tests { tool_call_signature("read_file", &serde_json::json!({"path": "b.rs", "offset": 0, "limit": 50})), tool_call_signature("read_file", &serde_json::json!({"path": "c.rs", "offset": 0, "limit": 50})), tool_call_signature("read_file", &serde_json::json!({"path": "d.rs", "offset": 100, "limit": 50})), - tool_call_signature("list_dir", &serde_json::json!({"path": "src-tauri"})), + tool_call_signature("list_directory", &serde_json::json!({"path": "src-tauri"})), tool_call_signature("grep", &serde_json::json!({"pattern": "STALL", "path": "src"})), tool_call_signature("read_file", &serde_json::json!({"path": "e.rs", "offset": 0, "limit": 50})), ]; @@ -562,7 +509,7 @@ mod tests { fn scenario_repeat_same_chunk_repetitive() { let same = tool_call_signature("read_file", &serde_json::json!({"path": "a.rs", "offset": 0, "limit": 50})); let other1 = tool_call_signature("grep", &serde_json::json!({"pattern": "x", "path": "y"})); - let other2 = tool_call_signature("list_dir", &serde_json::json!({"path": "z"})); + let other2 = tool_call_signature("list_directory", &serde_json::json!({"path": "z"})); let sigs = vec![same.clone(), same.clone(), same.clone(), same, other1, other2]; assert!(is_repetitive_exploration(&sigs), "反复读同段应判重复"); } diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index 5245562..4ee5d65 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -22,10 +22,8 @@ use df_ai::context_helpers::PLACEHOLDER_INTEGRITY_ENABLED; use df_ai::intent::{filter_tool_defs, filter_tool_defs_planned, suggested_model_tier, IntentRecognizer, Intent}; use df_ai::coordinator::{Coordinator, ExecutionResult}; use df_ai::persona::PersonaRegistry; -use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole}; -// 复用 retry::backoff_delay(jitter 1s→2s→4s) + is_status_retryable(Fatal 分类) -// 实现流前失败重试退避对齐,避免重写退避逻辑。 -use df_ai::retry; +use df_ai::provider::{ChatMessage, LlmProvider, MessageRole}; +// 流式调用 + 重试退避已随 stream 系函数迁至 stream_relay.rs(retry/stream_recv 其 use 同迁)。 // 智能路由 helper + TaskRequirements + 维度枚举。 // 调用点构造 TaskRequirements(主对话:needs_tool_use=true, // 当前仅 Text 模态;后续多模态接入时检测消息内 Part/Image 追加 Vision), @@ -38,13 +36,14 @@ use df_ai::router::{ use df_storage::db::Database; use df_storage::models::AiProviderRecord; -use crate::state::{AppState, LlmConcurrency, ProviderAcquire}; +// ProviderAcquire 已随 stream_round 迁至 stream_relay.rs。 +use crate::state::{AppState, LlmConcurrency}; use super::audit::process_tool_calls; +use super::event_bus::emit_ai_chat_event; // compress_via_llm 已随压缩逻辑迁至 context_lifecycle.rs(maybe_auto_compress 内调用)。 use super::conversation::{save_conversation, TokenAccumulator}; use super::knowledge_inject::{maybe_spawn_extraction}; -use super::stream_recv::{stream_llm, StreamResult}; use super::title::{ensure_conversation_title, spawn_ensure_title}; use super::{AiChatEvent, AiSession, ErrorType}; // ConvState 经本文件内 `pub mod conv_state;` 同 crate 直接访问(conv_state::ConvState)。 @@ -357,6 +356,12 @@ pub mod command_lock; pub(crate) mod helpers; pub(crate) mod router_setup; pub(crate) mod stall_detector; +// ============================================================ +// 单轮流式中继外移(stream_relay):candidate 链 + 流式调用 + 流前重试 + MidStream 保文 +// (纯搬运语义等价,锁边界/emit 顺序逐字保留,见 stream_relay.rs 文件头)。 +// ============================================================ +pub(crate) mod stream_relay; +pub(crate) use stream_relay::{merge_volatile_tail, stream_round, RoundOutcome}; pub(crate) use router_setup::resolve_model_with_override; pub(crate) use stall_detector::{ check_retry_exhausted, count_recent_failures, last_round_has_match_evidence, @@ -369,600 +374,6 @@ pub(crate) use helpers::{ DestructiveLoopSample, destructive_loop_sample, detect_destructive_loop, }; -// ============================================================ -// 单 Provider 流式结果 + fallback 辅助 -// ============================================================ - -/// 单 candidate provider 一次迭代的流式调用结果。 -/// -/// 显式区分三态,驱动外层 `for candidate` fallback 循环: -/// - `Success`:`Complete` 或 `Partial`(MidStream 保文)。携带该 provider 路由出的 -/// `resolved_model`(调用方据此更新迭代级 resolved_model,供 push/save)。 -/// - `InitFailedExhausted`:流前 `InitFailed{retryable=true}` 在本 provider 上重试 -/// 耗尽(或预算 30s 耗尽)。retryable=true 即「瞬态错误,换 provider 可能成功」→ -/// 外层 `continue` 切下一 candidate(重建 build_provider + resolved_model 重算)。 -/// 携带 `error`(最后一条诊断文本),供外层「全 candidate 耗尽」时 emit 最终 AiError。 -/// - `Fatal`:流前 `InitFailed{retryable=false}`(4xx 非429/鉴权/参数错)。立即放弃 -/// 整个 fallback(对齐 retry.rs Fatal 分类 + provider_pool 文档「不可重试错误 -/// 立即放弃不浪费备用 provider」)。携带 `error`,stream_one_provider 内 Fatal 分支 -/// emit AiError(终态不切候选,无残留气泡风险),调用方仅做 guard.reset + return。 -/// 空 key(build_provider_for Err)同样归 Fatal 语义——key 缺失/钥匙串损坏非瞬态, -/// 换 provider 无意义(但实际场景:外层已对 primary 做了启动 Auth 早失败,故此分支 -/// 多见于 secondary 配置不一致,保守 Fatal 收敛)。 -enum StreamOutcome { - Success { - text: String, - tool_calls: std::collections::HashMap, - usage: df_ai::provider::TokenUsage, - incomplete: bool, - resolved_model: String, - /// DeepSeek thinking 模式推理内容(需回传到下一轮请求) - reasoning_content: Option, - }, - /// error 字段供外层全 candidate 耗尽时 emit 最终 AiError(单气泡聚合)。 - InitFailedExhausted { error: String }, - /// error 字段供 stream_one_provider 内 Fatal 分支 emit AiError。 - Fatal { error: String }, -} - -/// 单 provider 流式调用 + 重试。 -/// -/// 封装一个 candidate 的:resolve_secret → ensure_resolved_key → build_provider -/// → select_model_id(resolved_model 在该 candidate.model_configs 上重算) -/// → 流式 stream_recv + 流前 InitFailed retryable 重试循环。 -/// -/// 与主链共享的退避/分类:`retry::backoff_delay`(1s→2s→4s+jitter) + -/// `StreamResult::InitFailed{retryable}`(镜像 retry::is_status_retryable) + -/// 30s 总挂钟预算(`retry_deadline` 由调用方传入,本轮 fallback 各 candidate 共享一个预算)。 -/// -/// **permit 责任**:本函数不取并发 permit——permit 持有期间多 provider 限流不重叠, -/// 由调用方在进 candidate 前取 global/per_conv(整轮迭代共享) + 本 candidate 的 -/// per-provider permit(切换 candidate 时释放旧取新)。 -/// -/// 返回 [`StreamOutcome`]:Fatal 含已 emit AiError;Success 含保文或正常完成; -/// InitFailedExhausted 让调用方切下一 candidate。 -async fn stream_one_provider( - candidate: &AiProviderRecord, - messages: &[ChatMessage], - tool_defs: &[df_ai::provider::ToolDefinition], - app_handle: &AppHandle, - stop_flag: &std::sync::atomic::AtomicBool, - notify: &tokio::sync::Notify, - conv_id: &str, - max_retries: usize, - retry_deadline: tokio::time::Instant, - model_override: &Option, - agentic_req: &TaskRequirements, - last_reasoning_content: &Option, -) -> StreamOutcome { - // resolve→ensure→build 三步(复用 secret::build_provider_for,DRY)。 - // key 缺失/损坏 → Err → 归 Fatal(stream_llm Fatal 也是 key 类错,语义一致)。 - // 注:primary 候选的启动 Auth 早失败已在外层 run_agentic_loop 顶部处理(emit + return), - // 本函数到达时 primary 的 key 已验证过;此处 Err 多见于 secondary 配置不一致, - // 保守归 Fatal 立即放弃(不浪费预算试下一 provider,因 key 错非瞬态)。 - let provider: Arc = match super::secret::build_provider_for(candidate) { - Ok(p) => Arc::from(p), - Err(msg) => return StreamOutcome::Fatal { error: msg }, - }; - - // resolved_model 在本 candidate.model_configs 上重算(provider 切换后 - // 模型池不同,必须重选;否则拿主 provider 的 model_id 去打次 provider 会吃 400/404)。 - // model_override 穿透:override 非空且在本 candidate 池中 → 用 override; - // 否则用 resolved_model(绝不因 override 致无模型)。 - let resolved_model = resolve_model_with_override(agentic_req, candidate, model_override); - - // 流前 InitFailed 重试循环。 - // 重试逻辑抽至 stream_with_retry helper(扁平重构),本函数仅负责 provider 准备 + model 决议 + 委派。 - stream_with_retry( - &provider, candidate, messages, tool_defs, app_handle, stop_flag, notify, conv_id, - max_retries, retry_deadline, &resolved_model, last_reasoning_content, - ).await -} - -// ── stream_with_retry: 流式 + 流前 InitFailed retryable 重试循环(扁平抽自原嵌套 5 层 match) ── -// 返回 StreamOutcome。内部含 retry_attempt 循环 + match StreamResult 三分支: -// Complete/Partial → Success;InitFailed Fatal → Fatal;InitFailed Retryable → 重试/耗尽。 -// 嵌套 ≤ 3 层:for 内 match 内单层 if(早返回避免嵌套加深)。 -async fn stream_with_retry( - provider: &Arc, - candidate: &AiProviderRecord, - messages: &[ChatMessage], - tool_defs: &[df_ai::provider::ToolDefinition], - app_handle: &AppHandle, - stop_flag: &std::sync::atomic::AtomicBool, - notify: &tokio::sync::Notify, - conv_id: &str, - max_retries: usize, - retry_deadline: tokio::time::Instant, - resolved_model: &str, - last_reasoning_content: &Option, -) -> StreamOutcome { - // 供应商级采样温度覆盖:provider config JSON 带 "temperature" 字段则用之, - // 治受限模型(如 Kimi 系经 u-switch 透传)仅接受 temperature=1、其它值 400 的问题; - // 未配置/非法 JSON 走默认 0.7。candidate 在重试循环内不变,解析提到循环外只做一次。 - let temperature = candidate - .config - .as_deref() - .and_then(|cfg| serde_json::from_str::(cfg).ok()) - .and_then(|v| v.get("temperature").and_then(|t| t.as_f64())) - .map(|t| t as f32) - .or(Some(0.7)); - for retry_attempt in 0..=max_retries { - let retry_request = CompletionRequest { - model: resolved_model.to_string(), - messages: messages.to_vec(), - temperature, - max_tokens: Some(8192), - stream: true, - tools: if tool_defs.is_empty() { None } else { Some(tool_defs.to_vec()) }, - tool_choice: None, - reasoning_content: last_reasoning_content.clone(), - }; - - let result = stream_llm( - Arc::clone(provider), retry_request, app_handle, stop_flag, notify, conv_id, - ).await; - - match result { - StreamResult::Complete { text, tool_calls, usage, reasoning_content } => { - return StreamOutcome::Success { - text, tool_calls, usage, incomplete: false, - resolved_model: resolved_model.to_string(), reasoning_content, - }; - } - StreamResult::Partial { text, tool_calls, usage, reasoning_content } => { - // MidStream 保文不重试。携带 incomplete=true 交调用方走保文路径。 - tracing::warn!( - conv_id = %conv_id, - text_len = text.len(), - "[ai] 流中途失败(候选 {}),保文不重试(incomplete=true)", - candidate.name, - ); - return StreamOutcome::Success { - text, tool_calls, usage, incomplete: true, - resolved_model: resolved_model.to_string(), reasoning_content, - }; - } - StreamResult::InitFailed { retryable, error } => { - // Fatal(4xx 非429/鉴权/参数错)立即放弃整个 fallback。 - if !retryable { - tracing::warn!( - conv_id = %conv_id, - provider = %candidate.name, - attempt = retry_attempt + 1, - "[ai] 候选 {} 流前失败 Fatal(4xx/鉴权),立即放弃 fallback", - candidate.name, - ); - return StreamOutcome::Fatal { error }; - } - // Retryable:判断是否重试预算耗尽 - if let Some(exhausted_error) = check_retry_exhausted( - conv_id, candidate, retry_attempt, max_retries, retry_deadline, &error, - ) { - return StreamOutcome::InitFailedExhausted { error: exhausted_error }; - } - // 未耗尽:退避 + emit AiStreamRetry + sleep + continue - emit_retry_attempt( - app_handle, conv_id, retry_attempt, max_retries, retry_deadline, - ).await; - } - } - } - - // 仅 max_retries=0(无重试配置)且首试 InitFailed retryable=true 时到达此处: - // 0..=0 循环首趟即 is_last → 已在循环内 return InitFailedExhausted。此为防御兜底。 - StreamOutcome::InitFailedExhausted { - error: "AI 调用失败:重试预算耗尽(防御兜底)".to_string(), - } -} - -// ============================================================ -// stream_round: 单轮流式调用(candidate 链 + stream_one_provider + MidStream 保文) -// ============================================================ - -/// `run_agentic_loop` for 循环体「单轮流式调用」自包含块的返回结果。 -/// -/// 抽自 L1505-L1756(retry_deadline → candidate_chain 构建/降级 → candidate 循环 -/// (stream_one_provider + match 三态) → epoch 后校验 → MidStream 保文)。 -/// 表达本轮的 4 种结束路径,供 loop 主框架据以 `return` 或继续后续处理。 -/// -/// **不在本函数范围**(loop 内其它段): -/// - 停止/收敛/审批等待/熔断这些在 process_tool_calls 之后, -/// 属「工具执行后路径」,与流式调用本身解耦,留 loop 主体。 -/// - 正常完成(loop 外 L1957+):loop 退出后由 run_agentic_loop 直收尾。 -enum RoundOutcome { - /// 本轮 stream 成功(Complete,无 incomplete)。携带本轮输出供 loop 后续 - /// push_assistant_message / save / 工具执行处理。 - /// - /// 注:reasoning_content 不在此 variant 携带——stream_round 内已通过 - /// `*last_reasoning_content = rc` 写回 caller 的 loop 级 mut 变量,loop 后续 - /// push_assistant_message 读 last_reasoning_content 即可(零行为变更)。 - Continue { - full_text: String, - tool_calls: std::collections::HashMap, - round_usage: df_ai::provider::TokenUsage, - }, - /// 流返回后 stale(epoch 已变,被新 loop 接管):guard 已 disarm, - /// loop 应直接 return(不再 push/save/执行工具)。 - StaleAfterStream, - /// MidStream 保文路径已完整收尾(tokens.add + push partial + 系统提示 + - /// finish_round_exit save/reset/emit)→ loop 直接 return。 - MidStreamSaved, - /// Fatal(候选 Fatal 或全候选耗尽):guard.reset + epoch 检查 + emit AiError + - /// save 已完成 → loop 直接 return。 - Fatal, -} - -/// 单轮流式调用:抽自 `run_agentic_loop` for 循环体的「单轮流式」自包含块。 -/// -/// 范围 = retry_deadline 计算 → candidate_chain 构建(G4.1 防抖降级) -/// → candidate 循环(try_acquire_for_provider + stream_one_provider + match 三态) -/// → epoch 后 stale 检查 → MidStream 保文路径。返回 [`RoundOutcome`] 表达本轮结果。 -/// -/// **逐字节等价**:原 L1505-L1756 内联块的 save/emit/guard.reset/permit 顺序、 -/// token 累加、push_assistant_message 调用、finish_round_exit 退出序列全部保留。 -/// 抽取仅改控制流(原各路径 `return` → `return RoundOutcome::Xxx`,loop 据返回值决定 return)。 -/// -/// **借用处理**: -/// - `tokens: &mut TokenAccumulator` / `saved_token_snapshot: &mut TokenUsage` / -/// `last_round_estimated: &mut bool`:loop 级累计状态,&mut 借用,函数内修改 caller 可见。 -/// - `resolved_model: &mut String` / `provider_config: &mut AiProviderRecord` / -/// `last_reasoning_content: &mut Option`:loop 级 mut 变量(被 candidate -/// 切换覆盖),&mut 借用,函数内赋新值后 loop 后续 push/save 用最新。 -/// - `guard: &mut GeneratingGuard`:RAII 复位,&mut 借用,函数内 guard.reset()/disarm() -/// 直接作用于 caller 的 guard。 -/// - 其它(session_arc/db/app_handle/conv_id/...):& 引用。 -/// -/// **permit**:per-provider permit 仍在 candidate 循环内取/释放(随作用域 Drop), -/// 与原实现一致;per_conv permit 由 loop 入口整 loop 持有,本函数不涉及。 -#[allow(clippy::too_many_arguments)] -async fn stream_round( - session_arc: &Arc>, - db: &Arc, - conv_id: &str, - messages: &[ChatMessage], - tool_defs: &[df_ai::provider::ToolDefinition], - app_handle: &AppHandle, - stop_flag: &std::sync::atomic::AtomicBool, - notify: &tokio::sync::Notify, - max_retries: usize, - model_override: &Option, - agentic_req: &TaskRequirements, - estimated_prompt: u32, - // &mut loop 级状态(函数内修改,caller 可见) - last_round_estimated: &mut bool, - last_reasoning_content: &mut Option, - resolved_model: &mut String, - provider_config: &mut AiProviderRecord, - provider_saturation: &mut HashMap, - tokens: &mut TokenAccumulator, - saved_token_snapshot: &mut df_ai::provider::TokenUsage, - guard: &mut GeneratingGuard, - // & 引用 - candidates: &[AiProviderRecord], - llm_concurrency: &LlmConcurrency, - pinned_goals_snapshot: &[super::GoalEntry], - loop_epoch_arc: &std::sync::Arc, - my_epoch: u64, -) -> RoundOutcome { - // 重试总预算(挂钟,含 sleep + 各次请求耗时),对齐 retry::MAX_TOTAL_BUDGET 30s。 - // 本轮各 candidate 共享一个 30s 预算(切换 provider 不重置预算, - // 防多 provider 串行重试累加超过单轮总预算)。超预算直接放弃重试交最终错误/保文路径。 - let retry_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); - - // 外层 for candidate 包裹 stream_one_provider。 - // 顺序:[primary, ...candidates](空池兜底时仅 primary)。 - // - Success → 用结果覆盖迭代级 resolved_model/provider_config(供 push/save/标题); - // 成功即 break。 - // - InitFailedExhausted(retryable 耗尽)→ continue 切下一 candidate。 - // - Fatal(4xx 非429/鉴权)→ stream_llm 已 emit AiError,guard.reset + return - // 放弃整轮(对齐 retry.rs Fatal + provider_pool 文档)。 - // 全 candidate 耗尽 → 沿用单 provider 失败语义(guard.reset + return,AiError 已在 - // 各 candidate 的 stream_llm 内 emit 最后一条)。 - // 候选链(拥有 Vec):[primary, ...candidates]。预先 clone primary 入链, - // 避免借用 provider_config(切换成功后需写回 provider_config = candidate.clone())。 - // candidates 空(单 provider)→ candidate_chain 仅 primary,循环跑一次,耗尽即 return。 - let mut candidate_chain: Vec = Vec::with_capacity(1 + candidates.len()); - candidate_chain.push(provider_config.clone()); - candidate_chain.extend(candidates.iter().cloned()); - - // 防瞬时抖动(G4.1):连续 PROVIDER_SATURATION_DEMOTE_THRESHOLD 次饱和才把主 provider - // 降级到链尾。计数器在候选循环内维护(Exhausted +1 / Acquired 清零),跨迭代累计。 - // 链首(当前主 provider)连续 N 次 Exhausted → 移到链尾让 fallback 先试; - // 恢复后 Acquired 清零 → 下轮回到链首。单次/瞬时饱和(N 次内)不降级,防跳过主 provider。 - // first():链构建总是 push 至少 1 个(provider_config),但防御性取首防空链 panic。 - if let Some(streak) = candidate_chain - .first() - .and_then(|c| provider_saturation.get(&c.id)) - .copied() - .filter(|s| *s >= PROVIDER_SATURATION_DEMOTE_THRESHOLD) - { - let saturated = candidate_chain.remove(0); - candidate_chain.push(saturated.clone()); - tracing::info!( - conv_id = %conv_id, - provider = %saturated.name, - streak = streak, - threshold = PROVIDER_SATURATION_DEMOTE_THRESHOLD, - "[ai] 主 provider 连续 {} 次 per-provider 饱和,降级到候选链尾(防抖动跳过)", - streak, - ); - } - - let (full_text, tool_calls_acc, round_usage, incomplete, round_reasoning_content) = { - // outcome 累积成功结果(Complete/Partial),InitFailed 不写入。 - let mut outcome: Option<(String, std::collections::HashMap, df_ai::provider::TokenUsage, bool, Option)> = None; - // 追踪最后一个 Exhausted candidate 的诊断文本,供「全 candidate 耗尽」时 emit 最终 AiError。 - // Fatal 分支即时 emit(终态不切候选),Exhausted 分支仅记录 error 不 emit(可能切下一 candidate 成功, - // emit 会留残留气泡)。 - let mut last_exhausted_error: Option = None; - - 'candidate: for candidate in &candidate_chain { - // per-provider permit(非阻塞三态,G4.1): - // NotConfigured(未配置,无限流)→ 无 permit 直接 proceed(单 provider 零变化); - // Acquired(permit)→ 持 permit stream,切换 candidate 时随作用域 Drop 释放; - // Exhausted(占满)→ 连续饱和计数 +1,跳下一 candidate(降级,不阻塞)。 - // 旧 acquire_for_provider(阻塞 acquire_owned)在主 provider 信号量占满时整条 - // fallback 链卡死(降级失效),已由本三态非阻塞版本取代。 - let _provider_permit = match llm_concurrency.try_acquire_for_provider(&candidate.id).await { - ProviderAcquire::NotConfigured => None, - ProviderAcquire::Acquired(permit) => { - // 成功拿到 permit:该 provider 已恢复,连续饱和计数清零(跨迭代防抖)。 - provider_saturation.insert(candidate.id.clone(), 0); - Some(permit) - } - ProviderAcquire::Exhausted => { - // 占满:连续饱和计数 +1;本次跳下一 candidate(非阻塞,不等待)。 - // 跨迭代防抖:连续 N 次饱和后链首降级(见候选链构建处),防瞬时抖动跳过主 provider。 - *provider_saturation.entry(candidate.id.clone()).or_insert(0) += 1; - tracing::debug!( - conv_id = %conv_id, - provider = %candidate.name, - streak = provider_saturation.get(&candidate.id).copied().unwrap_or(0), - "[ai] 候选 {} per-provider 信号量占满,跳过(非阻塞,防单 provider 429)", - candidate.name, - ); - continue 'candidate; - } - }; - - let stream_outcome = stream_one_provider( - candidate, - messages, - tool_defs, - app_handle, - stop_flag, - notify, - conv_id, - max_retries, - retry_deadline, - model_override, - agentic_req, - last_reasoning_content, - ).await; - - match stream_outcome { - StreamOutcome::Success { text, tool_calls, usage, incomplete, resolved_model: m, reasoning_content: rc } => { - // 成功:更新迭代级 resolved_model + provider_config(供后续 push/save/标题)。 - // mut 解构(已在顶部声明 mut):本轮后续 save/push 用「实际成功所用 provider」 - // 而非主 candidate。compress 已在本轮 stream 之前用过 primary,不受影响。 - *resolved_model = m; - *provider_config = candidate.clone(); - *last_reasoning_content = rc.clone(); - outcome = Some((text, tool_calls, usage, incomplete, last_reasoning_content.clone())); - break 'candidate; - } - StreamOutcome::InitFailedExhausted { error } => { - // 本 candidate 重试耗尽(retryable)。切下一 candidate 继续尝试。 - // candidates 空(单 provider)时此即「耗尽 return」语义—— - // 跳出循环后 outcome 仍 None,落入下方「全耗尽 return」兜底。 - // 不在此 emit AiError(可能切下一 candidate 成功留残留气泡), - // 仅记录 error 供全耗尽兜底 emit 最后一条。 - tracing::warn!( - conv_id = %conv_id, - provider = %candidate.name, - "[ai] 候选 {} 流前失败重试耗尽,切换下一 provider", - candidate.name, - ); - last_exhausted_error = Some(error); - continue 'candidate; - } - StreamOutcome::Fatal { error } => { - // Fatal(4xx 非429/鉴权/参数错):立即放弃整轮 fallback。 - // 统一 emit 最终错误气泡(单气泡聚合)。 - guard.reset().await; - // F1:旧 loop(被新 loop 接管)不 emit 错误气泡(owner loop 负责呈现)。 - if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { - return RoundOutcome::Fatal; - } - emit_fatal_error(app_handle, conv_id, &error).await; - // G1.1(2026-08-05):Fatal 退出也落库 user 消息(镜像下方 Exhausted 分支), - // 否则重启/切会话从 DB 恢复时末条 user 丢失(与 Exhausted 同根因不对称)。 - // epoch 校验通过后、return 前补 save(owner loop 才写,防 owner 已转移仍写)。 - save_conversation(session_arc, db, conv_id, None, Some(&*resolved_model), true).await; - return RoundOutcome::Fatal; - } - } - } - - match outcome { - Some(r) => r, - None => { - // 全 candidate 耗尽(含单 provider 场景):沿用原单 provider 失败语义。 - tracing::warn!( - conv_id = %conv_id, - candidates_tried = candidate_chain.len(), - "[ai] 全 provider 候选流前失败重试耗尽,放弃本轮", - ); - guard.reset().await; - if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { - return RoundOutcome::Fatal; - } - let err_msg = last_exhausted_error - .unwrap_or_else(|| "AI 调用失败:所有候选 provider 重试耗尽".to_string()); - emit_fatal_error(app_handle, conv_id, &err_msg).await; - // 全 provider 失败也需落库 user 消息(已 push 内存), - // 否则切换/重载从 DB 恢复时末条 user 丢失(实测压缩触发失败场景:DB 末条 tool 无后续 user)。 - save_conversation(session_arc, db, conv_id, None, Some(&*resolved_model), true).await; - return RoundOutcome::Fatal; - } - } - }; - - // G4.3:本轮 token 用量是否估算值(provider 未报 prompt_tokens → estimated_prompt 兜底), - // 供 loop 内/loop 后各退出路径透传 AiCompleted(is_estimated)仅作展示标注。 - // 语义修正:整 loop 只要任一轮估算即标估算(累计总量含估算成分),非仅末轮。 - *last_round_estimated |= round_usage.prompt_tokens == 0; - - // F1 并发 epoch:流返回后若已被新 loop 接管(force_send 等),旧 loop 不再 push 消息 / - // 保文 / 执行工具,立即退出(guard disarm 跳过复位,防 clobber 新 loop 的 Generating)。 - if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { - guard.disarm(); - return RoundOutcome::StaleAfterStream; - } - - // CR-30-2 / UX-2025-04 / 决策 a1: MidStream 保文路径——partial_text 已接收, - // 入库为正常 assistant 消息 + emit AiCompleted(incomplete=true) + 追加系统提示消息。 - // 不走 AiError(非异常中断,已有可用文本),不重试(决策 a1)。 - // 并发时用户停优先:网络断同时用户点停 → stop_flag true 时不走保文路径, - // 落入下方 ~1508 stop_flag 检查走停止路径(用户意图优先)。 - // partial 文本仍由下方 push_assistant_message(~1485)保文不丢。 - if incomplete && !stop_flag.load(Ordering::SeqCst) { - let usage = df_ai::provider::TokenUsage { - prompt_tokens: if round_usage.prompt_tokens == 0 { estimated_prompt } else { round_usage.prompt_tokens }, - completion_tokens: round_usage.completion_tokens, - total_tokens: if round_usage.prompt_tokens == 0 { estimated_prompt.saturating_add(round_usage.completion_tokens) } else { round_usage.total_tokens }, - // 分项 token(2026-08-02):cache/reasoning 透传自 round_usage,落库 + 累加器都需 - prompt_cache_hit_tokens: round_usage.prompt_cache_hit_tokens, - prompt_cache_miss_tokens: round_usage.prompt_cache_miss_tokens, - reasoning_tokens: round_usage.reasoning_tokens, - }; - // 累加本轮全量 usage(含 cache/reasoning 分项)到 tokens 累加器 - tokens.add_usage(&usage); - - // 追加 partial assistant 消息(若无 tool_calls 且有文本) - // 退出校验改 conv 存在性 + push 改 per_conv.messages。 - // conv_id 来源:run_agentic_loop 入参。 - { - let mut session = session_arc.lock().await; - if !session.per_conv.contains_key(conv_id) { - tracing::warn!( - stale_conv = %conv_id, - "[ai] MidStream 保文后 conv 已删除,丢弃本轮 push" - ); - return RoundOutcome::MidStreamSaved; - } - // 与 push_assistant_message 一致:trim 归一化,纯空白保文(网络中断只有空白)不落库 - let full_text = full_text.trim(); - if !full_text.is_empty() { - let conv = session.conv(conv_id); - let mut msg = ChatMessage::assistant(full_text); - msg.model = Some(resolved_model.clone()); - // MidStream 保文也回填 reasoning_content - msg.reasoning_content = round_reasoning_content.clone(); - // 消息级 token(对齐 push_assistant_message 双轨持久化):本轮 partial usage - // (prompt=round 或 estimated 兜底,completion=round)。系统提示消息无 token,不设。 - // 分项 token(2026-08-02):cache/reasoning 透传自 round_usage。 - msg.prompt_tokens = Some(usage.prompt_tokens); - msg.completion_tokens = Some(usage.completion_tokens); - msg.prompt_cache_hit_tokens = Some(usage.prompt_cache_hit_tokens); - msg.prompt_cache_miss_tokens = Some(usage.prompt_cache_miss_tokens); - msg.reasoning_tokens = Some(usage.reasoning_tokens); - // 消息级估算标记:本轮 prompt 是否 estimated 兜底,reload 逐条回显对齐 live 态 - msg.is_estimated = Some(round_usage.prompt_tokens == 0); - conv.messages.push(msg); - // 追加系统提示消息:响应因网络中断不完整(对齐决策 a1 系统提示机制) - let mut notice = ChatMessage::system("⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。"); - notice.model = Some(resolved_model.clone()); - conv.messages.push(notice); - } - } - - // 统一走 finish_round_exit 收尾(save + spawn_title + reset + emit)。 - // 注意:partial 文本+系统提示已先 push(上方 block),此 save 落库含本轮 partial,幂等覆盖。 - // save_usage 用增量(tokens 已累加本轮,减上次快照);emit_usage 用 tokens 快照累计。 - // MidStream 分叉:emit_incomplete=Some(true)(前端标不完整),publish_incomplete=None(总线消费方), - // do_publish=true(publish 走总线)。spawn_title=true(后台标题,失败 extract 兜底)。 - let save_usage = usage_delta_since(tokens, saved_token_snapshot); - let emit_usage = tokens_snapshot(tokens); - finish_round_exit( - session_arc, db, conv_id, - Some(&save_usage), Some(&*resolved_model), - true, - provider_config, llm_concurrency, - guard, - &emit_usage, - *last_round_estimated, - Some(true), None, true, - pinned_goals_snapshot, - app_handle, - loop_epoch_arc, my_epoch, - ).await; - return RoundOutcome::MidStreamSaved; - } - - // 正常完成本轮 stream(无 incomplete,或 incomplete 但用户停优先走 stop 路径)。 - // 携带本轮输出交 loop 主体处理后续 push/save/工具执行。 - // round_reasoning_content 已通过上方 `*last_reasoning_content = rc` 写回 caller, - // loop 后续 push_assistant_message 读 last_reasoning_content 即可。 - let _ = round_reasoning_content; // 防未用 warning(MidStream 分支已消费) - RoundOutcome::Continue { - full_text, - tool_calls: tool_calls_acc, - round_usage, - } -} - -// ── emit_retry_attempt: 退避日志 + emit AiStreamRetry + sleep ── -// 前置:未耗尽重试预算(check_retry_exhausted 已返 None)。本函数完成退避后返回。 -async fn emit_retry_attempt( - app_handle: &AppHandle, - conv_id: &str, - retry_attempt: usize, - max_retries: usize, - retry_deadline: tokio::time::Instant, -) { - let now = tokio::time::Instant::now(); - let delay = retry::backoff_delay((retry_attempt + 1) as u32) - .min(retry_deadline.saturating_duration_since(now)); - let total_retry = retry_attempt + 1; - tracing::warn!( - conv_id = %conv_id, - attempt = total_retry, - max_attempts = max_retries + 1, - delay_ms = delay.as_millis() as u64, - "[ai] 流前失败(Retryable),{}ms 后重试 ({}/{})", - delay.as_millis(), total_retry, max_retries + 1, - ); - let ev = AiChatEvent::AiStreamRetry { - attempt: total_retry as u32, - max_attempts: (max_retries + 1) as u32, - conversation_id: Some(conv_id.to_string()), - }; - let _ = app_handle.emit("ai-chat-event", ev.clone()); - let _ = app_handle.state::().ai_event_bus.publish_event(ev); - tokio::time::sleep(delay).await; -} - -/// 把 Anthropic prompt caching 易变尾段追加到请求消息流**末尾**(一条 user 消息)。 -/// -/// 多场景命中率设计要点: -/// - **放消息流末尾而非 system**:任何位于 system 之后的消息级缓存断点(含未来「多轮对话缓存」 -/// 在末条 user 上打断点)都在易变段之前——易变内容(日期/清单/augmentation/知识)每日/每请求 -/// 变化时,只影响其自身及之后(无),不失效已缓存的 system/历史前缀。若放首条 user,跨天日期 -/// 变化会失效其后全部历史缓存。 -/// - **用 user 角色而非 system**:anthropic_compat::convert_request 会把 System-role 消息抽到 -/// 顶层 system(受 cache_control 缓存),易变段若为 system 会随请求变化失效整段 system 缓存。 -/// 必须 user 承载,convert_request 对连续 user 做 merge_consecutive_users,追加安全。 -/// - **不持久化**:仅注入本轮请求的 messages clone,per_conv.messages 不含易变段 → 每轮重建 -/// 恰好一条,无重复。 -fn merge_volatile_tail(messages: &mut Vec, tail: &str) { - if tail.is_empty() { - return; - } - messages.push(ChatMessage::user(tail)); -} - /// Agentic 循环:流式接收 → 工具执行 → 结果回传 LLM → 循环 /// /// 退出条件: @@ -1136,17 +547,11 @@ pub(crate) async fn run_agentic_loop( // G1.1(2026-08-05):入口早退也落库 user 消息(调用方已 push 内存),防重启/切会话丢末条 user。 // resolved_model 此处尚未算出,model 传 None(仅持久化 user 消息,无 assistant 消息可标)。 save_conversation(&session_arc, &db, &conv_id, None, None, true).await; - let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { - error: msg.clone(), - // ensure_resolved_key 失败 = key 缺失/钥匙串损坏,归 Auth - error_type: Some(ErrorType::Auth), - conversation_id: Some(conv_id.clone()), - }); - // L3 emit 双写(2026-06-22):关键 AiError publish 到事件总线,供跨模块订阅。 - // EVENT_BUS_ENABLED 门控在 publish 内部(false 静默丢弃),无消费者时空转不报错。 - let _ = app_handle.state::().ai_event_bus.publish_event(AiChatEvent::AiError { + // ensure_resolved_key 失败 = key 缺失/钥匙串损坏,归 Auth。 + // 双写收敛经 emit_ai_chat_event;error_type 两端统一 Auth(原 publish 端漏传 None 属漂移,顺带修)。 + emit_ai_chat_event(&app_handle, AiChatEvent::AiError { error: msg, - error_type: None, + error_type: Some(ErrorType::Auth), conversation_id: Some(conv_id.clone()), }); return; @@ -1163,15 +568,10 @@ pub(crate) async fn run_agentic_loop( save_conversation(&session_arc, &db, &conv_id, None, None, true).await; tracing::error!(conv_id = %conv_id, "[ai] provider 解析超时(30s),可能 DB/keyring 卡死"); let err_msg = "Provider 解析超时(30s),请检查数据库/钥匙串状态后重试".to_string(); - let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { - error: err_msg.clone(), - error_type: Some(ErrorType::Unknown), - conversation_id: Some(conv_id.clone()), - }); - // L3 emit 双写:超时 AiError publish 到事件总线(同上,门控在 publish 内)。 - let _ = app_handle.state::().ai_event_bus.publish_event(AiChatEvent::AiError { + // 双写收敛经 emit_ai_chat_event;error_type 两端统一 Unknown(原 publish 端漏传 None 属漂移,顺带修)。 + emit_ai_chat_event(&app_handle, AiChatEvent::AiError { error: err_msg, - error_type: None, + error_type: Some(ErrorType::Unknown), conversation_id: Some(conv_id.clone()), }); return; @@ -1434,19 +834,13 @@ pub(crate) async fn run_agentic_loop( task_count = decompose_result.subtasks.len(), "[PLAN-LLM] Plan 创建,emit AiPlanCreated" ); - let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiPlanCreated { - plan_id: plan_id.clone(), + // 双写收敛经 emit_ai_chat_event:同一份事件值双路(原 publish 端重建 payload 的 + // 写法随之消失,build_plan_layers_payload 只调一次,值等价)。 + emit_ai_chat_event(&app_handle, AiChatEvent::AiPlanCreated { + plan_id, layers: layers_payload, conversation_id: Some(conv_id.clone()), }); - let _ = app_handle.state::().ai_event_bus.publish_event( - AiChatEvent::AiPlanCreated { - plan_id, - // layers 已 move,复用 decompose_result 重建等价载荷(reborrow 避免 move) - layers: build_plan_layers_payload(&decompose_result.plan, &coord), - conversation_id: Some(conv_id.clone()), - } - ); // Phase 1:emit 后继续走 ReAct 主链(执行调度是 Phase 2)。 // Plan 仅作规划可见性 + 前端展示;执行仍单链 ReAct,不改 dispatch 逻辑。 } @@ -1524,11 +918,11 @@ pub(crate) async fn run_agentic_loop( } } - // 落库 + generating 复位 + emit AiCompleted(统一走 finish_round_exit,行为零变更) + // 落库 + generating 复位 + emit AiCompleted(统一走 teardown_round,行为零变更) // Coordinator 路径:save(None,None) + 不 spawn_title + emit(None,None,publish=true) // emit_usage=tokens 快照(本路径无 round, tokens 为空, total=0 对齐原 tokens.total()) let coord_usage = tokens_snapshot(&tokens); - finish_round_exit( + teardown_round( &session_arc, &db, &conv_id, None, None, false, @@ -1567,300 +961,28 @@ pub(crate) async fn run_agentic_loop( // 0 = 不限:effective_max=usize::MAX,for 到不了上界,靠 stop_flag/收敛/审批退出(下方达上限暂停分支不触发) let effective_max = if max_iterations == 0 { usize::MAX } else { max_iterations }; for iteration in start_iteration..effective_max { - // 用户请求停止 或 已被新 loop 接管(stale)→ 收尾退出(已生成文本已在上一轮入库)。 - // F1:stale 判定(epoch 不匹配)让旧 loop 在新 loop 启动后立即在此退出,不再继续跑。 - if stop_flag.load(Ordering::SeqCst) || loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { - // save_usage 增量(自上次 save 后新增,首轮即停为 0);emit_usage 累计快照 - let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot); - let emit_usage = tokens_snapshot(&tokens); - // 入口 stop:本轮可能尚未 stream(首轮即停),不记 model——避免把未实际生成的 model 写入 models 数组 - // 统一走 finish_round_exit:save(Some usage, None model) + spawn_title + emit(None,None,publish=true) - // (stale 时 finish_round_exit 内部仅 disarm 跳过 save/reset/emit,不干扰新 loop) - finish_round_exit( - &session_arc, &db, &conv_id, - Some(&save_usage), None, - true, - &provider_config, &llm_concurrency, - &mut guard, - &emit_usage, - // 入口 stop:本轮尚未 stream,last_round_estimated 仍为初始 false(无估算)。 - last_round_estimated, - None, None, true, - &pinned_goals_snapshot, - &app_handle, - &loop_epoch_arc, my_epoch, - ).await; - return; - } - - // 改进5: 主题切换系统标记(保守,双高置信才标,软提示非强制)。 - // - // push 时若末两条 user 消息的 topic 都非 None 且不同(Intent 高置信推断的双 topic), - // ContextManager 已置位 pending_topic_marker("old|new")。loop 顶部读取并消费 - // (take 一次性清空,防重复 insert),insert 一条 system 软提示告知 LLM 用户已切换话题。 - // - // 保守设计: - // - 双高置信:两条 topic 都非 None(都达 0.7 阈值)才标,任一 None(低置信未标)不标。 - // - 软提示:仅 insert 一条 system 消息,不强制 LLM 行为(LLM 仍可按自己理解响应)。 - // - TOPIC_MARKER_ENABLED(常量开关)false → 跳过(排障/对比用)。 - // topic 不参与裁剪/压缩(只供检测),insert_at(0) 同压缩摘要定位(首位 system)。 - if TOPIC_MARKER_ENABLED { - let topic_marker_raw: Option = { - let mut session = session_arc.lock().await; - if !session.per_conv.contains_key(&conv_id) { - tracing::warn!( - stale_conv = %conv_id, - "[ai] conv 已删除,loop 退出(主题标记段入口)" - ); - return; - } - let conv = session.conv(&conv_id); - // G4 目标感知降级:总是 take_topic_marker(防 marker 累积),但若 pinned_goals 非空 - // 且 TOPIC_MARKER_GOAL_AWARE → 丢弃 take 结果(返 None 跳过 insert)。 - // 一次 lock 同读 pinned_goals(避免额外加锁)。take 后丢弃不影响下一轮(marker 每 push - // user 重检测生成,丢弃一次不残留)。GOAL_AWARE=false → 原样返回 marker(退旧行为)。 - let goal_active = TOPIC_MARKER_GOAL_AWARE && !conv.pinned_goals.is_empty(); - let marker = conv.messages.take_topic_marker(); - if goal_active && marker.is_some() { - tracing::info!( - conv_id = %conv_id, - iteration, - "[ai] G4 目标钉扎已启用,跳过话题标记 insert 避免双锚点稀释" - ); - None - } else { - marker - } - }; - if let Some((old_topic, new_topic)) = topic_marker_raw.and_then(|s| { - // 解析 "old|new" 格式;splitn 防 topic 名内含 '|' 误切(仅切首 '|' 一次)。 - let mut parts = s.splitn(2, '|'); - let old = parts.next()?.to_string(); - let new = parts.next()?.to_string(); - Some((old, new)) - }) { - let marker_text = format!( - "── 用户已切换话题(从「{}」到「{}」),请以新话题为准 ──", - old_topic, new_topic - ); - let mut session = session_arc.lock().await; - if session.per_conv.contains_key(&conv_id) { - let conv = session.conv(&conv_id); - conv.messages.insert_at(0, ChatMessage::system(&marker_text)); - tracing::info!( - conv_id = %conv_id, - iteration, - old_topic = %old_topic, - new_topic = %new_topic, - "[ai] 主题切换标记已 insert(软提示,保守双高置信)" - ); - } - } - } - - // 旧 loop 污染防护——每轮开始校验对话一致性。 - // 用户新建/切换对话后 active_conversation_id 变更,本 loop(conv_id 快照)成陈旧, - // 继续跑会往新对话 push 消息/pending 造成污染。检测到即退出(guard Drop 复位 generating)。 - // - // 退出判据:!per_conv.contains_key(conv_id)(conv 存在性)。旧 loop 跑自己的 conv - // 不污染他人,active_conversation_id 切换不应让旧 loop 退出;仅当 conv 被删(clear/delete) - // 才退出。单 loop 阶段 conv 不会被删,此校验主要保留语义对齐(真并发场景生效)。 - // conv_id 来源:run_agentic_loop 入参(与 guard/stop_flag 取用同源)。 - { - let mut session = session_arc.lock().await; - if !session.per_conv.contains_key(&conv_id) { - tracing::warn!( - stale_conv = %conv_id, - "[ai] conv 已删除,旧 loop 退出" - ); - return; - } - // 累计 iteration 计数(「下一轮起算值」= 当前轮+1)。 - // 审批等待/达 max 暂停退出时,本字段即「当前轮+1」;审批续跑 ai_approve 读此值作 - // start_iteration 透传,实现 iteration 累计不重置(防多次审批反复跑满 max 致 token 失控)。 - // per_conv.iteration_used 唯一真相源。 - let conv = session.conv(&conv_id); - conv.iteration_used = iteration + 1; - // F1 并发 epoch:每轮顶部 stale 检查(防旧 loop 在 topic/compress 等长段期间被接管后继续跑)。 - // F2 存活心跳:每轮更新(与 heartbeat_loop 20s 心跳互补,报活更即时)。 - if conv.loop_epoch.load(Ordering::SeqCst) != my_epoch { - guard.disarm(); - return; - } - conv.last_heartbeat.store(df_types::now_millis() as u64, Ordering::SeqCst); - } - - // 新一轮通知前端(第二轮起),前端需新建 assistant 消息 - if iteration > 0 { - let round_n = (iteration + 1) as u32; - let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiAgentRound { - round: round_n, - conversation_id: Some(conv_id.clone()), - }); - // L3 emit 双写:AiAgentRound publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。 - let _ = app_handle.state::().ai_event_bus.publish_event(AiChatEvent::AiAgentRound { - round: round_n, - conversation_id: Some(conv_id.clone()), - }); - } - - // 自动压缩(智能裁剪)——已抽取至 context_lifecycle::maybe_auto_compress。 - // - // 抽取自本函数原内联块。返回 true = conv 已删除,loop 应立即 return - // (对齐原内联块入口的 early-return);返回 false = 正常结束,继续后续流程。 - // 实现细节(触发条件 / 延迟 mutate 口径 / 关键词兜底)见 - // context_lifecycle.rs 顶部文档与函数体内注释。 - // L0-3(2026-08-09):压缩 LLM 调用已后台化(fire-and-forget),本调用不再同步阻塞本轮, - // 返回 false 让当前轮用未压缩上下文正常走;压缩结果下一轮生效(见 context_lifecycle.rs)。 - if maybe_auto_compress( - &session_arc, - &conv_id, - &app_handle, - &provider_config, - &llm_concurrency, - iteration, - sys_tokens, + // ── 轮次准备段:入口 stop/stale 收尾闸门 → 主题标记 → conv 一致性(iteration_used/epoch/心跳) + // → 轮通知 → 自动压缩 → 请求消息构建(build_for_request + namespace 展开 + 易变尾段 + // + tool_result 摘要 + 占位断言)→ estimated_prompt + agentic_req 重建 ── + // (实现见下方 prepare_round_inputs,Exit → 本轮终止 return,与原 early-return 等价) + let (messages, estimated_prompt, agentic_req) = match prepare_round_inputs( + &session_arc, &db, &app_handle, &conv_id, iteration, + &stop_flag, &loop_epoch_arc, my_epoch, + &mut guard, &mut tokens, &mut saved_token_snapshot, + last_round_estimated, &provider_config, &llm_concurrency, + &pinned_goals_snapshot, sys_tokens, &system_prompt, &volatile_tail, &intent, ).await { - return; - } - - // 构建请求消息(超预算时自动裁剪旧消息,保护工具调用三元组 + 最近 6 条) - // sys_tokens 已在 loop 外缓存;本块构建的 messages 在本轮重试循环中复用 - // (本轮 stream_llm 不持 session_arc、不改 messages,重试无 push 发生,重建等价于复用)。 - // build_for_request 读 per_conv.messages。 - // conv_id 来源:run_agentic_loop 入参。 - let messages = { - let session = session_arc.lock().await; - let conv = match session.conv_read(&conv_id) { - Some(c) => c, - None => { - tracing::warn!(stale_conv = %conv_id, "[ai] conv 已删除,loop 退出(build_for_request 入口)"); - return; - } - }; - let (mut history_msgs, _trimmed) = conv.messages.build_for_request(sys_tokens); - // T2-修复(方案A):发 LLM 前展开 namespace 引用为真实内容(view-only,仅本轮请求视图)。 - // push 时(audit/mod.rs)read_file/list_directory/grep/diff_files 等大结果被 - // namespace_store 引用化进 messages;持久化(conversation.rs)已展开写 DB,但发 LLM 的 - // 实时请求此前未展开 → LLM 每轮只看到 "namespace://..." 占位符,AI 实际拿不到工具结果。 - // 此处展开,namespace 退化为内存/持久化层优化,LLM 永远看真实内容。 - // read_only None(条目被 LRU 淘汰 / 重启后 namespace 清空 / 跨会话残留引用)→ - // 替换为 EVICTED_PLACEHOLDER 提示文案(非保留无意义 namespace:// URI)+ warn 日志。 - for m in &mut history_msgs { - if df_ai::namespace_store::is_namespace_ref(&m.content) { - let path = m.content.clone(); - if let Some(original) = session.namespace_store.read_only(&path) { - m.content = original.to_string(); - } else { - tracing::warn!( - conv_id = %conv_id, - path = %path, - "[namespace] 引用已淘汰,替换为提示文案" - ); - m.content = df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string(); - } - } + PreparedRound::Ready { messages, estimated_prompt, agentic_req } => { + (messages, estimated_prompt, agentic_req) } - let mut msgs = vec![ChatMessage::system(&system_prompt)]; - msgs.extend(history_msgs); - // Anthropic prompt caching:把易变尾段(日期/清单/augmentation/知识)追加到消息流末尾。 - // 放末尾而非 system:易变内容不进缓存前缀,每日/每请求变化不失效 system 缓存(跨天仍命中)。 - if let Some(tail) = &volatile_tail { - merge_volatile_tail(&mut msgs, tail); - } - msgs + PreparedRound::Exit => return, }; - // 改进4: tool_result view-only 摘要压缩(build_for_request 后,送 stream 前)。 - // - // 遍历 messages(history clone,已含 system prompt + sanitize 后历史)中的 role=Tool 消息, - // 超阈值(content >2KB 或占历史 token >40%)的 content 应用 extract_key_info 替换: - // 保留错误行(error/panic/失败/.rs:N)+ 首/尾各 5 行,中间省略。 - // - // **view-only**:messages 是 build_for_request 返回的 clone,改它只影响本轮 LLM 请求视图, - // 不改 ContextManager 持久化(对齐 sanitize_messages:DB 原始 tool_result 完整保留)。 - // 故即使摘要有误/过度压缩,下次 build_for_request 仍从 DB 全量重建,可自愈。 - // - // TOOL_RESULT_COMPRESS_ENABLED=false(常量开关)→ 跳过(原样送 LLM,排障/对比用)。 - let messages: Vec = if TOOL_RESULT_COMPRESS_ENABLED { - let history_tokens_snapshot: u32 = { - let session = session_arc.lock().await; - session - .conv_read(&conv_id) - .map(|c| c.messages.history_tokens()) - .unwrap_or(0) - }; - summarize_tool_results(messages, history_tokens_snapshot, &conv_id, iteration) - } else { - messages - }; - - // 占位配对完整性(第二道防线,depth-defense):build_for_request 已在 df-ai 内部 - // 自愈一次,此处 tool_result 压缩/系统提示插入后再断言一次,防转换引入新 orphan。 - // view-only:messages 是 clone,assert_placeholder_pairing 仅改本 Vec,不改 ContextManager 持久化。 - let messages = ContextManager::assert_placeholder_pairing(messages, PLACEHOLDER_INTEGRITY_ENABLED); - - // 预估输入 token(兜底:部分 provider 如 GLM 流式 usage 不报 prompt_tokens,后段用它补) - // 注:stream_one_provider 内每次重试重建 request(因 provider.stream 消费 body), - // 此处不再预构建 request(旧 request 变量已废弃),仅保留 messages 供 estimated_prompt。 - // 语义说明:每轮对全量历史重估并累加(GLM 类无 usage provider 多轮 prompt 总和偏高), - // 但「多轮累计」本就接近真实总量且标估算展示,保留现状(增量估算收益低,不动)。 - let estimated_prompt: u32 = { - let est = TokenEstimator::default(); - messages.iter().map(|m| est.estimate_message(m)).sum() - }; - - // 子项 1+2 根因修复:loop 内重建 agentic_req,真实 estimated_context + 意图 tier。 - // - // **子项 1(estimated_context 死代码)**:原 pre-loop agentic_req 传 0, - // 上下文窗口过滤维度(context_window >= estimated_context)恒过,失效。此处用本轮 - // estimated_prompt(system+history 全量 token 估值)作 estimated_context → 窗口过滤生效 - // (小窗口模型如 8K 被大上下文任务正确滤掉,不再误选)。 - // - // **子项 2(weight tier tiebreak)**:tier 接 intent→ModelTier(Code/Debug→Heavy, - // Chat→Fast 等),同 weight 候选间按 tier tiebreak(满足档位下限的候选胜,见 router.rs)。 - // intent 在 loop 外(786 行)已识别,loop 内每轮复用同一 intent(用户末条 active 消息 - // 在单轮 LLM 调用内不变;多轮对话 intent 演化由用户后续消息触发,下轮 recognize 更新)。 - // - // **变量 shadow**:此绑定覆盖 pre-loop 的 agentic_req(line ~744 算初始 resolved_model - // 用过),candidate chain(下方 stream_one_provider)取此 loop 内版本。Rust shadow 安全: - // pre-loop 版本在 line 744 用完即弃,resolved_model 已落到 mut 变量。 - let agentic_req = TaskRequirements { - modalities: vec![Modality::Text], - needs_tool_use: true, - estimated_context: estimated_prompt as usize, - tier: suggested_model_tier(&intent), - }; - - // LLM 并发限流: - // per_conv 由 loop 入口(_conv_per_conv_permit)整 loop 持有(含工具执行/审批等待/重试), - // 防单对话内并发 LLM 调用失控(单对话内 permits=2,非会话数限制)。 - // global 不再由 loop 入口持有,回归原义「LLM 调用并发限流」: - // 由各单次 LLM 调用点(stream_llm 重试循环内/标题/压缩/提炼/项目分析)各自 acquire/drop 防 429。 - // - // 流前失败(Init Err)重试,流中途失败(MidStream Partial)不重试保文。重试退避复用 - // retry::backoff_delay(1s→2s→4s±20% jitter) + retry::is_status_retryable Fatal 分类 - // (stream_recv classify_status_or_class 镜像, 4xx 非429 立即放弃) + 30s 总挂钟预算。 - // 重试期间持有 per_conv permit 不释放(防新请求挤占)。 - // - // per-provider permit 仍在 candidate 循环内取(切换 candidate 时释放旧取新,避免占用未用 provider 的槽); - // per_conv 由 loop 入口持有。 - - // 单轮流式调用(candidate 链 + stream_one_provider + MidStream 保文)抽至 stream_round。 - // - // 范围:retry_deadline 计算 → candidate_chain 构建/降级 → candidate 循环 - // (try_acquire_for_provider + stream_one_provider + match 三态)→ epoch 后 stale 检查 - // → MidStream 保文路径。返回 RoundOutcome 表达本轮结果: - // - Continue:正常完成,携输出交后续 push/save/工具执行; - // - StaleAfterStream:流后 epoch 变,guard 已 disarm,直接 return; - // - MidStreamSaved:保文路径已完整收尾,直接 return; - // - Fatal:候选 Fatal / 全耗尽,guard.reset + emit + save 已完成,直接 return。 - // - // 借用:tokens/saved_token_snapshot/last_round_estimated/last_reasoning_content/ - // resolved_model/provider_config/provider_saturation/guard 用 &mut(stream_round 内 - // 修改 caller 可见);其它 & 引用。permit 责任不变(per_conv loop 入口持有, - // per-provider candidate 循环内取/释放,均在 stream_round 内)。 - let (full_text, tool_calls_acc, round_usage) = match stream_round( + // ── 流式消费段:单轮流式调用(candidate 链 + MidStream 保文)── + // 实现见 stream_relay::stream_round。RoundOutcome 四态:Continue 携输出继续; + // StaleAfterStream(流后 epoch 变,guard 已 disarm)/ MidStreamSaved(保文已完整收尾)/ + // Fatal(候选 Fatal 或全耗尽,guard.reset + emit + save 已完成)→ 本轮终止 return。 + let (full_text, tool_calls_acc, round_usage, round_is_estimated) = match stream_round( &session_arc, &db, &conv_id, &messages, &tool_defs, &app_handle, &stop_flag, ¬ify, @@ -1873,8 +995,8 @@ pub(crate) async fn run_agentic_loop( &pinned_goals_snapshot, &loop_epoch_arc, my_epoch, ).await { - RoundOutcome::Continue { full_text, tool_calls, round_usage } => { - (full_text, tool_calls, round_usage) + RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated } => { + (full_text, tool_calls, round_usage, round_is_estimated) } RoundOutcome::StaleAfterStream => return, RoundOutcome::MidStreamSaved => return, @@ -1884,202 +1006,44 @@ pub(crate) async fn run_agentic_loop( // stream 后不再立即释放(会话级并发语义:工具执行期间也占槽)。per-provider permit // (_provider_permit)仍在 candidate 循环内随作用域 Drop 自动释放。 - // 累加本轮 token:用 provider 真实 usage。GLM 等流式不报 completion_tokens 时为 0,如实反映(不预估)。 - // 分项 token(2026-08-02):构造本轮完整 usage(prompt 用 estimated 兜底,cache/reasoning 透传 round), - // add_usage 一次性累加 prompt/completion/cache/reasoning 到 tokens 累加器。 - let round_prompt = if round_usage.prompt_tokens == 0 { estimated_prompt } else { round_usage.prompt_tokens }; - let round_usage_full = df_ai::provider::TokenUsage { - prompt_tokens: round_prompt, - completion_tokens: round_usage.completion_tokens, - total_tokens: if round_usage.prompt_tokens == 0 { - estimated_prompt.saturating_add(round_usage.completion_tokens) - } else { - round_usage.total_tokens - }, - prompt_cache_hit_tokens: round_usage.prompt_cache_hit_tokens, - prompt_cache_miss_tokens: round_usage.prompt_cache_miss_tokens, - reasoning_tokens: round_usage.reasoning_tokens, - }; - tokens.add_usage(&round_usage_full); - - // 追加 assistant 消息到历史 + G1 目标提取(扁平重构,原嵌套 8 层 → 3 层) - let has_tool_calls = !tool_calls_acc.is_empty(); - { - let mut session = session_arc.lock().await; - if !session.per_conv.contains_key(&conv_id) { - tracing::warn!( - stale_conv = %conv_id, - "[ai] stream 后 conv 已删除,丢弃本轮 push" - ); - return; - } - push_assistant_message( - &mut session, &conv_id, has_tool_calls, &tool_calls_acc, - &full_text, &resolved_model, &last_reasoning_content, - round_prompt, round_usage.completion_tokens, - round_usage.prompt_cache_hit_tokens, round_usage.prompt_cache_miss_tokens, - round_usage.reasoning_tokens, - round_usage.prompt_tokens == 0, - ); - if GOAL_PIN_ENABLED { - update_pinned_goals(&mut session, &conv_id, &tool_calls_acc); - } - } - - // P0-2 根治:LLM 回复 push 后、工具执行前立即落库。 - // 背景:save_conversation 原仅在 loop 出口调用,process_tool_calls 卡住时(60s timeout - // 或 session lock 竞争)永远到不了出口,用户重启后上轮回复丢失。此处出 push 锁作用域后 - // 立即 save,幂等(每轮重复覆盖落库),即使后续工具卡住本轮消息已持久化。 - { - // 落库传「自上次 save 的增量」而非累计快照(累计会被同 loop 多次 save 重复累加双计) - let usage = usage_delta_since(&tokens, &mut saved_token_snapshot); - save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await; - } - - // 停止信号 或 已被新 loop 接管(stale):已生成文本入库后退出,不再执行后续工具调用。 - // F1:stale 时旧 loop 在此退出,不执行工具(防重复工具执行);finish_round_exit 内部跳过 emit。 - if stop_flag.load(Ordering::SeqCst) || loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { - // save_usage 增量(自上次 save 后新增);emit_usage 累计快照(前端展示本 loop 总量) - let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot); - let emit_usage = tokens_snapshot(&tokens); - // 统一走 finish_round_exit:save(Some usage, Some model) + spawn_title + emit(None,None,publish=true) - finish_round_exit( - &session_arc, &db, &conv_id, - Some(&save_usage), Some(&resolved_model), - true, - &provider_config, &llm_concurrency, - &mut guard, - &emit_usage, - last_round_estimated, - None, None, true, - &pinned_goals_snapshot, - &app_handle, - &loop_epoch_arc, my_epoch, - ).await; - return; - } - - // 无工具调用 → 最终文本响应。正常收敛退出,或目标验证续轮(GOAL_VERIFY_ENABLED)。 - #[allow(unused_assignments)] - if !has_tool_calls { - // 目标验证续轮:开关开 + 有钉扎目标 + 本轮无 applied.matched_target 证据 + 未达上限 - // → 不收敛,注入 nudge 强制续轮(治过早宣称,机制非 prompt 说教)。 - // 任一条件不满足 → 正常收敛(逐字节等价改动前)。 - let needs_verify = GOAL_VERIFY_ENABLED - && !pinned_goals_snapshot.is_empty() - && goal_verify_rounds < GOAL_VERIFY_MAX_ROUNDS - && !last_round_has_match_evidence(&session_arc, &conv_id).await; - if needs_verify { - goal_verify_rounds += 1; - insert_goal_verify_nudge(&session_arc, &conv_id, iteration, goal_verify_rounds).await; - // 不 break,续轮。下轮 LLM 见 nudge → 调工具验证(计数重置)或诚实收敛。 - // 续轮走 loop 顶部正常流程(build_for_request + stream),无特殊路径。 - } else { - // 达上限强制收敛(GOAL_VERIFY_ENABLED && goal_verify_rounds >= MAX): - // LLM 经 N 轮 nudge 仍空手停,目标未验证。标注 warn 供事后核查主入口行为。 - // (未达上限/开关关/无目标/有证据 → 正常收敛,无额外日志。) - if GOAL_VERIFY_ENABLED - && !pinned_goals_snapshot.is_empty() - && goal_verify_rounds >= GOAL_VERIFY_MAX_ROUNDS - { - tracing::warn!( - conv_id = %conv_id, iteration, goal_verify_rounds, - "[GOAL-VERIFY] 达续轮上限强制收敛,目标未验证(LLM 经 nudge 仍空手停)" - ); - } + // ── 轮收尾段:token 累加 + assistant push(+ G1 目标提取)+ 落库 + stop 收尾退出 + // + 收敛/目标验证续轮判定 ── + // (实现见下方 finalize_round_output;Converged → 正常收敛,置 converged 后 break) + match finalize_round_output( + &session_arc, &db, &app_handle, &conv_id, iteration, + &stop_flag, &loop_epoch_arc, my_epoch, + &mut guard, &mut tokens, &mut saved_token_snapshot, + last_round_estimated, &resolved_model, &provider_config, &llm_concurrency, + &pinned_goals_snapshot, + &full_text, &tool_calls_acc, &round_usage, round_is_estimated, + &last_reasoning_content, + &mut goal_verify_rounds, + ).await { + // RunTools → 继续工具调用段(工具调用数由该段内部取,与原时序一致) + RoundEpilogue::RunTools => {} + RoundEpilogue::Converged => { + // 无工具调用且无续轮需求 → 正常收敛退出(原 converged = true; break,语义不变) converged = true; break; } - } - let _ = converged; + RoundEpilogue::Exit => return, + }; - // 续轮内 LLM 调了工具(无论成败)→ 目标验证计数重置(说明在行动非空转,非卡死)。 - // 紧跟在 converged 判定之后、process_tool_calls 之前:本轮 has_tool_calls=true 到这里, - // 说明 LLM 对 nudge 作出了工具响应,清零给后续验证留额度。 - if GOAL_VERIFY_ENABLED && goal_verify_rounds > 0 && has_tool_calls { - goal_verify_rounds = 0; - } - - // AC-2 ①:记录本轮工具调用数(供超限警告判定;process_tool_calls 会 move 走 tool_calls_acc)。 - let round_tool_call_count = tool_calls_acc.len(); - // 处理工具调用(Low 自动执行 / Medium+High 待审批) - let pending_count = process_tool_calls(&session_arc, tool_calls_acc, &tools_arc, &db, &app_handle, &conv_id).await; - // DIRAUTH 审批链已闭环。原 eprintln 诊断降级为 tracing::debug, - // 避免污染 stderr(用户可见),保留排障能力(RUST_LOG=debug 可见)。 - tracing::debug!( - conv_id = %conv_id, - pending_count, - "[AI-DIRAUTH-DIAG] agentic loop 收到 pending" - ); - - // F1 并发 epoch:工具执行期间被新 loop 接管(force_send)后立即退出, - // 不熔断/不停审批/不进下一轮(避免旧 loop 继续污染新 loop 会话)。 - if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { - guard.disarm(); + // ── 工具调用段:工具执行(Low 自动 / Medium+High 待审批)+ 三熔断(L1 失败/G2 探索/ + // 写删回环)+ 审批暂停 ── + // (实现见下方 process_round_tools,Exit → 本轮终止 return) + if let ToolsOutcome::Exit = process_round_tools( + &session_arc, &tools_arc, &db, &app_handle, &conv_id, iteration, + &loop_epoch_arc, my_epoch, + &mut guard, &mut tokens, &mut saved_token_snapshot, + &resolved_model, + tool_calls_acc, + &mut fail_window, + &mut stall_count, &mut stall_warned, &mut loop_count, &mut loop_warned, + ).await { return; } - // AC-2 ①:单轮工具调用数超限 → 注入软提示(机制化,非静默,弱模型能感知)。 - // 治单轮并行调用爆炸:LLM 看不到自己发了多少调用,必须在消息流里显式警告。 - if is_tool_call_over_limit(round_tool_call_count) { - insert_tool_call_over_limit_warning(&session_arc, &conv_id, iteration, round_tool_call_count).await; - } - - // L1 断路器:连续同类工具失败熔断(治 agent 无止损死循环,机制非 prompt 说教)。 - // count_recent_failures 读末尾连续 Tool 消息,失败包按结构化信号归一 key 入滚动窗口计数。 - if CIRCUIT_BREAKER_ENABLED { - let (max_count, sample_key) = count_recent_failures(&session_arc, &conv_id, &mut fail_window).await; - // 锁已随作用域 drop,可安全 await/emit(避免持锁 await 死锁)。 - if max_count >= CIRCUIT_BREAKER_THRESHOLD { - tracing::warn!( - conv_id = %conv_id, - max_count, - sample_key = %sample_key, - "[ai] L1 断路器熔断:连续同类失败 {} 次,疑似死循环停止", max_count - ); - guard.reset().await; - // F1:旧 loop 不 emit 熔断求助卡(owner loop 负责呈现)。 - if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { - return; - } - emit_circuit_breaker_tripped(&app_handle, &conv_id, max_count, &sample_key); - return; - } - } - - // G2 探索熔断:连续空结果无进展检查。抽至 check_stall_breaker helper(扁平重构)。 - // 返回 true=已熔断(loop 应 return),false=继续。stall_count/stall_warned 传入传出。 - if STALL_BREAKER_ENABLED { - let (tripped, new_count, new_warned, new_loop_count, new_loop_warned) = check_stall_breaker( - &session_arc, &app_handle, &conv_id, iteration, - stall_count, stall_warned, loop_count, loop_warned, - ).await; - stall_count = new_count; - stall_warned = new_warned; - loop_count = new_loop_count; - loop_warned = new_loop_warned; - if tripped { - guard.reset().await; - return; - } - } - - // 有待审批 → 暂停循环,等待用户审批后通过 ai_approve → try_continue_agent_loop 恢复 - if pending_count > 0 { - // F1:旧 loop 被接管后不停审批(force_send 已清 pending),直接退出。 - if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { - guard.disarm(); - return; - } - // 落库传「自上次 save 的增量」;审批暂停→恢复跨 loop 实例由 save_conversation old+add 兜住 - let usage = usage_delta_since(&tokens, &mut saved_token_snapshot); - save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await; - // 审批等待 return 前 disarm guard——保持 generating=true 留 try_continue 续生成, - // 同时 Drop 因 done=true 跳过复位 spawn(避免误复位审批态 generating 致 ai_approve→try_continue 不续) - guard.disarm(); - return; // generating 保持 true - } - // 全部自动执行完成 → 继续下一轮 } @@ -2099,10 +1063,10 @@ pub(crate) async fn run_agentic_loop( // save_usage 增量;emit_usage 累计快照(前端展示本 loop 总量) let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot); let emit_usage = tokens_snapshot(&tokens); - // 统一走 finish_round_exit:save(Some usage, Some model) + 不 spawn_title(对齐原无 title) + + // 统一走 teardown_round:save(Some usage, Some model) + 不 spawn_title(对齐原无 title) + // emit(Some(true), publish=false)。**do_publish=false 保留原 max_iterations 不 publish 行为** // (与其他 5 路径不一致是历史现状,本次仅收敛重复代码不改 publish 策略,语义零变更)。 - finish_round_exit( + teardown_round( &session_arc, &db, &conv_id, Some(&save_usage), Some(&resolved_model), false, @@ -2170,6 +1134,581 @@ pub(crate) async fn run_agentic_loop( ).await; } + +// ============================================================ +// run_agentic_loop 主循环体拆解(纯搬运,行为零变更,2026-10-01) +// +// for 体原为 ~500 行内联块,按职责拆为四段:轮次准备(prepare_round_inputs)/ +// 流式消费(stream_relay::stream_round,前批已外移)/ 轮收尾(finalize_round_output)/ +// 工具调用(process_round_tools)。各段原 `return`/`break` 语义经返回枚举由 loop 主体 +// 表达,控制流逐路径等价;锁边界、guard.reset/disarm 时序、emit/save 顺序逐字保留。 +// ============================================================ + +/// 轮次准备段结果(原 loop 体内联 early-return 语义的函数化表达)。 +enum PreparedRound { + /// 继续本轮:携请求消息 + 输入 token 估值 + 路由需求。 + Ready { + messages: Vec, + estimated_prompt: u32, + agentic_req: TaskRequirements, + }, + /// 本轮终止,loop 直接 return(收尾已在段内完成或依赖 guard Drop,与原 early-return 等价)。 + Exit, +} + +/// 轮收尾段结果。 +enum RoundEpilogue { + /// 继续工具执行段(目标验证续轮空工具也走此路);本轮工具调用数由工具调用段内部取。 + RunTools, + /// 无工具且无续轮需求:正常收敛(loop 主体置 converged 后 break,与原语义一致)。 + Converged, + /// 本轮终止,loop 直接 return(收尾已在段内完成或依赖 guard Drop)。 + Exit, +} + +/// 工具调用段结果。 +enum ToolsOutcome { + /// 全部自动执行完成(无待审批、未熔断)→ 继续下一轮。 + NextRound, + /// 熔断/stale/审批暂停 → loop 直接 return(收尾已在段内完成或依赖 guard Drop)。 + Exit, +} + +// ── 轮次准备段:入口 stop/stale 收尾闸门 → 主题标记 → conv 一致性(iteration_used/epoch/心跳) +// → 轮通知 → 自动压缩 → 请求消息构建(build_for_request + namespace 展开 + system prompt + +// 易变尾段)→ tool_result 摘要 → 占位配对断言 → estimated_prompt → agentic_req 重建。── +// 原 `return` → `PreparedRound::Exit`;锁边界(标记读锁/一致性锁/build_for_request 锁/ +// history_tokens 锁)与语句顺序逐字保留。 +#[allow(clippy::too_many_arguments)] +async fn prepare_round_inputs( + session_arc: &Arc>, + db: &Arc, + app_handle: &AppHandle, + conv_id: &str, + iteration: usize, + stop_flag: &std::sync::atomic::AtomicBool, + loop_epoch_arc: &std::sync::Arc, + my_epoch: u64, + guard: &mut GeneratingGuard, + tokens: &mut TokenAccumulator, + saved_token_snapshot: &mut df_ai::provider::TokenUsage, + last_round_estimated: bool, + provider_config: &AiProviderRecord, + llm_concurrency: &LlmConcurrency, + pinned_goals_snapshot: &[super::GoalEntry], + sys_tokens: u32, + system_prompt: &str, + volatile_tail: &Option, + intent: &Intent, +) -> PreparedRound { + // 用户请求停止 或 已被新 loop 接管(stale)→ 收尾退出(已生成文本已在上一轮入库)。 + // F1:stale 判定(epoch 不匹配)让旧 loop 在新 loop 启动后立即在此退出,不再继续跑。 + if stop_flag.load(Ordering::SeqCst) || loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { + // save_usage 增量(自上次 save 后新增,首轮即停为 0);emit_usage 累计快照 + let save_usage = usage_delta_since(tokens, saved_token_snapshot); + let emit_usage = tokens_snapshot(tokens); + // 入口 stop:本轮可能尚未 stream(首轮即停),不记 model——避免把未实际生成的 model 写入 models 数组 + // 统一走 teardown_round:save(Some usage, None model) + spawn_title + emit(None,None,publish=true) + // (stale 时 teardown_round 内部仅 disarm 跳过 save/reset/emit,不干扰新 loop) + teardown_round( + session_arc, db, conv_id, + Some(&save_usage), None, + true, + provider_config, llm_concurrency, + guard, + &emit_usage, + // 入口 stop:本轮尚未 stream,last_round_estimated 仍为初始 false(无估算)。 + last_round_estimated, + None, None, true, + pinned_goals_snapshot, + app_handle, + loop_epoch_arc, my_epoch, + ).await; + return PreparedRound::Exit; + } + + // 改进5: 主题切换系统标记(保守,双高置信才标,软提示非强制)。 + // + // push 时若末两条 user 消息的 topic 都非 None 且不同(Intent 高置信推断的双 topic), + // ContextManager 已置位 pending_topic_marker("old|new")。loop 顶部读取并消费 + // (take 一次性清空,防重复 insert),insert 一条 system 软提示告知 LLM 用户已切换话题。 + // + // 保守设计: + // - 双高置信:两条 topic 都非 None(都达 0.7 阈值)才标,任一 None(低置信未标)不标。 + // - 软提示:仅 insert 一条 system 消息,不强制 LLM 行为(LLM 仍可按自己理解响应)。 + // - TOPIC_MARKER_ENABLED(常量开关)false → 跳过(排障/对比用)。 + // topic 不参与裁剪/压缩(只供检测),insert_at(0) 同压缩摘要定位(首位 system)。 + if TOPIC_MARKER_ENABLED { + let topic_marker_raw: Option = { + let mut session = session_arc.lock().await; + if !session.per_conv.contains_key(conv_id) { + tracing::warn!( + stale_conv = %conv_id, + "[ai] conv 已删除,loop 退出(主题标记段入口)" + ); + return PreparedRound::Exit; + } + let conv = session.conv(conv_id); + // G4 目标感知降级:总是 take_topic_marker(防 marker 累积),但若 pinned_goals 非空 + // 且 TOPIC_MARKER_GOAL_AWARE → 丢弃 take 结果(返 None 跳过 insert)。 + // 一次 lock 同读 pinned_goals(避免额外加锁)。take 后丢弃不影响下一轮(marker 每 push + // user 重检测生成,丢弃一次不残留)。GOAL_AWARE=false → 原样返回 marker(退旧行为)。 + let goal_active = TOPIC_MARKER_GOAL_AWARE && !conv.pinned_goals.is_empty(); + let marker = conv.messages.take_topic_marker(); + if goal_active && marker.is_some() { + tracing::info!( + conv_id = %conv_id, + iteration, + "[ai] G4 目标钉扎已启用,跳过话题标记 insert 避免双锚点稀释" + ); + None + } else { + marker + } + }; + if let Some((old_topic, new_topic)) = topic_marker_raw.and_then(|s| { + // 解析 "old|new" 格式;splitn 防 topic 名内含 '|' 误切(仅切首 '|' 一次)。 + let mut parts = s.splitn(2, '|'); + let old = parts.next()?.to_string(); + let new = parts.next()?.to_string(); + Some((old, new)) + }) { + let marker_text = format!( + "── 用户已切换话题(从「{}」到「{}」),请以新话题为准 ──", + old_topic, new_topic + ); + let mut session = session_arc.lock().await; + if session.per_conv.contains_key(conv_id) { + let conv = session.conv(conv_id); + conv.messages.insert_at(0, ChatMessage::system(&marker_text)); + tracing::info!( + conv_id = %conv_id, + iteration, + old_topic = %old_topic, + new_topic = %new_topic, + "[ai] 主题切换标记已 insert(软提示,保守双高置信)" + ); + } + } + } + + // 旧 loop 污染防护——每轮开始校验对话一致性。 + // 用户新建/切换对话后 active_conversation_id 变更,本 loop(conv_id 快照)成陈旧, + // 继续跑会往新对话 push 消息/pending 造成污染。检测到即退出(guard Drop 复位 generating)。 + // + // 退出判据:!per_conv.contains_key(conv_id)(conv 存在性)。旧 loop 跑自己的 conv + // 不污染他人,active_conversation_id 切换不应让旧 loop 退出;仅当 conv 被删(clear/delete) + // 才退出。单 loop 阶段 conv 不会被删,此校验主要保留语义对齐(真并发场景生效)。 + // conv_id 来源:run_agentic_loop 入参(与 guard/stop_flag 取用同源)。 + { + let mut session = session_arc.lock().await; + if !session.per_conv.contains_key(conv_id) { + tracing::warn!( + stale_conv = %conv_id, + "[ai] conv 已删除,旧 loop 退出" + ); + return PreparedRound::Exit; + } + // 累计 iteration 计数(「下一轮起算值」= 当前轮+1)。 + // 审批等待/达 max 暂停退出时,本字段即「当前轮+1」;审批续跑 ai_approve 读此值作 + // start_iteration 透传,实现 iteration 累计不重置(防多次审批反复跑满 max 致 token 失控)。 + // per_conv.iteration_used 唯一真相源。 + let conv = session.conv(conv_id); + conv.iteration_used = iteration + 1; + // F1 并发 epoch:每轮顶部 stale 检查(防旧 loop 在 topic/compress 等长段期间被接管后继续跑)。 + // F2 存活心跳:每轮更新(与 heartbeat_loop 20s 心跳互补,报活更即时)。 + if conv.loop_epoch.load(Ordering::SeqCst) != my_epoch { + guard.disarm(); + return PreparedRound::Exit; + } + conv.last_heartbeat.store(df_types::now_millis() as u64, Ordering::SeqCst); + } + + // 新一轮通知前端(第二轮起),前端需新建 assistant 消息 + if iteration > 0 { + let round_n = (iteration + 1) as u32; + // 双写收敛经 emit_ai_chat_event(EVENT_BUS_ENABLED 门控在 publish 内)。 + emit_ai_chat_event(app_handle, AiChatEvent::AiAgentRound { + round: round_n, + conversation_id: Some(conv_id.to_string()), + }); + } + + // 自动压缩(智能裁剪)——已抽取至 context_lifecycle::maybe_auto_compress。 + // + // 抽取自本函数原内联块。返回 true = conv 已删除,loop 应立即 return + // (对齐原内联块入口的 early-return);返回 false = 正常结束,继续后续流程。 + // 实现细节(触发条件 / 延迟 mutate 口径 / 关键词兜底)见 + // context_lifecycle.rs 顶部文档与函数体内注释。 + // L0-3(2026-08-09):压缩 LLM 调用已后台化(fire-and-forget),本调用不再同步阻塞本轮, + // 返回 false 让当前轮用未压缩上下文正常走;压缩结果下一轮生效(见 context_lifecycle.rs)。 + if maybe_auto_compress( + session_arc, + conv_id, + app_handle, + provider_config, + llm_concurrency, + iteration, + sys_tokens, + ).await { + return PreparedRound::Exit; + } + + // 构建请求消息(超预算时自动裁剪旧消息,保护工具调用三元组 + 最近 6 条) + // sys_tokens 已在 loop 外缓存;本块构建的 messages 在本轮重试循环中复用 + // (本轮 stream_llm 不持 session_arc、不改 messages,重试无 push 发生,重建等价于复用)。 + // build_for_request 读 per_conv.messages。 + // conv_id 来源:run_agentic_loop 入参。 + let messages = { + let session = session_arc.lock().await; + let conv = match session.conv_read(conv_id) { + Some(c) => c, + None => { + tracing::warn!(stale_conv = %conv_id, "[ai] conv 已删除,loop 退出(build_for_request 入口)"); + return PreparedRound::Exit; + } + }; + let (mut history_msgs, _trimmed) = conv.messages.build_for_request(sys_tokens); + // T2-修复(方案A):发 LLM 前展开 namespace 引用为真实内容(view-only,仅本轮请求视图)。 + // push 时(audit/mod.rs)read_file/list_directory/grep/diff_files 等大结果被 + // namespace_store 引用化进 messages;持久化(conversation.rs)已展开写 DB,但发 LLM 的 + // 实时请求此前未展开 → LLM 每轮只看到 "namespace://..." 占位符,AI 实际拿不到工具结果。 + // 此处展开,namespace 退化为内存/持久化层优化,LLM 永远看真实内容。 + // read_only None(条目被 LRU 淘汰 / 重启后 namespace 清空 / 跨会话残留引用)→ + // 替换为 EVICTED_PLACEHOLDER 提示文案(非保留无意义 namespace:// URI)+ warn 日志。 + for m in &mut history_msgs { + if df_ai::namespace_store::is_namespace_ref(&m.content) { + let path = m.content.clone(); + if let Some(original) = session.namespace_store.read_only(&path) { + m.content = original.to_string(); + } else { + tracing::warn!( + conv_id = %conv_id, + path = %path, + "[namespace] 引用已淘汰,替换为提示文案" + ); + m.content = df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string(); + } + } + } + let mut msgs = vec![ChatMessage::system(system_prompt)]; + msgs.extend(history_msgs); + // Anthropic prompt caching:把易变尾段(日期/清单/augmentation/知识)追加到消息流末尾。 + // 放末尾而非 system:易变内容不进缓存前缀,每日/每请求变化不失效 system 缓存(跨天仍命中)。 + if let Some(tail) = volatile_tail { + merge_volatile_tail(&mut msgs, tail); + } + msgs + }; + + // 改进4: tool_result view-only 摘要压缩(build_for_request 后,送 stream 前)。 + // + // 遍历 messages(history clone,已含 system prompt + sanitize 后历史)中的 role=Tool 消息, + // 超阈值(content >2KB 或占历史 token >40%)的 content 应用 extract_key_info 替换: + // 保留错误行(error/panic/失败/.rs:N)+ 首/尾各 5 行,中间省略。 + // + // **view-only**:messages 是 build_for_request 返回的 clone,改它只影响本轮 LLM 请求视图, + // 不改 ContextManager 持久化(对齐 sanitize_messages:DB 原始 tool_result 完整保留)。 + // 故即使摘要有误/过度压缩,下次 build_for_request 仍从 DB 全量重建,可自愈。 + // + // TOOL_RESULT_COMPRESS_ENABLED=false(常量开关)→ 跳过(原样送 LLM,排障/对比用)。 + let messages: Vec = if TOOL_RESULT_COMPRESS_ENABLED { + let history_tokens_snapshot: u32 = { + let session = session_arc.lock().await; + session + .conv_read(conv_id) + .map(|c| c.messages.history_tokens()) + .unwrap_or(0) + }; + summarize_tool_results(messages, history_tokens_snapshot, conv_id, iteration) + } else { + messages + }; + + // 占位配对完整性(第二道防线,depth-defense):build_for_request 已在 df-ai 内部 + // 自愈一次,此处 tool_result 压缩/系统提示插入后再断言一次,防转换引入新 orphan。 + // view-only:messages 是 clone,assert_placeholder_pairing 仅改本 Vec,不改 ContextManager 持久化。 + let messages = ContextManager::assert_placeholder_pairing(messages, PLACEHOLDER_INTEGRITY_ENABLED); + + // 预估输入 token(兜底:部分 provider 如 GLM 流式 usage 不报 prompt_tokens,后段用它补) + // 注:stream_one_provider 内每次重试重建 request(因 provider.stream 消费 body), + // 此处不再预构建 request(旧 request 变量已废弃),仅保留 messages 供 estimated_prompt。 + // 语义说明:每轮对全量历史重估并累加(GLM 类无 usage provider 多轮 prompt 总和偏高), + // 但「多轮累计」本就接近真实总量且标估算展示,保留现状(增量估算收益低,不动)。 + let estimated_prompt: u32 = { + let est = TokenEstimator::default(); + messages.iter().map(|m| est.estimate_message(m)).sum() + }; + + // 子项 1+2 根因修复:loop 内重建 agentic_req,真实 estimated_context + 意图 tier。 + // + // **子项 1(estimated_context 死代码)**:原 pre-loop agentic_req 传 0, + // 上下文窗口过滤维度(context_window >= estimated_context)恒过,失效。此处用本轮 + // estimated_prompt(system+history 全量 token 估值)作 estimated_context → 窗口过滤生效 + // (小窗口模型如 8K 被大上下文任务正确滤掉,不再误选)。 + // + // **子项 2(weight tier tiebreak)**:tier 接 intent→ModelTier(Code/Debug→Heavy, + // Chat→Fast 等),同 weight 候选间按 tier tiebreak(满足档位下限的候选胜,见 router.rs)。 + // intent 在 loop 外(786 行)已识别,loop 内每轮复用同一 intent(用户末条 active 消息 + // 在单轮 LLM 调用内不变;多轮对话 intent 演化由用户后续消息触发,下轮 recognize 更新)。 + // + // **变量 shadow**:此绑定覆盖 pre-loop 的 agentic_req(line ~744 算初始 resolved_model + // 用过),candidate chain(下方 stream_one_provider)取此 loop 内版本。Rust shadow 安全: + // pre-loop 版本在 line 744 用完即弃,resolved_model 已落到 mut 变量。 + let agentic_req = TaskRequirements { + modalities: vec![Modality::Text], + needs_tool_use: true, + estimated_context: estimated_prompt as usize, + tier: suggested_model_tier(intent), + }; + + PreparedRound::Ready { messages, estimated_prompt, agentic_req } +} + +// ── 轮收尾段:token 累加 + assistant 消息 push(+ G1 目标提取)+ P0-2 立即落库 +// + stop/stale 收尾退出(teardown_round)+ 无工具收敛判定(目标验证续轮 GOAL_VERIFY_ENABLED) +// + 续轮计数重置。── +// 原 `return` → `RoundEpilogue::Exit`,原 `converged = true; break` → `RoundEpilogue::Converged` +//(loop 主体置 converged 后 break);push 锁边界与 teardown_round 调用序列逐字保留。 +#[allow(clippy::too_many_arguments)] +async fn finalize_round_output( + session_arc: &Arc>, + db: &Arc, + app_handle: &AppHandle, + conv_id: &str, + iteration: usize, + stop_flag: &std::sync::atomic::AtomicBool, + loop_epoch_arc: &std::sync::Arc, + my_epoch: u64, + guard: &mut GeneratingGuard, + tokens: &mut TokenAccumulator, + saved_token_snapshot: &mut df_ai::provider::TokenUsage, + last_round_estimated: bool, + resolved_model: &str, + provider_config: &AiProviderRecord, + llm_concurrency: &LlmConcurrency, + pinned_goals_snapshot: &[super::GoalEntry], + full_text: &str, + tool_calls_acc: &std::collections::HashMap, + round_usage: &df_ai::provider::TokenUsage, + round_is_estimated: bool, + last_reasoning_content: &Option, + goal_verify_rounds: &mut u32, +) -> RoundEpilogue { + // 累加本轮 token:用 provider 真实 usage。GLM 等流式不报 completion_tokens 时为 0,如实反映(不预估)。 + // 分项 token(2026-08-02):cache/reasoning 透传 round。三真相源收口:prompt 估算兜底 + + // estimated 判定已由 stream_round 内 round_usage_effective 单点派生,本处直接消费返回值, + // 不再字面量重组(原 round_prompt/round_usage_full 双条件分支已删,逐字段等价)。 + tokens.add_usage(round_usage); + + // 追加 assistant 消息到历史 + G1 目标提取(扁平重构,原嵌套 8 层 → 3 层) + let has_tool_calls = !tool_calls_acc.is_empty(); + { + let mut session = session_arc.lock().await; + if !session.per_conv.contains_key(conv_id) { + tracing::warn!( + stale_conv = %conv_id, + "[ai] stream 后 conv 已删除,丢弃本轮 push" + ); + return RoundEpilogue::Exit; + } + push_assistant_message( + &mut session, conv_id, has_tool_calls, tool_calls_acc, + full_text, resolved_model, last_reasoning_content, + round_usage.prompt_tokens, round_usage.completion_tokens, + round_usage.prompt_cache_hit_tokens, round_usage.prompt_cache_miss_tokens, + round_usage.reasoning_tokens, + round_is_estimated, + ); + if GOAL_PIN_ENABLED { + update_pinned_goals(&mut session, conv_id, tool_calls_acc); + } + } + + // P0-2 根治:LLM 回复 push 后、工具执行前立即落库。 + // 背景:save_conversation 原仅在 loop 出口调用,process_tool_calls 卡住时(60s timeout + // 或 session lock 竞争)永远到不了出口,用户重启后上轮回复丢失。此处出 push 锁作用域后 + // 立即 save,幂等(每轮重复覆盖落库),即使后续工具卡住本轮消息已持久化。 + { + // 落库传「自上次 save 的增量」而非累计快照(累计会被同 loop 多次 save 重复累加双计) + let usage = usage_delta_since(tokens, saved_token_snapshot); + save_conversation(session_arc, db, conv_id, Some(&usage), Some(resolved_model), true).await; + } + + // 停止信号 或 已被新 loop 接管(stale):已生成文本入库后退出,不再执行后续工具调用。 + // F1:stale 时旧 loop 在此退出,不执行工具(防重复工具执行);teardown_round 内部跳过 emit。 + if stop_flag.load(Ordering::SeqCst) || loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { + // save_usage 增量(自上次 save 后新增);emit_usage 累计快照(前端展示本 loop 总量) + let save_usage = usage_delta_since(tokens, saved_token_snapshot); + let emit_usage = tokens_snapshot(tokens); + // 统一走 teardown_round:save(Some usage, Some model) + spawn_title + emit(None,None,publish=true) + teardown_round( + session_arc, db, conv_id, + Some(&save_usage), Some(resolved_model), + true, + provider_config, llm_concurrency, + guard, + &emit_usage, + last_round_estimated, + None, None, true, + pinned_goals_snapshot, + app_handle, + loop_epoch_arc, my_epoch, + ).await; + return RoundEpilogue::Exit; + } + + // 无工具调用 → 最终文本响应。正常收敛退出,或目标验证续轮(GOAL_VERIFY_ENABLED)。 + if !has_tool_calls { + // 目标验证续轮:开关开 + 有钉扎目标 + 本轮无 applied.matched_target 证据 + 未达上限 + // → 不收敛,注入 nudge 强制续轮(治过早宣称,机制非 prompt 说教)。 + // 任一条件不满足 → 正常收敛(逐字节等价改动前)。 + let needs_verify = GOAL_VERIFY_ENABLED + && !pinned_goals_snapshot.is_empty() + && *goal_verify_rounds < GOAL_VERIFY_MAX_ROUNDS + && !last_round_has_match_evidence(session_arc, conv_id).await; + if needs_verify { + *goal_verify_rounds += 1; + insert_goal_verify_nudge(session_arc, conv_id, iteration, *goal_verify_rounds).await; + // 不 break,续轮。下轮 LLM 见 nudge → 调工具验证(计数重置)或诚实收敛。 + // 续轮走 loop 顶部正常流程(build_for_request + stream),无特殊路径。 + } else { + // 达上限强制收敛(GOAL_VERIFY_ENABLED && goal_verify_rounds >= MAX): + // LLM 经 N 轮 nudge 仍空手停,目标未验证。标注 warn 供事后核查主入口行为。 + // (未达上限/开关关/无目标/有证据 → 正常收敛,无额外日志。) + if GOAL_VERIFY_ENABLED + && !pinned_goals_snapshot.is_empty() + && *goal_verify_rounds >= GOAL_VERIFY_MAX_ROUNDS + { + tracing::warn!( + conv_id = %conv_id, iteration, goal_verify_rounds = *goal_verify_rounds, + "[GOAL-VERIFY] 达续轮上限强制收敛,目标未验证(LLM 经 nudge 仍空手停)" + ); + } + // 正常收敛(loop 主体置 converged 后 break) + return RoundEpilogue::Converged; + } + } + + // 续轮内 LLM 调了工具(无论成败)→ 目标验证计数重置(说明在行动非空转,非卡死)。 + // 紧跟在 converged 判定之后、process_tool_calls 之前:本轮 has_tool_calls=true 到这里, + // 说明 LLM 对 nudge 作出了工具响应,清零给后续验证留额度。 + if GOAL_VERIFY_ENABLED && *goal_verify_rounds > 0 && has_tool_calls { + *goal_verify_rounds = 0; + } + + // AC-2 ①:本轮工具调用数由工具调用段内部取(process_tool_calls 会 move 走 acc,收尾段只借)。 + RoundEpilogue::RunTools +} + +// ── 工具调用段:process_tool_calls 执行(Low 自动执行 / Medium+High 待审批)→ stale 检查 +// → AC-2 单轮超限软提示 → L1 断路器(连续同类失败)→ G2 探索熔断/写删回环熔断 → 审批暂停。── +// 原 `return` → `ToolsOutcome::Exit`;熔断计数器(fail_window/stall_*/loop_*)经 &mut 传入传出, +// 锁边界与 emit/guard.reset 顺序逐字保留。 +#[allow(clippy::too_many_arguments)] +async fn process_round_tools( + session_arc: &Arc>, + tools_arc: &Arc, + db: &Arc, + app_handle: &AppHandle, + conv_id: &str, + iteration: usize, + loop_epoch_arc: &std::sync::Arc, + my_epoch: u64, + guard: &mut GeneratingGuard, + tokens: &mut TokenAccumulator, + saved_token_snapshot: &mut df_ai::provider::TokenUsage, + resolved_model: &str, + tool_calls_acc: std::collections::HashMap, + fail_window: &mut std::collections::VecDeque, + stall_count: &mut u32, + stall_warned: &mut bool, + loop_count: &mut u32, + loop_warned: &mut bool, +) -> ToolsOutcome { + // AC-2 ①:记录本轮工具调用数(供超限警告判定;process_tool_calls 会 move 走 tool_calls_acc)。 + let round_tool_call_count = tool_calls_acc.len(); + // 处理工具调用(Low 自动执行 / Medium+High 待审批) + let pending_count = process_tool_calls(session_arc, tool_calls_acc, tools_arc, db, app_handle, conv_id).await; + // DIRAUTH 审批链已闭环。原 eprintln 诊断降级为 tracing::debug, + // 避免污染 stderr(用户可见),保留排障能力(RUST_LOG=debug 可见)。 + tracing::debug!( + conv_id = %conv_id, + pending_count, + "[AI-DIRAUTH-DIAG] agentic loop 收到 pending" + ); + + // F1 并发 epoch:工具执行期间被新 loop 接管(force_send)后立即退出, + // 不熔断/不停审批/不进下一轮(避免旧 loop 继续污染新 loop 会话)。 + if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { + guard.disarm(); + return ToolsOutcome::Exit; + } + + // AC-2 ①:单轮工具调用数超限 → 注入软提示(机制化,非静默,弱模型能感知)。 + // 治单轮并行调用爆炸:LLM 看不到自己发了多少调用,必须在消息流里显式警告。 + if is_tool_call_over_limit(round_tool_call_count) { + insert_tool_call_over_limit_warning(session_arc, conv_id, iteration, round_tool_call_count).await; + } + + // L1 断路器:连续同类工具失败熔断(治 agent 无止损死循环,机制非 prompt 说教)。 + // count_recent_failures 读末尾连续 Tool 消息,失败包按结构化信号归一 key 入滚动窗口计数。 + if CIRCUIT_BREAKER_ENABLED { + let (max_count, sample_key) = count_recent_failures(session_arc, conv_id, fail_window).await; + // 锁已随作用域 drop,可安全 await/emit(避免持锁 await 死锁)。 + if max_count >= CIRCUIT_BREAKER_THRESHOLD { + tracing::warn!( + conv_id = %conv_id, + max_count, + sample_key = %sample_key, + "[ai] L1 断路器熔断:连续同类失败 {} 次,疑似死循环停止", max_count + ); + guard.reset().await; + // F1:旧 loop 不 emit 熔断求助卡(owner loop 负责呈现)。 + if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { + return ToolsOutcome::Exit; + } + emit_circuit_breaker_tripped(app_handle, conv_id, max_count, &sample_key); + return ToolsOutcome::Exit; + } + } + + // G2 探索熔断:连续空结果无进展检查。抽至 check_stall_breaker helper(扁平重构)。 + // 返回 true=已熔断(loop 应 return),false=继续。stall_count/stall_warned 传入传出。 + if STALL_BREAKER_ENABLED { + let (tripped, new_count, new_warned, new_loop_count, new_loop_warned) = check_stall_breaker( + session_arc, app_handle, conv_id, iteration, + *stall_count, *stall_warned, *loop_count, *loop_warned, + ).await; + *stall_count = new_count; + *stall_warned = new_warned; + *loop_count = new_loop_count; + *loop_warned = new_loop_warned; + if tripped { + guard.reset().await; + return ToolsOutcome::Exit; + } + } + + // 有待审批 → 暂停循环,等待用户审批后通过 ai_approve → try_continue_agent_loop 恢复 + if pending_count > 0 { + // F1:旧 loop 被接管后不停审批(force_send 已清 pending),直接退出。 + if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { + guard.disarm(); + return ToolsOutcome::Exit; + } + // 落库传「自上次 save 的增量」;审批暂停→恢复跨 loop 实例由 save_conversation old+add 兜住 + let usage = usage_delta_since(tokens, saved_token_snapshot); + save_conversation(session_arc, db, conv_id, Some(&usage), Some(resolved_model), true).await; + // 审批等待 return 前 disarm guard——保持 generating=true 留 try_continue 续生成, + // 同时 Drop 因 done=true 跳过复位 spawn(避免误复位审批态 generating 致 ai_approve→try_continue 不续) + guard.disarm(); + return ToolsOutcome::Exit; // generating 保持 true + } + + ToolsOutcome::NextRound +} + // ── heartbeat_loop: 后台心跳任务(emit AiHeartbeat 防前端 watchdog 误杀) ── // 退出条件:heartbeat_rx 发送端 Drop(function 作用域结束)。每 20s emit 一次。 // F2(2026-08-05):同时更新 per_conv.last_heartbeat(loop 存活证据)——loop 全程(含流式/工具 @@ -2433,15 +1972,16 @@ async fn insert_destructive_loop_warning( "⚠ 检测到对 {} 反复写删(写完又删的回环),可能在销毁自己的工作成果{},请回顾目标停止删除或换思路。", path_desc, goal_text ); - let mut session = session_arc.lock().await; - if session.per_conv.contains_key(conv_id) { - let conv = session.conv(conv_id); - conv.messages.insert_at(0, ChatMessage::system(&warn_text)); - tracing::info!( - conv_id = %conv_id, iteration, loop_count, - "[ai] G2 破坏性回环:写删回环警示已 insert(软提示,给 LLM 自纠机会)" - ); - } + helpers::insert_system_notice( + session_arc, + conv_id, + &warn_text, + &format!( + "G2 破坏性回环:写删回环警示已 insert(软提示,给 LLM 自纠机会) iteration={} loop_count={}", + iteration, loop_count + ), + ) + .await; } // ── insert_stall_warning: G2 软提示 insert system 消息(给 LLM 自纠机会) ── @@ -2460,15 +2000,16 @@ async fn insert_stall_warning( "⚠ 检测到重复的工具调用(同样的 grep/读文件反复),可能卡住{},请回顾目标换思路或停止探索。", goal_text ); - let mut session = session_arc.lock().await; - if session.per_conv.contains_key(conv_id) { - let conv = session.conv(conv_id); - conv.messages.insert_at(0, ChatMessage::system(&warn_text)); - tracing::info!( - conv_id = %conv_id, iteration, stall_count, - "[ai] G2 探索熔断:重复调用警示已 insert(软提示,给 LLM 自纠机会)" - ); - } + helpers::insert_system_notice( + session_arc, + conv_id, + &warn_text, + &format!( + "G2 探索熔断:重复调用警示已 insert(软提示,给 LLM 自纠机会) iteration={} stall_count={}", + iteration, stall_count + ), + ) + .await; } // ── fetch_goal_summary: 读 pinned_goals 拼接为 "(当前目标: a; b)" 或空串 ── @@ -2502,34 +2043,27 @@ async fn insert_tool_call_over_limit_warning( "⚠ 本轮工具调用达 {} 个,超过单轮上限 {} 个。请聚焦关键操作,减少并行调用,勿重复探索同一区域。", count, MAX_TOOL_CALLS_PER_ROUND ); - let mut session = session_arc.lock().await; - if session.per_conv.contains_key(conv_id) { - let conv = session.conv(conv_id); - conv.messages.insert_at(0, ChatMessage::system(&warn_text)); - tracing::info!( - conv_id = %conv_id, - iteration, - count, - limit = MAX_TOOL_CALLS_PER_ROUND, - "[ai] AC-2 单轮工具调用数超限:注入软提示" - ); - } + helpers::insert_system_notice( + session_arc, + conv_id, + &warn_text, + &format!( + "AC-2 单轮工具调用数超限:注入软提示 iteration={} count={} limit={}", + iteration, count, MAX_TOOL_CALLS_PER_ROUND + ), + ) + .await; } // ── emit_fatal_error: 致命错误统一 emit AiError(扁平抽自原 Fatal/Exhausted 双分支重复代码) ── -// emit+publish 双写 Tauri 事件总线。error_type=Network 对齐原 Fatal 分支(鉴权/4xx归类)。 -async fn emit_fatal_error(app_handle: &AppHandle, conv_id: &str, error: &str) { - let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { +// 双写经 emit_ai_chat_event。error_type=Network 对齐原 Fatal 分支(鉴权/4xx归类); +// 两端统一 Network(原 publish 端漏传 None 属漂移,2026-10-01 顺带修)。 +pub(crate) async fn emit_fatal_error(app_handle: &AppHandle, conv_id: &str, error: &str) { + emit_ai_chat_event(app_handle, AiChatEvent::AiError { error: error.to_string(), error_type: Some(ErrorType::Network), conversation_id: Some(conv_id.to_string()), }); - // L3 emit 双写:Fatal AiError publish 到事件总线(门控在 publish 内)。 - let _ = app_handle.state::().ai_event_bus.publish_event(AiChatEvent::AiError { - error: error.to_string(), - error_type: None, - conversation_id: Some(conv_id.to_string()), - }); } // ── emit_ai_completed_once: AiCompleted 事件 emit+publish 双写 helper(对齐 emit_fatal_error 模式) ── @@ -2575,41 +2109,44 @@ async fn emit_ai_completed_once( } // emit 端(前端通道):incomplete 用 emit_incomplete(MidStream 传 Some(true))。 // token 分项(2026-08-02):cache_hit/cache_miss/reasoning 透传前端分计费展示。 - let _ = app_handle.emit( - "ai-chat-event", - AiChatEvent::AiCompleted { - total_tokens: usage.total_tokens, - prompt_tokens: usage.prompt_tokens, - completion_tokens: usage.completion_tokens, - prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens, - prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens, - reasoning_tokens: usage.reasoning_tokens, - is_estimated, - incomplete: emit_incomplete, - conversation_id: Some(conv_id.to_string()), - pinned_goals: pinned_goals.to_vec(), - }, - ); - // publish 端(事件总线):EVENT_BUS_ENABLED 门控在 publish 内;incomplete 用 publish_incomplete - // (MidStream 传 None);do_publish=false(max_iterations)跳过整段 publish(保留原无 publish 行为)。 + // publish 端:do_publish=false(max_iterations)跳过 publish(保留原无 publish 行为); + // MidStream incomplete 分叉(emit=Some(true)/publish=None)事件值不同,不能并成单次 + // emit_ai_chat_event 调用,分路构造(其余 5 路径两端同值,走单次双写)。 + let ev = completed_event(usage, is_estimated, emit_incomplete, conv_id, pinned_goals); + if do_publish && emit_incomplete == publish_incomplete { + emit_ai_chat_event(app_handle, ev); + return; + } + let _ = app_handle.emit("ai-chat-event", ev); if do_publish { let _ = app_handle.state::().ai_event_bus.publish_event( - AiChatEvent::AiCompleted { - total_tokens: usage.total_tokens, - prompt_tokens: usage.prompt_tokens, - completion_tokens: usage.completion_tokens, - prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens, - prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens, - reasoning_tokens: usage.reasoning_tokens, - is_estimated, - incomplete: publish_incomplete, - conversation_id: Some(conv_id.to_string()), - pinned_goals: pinned_goals.to_vec(), - }, + completed_event(usage, is_estimated, publish_incomplete, conv_id, pinned_goals), ); } } +/// 构造 AiCompleted 事件(emit_ai_completed_once 内部去重用,字段口径见其文档)。 +fn completed_event( + usage: &df_ai::provider::TokenUsage, + is_estimated: bool, + incomplete: Option, + conv_id: &str, + pinned_goals: &[super::GoalEntry], +) -> AiChatEvent { + AiChatEvent::AiCompleted { + total_tokens: usage.total_tokens, + prompt_tokens: usage.prompt_tokens, + completion_tokens: usage.completion_tokens, + prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens, + prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens, + reasoning_tokens: usage.reasoning_tokens, + is_estimated, + incomplete, + conversation_id: Some(conv_id.to_string()), + pinned_goals: pinned_goals.to_vec(), + } +} + // ── build_runtime_prompt: run_agentic_loop 入口 system_prompt 五段拼接 ── // // 抽自原 L1024-1152(G1 目标钉扎 / T5 工作上下文 / Env+behavior / 技能清单 / T4 工作流 DAG)。 @@ -2758,7 +2295,7 @@ async fn build_runtime_prompt( // reasoning} 字面量组装。total = tokens.total() = prompt.saturating_add(completion) // (TokenAccumulator::total 语义),与原 6 处逐字节等价。usage_delta_since 内部「取 cur 快照」 // 也复用本 helper(cur 字段同构)。 -fn tokens_snapshot(tokens: &TokenAccumulator) -> df_ai::provider::TokenUsage { +pub(crate) fn tokens_snapshot(tokens: &TokenAccumulator) -> df_ai::provider::TokenUsage { df_ai::provider::TokenUsage { prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), @@ -2775,7 +2312,7 @@ fn tokens_snapshot(tokens: &TokenAccumulator) -> df_ai::provider::TokenUsage { // 「自上次 save 以来的增量」——传累计快照会被同 loop 多次 save(每轮 + 各退出路径) // 重复累加致 ai_conversations token 双计。本函数取 tokens 当前值减上次快照得增量, // 并推进快照,同轮多次 save 幂等(第二次增量=0)。 -fn usage_delta_since( +pub(crate) fn usage_delta_since( tokens: &TokenAccumulator, last_saved: &mut df_ai::provider::TokenUsage, ) -> df_ai::provider::TokenUsage { @@ -2799,7 +2336,7 @@ fn usage_delta_since( } } -// ── finish_round_exit: run_agentic_loop 收尾统一入口(抽自 5 处退出路径重复代码) ── +// ── teardown_round: run_agentic_loop 收尾统一入口(抽自 5 处退出路径重复代码) ── // // 收敛各退出点的「save_conversation + spawn_ensure_title + guard.reset + emit AiCompleted」序列。 // 退出点(行为零变更): @@ -2823,7 +2360,7 @@ fn usage_delta_since( // - emit_incomplete/publish_incomplete/do_publish:透传 emit_ai_completed_once(见该 helper 文档) // - loop_epoch_arc/my_epoch:F1 并发 epoch——旧 loop(epoch 不匹配)只 disarm 跳过 save/reset/emit, // 不干扰新 loop 收尾(防「双完成 + 双 AiCompleted + guard 互踩」)。 -async fn finish_round_exit( +pub(crate) async fn teardown_round( session_arc: &Arc>, db: &Arc, conv_id: &str, diff --git a/src-tauri/src/commands/ai/agentic/stream_relay.rs b/src-tauri/src/commands/ai/agentic/stream_relay.rs new file mode 100644 index 0000000..8812adb --- /dev/null +++ b/src-tauri/src/commands/ai/agentic/stream_relay.rs @@ -0,0 +1,666 @@ +//! 单轮流式中继 — candidate 链 fallback + 流式调用 + 流前重试 + MidStream 保文。 +//! +//! 自 agentic/mod.rs 巨石拆解外移(2026-10-01,纯搬运语义等价,职责分组): +//! - `StreamOutcome` / `stream_one_provider` / `stream_with_retry`:单 candidate 流式调用 +//! + 流前 InitFailed retryable 重试循环; +//! - `RoundOutcome` / `stream_round`:单轮流式自包含块(retry_deadline → candidate 链 +//! 构建/降级 → candidate 循环 → epoch 后校验 → MidStream 保文); +//! - `emit_retry_attempt`:退避日志 + AiStreamRetry emit + sleep; +//! - `merge_volatile_tail`:Anthropic prompt caching 易变尾段追加到请求消息流末尾。 +//! +//! 行为红线:session_arc 锁的获取/释放位置、guard.reset/disarm 时序、emit+publish 双写 +//! 顺序、per-provider permit 取放范围均与拆解前逐字一致,本模块不改任何行为。 + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::Ordering; + +use tauri::AppHandle; +use tokio::sync::Mutex; + +// 复用 retry::backoff_delay(jitter 1s→2s→4s) + is_status_retryable(Fatal 分类) +// 实现流前失败重试退避对齐,避免重写退避逻辑。 +use df_ai::retry; +use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider}; +// 智能路由 helper + TaskRequirements(主对话:needs_tool_use=true,当前仅 Text 模态)。 +use df_ai::router::TaskRequirements; + +use df_storage::db::Database; +use df_storage::models::AiProviderRecord; + +use crate::state::{LlmConcurrency, ProviderAcquire}; + +use crate::commands::ai::conversation::{save_conversation, TokenAccumulator}; +use crate::commands::ai::stream_recv::{stream_llm, StreamResult}; +use crate::commands::ai::{AiChatEvent, AiSession, GoalEntry, ToolCallDraft}; + +use super::guard::GeneratingGuard; +use super::PROVIDER_SATURATION_DEMOTE_THRESHOLD; +use super::{check_retry_exhausted, resolve_model_with_override}; +use super::{emit_fatal_error, teardown_round, tokens_snapshot, usage_delta_since}; +use super::super::event_bus::emit_ai_chat_event; + +// ============================================================ +// 单 Provider 流式结果 + fallback 辅助 +// ============================================================ + +/// 单 candidate provider 一次迭代的流式调用结果。 +/// +/// 显式区分三态,驱动外层 `for candidate` fallback 循环: +/// - `Success`:`Complete` 或 `Partial`(MidStream 保文)。携带该 provider 路由出的 +/// `resolved_model`(调用方据此更新迭代级 resolved_model,供 push/save)。 +/// - `InitFailedExhausted`:流前 `InitFailed{retryable=true}` 在本 provider 上重试 +/// 耗尽(或预算 30s 耗尽)。retryable=true 即「瞬态错误,换 provider 可能成功」→ +/// 外层 `continue` 切下一 candidate(重建 build_provider + resolved_model 重算)。 +/// 携带 `error`(最后一条诊断文本),供外层「全 candidate 耗尽」时 emit 最终 AiError。 +/// - `Fatal`:流前 `InitFailed{retryable=false}`(4xx 非429/鉴权/参数错)。立即放弃 +/// 整个 fallback(对齐 retry.rs Fatal 分类 + provider_pool 文档「不可重试错误 +/// 立即放弃不浪费备用 provider」)。携带 `error`,stream_one_provider 内 Fatal 分支 +/// emit AiError(终态不切候选,无残留气泡风险),调用方仅做 guard.reset + return。 +/// 空 key(build_provider_for Err)同样归 Fatal 语义——key 缺失/钥匙串损坏非瞬态, +/// 换 provider 无意义(但实际场景:外层已对 primary 做了启动 Auth 早失败,故此分支 +/// 多见于 secondary 配置不一致,保守 Fatal 收敛)。 +enum StreamOutcome { + Success { + text: String, + tool_calls: std::collections::HashMap, + usage: df_ai::provider::TokenUsage, + incomplete: bool, + resolved_model: String, + /// DeepSeek thinking 模式推理内容(需回传到下一轮请求) + reasoning_content: Option, + }, + /// error 字段供外层全 candidate 耗尽时 emit 最终 AiError(单气泡聚合)。 + InitFailedExhausted { error: String }, + /// error 字段供 stream_one_provider 内 Fatal 分支 emit AiError。 + Fatal { error: String }, +} + +/// 单 provider 流式调用 + 重试。 +/// +/// 封装一个 candidate 的:resolve_secret → ensure_resolved_key → build_provider +/// → select_model_id(resolved_model 在该 candidate.model_configs 上重算) +/// → 流式 stream_recv + 流前 InitFailed retryable 重试循环。 +/// +/// 与主链共享的退避/分类:`retry::backoff_delay`(1s→2s→4s+jitter) + +/// `StreamResult::InitFailed{retryable}`(镜像 retry::is_status_retryable) + +/// 30s 总挂钟预算(`retry_deadline` 由调用方传入,本轮 fallback 各 candidate 共享一个预算)。 +/// +/// **permit 责任**:本函数不取并发 permit——permit 持有期间多 provider 限流不重叠, +/// 由调用方在进 candidate 前取 global/per_conv(整轮迭代共享) + 本 candidate 的 +/// per-provider permit(切换 candidate 时释放旧取新)。 +/// +/// 返回 [`StreamOutcome`]:Fatal 含已 emit AiError;Success 含保文或正常完成; +/// InitFailedExhausted 让调用方切下一 candidate。 +async fn stream_one_provider( + candidate: &AiProviderRecord, + messages: &[ChatMessage], + tool_defs: &[df_ai::provider::ToolDefinition], + app_handle: &AppHandle, + stop_flag: &std::sync::atomic::AtomicBool, + notify: &tokio::sync::Notify, + conv_id: &str, + max_retries: usize, + retry_deadline: tokio::time::Instant, + model_override: &Option, + agentic_req: &TaskRequirements, + last_reasoning_content: &Option, +) -> StreamOutcome { + // resolve→ensure→build 三步(复用 secret::build_provider_for,DRY)。 + // key 缺失/损坏 → Err → 归 Fatal(stream_llm Fatal 也是 key 类错,语义一致)。 + // 注:primary 候选的启动 Auth 早失败已在外层 run_agentic_loop 顶部处理(emit + return), + // 本函数到达时 primary 的 key 已验证过;此处 Err 多见于 secondary 配置不一致, + // 保守归 Fatal 立即放弃(不浪费预算试下一 provider,因 key 错非瞬态)。 + let provider: Arc = match crate::commands::ai::secret::build_provider_for(candidate) { + Ok(p) => Arc::from(p), + Err(msg) => return StreamOutcome::Fatal { error: msg }, + }; + + // resolved_model 在本 candidate.model_configs 上重算(provider 切换后 + // 模型池不同,必须重选;否则拿主 provider 的 model_id 去打次 provider 会吃 400/404)。 + // model_override 穿透:override 非空且在本 candidate 池中 → 用 override; + // 否则用 resolved_model(绝不因 override 致无模型)。 + let resolved_model = resolve_model_with_override(agentic_req, candidate, model_override); + + // 流前 InitFailed 重试循环。 + // 重试逻辑抽至 stream_with_retry helper(扁平重构),本函数仅负责 provider 准备 + model 决议 + 委派。 + stream_with_retry( + &provider, candidate, messages, tool_defs, app_handle, stop_flag, notify, conv_id, + max_retries, retry_deadline, &resolved_model, last_reasoning_content, + ).await +} + +// ── stream_with_retry: 流式 + 流前 InitFailed retryable 重试循环(扁平抽自原嵌套 5 层 match) ── +// 返回 StreamOutcome。内部含 retry_attempt 循环 + match StreamResult 三分支: +// Complete/Partial → Success;InitFailed Fatal → Fatal;InitFailed Retryable → 重试/耗尽。 +// 嵌套 ≤ 3 层:for 内 match 内单层 if(早返回避免嵌套加深)。 +async fn stream_with_retry( + provider: &Arc, + candidate: &AiProviderRecord, + messages: &[ChatMessage], + tool_defs: &[df_ai::provider::ToolDefinition], + app_handle: &AppHandle, + stop_flag: &std::sync::atomic::AtomicBool, + notify: &tokio::sync::Notify, + conv_id: &str, + max_retries: usize, + retry_deadline: tokio::time::Instant, + resolved_model: &str, + last_reasoning_content: &Option, +) -> StreamOutcome { + // 供应商级采样温度覆盖:provider config JSON 带 "temperature" 字段则用之, + // 治受限模型(如 Kimi 系经 u-switch 透传)仅接受 temperature=1、其它值 400 的问题; + // 未配置/非法 JSON 走默认 0.7。candidate 在重试循环内不变,解析提到循环外只做一次。 + let temperature = candidate + .config + .as_deref() + .and_then(|cfg| serde_json::from_str::(cfg).ok()) + .and_then(|v| v.get("temperature").and_then(|t| t.as_f64())) + .map(|t| t as f32) + .or(Some(0.7)); + for retry_attempt in 0..=max_retries { + let retry_request = CompletionRequest { + model: resolved_model.to_string(), + messages: messages.to_vec(), + temperature, + max_tokens: Some(8192), + stream: true, + tools: if tool_defs.is_empty() { None } else { Some(tool_defs.to_vec()) }, + tool_choice: None, + reasoning_content: last_reasoning_content.clone(), + }; + + let result = stream_llm( + Arc::clone(provider), retry_request, app_handle, stop_flag, notify, conv_id, + ).await; + + match result { + StreamResult::Complete { text, tool_calls, usage, reasoning_content } => { + return StreamOutcome::Success { + text, tool_calls, usage, incomplete: false, + resolved_model: resolved_model.to_string(), reasoning_content, + }; + } + StreamResult::Partial { text, tool_calls, usage, reasoning_content } => { + // MidStream 保文不重试。携带 incomplete=true 交调用方走保文路径。 + tracing::warn!( + conv_id = %conv_id, + text_len = text.len(), + "[ai] 流中途失败(候选 {}),保文不重试(incomplete=true)", + candidate.name, + ); + return StreamOutcome::Success { + text, tool_calls, usage, incomplete: true, + resolved_model: resolved_model.to_string(), reasoning_content, + }; + } + StreamResult::InitFailed { retryable, error } => { + // Fatal(4xx 非429/鉴权/参数错)立即放弃整个 fallback。 + if !retryable { + tracing::warn!( + conv_id = %conv_id, + provider = %candidate.name, + attempt = retry_attempt + 1, + "[ai] 候选 {} 流前失败 Fatal(4xx/鉴权),立即放弃 fallback", + candidate.name, + ); + return StreamOutcome::Fatal { error }; + } + // Retryable:判断是否重试预算耗尽 + if let Some(exhausted_error) = check_retry_exhausted( + conv_id, candidate, retry_attempt, max_retries, retry_deadline, &error, + ) { + return StreamOutcome::InitFailedExhausted { error: exhausted_error }; + } + // 未耗尽:退避 + emit AiStreamRetry + sleep + continue + emit_retry_attempt( + app_handle, conv_id, retry_attempt, max_retries, retry_deadline, + ).await; + } + } + } + + // 仅 max_retries=0(无重试配置)且首试 InitFailed retryable=true 时到达此处: + // 0..=0 循环首趟即 is_last → 已在循环内 return InitFailedExhausted。此为防御兜底。 + StreamOutcome::InitFailedExhausted { + error: "AI 调用失败:重试预算耗尽(防御兜底)".to_string(), + } +} + +// ============================================================ +// stream_round: 单轮流式调用(candidate 链 + stream_one_provider + MidStream 保文) +// ============================================================ + +/// `run_agentic_loop` for 循环体「单轮流式调用」自包含块的返回结果。 +/// +/// 抽自 L1505-L1756(retry_deadline → candidate_chain 构建/降级 → candidate 循环 +/// (stream_one_provider + match 三态) → epoch 后校验 → MidStream 保文)。 +/// 表达本轮的 4 种结束路径,供 loop 主框架据以 `return` 或继续后续处理。 +/// +/// **不在本函数范围**(loop 内其它段): +/// - 停止/收敛/审批等待/熔断这些在 process_tool_calls 之后, +/// 属「工具执行后路径」,与流式调用本身解耦,留 loop 主体。 +/// - 正常完成(loop 外 L1957+):loop 退出后由 run_agentic_loop 直接收尾。 +pub(crate) enum RoundOutcome { + /// 本轮 stream 成功(Complete,无 incomplete)。携带本轮输出供 loop 后续 + /// push_assistant_message / save / 工具执行处理。 + /// + /// 注:reasoning_content 不在此 variant 携带——stream_round 内已通过 + /// `*last_reasoning_content = rc` 写回 caller 的 loop 级 mut 变量,loop 后续 + /// push_assistant_message 读 last_reasoning_content 即可(零行为变更)。 + Continue { + full_text: String, + tool_calls: std::collections::HashMap, + /// 本轮补全后 usage(prompt 估算兜底,round_usage_effective 单点派生), + /// loop 主体直接 add_usage/push,不再字面量重组。 + round_usage: df_ai::provider::TokenUsage, + /// 本轮 prompt 是否估算兜底(= 进 stream_round 时的 round_usage.prompt_tokens == 0), + /// 供 loop 主体 push 消息级 is_estimated,与 round_usage 同点派生防漂移。 + round_is_estimated: bool, + }, + /// 流返回后 stale(epoch 已变,被新 loop 接管):guard 已 disarm, + /// loop 应直接 return(不再 push/save/执行工具)。 + StaleAfterStream, + /// MidStream 保文路径已完整收尾(tokens.add + push partial + 系统提示 + + /// teardown_round save/reset/emit)→ loop 直接 return。 + MidStreamSaved, + /// Fatal(候选 Fatal 或全候选耗尽):guard.reset + epoch 检查 + emit AiError + + /// save 已完成 → loop 直接 return。 + Fatal, +} + +/// 单轮流式调用:抽自 `run_agentic_loop` for 循环体的「单轮流式」自包含块。 +/// +/// 范围 = retry_deadline 计算 → candidate_chain 构建(G4.1 防抖降级) +/// → candidate 循环(try_acquire_for_provider + stream_one_provider + match 三态) +/// → epoch 后 stale 检查 → MidStream 保文路径。返回 [`RoundOutcome`] 表达本轮结果。 +/// +/// **逐字节等价**:原 L1505-L1756 内联块的 save/emit/guard.reset/permit 顺序、 +/// token 累加、push_assistant_message 调用、teardown_round 退出序列全部保留。 +/// 抽取仅改控制流(原各路径 `return` → `return RoundOutcome::Xxx`,loop 据返回值决定 return)。 +/// +/// **借用处理**: +/// - `tokens: &mut TokenAccumulator` / `saved_token_snapshot: &mut TokenUsage` / +/// `last_round_estimated: &mut bool`:loop 级累计状态,&mut 借用,函数内修改 caller 可见。 +/// - `resolved_model: &mut String` / `provider_config: &mut AiProviderRecord` / +/// `last_reasoning_content: &mut Option`:loop 级 mut 变量(被 candidate +/// 切换覆盖),&mut 借用,函数内赋新值后 loop 后续 push/save 用最新。 +/// - `guard: &mut GeneratingGuard`:RAII 复位,&mut 借用,函数内 guard.reset()/disarm() +/// 直接作用于 caller 的 guard。 +/// - 其它(session_arc/db/app_handle/conv_id/...):& 引用。 +/// +/// **permit**:per-provider permit 仍在 candidate 循环内取/释放(随作用域 Drop), +/// 与原实现一致;per_conv permit 由 loop 入口整 loop 持有,本函数不涉及。 +#[allow(clippy::too_many_arguments)] +pub(crate) async fn stream_round( + session_arc: &Arc>, + db: &Arc, + conv_id: &str, + messages: &[ChatMessage], + tool_defs: &[df_ai::provider::ToolDefinition], + app_handle: &AppHandle, + stop_flag: &std::sync::atomic::AtomicBool, + notify: &tokio::sync::Notify, + max_retries: usize, + model_override: &Option, + agentic_req: &TaskRequirements, + estimated_prompt: u32, + // &mut loop 级状态(函数内修改,caller 可见) + last_round_estimated: &mut bool, + last_reasoning_content: &mut Option, + resolved_model: &mut String, + provider_config: &mut AiProviderRecord, + provider_saturation: &mut HashMap, + tokens: &mut TokenAccumulator, + saved_token_snapshot: &mut df_ai::provider::TokenUsage, + guard: &mut GeneratingGuard, + // & 引用 + candidates: &[AiProviderRecord], + llm_concurrency: &LlmConcurrency, + pinned_goals_snapshot: &[GoalEntry], + loop_epoch_arc: &std::sync::Arc, + my_epoch: u64, +) -> RoundOutcome { + // 重试总预算(挂钟,含 sleep + 各次请求耗时),对齐 retry::MAX_TOTAL_BUDGET 30s。 + // 本轮各 candidate 共享一个 30s 预算(切换 provider 不重置预算, + // 防多 provider 串行重试累加超过单轮总预算)。超预算直接放弃重试交最终错误/保文路径。 + let retry_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); + + // 外层 for candidate 包裹 stream_one_provider。 + // 顺序:[primary, ...candidates](空池兜底时仅 primary)。 + // - Success → 用结果覆盖迭代级 resolved_model/provider_config(供 push/save/标题); + // 成功即 break。 + // - InitFailedExhausted(retryable 耗尽)→ continue 切下一 candidate。 + // - Fatal(4xx 非429/鉴权)→ stream_llm 已 emit AiError,guard.reset + return + // 放弃整轮(对齐 retry.rs Fatal + provider_pool 文档)。 + // 全 candidate 耗尽 → 沿用单 provider 失败语义(guard.reset + return,AiError 已在 + // 各 candidate 的 stream_llm 内 emit 最后一条)。 + // 候选链(拥有 Vec):[primary, ...candidates]。预先 clone primary 入链, + // 避免借用 provider_config(切换成功后需写回 provider_config = candidate.clone())。 + // candidates 空(单 provider)→ candidate_chain 仅 primary,循环跑一次,耗尽即 return。 + let mut candidate_chain: Vec = Vec::with_capacity(1 + candidates.len()); + candidate_chain.push(provider_config.clone()); + candidate_chain.extend(candidates.iter().cloned()); + + // 防瞬时抖动(G4.1):连续 PROVIDER_SATURATION_DEMOTE_THRESHOLD 次饱和才把主 provider + // 降级到链尾。计数器在候选循环内维护(Exhausted +1 / Acquired 清零),跨迭代累计。 + // 链首(当前主 provider)连续 N 次 Exhausted → 移到链尾让 fallback 先试; + // 恢复后 Acquired 清零 → 下轮回到链首。单次/瞬时饱和(N 次内)不降级,防跳过主 provider。 + // first():链构建总是 push 至少 1 个(provider_config),但防御性取首防空链 panic。 + if let Some(streak) = candidate_chain + .first() + .and_then(|c| provider_saturation.get(&c.id)) + .copied() + .filter(|s| *s >= PROVIDER_SATURATION_DEMOTE_THRESHOLD) + { + let saturated = candidate_chain.remove(0); + candidate_chain.push(saturated.clone()); + tracing::info!( + conv_id = %conv_id, + provider = %saturated.name, + streak = streak, + threshold = PROVIDER_SATURATION_DEMOTE_THRESHOLD, + "[ai] 主 provider 连续 {} 次 per-provider 饱和,降级到候选链尾(防抖动跳过)", + streak, + ); + } + + let (full_text, tool_calls_acc, round_usage, incomplete, round_reasoning_content) = { + // outcome 累积成功结果(Complete/Partial),InitFailed 不写入。 + let mut outcome: Option<(String, std::collections::HashMap, df_ai::provider::TokenUsage, bool, Option)> = None; + // 追踪最后一个 Exhausted candidate 的诊断文本,供「全 candidate 耗尽」时 emit 最终 AiError。 + // Fatal 分支即时 emit(终态不切候选),Exhausted 分支仅记录 error 不 emit(可能切下一 candidate 成功, + // emit 会留残留气泡)。 + let mut last_exhausted_error: Option = None; + + 'candidate: for candidate in &candidate_chain { + // per-provider permit(非阻塞三态,G4.1): + // NotConfigured(未配置,无限流)→ 无 permit 直接 proceed(单 provider 零变化); + // Acquired(permit)→ 持 permit stream,切换 candidate 时随作用域 Drop 释放; + // Exhausted(占满)→ 连续饱和计数 +1,跳下一 candidate(降级,不阻塞)。 + // 旧 acquire_for_provider(阻塞 acquire_owned)在主 provider 信号量占满时整条 + // fallback 链卡死(降级失效),已由本三态非阻塞版本取代。 + let _provider_permit = match llm_concurrency.try_acquire_for_provider(&candidate.id).await { + ProviderAcquire::NotConfigured => None, + ProviderAcquire::Acquired(permit) => { + // 成功拿到 permit:该 provider 已恢复,连续饱和计数清零(跨迭代防抖)。 + provider_saturation.insert(candidate.id.clone(), 0); + Some(permit) + } + ProviderAcquire::Exhausted => { + // 占满:连续饱和计数 +1;本次跳下一 candidate(非阻塞,不等待)。 + // 跨迭代防抖:连续 N 次饱和后链首降级(见候选链构建处),防瞬时抖动跳过主 provider。 + *provider_saturation.entry(candidate.id.clone()).or_insert(0) += 1; + tracing::debug!( + conv_id = %conv_id, + provider = %candidate.name, + streak = provider_saturation.get(&candidate.id).copied().unwrap_or(0), + "[ai] 候选 {} per-provider 信号量占满,跳过(非阻塞,防单 provider 429)", + candidate.name, + ); + continue 'candidate; + } + }; + + let stream_outcome = stream_one_provider( + candidate, + messages, + tool_defs, + app_handle, + stop_flag, + notify, + conv_id, + max_retries, + retry_deadline, + model_override, + agentic_req, + last_reasoning_content, + ).await; + + match stream_outcome { + StreamOutcome::Success { text, tool_calls, usage, incomplete, resolved_model: m, reasoning_content: rc } => { + // 成功:更新迭代级 resolved_model + provider_config(供后续 push/save/标题)。 + // mut 解构(已在顶部声明 mut):本轮后续 save/push 用「实际成功所用 provider」 + // 而非主 candidate。compress 已在本轮 stream 之前用过 primary,不受影响。 + *resolved_model = m; + *provider_config = candidate.clone(); + *last_reasoning_content = rc.clone(); + outcome = Some((text, tool_calls, usage, incomplete, last_reasoning_content.clone())); + break 'candidate; + } + StreamOutcome::InitFailedExhausted { error } => { + // 本 candidate 重试耗尽(retryable)。切下一 candidate 继续尝试。 + // candidates 空(单 provider)时此即「耗尽 return」语义—— + // 跳出循环后 outcome 仍 None,落入下方「全耗尽 return」兜底。 + // 不在此 emit AiError(可能切下一 candidate 成功留残留气泡), + // 仅记录 error 供全耗尽兜底 emit 最后一条。 + tracing::warn!( + conv_id = %conv_id, + provider = %candidate.name, + "[ai] 候选 {} 流前失败重试耗尽,切换下一 provider", + candidate.name, + ); + last_exhausted_error = Some(error); + continue 'candidate; + } + StreamOutcome::Fatal { error } => { + // Fatal(4xx 非429/鉴权/参数错):立即放弃整轮 fallback。 + // 统一 emit 最终错误气泡(单气泡聚合)。 + guard.reset().await; + // F1:旧 loop(被新 loop 接管)不 emit 错误气泡(owner loop 负责呈现)。 + if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { + return RoundOutcome::Fatal; + } + emit_fatal_error(app_handle, conv_id, &error).await; + // G1.1(2026-08-05):Fatal 退出也落库 user 消息(镜像下方 Exhausted 分支), + // 否则重启/切会话从 DB 恢复时末条 user 丢失(与 Exhausted 同根因不对称)。 + // epoch 校验通过后、return 前补 save(owner loop 才写,防 owner 已转移仍写)。 + save_conversation(session_arc, db, conv_id, None, Some(&*resolved_model), true).await; + return RoundOutcome::Fatal; + } + } + } + + match outcome { + Some(r) => r, + None => { + // 全 candidate 耗尽(含单 provider 场景):沿用原单 provider 失败语义。 + tracing::warn!( + conv_id = %conv_id, + candidates_tried = candidate_chain.len(), + "[ai] 全 provider 候选流前失败重试耗尽,放弃本轮", + ); + guard.reset().await; + if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { + return RoundOutcome::Fatal; + } + let err_msg = last_exhausted_error + .unwrap_or_else(|| "AI 调用失败:所有候选 provider 重试耗尽".to_string()); + emit_fatal_error(app_handle, conv_id, &err_msg).await; + // 全 provider 失败也需落库 user 消息(已 push 内存), + // 否则切换/重载从 DB 恢复时末条 user 丢失(实测压缩触发失败场景:DB 末条 tool 无后续 user)。 + save_conversation(session_arc, db, conv_id, None, Some(&*resolved_model), true).await; + return RoundOutcome::Fatal; + } + } + }; + + // G4.3:本轮 token 用量是否估算值(provider 未报 prompt_tokens → estimated_prompt 兜底), + // 供 loop 内/loop 后各退出路径透传 AiCompleted(is_estimated)仅作展示标注。 + // 语义修正:整 loop 只要任一轮估算即标估算(累计总量含估算成分),非仅末轮。 + // 三真相源收口:本轮 usage 补全 + estimated 判定在此单点派生(round_usage_effective), + // MidStream 保文分支与 Continue 路径(loop 主体)均消费本结果,不再各自字面量重组。 + let (round_usage_full, round_is_estimated) = round_usage_effective(&round_usage, estimated_prompt); + *last_round_estimated |= round_is_estimated; + + // F1 并发 epoch:流返回后若已被新 loop 接管(force_send 等),旧 loop 不再 push 消息 / + // 保文 / 执行工具,立即退出(guard disarm 跳过复位,防 clobber 新 loop 的 Generating)。 + if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { + guard.disarm(); + return RoundOutcome::StaleAfterStream; + } + + // CR-30-2 / UX-2025-04 / 决策 a1: MidStream 保文路径——partial_text 已接收, + // 入库为正常 assistant 消息 + emit AiCompleted(incomplete=true) + 追加系统提示消息。 + // 不走 AiError(非异常中断,已有可用文本),不重试(决策 a1)。 + // 并发时用户停优先:网络断同时用户点停 → stop_flag true 时不走保文路径, + // 落入下方 ~1508 stop_flag 检查走停止路径(用户意图优先)。 + // partial 文本仍由下方 push_assistant_message(~1485)保文不丢。 + if incomplete && !stop_flag.load(Ordering::SeqCst) { + // 本轮 usage 补全(prompt 估算兜底,含 cache/reasoning 分项)已由 round_usage_effective 单点派生 + let usage = round_usage_full; + // 累加本轮全量 usage(含 cache/reasoning 分项)到 tokens 累加器 + tokens.add_usage(&usage); + + // 追加 partial assistant 消息(若无 tool_calls 且有文本) + // 退出校验改 conv 存在性 + push 改 per_conv.messages。 + // conv_id 来源:run_agentic_loop 入参。 + { + let mut session = session_arc.lock().await; + if !session.per_conv.contains_key(conv_id) { + tracing::warn!( + stale_conv = %conv_id, + "[ai] MidStream 保文后 conv 已删除,丢弃本轮 push" + ); + return RoundOutcome::MidStreamSaved; + } + // 与 push_assistant_message 一致:trim 归一化,纯空白保文(网络中断只有空白)不落库 + let full_text = full_text.trim(); + if !full_text.is_empty() { + let conv = session.conv(conv_id); + let mut msg = ChatMessage::assistant(full_text); + msg.model = Some(resolved_model.clone()); + // MidStream 保文也回填 reasoning_content + msg.reasoning_content = round_reasoning_content.clone(); + // 消息级 token(对齐 push_assistant_message 双轨持久化):本轮 partial usage + // (prompt=round 或 estimated 兜底,completion=round)。系统提示消息无 token,不设。 + // 分项 token(2026-08-02):cache/reasoning 透传自 round_usage。 + msg.prompt_tokens = Some(usage.prompt_tokens); + msg.completion_tokens = Some(usage.completion_tokens); + msg.prompt_cache_hit_tokens = Some(usage.prompt_cache_hit_tokens); + msg.prompt_cache_miss_tokens = Some(usage.prompt_cache_miss_tokens); + msg.reasoning_tokens = Some(usage.reasoning_tokens); + // 消息级估算标记:本轮 prompt 是否 estimated 兜底,reload 逐条回显对齐 live 态 + msg.is_estimated = Some(round_is_estimated); + conv.messages.push(msg); + // 追加系统提示消息:响应因网络中断不完整(对齐决策 a1 系统提示机制) + let mut notice = ChatMessage::system("⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。"); + notice.model = Some(resolved_model.clone()); + conv.messages.push(notice); + } + } + + // 统一走 teardown_round 收尾(save + spawn_title + reset + emit)。 + // 注意:partial 文本+系统提示已先 push(上方 block),此 save 落库含本轮 partial,幂等覆盖。 + // save_usage 用增量(tokens 已累加本轮,减上次快照);emit_usage 用 tokens 快照累计。 + // MidStream 分叉:emit_incomplete=Some(true)(前端标不完整),publish_incomplete=None(总线消费方), + // do_publish=true(publish 走总线)。spawn_title=true(后台标题,失败 extract 兜底)。 + let save_usage = usage_delta_since(tokens, saved_token_snapshot); + let emit_usage = tokens_snapshot(tokens); + teardown_round( + session_arc, db, conv_id, + Some(&save_usage), Some(&*resolved_model), + true, + provider_config, llm_concurrency, + guard, + &emit_usage, + *last_round_estimated, + Some(true), None, true, + pinned_goals_snapshot, + app_handle, + loop_epoch_arc, my_epoch, + ).await; + return RoundOutcome::MidStreamSaved; + } + + // 正常完成本轮 stream(无 incomplete,或 incomplete 但用户停优先走 stop 路径)。 + // 携带本轮输出交 loop 主体处理后续 push/save/工具执行。 + // round_reasoning_content 已通过上方 `*last_reasoning_content = rc` 写回 caller, + // loop 后续 push_assistant_message 读 last_reasoning_content 即可。 + let _ = round_reasoning_content; // 防未用 warning(MidStream 分支已消费) + RoundOutcome::Continue { + full_text, + tool_calls: tool_calls_acc, + round_usage: round_usage_full, + round_is_estimated, + } +} + +// ── round_usage_effective: 本轮 usage 补全 + estimated 判定的单一派生点(三真相源收口) ── +// +// 收口前同一事实(「provider 未报 prompt_tokens → estimated_prompt 兜底」)在三处独立派生, +// 各持一份可漂移的重复逻辑: +// 1) MidStream 保文分支:TokenUsage 字面量重组(prompt/total 双条件分支)+ msg.is_estimated; +// 2) loop 主体 Continue 后:round_prompt + round_usage_full 同构字面量重组 + push 实参 estimated; +// 3) G4.3 累计标注:*last_round_estimated |= round_usage.prompt_tokens == 0。 +// 收口后:此处一次求值返回 (补全 usage, is_estimated),三处消费方全部改为引用/派生, +// 逐字段等价(prompt=estimated 兜底 / total=estimated+completion / 分项透传),行为零变更。 +fn round_usage_effective( + round_usage: &df_ai::provider::TokenUsage, + estimated_prompt: u32, +) -> (df_ai::provider::TokenUsage, bool) { + let is_estimated = round_usage.prompt_tokens == 0; + let usage = df_ai::provider::TokenUsage { + prompt_tokens: if is_estimated { estimated_prompt } else { round_usage.prompt_tokens }, + completion_tokens: round_usage.completion_tokens, + total_tokens: if is_estimated { + estimated_prompt.saturating_add(round_usage.completion_tokens) + } else { + round_usage.total_tokens + }, + // 分项 token(2026-08-02):cache/reasoning 透传自 round_usage,落库 + 累加器都需 + prompt_cache_hit_tokens: round_usage.prompt_cache_hit_tokens, + prompt_cache_miss_tokens: round_usage.prompt_cache_miss_tokens, + reasoning_tokens: round_usage.reasoning_tokens, + }; + (usage, is_estimated) +} + +// ── emit_retry_attempt: 退避日志 + emit AiStreamRetry + sleep ── +// 前置:未耗尽重试预算(check_retry_exhausted 已返 None)。本函数完成退避后返回。 +async fn emit_retry_attempt( + app_handle: &AppHandle, + conv_id: &str, + retry_attempt: usize, + max_retries: usize, + retry_deadline: tokio::time::Instant, +) { + let now = tokio::time::Instant::now(); + let delay = retry::backoff_delay((retry_attempt + 1) as u32) + .min(retry_deadline.saturating_duration_since(now)); + let total_retry = retry_attempt + 1; + tracing::warn!( + conv_id = %conv_id, + attempt = total_retry, + max_attempts = max_retries + 1, + delay_ms = delay.as_millis() as u64, + "[ai] 流前失败(Retryable),{}ms 后重试 ({}/{})", + delay.as_millis(), total_retry, max_retries + 1, + ); + let ev = AiChatEvent::AiStreamRetry { + attempt: total_retry as u32, + max_attempts: (max_retries + 1) as u32, + conversation_id: Some(conv_id.to_string()), + }; + emit_ai_chat_event(app_handle, ev); + tokio::time::sleep(delay).await; +} + +/// 把 Anthropic prompt caching 易变尾段追加到请求消息流**末尾**(一条 user 消息)。 +/// +/// 多场景命中率设计要点: +/// - **放消息流末尾而非 system**:任何位于 system 之后的消息级缓存断点(含未来「多轮对话缓存」 +/// 在末条 user 上打断点)都在易变段之前——易变内容(日期/清单/augmentation/知识)每日/每请求 +/// 变化时,只影响其自身及之后(无),不失效已缓存的 system/历史前缀。若放首条 user,跨天日期 +/// 变化会失效其后全部历史缓存。 +/// - **用 user 角色而非 system**:anthropic_compat::convert_request 会把 System-role 消息抽到 +/// 顶层 system(受 cache_control 缓存),易变段若为 system 会随请求变化失效整段 system 缓存。 +/// 必须 user 承载,convert_request 对连续 user 做 merge_consecutive_users,追加安全。 +/// - **不持久化**:仅注入本轮请求的 messages clone,per_conv.messages 不含易变段 → 每轮重建 +/// 恰好一条,无重复。 +pub(crate) fn merge_volatile_tail(messages: &mut Vec, tail: &str) { + if tail.is_empty() { + return; + } + messages.push(ChatMessage::user(tail)); +} diff --git a/src-tauri/src/commands/ai/audit/approval.rs b/src-tauri/src/commands/ai/audit/approval.rs index eed036d..308600c 100644 --- a/src-tauri/src/commands/ai/audit/approval.rs +++ b/src-tauri/src/commands/ai/audit/approval.rs @@ -7,7 +7,7 @@ use std::sync::Arc; -use tauri::{AppHandle, Emitter, Manager}; +use tauri::AppHandle; use tokio::sync::Mutex; use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; @@ -16,6 +16,7 @@ use df_storage::crud::AiToolExecutionRepo; use df_storage::db::Database; use super::cache::{cache_hit_warning, find_cached_high_risk_result, pending_placeholder_for}; +use super::super::event_bus::emit_ai_chat_event; use super::diff::build_write_file_diff; use super::reason::build_approval_reason; use super::record::audit_tool_call; @@ -180,8 +181,7 @@ pub(super) async fn insert_pending_approval( result: serde_json::Value::String(skip_msg.clone()), conversation_id: Some(conv_id.to_string()), }; - let _ = app_handle.emit("ai-chat-event", ev.clone()); - let _ = app_handle.state::().ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app_handle, ev); audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"), current_message_id).await; return; } @@ -214,8 +214,7 @@ pub(super) async fn insert_pending_approval( diff: approval_diff, conversation_id: Some(conv_id.to_string()), }; - let _ = app_handle.emit("ai-chat-event", ev.clone()); - let _ = app_handle.state::().ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app_handle, ev); audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id).await; } @@ -322,8 +321,7 @@ pub(super) async fn handle_approval_tool( dir: dir_label.clone(), conversation_id: Some(conv_id.to_string()), }; - let _ = app_handle.emit("ai-chat-event", ev.clone()); - let _ = app_handle.state::().ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app_handle, ev); // 收集后循环外 spawn 执行,对齐 Low risk 不持锁(原注释 L593 声称"锁外"但代码持锁, // run_command 慢命令会阻塞同会话所有触 state.ai_session 的 IPC,CR-51 修此) trust_hits.push((draft, args, dir_label, risk_level)); @@ -357,8 +355,7 @@ pub(super) async fn handle_approval_tool( result: serde_json::Value::String(warned_content.clone()), conversation_id: Some(conv_id.to_string()), }; - let _ = app_handle.emit("ai-chat-event", ev.clone()); - let _ = app_handle.state::().ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app_handle, ev); // 审计:去重命中记一条(status 透传缓存来源 completed/rejected/failed,SW-260618-16;decided_by=auto_dedup),不进 pending audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(warned_content), Some("auto_dedup"), current_message_id).await; return; diff --git a/src-tauri/src/commands/ai/audit/cache.rs b/src-tauri/src/commands/ai/audit/cache.rs index 40e7962..3f51bb9 100644 --- a/src-tauri/src/commands/ai/audit/cache.rs +++ b/src-tauri/src/commands/ai/audit/cache.rs @@ -56,8 +56,12 @@ pub(crate) fn is_pending_placeholder(content: &str) -> bool { /// AC-2 ②:list_directory 目录级去重(cache key 只取 path,同目录换参数也命中)后, /// 通用文案「换参数或换工具」对同目录无效(换 max_depth 也仍命中),故目录类工具 /// 用专用文案:明确告知「目录已列过 + 内容未变」,引导换 grep/search_files 定向搜索。 +/// +/// 目录类判定查注册元数据(cache_path_key 维度,DRY 债 #5 收敛),与 +/// readonly_cache_args_key 的目录级 key 同一判据,不再各自手写工具名 match。 pub(crate) fn cache_hit_warning(tool_name: &str, hit_count: u32) -> String { - if matches!(tool_name, "list_directory" | "list_dir") { + crate::commands::ai::tool_registry::ensure_tool_meta_seeded(); + if df_ai::ai_tools::cache_path_key_only(tool_name) { format!( "[重复调用拦截] 目录 {} 此前已列过(第 {} 次命中缓存),目录内容未变,以下为缓存结果。不要重复列同一目录,如需查找文件请改用 grep/search_files 定向搜索,或换其他目录。\n\n", tool_name, hit_count @@ -212,14 +216,16 @@ fn canonical_args_key(args: &serde_json::Value) -> String { /// /// list_directory 同目录换参数(recursive/max_depth/skip_noise_dirs 不同)也判重复—— /// 目录内容没变,换深度/递归参数重列是重复探索(实证 09e7abfa:同一目录列 3 次)。 -/// 故 list_directory 的缓存 key 只取 path(忽略其他参数),同目录即命中缓存, +/// 故声明 cache_path_key 的工具(list_directory,DRY 债 #5 收敛后查注册元数据) +/// 缓存 key 只取 path(忽略其他参数),同目录即命中缓存, /// 后续命中返回缓存 + 专用警告(cache_hit_warning 目录分支)。 /// 其他只读工具仍走全参 JSON 归一(键序无关,见 canonical_args_key)。 /// /// pub(crate):供 audit/mod.rs 轮内去重复用同一 key 口径(同源同 key, /// 跨轮缓存 + 轮内去重判定一致,防两处 key 漂移致去重误判)。 pub(crate) fn readonly_cache_args_key(tool_name: &str, args: &serde_json::Value) -> String { - if matches!(tool_name, "list_directory" | "list_dir") { + crate::commands::ai::tool_registry::ensure_tool_meta_seeded(); + if df_ai::ai_tools::cache_path_key_only(tool_name) { args.get("path") .and_then(|v| v.as_str()) .unwrap_or_default() @@ -241,9 +247,9 @@ pub(crate) fn readonly_cache_args_key(tool_name: &str, args: &serde_json::Value) /// 跳过真实执行(也跳过 emit Started/Completed——直接走缓存回填路径)。 /// /// **安全边界**(对照 find_cached_high_risk_result 严格收紧): -/// - 仅匹配白名单只读幂等工具(READONLY_CACHE_TOOLS):read_file/list_directory/ -/// search_files/grep/read_symbol。写工具(write_file/patch_file/run_command 等) -/// 永不缓存,避免误伤。 +/// - 仅匹配只读幂等工具(readonly 元数据维度,DRY 债 #5 收敛后查注册元数据, +/// 现 read_file/read_symbol/list_directory/search_files/grep 声明)。 +/// 写工具(write_file/patch_file/run_command 等)未声明,永不缓存,避免误伤。 /// - 仅匹配 **status=completed** 的成功结果(失败/拒绝不缓存,可能重试成功)。 /// - 仅匹配 **非占位** 结果(排除 pending_placeholder 占位)。 /// - args 走 JSON 规范化比较(键序无关),与 find_cached_high_risk_result 一致。 @@ -260,8 +266,9 @@ pub(crate) async fn find_cached_readonly_result( ) -> Option<(String, u32)> { use df_ai::provider::MessageRole; - // 白名单:只读幂等工具才缓存。写工具/有副作用工具永不缓存。 - if !READONLY_CACHE_TOOLS.contains(&tool_name) { + // 只读幂等工具才缓存。写工具/有副作用工具永不缓存。 + crate::commands::ai::tool_registry::ensure_tool_meta_seeded(); + if !df_ai::ai_tools::is_readonly(tool_name) { return None; } @@ -332,17 +339,8 @@ pub(crate) async fn find_cached_readonly_result( Some((content, hit_count)) } -/// 可缓存的只读幂等工具白名单。 -/// 仅含纯读取/查询类工具:同参多次调用结果一致,缓存命中安全。 -/// 写工具/有副作用工具(write_file/patch_file/run_command/create_*/update_*/delete_* 等) -/// 永不进此白名单——它们每次执行都可能改变状态,缓存会掩盖变化。 -const READONLY_CACHE_TOOLS: &[&str] = &[ - "read_file", // 读文件内容(同 path+offset+limit+search 参数 → 同结果) - "read_symbol", // AST 符号读取(同 path+symbol+full → 同结果) - "list_directory", // 列目录(AC-2 ②:缓存 key 只取 path,同目录换 recursive/max_depth 也判重复) - "search_files", // 搜文件名(同 path+pattern+offset+limit → 同结果) - "grep", // 搜文件内容(同 path+pattern+mode → 同结果) -]; +/// 只读缓存白名单已收敛为注册元数据(readonly 维度,DRY 债 #5): +/// find_cached_readonly_result 改查 df_ai::ai_tools::is_readonly,本文件不再持名单。 /// 递归对 JSON 对象的键做字典序排序(就地),数组成员也递归排序。 fn sort_object_keys(v: &mut serde_json::Value) { @@ -421,15 +419,18 @@ fn contains_listing_command(command: &str) -> bool { || contains_word(&lower, "ls") } -/// 扫描会话历史,取所有 list_directory 已列过的目录路径(去重,保留原始大小写)。 +/// 扫描会话历史,取所有目录级缓存工具(cache_path_key 声明者,现即 list_directory) +/// 已列过的目录路径(去重,保留原始大小写)。 /// -/// 与 find_cached_readonly_result 同源读 assistant tool_calls;仅取 list_directory -/// 的 args.path,忽略递归/深度参数(② 同语义:同目录即算已列过)。 +/// 与 find_cached_readonly_result 同源读 assistant tool_calls;仅取 args.path, +/// 忽略递归/深度参数(② 同语义:同目录即算已列过)。工具判定查注册元数据 +/// (cache_path_key 维度,DRY 债 #5 收敛),与目录级缓存 key 同一判据。 async fn listed_directory_paths( session_arc: &Arc>, conv_id: &str, ) -> Vec { use df_ai::provider::MessageRole; + crate::commands::ai::tool_registry::ensure_tool_meta_seeded(); let session = session_arc.lock().await; let Some(conv) = session.conv_read(conv_id) else { return Vec::new() }; let mut dirs: Vec = Vec::new(); @@ -440,7 +441,7 @@ async fn listed_directory_paths( } let Some(tcs) = m.tool_calls.as_ref() else { continue }; for tc in tcs { - if tc.function.name != "list_directory" { + if !df_ai::ai_tools::cache_path_key_only(&tc.function.name) { continue; } let Ok(args) = serde_json::from_str::(&tc.function.arguments) else { @@ -494,9 +495,10 @@ pub(crate) async fn detect_listing_bypass( None } -/// AC-2 ③:目录列举绕行警告注入(system 消息首位,对齐 G2/AC-2① 软提示模式)。 +/// AC-2 ③:目录列举绕行警告注入(system 消息首位)。 /// /// 只警告不硬拒:命令照常执行/审批,误报最多多一条提示,不阻断工作流。 +/// 插入骨架经 agentic::helpers::insert_system_notice 复用(DRY #7,与 G2/AC-2① 同一实现)。 pub(crate) async fn insert_listing_bypass_warning( session_arc: &Arc>, conv_id: &str, @@ -506,16 +508,13 @@ pub(crate) async fn insert_listing_bypass_warning( "⚠ 检测到用 run_command 列目录({}) 绕行 list_directory 去重。列目录请用 list_directory 工具,如需查找文件请用 grep/search_files 定向搜索。", dir ); - let mut session = session_arc.lock().await; - if session.per_conv.contains_key(conv_id) { - let conv = session.conv(conv_id); - conv.messages.insert_at(0, ChatMessage::system(&warn_text)); - tracing::info!( - conv_id = %conv_id, - dir = %dir, - "[ai] AC-2 目录列举绕行:注入警告(只警告不硬拒)" - ); - } + crate::commands::ai::agentic::helpers::insert_system_notice( + session_arc, + conv_id, + &warn_text, + &format!("AC-2 目录列举绕行:注入警告(只警告不硬拒) dir={}", dir), + ) + .await; } #[cfg(test)] diff --git a/src-tauri/src/commands/ai/audit/data_change.rs b/src-tauri/src/commands/ai/audit/data_change.rs index e52e5e1..7f5815a 100644 --- a/src-tauri/src/commands/ai/audit/data_change.rs +++ b/src-tauri/src/commands/ai/audit/data_change.rs @@ -11,33 +11,13 @@ use tauri::{AppHandle, Emitter}; /// AR-11(方案A):工具名 → (entity, action) 映射,用于数据变更联动刷新。 /// /// 仅数据变更类工具返回 Some,其余工具返回 None(不 emit df-data-changed)。 -/// 映射规则: -/// - create_project/create_idea/create_task → (project/idea/task, create) -/// - update_project/update_task/update_idea → (..., update) -/// - delete_project/delete_task/delete_idea/purge_project → (..., delete) -/// - restore_project → (project, update)(恢复等同状态变更,前端走 update 刷新) -/// - bind_directory → (project, update)(绑定目录属项目字段更新) +/// 映射查注册元数据(entity_action 维度,DRY 债 #5 收敛),在 declare_tool! 注册点声明: +/// create_*(project/idea/task, create)、update_*(..., update)、delete_*/purge_project +/// (..., delete)、restore_project/bind_directory → (project, update)、advance_task → +/// (task, update)(状态推进对齐 update_task)、write_file/patch_file → (file, update)。 pub(super) fn data_change_for_tool(name: &str) -> Option<(&'static str, &'static str)> { - let (entity, action) = match name { - "create_project" => ("project", "create"), - "create_task" => ("task", "create"), - "create_idea" => ("idea", "create"), - "update_project" => ("project", "update"), - "update_task" => ("task", "update"), - "update_idea" => ("idea", "update"), - // advance_task 改任务状态 = 任务数据变更,MCP 走 HTTP 形态时让 GUI 前端 - // listen df-data-changed 刷新任务列表(此前不在映射 → 状态推进后 GUI 不刷新)。 - // 数据变更机制是 entity 级(无 id),映射到 (task, update) 对齐 update_task。 - "advance_task" => ("task", "update"), - "delete_project" | "purge_project" => ("project", "delete"), - "delete_task" => ("task", "delete"), - "delete_idea" => ("idea", "delete"), - "restore_project" => ("project", "update"), - "bind_directory" => ("project", "update"), - "write_file" | "patch_file" => ("file", "update"), - _ => return None, - }; - Some((entity, action)) + crate::commands::ai::tool_registry::ensure_tool_meta_seeded(); + df_ai::ai_tools::entity_action(name) } /// AR-11(方案A):工具执行成功后按映射 emit "df-data-changed" { entity, action }。 diff --git a/src-tauri/src/commands/ai/audit/mod.rs b/src-tauri/src/commands/ai/audit/mod.rs index cd819c2..31b2b69 100644 --- a/src-tauri/src/commands/ai/audit/mod.rs +++ b/src-tauri/src/commands/ai/audit/mod.rs @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use tauri::{AppHandle, Emitter, Manager}; +use tauri::{AppHandle, Manager}; use tokio::sync::Mutex; use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; @@ -11,6 +11,8 @@ use df_ai::provider::ChatMessage; use df_storage::crud::AiToolExecutionRepo; use df_storage::db::Database; +use super::event_bus::emit_ai_chat_event; +use crate::state::{SettingsNorm, log_slow_lock}; use super::{AiChatEvent, AiSession, ApprovalKind, PathAuthRequest, PendingApproval, ToolCallDraft}; use super::tools::entity_resolve; @@ -142,8 +144,7 @@ async fn execute_with_heartbeat( break; } let ev = AiChatEvent::AiHeartbeat { conversation_id: Some(conv.clone()) }; - let _ = app_h.emit("ai-chat-event", ev.clone()); - let _ = app_h.state::().ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app_h, ev); } }); // RAII:execute 无论 Ok/Err/panic(unwind),_guard drop 自动 stop+abort,无心跳 task 泄漏。 @@ -233,10 +234,7 @@ pub(crate) async fn process_tool_calls( let session = session_arc.lock().await; let __id = session.conv_read(conv_id) .and_then(|c| c.messages.last_assistant_message_id()); - let __hold = __lock_t.elapsed(); - if __hold > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] process_tool_calls:192 持锁 {:?} (含 lock 等待)", __hold); - } + log_slow_lock(__lock_t, "process_tool_calls:192"); __id }, ).await { @@ -260,8 +258,7 @@ pub(crate) async fn process_tool_calls( args: args.clone(), conversation_id: Some(conv_id.to_string()), }; - let _ = app_handle.emit("ai-chat-event", ev.clone()); - let _ = app_handle.state::().ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app_handle, ev); (idx, draft, args) }) .collect(); @@ -283,18 +280,14 @@ pub(crate) async fn process_tool_calls( let __lock_t = std::time::Instant::now(); let mut session = session_arc.lock().await; session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &err_payload)); - let __hold = __lock_t.elapsed(); - if __hold > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] process_tool_calls:268 持锁 {:?} (含 lock 等待)", __hold); - } + log_slow_lock(__lock_t, "process_tool_calls:268"); } let ev = AiChatEvent::AiToolCallCompleted { id: draft.id.clone(), result: serde_json::Value::String(err_payload.clone()), conversation_id: Some(conv_id.to_string()), }; - let _ = app_handle.emit("ai-chat-event", ev.clone()); - let _ = app_handle.state::().ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app_handle, ev); let risk = 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, "failed", risk, Some(err_payload.clone()), Some("auto_resolve_fail"), current_message_id).await; @@ -306,16 +299,12 @@ pub(crate) async fn process_tool_calls( // 自动执行范围三档读取(前置到路径授权预校验之前:两处门控共用)。 // KV df-ai-auto-execute-mode 取值 low/medium/all,默认 low; // low 仅 Low 自动(等价旧行为),medium Low+Medium,all 全自动无审批(完全 AI 接管)。 + // get_string_norm 归一去引号(前端 JSON.stringify 落库带引号),不归一则 medium/all 档静默失效全走审批。 let auto_exec_mode = app_handle .state::() .settings - .get("df-ai-auto-execute-mode") + .get_string_norm("df-ai-auto-execute-mode") .await - .ok() - .flatten() - // 前端 appSettings.set 以 JSON.stringify 落库(字符串值带引号,"all" != all), - // 不归一则 medium/all 档静默失效全走审批(同 prompt.rs custom_prompt 的 trim 先例)。 - .map(|v| v.trim_matches('"').to_string()) .unwrap_or_else(|| "low".to_string()); // ── 文件工具路径授权预校验 ── @@ -334,16 +323,12 @@ pub(crate) async fn process_tool_calls( // 「信任所有目录」总开关(KV df-ai-path-auth-bypass,默认关):开启后白名单外路径不再挂起 // 授权弹窗(NeedsAuth 时直接放行),黑名单拦截也一并放行(产品拍板:体验优先,含敏感目录)。 // 读取失败按关处理(fail-safe,默认行为不变);前端设置页改后下一轮 process_tool_calls 即生效。 + // get_bool_norm 归一比对(前端 JSON.stringify 落库带引号),默认 false 由调用方显式传参。 let path_auth_bypass = app_handle .state::() .settings - .get(PATH_AUTH_BYPASS_KEY) - .await - .ok() - .flatten() - // 前端 appSettings.set 以 JSON.stringify 落库(带引号),归一后比对(同 df-ai-trace-approve 先例) - .map(|v| { let v = v.trim_matches('"'); v == "true" || v == "1" }) - .unwrap_or(false); + .get_bool_norm(PATH_AUTH_BYPASS_KEY, false) + .await; // 同步进程级黑名单放行标志:validate_path(tool_registry)无 bypass 参数,靠此标志放行黑名单; // 每轮工具执行前刷新,与下方 check_file_tool_auth 的入参 bypass 同源同值,两处拦截点语义一致。 path_auth::set_blacklist_bypass(path_auth_bypass); @@ -385,10 +370,7 @@ pub(crate) async fn process_tool_calls( let __lock_t = std::time::Instant::now(); let mut session = session_arc.lock().await; session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &err_msg)); - let __hold = __lock_t.elapsed(); - if __hold > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] process_tool_calls:262 持锁 {:?} (含 lock 等待)", __hold); - } + log_slow_lock(__lock_t, "process_tool_calls:262"); } // L3 emit 双写:路径黑名单拒绝 emit Completed 双路发布。 let ev = AiChatEvent::AiToolCallCompleted { @@ -396,8 +378,7 @@ pub(crate) async fn process_tool_calls( result: serde_json::Value::String(err_msg.clone()), conversation_id: Some(conv_id.to_string()), }; - let _ = app_handle.emit("ai-chat-event", ev.clone()); - let _ = app_handle.state::().ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app_handle, ev); // 审计:路径黑名单拒绝(decided_by=auto_blacklist),留痕可追溯 let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High); audit_tool_call( @@ -433,18 +414,14 @@ pub(crate) async fn process_tool_calls( let __lock_t = std::time::Instant::now(); let mut session = session_arc.lock().await; session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg)); - let __hold = __lock_t.elapsed(); - if __hold > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] process_tool_calls:305 持锁 {:?} (含 lock 等待)", __hold); - } + log_slow_lock(__lock_t, "process_tool_calls:305"); } let ev = AiChatEvent::AiToolCallCompleted { id: draft.id.clone(), result: serde_json::Value::String(skip_msg.clone()), conversation_id: Some(conv_id.to_string()), }; - let _ = app_handle.emit("ai-chat-event", ev.clone()); - let _ = app_handle.state::().ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app_handle, ev); 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, @@ -473,10 +450,7 @@ pub(crate) async fn process_tool_calls( ); // 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果。 session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id))); - let __hold = __lock_t.elapsed(); - if __hold > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] process_tool_calls:325 持锁 {:?} (含 lock 等待)", __hold); - } + log_slow_lock(__lock_t, "process_tool_calls:325"); } tracing::debug!(target: "ai_dirauth", conv = %conv_id, tool = %draft.name, tc_id = %draft.id, "emit AiDirAuthRequired(路径授权挂起,等 ai_authorize_dir 恢复)"); let ev = AiChatEvent::AiDirAuthRequired { @@ -486,8 +460,7 @@ pub(crate) async fn process_tool_calls( dir: dir_str, conversation_id: Some(conv_id.to_string()), }; - let _ = app_handle.emit("ai-chat-event", ev.clone()); - let _ = app_handle.state::().ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app_handle, ev); 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, @@ -507,16 +480,12 @@ pub(crate) async fn process_tool_calls( // 也进此向量,需透传真实等级,审计方可追溯「all 模式执行了多少高危命令」。 let mut low_risk: Vec<(ToolCallDraft, serde_json::Value, RiskLevel)> = Vec::new(); // F-#97 诊断:用户报「选 all 仍审批」时辅助定位 KV 是否真落库。 - // df-ai-trace-approve 开关控制(默认开)。常见误以为已生效但实际未点确认按钮致 KV 未落。 + // df-ai-trace-approve 开关控制(默认开,get_bool_norm 默认值调用方传参)。常见误以为已生效但实际未点确认按钮致 KV 未落。 let trace_on = app_handle .state::() .settings - .get("df-ai-trace-approve") - .await - .ok() - .flatten() - .map(|v| { let v = v.trim_matches('"'); v == "true" || v == "1" }) - .unwrap_or(true); + .get_bool_norm("df-ai-trace-approve", true) + .await; if trace_on { tracing::info!( conv_id = %conv_id, @@ -570,8 +539,7 @@ pub(crate) async fn process_tool_calls( result: serde_json::Value::String(content.clone()), conversation_id: Some(conv_clone), }; - let _ = app_clone.emit("ai-chat-event", ev.clone()); - let _ = app_clone.state::().ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app_clone, ev); // 数据变更类工具执行成功后 emit df-data-changed 联动刷新 // (write_file/run_command 不在 data_change_for_tool 映射内,emit_data_changed 内部 None 即 noop) emit_data_changed(&app_clone, &draft.name); @@ -585,8 +553,7 @@ pub(crate) async fn process_tool_calls( result: serde_json::Value::String(content.clone()), conversation_id: Some(conv_clone), }; - let _ = app_clone.emit("ai-chat-event", ev.clone()); - let _ = app_clone.state::().ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app_clone, ev); (draft, risk_level, Err(content)) } } @@ -668,8 +635,7 @@ pub(crate) async fn process_tool_calls( result: serde_json::Value::String(warned_content.clone()), conversation_id: Some(conv_id.to_string()), }; - let _ = app_handle.emit("ai-chat-event", ev.clone()); - let _ = app_handle.state::().ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app_handle, ev); // 短 lock 段:push tool_result(纯写,无 await) { let mut session = session_arc.lock().await; @@ -709,8 +675,7 @@ pub(crate) async fn process_tool_calls( result: val.clone(), conversation_id: Some(conv_clone), }; - let _ = app_clone.emit("ai-chat-event", ev.clone()); - let _ = app_clone.state::().ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app_clone, ev); // AR-11(方案A):数据变更工具执行成功后 emit df-data-changed, // 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。 emit_data_changed(&app_clone, &draft.name); @@ -729,8 +694,7 @@ pub(crate) async fn process_tool_calls( result: serde_json::Value::String(err_msg.clone()), conversation_id: Some(conv_clone), }; - let _ = app_clone.emit("ai-chat-event", ev.clone()); - let _ = app_clone.state::().ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app_clone, ev); (draft, serde_json::Value::Null, risk_level, Err(err_msg)) } } @@ -831,8 +795,7 @@ pub(crate) async fn process_tool_calls( result: serde_json::Value::String(warned_content.clone()), conversation_id: Some(conv_id.to_string()), }; - let _ = app_handle.emit("ai-chat-event", ev.clone()); - let _ = app_handle.state::().ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app_handle, ev); // 短 lock 段:push tool_result(纯写,无 await) { let mut session = session_arc.lock().await; diff --git a/src-tauri/src/commands/ai/audit/path_auth.rs b/src-tauri/src/commands/ai/audit/path_auth.rs index 1e0e0a1..5cbc9b0 100644 --- a/src-tauri/src/commands/ai/audit/path_auth.rs +++ b/src-tauri/src/commands/ai/audit/path_auth.rs @@ -40,28 +40,13 @@ pub(crate) static BLACKLIST_BYPASS_TEST_LOCK: std::sync::Mutex<()> = std::sync:: /// 提取文件工具的路径参数(用于路径授权预校验)。 /// /// 仅对走 resolve_workspace_path 校验的文件工具返回路径;非文件工具返回空 Vec(不预校验)。 -/// - 单路径工具(read_file/write_file/list_directory/patch_file/file_info/append_file/delete_file/search_files/grep) -/// 取 args["path"] -/// - rename_file 双路径取 args["from"] + args["to"](两个都需授权) -/// - run_command 不返回(它只走 validate_path 黑名单,不限定 workspace,High risk 靠人工审批) +/// 路径参数键查注册元数据(path_args 维度,DRY 债 #5 收敛):单路径工具(read_file/ +/// write_file/list_directory/patch_file/file_info/append_file/delete_file/search_files/grep) +/// 声明 ["path"],rename_file 声明 ["from","to"](两个都需授权),其余工具默认空(不预校验)。 +/// run_command 不声明(只走 validate_path 黑名单,不限定 workspace,High risk 靠人工审批)。 pub(crate) 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" | "grep" => { - args.get("path").and_then(|x| x.as_str()).map(|s| vec![s.to_string()]).unwrap_or_default() - } - _ => Vec::new(), - } + crate::commands::ai::tool_registry::ensure_tool_meta_seeded(); + df_ai::ai_tools::extract_paths(df_ai::ai_tools::path_arg_keys(tool_name), args) } /// 路径授权预校验结果。 @@ -124,10 +109,11 @@ pub(crate) fn check_file_tool_auth( } if pending_dirs.is_empty() { FileToolAuthOutcome::Authorized - } else if tool_name == "search_files" { + } else if df_ai::ai_tools::denies_unauthorized(tool_name) { // 核心设计5:search_files 兜底 — 盲搜语义应拒不应询。 // search_files 仅在已授权目录可用;用户已通过 @项目 提供项目上下文,无需搜索文件系统。 // (黑名单已在上方 for 循环内短路返 Denied,走到此处均为 NeedsAuthorization 场景。) + // 拒绝名单查注册元数据(deny_unauthorized 维度,DRY 债 #5 收敛,现仅 search_files 声明); // 其他文件工具保持原 NeedsAuth 弹窗逻辑完全不变。 FileToolAuthOutcome::Denied( "search_files 仅在已授权目录可用;请用 @[项目] 引用或提供绝对路径,不要盲搜文件系统。".to_string(), diff --git a/src-tauri/src/commands/ai/command_stream.rs b/src-tauri/src/commands/ai/command_stream.rs index 0f0a83a..9e7832e 100644 --- a/src-tauri/src/commands/ai/command_stream.rs +++ b/src-tauri/src/commands/ai/command_stream.rs @@ -27,8 +27,9 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex as StdMutex; use std::time::Duration; -use tauri::{AppHandle, Emitter, Manager}; +use tauri::AppHandle; +use super::event_bus::emit_ai_chat_event; use super::AiChatEvent; use df_execute::shell::StreamKind; @@ -172,8 +173,7 @@ impl CommandSink { line: line.to_string(), conversation_id: conversation_id.clone(), }; - let _ = app.emit("ai-chat-event", ev.clone()); - let _ = app.state::().ai_event_bus.publish_event(ev); + emit_ai_chat_event(app, ev); } } diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index 5310261..815cd08 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -13,21 +13,22 @@ use std::sync::atomic::Ordering; use serde::Serialize; -use tauri::{AppHandle, Emitter, Manager, State}; +use tauri::{AppHandle, Manager, State}; // F-260801 Phase2:anthropic 协议 url→base64 预拉(base64 内嵌编码,generate_image.rs 同款用法)。 use base64::{engine::general_purpose::STANDARD, Engine as _}; use df_ai::provider::{ChatMessage, ContentPart, MessageStatus}; use df_storage::models::AiProviderRecord; -use df_types::augmentation::{MentionRef, MentionSpanDto}; +use df_types::augmentation::{Augmentation, MentionRef, MentionSpanDto, ResolveError}; use df_types::types::new_id; -use crate::state::AppState; +use crate::state::{AppState, log_slow_lock}; use crate::commands::{err_str, now_millis}; // chat.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。 use super::super::agentic::{run_agentic_loop, try_continue_agent_loop}; +use super::super::event_bus::emit_ai_chat_event; #[cfg(test)] use super::super::agentic::GOAL_MAX_CHARS; // ConvState 枚举变体引用(Idle 等),用于 B-Phase2 写侧 conv_states.transition 调用。 @@ -152,10 +153,12 @@ fn find_mention_close(chars: &[char], open: usize, kinds: &[&str]) -> Option, spans: &Option>, @@ -188,9 +191,78 @@ async fn resolve_and_inject( errors ); } + // 注入反馈闭环:技能 resolve 成功/失败 emit 前端事件(轻 toast 用)。仅在 resolvers + // 聚合点(此函数)发,这里恰有 AppHandle 且同时看得到成功(augs)与失败(errors)两侧; + // project/task/idea mention 无用户显式调用语义,不发反馈。 + for aug in &augs { + if let Augmentation::Skill { name, .. } = aug { + emit_skill_feedback(app, conv_id, name, true); + } + } + for err in &errors { + let name = match err { + ResolveError::NotFound { kind, ref_id } if kind == "skill" => Some(ref_id), + ResolveError::Skip { kind, ref_id, .. } if kind == "skill" => Some(ref_id), + _ => None, + }; + if let Some(name) = name { + emit_skill_feedback(app, conv_id, name, false); + } + } build_augmentation_segment(&augs, lang) } +/// 技能注入成功/失败反馈事件(双写经 [`emit_ai_chat_event`])。 +/// +/// 前端 AiChat.vue listen 'ai-chat-event' 过滤本变体做轻 toast(成功「已注入技能《name》」/ +/// 失败「技能 name 未找到」,i18n 中英成对)。fire-and-forget,emit 失败仅丢弃不阻断注入。 +fn emit_skill_feedback(app: &AppHandle, conv_id: &str, name: &str, ok: bool) { + let ev = AiChatEvent::AiSkillInjected { + name: name.to_string(), + ok, + conversation_id: Some(conv_id.to_string()), + }; + emit_ai_chat_event(app, ev); +} + +/// 回溯末条 active user 消息的前导 `/技能名`(regenerate/edit 补技能注入)。 +/// +/// 背景:send 路径入口已 parse_leading_skill;regenerate/edit 此前写死 `&None` 丢技能。 +/// 用户打字 `/name ...` 的原文落库在 user 消息 content 里,反向扫出末条 active user 后 +/// 复用同一纯函数 [`parse_leading_skill`] 回溯(不新写解析逻辑),解析不到返 None(无注入)。 +/// 注:edit 路径调用时末条 user 已被替换为编辑后文本,回溯对象即新文本,语义一致。 +async fn backtrack_leading_skill(state: &AppState, conv_id: &str) -> Option { + let text = { + let session = state.ai_session.lock().await; + session + .conv_read(conv_id) + .map(|c| c.messages.all_messages_clone()) + .unwrap_or_default() + .into_iter() + .rev() + .find(|m| matches!(m.role, df_ai::provider::MessageRole::User) && m.is_active()) + .map(|m| m.content) + .unwrap_or_default() + }; + if text.is_empty() { + return None; + } + let available = super::super::skills::skills_cached() + .await + .iter() + .map(|s| s.name.clone()) + .collect::>(); + let hit = parse_leading_skill(&text, &available); + if hit.is_some() { + tracing::info!( + conv_id = %conv_id, + skill = ?hit, + "[ai] regenerate/edit 回溯末条 user 前导 /技能名命中,补技能注入" + ); + } + hit +} + /// 单条 [`MentionSpanDto`] → [`MentionRef`] 转换(kind 驱动)。 /// /// 未知 kind / 空 ref_id 返 None(静默跳过,不中断整批注入——与 resolve_all 失败收集语义一致)。 @@ -336,9 +408,10 @@ pub async fn ai_regenerate( system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await; } } - // Augmentation 注入:regenerate 路径无新 @ mention / 技能,传 None 走空路径(不污染 prompt)。 - // 若未来需从末条 user 消息的 mentionSpans resolve(验证 #8),改此处传入即可。 - let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await; + // Augmentation 注入:regenerate 回溯末条 user 前导 /技能名(原文在 user 消息里,复用 + // parse_leading_skill);@ mention spans 不重放,传 None 走空路径(不污染 prompt)。 + let skill = backtrack_leading_skill(&state, &conv_id).await; + let aug_seg = resolve_and_inject(&app, &state, &conv_id, &provider_config, &skill, &None, &lang).await; if !aug_seg.is_empty() { if cache_on { volatile = append_volatile(volatile, &aug_seg); @@ -716,7 +789,7 @@ pub async fn ai_chat_send( build_system_prompt_parts(&state, &lang, &excl_proj, &excl_task, cache_on).await; // Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影后拼到 system prompt 前。 // 隔离标注(build_augmentation_segment 头尾包裹,FR-S4 风格)防 prompt injection 与用户指令/行为准则混淆。 - let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await; + let aug_seg = resolve_and_inject(&app, &state, &conv_id, &provider_config, &skill, &mention_spans, &lang).await; if !aug_seg.is_empty() { if cache_on { volatile = append_volatile(volatile, &aug_seg); @@ -814,8 +887,7 @@ pub async fn ai_approve( approved: false, conversation_id: rec.conversation_id.clone(), }; - let _ = app.emit("ai-chat-event", ev.clone()); - let _ = state.ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app, ev); return Ok(format!("已处理({})", rec.status)); } rec.status // 其他状态(如 pending/interrupted/skipped_retry)继续走未找到报错 @@ -863,10 +935,8 @@ pub async fn ai_approve( approved: false, conversation_id: conv_id.clone(), }; - let _ = app.emit("ai-chat-event", ev.clone()); - // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp;publish 内 EVENT_BUS_ENABLED 门控, - // 无消费者空转。app.emit 前端 + publish_event tunnel 两路独立,emit 保留不删。 - let _ = app.state::().ai_event_bus.publish_event(ev); + // app.emit 前端 + publish_event tunnel 两路独立,emit 保留不删(双写收敛进 emit_ai_chat_event)。 + emit_ai_chat_event(&app, ev); drop(session); // 拒绝结果立即落库(含 recovered 积压审批)——switch 时已 restore_from_messages 载完整历史, // messages 非空,save 不会污染老对话;原 if !recovered 守卫前提不成立已移除。 @@ -885,10 +955,7 @@ pub async fn ai_approve( .and_then(|c| session.conv_read(c)) .map(|c| c.iteration_used) .unwrap_or(0); - let __hold610 = __lock_t610.elapsed(); - if __hold610 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_approve:610 持锁 {:?} (含 lock 等待)", __hold610); - } + log_slow_lock(__lock_t610, "ai_approve:610"); v }; // 所有待审批处理完毕后恢复 agentic 循环 @@ -921,10 +988,7 @@ pub async fn ai_approve( ); } } - let __hold534 = __lock_t534.elapsed(); - if __hold534 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_approve:534 持锁 {:?} (含 lock 等待)", __hold534); - } + log_slow_lock(__lock_t534, "ai_approve:534"); drop(session); // 释放锁后再执行 // B-260617-01 决策 a(方案 a2 后端分支):run_workflow 特殊处理。 @@ -1002,21 +1066,14 @@ pub async fn ai_approve( result: result_val.clone(), conversation_id: conv_id.clone(), }; - let _ = app.emit("ai-chat-event", ev_completed.clone()); - // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 - let _ = app.state::().ai_event_bus.publish_event(ev_completed); + emit_ai_chat_event(&app, ev_completed); let ev_approval = AiChatEvent::AiApprovalResult { id, approved: true, conversation_id: conv_id.clone(), }; - let _ = app.emit("ai-chat-event", ev_approval.clone()); - // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 - let _ = app.state::().ai_event_bus.publish_event(ev_approval); - let __hold704 = __lock_t704.elapsed(); - if __hold704 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_approve:704 持锁 {:?} (含 lock 等待)", __hold704); - } + emit_ai_chat_event(&app, ev_approval); + log_slow_lock(__lock_t704, "ai_approve:704"); drop(session); // F-260619-04 P2(方案 B): create_idea source 消息级溯源补全。 @@ -1032,10 +1089,7 @@ pub async fn ai_approve( let v = conv_id.as_deref().and_then(|cid| { session.conv_read(cid).and_then(|c| c.messages.last_assistant_message_id()) }); - let __hold735 = __lock_t735.elapsed(); - if __hold735 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_approve:735 持锁 {:?} (含 lock 等待)", __hold735); - } + log_slow_lock(__lock_t735, "ai_approve:735"); v }; super::super::audit::maybe_fill_idea_source( @@ -1062,10 +1116,7 @@ pub async fn ai_approve( let __lock_t761 = std::time::Instant::now(); let session = state.ai_session.lock().await; let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0); - let __hold761 = __lock_t761.elapsed(); - if __hold761 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_approve:761 持锁 {:?} (含 lock 等待)", __hold761); - } + log_slow_lock(__lock_t761, "ai_approve:761"); v }; // 所有待审批处理完毕后恢复 agentic 循环(recovered 无 live loop,try_continue 因 generating=false 自然不续) @@ -1187,8 +1238,7 @@ pub async fn ai_authorize_dir( approved: false, conversation_id: rec.conversation_id.clone(), }; - let _ = app.emit("ai-chat-event", ev.clone()); - let _ = state.ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app, ev); return Ok(format!("已处理({})", rec.status)); } _ => {} @@ -1196,10 +1246,7 @@ pub async fn ai_authorize_dir( return Err(format!("未找到路径授权挂起: {}", tool_call_id)); } }; - let __hold864 = __lock_t864.elapsed(); - if __hold864 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_authorize_dir:864 持锁 {:?} (含 lock 等待)", __hold864); - } + log_slow_lock(__lock_t864, "ai_authorize_dir:864"); v }; // 阶段3a 决策层分离:ai_authorize_dir 只消费 kind==Path(路径授权挂起,once/always/deny)。 @@ -1213,10 +1260,7 @@ pub async fn ai_authorize_dir( let __lock_t878 = std::time::Instant::now(); let mut session = state.ai_session.lock().await; session.pending_approvals.insert(tool_call_id.clone(), approval); - let __hold878 = __lock_t878.elapsed(); - if __hold878 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_authorize_dir:878 持锁 {:?} (含 lock 等待)", __hold878); - } + log_slow_lock(__lock_t878, "ai_authorize_dir:878"); return Err(format!( "tool_call_id={} 非路径授权挂起(普通审批请用 ai_approve)", tool_call_id @@ -1245,27 +1289,20 @@ pub async fn ai_authorize_dir( if let Some(ref cid) = conv_id { session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &err_msg); } - let __hold903 = __lock_t903.elapsed(); - if __hold903 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_authorize_dir:903 持锁 {:?} (含 lock 等待)", __hold903); - } + log_slow_lock(__lock_t903, "ai_authorize_dir:903"); } let ev_completed = 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", ev_completed.clone()); - // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 - let _ = app.state::().ai_event_bus.publish_event(ev_completed); + emit_ai_chat_event(&app, ev_completed); let ev_approval = AiChatEvent::AiApprovalResult { id: tool_call_id.clone(), approved: false, conversation_id: conv_id.clone(), }; - let _ = app.emit("ai-chat-event", ev_approval.clone()); - // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 - let _ = app.state::().ai_event_bus.publish_event(ev_approval); + emit_ai_chat_event(&app, ev_approval); 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, true).await; @@ -1275,10 +1312,7 @@ pub async fn ai_authorize_dir( let __lock_t930 = std::time::Instant::now(); let session = state.ai_session.lock().await; let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0); - let __hold930 = __lock_t930.elapsed(); - if __hold930 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_authorize_dir:930 持锁 {:?} (含 lock 等待)", __hold930); - } + log_slow_lock(__lock_t930, "ai_authorize_dir:930"); v }; try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await; @@ -1371,27 +1405,20 @@ pub async fn ai_authorize_dir( if let Some(ref cid) = conv_id { session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &result_val.to_string()); } - let __hold1018 = __lock_t1018.elapsed(); - if __hold1018 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_authorize_dir:1018 持锁 {:?} (含 lock 等待)", __hold1018); - } + log_slow_lock(__lock_t1018, "ai_authorize_dir:1018"); } let ev_completed = AiChatEvent::AiToolCallCompleted { id: tool_call_id.clone(), result: result_val.clone(), conversation_id: conv_id.clone(), }; - let _ = app.emit("ai-chat-event", ev_completed.clone()); - // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 - let _ = app.state::().ai_event_bus.publish_event(ev_completed); + emit_ai_chat_event(&app, ev_completed); let ev_approval = AiChatEvent::AiApprovalResult { id: tool_call_id.clone(), approved: true, conversation_id: conv_id.clone(), }; - let _ = app.emit("ai-chat-event", ev_approval.clone()); - // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 - let _ = app.state::().ai_event_bus.publish_event(ev_approval); + emit_ai_chat_event(&app, ev_approval); if let Some(ref cid) = conv_id { save_conversation(&state.ai_session, &state.db, cid, None, None, true).await; } @@ -1400,10 +1427,7 @@ pub async fn ai_authorize_dir( let __lock_t1044 = std::time::Instant::now(); let session = state.ai_session.lock().await; let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0); - let __hold1044 = __lock_t1044.elapsed(); - if __hold1044 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_authorize_dir:1044 持锁 {:?} (含 lock 等待)", __hold1044); - } + log_slow_lock(__lock_t1044, "ai_authorize_dir:1044"); v }; try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await; @@ -1428,18 +1452,18 @@ pub struct PendingToolCallInfo { #[tauri::command] pub async fn ai_pending_tool_calls( state: State<'_, AppState>, - conv_id: String, + conversation_id: String, ) -> Result, String> { let __lock_t1071 = std::time::Instant::now(); let session = state.ai_session.lock().await; - // 阶段3a 单真相源合并:单 pending_approvals 表按 conv_id 过滤,kind 字段透传给前端。 + // 阶段3a 单真相源合并:单 pending_approvals 表按 conversation_id 过滤,kind 字段透传给前端。 // 阶段4:返 kind 字段,前端 switchConversation 恢复 pendingApprovals 时按 kind 渲染 // (risk→approve/reject,path→once/always/deny),对齐阶段3b 统一审批模型。 // (阶段3a 时前端仅恢复 kind==Risk 走 toolCard;阶段3b 前端切后 path 类也归一渲染,后端两 kind 都返。) let list = session .pending_approvals .values() - .filter(|a| a.conversation_id.as_deref() == Some(conv_id.as_str())) + .filter(|a| a.conversation_id.as_deref() == Some(conversation_id.as_str())) .map(|a| PendingToolCallInfo { tool_call_id: a.tool_call_id.clone(), conversation_id: a.conversation_id.clone(), @@ -1449,10 +1473,7 @@ pub async fn ai_pending_tool_calls( }, }) .collect(); - let __hold1071 = __lock_t1071.elapsed(); - if __hold1071 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_pending_tool_calls:1071 持锁 {:?} (含 lock 等待)", __hold1071); - } + log_slow_lock(__lock_t1071, "ai_pending_tool_calls:1071"); Ok(list) } @@ -1469,7 +1490,7 @@ pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> { let __lock_t1095 = std::time::Instant::now(); // ── ① 先停 loop 防并发 save ── // 取活跃对话 id 后释放锁(避免持 session 锁调 DB)。stop_flag 置位 + notify 唤醒: - // 旧 loop 下一轮边界检测到 stop_flag 即走 finish_round_exit 退出;conv_states→Idle + // 旧 loop 下一轮边界检测到 stop_flag 即走 teardown_round 退出;conv_states→Idle // 让读侧 is_active 立即归零(Idle 自环幂等,guard.reset 不冲突)。 let active_id = { let mut session = state.ai_session.lock().await; @@ -1488,10 +1509,7 @@ pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> { ); } } - let __hold1095 = __lock_t1095.elapsed(); - if __hold1095 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_chat_clear:1095 持锁 {:?} (含 lock 等待)", __hold1095); - } + log_slow_lock(__lock_t1095, "ai_chat_clear:1095"); active_id }; // ── ② DB 单事务清空(clear_messages + delete_range 一次 transaction 原子)── @@ -1551,9 +1569,7 @@ pub async fn ai_chat_clear_context( let ev = AiChatEvent::AiContextCleared { conversation_id: Some(conversation_id.clone()), }; - let _ = app.emit("ai-chat-event", ev.clone()); - // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 - let _ = app.state::().ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app, ev); return Ok(()); } // 对保护区外 active 消息标 archived_segment(messages_mut 直接改 status)。 @@ -1569,10 +1585,7 @@ pub async fn ai_chat_clear_context( if dirtied { conv.messages.mark_needs_full_rewrite(); } - let __hold1145 = __lock_t1145.elapsed(); - if __hold1145 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_chat_clear_context:1145 持锁 {:?} (含 lock 等待)", __hold1145); - } + log_slow_lock(__lock_t1145, "ai_chat_clear_context:1145"); drop(session); conversation_id }; @@ -1581,9 +1594,7 @@ pub async fn ai_chat_clear_context( let ev = AiChatEvent::AiContextCleared { conversation_id: Some(conv_id), }; - let _ = app.emit("ai-chat-event", ev.clone()); - // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 - let _ = app.state::().ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app, ev); Ok(()) } @@ -1622,9 +1633,7 @@ pub async fn ai_chat_compress_context( let ev = AiChatEvent::AiCompressing { conversation_id: Some(conversation_id.clone()), }; - let _ = app.emit("ai-chat-event", ev.clone()); - // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 - let _ = app.state::().ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app, ev); conv.messages.set_compressing(true); let protect_start = conv.messages.len().saturating_sub(PROTECT_COUNT); @@ -1638,9 +1647,7 @@ pub async fn ai_chat_compress_context( conversation_id: cid, summary: String::new(), }; - let _ = app.emit("ai-chat-event", ev.clone()); - // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 - let _ = app.state::().ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app, ev); return Ok(()); } // 读 active 克隆(不改 status):compress_old_messages 留到 LLM 成功后才调 @@ -1652,10 +1659,7 @@ pub async fn ai_chat_compress_context( .map(|t| t.message.clone()) .collect(); let cid = session.active_conversation_id.clone().unwrap_or_else(|| conversation_id.clone()); - let __hold1205 = __lock_t1205.elapsed(); - if __hold1205 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_chat_compress_context:1205 持锁 {:?} (含 lock 等待)", __hold1205); - } + log_slow_lock(__lock_t1205, "ai_chat_compress_context:1205"); (cid, active_msgs, protect_start) }; @@ -1664,19 +1668,14 @@ pub async fn ai_chat_compress_context( let __lock_t1250 = std::time::Instant::now(); let mut session = state.ai_session.lock().await; session.conv(&conv_id).messages.set_compressing(false); - let __hold1250 = __lock_t1250.elapsed(); - if __hold1250 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_chat_compress_context:1250 持锁 {:?} (含 lock 等待)", __hold1250); - } + log_slow_lock(__lock_t1250, "ai_chat_compress_context:1250"); drop(session); // 治 Task#1:手动 IPC 降级(无 active 可压缩)用 AiManualCompressed(前端弹 toast)。 let ev = AiChatEvent::AiManualCompressed { conversation_id: Some(conv_id), summary: String::new(), }; - let _ = app.emit("ai-chat-event", ev.clone()); - // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 - let _ = app.state::().ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app, ev); return Ok(()); } @@ -1688,19 +1687,14 @@ pub async fn ai_chat_compress_context( let __lock_t1269 = std::time::Instant::now(); let mut session = state.ai_session.lock().await; session.conv(&conv_id).messages.set_compressing(false); - let __hold1269 = __lock_t1269.elapsed(); - if __hold1269 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_chat_compress_context:1269 持锁 {:?} (含 lock 等待)", __hold1269); - } + log_slow_lock(__lock_t1269, "ai_chat_compress_context:1269"); drop(session); let ev = AiChatEvent::AiError { error: format!("压缩失败: {}", e), error_type: Some(super::super::ErrorType::Auth), conversation_id: Some(conv_id), }; - let _ = app.emit("ai-chat-event", ev.clone()); - // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 - let _ = app.state::().ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app, ev); return Err(format!("压缩失败: {}", e)); } }; @@ -1719,19 +1713,14 @@ pub async fn ai_chat_compress_context( let __lock_t1295 = std::time::Instant::now(); let mut session = state.ai_session.lock().await; session.conv(&conv_id).messages.set_compressing(false); - let __hold1295 = __lock_t1295.elapsed(); - if __hold1295 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_chat_compress_context:1295 持锁 {:?} (含 lock 等待)", __hold1295); - } + log_slow_lock(__lock_t1295, "ai_chat_compress_context:1295"); drop(session); let ev = AiChatEvent::AiError { error: format!("压缩失败: {}", e), error_type: Some(super::super::ErrorType::Unknown), conversation_id: Some(conv_id), }; - let _ = app.emit("ai-chat-event", ev.clone()); - // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 - let _ = app.state::().ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app, ev); return Err(format!("压缩失败: {}", e)); } }; @@ -1744,10 +1733,7 @@ pub async fn ai_chat_compress_context( let _compressed = conv.messages.compress_old_messages(compress_end); conv.messages.insert_at(0, ChatMessage::system(&summary)); conv.messages.set_compressing(false); - let __hold1312 = __lock_t1312.elapsed(); - if __hold1312 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_chat_compress_context:1312 持锁 {:?} (含 lock 等待)", __hold1312); - } + log_slow_lock(__lock_t1312, "ai_chat_compress_context:1312"); } save_conversation(&state.ai_session, &state.db, &conv_id, None, None, false).await; // 治 Task#1:手动 IPC 成功主路径用 AiManualCompressed(Task#1 期望保留 toast+刷新)。 @@ -1755,9 +1741,7 @@ pub async fn ai_chat_compress_context( conversation_id: Some(conv_id), summary, }; - let _ = app.emit("ai-chat-event", ev.clone()); - // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 - let _ = app.state::().ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app, ev); Ok(()) } @@ -1831,8 +1815,10 @@ pub async fn ai_chat_edit( system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await; } } - // Augmentation 注入:edit 路径无新 @ mention / 技能,传 None 走空路径(不污染 prompt)。 - let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await; + // Augmentation 注入:edit 回溯末条 user(已被 replace_last_active_user_content 写为新文本) + // 前导 /技能名,复用 parse_leading_skill;@ mention spans 不重放,传 None 走空路径。 + let skill = backtrack_leading_skill(&state, &conv_id).await; + let aug_seg = resolve_and_inject(&app, &state, &conv_id, &provider_config, &skill, &None, &lang).await; if !aug_seg.is_empty() { if cache_on { volatile = append_volatile(volatile, &aug_seg); @@ -2026,10 +2012,7 @@ pub async fn ai_chat_force_send( // F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。 let user_msg_id = conv.messages.last_user_message_id(); // G1 目标钉扎已迁移至 run_agentic_loop 工具调用推理(提取方式同 ai_chat_send) - let __hold1473 = __lock_t1473.elapsed(); - if __hold1473 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_chat_force_send:1473 持锁 {:?} (含 lock 等待)", __hold1473); - } + log_slow_lock(__lock_t1473, "ai_chat_force_send:1473"); (was_gen.then_some(target.clone()), target, user_msg_id, conv.pinned_goals.clone(), new_epoch) }; @@ -2059,9 +2042,7 @@ pub async fn ai_chat_force_send( conversation_id: Some(cid.clone()), pinned_goals: old_pinned_goals.clone(), }; - let _ = app.emit("ai-chat-event", ev.clone()); - // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 - let _ = app.state::().ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app, ev); } // system prompt 构建(照 ai_chat_send):augmentation 注入 + 知识注入顺序一致。 @@ -2074,7 +2055,7 @@ pub async fn ai_chat_force_send( let (mut system_prompt, mut volatile) = build_system_prompt_parts(&state, &lang, &excl_proj, &excl_task, cache_on).await; // Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影(语义同 ai_chat_send)。 - let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await; + let aug_seg = resolve_and_inject(&app, &state, &conv_id, &provider_config, &skill, &mention_spans, &lang).await; if !aug_seg.is_empty() { if cache_on { volatile = append_volatile(volatile, &aug_seg); @@ -2144,10 +2125,7 @@ pub async fn ai_chat_stop( .unwrap_or_default(); if target.is_empty() { // 无目标 conv 且无 active:无任何 conv 在跑(批4 顶层 generating 已退役),直接返回。 - let __hold1611 = __lock_t1611.elapsed(); - if __hold1611 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_chat_stop:1611 持锁 {:?} (含 lock 等待)", __hold1611); - } + log_slow_lock(__lock_t1611, "ai_chat_stop:1611"); drop(session); return Ok(()); } @@ -2178,10 +2156,7 @@ pub async fn ai_chat_stop( } conv.stop_flag.store(true, Ordering::SeqCst); // 双保险:防 try_continue 误判重启 let pinned_goals = conv.pinned_goals.clone(); - let __hold1611 = __lock_t1611.elapsed(); - if __hold1611 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_chat_stop:1611 持锁 {:?} (含 lock 等待)", __hold1611); - } + log_slow_lock(__lock_t1611, "ai_chat_stop:1611"); drop(session); let ev = AiChatEvent::AiCompleted { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, @@ -2192,9 +2167,7 @@ pub async fn ai_chat_stop( conversation_id: Some(target), pinned_goals, }; - let _ = app.emit("ai-chat-event", ev.clone()); - // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 - let _ = app.state::().ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app, ev); return Ok(()); } // 流式生成中:置位让目标 conv 的 loop 自行收尾 @@ -2208,10 +2181,7 @@ pub async fn ai_chat_stop( // Notify 仅承载「即时唤醒」,停止真值仍由 stop_flag 决定(stream_llm 唤醒后再判 flag)。 notify.notify_one(); let conv_id = Some(target); - let __hold1611 = __lock_t1611.elapsed(); - if __hold1611 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_chat_stop:1611 持锁 {:?} (含 lock 等待)", __hold1611); - } + log_slow_lock(__lock_t1611, "ai_chat_stop:1611"); drop(session); // B-260615-13 兜底任务(F2 加固,2026-08-05):loop 若 panic/异常退出漏发收尾,stop_flag 无人读, @@ -2302,9 +2272,8 @@ pub async fn ai_chat_stop( conversation_id: conv_id.clone(), pinned_goals, }; - let _ = app_handle.emit("ai-chat-event", ev.clone()); - // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。闭包内 app_handle 仍可访问 AppState。 - let _ = app_handle.state::().ai_event_bus.publish_event(ev); + // 闭包内 app_handle 仍可访问 AppState(双写收敛进 emit_ai_chat_event)。 + emit_ai_chat_event(&app_handle, ev); return; } }); @@ -2336,10 +2305,7 @@ pub async fn ai_continue_loop( // (含切走后的后台 conv),移除 active 一致性校验(真并发下后台 conv 也应可续跑)。 // B-Phase3:读侧切无锁 conv_states。 if !state.conv_states.is_active(&conversation_id) { - let __hold1744 = __lock_t1744.elapsed(); - if __hold1744 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_continue_loop:1744 持锁 {:?} (含 lock 等待)", __hold1744); - } + log_slow_lock(__lock_t1744, "ai_continue_loop:1744"); return Err("AI 未在暂停态,无需继续".to_string()); } let conv = session.conv(&conversation_id); @@ -2348,10 +2314,7 @@ pub async fn ai_continue_loop( // F-260616-11 决策 a: 达 max 续跑重计 iteration(F-260616-03 决策 a,用户点继续=授权重来)。 // 审批续跑(ai_approve)累计不重置见另路径;本路径 start_iteration 传 0,loop 从头计数。 conv.iteration_used = 0; - let __hold1744 = __lock_t1744.elapsed(); - if __hold1744 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_continue_loop:1744 持锁 {:?} (含 lock 等待)", __hold1744); - } + log_slow_lock(__lock_t1744, "ai_continue_loop:1744"); } // 复用审批恢复续 loop 入口(不重写 loop),其内部 spawn run_agentic_loop // F-09 B 批2:传 conv_id(IPC 参数 conversation_id)。 @@ -2381,10 +2344,7 @@ pub async fn ai_stop_loop( // (真并发下后台 conv 也应可停止)。 // B-Phase3:读侧切无锁 conv_states。 if !state.conv_states.is_active(&conversation_id) { - let __hold1782 = __lock_t1782.elapsed(); - if __hold1782 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_stop_loop:1782 持锁 {:?} (含 lock 等待)", __hold1782); - } + log_slow_lock(__lock_t1782, "ai_stop_loop:1782"); return Err("AI 未在暂停态,无需停止".to_string()); } let conv = session.conv(&conversation_id); @@ -2399,10 +2359,7 @@ pub async fn ai_stop_loop( ); } pinned_goals = conv.pinned_goals.clone(); - let __hold1782 = __lock_t1782.elapsed(); - if __hold1782 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_stop_loop:1782 持锁 {:?} (含 lock 等待)", __hold1782); - } + log_slow_lock(__lock_t1782, "ai_stop_loop:1782"); } // 暂停态进入前已 save_conversation,此处零 token 上报仅作收敛信号(与 try_continue 补发 AiCompleted 一致) let ev = AiChatEvent::AiCompleted { @@ -2418,9 +2375,7 @@ pub async fn ai_stop_loop( conversation_id: Some(conversation_id), pinned_goals, }; - let _ = app.emit("ai-chat-event", ev.clone()); - // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 - let _ = app.state::().ai_event_bus.publish_event(ev); + emit_ai_chat_event(&app, ev); Ok("ok".to_string()) } diff --git a/src-tauri/src/commands/ai/commands/config.rs b/src-tauri/src/commands/ai/commands/config.rs index 866361f..ff7e480 100644 --- a/src-tauri/src/commands/ai/commands/config.rs +++ b/src-tauri/src/commands/ai/commands/config.rs @@ -1,8 +1,10 @@ //! skills + config 域 IPC — 5 个 `#[tauri::command]` 函数 //! //! 原 `commands/ai/commands/mod.rs` 收敛到 re-export only 后,本文件承接最后一批 IPC: -//! - `ai_list_skills` — 列出本机 Claude 技能(skills + commands + plugins),供前端 `/` 联想 -//! - `ai_reload_skills` — 热重载技能列表(核心设计6:invalidate + 重扫,不重启生效) +//! - `ai_list_skills` — 列出本机 Claude 技能(project + skills + commands + plugins,可选 +//! project_path 启用项目级扫描),供前端 `/` 联想 +//! - `ai_reload_skills` — 热重载技能列表(核心设计6:invalidate + 重扫,不重启生效; +//! 项目级按已设 PROJECT_SKILLS_ROOT 一并重扫) //! - `ai_set_concurrency_config` — LLM 并发上限运行时调整(global/per-conv,立即生效) //! - `ai_set_agent_max_iterations` — Agentic 循环最大轮次(下次发消息生效) //! - `ai_set_agent_max_retries` — 流式对话失败自动重试次数(下次发消息生效) @@ -28,10 +30,18 @@ use df_ai::ai_tools::RiskLevel; use crate::state::AppState; -/// 列出本机 Claude 技能(skills + commands + plugins 三类),供前端 `/` 联想 +/// 列出本机 Claude 技能(project + skills + commands + plugins),供前端 `/` 联想。 +/// +/// `project_path`(可选):当前项目根目录,Some 时扫 `/.claude/skills/*/SKILL.md` +/// (source="project",同名覆盖 home 级)。变更才 invalidate 触发重扫(同值幂等零开销); +/// None/空 = 沿用已设 root 不清除(remote_bridge 零参调用与 FE 无项目语境时不传均安全)。 #[tauri::command] -pub async fn ai_list_skills() -> Result, String> { - // 命中进程内缓存(skills_cached 已 owned Vec,直接返回) +pub async fn ai_list_skills(project_path: Option) -> Result, String> { + // 项目根变更才置缓存 None,skills_cached 慢路径重扫(含新项目级技能); + // 命中进程内缓存时零扫盘(skills_cached 已 owned Vec,直接返回)。 + if super::super::skills::set_project_skills_root(project_path) { + invalidate_skills(); + } Ok(skills_cached().await) } diff --git a/src-tauri/src/commands/ai/event_bus.rs b/src-tauri/src/commands/ai/event_bus.rs index aa832d5..c66602e 100644 --- a/src-tauri/src/commands/ai/event_bus.rs +++ b/src-tauri/src/commands/ai/event_bus.rs @@ -36,6 +36,7 @@ //! (仅 `WorkflowEvent`),本总线是面向 AI 域的通用 pub-sub(透传 AiChatEvent 全量 Value)。 //! AppState 分两个字段:`event_bus`(工作流)+ `ai_event_bus`(AI 域),互不干扰。 +use tauri::{AppHandle, Emitter, Manager}; use tokio::sync::broadcast; use super::AiChatEvent; @@ -69,6 +70,24 @@ pub const EVENT_BUS_ENABLED: bool = true; #[allow(dead_code)] pub const DEFAULT_BUS_CAPACITY: usize = 256; +/// emit+publish 双写:AI 事件对外发布的唯一出口(2026-10-01 DRY #1 收敛,正典自 stream_recv.rs)。 +/// +/// 两路通道受众不同,缺一不可: +/// - `app.emit("ai-chat-event")`:桌面前端(前端只 listen 这一路) +/// - `ai_event_bus.publish_event`:总线订阅者(tunnel → miniapp 跨端透传 / 跨模块订阅) +/// +/// **为什么必须双写**(BUG-260805-02 教训):桌面前端只 listen `app.emit('ai-chat-event')`, +/// 只 publish_event 桌面前端收不到(事故:微信端发的 user 消息桌面不可见);反之只 emit 不 +/// publish 则 tunnel/miniapp 订阅者收不到。**禁止**为省事手抄单路 emit 或单路 publish 替代本 helper, +/// 也**禁止**再手抄两行式(emit + publish)——历史 50+ 处手抄已漂移出 error_type 两端不一致等 bug。 +/// +/// 事件值语义:emit 与 publish 收到**同一份事件值**(内部 clone),对齐原手抄 `ev.clone()` 两行式。 +/// 消费 ev(调用方无需再 clone)。 +pub(crate) fn emit_ai_chat_event(app: &AppHandle, ev: AiChatEvent) { + let _ = app.emit("ai-chat-event", ev.clone()); + let _ = app.state::().ai_event_bus.publish_event(ev); +} + // ============================================================ // EventBus — 基于 tokio::sync::broadcast 的 pub-sub 总线(载荷 serde_json::Value) // diff --git a/src-tauri/src/commands/ai/fetch_url.rs b/src-tauri/src/commands/ai/fetch_url.rs index 467b53b..89a3d13 100644 --- a/src-tauri/src/commands/ai/fetch_url.rs +++ b/src-tauri/src/commands/ai/fetch_url.rs @@ -224,7 +224,7 @@ async fn fetch_static( markdown = cleanup_markdown(&markdown); // ── 截断(按 char 边界) ── - let (markdown, truncated) = truncate_chars(&markdown, max_length); + let (markdown, truncated) = truncate_markdown(&markdown, max_length); Ok(json!({ "url": final_url, @@ -481,7 +481,7 @@ fn build_obscura_json( ) -> Value { let html_bytes_est = markdown_raw.len(); // obscura 不给原始 HTML 字节数,用 markdown 长度近似(仅信息字段) // 截断(按 char 边界,与静态一致) - let (markdown, truncated) = truncate_chars(markdown_raw, max_length); + let (markdown, truncated) = truncate_markdown(markdown_raw, max_length); let elapsed_ms = started.elapsed().as_millis() as u64; let title = extract_title_from_markdown(&markdown); // scheme:obscura 路径下重新 validate_url 拿 scheme(已在入口校验过,这里仅取字段值,失败兜底 https)。 @@ -652,7 +652,8 @@ fn cleanup_markdown(md: &str) -> String { /// 按 char 边界截断 markdown。返回 (截断后 String, 是否截断)。 /// 不切多字节 UTF-8 中间(char_indices 保证落在 char 边界)。 -fn truncate_chars(md: &str, max: usize) -> (String, bool) { +/// 与 audit 的 `truncate_chars`(补 … 正典)语义不同,改名消同名陷阱(DRY #11)。 +fn truncate_markdown(md: &str, max: usize) -> (String, bool) { let char_count = md.chars().count(); if char_count <= max { return (md.to_string(), false); @@ -678,7 +679,7 @@ fn truncate_chars(md: &str, max: usize) -> (String, bool) { // ============================================================ // 单元测试 // -// 覆盖纯函数(extract_title / cleanup_markdown / truncate_chars),确定性零网络。 +// 覆盖纯函数(extract_title / cleanup_markdown / truncate_markdown),确定性零网络。 // handler 集成层走真实网络 #[ignore](对齐 http.rs 测试策略)。 // ============================================================ @@ -792,12 +793,12 @@ mod tests { assert_eq!(extract_title_from_markdown(" \n\n "), None); } - // ── truncate_chars ── + // ── truncate_markdown ── #[test] fn test_truncate_under_limit_unchanged() { let md = "short content"; - let (out, trunc) = truncate_chars(md, 1000); + let (out, trunc) = truncate_markdown(md, 1000); assert!(!trunc); assert_eq!(out, "short content"); } @@ -805,7 +806,7 @@ mod tests { #[test] fn test_truncate_over_limit_marks_truncated() { let md = "abcdefghij".repeat(1000); // 10000 chars - let (out, trunc) = truncate_chars(&md, 100); + let (out, trunc) = truncate_markdown(&md, 100); assert!(trunc); assert!(out.contains("[markdown 已截断")); assert!(out.contains("原文 10000 chars")); @@ -816,7 +817,7 @@ mod tests { fn test_truncate_respects_char_boundary_unicode() { // 中文每字 3 字节,截 5 char 应得 15 字节(不在中间切) let md = "你好世界测试内容"; // 8 chars,每字 3 字节 - let (out, trunc) = truncate_chars(md, 5); + let (out, trunc) = truncate_markdown(md, 5); assert!(trunc); // 截断点应在 "你好世界测" 后(5 chars = 15 bytes),无半个字 assert!(out.starts_with("你好世界测\n\n")); @@ -961,7 +962,7 @@ mod tests { #[test] fn test_build_obscura_json_truncation_applies() { - // build_obscura_json 内部应走 truncate_chars:超长 markdown 截断标记 + // build_obscura_json 内部应走 truncate_markdown:超长 markdown 截断标记 let md: String = "abcdefghij".repeat(2000); // 20000 chars let started = Instant::now(); let v = build_obscura_json("https://example.com/", &md, 1000, started, "obscura", None); diff --git a/src-tauri/src/commands/ai/generate_image.rs b/src-tauri/src/commands/ai/generate_image.rs index 29e0ed6..63dbcea 100644 --- a/src-tauri/src/commands/ai/generate_image.rs +++ b/src-tauri/src/commands/ai/generate_image.rs @@ -49,6 +49,7 @@ use df_storage::db::Database; use df_storage::models::AiProviderRecord; use df_storage::secret::resolve_provider_secret; +use crate::commands::ai::audit::truncate_chars; use crate::commands::ai::http::{ build_client, execute_with_redirects, resolve_and_check_host, validate_url, MAX_REDIRECTS, }; @@ -181,9 +182,9 @@ pub(crate) async fn execute_generate_image( .map_err(|e| anyhow::anyhow!("调用图像生成端点失败 ({}): {}", endpoint, e))?; let status = resp.status(); if !status.is_success() { - // 错误响应读 body 摘要(截断 500 chars)帮助定位 + // 错误响应读 body 摘要(截断 500 chars,截断时尾部补 …)帮助定位 let err_text = resp.text().await.unwrap_or_default(); - let snippet: String = err_text.chars().take(500).collect(); + let snippet: String = truncate_chars(&err_text, 500); anyhow::bail!( "图像生成端点返回 HTTP {}: {}", status.as_u16(), @@ -455,9 +456,10 @@ async fn write_atomic(output_path: &str, data: &[u8]) -> anyhow::Result<()> { } /// 响应 body 截断为最多 500 chars 的字符串(用于错误信息),供定位用。 +/// 走 audit truncate_chars 正典(截断时尾部补 …);错误文案无硬长度约束,允许多 1 字符。 fn body_snippet(v: &Value) -> String { let s = v.to_string(); - s.chars().take(500).collect() + truncate_chars(&s, 500) } // ============================================================ diff --git a/src-tauri/src/commands/ai/knowledge_inject.rs b/src-tauri/src/commands/ai/knowledge_inject.rs index 838648b..09876c7 100644 --- a/src-tauri/src/commands/ai/knowledge_inject.rs +++ b/src-tauri/src/commands/ai/knowledge_inject.rs @@ -19,8 +19,9 @@ use df_storage::models::{AiProviderRecord, KnowledgeRecord}; use df_types::types::new_id; -use crate::state::{AppState, ExtractTrigger, LlmConcurrency}; +use crate::state::{AppState, ExtractTrigger, LlmConcurrency, log_slow_lock}; +use crate::commands::ai::audit::truncate_chars; use crate::commands::err_str; use super::{AiSession}; @@ -560,8 +561,8 @@ pub(crate) async fn build_knowledge_context( for r in &results { let kind = r.kind.clone(); let title = r.title.clone(); - // 截断 content 防膨胀(注入侧最多 500 字符) - let snippet: String = r.content.chars().take(500).collect(); + // 截断 content 防膨胀(注入侧最多 500 字符;走 audit 正典,截断时尾部补 … 提示有省略) + let snippet: String = truncate_chars(&r.content, 500); out.push_str(&format!("- [{}] {}: {} (复用 {} 次)\n", kind, title, snippet, r.reuse_count)); } out @@ -588,10 +589,7 @@ pub(crate) async fn build_knowledge_context_for_conv( .iter() .rev() .find(|m| matches!(m.role, MessageRole::User) && m.is_active()); - let __hold = __lock_t.elapsed(); - if __hold > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] build_knowledge_context_for_conv 持锁 {:?} (含 lock 等待)", __hold); - } + log_slow_lock(__lock_t, "build_knowledge_context_for_conv:582"); match found { Some(m) => (m.content.clone(), m.id.clone()), None => (String::new(), None), @@ -688,10 +686,7 @@ pub(crate) async fn maybe_spawn_extraction( } }; if blocked { - let __hold = __lock_t.elapsed(); - if __hold > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] maybe_spawn_extraction 持锁 {:?} (含 lock 等待)", __hold); - } + log_slow_lock(__lock_t, "maybe_spawn_extraction:692"); tracing::debug!( conv_id = %conv_id, "[knowledge] 跳过自动提炼:提炼中或距上次提炼新增用户消息不足 {} 条(如需立即重提炼用手动按钮)", @@ -701,10 +696,7 @@ pub(crate) async fn maybe_spawn_extraction( } // 守卫 2:消息数。conv_read 未建返 0(< min_messages 自然跳过,语义=空对话不注入知识)。 if msg_count < config.min_messages as usize { - let __hold = __lock_t.elapsed(); - if __hold > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] maybe_spawn_extraction 持锁 {:?} (含 lock 等待)", __hold); - } + log_slow_lock(__lock_t, "maybe_spawn_extraction:705"); return Ok(()); } // 原子预置位(TOCTOU 核心):释放锁前先占提炼槽位,杜绝并发窗口内重复 spawn。 @@ -713,10 +705,7 @@ pub(crate) async fn maybe_spawn_extraction( .lock() .expect("提炼水位互斥锁中毒") .insert(conv_id.to_string(), ExtractWatermark::InFlight); - let __hold = __lock_t.elapsed(); - if __hold > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] maybe_spawn_extraction 持锁 {:?} (含 lock 等待)", __hold); - } + log_slow_lock(__lock_t, "maybe_spawn_extraction:717"); } let session_arc = session_arc.clone(); diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index a9d19d1..b036ac6 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -143,6 +143,13 @@ pub enum AiChatEvent { /// 与 AiToolCallStarted/Completed 正交——这三个事件描述一次信任放行调用生命周期: /// Started → AutoApproved(toast 提示用户已自动放行)→ Completed。 AiToolAutoApproved { id: String, tool: String, dir: String, conversation_id: Option }, + /// 技能注入反馈闭环(/ 技能 resolve 成功/失败通知,前端轻 toast)。 + /// + /// 触发:chat.rs `resolve_and_inject` 聚合点(SkillResolver 成功入 augs / 失败收集进 + /// errors,均在此可见)双写 app.emit + ai_event_bus。`ok=false` 涵盖 skills.rs + /// read_skill_content_stripped 的两类失败(缓存未命中 / 文件读失败,均归 NotFound)。 + /// 前端 AiChat.vue 过滤本变体弹「已注入技能《name》」/「技能 name 未找到」。 + AiSkillInjected { name: String, ok: bool, conversation_id: Option }, /// 需要人工审批 /// diff(AE-2025-03,路径 B):仅 write_file 注入(旧文件 vs 新内容行级 unified diff), /// 供审批卡即时预览。旧文件不存在(新建)或非 write_file 工具为 None。 diff --git a/src-tauri/src/commands/ai/prompt.rs b/src-tauri/src/commands/ai/prompt.rs index 2da59d7..6903d88 100644 --- a/src-tauri/src/commands/ai/prompt.rs +++ b/src-tauri/src/commands/ai/prompt.rs @@ -7,7 +7,7 @@ use df_storage::db::Database; use df_storage::models::AiProviderRecord; use crate::commands::err_str; -use crate::state::AppState; +use crate::state::{AppState, SettingsNorm}; /// 获取当前活跃提供商配置 pub(crate) async fn get_active_provider(state: &AppState) -> Result { @@ -486,13 +486,14 @@ async fn build_context_lists( /// 自定义提示词段(设置 custom_prompt,追加到 system prompt 末尾)。无/空返回空串。 async fn build_custom_section(state: &AppState) -> String { - if let Ok(Some(custom)) = state.settings.get("custom_prompt").await { - if !custom.is_empty() { - let clean = custom.trim().trim_matches('"'); - if !clean.is_empty() { - return format!("\n## 自定义指令\n{}\n", clean); - } - } + // get_string_norm 归一去引号(前端 JSON.stringify 落库带引号),再 trim 空白;空返回空段。 + let clean = state + .settings + .get_string_norm("custom_prompt") + .await + .unwrap_or_default(); + if !clean.trim().is_empty() { + return format!("\n## 自定义指令\n{}\n", clean.trim()); } String::new() } diff --git a/src-tauri/src/commands/ai/remote_bridge.rs b/src-tauri/src/commands/ai/remote_bridge.rs index 0772846..70f80d2 100644 --- a/src-tauri/src/commands/ai/remote_bridge.rs +++ b/src-tauri/src/commands/ai/remote_bridge.rs @@ -65,8 +65,8 @@ use crate::state::AppState; use crate::commands::ai::{ ai_approve, ai_authorize_dir, ai_chat_clear_context, ai_chat_compress_context, ai_chat_edit, ai_chat_force_send, ai_chat_send, ai_chat_stop, ai_conversation_rename, ai_continue_loop, - ai_list_skills, ai_regenerate, ai_stop_loop, record_to_message, AiChatEvent, ApprovalKind, - ConvSummary, ModelInfo, PendingApproval, + ai_list_skills, ai_regenerate, ai_stop_loop, event_bus::emit_ai_chat_event, + record_to_message, AiChatEvent, ApprovalKind, ConvSummary, ModelInfo, PendingApproval, }; // list_models 路由:get_active_provider(prompt.rs 已 pub(crate))读活跃 provider。 // 不走 commands 模块(provider 获取是 prompt 子模块职责,对齐 chat.rs 同源调用路径)。 @@ -581,8 +581,8 @@ async fn route_sync_pending(state: &State<'_, AppState>, conversation_id: String /// `list_skills` 路由 —— 读本机 Claude 技能进程内缓存,跨端推回 miniapp。 /// /// 流程: -/// 1. `ai_list_skills().await`(零参,直接读 `skills_cached()` 进程内缓存,不重复扫盘) -/// → `Vec`(已按 skills>commands>plugins 优先级去重) +/// 1. `ai_list_skills(None).await`(直接读 `skills_cached()` 进程内缓存,不重复扫盘) +/// → `Vec`(已按 project>skills>commands>plugins 优先级去重) /// 2. `state.ai_event_bus.publish_event(AiSkillList)` 跨端透传 /// (经 EventBus→tunnel subscriber→relay→miniapp,非 app.emit 仅桌面前端) /// @@ -591,10 +591,11 @@ async fn route_sync_pending(state: &State<'_, AppState>, conversation_id: String /// 错误兜底:ai_list_skills 返 Err(skills_cached 内部理论上不失败,但签名返 Result 兜底) /// → publish 空 Vec(对齐 route_list_conversations 失败推空列表语义)。 /// -/// 注:ai_list_skills 是 `#[tauri::command]` 零参函数(不需要 app/state),此处直接 async 调, +/// 注:ai_list_skills 是 `#[tauri::command]` 函数(不需要 app/state),此处直接 async 调, /// 不经 IPC invoke(对齐 route_send_message 调 ai_chat_send 的直接调用模式)。 async fn route_list_skills(state: &State<'_, AppState>) { - let skills: Vec = match ai_list_skills().await { + // project_path 传 None:沿用已设项目根(桌面端 ai_list_skills 已设则项目级技能一并透传) + let skills: Vec = match ai_list_skills(None).await { Ok(s) => s, Err(e) => { tracing::warn!(error = %e, "[remote_bridge] list_skills 读技能缓存失败,推送空列表"); @@ -840,16 +841,15 @@ async fn route_send_message(app: &AppHandle, state: &State<'_, AppState>, args: // 注:publish 走事件通道,device 端不会把此事件当入站命令回灌(命令/事件分轨)。 // 前端 handleEvent 去重防双气泡(末条已是同 content user 跳过)。 // BUG-260805-02 双写修复:桌面端只 listen app.emit('ai-chat-event'),publish_event(ai_event_bus) - // 桌面前端收不到 → 微信 user 消息桌面不可见。补 emit 双写(桌面收),保留 publish(微信自回灌去重 + - // tunnel 透传其他 miniapp 订阅者)。 + // 桌面前端收不到 → 微信 user 消息桌面不可见。emit+publish 双写两行式已收敛为 + // emit_ai_chat_event(2026-10-01 DRY,两通道各收同一份事件值,语义不变)。 let user_msg_ev = AiChatEvent::AiUserMessage { message: message.clone(), // 用 resolve 后真实 conv_id(新会话是强制新建 id,旧会话是入参/active), // 桌面端才能正确归属用户气泡到会话(此前广播原始 null 致无法归属)。 conversation_id: resolved_conv_id.clone(), }; - let _ = app.emit("ai-chat-event", user_msg_ev.clone()); - let _ = state.ai_event_bus.publish_event(user_msg_ev); + emit_ai_chat_event(app, user_msg_ev); let result = ai_chat_send( app.clone(), state.clone(), @@ -924,13 +924,12 @@ async fn route_force_send(app: &AppHandle, state: &State<'_, AppState>, args: Va // 广播 AiUserMessage(同 route_send_message:force_send 追加新 user 消息, // 桌面端需据此补 user 气泡,防孤立 assistant 响应)。 - // BUG-260805-02 双写:桌面端只 listen app.emit,补 emit 让桌面收到(对齐 route_send_message)。 + // BUG-260805-02 双写:emit+publish 两行式已收敛 emit_ai_chat_event(对齐 route_send_message)。 let user_msg_ev = AiChatEvent::AiUserMessage { message: message.clone(), conversation_id: conversation_id.clone(), }; - let _ = app.emit("ai-chat-event", user_msg_ev.clone()); - let _ = state.ai_event_bus.publish_event(user_msg_ev); + emit_ai_chat_event(app, user_msg_ev); let result = ai_chat_force_send( app.clone(), state.clone(), diff --git a/src-tauri/src/commands/ai/skills.rs b/src-tauri/src/commands/ai/skills.rs index 06ebbc2..10bf7d4 100644 --- a/src-tauri/src/commands/ai/skills.rs +++ b/src-tauri/src/commands/ai/skills.rs @@ -6,7 +6,8 @@ //! - `OnceLock` → `RwLock>` + 快路径(读锁命中 clone)/ 慢路径(重扫) //! + `invalidate_skills` 写锁置 None,支持进程内 `ai_reload_skills` 热重载 //! - `scan_skills` 返 `ScanResult{skills, conflicts}`:同名收集所有 path 入 conflicts, -//! 保留首份(优先级 skills>commands>plugins);`SkillInfo.duplicates` 仅冲突时填。 +//! 保留首份(优先级 project>skills>commands>plugins,项目级经 `ai_list_skills(project_path)` +//! 设 `PROJECT_SKILLS_ROOT` 后生效);`SkillInfo.duplicates` 仅冲突时填。 use std::collections::HashSet; use std::fs; @@ -15,6 +16,8 @@ use std::sync::{RwLock, RwLockReadGuard}; use serde::Serialize; +use crate::commands::ai::tool_registry::{is_noise_entry, NoiseDirTier}; + /// 技能元信息(前端 `/` 联想 + 后端注入用) #[derive(Debug, Clone, Serialize)] pub struct SkillInfo { @@ -51,6 +54,58 @@ fn claude_home() -> Option { .map(|h| h.join(".claude")) } +/// 项目级技能根目录(进程内,前端经 `ai_list_skills(project_path)` 设置)。 +/// +/// Some(root) 时 `scan_skills` 额外扫 `/.claude/skills/*/SKILL.md`(source="project"), +/// 与 home 级三类一起参与去重/冲突;项目级优先级最高(同名覆盖 home,对齐 Claude Code +/// 项目技能语义)。None = 仅 home 级(与旧行为一致)。 +/// 更新入口 `set_project_skills_root`(变更才触发重扫),`ai_list_skills` IPC 调用。 +static PROJECT_SKILLS_ROOT: RwLock> = RwLock::new(None); + +/// 更新项目级技能根目录。返回是否发生变更(调用方据 true 调 `invalidate_skills` 触发重扫)。 +/// +/// `None`/空串 = 不改动当前值(`ai_list_skills` 不传参沿用已设 root,remote_bridge 零参 +/// 调用不受影响);重复传同值幂等(不触发重扫)。 +pub(crate) fn set_project_skills_root(path: Option) -> bool { + let new_root = path + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(PathBuf::from); + // 锁中毒:写不进就当"无变更"返回(下次 IPC 重试自愈,不 panic)。 + let mut g = match RwLock::write(&PROJECT_SKILLS_ROOT) { + Ok(g) => g, + Err(_) => { + tracing::error!("PROJECT_SKILLS_ROOT 写锁中毒,set_project_skills_root 跳过"); + return false; + } + }; + if *g == new_root { + return false; + } + *g = new_root; + true +} + +/// 读当前项目根快照(spawn_blocking 的 scan_skills 内同步读,clone 出避免持锁跨扫盘)。 +fn project_skills_root_snapshot() -> Option { + // 锁中毒降级 None(仅丢项目级技能,home 级不受影响)。 + RwLock::read(&PROJECT_SKILLS_ROOT) + .ok() + .and_then(|g| g.clone()) +} + +/// 扫描项目级技能目录 `/.claude/skills/*/SKILL.md`(单层,与 home skills 同构)。 +fn scan_project_skills(root: &Path, out: &mut Vec) { + if let Ok(entries) = fs::read_dir(root.join(".claude").join("skills")) { + for entry in entries.flatten() { + if let Some(info) = parse_skill_file(&entry.path().join("SKILL.md"), "project") { + out.push(info); + } + } + } +} + /// 剥离 markdown 首个 `---...---` frontmatter 块,返回正文。 /// /// 状态机:用行索引推进;首个 `---`(trim 后)进入 in_fm,遇到下个 `---` 退出,余为正文。 @@ -159,9 +214,9 @@ fn collect_skill_files(dir: &Path, out: &mut Vec) { for entry in entries.flatten() { let p = entry.path(); if p.is_dir() { - // 跳过依赖/版本目录,避免递归爆炸 + // 跳过依赖/VCS 目录,避免递归爆炸(SkillScan 最小集档,名单统一走 is_noise_entry) let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(""); - if name == "node_modules" || name == ".git" { + if is_noise_entry(name, NoiseDirTier::SkillScan) { continue; } collect_skill_files(&p, out); @@ -172,7 +227,10 @@ fn collect_skill_files(dir: &Path, out: &mut Vec) { } } -/// 扫描三类来源,按 name 去重(skills 优先 > commands > plugins)。 +/// 扫描全部来源,按 name 去重(project 优先 > skills > commands > plugins)。 +/// +/// 项目级:`PROJECT_SKILLS_ROOT` 已设时额外扫 `/.claude/skills/*/SKILL.md` +/// (source="project"),排在首位(项目级同名覆盖 home 级,对齐 Claude Code 项目技能语义)。 /// /// 返 `ScanResult`:`skills` 为去重后首份(优先级保留),`conflicts` 收集所有同名 path /// (仅当同名 > 1 时入列,前端提示用)。 @@ -187,8 +245,15 @@ fn scan_skills() -> ScanResult { } }; - // 三类来源按优先级顺序收集(skills > commands > plugins) - let mut all: Vec> = Vec::with_capacity(3); + // 各来源按优先级顺序收集(project > skills > commands > plugins) + let mut all: Vec> = Vec::with_capacity(4); + + // 0. /.claude/skills/*/SKILL.md(项目级,PROJECT_SKILLS_ROOT 已设才扫) + let mut batch_project = Vec::new(); + if let Some(root) = project_skills_root_snapshot() { + scan_project_skills(&root, &mut batch_project); + } + all.push(batch_project); // 1. ~/.claude/skills/*/SKILL.md let mut batch_skill = Vec::new(); diff --git a/src-tauri/src/commands/ai/stream_recv.rs b/src-tauri/src/commands/ai/stream_recv.rs index 85574b9..fc895cb 100644 --- a/src-tauri/src/commands/ai/stream_recv.rs +++ b/src-tauri/src/commands/ai/stream_recv.rs @@ -5,11 +5,12 @@ use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::time::Duration; -use tauri::{AppHandle, Emitter, Manager}; +use tauri::AppHandle; use tracing::warn; use df_ai::provider::{CompletionRequest, LlmProvider}; +use super::event_bus::emit_ai_chat_event; use super::{AiChatEvent, ToolCallDraft}; /// 从 anyhow 错误中尽力提取 HTTP 状态码/错误分类,供诊断拼接。 @@ -148,12 +149,6 @@ fn accumulate_tool_calls( } } -/// 双写 emit: Tauri 事件 + ai_event_bus 总线(跨端透传)。消费 ev。 -fn emit_ai_chat_event(app_handle: &AppHandle, ev: AiChatEvent) { - let _ = app_handle.emit("ai-chat-event", ev.clone()); - let _ = app_handle.state::().ai_event_bus.publish_event(ev); -} - /// flush 合批的 AiTextDelta(双写 emit + event_bus)。空 buffer 不 emit。 fn flush_delta(app_handle: &AppHandle, conv_id: &str, pending_delta: &mut String) { if pending_delta.is_empty() { diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index ffb8623..25fa30b 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -526,9 +526,19 @@ pub fn build_ai_tool_registry( register_generate_image_tool(&mut registry, db, allowed_dirs); register_get_app_config_tool(&mut registry, get_app_config_ctx); register_local_proxy_tools(&mut registry, local_proxy_ctx); + register_read_skill_tool(&mut registry); registry } +/// read_skill AI 工具注册(1 个:按名读本机 Claude 技能规程正文,只读)— thin 委托。 +/// +/// 工具职责:LLM 主动调 read_skill(name) 获取技能 SKILL.md 剥 frontmatter 正文, +/// 与 / 技能注入链共用 skills.rs 进程内缓存(同一真相源)。未命中返回明确错误文案。 +/// 风险:Low(纯只读)。声明式注册在 tools/read_skill.rs。 +fn register_read_skill_tool(registry: &mut AiToolRegistry) { + super::tools::read_skill::register(registry); +} + /// 网络层 AI 工具注册(1 个:http_request)— 结构化 HTTP(GET/POST/PUT/PATCH/DELETE)。 /// 不持 db,纯 reqwest 调用。SSRF 防护 + 重定向每跳校验见 commands/ai/http.rs。 /// 风险:GET=Medium(只读但触发外发)/ 写方法=High(须人工批准,对齐 audit.rs risk 分流)。 @@ -869,7 +879,7 @@ pub(crate) fn list_dir_recursive<'a>( } let name = entry.file_name().to_string_lossy().to_string(); // 噪音目录(.git/node_modules/target 等)既不深入、也不作 entry 返回(避免污染 AI 工具上下文) - if skip_noise && is_noise_dir(&name) { + if skip_noise && is_noise_entry(&name, NoiseDirTier::AiContext) { continue; } // 临时文件(.bak/.tmp-write 等)也不返回:write_file 原子写过程的副产物, @@ -889,7 +899,7 @@ pub(crate) fn list_dir_recursive<'a>( "depth": depth, })); // 仅递归真实目录(symlink 不递归,防符号链接逃逸到 workspace 外) - if recursive && is_dir && depth < max_depth && !(skip_noise && is_noise_dir(&name)) { + if recursive && is_dir && depth < max_depth && !(skip_noise && is_noise_entry(&name, NoiseDirTier::AiContext)) { let child_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned(); let truncated = list_dir_recursive(&child_path, true, depth + 1, max_depth, max_entries, skip_noise, result).await?; if truncated { @@ -906,72 +916,85 @@ pub(crate) fn list_dir_recursive<'a>( /// 返回 Option<(&str, &[&str])>:(动作前缀模板, 关键参数名列表)。 /// 模板中 `{}` 占位符由 build_approval_reason 按参数列表从 args 取值填充。 /// 未注册的工具返回 None,由调用方 fallback 到通用风险提示。 +/// +/// DRY 债 #5 收敛:模板不再本文件手写 match,改查注册元数据(approval_hint 维度), +/// 模板在 declare_tool! 注册点(tools/project.rs 等)声明。 pub(crate) fn display_hint_for_tool(name: &str) -> Option<(&'static str, &'static [&'static str])> { - let (template, keys): (&str, &[&str]) = match name { - "delete_project" => ("删除项目{}", &["id"]), - "restore_project" => ("从回收站恢复项目{}", &["id"]), - "purge_project" => ("永久删除项目及关联数据,不可恢复{}", &["id"]), - "update_project" => ("修改项目{}字段「{}」", &["id", "field"]), - "bind_directory" => ("绑定目录:{}(项目{})", &["path", "id"]), - "create_task" => ("创建任务:{}(项目{})", &["title", "project_id"]), - "create_project" => ("创建项目:「{}」", &["name"]), - "create_idea" => ("捕获灵感:{}", &["title"]), - "patch_file" => ("修改文件:{}", &["path"]), - "delete_file" => ("删除文件:{}({})", &["path", "permanent"]), - "rename_file" => ("重命名/移动:{} → {}", &["from", "to"]), - // F-03 收口:推进链工具审批文案(advance_task/run_workflow) - "advance_task" => ("推进任务状态:{} → {}", &["id", "target_status"]), - "run_workflow" => ("触发工作流:任务{} 推进到 {}", &["task_id", "target_status"]), - // http_request 审批文案:method + url(看是什么请求打到哪) - "http_request" => ("HTTP 请求:{} {}", &["method", "url"]), - _ => return None, - }; - Some((template, keys)) + ensure_tool_meta_seeded(); + df_ai::ai_tools::approval_hint(name) } /// 工具名 → 中文动作前缀(轻量映射,供 build_approval_reason 快速取可读标签)。 /// /// 仅返回动作动词+对象名(如 "创建任务"、"写入文件"),不含参数细节。 /// 未注册的工具返回 None,由调用方 fallback 到 display_hint_for_tool 模板填充或硬编码。 +/// +/// DRY 债 #5 收敛:标签不再本文件手写 match,改查注册元数据(display_verb 维度)。 +/// advance_task 刻意无标签:轻量命中会丢 target_status 详情,审批文案须走模板,与旧表一致。 pub(crate) fn tool_display_hint(name: &str) -> Option<&'static str> { - match name { - "create_task" => Some("创建任务"), - "write_file" => Some("写入文件"), - "read_file" => Some("读取文件"), - "list_directory" => Some("列出目录"), - "search_files" => Some("搜索文件"), - "grep" => Some("搜索代码内容"), - "create_project" => Some("创建项目"), - "update_project" => Some("修改项目"), - "delete_project" => Some("删除项目"), - "restore_project" => Some("恢复项目"), - "purge_project" => Some("永久删除项目"), - "bind_directory" => Some("绑定目录"), - "create_idea" => Some("捕获灵感"), - "update_task" => Some("修改任务"), - // advance_task 不在此轻量映射:让 build_approval_reason 走 display_hint_for_tool - // 模板(含 target_status 详情),否则轻量命中"推进任务状态"丢失目标态信息 - "run_workflow" => Some("触发工作流"), - "delete_task" => Some("删除任务"), - "update_idea" => Some("修改灵感"), - "delete_idea" => Some("删除灵感"), - "patch_file" => Some("修改文件"), - "append_file" => Some("追加写入"), - "delete_file" => Some("删除文件"), - "rename_file" => Some("重命名/移动"), - "http_request" => Some("发起 HTTP 请求"), - _ => None, - } + ensure_tool_meta_seeded(); + df_ai::ai_tools::display_verb(name) } -/// 判断是否为不应深入递归的噪音目录(构建产物/依赖/缓存等) -pub(crate) fn is_noise_dir(name: &str) -> bool { - const NOISE_DIRS: &[&str] = &[ +/// 元数据种子(仅测试构建):元数据查询依赖注册期写入的全局表。 +/// +/// 生产 build_ai_tool_registry 先于任何查询执行,表必非空,本函数恒空操作; +/// 单测可能不经 build 直接调消费方自由函数(extract_file_tool_paths/tool_call_signature 等), +/// 表尚空时用 in-memory db 构建一次真实注册表,让 declare_tool! 注册点把元数据写入全局表。 +/// Once 保证并发测试只种一次;表已非空(任一测试已 build)则跳过。 +#[cfg(test)] +pub(crate) fn ensure_tool_meta_seeded() { + static SEED: std::sync::Once = std::sync::Once::new(); + SEED.call_once(|| { + if !df_ai::ai_tools::meta_table_is_empty() { + return; + } + let rt = tokio::runtime::Runtime::new().expect("元数据种子 runtime"); + rt.block_on(async { + let db = Arc::new(Database::open_in_memory().await.expect("in-memory db 初始化失败")); + let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root())); + let _ = build_ai_tool_registry( + &db, + &allowed_dirs, + PathBuf::from(""), + make_test_get_app_config_ctx(&db), + make_test_local_proxy_ctx(), + ); + }); + }); +} + +/// 生产恒空(注册先于查询,无种子需求)。 +#[cfg(not(test))] +pub(crate) fn ensure_tool_meta_seeded() {} + +/// 噪音目录判定档位:不同消费面"哪些目录算噪音"的口径不同,差异用档位在此显式表达, +/// 各消费方不再自持名单(DRY #4:全仓唯一名单入口)。 +pub(crate) enum NoiseDirTier { + /// AI 工具上下文(list_directory/grep 递归):全量名单,构建产物/依赖/缓存/IDE 元数据/回收站不进上下文 + AiContext, + /// 前端文件树/工程扫描:全量名单放行 `build` 与 `venv`(文件树面向用户浏览要见构建产物; + /// venv 历史上未过滤,行为保持)。点开头目录(.git/.vite 等)由调用方的隐藏文件过滤兜住 + FileTree, + /// 技能扫描递归剪枝:最小集(仅依赖/VCS)。扩大名单属行为变更(build/ 下若有 SKILL.md 会漏扫) + SkillScan, +} + +/// 判断是否为不应深入递归/展示的噪音条目(目录/文件名皆可传入,构建产物/依赖/缓存等)。 +/// 全仓唯一名单入口。命中判定短路返回,技能扫描等热路径可放心逐条目调用。 +pub(crate) fn is_noise_entry(name: &str, tier: NoiseDirTier) -> bool { + const FULL: &[&str] = &[ ".git", "node_modules", "target", "dist", "build", ".next", ".cache", "__pycache__", ".venv", "venv", ".idea", ".trash", // delete_file 软删除回收站,不污染 list_directory ]; - NOISE_DIRS.contains(&name) + match tier { + NoiseDirTier::AiContext => FULL.contains(&name), + // 文件树口径 = 全量名单放行 build(用户要看构建产物)与 venv(历史未过滤,行为保持) + NoiseDirTier::FileTree => FULL.contains(&name) && !matches!(name, "build" | "venv"), + // 技能扫描最小集:只防依赖/VCS 递归爆炸,与历史两项一致 + NoiseDirTier::SkillScan => matches!(name, "node_modules" | ".git"), + } } /// 判断是否为不应返回给 AI 的噪音文件(临时/备份/编辑器产物)。 @@ -1062,7 +1085,7 @@ pub(crate) fn compile_glob_to_regex(glob: &str) -> anyhow::Result /// 递归跨文件内容搜索(grep -rn 模式) /// /// 遍历 `dir`(深度 `depth`,上限 `max_depth`)下所有文件: -/// - 跳过噪音目录(is_noise_dir)/噪音文件(is_noise_file)/symlink(file_type 不跟随) +/// - 跳过噪音目录(is_noise_entry)/噪音文件(is_noise_file)/symlink(file_type 不跟随) /// - glob 过滤器(可选):命中文件名才读内容 /// - 二进制文件跳过:前 8KB 含 \0 视为二进制(对齐 read_file/list_directory) /// - 单文件 1MB 上限跳过(对齐 read_file,防读超大文件撑爆) @@ -1110,7 +1133,7 @@ pub(crate) fn grep_recursive<'a>( } if file_type.is_dir() { // 噪音目录(.git/node_modules/target/.trash 等)不深入 - if is_noise_dir(&file_name) { + if is_noise_entry(&file_name, NoiseDirTier::AiContext) { continue; } if depth < max_depth { @@ -1281,40 +1304,46 @@ pub(crate) fn search_files_recursive<'a>( }) } +// get_app_config baseline 测试辅助:构造 GetAppConfigCtx(与 AppState init 同款默认值, +// 仅用于 build_ai_tool_registry 注册阶段,handler 不实际执行故无需真实配置)。 +// 提到 tests 模块外(file 级 cfg(test)):ensure_tool_meta_seeded 种子路径也要用。 +#[cfg(test)] +use std::sync::atomic::{AtomicU64, AtomicUsize}; +#[cfg(test)] +use crate::state::LlmConcurrency; + +/// 测试用 GetAppConfigCtx 构造器(默认值对齐 AppState::init:iter=10/retries=3/timeout=15/concurrency 3,2)。 +/// +/// build_ai_tool_registry 第 4 参,5 处测试调用共用,避免每处内联 5 行重复。 +/// Arc 句柄独立于 AppState(测试无 AppState),仅满足注册期类型签名,handler 不执行。 +#[cfg(test)] +fn make_test_get_app_config_ctx(db: &Arc) -> crate::commands::ai::get_app_config::GetAppConfigCtx { + crate::commands::ai::get_app_config::GetAppConfigCtx { + db: db.clone(), + agent_max_iterations: Arc::new(AtomicUsize::new( + crate::commands::ai::agentic::DEFAULT_MAX_AGENT_ITERATIONS, + )), + agent_max_retries: Arc::new(AtomicUsize::new( + crate::commands::ai::agentic::DEFAULT_MAX_AGENT_RETRIES, + )), + approval_timeout_minutes: Arc::new(AtomicU64::new(15)), + llm_concurrency: LlmConcurrency::new(3, 2), + } +} + +/// build_ai_tool_registry 第 5 参(local_proxy 工具代理 ctx),测试调用共用。 +/// gateway_url 用默认本地网关即可,handler 不实际执行无需真实代理。 +#[cfg(test)] +fn make_test_local_proxy_ctx() -> crate::commands::ai::tools::local_proxy::LocalProxyCtx { + crate::commands::ai::tools::local_proxy::LocalProxyCtx { + gateway_url: "http://127.0.0.1:8899".to_string(), + } +} + #[cfg(test)] mod tests { use super::*; use std::fs; - // get_app_config baseline 测试辅助:构造 GetAppConfigCtx(与 AppState init 同款默认值, - // 仅用于 build_ai_tool_registry 注册阶段定义完整性断言,handler 不实际执行故无需真实配置)。 - use std::sync::atomic::{AtomicU64, AtomicUsize}; - use crate::state::LlmConcurrency; - - /// 测试用 GetAppConfigCtx 构造器(默认值对齐 AppState::init:iter=10/retries=3/timeout=15/concurrency 3,2)。 - /// - /// build_ai_tool_registry 第 4 参,5 处测试调用共用,避免每处内联 5 行重复。 - /// Arc 句柄独立于 AppState(测试无 AppState),仅满足注册期类型签名,handler 不执行。 - fn make_test_get_app_config_ctx(db: &Arc) -> crate::commands::ai::get_app_config::GetAppConfigCtx { - crate::commands::ai::get_app_config::GetAppConfigCtx { - db: db.clone(), - agent_max_iterations: Arc::new(AtomicUsize::new( - crate::commands::ai::agentic::DEFAULT_MAX_AGENT_ITERATIONS, - )), - agent_max_retries: Arc::new(AtomicUsize::new( - crate::commands::ai::agentic::DEFAULT_MAX_AGENT_RETRIES, - )), - approval_timeout_minutes: Arc::new(AtomicU64::new(15)), - llm_concurrency: LlmConcurrency::new(3, 2), - } - } - - /// build_ai_tool_registry 第 5 参(local_proxy 工具代理 ctx),测试调用共用。 - /// gateway_url 用默认本地网关即可,handler 不实际执行无需真实代理。 - fn make_test_local_proxy_ctx() -> crate::commands::ai::tools::local_proxy::LocalProxyCtx { - crate::commands::ai::tools::local_proxy::LocalProxyCtx { - gateway_url: "http://127.0.0.1:8899".to_string(), - } - } // ============================================================ // 工具注册基线测试(SMELL-P0-2 拆分防护) @@ -1324,7 +1353,7 @@ mod tests { // 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。 // ============================================================ - /// build_ai_tool_registry 应注册恰好 76 个工具(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy),且工具名集合稳定。 + /// build_ai_tool_registry 应注册恰好 77 个工具(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill),且工具名集合稳定。 /// /// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db, // 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。 @@ -1395,10 +1424,14 @@ mod tests { // knowledge_promote(2026-10-01): data 层 44→45(新增候选知识显式转正工具, // candidate→published 单向,补齐自动晋升默认关后的对话内审核通道,Medium)。 // 76 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。 + // read_skill(2026-10-01): read_skill 层 0→1(按名读本机 Claude 技能规程正文,只读 Low, + // 与 / 技能注入链共用 skills.rs 进程内缓存;未命中返明确错误,LLM 可向用户澄清。 + // 声明式注册在 tools/read_skill.rs,register_read_skill_tool thin 委托)。 + // 77 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill。 assert_eq!( registry.len(), - 76, - "工具总数应为 76(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy),实际 {}", registry.len() + 77, + "工具总数应为 77(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill),实际 {}", registry.len() ); // 工具名集合基线:防 rename / 漏注册 / 误删除。 @@ -1463,6 +1496,9 @@ mod tests { "ssh_exec", "mongo_find", "mongo_insert", "mongo_update", "mongo_delete", "redis_get", "redis_set", "redis_del", + // ── read_skill 层 (1) ──(按名读本机 Claude 技能规程正文,只读 Low, + // 与 / 技能注入链共用 skills.rs 进程内缓存,声明式注册 tools/read_skill.rs) + "read_skill", ]; expected.sort_unstable(); @@ -1476,39 +1512,57 @@ mod tests { ); } - /// is_noise_dir 纯函数:覆盖 .git/.gitignore 区分(目录是噪音,.gitignore 文件名不是) + /// is_noise_entry 纯函数:覆盖 .git/.gitignore 区分(目录是噪音,.gitignore 文件名不是) #[test] - fn test_is_noise_dir_distinguishes_dir_and_gitignore_file() { + fn test_is_noise_entry_distinguishes_dir_and_gitignore_file() { // 噪音目录命中 - assert!(is_noise_dir(".git")); - assert!(is_noise_dir("node_modules")); - assert!(is_noise_dir("target")); - assert!(is_noise_dir("dist")); - assert!(is_noise_dir("build")); + assert!(is_noise_entry(".git", NoiseDirTier::AiContext)); + assert!(is_noise_entry("node_modules", NoiseDirTier::AiContext)); + assert!(is_noise_entry("target", NoiseDirTier::AiContext)); + assert!(is_noise_entry("dist", NoiseDirTier::AiContext)); + assert!(is_noise_entry("build", NoiseDirTier::AiContext)); // .gitignore / .gitattributes 等文件名不应命中(只匹配纯目录名 .git) - assert!(!is_noise_dir(".gitignore")); - assert!(!is_noise_dir(".gitattributes")); - assert!(!is_noise_dir(".gitkeep")); + assert!(!is_noise_entry(".gitignore", NoiseDirTier::AiContext)); + assert!(!is_noise_entry(".gitattributes", NoiseDirTier::AiContext)); + assert!(!is_noise_entry(".gitkeep", NoiseDirTier::AiContext)); } - /// is_noise_dir 纯函数:普通目录不命中 + /// is_noise_entry 纯函数:普通目录不命中 #[test] - fn test_is_noise_dir_normal_dirs_not_matched() { - assert!(!is_noise_dir("src")); - assert!(!is_noise_dir("tests")); - assert!(!is_noise_dir("docs")); - assert!(!is_noise_dir("main.rs")); - assert!(!is_noise_dir("")); + fn test_is_noise_entry_normal_dirs_not_matched() { + assert!(!is_noise_entry("src", NoiseDirTier::AiContext)); + assert!(!is_noise_entry("tests", NoiseDirTier::AiContext)); + assert!(!is_noise_entry("docs", NoiseDirTier::AiContext)); + assert!(!is_noise_entry("main.rs", NoiseDirTier::AiContext)); + assert!(!is_noise_entry("", NoiseDirTier::AiContext)); } - /// is_noise_dir 纯函数:大小写敏感(不靠 to_lowercase 误命中 Target) + /// is_noise_entry 纯函数:大小写敏感(不靠 to_lowercase 误命中 Target) #[test] - fn test_is_noise_dir_case_sensitive() { + fn test_is_noise_entry_case_sensitive() { // 文件系统在 Windows 上大小写不敏感,但函数本身用精确匹配; // 锁定当前语义:大写变体不命中(避免后续误改 to_lowercase 引入行为变化) - assert!(!is_noise_dir("Target")); - assert!(!is_noise_dir("DIST")); - assert!(!is_noise_dir("NODE_MODULES")); + assert!(!is_noise_entry("Target", NoiseDirTier::AiContext)); + assert!(!is_noise_entry("DIST", NoiseDirTier::AiContext)); + assert!(!is_noise_entry("NODE_MODULES", NoiseDirTier::AiContext)); + } + + /// is_noise_entry 档位差异锁定:文件树放行 build/venv,技能扫描仅两项 + #[test] + fn test_is_noise_entry_tier_deltas() { + // FileTree:全量名单放行 build 与 venv(用户浏览要见构建产物),其余与 AiContext 一致 + assert!(!is_noise_entry("build", NoiseDirTier::FileTree)); + assert!(!is_noise_entry("venv", NoiseDirTier::FileTree)); + assert!(is_noise_entry("node_modules", NoiseDirTier::FileTree)); + assert!(is_noise_entry("target", NoiseDirTier::FileTree)); + assert!(is_noise_entry("dist", NoiseDirTier::FileTree)); + assert!(is_noise_entry("__pycache__", NoiseDirTier::FileTree)); + assert!(!is_noise_entry("src", NoiseDirTier::FileTree)); + // SkillScan:最小集,仅依赖/VCS + assert!(is_noise_entry("node_modules", NoiseDirTier::SkillScan)); + assert!(is_noise_entry(".git", NoiseDirTier::SkillScan)); + assert!(!is_noise_entry("target", NoiseDirTier::SkillScan)); + assert!(!is_noise_entry("build", NoiseDirTier::SkillScan)); } /// is_noise_file 纯函数:write_file 副产物 / 编辑器临时文件命中 @@ -2381,4 +2435,191 @@ mod tests { .unwrap(); assert_eq!(staging["total"], 0); } + + // ============================================================ + // DRY 债 #5 对照验证:5 处旧 match 表收敛到注册元数据后逐工具比对。 + // + // 旧表值在本测试内固化为期望数据(收敛前从各 match 表逐行抄录): + // path_auth 路径参数键 / cache 只读白名单 + 目录级 key / data_change entity-action / + // tool_registry 审批模板 + 轻量标签 / helpers 目标描述 + 漂移签名(按样例 args 比对输出)。 + // 任何一维漂移此测试立即红。 + // ============================================================ + #[tokio::test] + async fn test_tool_meta_parity_with_legacy_tables() { + // 真实构建一次注册表(条目与全局表一致性断言用) + let db = Arc::new(Database::open_in_memory().await.expect("in-memory db 初始化失败")); + let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root())); + let registry = build_ai_tool_registry( + &db, &allowed_dirs, PathBuf::from(""), + make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx(), + ); + ensure_tool_meta_seeded(); + + // ── 旧 path_auth::extract_file_tool_paths 表(name → 路径参数键)── + let legacy_path_args: &[(&str, &[&str])] = &[ + ("rename_file", &["from", "to"]), + ("read_file", &["path"]), ("write_file", &["path"]), ("list_directory", &["path"]), + ("patch_file", &["path"]), ("file_info", &["path"]), ("append_file", &["path"]), + ("delete_file", &["path"]), ("search_files", &["path"]), ("grep", &["path"]), + ]; + for (name, keys) in legacy_path_args { + assert_eq!(df_ai::ai_tools::path_arg_keys(name), *keys, "path_args 漂移: {}", name); + } + // 旧 default:非文件工具返回空(不预校验) + for name in ["run_command", "http_request", "create_task", "git_status", "read_symbol", "download_file", "diff_files"] { + assert!(df_ai::ai_tools::path_arg_keys(name).is_empty(), "非文件工具不应有 path_args: {}", name); + } + + // ── 旧 cache.rs READONLY_CACHE_TOOLS 白名单 ── + for name in ["read_file", "read_symbol", "list_directory", "search_files", "grep"] { + assert!(df_ai::ai_tools::is_readonly(name), "readonly 漂移: {}", name); + } + for name in ["write_file", "patch_file", "run_command", "delete_file", "rename_file", "append_file", "file_info", "http_request", "git_commit", "mysql_query"] { + assert!(!df_ai::ai_tools::is_readonly(name), "非只读工具误标 readonly: {}", name); + } + + // ── 旧 cache.rs 目录级 key / 目录专用警告判据(旧匹配 list_directory;幽灵 list_dir 已删)── + assert!(df_ai::ai_tools::cache_path_key_only("list_directory")); + for name in ["read_file", "grep", "search_files", "list_dir"] { + assert!(!df_ai::ai_tools::cache_path_key_only(name), "cache_path_key 误标: {}", name); + } + + // ── 旧 path_auth search_files 盲搜硬拒 ── + assert!(df_ai::ai_tools::denies_unauthorized("search_files")); + for name in ["grep", "read_file", "list_directory", "run_command"] { + assert!(!df_ai::ai_tools::denies_unauthorized(name), "deny_unauthorized 误标: {}", name); + } + + // ── 旧 data_change::data_change_for_tool 表(name, entity, action)── + let legacy_entity_action: &[(&str, &str, &str)] = &[ + ("create_project", "project", "create"), + ("create_task", "task", "create"), + ("create_idea", "idea", "create"), + ("update_project", "project", "update"), + ("update_task", "task", "update"), + ("update_idea", "idea", "update"), + ("advance_task", "task", "update"), + ("delete_project", "project", "delete"), + ("purge_project", "project", "delete"), + ("delete_task", "task", "delete"), + ("delete_idea", "idea", "delete"), + ("restore_project", "project", "update"), + ("bind_directory", "project", "update"), + ("write_file", "file", "update"), + ("patch_file", "file", "update"), + ]; + for (name, entity, action) in legacy_entity_action { + assert_eq!( + df_ai::ai_tools::entity_action(name), + Some((*entity, *action)), + "entity_action 漂移: {}", name + ); + } + for name in ["list_tasks", "run_workflow", "read_file", "git_status"] { + assert_eq!(df_ai::ai_tools::entity_action(name), None, "entity_action 应为 None: {}", name); + } + + // ── 旧 tool_registry display_hint_for_tool 表(name, 模板, 键)── + let legacy_approval: &[(&str, &str, &[&str])] = &[ + ("delete_project", "删除项目{}", &["id"]), + ("restore_project", "从回收站恢复项目{}", &["id"]), + ("purge_project", "永久删除项目及关联数据,不可恢复{}", &["id"]), + ("update_project", "修改项目{}字段「{}」", &["id", "field"]), + ("bind_directory", "绑定目录:{}(项目{})", &["path", "id"]), + ("create_task", "创建任务:{}(项目{})", &["title", "project_id"]), + ("create_project", "创建项目:「{}」", &["name"]), + ("create_idea", "捕获灵感:{}", &["title"]), + ("patch_file", "修改文件:{}", &["path"]), + ("delete_file", "删除文件:{}({})", &["path", "permanent"]), + ("rename_file", "重命名/移动:{} → {}", &["from", "to"]), + ("advance_task", "推进任务状态:{} → {}", &["id", "target_status"]), + ("run_workflow", "触发工作流:任务{} 推进到 {}", &["task_id", "target_status"]), + ("http_request", "HTTP 请求:{} {}", &["method", "url"]), + ]; + for (name, template, keys) in legacy_approval { + assert_eq!( + df_ai::ai_tools::approval_hint(name), + Some((*template, *keys)), + "approval_hint 漂移: {}", name + ); + } + assert_eq!(df_ai::ai_tools::approval_hint("read_file"), None); + + // ── 旧 tool_registry tool_display_hint 表(advance_task 刻意不在表内)── + let legacy_verb: &[(&str, &str)] = &[ + ("create_task", "创建任务"), ("write_file", "写入文件"), ("read_file", "读取文件"), + ("list_directory", "列出目录"), ("search_files", "搜索文件"), ("grep", "搜索代码内容"), + ("create_project", "创建项目"), ("update_project", "修改项目"), ("delete_project", "删除项目"), + ("restore_project", "恢复项目"), ("purge_project", "永久删除项目"), ("bind_directory", "绑定目录"), + ("create_idea", "捕获灵感"), ("update_task", "修改任务"), ("run_workflow", "触发工作流"), + ("delete_task", "删除任务"), ("update_idea", "修改灵感"), ("delete_idea", "删除灵感"), + ("patch_file", "修改文件"), ("append_file", "追加写入"), ("delete_file", "删除文件"), + ("rename_file", "重命名/移动"), ("http_request", "发起 HTTP 请求"), + ]; + for (name, verb) in legacy_verb { + assert_eq!(df_ai::ai_tools::display_verb(name), Some(*verb), "display_verb 漂移: {}", name); + } + assert_eq!(df_ai::ai_tools::display_verb("advance_task"), None, "advance_task 无轻量标签(走模板),与旧表一致"); + assert_eq!(df_ai::ai_tools::display_verb("read_symbol"), None); + + // ── 旧 helpers::infer_goal_from_tool_calls(样例 args 逐字比对输出)── + let goal_of = |name: &str, args: Option| -> Option { + df_ai::ai_tools::goal_of(name).and_then(|g| df_ai::ai_tools::goal_text(g, args.as_ref())) + }; + // FileName 风格:按旧实现取末个 / 段(纯反斜杠路径旧 or_else 分支恒死,保持原样) + assert_eq!(goal_of("patch_file", Some(serde_json::json!({"path": "a/b/c.rs"}))), Some("修改 c.rs".to_string())); + assert_eq!(goal_of("write_file", Some(serde_json::json!({"path": "docs/dry.md"}))), Some("修改 dry.md".to_string())); + assert_eq!(goal_of("append_file", Some(serde_json::json!({"path": "log.txt"}))), Some("修改 log.txt".to_string())); + assert_eq!(goal_of("read_file", Some(serde_json::json!({"path": "src/lib.rs"}))), Some("查看 lib.rs".to_string())); + assert_eq!(goal_of("file_info", Some(serde_json::json!({"path": "Cargo.toml"}))), Some("查看 Cargo.toml".to_string())); + assert_eq!(goal_of("delete_file", Some(serde_json::json!({"path": "a/b.txt"}))), Some("处理 b.txt".to_string())); + assert_eq!(goal_of("rename_file", Some(serde_json::json!({"path": "a/b.txt"}))), Some("处理 b.txt".to_string())); + // 反斜杠路径:旧实现 rsplit('/') 取整串(or_else 恒不触发),新实现保持同值 + assert_eq!(goal_of("write_file", Some(serde_json::json!({"path": "x\\y\\n.txt"}))), Some("修改 x\\y\\n.txt".to_string())); + // Raw 风格 + assert_eq!(goal_of("list_directory", Some(serde_json::json!({"path": "/tmp/logs"}))), Some("浏览 /tmp/logs".to_string())); + assert_eq!(goal_of("create_project", Some(serde_json::json!({"name": "moyu"}))), Some("创建项目 moyu".to_string())); + assert_eq!(goal_of("create_idea", Some(serde_json::json!({"title": "灵感一闪"}))), Some("捕获灵感 灵感一闪".to_string())); + // Truncate 风格 + 键回退 + 缺参兜底 + let long_cmd = "abcdefghijklmnopqrstuvwxyz0123456789"; + assert_eq!(goal_of("run_command", Some(serde_json::json!({"command": long_cmd}))), Some(format!("执行 {}", &long_cmd[..25]))); + assert_eq!(goal_of("search_files", Some(serde_json::json!({"pattern": long_cmd}))), Some(format!("搜索 {}", &long_cmd[..25]))); + assert_eq!(goal_of("grep", Some(serde_json::json!({"query": "todo"}))), Some("搜索 todo".to_string())); + assert_eq!(goal_of("search_files", Some(serde_json::json!({}))), Some("搜索文件".to_string())); + assert_eq!(goal_of("run_command", Some(serde_json::json!({}))), Some("执行命令".to_string())); + // 无键工具恒返兜底(旧 run_workflow 无条件 Some) + assert_eq!(goal_of("run_workflow", Some(serde_json::json!({"task_id": "t1"}))), Some("执行工作流".to_string())); + assert_eq!(goal_of("run_workflow", None), Some("执行工作流".to_string())); + // 键缺失且无兜底返 None(旧表 _ => None 分支) + assert_eq!(goal_of("read_file", Some(serde_json::json!({}))), None); + assert_eq!(goal_of("update_task", Some(serde_json::json!({"id": "t1"}))), None); + + // ── 旧 helpers::tool_call_signature(样例 args 逐字比对输出)── + let sig = |name: &str, args: serde_json::Value| df_ai::ai_tools::tool_signature(name, &args); + assert_eq!(sig("grep", serde_json::json!({"pattern": "MAX", "path": "src", "case_sensitive": true})), "grep:pattern=MAX,path=src"); + assert_eq!(sig("search_files", serde_json::json!({"query": "TODO", "glob": "**/*.rs"})), "search_files:pattern=TODO,path=**/*.rs"); + assert_eq!(sig("read_file", serde_json::json!({"path": "big.log", "offset": 50, "limit": 100})), "read_file:path=big.log,offset=50,limit=100"); + assert_eq!(sig("read_file", serde_json::json!({})), "read_file:path=,offset=,limit="); + assert_eq!(sig("run_command", serde_json::json!({"command": "ls -la", "timeout": 5000})), "run_command:command=ls -la"); + assert_eq!(sig("list_directory", serde_json::json!({"path": "/tmp"})), "list_directory:path=/tmp"); + assert_eq!(sig("http_request", serde_json::json!({"url": "https://x.io", "method": "GET", "headers": {}})), "http_request:url=https://x.io,method=GET"); + assert_eq!(sig("fetch_url", serde_json::json!({"url": "https://y.io", "raw": false})), "fetch_url:url=https://y.io"); + // 未注册/未声明 sig 走旧 default:工具名 + 全参 JSON + let unknown = sig("custom_tool", serde_json::json!({"x": 1, "y": "z"})); + assert!(unknown.starts_with("custom_tool:name=custom_tool,args="), "未知工具应走全参兜底: {}", unknown); + let nosig = sig("write_file", serde_json::json!({"path": "a"})); + assert!(nosig.starts_with("write_file:name=write_file,args="), "未声明 sig 的已注册工具应走全参兜底: {}", nosig); + + // ── 注册表条目与全局表一致(条目存储元数据 + 查询函数双面)── + for name in registry.tool_names() { + let entry = registry.meta(&name).expect("条目应有元数据"); + assert_eq!(Some(entry), df_ai::ai_tools::tool_meta(&name), "条目与全局表漂移: {}", name); + } + // 抽查条目维度:read_file 只读 + path 路径键;advance_task 有 entity 无轻量标签 + let rf = registry.meta("read_file").unwrap(); + assert!(rf.readonly && rf.path_args == ["path"]); + let at = registry.meta("advance_task").unwrap(); + assert_eq!(at.entity_action, Some(("task", "update"))); + assert_eq!(at.display_verb, None); + } } diff --git a/src-tauri/src/commands/ai/tools/fetch_url.rs b/src-tauri/src/commands/ai/tools/fetch_url.rs index abd950d..b81cf80 100644 --- a/src-tauri/src/commands/ai/tools/fetch_url.rs +++ b/src-tauri/src/commands/ai/tools/fetch_url.rs @@ -10,7 +10,7 @@ use std::sync::Arc; -use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; +use df_ai::ai_tools::{AiToolRegistry, RiskLevel, SigMeta, SigValKind, ToolMeta}; use df_ai::declare_tool; /// 注册 fetch_url 工具到 `$registry`(无 db 捕获,纯网络 GET + htmd 转换)。 @@ -42,6 +42,10 @@ pub fn register(registry: &mut AiToolRegistry) { "fetch_url", "获取网页 URL 内容并转为 markdown(HTML 清洗 + 截断),用于高效理解网页文档(API 文档/博客/技术资料)。只读 GET,自动剥离 script/style/nav 等噪声,提取 title,按 max_length 截断。返回 {url, title, markdown, length, truncated, render_mode, ?hint}。安全:仅 http/https,拒绝私网/保留 IP(SSRF 防护含 DNS resolve 后校验),重定向≤3 跳。render=true 用 obscura 渲染 JS(SPA/反爬,需装 obscura,未装自动回退静态)。如需 POST/鉴权/原始响应用 http_request", RiskLevel::Low, + meta: ToolMeta { + sig: Some(SigMeta { groups: &[(&["url"], SigValKind::Str)] }), + ..Default::default() + }, schema: schema, args => { // 转调 fetch_url.rs handler(SSRF 防护 + htmd 转换 + 去噪音 + 截断全在那) diff --git a/src-tauri/src/commands/ai/tools/file.rs b/src-tauri/src/commands/ai/tools/file.rs index 4519b55..926490c 100644 --- a/src-tauri/src/commands/ai/tools/file.rs +++ b/src-tauri/src/commands/ai/tools/file.rs @@ -14,7 +14,7 @@ //! validate_path / generate_diff / apply_line_range / resolve_anchor_to_lines / //! resolve_workspace_path_with_allowed / rename_or_cross_volume_copy / compute_file_hash / //! FILE_LOCKS / probe_executable / list_dir_recursive / compile_glob_to_regex / -//! grep_recursive / grep_one_file / search_files_recursive / is_noise_dir / is_noise_file / +//! grep_recursive / grep_one_file / search_files_recursive / is_noise_entry / is_noise_file / //! FileGrepHit / LineMatch。 //! //! 单捕获宏适配(declare_tool! 宏仅支持单 capture 变量): @@ -30,7 +30,7 @@ use std::sync::Arc; -use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel}; +use df_ai::ai_tools::{object_schema, AiToolRegistry, GoalMeta, GoalStyle, RiskLevel, SigMeta, SigValKind, ToolMeta}; use df_ai::declare_tool; use tokio::sync::RwLock; @@ -73,6 +73,14 @@ pub fn register( "read_file", "读取文件内容,返回文本内容。支持 offset/limit 分页;传入 search 则在文件内容中搜索匹配行(大小写敏感,字符串包含匹配),返回 matches 数组(限50条)。仅需函数/类型符号的签名或定义体时优先用 read_symbol(骨架/全文,避免 prompt 爆)", RiskLevel::Low, + meta: ToolMeta { + path_args: &["path"], + readonly: true, + display_verb: Some("读取文件"), + goal: Some(GoalMeta { verb: "查看", keys: &["path"], style: GoalStyle::FileName, fallback: None }), + sig: Some(SigMeta { groups: &[(&["path"], SigValKind::Str), (&["offset"], SigValKind::Json), (&["limit"], SigValKind::Json)] }), + ..Default::default() + }, schema: object_schema(vec![("path", "string", true), ("offset", "integer", false), ("limit", "integer", false), ("search", "string", false)]), args => { let snap = allowed_dirs.read().await.clone(); @@ -208,6 +216,10 @@ pub fn register( "read_symbol", "AST 符号解析(信息密度驱动,治 read_file 全文回灌 prompt 爆)。提取函数/结构体/类等符号:默认返回骨架(签名+行范围+内部调用,极小高密度);传 full=true 取完整定义体。不支持/未找到时回退提示用 grep+read_file。Phase1 支持 .rs/.ts/.tsx/.js/.jsx/.vue。", RiskLevel::Low, + meta: ToolMeta { + readonly: true, + ..Default::default() + }, schema: object_schema(vec![("path", "string", true), ("symbol", "string", true), ("full", "boolean", false), ("kind", "string", false), ("drill", "string", false)]), args => { let snap = allowed_dirs.read().await.clone(); @@ -308,6 +320,15 @@ pub fn register( "list_directory", "列出目录内容,返回文件和子目录列表(名称、类型、大小)。path 可选,不传时返回引导提示", RiskLevel::Low, + meta: ToolMeta { + path_args: &["path"], + readonly: true, + cache_path_key: true, + display_verb: Some("列出目录"), + goal: Some(GoalMeta { verb: "浏览", keys: &["path"], style: GoalStyle::Raw, fallback: None }), + sig: Some(SigMeta { groups: &[(&["path"], SigValKind::Str)] }), + ..Default::default() + }, schema: object_schema(vec![("path", "string", false), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false), ("max_depth", "integer", false)]), args => { let p = args["path"].as_str().unwrap_or(""); @@ -335,6 +356,13 @@ pub fn register( "write_file", "写入或创建文件,自动创建不存在的父目录。支持 encoding=\"base64\" 写入二进制/非 UTF-8 内容(图片/PDF/Excel 等),content 传 base64 编码字符串;默认 encoding=\"utf-8\" 传纯文本", RiskLevel::Medium, + meta: ToolMeta { + path_args: &["path"], + entity_action: Some(("file", "update")), + display_verb: Some("写入文件"), + goal: Some(GoalMeta { verb: "修改", keys: &["path"], style: GoalStyle::FileName, fallback: None }), + ..Default::default() + }, schema: object_schema(vec![ ("path", "string", true), ("content", "string", true), @@ -453,6 +481,14 @@ pub fn register( "patch_file", "局部更新文件内容(三模式互斥)。模式1 old_text:精确匹配原文替换(含空格/缩进,CAS 语义)。模式2 replace_lines:按行号区间 {start,end}(1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor:按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文。三模式均需 path+new_text,expected_hash 可选通用。注意:若文件已被外部修改,请先重新 read_file 获取最新内容。所有 patch_file 操作自动放行不阻塞 AI 工作流,写入会落 audit 表可追溯。", RiskLevel::Medium, + meta: ToolMeta { + path_args: &["path"], + entity_action: Some(("file", "update")), + approval_hint: Some(("修改文件:{}", &["path"])), + display_verb: Some("修改文件"), + goal: Some(GoalMeta { verb: "修改", keys: &["path"], style: GoalStyle::FileName, fallback: None }), + ..Default::default() + }, schema: patch_file_schema, args => { let snap = allowed_dirs.read().await.clone(); @@ -637,6 +673,11 @@ pub fn register( "file_info", "获取文件或目录的元信息(是否存在、大小、行数、修改时间、是否二进制、是否目录),不读取文件内容", RiskLevel::Low, + meta: ToolMeta { + path_args: &["path"], + goal: Some(GoalMeta { verb: "查看", keys: &["path"], style: GoalStyle::FileName, fallback: None }), + ..Default::default() + }, schema: object_schema(vec![("path", "string", true)]), args => { let snap = allowed_dirs.read().await.clone(); @@ -691,6 +732,12 @@ pub fn register( "append_file", "向文件末尾追加内容,文件不存在则自动创建。返回写入字数和新文件大小", RiskLevel::Medium, + meta: ToolMeta { + path_args: &["path"], + display_verb: Some("追加写入"), + goal: Some(GoalMeta { verb: "修改", keys: &["path"], style: GoalStyle::FileName, fallback: None }), + ..Default::default() + }, schema: object_schema(vec![("path", "string", true), ("content", "string", true)]), args => { let snap = allowed_dirs.read().await.clone(); @@ -730,6 +777,13 @@ pub fn register( "delete_file", "删除文件。默认(permanent=false)软删除:移到工作区 .trash/ 目录保留可恢复;permanent=true 硬删除不可恢复。删除前备份,回收站机制对齐 list_trash。高风险,须人工批准", RiskLevel::High, + meta: ToolMeta { + path_args: &["path"], + approval_hint: Some(("删除文件:{}({})", &["path", "permanent"])), + display_verb: Some("删除文件"), + goal: Some(GoalMeta { verb: "处理", keys: &["path"], style: GoalStyle::FileName, fallback: None }), + ..Default::default() + }, schema: object_schema(vec![ ("path", "string", true), ("permanent", "boolean", false), @@ -810,6 +864,13 @@ pub fn register( "rename_file", "重命名或移动文件(同一工具覆盖 rename 和 move)。参数:from(源路径)+ to(目标路径)+ overwrite(默认 false,目标存在则拒绝)。同卷走 tokio::fs::rename(原子),跨卷自动降级 copy+remove(非原子,失败回滚删 to 保 from 完整)。须人工批准", RiskLevel::Medium, + meta: ToolMeta { + path_args: &["from", "to"], + approval_hint: Some(("重命名/移动:{} → {}", &["from", "to"])), + display_verb: Some("重命名/移动"), + goal: Some(GoalMeta { verb: "处理", keys: &["path"], style: GoalStyle::FileName, fallback: None }), + ..Default::default() + }, schema: object_schema(vec![ ("from", "string", true), ("to", "string", true), @@ -902,6 +963,14 @@ pub fn register( "grep", "跨文件内容搜索(grep -rn 模式)。参数:pattern(正则,大小写敏感,无特殊字符时等价字面包含)、path(搜索根,可选,不传时返回引导提示)、glob(可选文件名过滤如 *.rs)、output_mode(content/files_with_matches/count)、-n(行号默认 true)、-i(大小写不敏感默认 false)、-C(上下文行数默认 0)、context_chars(字符级窗口,大单行如 minified JS/CSS 命中行超阈值时截匹配位置 ±N 字符窗口替代整行,默认 0 不截;与 -C 互补:-C 行级前后 N 行,context_chars 同长行内字符级)、max_results(上限默认 50)。跳过噪音目录/噪音文件/symlink/二进制文件。返回 matches(files_with_matches 模式)或 matches(含 file/line/content/context,content 模式)+ total + truncated。授权目录内放行,未授权触发目录授权申请(AiDirAuthRequired)", RiskLevel::Low, + meta: ToolMeta { + path_args: &["path"], + readonly: true, + display_verb: Some("搜索代码内容"), + goal: Some(GoalMeta { verb: "搜索", keys: &["pattern", "query"], style: GoalStyle::Truncate, fallback: Some("搜索文件") }), + sig: Some(SigMeta { groups: &[(&["pattern", "query"], SigValKind::Str), (&["path", "glob"], SigValKind::Str)] }), + ..Default::default() + }, schema: grep_schema, args => { let p = args["path"].as_str().unwrap_or(""); @@ -1078,6 +1147,15 @@ pub fn register( "search_files", "在指定目录下搜索匹配模式(字符串包含匹配)的文件名,支持 offset/limit 分页。返回 results、total、has_more。默认 limit=50", RiskLevel::Low, + meta: ToolMeta { + path_args: &["path"], + readonly: true, + deny_unauthorized: true, + display_verb: Some("搜索文件"), + goal: Some(GoalMeta { verb: "搜索", keys: &["pattern", "query"], style: GoalStyle::Truncate, fallback: Some("搜索文件") }), + sig: Some(SigMeta { groups: &[(&["pattern", "query"], SigValKind::Str), (&["path", "glob"], SigValKind::Str)] }), + ..Default::default() + }, schema: object_schema(vec![("path", "string", false), ("pattern", "string", true), ("recursive", "boolean", false), ("offset", "integer", false), ("limit", "integer", false)]), args => { let p = args["path"].as_str().unwrap_or(""); @@ -1122,6 +1200,11 @@ pub fn register( "run_command", "在指定工作目录执行 shell 命令,返回 stdout/stderr/exit_code。仅用于命令执行场景:跑测试套件、构建项目、运行二进制/脚本验证行为。读取文件用 read_file,编辑文件用 patch_file/write_file,列目录用 list_directory,搜索文件名用 search_files——不要用本工具完成这些操作。高风险,须人工批准。命令需自包含(非交互式,避免需用户输入的程序)。默认超时 60 秒。返回含 succeeded 布尔(按 exit_code==0 判定)。注意:部分命令非零退出属正常语义(如 git diff 无差异返回 1),此时以 stdout/stderr 内容为准,勿仅凭 exit_code≠0 判失败。", RiskLevel::High, + meta: ToolMeta { + goal: Some(GoalMeta { verb: "执行", keys: &["command"], style: GoalStyle::Truncate, fallback: Some("执行命令") }), + sig: Some(SigMeta { groups: &[(&["command"], SigValKind::Str)] }), + ..Default::default() + }, schema: object_schema(vec![ ("command", "string", true), ("working_dir", "string", false), diff --git a/src-tauri/src/commands/ai/tools/http.rs b/src-tauri/src/commands/ai/tools/http.rs index a1e8546..fecfaf4 100644 --- a/src-tauri/src/commands/ai/tools/http.rs +++ b/src-tauri/src/commands/ai/tools/http.rs @@ -23,7 +23,7 @@ use std::sync::Arc; -use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; +use df_ai::ai_tools::{AiToolRegistry, RiskLevel, SigMeta, SigValKind, ToolMeta}; use df_ai::declare_tool; use tokio::sync::RwLock; @@ -65,6 +65,12 @@ pub fn register(registry: &mut AiToolRegistry, allowed_dirs: &Arc { // allowed_dirs read lock clone(output_file 路径校验用;无 output_file 时校验不触发) diff --git a/src-tauri/src/commands/ai/tools/idea.rs b/src-tauri/src/commands/ai/tools/idea.rs index 288ed1e..6850894 100644 --- a/src-tauri/src/commands/ai/tools/idea.rs +++ b/src-tauri/src/commands/ai/tools/idea.rs @@ -18,7 +18,7 @@ use std::sync::Arc; -use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel}; +use df_ai::ai_tools::{object_schema, AiToolRegistry, GoalMeta, GoalStyle, RiskLevel, ToolMeta}; use df_ai::declare_tool; use df_storage::db::Database; use df_storage::models::IdeaRecord; @@ -61,6 +61,13 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { "create_idea", "捕获一个新灵感", RiskLevel::Medium, + meta: ToolMeta { + entity_action: Some(("idea", "create")), + approval_hint: Some(("捕获灵感:{}", &["title"])), + display_verb: Some("捕获灵感"), + goal: Some(GoalMeta { verb: "捕获灵感", keys: &["title"], style: GoalStyle::Raw, fallback: None }), + ..Default::default() + }, schema: object_schema(vec![ ("title", "string", true), ("description", "string", false), ("tags", "string", false), ("source", "string", false), @@ -99,6 +106,11 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { "update_idea", "更新灵感的指定字段(title/description/priority/tags/source/score 等),需要提供灵感 ID、字段名和新值。", RiskLevel::Medium, + meta: ToolMeta { + entity_action: Some(("idea", "update")), + display_verb: Some("修改灵感"), + ..Default::default() + }, schema: object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]), args => { let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; @@ -124,6 +136,11 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { "delete_idea", "删除灵感(软删除,移入回收站,可恢复)", RiskLevel::Medium, + meta: ToolMeta { + entity_action: Some(("idea", "delete")), + display_verb: Some("删除灵感"), + ..Default::default() + }, schema: object_schema(vec![("id", "string", true)]), args => { let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; diff --git a/src-tauri/src/commands/ai/tools/mod.rs b/src-tauri/src/commands/ai/tools/mod.rs index 3da90ca..c139324 100644 --- a/src-tauri/src/commands/ai/tools/mod.rs +++ b/src-tauri/src/commands/ai/tools/mod.rs @@ -26,3 +26,4 @@ pub mod trash; pub mod file; pub mod download_file; pub mod entity_resolve; +pub mod read_skill; diff --git a/src-tauri/src/commands/ai/tools/project.rs b/src-tauri/src/commands/ai/tools/project.rs index 2718e31..f1ac903 100644 --- a/src-tauri/src/commands/ai/tools/project.rs +++ b/src-tauri/src/commands/ai/tools/project.rs @@ -17,7 +17,7 @@ use std::sync::Arc; -use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel}; +use df_ai::ai_tools::{object_schema, AiToolRegistry, GoalMeta, GoalStyle, RiskLevel, ToolMeta}; use df_ai::declare_tool; use df_storage::db::Database; use df_storage::models::ProjectRecord; @@ -42,6 +42,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { "update_project", "更新项目的指定字段(name/status/description/path/stack),需要提供项目 ID、字段名和新值。绑定代码目录推荐改用 bind_directory", RiskLevel::Medium, + meta: ToolMeta { + entity_action: Some(("project", "update")), + approval_hint: Some(("修改项目{}字段「{}」", &["id", "field"])), + display_verb: Some("修改项目"), + ..Default::default() + }, schema: object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]), args => { let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; @@ -69,6 +75,13 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { "create_project", "创建新项目,可选传 path/stack 一步完成创建+绑定目录(无需再调 bind_directory)", RiskLevel::Medium, + meta: ToolMeta { + entity_action: Some(("project", "create")), + approval_hint: Some(("创建项目:「{}」", &["name"])), + display_verb: Some("创建项目"), + goal: Some(GoalMeta { verb: "创建项目", keys: &["name"], style: GoalStyle::Raw, fallback: None }), + ..Default::default() + }, schema: object_schema(vec![ ("name", "string", true), ("description", "string", false), ("path", "string", false), ("stack", "string", false), @@ -104,6 +117,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { "bind_directory", "为项目绑定代码目录(自动探测技术栈,防重复绑定)。path 应为**项目根路径**(单仓库=仓库根;多工程 monorepo=工作区根,非单个子工程路径;子工程信息走 list_project_modules/update_project_module 维护)。多次调用会覆盖 path 字段,monorepo 场景勿为每个子工程重复绑定。可选 stack 手动指定技术栈(如 'Go / Gin / Vue3'),不传则自动探测。", RiskLevel::Medium, + meta: ToolMeta { + entity_action: Some(("project", "update")), + approval_hint: Some(("绑定目录:{}(项目{})", &["path", "id"])), + display_verb: Some("绑定目录"), + ..Default::default() + }, schema: object_schema(vec![("id", "string", true), ("path", "string", true), ("stack", "string", false)]), args => { let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id 参数"))?; @@ -126,6 +145,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { "delete_project", "删除项目(移入回收站,可恢复。永久删除用 purge_project)", RiskLevel::High, + meta: ToolMeta { + entity_action: Some(("project", "delete")), + approval_hint: Some(("删除项目{}", &["id"])), + display_verb: Some("删除项目"), + ..Default::default() + }, schema: object_schema(vec![("id", "string", true)]), args => { let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; @@ -142,6 +167,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { "restore_project", "从回收站恢复已删除项目", RiskLevel::High, + meta: ToolMeta { + entity_action: Some(("project", "update")), + approval_hint: Some(("从回收站恢复项目{}", &["id"])), + display_verb: Some("恢复项目"), + ..Default::default() + }, schema: object_schema(vec![("id", "string", true)]), args => { let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; @@ -158,6 +189,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { "purge_project", "永久删除项目及关联数据(不可恢复)", RiskLevel::High, + meta: ToolMeta { + entity_action: Some(("project", "delete")), + approval_hint: Some(("永久删除项目及关联数据,不可恢复{}", &["id"])), + display_verb: Some("永久删除项目"), + ..Default::default() + }, schema: object_schema(vec![("id", "string", true)]), args => { let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; diff --git a/src-tauri/src/commands/ai/tools/read_skill.rs b/src-tauri/src/commands/ai/tools/read_skill.rs new file mode 100644 index 0000000..c6eb05d --- /dev/null +++ b/src-tauri/src/commands/ai/tools/read_skill.rs @@ -0,0 +1,48 @@ +//! read_skill AI 工具声明式注册(按名读本机 Claude 技能规程正文,只读)。 +//! +//! 工具职责:LLM 主动调 read_skill(name) → 查进程内技能缓存(含项目级,PROJECT_SKILLS_ROOT) +//! 命中后读 SKILL.md 剥 frontmatter 正文返回(含 source 来源标注);未命中返回明确错误文案 +//! (LLM 可据此向用户澄清技能名,不静默空转)。 +//! +//! 风险:Low(纯只读,读 ~/.claude 与项目 .claude 下 SKILL.md,无写副作用)。 +//! handler 直调 skills.rs 的 `read_skill_content_stripped` / `skills_cached`(注入链同一 +//! 真相源,与 / 技能 SkillResolver 读到相同内容)。 + +use std::sync::Arc; + +use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel}; +use df_ai::declare_tool; + +/// 注册 read_skill 工具到 `$registry`(无外部捕获,读 skills.rs 进程内缓存 + 单文件)。 +pub fn register(registry: &mut AiToolRegistry) { + // 无捕获:占位 Arc<()>(handler 只调 skills.rs 缓存/读文件,不持外部状态)。 + let dummy: Arc<()> = Arc::new(()); + // schema:name 必填(技能名,如 "review" / "swarm")。 + let schema = object_schema(vec![("name", "string", true)]); + declare_tool!( + registry, + dummy: Arc<()>, + "read_skill", + "按名称读取本机已安装的 Claude 技能规程(SKILL.md)完整正文,只读查阅,不执行技能。参数 name 为技能名。命中返回 {name, source, content}(source 为来源 skill/project/command/plugin,content 已剥离 frontmatter);未命中返回明确错误。当用户 / 技能调用失败、用户提到某技能名、或你判断某技能与本任务相关时调用本工具读取其完整规程内容", + RiskLevel::Low, + schema: schema, + args => { + let name = args["name"].as_str().unwrap_or("").trim().to_string(); + if name.is_empty() { + return Err(anyhow::anyhow!("参数 name 不能为空:请传入技能名,如 {{\"name\": \"review\"}}")); + } + let content = crate::commands::ai::skills::read_skill_content_stripped(name.clone()) + .await + .ok_or_else(|| anyhow::anyhow!( + "技能 \"{name}\" 未找到(未安装/名称不匹配/user_invocable:false)。请核对技能名后重试,或请用户确认该技能是否已安装", + ))?; + let source = crate::commands::ai::skills::skills_cached() + .await + .into_iter() + .find(|s| s.name == name) + .map(|s| s.source) + .unwrap_or_else(|| "skill".to_string()); + Ok(serde_json::json!({ "name": name, "source": source, "content": content })) + } + ); +} diff --git a/src-tauri/src/commands/ai/tools/task.rs b/src-tauri/src/commands/ai/tools/task.rs index bf41a13..e5d1c69 100644 --- a/src-tauri/src/commands/ai/tools/task.rs +++ b/src-tauri/src/commands/ai/tools/task.rs @@ -16,7 +16,7 @@ use std::sync::Arc; -use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel}; +use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel, ToolMeta}; use df_ai::declare_tool; use df_storage::db::Database; use df_storage::models::TaskRecord; @@ -67,6 +67,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { "create_task", "在指定项目下创建新任务,可选传 idea_id 关联灵感(1对1 单向)。知识图谱 Phase 1 扩展三个可选参数:queue(管理维度池 backlog/todo/decision,默认 todo,新建不可直接落 active/done 须经 move_task_queue 流转)、parent_id(父任务 ID,限 1 级嵌套,父任务自身不能有 parent_id,IPC 层校验防孙任务)、content_json(结构化需求规格 JSON 字符串 {background,acceptance_criteria[],scope[],technical_design},须合法 JSON)", RiskLevel::Medium, + meta: ToolMeta { + entity_action: Some(("task", "create")), + approval_hint: Some(("创建任务:{}(项目{})", &["title", "project_id"])), + display_verb: Some("创建任务"), + ..Default::default() + }, schema: object_schema(vec![ ("project_id", "string", true), ("title", "string", true), ("description", "string", false), ("priority", "integer", false), @@ -129,6 +135,11 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { "update_task", "更新任务的指定字段(title/description/priority/assignee 等),需要提供任务 ID、字段名和新值。注意:status 改动须走 advance_task 工具(状态机推进,8 态 todo/in_progress/in_review/testing/blocked/done/cancelled/deferred,按 target_status 推进不可跳跃),本工具不接受 status 字段(防绕过状态机)", RiskLevel::Medium, + meta: ToolMeta { + entity_action: Some(("task", "update")), + display_verb: Some("修改任务"), + ..Default::default() + }, schema: object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]), args => { let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; @@ -168,6 +179,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { "advance_task", "推进任务状态,必须走状态机(8 态: todo/in_progress/in_review/testing/blocked/done/cancelled/deferred),按 target_status 推进,不可跳跃(非法转换会被状态机拦截)。退回转换(in_review→in_progress 等)自动累加 review_rounds。返回推进后的最新 TaskRecord", RiskLevel::Medium, + // display_verb 刻意不设:轻量标签缺位让审批文案走模板(含 target_status 详情),与旧表一致 + meta: ToolMeta { + entity_action: Some(("task", "update")), + approval_hint: Some(("推进任务状态:{} → {}", &["id", "target_status"])), + ..Default::default() + }, schema: object_schema(vec![("id", "string", true), ("target_status", "string", true)]), args => { let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; @@ -211,6 +228,11 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { "delete_task", "删除任务(移入回收站,可恢复)", RiskLevel::High, + meta: ToolMeta { + entity_action: Some(("task", "delete")), + display_verb: Some("删除任务"), + ..Default::default() + }, schema: object_schema(vec![("id", "string", true)]), args => { let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; diff --git a/src-tauri/src/commands/ai/tools/workflow.rs b/src-tauri/src/commands/ai/tools/workflow.rs index 466396f..e3ccdbf 100644 --- a/src-tauri/src/commands/ai/tools/workflow.rs +++ b/src-tauri/src/commands/ai/tools/workflow.rs @@ -15,7 +15,7 @@ use std::sync::Arc; -use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel}; +use df_ai::ai_tools::{object_schema, AiToolRegistry, GoalMeta, GoalStyle, RiskLevel, ToolMeta}; use df_ai::declare_tool; /// 注册 run_workflow 工具到 `$registry`(无 db 捕获,防御兜底)。 @@ -35,6 +35,12 @@ pub fn register(registry: &mut AiToolRegistry) { "run_workflow", "按任务 target_status 推进对应工作流(含 AiNode 自审 / HumanNode 核对闸门)。参数 task_id + target_status 同时提供才联动任务推进(完成后按 target_status 推进任务,失败按退回态回滚)。属高风险操作(触发工作流引擎执行),须人工批准。审批通过后由后端直接执行工作流引擎并联动推进任务,返回 execution_id", RiskLevel::High, + meta: ToolMeta { + approval_hint: Some(("触发工作流:任务{} 推进到 {}", &["task_id", "target_status"])), + display_verb: Some("触发工作流"), + goal: Some(GoalMeta { verb: "执行工作流", keys: &[], style: GoalStyle::Raw, fallback: Some("执行工作流") }), + ..Default::default() + }, schema: object_schema(vec![("task_id", "string", true), ("target_status", "string", true)]), args => { let task_id = args["task_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 task_id"))?; diff --git a/src-tauri/src/commands/module.rs b/src-tauri/src/commands/module.rs index cb26553..ca4315e 100644 --- a/src-tauri/src/commands/module.rs +++ b/src-tauri/src/commands/module.rs @@ -27,6 +27,7 @@ use df_types::types::new_id; use df_project::scan::detect_stack; // 文件符号概览(file_outline)复用 code_intel 的 AST 符号提取 use crate::commands::ai::code_intel; +use crate::commands::ai::tool_registry::{is_noise_entry, NoiseDirTier}; use crate::state::AppState; @@ -652,22 +653,12 @@ fn run_git_cmd(cwd: &std::path::Path, args: &[&str], timeout: std::time::Duratio // 设计要点(对标设计 §五 + Batch 10): // - **路径安全**:sub_path / file_path 均拒含 `..`(防目录穿越)。先 join 再 canonicalize // 校验结果仍位于 module.path 子树内(双重防御,即便 `..` 漏网也无法逃出工程根)。 -// - **噪音过滤**:`get_module_file_tree` 跳过 node_modules/target/.git/__pycache__/dist/.vite -// (对齐需求清单,前端文件树不展示构建产物/VCS 元数据)。 +// - **噪音过滤**:`get_module_file_tree` 经 `is_noise_entry`(FileTree 档)过滤依赖/缓存目录, +// 隐藏文件(`.` 开头)另有过滤;构建产物 build 与 venv 保留展示给用户。 // - **Git 状态合并**:跑 `git status --porcelain` 得 {rel_path: status} 映射,按条目相对路径 // 查表填充 git_status。文件夹一律 git_status = None(只标文件,符合需求)。 // - **二进制检测**:read_module_file 读 1MB 上限,前 8KB 含 \0 视作二进制(is_binary=true,content 留空)。 -/// 噪音目录名(列表级过滤,read_dir 看到这些名字跳过)。 -const NOISE_DIRS: &[&str] = &[ - "node_modules", - "target", - ".git", - "__pycache__", - "dist", - ".vite", -]; - /// 判断目录是否含可见子项(过滤隐藏文件/噪音目录后,与 `get_module_file_tree` 展开实际展示一致)。 /// /// 供 `FileTreeEntry.has_children` 用:前端未展开目录即可据此在行内标注空目录, @@ -684,7 +675,7 @@ fn dir_has_visible_children(dir: &Path) -> bool { if name.starts_with('.') { return false; } - if NOISE_DIRS.iter().any(|n| *n == name.as_ref()) { + if is_noise_entry(&name, NoiseDirTier::FileTree) { return false; } // 与列目录一致:无法判定类型的条目(断链符号链接等)视为不可见 @@ -786,8 +777,8 @@ pub async fn get_module_file_tree( for entry in read.flatten() { let file_name = entry.file_name(); let name = file_name.to_string_lossy().to_string(); - // 噪音过滤(无论文件/目录,凡名命中即跳)。 - if NOISE_DIRS.iter().any(|n| *n == name.as_str()) { + // 噪音过滤(无论文件/目录,凡名命中即跳;名单统一走 is_noise_entry FileTree 档)。 + if is_noise_entry(&name, NoiseDirTier::FileTree) { continue; } let ft = match entry.file_type() { @@ -1131,8 +1122,8 @@ pub async fn scan_project_modules( let ft = match entry.file_type() { Ok(t) => t, Err(_) => continue }; if !ft.is_dir() { continue; } let name = entry.file_name().to_string_lossy().to_string(); - // 跳过隐藏目录和噪音目录 - if name.starts_with('.') || name == "node_modules" || name == "target" || name == "__pycache__" || name == "dist" { + // 跳过隐藏目录和噪音目录(名单统一走 is_noise_entry FileTree 档) + if name.starts_with('.') || is_noise_entry(&name, NoiseDirTier::FileTree) { continue; } let child_path = entry.path(); diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index d4ffb78..12a3f02 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -127,7 +127,7 @@ pub async fn set_plan_execution(enabled: bool) -> Result<(), String> { /// 读取多 ReAct Plan 执行开关状态。 #[tauri::command] -pub async fn get_plan_execution() -> Result { +pub async fn is_plan_execution_enabled() -> Result { Ok(df_ai::plan_executor::plan_execution_enabled()) } @@ -234,7 +234,6 @@ pub async fn resolve_conflict( resolved_by: String, conversation_id: Option, ) -> Result<(), String> { - use tauri::Emitter; let repo = df_storage::crud::ConflictRepo::new(&state.db); let now = df_types::now_millis().to_string(); let ok = repo.resolve(&conflict_id, &resolution, &resolved_by, &now) @@ -249,7 +248,7 @@ pub async fn resolve_conflict( resolved_by = %resolved_by, "[CONFLICT] 冲突已解决" ); - // emit AiConflictResolved 事件 + // emit AiConflictResolved 事件(双写收敛经 emit_ai_chat_event) let ev = crate::commands::ai::AiChatEvent::AiConflictResolved { conflict_id, plan_id, @@ -257,7 +256,6 @@ pub async fn resolve_conflict( resolved_by, conversation_id, }; - let _ = app_handle.emit("ai-chat-event", ev.clone()); - let _ = state.ai_event_bus.publish_event(ev); + crate::commands::ai::event_bus::emit_ai_chat_event(&app_handle, ev); Ok(()) } diff --git a/src-tauri/src/commands/workflow.rs b/src-tauri/src/commands/workflow.rs index d737120..a3edda0 100644 --- a/src-tauri/src/commands/workflow.rs +++ b/src-tauri/src/commands/workflow.rs @@ -40,7 +40,7 @@ struct WorkflowEventPayload { /// broadcast `Lagged` 不暴露被丢事件类型,关键终态事件(WorkflowCompleted/Failed)可能已丢, /// forward 循环会永久等不到。达阈值时查 DB 终态补发。三种"未命中"(仍 running / 无记录 / /// 查询失败)统一返回 None 并各自 warn,主循环据此重置 `lagged_total` 继续等。 -async fn probe_lagged_terminal( +async fn probe_missed_terminal_event( db: &df_storage::db::Database, exec_id: &ExecutionId, ) -> Option<(WorkflowEvent, String)> { @@ -264,8 +264,8 @@ pub async fn run_workflow_inner( if lagged_total < LAGGED_PROBE_THRESHOLD { continue; } - // 累计/单次达阈值:查 DB 终态兜底(probe_lagged_terminal 收口三种未命中情况) - match probe_lagged_terminal(&forward_db, &forward_exec_id).await { + // 累计/单次达阈值:查 DB 终态兜底(probe_missed_terminal_event 收口三种未命中情况) + match probe_missed_terminal_event(&forward_db, &forward_exec_id).await { Some((synth_event, status)) => { let payload = WorkflowEventPayload { execution_id: forward_exec_id.clone(), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d5d5250..e158ec9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -200,20 +200,15 @@ pub fn run() { } }; // relay_url:KV 取,无则默认本地(localhost:8080,本地开发 df-relay 默认监听)。 - // settings value 契约模糊(JSON 序列化带引号或裸值并存,见 SettingsRepo get/set 不解析 JSON): - // 统一解析去引号 —— serde_json::from_str 处理 JSON 字符串值,失败(裸值)则 trim 首尾引号兜底, + // settings value 契约模糊(JSON 序列化带引号或裸值并存):经 get_string_norm + // 归一去引号(此处 from_str 写法已收敛为 state.rs SettingsNorm 正典,2026-10-01 DRY #2), // 确保拿到纯净 URL 供 connect_async(带引号 URL 解析失败致 connect 静默失败,relay 无 device 接入)。 + use state::SettingsNorm; let relay_url = state .settings - .get("relay_url") + .get_string_norm("relay_url") .await - .ok() - .flatten() .filter(|s| !s.is_empty()) - .map(|raw| { - serde_json::from_str::(&raw) - .unwrap_or_else(|_| raw.trim_matches('"').to_string()) - }) .unwrap_or_else(|| "ws://localhost:8080/ws/device".to_string()); // token:固定常量(三端同源,对齐 df-relay relay.rs:37 DEFAULT_TOKEN) let token = "devflow-relay-default-token".to_string(); @@ -499,7 +494,7 @@ pub fn run() { commands::settings::set_script_safety, // Plan 执行开关 commands::settings::set_plan_execution, - commands::settings::get_plan_execution, + commands::settings::is_plan_execution_enabled, // 冲突解决 commands::settings::resolve_conflict, // 模板管理 diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index e9bcd01..b29b117 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -31,7 +31,7 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; -use std::time::Duration; +use std::time::{Duration, Instant}; use anyhow::Result; use tokio::sync::{Mutex, RwLock}; @@ -52,6 +52,82 @@ use crate::commands::ai::augmentation::ResolverRegistry; use crate::commands::ai::AiSession; use df_types::types::ExecutionId; +// ============================================================ +// Settings KV 引号归一读取(DRY #2 收敛,2026-10-01) +// +// 背景:前端 appSettings.set 以 JSON.stringify 落库(字符串值带引号),但 KV 里也并存 +// 历史裸值,读侧此前散布三种归一策略(trim_matches / serde_json::from_str / bool 比对), +// 已漂移出行为差异(from_str 版能正确处理转义,trim 版不能)。 +// 正典 = lib.rs 原 relay_url 的 from_str 版,收敛为本 trait 两个方法,新代码禁止手抄归一。 +// ============================================================ + +/// SettingsRepo 的引号归一读扩展(KV 值契约模糊:JSON 序列化带引号或裸值并存)。 +/// +/// 只加只读归一方法,不碰 SettingsRepo 本身的 get/set 语义。 +pub trait SettingsNorm { + /// 读字符串值并归一去引号。 + /// + /// 策略(正典,原 lib.rs relay_url 写法):先按 JSON 字符串解析(正确处理 `\` 转义), + /// 失败(裸值)再 trim 首尾 `"` 兜底。key 缺失或读库失败返 None(调用方决定默认值)。 + async fn get_string_norm(&self, key: &str) -> Option; + + /// 读 bool 值并归一比对。 + /// + /// 归一后 == "true" 或 == "1" 为 true,其余(含 key 缺失/读库失败/空值)取 `default`。 + /// 默认值由调用方显式传参,禁止在方法内隐藏默认(各开关默认不同:bypass=false / trace=true)。 + async fn get_bool_norm(&self, key: &str, default: bool) -> bool; +} + +impl SettingsNorm for SettingsRepo { + async fn get_string_norm(&self, key: &str) -> Option { + let raw = self.get(key).await.ok().flatten()?; + Some( + serde_json::from_str::(&raw) + .unwrap_or_else(|_| raw.trim_matches('"').to_string()), + ) + } + + async fn get_bool_norm(&self, key: &str, default: bool) -> bool { + match self.get(key).await { + Ok(Some(raw)) => { + let v = serde_json::from_str::(&raw) + .unwrap_or_else(|_| raw.trim_matches('"').to_string()); + v == "true" || v == "1" + } + _ => default, + } + } +} + +// ============================================================ +// 慢锁诊断打点(DRY #12 收敛,2026-10-01) +// +// 背景:排查 AiCompleted 延迟/卡死连环期间,各持锁段手抄了 +// `let t = Instant::now(); ... let held = t.elapsed(); if held > 30ms { eprintln!(...) }` +// 5 行样板(29 处,变量名编码行号如 __hold610,阈值 30ms 逐处复制),收敛为本函数。 +// ============================================================ + +/// 慢锁打点阈值:持锁段(含 lock 等待)超过 30ms 打 [LOCK-SLOW] 日志。 +pub(crate) const LOCK_SLOW_THRESHOLD: Duration = Duration::from_millis(30); + +/// 慢锁单行打点:持锁段耗时(自 `t` 起,含 lock 等待)超 [`LOCK_SLOW_THRESHOLD`] 时打 stderr 日志。 +/// +/// 用法(替代手抄 5 行样板): +/// ```ignore +/// let t = std::time::Instant::now(); +/// let mut session = state.ai_session.lock().await; +/// ... // 持锁段 +/// drop(session); +/// log_slow_lock(t, "ai_approve:610"); +/// ``` +/// `site` 传「函数名:行号」字符串,保留原变量名编码行号的定位信息。 +pub(crate) fn log_slow_lock(t: Instant, site: &str) { + let held = t.elapsed(); + if held > LOCK_SLOW_THRESHOLD { + eprintln!("[LOCK-SLOW] {} 持锁 {:?} (含 lock 等待)", site, held); + } +} + /// 应用全局状态 — 通过 `app.manage()` 注入,command 中以 `State<'_, AppState>` 取用 pub struct AppState { /// 数据库句柄(Arc 包装,便于在异步任务中重建 Repo)