Compare commits

...
14 Commits
Author SHA1 Message Date
lxy 26d9a1af1e 优化: sanitize 告警降级
- 修复告警 warn 降 debug,同份畸形历史每请求重复触发成洪水
- 涉 sanitize.rs 四处与 openai_compat 两处,含逐 orphan 单条刷屏点
- view-only 修复语义不变,存量配对清洗已完成
2026-10-01 19:36:20 +08:00
lxy 1e6db6f310 新增: anthropic 思维链解析入库与思考卡展示
- 流式与非流式 thinking 块接入 reasoning 通道
- 签名与密文块合规静默,请求侧零改动
- 历史映射透传 reasoning_content
- 思考卡数据源真推理文本优先
2026-10-01 19:35:23 +08:00
lxy bf0db10ef7 新增: 任务工作流 AiAgentNode 直连会话编排
- ai 节点 mode agent 开关派发,默认旧路径零回归
- 完成通知桥注册触发,收敛出口单点投递报告
- 节点超时取消兜底,结果落任务产出
- 会话编排走独立新会话,不劫持活跃面板
2026-10-01 19:35:22 +08:00
lxy 7dcbdb1feb 修复: 连接池收敛与传输错误分类重试
- 连接池空闲十五秒,陈旧连接复用致重置根治
- 流式发送错误改谓词分类,openai 路径不再裸奔
- decode 类失败纳入重试,正文中断可自愈
- 状态分类合并响应体,激活休眠词表分支
- 手动压缩失败降级兜底,不再弹红卡
2026-10-01 19:35:22 +08:00
lxy 4417b3ddd3 修复: 压缩边界对齐与断路器学习语义
- 压缩边界按对话单元回退,不再劈开工具对
- 停止与接管退出补跳过结果,畸形不再新增
- 失败指纹纳入参数,改参重试不再累计熔断
- 成功断连失败段,红绿循环不再误熔断
- 脚本非零退出豁免,同命令死磕由签名熔断兜底
- 熔断前加自纠警示轮,临时路径写删豁免
2026-10-01 19:35:22 +08:00
lxy b8416dea63 修复: 知识提炼截断打捞与水位保节奏
- 提炼额度 2048 提至 4096,prompt 封顶五条降序
- 截断 JSON 字符串感知打捞,只收校验通过前缀
- finish_reason 截断留痕,空输出分开口径
- 失败改记节奏位,灭逐轮重提炼风暴
2026-10-01 19:35:22 +08:00
lxy 423acfdd00 优化: 缓存命中字段解析与提示词前缀稳定化
- openai 兼容口径补解析嵌套 cached_tokens
- cache 显示区分无数据与真零,估算轮不渲染
- 轨迹展开折叠空过程记录行降噪
- token 统计行恒完整可见不截断
- openai_compat 纳入 system 稳定易变拆分
- 系统警示与主题标记改尾部注入不位移前缀
- 工具结果视图压缩按入库时刻冻结
- 会话内工具集冻结仅增不减
- anthropic 缓存补消息级断点,设置页加开关
- 补 intent 测试对 Agent domain 覆盖
2026-10-01 19:35:21 +08:00
lxy 2187ffe173 新增: spawn_agent 子代理派发与级联取消
- 纯内存子会话隔离上下文,一轮回传结论报告
- 白名单钳制低风险并恒剔 spawn_agent 防递归
- 子代理策略下高风险自动拒绝,不挂审批防卡死
- 轮数令牌挂钟三重熔断
- 父停止与删会话级联取消子代理
- AiAgentFinished 事件挂父会话
2026-10-01 19:35:21 +08:00
lxy 524120b110 修复: 切会话残片覆盖回复与收尾工具卡丢失
- flushCurrentText 加非流式守卫,非流式只清不写
- 后台会话终止即清流式态,残片不跨轮引爆
- 异常收尾尾部工具气泡折进轨迹卡不再蒸发
- 停止与看门狗超时先保文再复位
2026-10-01 19:35:21 +08:00
lxy e1dd0bc3a3 新增: 后台会话系统通知与托盘运行态
- 完成/失败/需介入三类系统通知,主窗聚焦静默
- 同类通知五秒合并,用户主动停止不通知
- 托盘菜单与悬浮提示实时显运行中会话数
- 关窗遇运行会话提示后台续跑,每进程一次
- 侧栏常驻徽章显后台运行会话数
2026-10-01 19:35:20 +08:00
lxy 66c6553cdf 优化: ai-chat 对话流布局与轨迹信息密度改造
- 分组行去统计化,调用统计归并轨迹卡头部
- 相对时间改时间分隔线,跨日显绝对时间
- 思考过程单行 pill 化,答案卡加 accent 边
- 工具卡短路径展示与耗时提示统一
- 高风险工具名单启动时同步后端注册表
- 自动重试改琥珀细条,红卡与手动重试仅终态
- 轨迹卡头并时间线行,显轮次号与绝对时间
- 运行中摘要补当前工具名与失败计数
- 执行中工具卡加实时计时,耗时统一人话口径
- 重试残留清理改标记匹配,替代中文子串硬编码
2026-10-01 19:35:20 +08:00
lxy 0560137840 优化: 前端DRY收敛与命名对齐
审批等待格式化与阈值沉 utils/time 正典
相对时间阶梯三套收敛 formatRelative 参数化
灵感状态表沉 constants,透传通道退役
aggText/clearOwn 等命名对齐,IPC 参数名统一 conversation_id
2026-10-01 19:35:20 +08:00
lxy 6ef15a0940 新增: 技能注入反馈闭环与项目扫描前端接线
技能注入成功/失败 toast 与事件类型监听接线
项目级技能扫描前端传参点就绪
2026-10-01 19:35:19 +08:00
lxy 75c5e23523 重构: 后端编排拆解与DRY债收敛及命名纠偏
阶段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 项纠偏
2026-10-01 19:35:19 +08:00
102 changed files with 7522 additions and 2474 deletions
Generated
+122 -12
View File
@@ -872,6 +872,7 @@ dependencies = [
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-notification",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-single-instance", "tauri-plugin-single-instance",
"tauri-plugin-window-state", "tauri-plugin-window-state",
@@ -2527,6 +2528,20 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac-notification-sys"
version = "0.6.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca"
dependencies = [
"cc",
"log",
"objc2",
"objc2-foundation",
"time",
"uuid",
]
[[package]] [[package]]
name = "markup5ever" name = "markup5ever"
version = "0.38.0" version = "0.38.0"
@@ -2688,6 +2703,20 @@ dependencies = [
"memoffset", "memoffset",
] ]
[[package]]
name = "notify-rust"
version = "4.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe430c879247371c47fdedd328ad4e301e6b30b533c5fd5db1c06d75253d0e46"
dependencies = [
"futures-lite",
"log",
"mac-notification-sys",
"serde",
"tauri-winrt-notification",
"zbus 5.16.0",
]
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -4455,7 +4484,7 @@ dependencies = [
"tao-macros", "tao-macros",
"unicode-segmentation", "unicode-segmentation",
"url", "url",
"windows", "windows 0.61.3",
"windows-core 0.61.2", "windows-core 0.61.2",
"windows-version", "windows-version",
"x11-dl", "x11-dl",
@@ -4526,7 +4555,7 @@ dependencies = [
"webkit2gtk", "webkit2gtk",
"webview2-com", "webview2-com",
"window-vibrancy", "window-vibrancy",
"windows", "windows 0.61.3",
] ]
[[package]] [[package]]
@@ -4649,6 +4678,25 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "tauri-plugin-notification"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad2fd40946aef810c4be9fd33a2d1b9b397cb79042b2d21c81a0a8f204354fd1"
dependencies = [
"log",
"notify-rust",
"rand 0.9.4",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"time",
"url",
]
[[package]] [[package]]
name = "tauri-plugin-opener" name = "tauri-plugin-opener"
version = "2.5.4" version = "2.5.4"
@@ -4667,7 +4715,7 @@ dependencies = [
"tauri-plugin", "tauri-plugin",
"thiserror 2.0.18", "thiserror 2.0.18",
"url", "url",
"windows", "windows 0.61.3",
"zbus 5.16.0", "zbus 5.16.0",
] ]
@@ -4724,7 +4772,7 @@ dependencies = [
"url", "url",
"webkit2gtk", "webkit2gtk",
"webview2-com", "webview2-com",
"windows", "windows 0.61.3",
] ]
[[package]] [[package]]
@@ -4749,7 +4797,7 @@ dependencies = [
"url", "url",
"webkit2gtk", "webkit2gtk",
"webview2-com", "webview2-com",
"windows", "windows 0.61.3",
"wry", "wry",
] ]
@@ -4802,6 +4850,17 @@ dependencies = [
"toml 1.1.2+spec-1.1.0", "toml 1.1.2+spec-1.1.0",
] ]
[[package]]
name = "tauri-winrt-notification"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f37a6c354fd28fc9e322ed9bd47e3959576dad28c9d58ea1cf888cce1c7ccb36"
dependencies = [
"thiserror 2.0.18",
"windows 0.62.2",
"windows-version",
]
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.27.0" version = "3.27.0"
@@ -5846,7 +5905,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
dependencies = [ dependencies = [
"webview2-com-macros", "webview2-com-macros",
"webview2-com-sys", "webview2-com-sys",
"windows", "windows 0.61.3",
"windows-core 0.61.2", "windows-core 0.61.2",
"windows-implement", "windows-implement",
"windows-interface", "windows-interface",
@@ -5870,7 +5929,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
"windows", "windows 0.61.3",
"windows-core 0.61.2", "windows-core 0.61.2",
] ]
@@ -5926,11 +5985,23 @@ version = "0.61.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
dependencies = [ dependencies = [
"windows-collections", "windows-collections 0.2.0",
"windows-core 0.61.2", "windows-core 0.61.2",
"windows-future", "windows-future 0.2.1",
"windows-link 0.1.3", "windows-link 0.1.3",
"windows-numerics", "windows-numerics 0.2.0",
]
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections 0.3.2",
"windows-core 0.62.2",
"windows-future 0.3.2",
"windows-numerics 0.3.1",
] ]
[[package]] [[package]]
@@ -5942,6 +6013,15 @@ dependencies = [
"windows-core 0.61.2", "windows-core 0.61.2",
] ]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core 0.62.2",
]
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.61.2" version = "0.61.2"
@@ -5976,7 +6056,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [ dependencies = [
"windows-core 0.61.2", "windows-core 0.61.2",
"windows-link 0.1.3", "windows-link 0.1.3",
"windows-threading", "windows-threading 0.1.0",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core 0.62.2",
"windows-link 0.2.1",
"windows-threading 0.2.1",
] ]
[[package]] [[package]]
@@ -6023,6 +6114,16 @@ dependencies = [
"windows-link 0.1.3", "windows-link 0.1.3",
] ]
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core 0.62.2",
"windows-link 0.2.1",
]
[[package]] [[package]]
name = "windows-registry" name = "windows-registry"
version = "0.6.1" version = "0.6.1"
@@ -6172,6 +6273,15 @@ dependencies = [
"windows-link 0.1.3", "windows-link 0.1.3",
] ]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link 0.2.1",
]
[[package]] [[package]]
name = "windows-version" name = "windows-version"
version = "0.1.7" version = "0.1.7"
@@ -6491,7 +6601,7 @@ dependencies = [
"webkit2gtk", "webkit2gtk",
"webkit2gtk-sys", "webkit2gtk-sys",
"webview2-com", "webview2-com",
"windows", "windows 0.61.3",
"windows-core 0.61.2", "windows-core 0.61.2",
"windows-version", "windows-version",
"x11-dl", "x11-dl",
+310 -1
View File
@@ -8,6 +8,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::future::Future; use std::future::Future;
use std::pin::Pin; use std::pin::Pin;
use std::sync::{LazyLock, RwLock};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
@@ -40,10 +41,226 @@ pub struct AiTool {
pub definition: ToolDefinition, pub definition: ToolDefinition,
/// 风险级别 /// 风险级别
pub risk_level: RiskLevel, pub risk_level: RiskLevel,
/// 工具元数据(DRY 债 #5 收敛:注册点声明,消费方按条目或全局表查询,不再散布 match 表)
pub meta: ToolMeta,
/// 执行处理器 /// 执行处理器
pub handler: AiToolHandler, 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<GoalMeta>,
/// 漂移检测签名元数据
pub sig: Option<SigMeta>,
}
/// 进程级工具元数据表。register_meta 写入(注册点 = 唯一写入口),
/// path_auth / cache / agentic helpers 等无 registry 句柄的自由函数按名查询。
static GLOBAL_TOOL_META: LazyLock<RwLock<HashMap<String, ToolMeta>>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
/// 按工具名查元数据(查全局表,未注册返回 None)
pub fn tool_meta(name: &str) -> Option<ToolMeta> {
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<GoalMeta> {
tool_meta(name).and_then(|m| m.goal)
}
/// 漂移检测签名元数据(未注册返回 None,调用方走全参兜底)
pub fn sig_of(name: &str) -> Option<SigMeta> {
tool_meta(name).and_then(|m| m.sig)
}
/// 按路径参数键从 args 提取路径列表(键序即提取序,缺失键跳过)
pub fn extract_paths(keys: &'static [&'static str], args: &Value) -> Vec<String> {
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<String> {
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::<Vec<_>>()
.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 结果 /// 工具处理器类型 — 异步函数,接收 JSON 参数,返回 JSON 结果
pub type AiToolHandler = pub type AiToolHandler =
Box<dyn Fn(Value) -> Pin<Box<dyn Future<Output = anyhow::Result<Value>> + Send>> + Send + Sync>; Box<dyn Fn(Value) -> Pin<Box<dyn Future<Output = anyhow::Result<Value>> + Send>> + Send + Sync>;
@@ -65,7 +282,7 @@ impl AiToolRegistry {
} }
} }
/// 注册一个工具 /// 注册一个工具(无元数据,ToolMeta::default)
pub fn register( pub fn register(
&mut self, &mut self,
name: impl Into<String>, name: impl Into<String>,
@@ -73,19 +290,47 @@ impl AiToolRegistry {
parameters: Value, parameters: Value,
risk_level: RiskLevel, risk_level: RiskLevel,
handler: AiToolHandler, 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<String>,
description: impl Into<String>,
parameters: Value,
risk_level: RiskLevel,
meta: ToolMeta,
handler: AiToolHandler,
) { ) {
let name_str = name.into(); let name_str = name.into();
let definition = ToolDefinition::function(&name_str, description, parameters); let definition = ToolDefinition::function(&name_str, description, parameters);
GLOBAL_TOOL_META
.write()
.expect("全局工具元数据表锁")
.insert(name_str.clone(), meta);
self.tools.insert( self.tools.insert(
name_str, name_str,
AiTool { AiTool {
definition, definition,
risk_level, risk_level,
meta,
handler, handler,
}, },
); );
} }
/// 查询工具元数据条目(持有 registry 实例的消费方用)
pub fn meta(&self, name: &str) -> Option<ToolMeta> {
self.tools.get(name).map(|t| t.meta)
}
/// 获取所有工具定义(发送给 LLM 的 tools 参数) /// 获取所有工具定义(发送给 LLM 的 tools 参数)
pub fn tool_definitions(&self) -> Vec<ToolDefinition> { pub fn tool_definitions(&self) -> Vec<ToolDefinition> {
self.tools.values().map(|t| t.definition.clone()).collect() self.tools.values().map(|t| t.definition.clone()).collect()
@@ -236,4 +481,68 @@ mod tests {
assert_eq!(required.len(), 1); assert_eq!(required.len(), 1);
assert_eq!(required[0], "name"); 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="));
}
} }
+69 -4
View File
@@ -54,16 +54,47 @@
/// - `$name`: 工具名 `&str`。 /// - `$name`: 工具名 `&str`。
/// - `$desc`: 工具描述 `&str`(发给 LLM)。 /// - `$desc`: 工具描述 `&str`(发给 LLM)。
/// - `$risk`: `RiskLevel`(如 `RiskLevel::Low`)。 /// - `$risk`: `RiskLevel`(如 `RiskLevel::Low`)。
/// - `meta: $meta`(可选段): `ToolMeta` 字面量(DRY 债 #5)。带此段走 `register_meta` 落
/// 条目 + 全局元数据表;不带则 default 元数据(全空,消费方走保守 default 分支)。
/// - `$schema`: 参数 JSON Schema(`serde_json::Value`),常用 `object_schema(...)`。 /// - `$schema`: 参数 JSON Schema(`serde_json::Value`),常用 `object_schema(...)`。
/// - `$handler`: 一个 **block 表达式**(花括号体),返回 `anyhow::Result<serde_json::Value>`。 /// - `$handler`: 一个 **block 表达式**(花括号体),返回 `anyhow::Result<serde_json::Value>`。
/// 体内在 `$args`(serde_json::Value)与 `$capture`(克隆后的捕获变量)上工作。 /// 体内在 `$args`(serde_json::Value)与 `$capture`(克隆后的捕获变量)上工作。
/// 宏负责把它包进 `async move { ... }` 并 `Box::pin`——故调用方只写「同步语义的体」, /// 宏负责把它包进 `async move { ... }` 并 `Box::pin`——故调用方只写「同步语义的体」,
/// 不写 `async move`/`Box::pin`/`Box::new` 三层样板。 /// 不写 `async move`/`Box::pin`/`Box::new` 三层样板。
/// ///
/// 注意:此宏不替代 `register`,而是包装它——`register` 仍是 `AiToolRegistry` 的唯一注册入口, /// 注意:此宏不替代 `register`,而是包装它——`register`/`register_meta` 仍是
/// 宏仅是语法糖。现有 48 个手写 `register` 调用不动,新工具改用 `declare_tool!`。 /// `AiToolRegistry` 的唯一注册入口,宏仅是语法糖。
#[macro_export] #[macro_export]
macro_rules! declare_tool { 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<Output = ::anyhow::Result<::serde_json::Value>>
+ ::std::marker::Send,
>>
})
},
);
}};
( (
$registry:expr, $registry:expr,
$capture:ident : $cap_ty:ty, $capture:ident : $cap_ty:ty,
@@ -74,8 +105,8 @@ macro_rules! declare_tool {
$args:ident => $handler:block $args:ident => $handler:block
) => {{ ) => {{
let __cap: $cap_ty = $capture.clone(); let __cap: $cap_ty = $capture.clone();
$registry.register( $registry.register_meta(
$name, $desc, $schema, $risk, $name, $desc, $schema, $risk, $crate::ai_tools::ToolMeta::default(),
{ {
let $capture = __cap.clone(); let $capture = __cap.clone();
::std::boxed::Box::new(move |$args: ::serde_json::Value| { ::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(); let out = reg.execute("const_tool", serde_json::json!({})).await.unwrap();
assert_eq!(out["ok"], true); 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]);
}
} }
+176 -7
View File
@@ -43,8 +43,9 @@ use crate::anthropic_helpers::{
/// API 网关——**cache_control 字段可能被非官方网关拒收**,故必须默认关、用户对支持的端点显式开启。 /// API 网关——**cache_control 字段可能被非官方网关拒收**,故必须默认关、用户对支持的端点显式开启。
/// ///
/// 开关开:`convert_request` 把 system 包成 blocks 数组并附加 `cache_control:{type:ephemeral}` /// 开关开:`convert_request` 把 system 包成 blocks 数组并附加 `cache_control:{type:ephemeral}`
/// (配合上层把易变段挪出 system,让 system 稳定段真正命中缓存)。 /// (配合上层把易变段挪出 system,让 system 稳定段真正命中缓存),
/// 开关关:system 保持纯字符串(现状行为,零风险兼容网关)。 /// 并在历史段稳定边界补消息级断点(使 system 之后的对话历史也可命中)。
/// 开关关:system 保持纯字符串、消息不打断点(现状行为,零风险兼容网关)。
/// ///
/// 进程级全局而非 per-provider:上层(AppState settings)按「用户对当前启用的 anthropic 端点 /// 进程级全局而非 per-provider:上层(AppState settings)按「用户对当前启用的 anthropic 端点
/// 是否支持缓存」一次性开启,所有 anthropic 请求一致生效。默认关。 /// 是否支持缓存」一次性开启,所有 anthropic 请求一致生效。默认关。
@@ -239,6 +240,12 @@ impl AnthropicCompatProvider {
// (正常会话首条本就是 user),远优于丢弃上下文。 // (正常会话首条本就是 user),远优于丢弃上下文。
Self::ensure_leading_user(&mut messages); Self::ensure_leading_user(&mut messages);
// 消息级缓存断点(开关开):除 system 断点外在历史段尾部再打一个 cache_control,
// 使 system 之后的对话历史也可命中(否则只缓存 system,长会话历史段全价重算)。
if anthropic_cache_enabled() {
Self::apply_message_cache_breakpoint(&mut messages);
}
let tools = req.tools.map(|defs| { let tools = req.tools.map(|defs| {
defs.into_iter() defs.into_iter()
.map(|d| AnthropicToolDef { .map(|d| AnthropicToolDef {
@@ -316,6 +323,45 @@ impl AnthropicCompatProvider {
messages.push(serde_json::json!({ "role": "user", "content": blocks })); messages.push(serde_json::json!({ "role": "user", "content": blocks }));
} }
/// 消息级缓存断点:在「历史段稳定边界」消息的末个 content block 上打 cache_control。
///
/// 断点落点取**末条 user 消息之前的相邻前一条**(末条非 user 收尾的异常序列则取末条):
/// 末条 user 是每轮请求的漂移边界——缓存拆分模式下它是请求末尾追加的易变尾段
/// (日期/清单/augmentation,每次重组),非拆分模式下是本轮新问题,二者都不属于
/// 只增历史;打在它身上断点位置随请求漂移,永远无法命中。标其前一条(assistant /
/// tool_result 归并的 user)则断点始终落在只增历史的末尾,历史只增不改位:
/// 上一轮写入的缓存前缀是本轮请求前缀的逐字节前缀 → 命中后增量续写,滚动前进。
///
/// content 形态:string(简写)→ 升级为单 text block 数组再标;array(blocks)→ 标末块。
/// 断点预算:Anthropic 单请求最多 4 个 cache_control,system 1 个 + 消息级 1 个,余量充足。
fn apply_message_cache_breakpoint(messages: &mut [serde_json::Value]) {
let n = messages.len();
if n == 0 {
return;
}
let last_is_user =
messages[n - 1].get("role").and_then(|r| r.as_str()) == Some("user");
let idx = if last_is_user && n >= 2 { n - 2 } else { n - 1 };
let msg = &mut messages[idx];
let cache_control = serde_json::json!({ "type": "ephemeral" });
match msg.get("content").cloned() {
Some(serde_json::Value::Array(mut blocks)) => {
if let Some(last_block) = blocks.last_mut() {
last_block["cache_control"] = cache_control;
}
msg["content"] = serde_json::Value::Array(blocks);
}
Some(serde_json::Value::String(text)) => {
msg["content"] = serde_json::json!([{
"type": "text",
"text": text,
"cache_control": cache_control,
}]);
}
_ => {}
}
}
/// 合并相邻 user 消息为一条(content 拼成 blocks 数组)。 /// 合并相邻 user 消息为一条(content 拼成 blocks 数组)。
/// Anthropic 协议要求 user/assistant 严格交替,连续 user 触发 1214。 /// Anthropic 协议要求 user/assistant 严格交替,连续 user 触发 1214。
/// 触发场景:flush_tool_results 把 tool_result 转 user 后紧跟新 user(drainQueue 续发, /// 触发场景:flush_tool_results 把 tool_result 转 user 后紧跟新 user(drainQueue 续发,
@@ -554,7 +600,9 @@ impl LlmProvider for AnthropicCompatProvider {
source = ?std::error::Error::source(&e), source = ?std::error::Error::source(&e),
"Anthropic 同步 send 失败" "Anthropic 同步 send 失败"
); );
if is_reqwest_error_retryable(&e) { // is_decode 补可重试:body 中途断连在 reqwest 归 decode 类,complete 重发
// 幂等安全(受 3 次/30s 预算约束,真正格式错重试同败,预算内止损)。
if is_reqwest_error_retryable(&e) || e.is_decode() {
return AttemptOutcome::Retryable(format!("请求失败(可重试): {}", e)); return AttemptOutcome::Retryable(format!("请求失败(可重试): {}", e));
} }
return AttemptOutcome::Fatal(format!( return AttemptOutcome::Fatal(format!(
@@ -580,10 +628,17 @@ impl LlmProvider for AnthropicCompatProvider {
} }
let resp: AnthropicResponse = match resp.json().await { let resp: AnthropicResponse = match resp.json().await {
Ok(r) => r, Ok(r) => r,
Err(e) => return AttemptOutcome::Fatal(format!("响应解析失败: {}", e)), // serde 结构错属 Fatal(重试同败);body 读取中途断连同归 is_decode,可重试。
Err(e) => {
if e.is_decode() {
return AttemptOutcome::Retryable(format!("响应解码失败(可重试): {}", e));
}
return AttemptOutcome::Fatal(format!("响应解析失败: {}", e));
}
}; };
// content 块中拼接 text,收集 tool_use // content 块中拼接 text,收集 tool_use,累积 thinking
let mut text = String::new(); let mut text = String::new();
let mut reasoning = String::new();
let mut tool_calls: Vec<ToolCall> = Vec::new(); let mut tool_calls: Vec<ToolCall> = Vec::new();
// CR-空 id:按 tool_use 块在数组中的顺序计数(仅 tool_use 递增),用于 fallback index。 // CR-空 id:按 tool_use 块在数组中的顺序计数(仅 tool_use 递增),用于 fallback index。
// 用独立计数器而非 for enumerate,避免 text/unknown 块占用 index 致 fallback 编号跳号。 // 用独立计数器而非 for enumerate,避免 text/unknown 块占用 index 致 fallback 编号跳号。
@@ -595,6 +650,15 @@ impl LlmProvider for AnthropicCompatProvider {
text.push_str(&t); text.push_str(&t);
} }
} }
"thinking" => {
// thinking 块正文回填 reasoning_content 通道(与 DeepSeek 同路入库展示)。
// 仅入库与展示,绝不回传 anthropic 历史(丢弃 signature 的伪造回传必 400)。
if let Some(t) = block.thinking {
reasoning.push_str(&t);
}
}
// redacted_thinking 为端点侧密文块,不可解析也不可伪造回传,合规丢弃不告警
"redacted_thinking" => {}
"tool_use" => { "tool_use" => {
// CR-空 id:原逻辑空 id 直接 continue 跳过整个块(丢工具调用)。 // CR-空 id:原逻辑空 id 直接 continue 跳过整个块(丢工具调用)。
// 改为兜底:id 非空原样,空 → `gen_anthropic_{idx}` fallback(DRY 共用 // 改为兜底:id 非空原样,空 → `gen_anthropic_{idx}` fallback(DRY 共用
@@ -634,7 +698,8 @@ impl LlmProvider for AnthropicCompatProvider {
model: resp.model, model: resp.model,
usage, usage,
tool_calls: if tool_calls.is_empty() { None } else { Some(tool_calls) }, tool_calls: if tool_calls.is_empty() { None } else { Some(tool_calls) },
reasoning_content: None, // 无 thinking 块时保持 None(语义与 DeepSeek 路径一致,空串不落库)
reasoning_content: if reasoning.is_empty() { None } else { Some(reasoning) },
}) })
} }
}) })
@@ -668,17 +733,24 @@ impl LlmProvider for AnthropicCompatProvider {
let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await { let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await {
Ok(Ok(r)) => r, Ok(Ok(r)) => r,
Ok(Err(e)) => { Ok(Err(e)) => {
// 谓词结构化分类(对齐 openai 路径,共用 crate::send_error_class)。原 bail 串
// [timeout={} connect={}=...] 的字面词让一切错误(含 builder 错)侥幸可重试。
// 类词改由谓词决定置串首;谓词转储缩写为 t/c/b/r 防误触分类词表,source 链
// 保留真实传输词(reset/broken pipe 等)作串兜底。
let class = crate::send_error_class(crate::SendErrPreds::of(&e));
tracing::error!( tracing::error!(
is_timeout = e.is_timeout(), is_timeout = e.is_timeout(),
is_connect = e.is_connect(), is_connect = e.is_connect(),
is_body = e.is_body(), is_body = e.is_body(),
is_request = e.is_request(), is_request = e.is_request(),
class = class,
url = %self.messages_url(), url = %self.messages_url(),
source = ?std::error::Error::source(&e), source = ?std::error::Error::source(&e),
"Anthropic 流式 send 失败" "Anthropic 流式 send 失败"
); );
anyhow::bail!( anyhow::bail!(
"send 失败: {} [timeout={} connect={} body={} request={} src={:?}]", "send 失败({}): {} [t={} c={} b={} r={} src={:?}]",
class,
e, e,
e.is_timeout(), e.is_timeout(),
e.is_connect(), e.is_connect(),
@@ -752,6 +824,14 @@ mod tests {
use super::*; use super::*;
// apply_anthropic_event / TokenUsage 经 super::*(含 anthropic_helpers::apply_anthropic_event 的 use)可见。 // apply_anthropic_event / TokenUsage 经 super::*(含 anthropic_helpers::apply_anthropic_event 的 use)可见。
/// convert_request 读取进程级缓存开关(全局 AtomicBool)。开关切换用例的「开」窗口内,
/// 并行运行的普通转换用例会带上缓存形态(system blocks / 消息级断点)致断言互踩。
/// 所有走 convert_request 的用例统一先取本锁串行化,消除进程级状态的用例间污染。
static CONVERT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn convert_lock() -> std::sync::MutexGuard<'static, ()> {
CONVERT_LOCK.lock().unwrap_or_else(|p| p.into_inner())
}
/// 辅助:构造 message_start 事件 /// 辅助:构造 message_start 事件
fn message_start(input_tokens: u32) -> String { fn message_start(input_tokens: u32) -> String {
format!( format!(
@@ -894,6 +974,31 @@ mod tests {
assert!(!c.finished); assert!(!c.finished);
} }
/// thinking_delta → reasoning_content 增量(不混入正文 delta);signature_delta 安静忽略。
/// 推理正文走 reasoning_content 通道与 DeepSeek 同路入库展示。
#[test]
fn anthropic_thinking_delta_accumulates_reasoning() {
let mut acc: Option<TokenUsage> = None;
let think1 = r#"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"先查"}}"#;
let c1 = apply_anthropic_event(think1, &mut acc);
assert_eq!(c1.reasoning_content.as_deref(), Some("先查"), "thinking_delta 应产出 reasoning_content");
assert_eq!(c1.delta, "", "思考增量不得混入正文 delta");
assert!(!c1.finished);
let think2 = r#"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"用户意图"}}"#;
let c2 = apply_anthropic_event(think2, &mut acc);
assert_eq!(c2.reasoning_content.as_deref(), Some("用户意图"));
// signature_delta:thinking 块签名字段,本侧无处消费,安静跳过(空 chunk 不告警)
let sig = r#"{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig-abc"}}"#;
let c3 = apply_anthropic_event(sig, &mut acc);
assert!(c3.reasoning_content.is_none());
assert!(c3.delta.is_empty());
assert!(!c3.finished);
// 思考块之后正文照常
let c4 = apply_anthropic_event(&text_delta("答案"), &mut acc);
assert_eq!(c4.delta, "答案");
assert!(c4.reasoning_content.is_none());
}
/// error 事件 → error=Some + finished=false避免残缺响应被当正常完成入库, /// error 事件 → error=Some + finished=false避免残缺响应被当正常完成入库,
/// 由 stream_llm 识别 error 非空发 AiError + 丢弃残缺,与 OpenAI 路径 Err 一致) /// 由 stream_llm 识别 error 非空发 AiError + 丢弃残缺,与 OpenAI 路径 Err 一致)
#[test] #[test]
@@ -945,6 +1050,7 @@ mod tests {
/// 含图 user 消息 → content blocks(text + image source.base64) /// 含图 user 消息 → content blocks(text + image source.base64)
#[test] #[test]
fn anthropic_convert_multimodal_user_blocks() { fn anthropic_convert_multimodal_user_blocks() {
let _convert = convert_lock();
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet"); let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest { let req = CompletionRequest {
model: "claude-3-5-sonnet".into(), model: "claude-3-5-sonnet".into(),
@@ -980,6 +1086,7 @@ mod tests {
/// 纯文本 user 消息 → content 仍是字符串简写(无图不数组化) /// 纯文本 user 消息 → content 仍是字符串简写(无图不数组化)
#[test] #[test]
fn anthropic_convert_text_only_user_remains_string() { fn anthropic_convert_text_only_user_remains_string() {
let _convert = convert_lock();
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet"); let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest { let req = CompletionRequest {
model: "claude-3-5-sonnet".into(), model: "claude-3-5-sonnet".into(),
@@ -1001,6 +1108,62 @@ mod tests {
assert_eq!(user_msg.get("content").and_then(|c| c.as_str()), Some("hello")); assert_eq!(user_msg.get("content").and_then(|c| c.as_str()), Some("hello"));
} }
// ---------- 消息级缓存断点(apply_message_cache_breakpoint,纯函数直测) ----------
// 注:不开全局 ANTHROPIC_CACHE_ENABLED(convert_request 层开关默认关,已有断言不受扰),
// 直接测纯函数落点,避免进程级开关与并行测试互踩。
/// 末条 user(漂移边界:易变尾段/本轮新问题)不打断点,标其前一条稳定历史消息。
#[test]
fn cache_breakpoint_marks_message_before_trailing_user() {
let mut messages = vec![
serde_json::json!({ "role": "user", "content": "q1" }),
serde_json::json!({ "role": "assistant", "content": [{"type": "text", "text": "a1"}] }),
serde_json::json!({ "role": "user", "content": "q2" }),
];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages);
// 前一条 assistant 的末块带 cache_control
let a1 = &messages[1];
let blocks = a1["content"].as_array().unwrap();
assert_eq!(blocks.last().unwrap()["cache_control"]["type"], "ephemeral");
// 末条 user 保持字符串简写,未被打点
assert_eq!(messages[2]["content"].as_str(), Some("q2"));
}
/// string content 升级为单 text block 数组并附带 cache_control。
#[test]
fn cache_breakpoint_upgrades_string_content_to_blocks() {
let mut messages = vec![
serde_json::json!({ "role": "user", "content": "q1" }),
serde_json::json!({ "role": "assistant", "content": "a1" }),
serde_json::json!({ "role": "user", "content": "q2" }),
];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages);
let blocks = messages[1]["content"].as_array().expect("string 应升级为 blocks 数组");
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0]["type"], "text");
assert_eq!(blocks[0]["text"], "a1");
assert_eq!(blocks[0]["cache_control"]["type"], "ephemeral");
}
/// 单条消息(无前一条可标)→ 标自身,不 panic。
#[test]
fn cache_breakpoint_single_message_marks_itself() {
let mut messages = vec![
serde_json::json!({ "role": "user", "content": "only" }),
];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages);
let blocks = messages[0]["content"].as_array().expect("单条应升级为 blocks");
assert_eq!(blocks[0]["cache_control"]["type"], "ephemeral");
}
/// 空 messages 不 panic。
#[test]
fn cache_breakpoint_empty_messages_noop() {
let mut messages: Vec<serde_json::Value> = vec![];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages);
assert!(messages.is_empty());
}
// ---------- ensure_leading_user(首条 assistant → 补 user 占位,保留上下文)---------- // ---------- ensure_leading_user(首条 assistant → 补 user 占位,保留上下文)----------
/// 辅助:构造 assistant(tool_use) 消息 /// 辅助:构造 assistant(tool_use) 消息
@@ -1016,6 +1179,7 @@ mod tests {
/// (原"砍"策略会把每对三元组砍掉,多轮砍到空,丢失全部工具调用历史——"补"策略零丢失。) /// (原"砍"策略会把每对三元组砍掉,多轮砍到空,丢失全部工具调用历史——"补"策略零丢失。)
#[test] #[test]
fn anthropic_ensure_leading_user_tool_use_chain_preserves_context() { fn anthropic_ensure_leading_user_tool_use_chain_preserves_context() {
let _convert = convert_lock();
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet"); let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest { let req = CompletionRequest {
model: "claude-3-5-sonnet".into(), model: "claude-3-5-sonnet".into(),
@@ -1054,6 +1218,7 @@ mod tests {
/// 首条 assistant 无 tool_use → 补占位,首条 user,原上下文保留。 /// 首条 assistant 无 tool_use → 补占位,首条 user,原上下文保留。
#[test] #[test]
fn anthropic_ensure_leading_user_plain_assistant() { fn anthropic_ensure_leading_user_plain_assistant() {
let _convert = convert_lock();
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet"); let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest { let req = CompletionRequest {
model: "claude-3-5-sonnet".into(), model: "claude-3-5-sonnet".into(),
@@ -1080,6 +1245,7 @@ mod tests {
/// 正常序列(user 开头)不补占位——零回归验证。 /// 正常序列(user 开头)不补占位——零回归验证。
#[test] #[test]
fn anthropic_ensure_leading_user_normal_sequence_unchanged() { fn anthropic_ensure_leading_user_normal_sequence_unchanged() {
let _convert = convert_lock();
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet"); let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest { let req = CompletionRequest {
model: "claude-3-5-sonnet".into(), model: "claude-3-5-sonnet".into(),
@@ -1107,6 +1273,7 @@ mod tests {
/// 补一个 user 占位后全部保留,验证多轮链不丢数据、precheck 通过(原"砍"策略此场景砍到空)。 /// 补一个 user 占位后全部保留,验证多轮链不丢数据、precheck 通过(原"砍"策略此场景砍到空)。
#[test] #[test]
fn anthropic_ensure_leading_user_three_round_chain() { fn anthropic_ensure_leading_user_three_round_chain() {
let _convert = convert_lock();
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet"); let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest { let req = CompletionRequest {
model: "claude-3-5-sonnet".into(), model: "claude-3-5-sonnet".into(),
@@ -1144,6 +1311,7 @@ mod tests {
/// 避免发空 messages 触发 precheck "messages 为空"(降级让会话能继续)。 /// 避免发空 messages 触发 precheck "messages 为空"(降级让会话能继续)。
#[test] #[test]
fn anthropic_ensure_leading_user_empty_messages_gets_placeholder() { fn anthropic_ensure_leading_user_empty_messages_gets_placeholder() {
let _convert = convert_lock();
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet"); let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest { let req = CompletionRequest {
model: "claude-3-5-sonnet".into(), model: "claude-3-5-sonnet".into(),
@@ -1173,6 +1341,7 @@ mod tests {
/// (两态共用进程级全局开关,拆两个用例并行跑会互相污染——单测内顺序切换断言,末尾复位。) /// (两态共用进程级全局开关,拆两个用例并行跑会互相污染——单测内顺序切换断言,末尾复位。)
#[test] #[test]
fn anthropic_cache_switch_controls_system_shape() { fn anthropic_cache_switch_controls_system_shape() {
let _convert = convert_lock();
set_anthropic_cache_enabled(false); // 兜底复位,防污染其他并行用例 set_anthropic_cache_enabled(false); // 兜底复位,防污染其他并行用例
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet"); let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
+19 -2
View File
@@ -72,13 +72,16 @@ pub(crate) struct AnthropicResponse {
pub usage: AnthropicUsage, pub usage: AnthropicUsage,
} }
/// 响应 content 块(text 或 tool_use) /// 响应 content 块(text / tool_use / thinking)
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct AnthropicContentBlock { pub(crate) struct AnthropicContentBlock {
#[serde(rename = "type")] #[serde(rename = "type")]
pub block_type: String, pub block_type: String,
#[serde(default)] #[serde(default)]
pub text: Option<String>, pub text: Option<String>,
/// thinking 块推理正文(端点侧开启扩展思考时出现;本侧请求未启用 thinking,一般缺省)
#[serde(default)]
pub thinking: Option<String>,
/// tool_use 块字段 /// tool_use 块字段
pub id: Option<String>, pub id: Option<String>,
pub name: Option<String>, pub name: Option<String>,
@@ -119,7 +122,8 @@ pub(crate) const DEFAULT_MAX_TOKENS: u32 = 4096;
/// 按 `type` 字段分发: /// 按 `type` 字段分发:
/// - `message_start` → 用 `message.usage.input_tokens` 初始化累加器(output 置 0)。 /// - `message_start` → 用 `message.usage.input_tokens` 初始化累加器(output 置 0)。
/// - `message_delta` → **output_tokens 是累计值(非增量)**,直接覆盖 `completion_tokens` 并重算 `total`。 /// - `message_delta` → **output_tokens 是累计值(非增量)**,直接覆盖 `completion_tokens` 并重算 `total`。
/// - `content_block_delta` (text_delta/input_json_delta) → 文本/工具入参增量。 /// - `content_block_delta` (text_delta/input_json_delta/thinking_delta) → 文本/工具入参/思考增量,
/// signature_delta 安静忽略。
/// - `content_block_start` (tool_use) → 工具块开始,带 id+name。 /// - `content_block_start` (tool_use) → 工具块开始,带 id+name。
/// - `message_stop` → 返回 `finished=true` 终态 chunk,`usage` 取自累加器(`take()`)。 /// - `message_stop` → 返回 `finished=true` 终态 chunk,`usage` 取自累加器(`take()`)。
/// - `error` → 返回 `finished=true` 终态空 chunk。 /// - `error` → 返回 `finished=true` 终态空 chunk。
@@ -182,6 +186,17 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
// 文本增量 // 文本增量
"content_block_delta" => { "content_block_delta" => {
if let Some(delta) = v.get("delta") { if let Some(delta) = v.get("delta") {
// thinking 增量:推理正文走 reasoning_content 通道(与 DeepSeek 同路入库展示),
// 不混入正文 delta。本侧请求未启用 thinking,此分支只为不丢端点侧自行产出的思考。
if delta.get("type").and_then(|t| t.as_str()) == Some("thinking_delta") {
let text = delta.get("thinking").and_then(|t| t.as_str()).unwrap_or("").to_string();
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: Some(text) };
}
// signature_delta:thinking 块签名字段,仅回传校验用。DevFlow 从不把 thinking 回传
// anthropic 历史(丢弃 signature 后伪造回传必 400),签名无处消费,安静忽略。
if delta.get("type").and_then(|t| t.as_str()) == Some("signature_delta") {
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None };
}
if delta.get("type").and_then(|t| t.as_str()) == Some("text_delta") { if delta.get("type").and_then(|t| t.as_str()) == Some("text_delta") {
let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string(); let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string();
return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }; return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None };
@@ -209,6 +224,8 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
} }
// 工具块开始:带 id + name // 工具块开始:带 id + name
"content_block_start" => { "content_block_start" => {
// thinking / redacted_thinking 块起点无正文要记(正文随后经 thinking_delta 流出;
// redacted_thinking 为密文不可解析),落入末尾统一空 chunk 即安静跳过,不告警。
if let Some(cb) = v.get("content_block") { if let Some(cb) = v.get("content_block") {
if cb.get("type").and_then(|t| t.as_str()) == Some("tool_use") { if cb.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32; let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32;
+84 -2
View File
@@ -618,11 +618,24 @@ impl ContextManager {
/// 也只扣首次标记的 token。返回的 Vec 仅含**本次新标记**的消息(已 compressed 的不返)。 /// 也只扣首次标记的 token。返回的 Vec 仅含**本次新标记**的消息(已 compressed 的不返)。
/// ///
/// **单向不可逆**:压缩后 DB 原始消息保留,但 LLM 上下文里被 is_active 白名单隔离 /// **单向不可逆**:压缩后 DB 原始消息保留,但 LLM 上下文里被 is_active 白名单隔离
/// (sanitize step0 过滤)。`compress_end` 越界自动 clamp 到 `messages.len()`。 /// (sanitize step0 过滤)。`compress_end` 越界自动 clamp 到 `messages.len()`,且实际
/// 压缩边界会回退到最近的对话单元边界(三元组不劈开,见方法内注释)。
/// ///
/// 返回空 Vec 表示本批次无可压缩消息(全部已 compressed 或范围空),调用方据此跳过 LLM 调用。 /// 返回空 Vec 表示本批次无可压缩消息(全部已 compressed 或范围空),调用方据此跳过 LLM 调用。
pub fn compress_old_messages(&mut self, compress_end: usize) -> Vec<ChatMessage> { pub fn compress_old_messages(&mut self, compress_end: usize) -> Vec<ChatMessage> {
let end = compress_end.min(self.messages.len()); // 压缩边界按对话单元(三元组)对齐:调用方传入的 end(如 len - PROTECT_COUNT)周期性
// 落在单元中间(assistant 头与其 tool 结果之间),只压半个三元组会让保护区以孤立的
// 头或尾开头,成为 sanitize 每请求告警的确定性供给。按全长分组取「完整单元边界中
// 不超过 raw_end 的最大者」回退(只少压不多压,不侵占保护区);三元组要么整段压缩
// 要么整段保留。空范围/空历史无单元,取 0。
let raw_end = compress_end.min(self.messages.len());
let end = self
.build_eviction_units(self.messages.len())
.iter()
.map(|u| u.end)
.take_while(|&e| e <= raw_end)
.last()
.unwrap_or(0);
let mut newly_compressed = Vec::new(); let mut newly_compressed = Vec::new();
for t in self.messages[..end].iter_mut() { for t in self.messages[..end].iter_mut() {
if t.message.is_active() { if t.message.is_active() {
@@ -656,3 +669,72 @@ impl ContextManager {
#[cfg(test)] #[cfg(test)]
mod manager_tests; mod manager_tests;
#[cfg(test)]
mod compress_boundary_tests {
use super::*;
use crate::provider::ToolCall;
fn mgr() -> ContextManager {
ContextManager::new(ContextConfig {
max_tokens: 100_000,
..Default::default()
})
}
/// 构造 4 个周期轮次,每轮 [user, assistant(tool_calls), tool_result, assistant]:
/// user 独占一个单元,后三条(头+尾+紧随独立 assistant)合并为一个单元,
/// 单元边界落在 4/8/12/16。
fn periodic_rounds() -> ContextManager {
let mut m = mgr();
for r in 0..4 {
m.push(ChatMessage::user(&format!("问题 {}", r)));
m.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new(format!("c{}", r), "run_command", "{}")],
));
m.push(ChatMessage::tool_result(format!("c{}", r), "ok"));
m.push(ChatMessage::assistant("小结"));
}
m
}
#[test]
fn compress_end_mid_unit_retreats_to_unit_boundary() {
let mut m = periodic_rounds();
// len=16,调用方口径 len - PROTECT_COUNT(6) = 10,落在第 3 轮三元组中间
// (9 头 / 10 尾 / 11 独立)。旧行为会把头压掉却留下孤立的 tool 结果。
// 回退到完整单元边界 9:只多压前面的 user,不劈开三元组(只少压不多压)。
let compressed = m.compress_old_messages(10);
assert_eq!(compressed.len(), 9, "边界回退到完整单元边界 9");
let msgs = m.messages_mut();
for i in 9..16 {
assert!(msgs[i].message.is_active(), "第 {} 条应保持 active", i);
}
// 关键回归点:第 3 轮三元组(9 头 / 10 尾 / 11 独立)整体保持 active,头未被劈开
assert!(msgs[9].message.is_active(), "三元组头不应被单独压缩");
assert!(msgs[10].message.is_active(), "三元组尾不应残留 active 而头被压");
assert!(msgs[11].message.is_active());
assert_eq!(msgs[9].group, MessageGroup::ToolCallHead);
}
#[test]
fn compress_end_on_boundary_unchanged() {
let mut m = periodic_rounds();
// end=8 恰为单元边界,行为与对齐前一致(整 8 条压缩)。
let compressed = m.compress_old_messages(8);
assert_eq!(compressed.len(), 8);
let msgs = m.messages_mut();
assert!(msgs[..8].iter().all(|t| !t.message.is_active()));
assert!(msgs[8..].iter().all(|t| t.message.is_active()));
}
#[test]
fn compress_end_oversized_still_compresses_all() {
let mut m = periodic_rounds();
// 越界 clamp 到 len:全量单元分组后末单元 end=len,语义与改动前一致。
let compressed = m.compress_old_messages(999);
assert_eq!(compressed.len(), 16);
assert_eq!(m.history_tokens(), 0);
}
}
+5 -4
View File
@@ -142,7 +142,8 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
}) })
.collect(); .collect();
tracing::warn!( // debug 级:同份畸形历史每请求重复触发,降级防告警洪水;根治=ai_messages 配对清洗(DB 原始保留不动)
tracing::debug!(
full_drop_heads, full_drop_heads,
partial_rewrite_heads = partial_heads, partial_rewrite_heads = partial_heads,
orphaned_tool_results = orphaned_ids.len(), orphaned_tool_results = orphaned_ids.len(),
@@ -252,7 +253,7 @@ pub fn drop_reverse_orphans(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
} }
if healed > 0 || unidentifiable_kept > 0 { if healed > 0 || unidentifiable_kept > 0 {
tracing::warn!( tracing::debug!(
healed_orphan_tool_results = healed, healed_orphan_tool_results = healed,
unidentifiable_tool_results_kept = unidentifiable_kept, unidentifiable_tool_results_kept = unidentifiable_kept,
"history sanitized: healed orphan tool_results by inserting placeholder heads (view-only, persisted history untouched)" "history sanitized: healed orphan tool_results by inserting placeholder heads (view-only, persisted history untouched)"
@@ -331,7 +332,7 @@ pub fn assert_placeholder_pairing(
out.push(m); out.push(m);
} }
if healed > 0 { if healed > 0 {
tracing::warn!( tracing::debug!(
healed_orphan_tool_results = healed, healed_orphan_tool_results = healed,
"send-view exit: healed orphan tool_results by inserting TOOL_MISSING_PREFIX heads (view-only, persisted history untouched)" "send-view exit: healed orphan tool_results by inserting TOOL_MISSING_PREFIX heads (view-only, persisted history untouched)"
); );
@@ -425,7 +426,7 @@ pub fn ensure_sequence_legal(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
fixed.push(m); fixed.push(m);
} }
if skipped > 0 || merged > 0 { if skipped > 0 || merged > 0 {
tracing::warn!( tracing::debug!(
skipped, skipped,
merged, merged,
"序列修复:skip 开头非 user(含连带丢 orphan result)+ 合并连续同 role(view-only,避免 Anthropic/GLM 1214)" "序列修复:skip 开头非 user(含连带丢 orphan result)+ 合并连续同 role(view-only,避免 Anthropic/GLM 1214)"
+13 -4
View File
@@ -245,6 +245,9 @@ pub enum ToolDomain {
/// 命令执行:run_command(shell 命令)。基本盘成员:读写执行为 agent 基本能力 /// 命令执行:run_command(shell 命令)。基本盘成员:读写执行为 agent 基本能力
/// (2026-09-30 用户拍板,不受意图收敛限制),安全由执行层 validate_path/授权/RiskLevel 兜底 /// (2026-09-30 用户拍板,不受意图收敛限制),安全由执行层 validate_path/授权/RiskLevel 兜底
Exec, Exec,
/// 编排:spawn_agent 子代理派发。基本盘成员:委派是编排类基本能力,
/// 高置信意图收敛下不登记则工具对 LLM 不可见,安全由子代理侧白名单钳制兜底
Agent,
/// 网络:HTTP /// 网络:HTTP
Http, Http,
} }
@@ -288,6 +291,8 @@ impl ToolDomain {
// 2026-09-30 用户拍板读写执行为 agent 基本能力,不再仅 Debug 暴露; // 2026-09-30 用户拍板读写执行为 agent 基本能力,不再仅 Debug 暴露;
// 安全由执行层兜底(RiskLevel 审批/授权三道门/路径黑名单),藏工具≠安全。 // 安全由执行层兜底(RiskLevel 审批/授权三道门/路径黑名单),藏工具≠安全。
ToolDomain::Exec => &["run_command"], ToolDomain::Exec => &["run_command"],
// Agent domain:spawn_agent 单列,编排委派基本盘成员(子代理侧白名单钳制兜底安全)。
ToolDomain::Agent => &["spawn_agent"],
ToolDomain::Http => &["http_request"], ToolDomain::Http => &["http_request"],
// DataReadOnly:Data domain 的只读子集。 // DataReadOnly:Data domain 的只读子集。
// 源于 Code 意图收敛需求:用户在编码场景说"先 list_projects 看下项目结构" // 源于 Code 意图收敛需求:用户在编码场景说"先 list_projects 看下项目结构"
@@ -440,7 +445,8 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)>
/// 上游应走**全量 fallback**(即不过滤工具,交全量给 LLM)。 /// 上游应走**全量 fallback**(即不过滤工具,交全量给 LLM)。
/// ///
/// 设计(2026-09-30 起):基本盘 + 场景增量。 /// 设计(2026-09-30 起):基本盘 + 场景增量。
/// 基本盘 = File + Exec:读、写、执行命令是 agent 基本能力,不受意图收敛限制(用户拍板), /// 基本盘 = File + Exec + Agent:读、写、执行命令、委派子代理是 agent 基本能力,
/// 不受意图收敛限制(用户拍板),
/// 所有非空子集意图(Code/Debug/File/Project/Task/Idea/Search/Http)都可见。 /// 所有非空子集意图(Code/Debug/File/Project/Task/Idea/Search/Http)都可见。
/// 安全防线在执行层:validate_path 黑名单 + 授权三道门 + RiskLevel 审批,藏工具≠安全; /// 安全防线在执行层:validate_path 黑名单 + 授权三道门 + RiskLevel 审批,藏工具≠安全;
/// 藏工具只会复现断手(Project 砍 File 致 read_file 不可见、Debug 独占 run_command 致 /// 藏工具只会复现断手(Project 砍 File 致 read_file 不可见、Debug 独占 run_command 致
@@ -451,8 +457,9 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)>
/// (发请求是该意图本名能力,砍掉即复现断手)。 /// (发请求是该意图本名能力,砍掉即复现断手)。
/// 各意图新子集均为旧子集超集,只增不减。 /// 各意图新子集均为旧子集超集,只增不减。
pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> { pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> {
// 基本盘:读写执行(RiskLevel/审批/路径授权兜底,藏工具≠安全);场景增量=业务域收敛减少分心 // 基本盘:读写执行 + 编排委派(RiskLevel/审批/路径授权/子代理白名单钳制兜底,藏工具≠安全);
const BASE_DOMAINS: &[ToolDomain] = &[ToolDomain::File, ToolDomain::Exec]; // 场景增量=业务域收敛减少分心
const BASE_DOMAINS: &[ToolDomain] = &[ToolDomain::File, ToolDomain::Exec, ToolDomain::Agent];
let extra: &[ToolDomain] = match intent { let extra: &[ToolDomain] = match intent {
Intent::Code => &[ToolDomain::Http, ToolDomain::DataReadOnly], Intent::Code => &[ToolDomain::Http, ToolDomain::DataReadOnly],
Intent::Debug => &[ToolDomain::Http, ToolDomain::Data], Intent::Debug => &[ToolDomain::Http, ToolDomain::Data],
@@ -1266,9 +1273,10 @@ mod tests {
]; ];
for intent in all_intents { for intent in all_intents {
let subset = tool_subset_for(&intent); let subset = tool_subset_for(&intent);
// 全 registry 工具名(五 domain 并集:Data + DataReadOnly + File + Exec + Http) // 全 registry 工具名(六 domain 并集:Data + DataReadOnly + File + Exec + Http + Agent)
// 注:DataReadOnly 工具名是 Data 的子集,chain 它仅为语义显式(并集去重无副作用), // 注:DataReadOnly 工具名是 Data 的子集,chain 它仅为语义显式(并集去重无副作用),
// 防 Code subset 里 list_projects 等 DataReadOnly 工具被判"不在 registry"。 // 防 Code subset 里 list_projects 等 DataReadOnly 工具被判"不在 registry"。
// Agent(spawn_agent)是编排委派基本盘成员,子集合法出现,漏 chain 会误报。
let registry: std::collections::HashSet<&str> = ToolDomain::Data let registry: std::collections::HashSet<&str> = ToolDomain::Data
.tools() .tools()
.iter() .iter()
@@ -1276,6 +1284,7 @@ mod tests {
.chain(ToolDomain::File.tools().iter()) .chain(ToolDomain::File.tools().iter())
.chain(ToolDomain::Exec.tools().iter()) .chain(ToolDomain::Exec.tools().iter())
.chain(ToolDomain::Http.tools().iter()) .chain(ToolDomain::Http.tools().iter())
.chain(ToolDomain::Agent.tools().iter())
.copied() .copied()
.collect(); .collect();
for name in &subset { for name in &subset {
+57
View File
@@ -64,6 +64,11 @@ pub use df_ai_core;
pub(crate) fn build_provider_client() -> Client { pub(crate) fn build_provider_client() -> Client {
Client::builder() Client::builder()
.connect_timeout(std::time::Duration::from_secs(30)) .connect_timeout(std::time::Duration::from_secs(30))
// 连接池收敛:idle 连接 15s 弃用 + 每主机最多存 2 条。reqwest 默认 idle 90s 且
// 无上限,服务端/前置 keepalive 更短时复用陈旧连接首包即被 RST(ConnectionReset
// 10054),呈现「隔几十秒一发就失败」的间歇形态;15s 保守低于常见网关 60s keepalive。
.pool_idle_timeout(std::time::Duration::from_secs(15))
.pool_max_idle_per_host(2)
.build() .build()
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
tracing::warn!("reqwest builder 失败,回退默认 client: {}", e); tracing::warn!("reqwest builder 失败,回退默认 client: {}", e);
@@ -71,6 +76,58 @@ pub(crate) fn build_provider_client() -> Client {
}) })
} }
/// 流式 send 阶段 reqwest 错误的谓词快照。
///
/// reqwest::Error 无公开构造器,抽谓词结构体过纯函数,单测才能锁定分类
/// (send 阶段 ConnectionReset → 可重试类词)。
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct SendErrPreds {
pub is_timeout: bool,
pub is_connect: bool,
pub is_decode: bool,
pub is_body: bool,
pub is_request: bool,
pub is_builder: bool,
}
impl SendErrPreds {
pub(crate) fn of(e: &reqwest::Error) -> Self {
Self {
is_timeout: e.is_timeout(),
is_connect: e.is_connect(),
is_decode: e.is_decode(),
is_body: e.is_body(),
is_request: e.is_request(),
is_builder: e.is_builder(),
}
}
}
/// 流式 send 阶段错误分类,返回类词(置于 bail 串首位供下游按串分类)。
///
/// reqwest 0.12 的 Display 只给 "error sending request for url",不含 source 关键词,
/// 纯 Display 串分类必得 unknown → Fatal 零重试。此处用谓词确定性给类词:
/// timeout/connect/decode/body 各归本类;is_request(非 builder)覆盖陈旧池化连接
/// 首包 RST(ConnectionReset 10054)等传输层发送错,归 connect(与 retry 的
/// is_reqwest_error_retryable 对 is_request 可重试口径一致);builder 构造错归
/// request(不可重试)。类词须落在 stream_recv classify_status_or_class 传输词表内,
/// 改任一侧需同步。
pub(crate) fn send_error_class(p: SendErrPreds) -> &'static str {
if p.is_timeout {
"timeout"
} else if p.is_connect {
"connect"
} else if p.is_decode {
"decode"
} else if p.is_body {
"body"
} else if p.is_request && !p.is_builder {
"connect"
} else {
"request"
}
}
/// 按 provider_type 构建 LLM Provider 实例(统一选择逻辑,消除调用方重复 match) /// 按 provider_type 构建 LLM Provider 实例(统一选择逻辑,消除调用方重复 match)
/// ///
/// `anthropic` 协议走 AnthropicCompatProvider(GLM 订阅端点 / Claude 官方), /// `anthropic` 协议走 AnthropicCompatProvider(GLM 订阅端点 / Claude 官方),
+87 -7
View File
@@ -424,7 +424,7 @@ fn sanitize_openai_triplets(messages: &mut Vec<OpenAiMessage>) {
// 无配对头(id 不在任何保留 assistant 头内)→ orphan tool_result,丢弃。 // 无配对头(id 不在任何保留 assistant 头内)→ orphan tool_result,丢弃。
// 含两类:(a) assistant 头被 step2 剥后残留的 tool;(b) 全程无配对头的直构造/DB 残留。 // 含两类:(a) assistant 头被 step2 剥后残留的 tool;(b) 全程无配对头的直构造/DB 残留。
dropped_orphan_tools += 1; dropped_orphan_tools += 1;
tracing::warn!( tracing::debug!(
tool_call_id = %id, tool_call_id = %id,
"[openai] orphan tool result(无配对 assistant tool_calls),已丢弃(防 'tool must be response to preceding tool_calls' 400)", "[openai] orphan tool result(无配对 assistant tool_calls),已丢弃(防 'tool must be response to preceding tool_calls' 400)",
); );
@@ -432,7 +432,7 @@ fn sanitize_openai_triplets(messages: &mut Vec<OpenAiMessage>) {
}); });
if stripped_heads > 0 || dropped_orphan_tools > 0 { if stripped_heads > 0 || dropped_orphan_tools > 0 {
tracing::warn!( tracing::debug!(
stripped_heads, stripped_heads,
total_stripped, total_stripped,
dropped_orphan_tools, dropped_orphan_tools,
@@ -502,7 +502,9 @@ impl LlmProvider for OpenAICompatProvider {
let resp = match resp { let resp = match resp {
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
if is_reqwest_error_retryable(&e) { // is_decode 补可重试:body 中途断连在 reqwest 归 decode 类,complete 重发
// 幂等安全(受 3 次/30s 预算约束,真正格式错重试同败,预算内止损)。
if is_reqwest_error_retryable(&e) || e.is_decode() {
return AttemptOutcome::Retryable(format!("请求失败(可重试): {}", e)); return AttemptOutcome::Retryable(format!("请求失败(可重试): {}", e));
} }
return AttemptOutcome::Fatal(format!("请求失败(不可重试): {}", e)); return AttemptOutcome::Fatal(format!("请求失败(不可重试): {}", e));
@@ -520,10 +522,16 @@ impl LlmProvider for OpenAICompatProvider {
error!(%status, %body, "LLM API 调用失败(不可重试)"); error!(%status, %body, "LLM API 调用失败(不可重试)");
return AttemptOutcome::Fatal(msg); return AttemptOutcome::Fatal(msg);
} }
// body 解析: 解析错属 Fatal(响应已成功送达,重试也会因同样格式失败) // body 解析: serde 结构错属 Fatal(响应已成功送达,重试同样失败);
// body 读取中途断连在 reqwest 同归 is_decode,重发幂等安全 → 可重试。
let body: OpenAiResponse = match resp.json().await { let body: OpenAiResponse = match resp.json().await {
Ok(b) => b, Ok(b) => b,
Err(e) => return AttemptOutcome::Fatal(format!("响应解析失败: {}", e)), Err(e) => {
if e.is_decode() {
return AttemptOutcome::Retryable(format!("响应解码失败(可重试): {}", e));
}
return AttemptOutcome::Fatal(format!("响应解析失败: {}", e));
}
}; };
let choice = match body.choices.into_iter().next() { let choice = match body.choices.into_iter().next() {
Some(c) => c, Some(c) => c,
@@ -570,8 +578,23 @@ impl LlmProvider for OpenAICompatProvider {
let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await { let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await {
Ok(Ok(r)) => r, Ok(Ok(r)) => r,
Ok(Err(e)) => { Ok(Err(e)) => {
tracing::error!(error = %e, is_timeout = e.is_timeout(), "OpenAI 流式 send 失败"); // 谓词结构化分类:Display 串不含 source 关键词("error sending request for url"),
return Err(e.into()); // 纯串分类必 unknown → Fatal 零重试(陈旧连接首包 RST 就此裸奔)。类词置串首,
// source 链兜底真实传输词(reset/broken pipe 等),下游按类词走可重试。
let class = crate::send_error_class(crate::SendErrPreds::of(&e));
tracing::error!(
error = %e,
class = class,
url = %self.chat_url(),
source = ?std::error::Error::source(&e),
"OpenAI 流式 send 失败"
);
anyhow::bail!(
"流式 send 失败({}): {} [src={:?}]",
class,
e,
std::error::Error::source(&e)
);
} }
Err(_elapsed) => { Err(_elapsed) => {
tracing::error!(url = %self.chat_url(), "OpenAI 流式 send 超时(60s 未返回响应头)"); tracing::error!(url = %self.chat_url(), "OpenAI 流式 send 超时(60s 未返回响应头)");
@@ -1279,4 +1302,61 @@ mod tests {
let tc4 = c4.tool_calls.as_ref().expect("应有 tool_calls").first().unwrap(); let tc4 = c4.tool_calls.as_ref().expect("应有 tool_calls").first().unwrap();
assert_eq!(tc4.id.as_deref(), Some("call_xyz"), "非空 id 原样透传"); assert_eq!(tc4.id.as_deref(), Some("call_xyz"), "非空 id 原样透传");
} }
// ---------- 流式 send 错误分类(谓词纯函数,reqwest::Error 无公开构造器以快照模拟) ----------
/// send 阶段陈旧连接首包 RST(reqwest 形态 is_request=true + 源 ConnectionReset)
/// → connect 类词 → 下游可重试。根治 Display 串无 source 关键词被分 unknown 零重试。
#[test]
fn send_error_connection_reset_is_retryable_class() {
let class = crate::send_error_class(crate::SendErrPreds {
is_request: true,
..Default::default()
});
assert_eq!(class, "connect");
}
/// timeout/connect/decode/body/request 形态各归本类,且类词均须落在下游传输可重试
/// 词表内(镜像 src-tauri stream_recv classify_status_or_class 词表,改任一侧需同步)。
#[test]
fn send_error_transport_classes_are_retryable_words() {
const RETRYABLE_WORDS: &[&str] = &[
"timeout",
"connect",
"transport",
"decoding",
"decode",
"body",
"eof",
"reset",
"broken pipe",
];
let cases = [
crate::SendErrPreds { is_timeout: true, ..Default::default() },
crate::SendErrPreds { is_connect: true, ..Default::default() },
crate::SendErrPreds { is_decode: true, ..Default::default() },
crate::SendErrPreds { is_body: true, ..Default::default() },
crate::SendErrPreds { is_request: true, ..Default::default() },
];
for p in cases {
let class = crate::send_error_class(p);
assert!(
RETRYABLE_WORDS.contains(&class),
"类词 {} 应在传输可重试词表内",
class
);
}
}
/// builder 构造错(请求本体问题,重试同败)→ request 类词,不在词表 → 下游 Fatal,
/// 与 retry::is_reqwest_error_retryable 的 is_builder 排除口径一致。
#[test]
fn send_error_builder_is_fatal_class() {
let class = crate::send_error_class(crate::SendErrPreds {
is_request: true,
is_builder: true,
..Default::default()
});
assert_eq!(class, "request");
}
} }
+139 -14
View File
@@ -65,11 +65,30 @@ pub(crate) struct OpenAiResponse {
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct OpenAiChoice { pub struct OpenAiChoice {
pub message: OpenAiMessageResp, pub(crate) message: OpenAiMessageResp,
// OpenAI 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning /// 调用终止原因。`length` = 输出被 max_tokens 截断(不完整),由 [`FinishReason`]
#[allow(dead_code)] /// 的 Drop 侧效应打 warn 留痕 — 此前该字段无人读、截断不可见,截断的 JSON 会被
pub finish_reason: Option<String>, /// 下游当解析失败整批丢弃而无任何日志线索。
#[serde(default)]
pub finish_reason: FinishReason,
}
/// finish_reason 载体(透明 NewType)。
///
/// 消费方只取 message,本字段值随 choice 整体 drop 时触发截断告警;对外公开
/// (pub 模块下的 pub 结构属库 API 面)同时免去字段 dead_code 豁免。
/// serde 透明,线上 JSON 形态与 Option<String> 一致。
#[derive(Debug, Default, Deserialize)]
#[serde(transparent)]
pub struct FinishReason(Option<String>);
impl Drop for FinishReason {
fn drop(&mut self) {
if self.0.as_deref() == Some("length") {
tracing::warn!("[OpenAI] 同步响应因 max_tokens 截断(finish_reason=length),输出不完整");
}
}
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -97,25 +116,78 @@ pub(crate) struct OpenAiFunctionResp {
pub arguments: String, pub arguments: String,
} }
/// OpenAI 标准嵌套缓存口径形态(usage.prompt_tokens_details):
/// GLM/Qwen/OpenAI 官方在嵌套结构报缓存命中,DeepSeek 用扁平 prompt_cache_hit_tokens。
/// cached_tokens 用 Option 容错给空 details 对象的网关。
#[derive(Debug, Default, Deserialize)]
pub(crate) struct PromptTokensDetails {
#[serde(default)]
pub cached_tokens: Option<u64>,
}
/// usage 反序列化中间载体:与 OpenAiUsage 字段同构,经 `From` 做缓存口径归一后产出
/// OpenAiUsage(见 impl From)。流式 SSE 与非流式 JSON 两条解析路径因此都自动获得
/// 嵌套 cached_tokens 回填,消费方一律读归一后的扁平字段。
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct OpenAiUsageRaw {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
/// DeepSeek 扩展:缓存命中 token(低价,deepseek-chat/reasoner)。无此字段 → default 0。
#[serde(default)]
pub prompt_cache_hit_tokens: u32,
/// DeepSeek 扩展:未命中 token(全价真实输入,prompt_cache_miss_tokens)。无此字段 → default 0。
#[serde(default)]
pub prompt_cache_miss_tokens: u32,
/// DeepSeek-reasoner / OpenAI o1 扩展:思考 token(隐藏输出,reasoning_tokens)。
/// 非 reasoning 模型无此字段 → default 0。
#[serde(default)]
pub reasoning_tokens: u32,
/// OpenAI 标准嵌套缓存口径(GLM/Qwen/OpenAI 官方)。
#[serde(default)]
pub prompt_tokens_details: Option<PromptTokensDetails>,
}
/// OpenAI usage(缓存口径已归一)
///
/// 归一发生在反序列化时(`#[serde(from)]`):扁平 hit 有值优先(DeepSeek 自报 hit+miss);
/// 扁平 hit 未报而嵌套 cached_tokens > 0 时回填 hit,miss 推断为 prompt - hit(嵌套口径
/// 缺全价分项);两种口径都没有则保持 0。消费方(apply_openai_sse / openai_compat 非流式)
/// 直接读扁平字段,无需感知 provider 差异。
#[derive(Debug, Deserialize)]
#[serde(from = "OpenAiUsageRaw")]
pub(crate) struct OpenAiUsage { pub(crate) struct OpenAiUsage {
pub prompt_tokens: u32, pub prompt_tokens: u32,
pub completion_tokens: u32, pub completion_tokens: u32,
pub total_tokens: u32, pub total_tokens: u32,
/// DeepSeek 扩展:缓存命中 token(低价,deepseek-chat/reasoner prompt_cache_hit_tokens)。 /// 缓存命中 token(低价;扁平优先,缺失时回填自嵌套 cached_tokens)
/// OpenAI 官方(o1 等)无此字段 → serde default 0。其他 OpenAI 兼容网关若支持 cache 也用此名。
#[serde(default)]
pub prompt_cache_hit_tokens: u32, pub prompt_cache_hit_tokens: u32,
/// DeepSeek 扩展:未命中 token(全价真实输入,prompt_cache_miss_tokens)。 /// 缓存未命中 token(全价真实输入;嵌套口径推断时 = prompt - hit)
/// OpenAI 官方无此字段 → serde default 0。
#[serde(default)]
pub prompt_cache_miss_tokens: u32, pub prompt_cache_miss_tokens: u32,
/// DeepSeek-reasoner / OpenAI o1 扩展:思考 token(隐藏输出,reasoning_tokens)。 /// 思考 token(隐藏输出)
/// 非 reasoning 模型无此字段 → serde default 0。
#[serde(default)]
pub reasoning_tokens: u32, pub reasoning_tokens: u32,
} }
impl From<OpenAiUsageRaw> for OpenAiUsage {
fn from(raw: OpenAiUsageRaw) -> Self {
let (hit, miss) = match raw.prompt_tokens_details.and_then(|d| d.cached_tokens) {
Some(nested) if nested > 0 && raw.prompt_cache_hit_tokens == 0 => {
let hit = u32::try_from(nested).unwrap_or(u32::MAX);
(hit, raw.prompt_tokens.saturating_sub(hit))
}
_ => (raw.prompt_cache_hit_tokens, raw.prompt_cache_miss_tokens),
};
Self {
prompt_tokens: raw.prompt_tokens,
completion_tokens: raw.completion_tokens,
total_tokens: raw.total_tokens,
prompt_cache_hit_tokens: hit,
prompt_cache_miss_tokens: miss,
reasoning_tokens: raw.reasoning_tokens,
}
}
}
/// SSE 流式响应 chunk /// SSE 流式响应 chunk
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct OpenAiStreamChunk { pub(crate) struct OpenAiStreamChunk {
@@ -287,3 +359,56 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
/// DeepSeek 扁平口径:hit/miss 各取其值,不参与嵌套回填
#[test]
fn usage_deepseek_flat_cache() {
let u: OpenAiUsage = serde_json::from_str(
r#"{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110,
"prompt_cache_hit_tokens":64,"prompt_cache_miss_tokens":36}"#,
)
.unwrap();
assert_eq!(u.prompt_tokens, 100);
assert_eq!(u.prompt_cache_hit_tokens, 64);
assert_eq!(u.prompt_cache_miss_tokens, 36);
}
/// OpenAI/GLM 嵌套口径:扁平未报,hit 回填自 cached_tokens,miss 推断为 prompt - hit
#[test]
fn usage_nested_cached_tokens_backfill() {
let u: OpenAiUsage = serde_json::from_str(
r#"{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110,
"prompt_tokens_details":{"cached_tokens":64}}"#,
)
.unwrap();
assert_eq!(u.prompt_cache_hit_tokens, 64);
assert_eq!(u.prompt_cache_miss_tokens, 36);
}
/// 两种口径都没有:hit/miss 保持 0(前端不渲染 cache 段)
#[test]
fn usage_no_cache_fields() {
let u: OpenAiUsage = serde_json::from_str(
r#"{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110}"#,
)
.unwrap();
assert_eq!(u.prompt_cache_hit_tokens, 0);
assert_eq!(u.prompt_cache_miss_tokens, 0);
}
/// 端到端:usage-only chunk 经 apply_openai_sse 累加路径带出归一后的缓存分项
#[test]
fn sse_usage_chunk_nested_cache_reaches_token_usage() {
let data = r#"{"choices":[],"usage":{"prompt_tokens":100,"completion_tokens":10,
"total_tokens":110,"prompt_tokens_details":{"cached_tokens":64}}}"#;
let mut accum = None;
let chunk = apply_openai_sse(data, &mut accum);
let usage = chunk.usage.expect("usage chunk 应携带 usage");
assert_eq!(usage.prompt_cache_hit_tokens, 64);
assert_eq!(usage.prompt_cache_miss_tokens, 36);
}
}
+409
View File
@@ -0,0 +1,409 @@
//! AI 编排节点 — 直连 agentic 会话执行工作流任务
//!
//! 与 AiNode(单次 LLM 补全)的区别:AiAgentNode 在节点 execute 时以编程方式启动一个
//! 独立 agentic 会话(新 conv_id,与用户活跃会话隔离,防 epoch 互踩),由 run_agentic_loop
//! 完整驱动(多轮迭代 + 工具调用),会话收敛后取最终 assistant 报告作为节点输出。
//!
//! 阻塞语义与 HumanNode 同构:tokio::select! 三路等待
//! - 会话完成通知(AppState.agent_completion_bridge 的 oneshot,由 agentic loop 收敛
//! 统一出口 emit_ai_completed_once 投递,epoch 守卫保证仅 owner loop 触发)
//! - deadline 超时(config.timeout_secs,默认 1800s)→ Err(工作流 failed → 推进链退回)
//! - 500ms tick 轮询 is_cancelled
//!
//! spawn 通道经 [`SpawnFn`] 注入(src-tauri 节点工厂提供,内部取 AppState 起会话),
//! 本 crate 不依赖 src-tauri 类型,依赖方向与 AiNode 持 Arc<Database> 同款。
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use df_storage::crud::TaskRepo;
use df_storage::db::Database;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
// 任务文本兜底 prompt(与 AiNode 同款,经 ai_node_helpers 复用)
use crate::ai_node_helpers::{default_fallback_prompt, resolve_fallback_prompt};
/// 会话启动结果:(conv_id, 完成通知接收端)。接收端值 = 最终 assistant 报告文本。
pub type SpawnedSession = (String, tokio::sync::oneshot::Receiver<String>);
/// 会话启动 future(Err = 启动失败,如 provider 缺失/应用句柄未就绪)。
pub type SpawnFuture = Pin<Box<dyn Future<Output = Result<SpawnedSession, String>> + Send>>;
/// 会话启动通道:入参任务文本,由 src-tauri 节点工厂注入实现(见 chat.rs
/// orchestration_session_spawner)。
pub type SpawnFn = Arc<dyn Fn(String) -> SpawnFuture + Send + Sync>;
/// 默认编排超时(秒)。1800s = 30min,与 HumanNode 默认审批窗口同量级;
/// agentic 会话含多轮工具调用,给宽值,到点 fail 退回任务状态。
const DEFAULT_TIMEOUT_SECS: u64 = 1800;
/// AI 编排节点
///
/// 持 Arc<Database> 落 task.output_json(有 task_id 时,与 AiNode 同模式),
/// 持 SpawnFn 启动独立编排会话。
pub struct AiAgentNode {
db: Arc<Database>,
spawn: SpawnFn,
}
impl AiAgentNode {
/// 构造节点(注册表工厂调用,注入数据库句柄与会话启动通道)
pub fn new(db: Arc<Database>, spawn: SpawnFn) -> Self {
Self { db, spawn }
}
}
#[async_trait]
impl Node for AiAgentNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult {
tracing::info!("AiAgentNode 执行: node_id={}", ctx.node_id);
// 任务文本取值优先级与 AiNode 一致:上游 prompt 输出 > config.prompt >
// task_id 兜底(读 TaskRecord 生成) > 通用默认。推进链模板 config 无 prompt,
// 兜底链保证单节点 DAG 也能拿到任务书。
let task = match ctx
.inputs
.get("prompt")
.and_then(|o| o.data.as_str())
.map(String::from)
.or_else(|| {
ctx.config
.get("prompt")
.and_then(|v| v.as_str())
.map(String::from)
}) {
Some(t) if !t.trim().is_empty() => t,
_ => {
resolve_fallback_prompt(&self.db, &ctx.config)
.await
.unwrap_or_else(default_fallback_prompt)
}
};
let timeout_secs = ctx
.config
.get("timeout_secs")
.and_then(|v| v.as_u64())
.unwrap_or(DEFAULT_TIMEOUT_SECS);
let task_id = ctx
.config
.get("task_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
// 启动独立编排会话(内部生成新 conv_id 并注册完成通知桥,注册先于 spawn)
let (conv_id, mut done_rx) = (self.spawn)(task)
.await
.map_err(|e| anyhow::anyhow!("AI 编排会话启动失败: {}", e))?;
// select! 骨架与 HumanNode 同构:完成通知 / deadline 超时 / 500ms 取消轮询。
// oneshot 接收端在 loop 里重 poll,须借 &mut(移动进 select! 会破坏循环重入)。
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
let mut cancel_tick = tokio::time::interval(Duration::from_millis(500));
cancel_tick.tick().await; // 丢弃首个立即触发的 tick
let report = loop {
tokio::select! {
done = &mut done_rx => match done {
Ok(text) => break text,
Err(_) => {
// 发送端被 drop 且未投递:会话条目被覆盖注册或异常清除,无报告可取
return Err(anyhow::anyhow!(
"AI 编排会话完成信号丢失(conv_id={})",
conv_id
));
}
},
_ = tokio::time::sleep_until(deadline) => {
// 超时 fail:工作流 failed → 推进链按 regression_target 退回任务状态
return Err(anyhow::anyhow!(
"AI 编排会话超时({}s), conv_id={}",
timeout_secs,
conv_id
));
}
_ = cancel_tick.tick() => {
if ctx.node_status.is_cancelled(&ctx.node_id) {
return Err(anyhow::anyhow!("AI 编排会话被取消"));
}
}
}
};
// 空报告判失败:stop 在任何输出前 / 会话被清理的场景,不作为有效产出推进任务
if report.trim().is_empty() {
return Err(anyhow::anyhow!(
"AI 编排会话无输出(会话被停止或未产出报告), conv_id={}",
conv_id
));
}
let output = serde_json::json!({
"text": report,
"conv_id": conv_id,
"task_id": task_id,
});
// 有 task_id 落 task.output_json(与 AiNode 同模式:通用 update_field 白名单字段,
// 落库失败不阻断节点返回,产出已在内存工作流可继续)。
if !task_id.is_empty() {
if let Ok(json_str) = serde_json::to_string(&output) {
let repo = TaskRepo::new(&self.db);
if let Err(e) = repo.update_field(&task_id, "output_json", &json_str).await {
tracing::warn!(
task_id = task_id,
error = %e,
"AiAgentNode 落 output_json 失败(产出已在内存,节点继续)"
);
}
}
}
Ok(NodeOutput::from_value(output))
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"prompt": { "type": "string", "description": "任务书(缺省依次取上游 prompt 输出 / 按 task_id 读任务生成 / 通用默认)" },
"timeout_secs": { "type": "integer", "description": "编排超时秒数(默认 1800,到点节点 fail 触发工作流退回)" },
"task_id": { "type": "string", "description": "关联任务 ID(run_workflow 注入;有值时产出落 task.output_json)" }
},
"required": []
}),
output: serde_json::json!({
"type": "object",
"properties": {
"text": { "type": "string", "description": "最终 assistant 报告文本" },
"conv_id": { "type": "string", "description": "编排会话 ID(独立于用户活跃会话)" },
"task_id": { "type": "string" }
}
}),
}
}
fn is_blocking(&self) -> bool {
true
}
fn node_type(&self) -> &str {
"ai_agent"
}
}
#[cfg(test)]
mod tests {
use super::*;
use df_storage::crud::ProjectRepo;
use df_storage::models::{ProjectRecord, TaskRecord};
use df_types::types::{ProjectStatus, TaskStatus};
use df_workflow::eventbus::EventBus;
use df_workflow::state::StateMachine;
use serde_json::json;
use std::collections::HashMap;
/// 构造 NodeContext:自定义 config,共享指定状态机(取消轮询测试用)。
fn make_ctx_with(config: serde_json::Value, sm: StateMachine) -> NodeContext {
NodeContext {
node_id: "node-a".to_string(),
inputs: HashMap::new(),
config,
execution_id: "exec-1".into(),
event_bus: EventBus::new(),
node_status: sm,
}
}
fn make_ctx(config: serde_json::Value) -> NodeContext {
make_ctx_with(config, StateMachine::new())
}
/// 内存 DB(无表数据;task_id 为空时节点不触库)。
async fn empty_db() -> Arc<Database> {
Arc::new(Database::open_in_memory().await.expect("open_in_memory"))
}
/// 内存 DB helper:插父项目 + 指定 task,返回 (db, task_id)。
async fn setup_task_db() -> (Arc<Database>, String) {
let db = Database::open_in_memory().await.expect("open_in_memory");
ProjectRepo::new(&db)
.insert(ProjectRecord {
id: "p1".to_string(),
name: "proj".to_string(),
description: "".to_string(),
status: ProjectStatus::Planning,
idea_id: None,
path: None,
stack: None,
created_at: "0".to_string(),
updated_at: "0".to_string(),
})
.await
.expect("insert project");
TaskRepo::new(&db)
.insert(TaskRecord {
id: "t1".to_string(),
project_id: "p1".to_string(),
title: "实现登录接口".to_string(),
description: "含前端表单与后端校验".to_string(),
status: TaskStatus::Todo,
priority: 2,
branch_name: None,
assignee: None,
workflow_def_id: None,
base_branch: None,
review_rounds: 0,
output_json: None,
idea_id: None,
module_id: None,
queue: "todo".to_string(),
parent_id: None,
content_json: None,
created_at: "0".to_string(),
updated_at: "0".to_string(),
})
.await
.expect("insert task");
(Arc::new(db), "t1".to_string())
}
/// spawn 桩:启动后 50ms 投递指定报告文本。
fn spawn_delivering(report: &'static str) -> SpawnFn {
Arc::new(move |_task: String| {
Box::pin(async move {
let (tx, rx) = tokio::sync::oneshot::channel::<String>();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
let _ = tx.send(report.to_string());
});
Ok(("conv-1".to_string(), rx))
})
})
}
/// spawn 桩:发送端保活不投递(模拟会话长时间未收敛)。
fn spawn_silent() -> SpawnFn {
Arc::new(|_task: String| {
Box::pin(async move {
let (tx, rx) = tokio::sync::oneshot::channel::<String>();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(5)).await;
let _ = tx.send("迟到的报告".to_string());
});
Ok(("conv-silent".to_string(), rx))
})
})
}
/// 完成通知桥触发:spawn 后收到投递 → Ok 输出 {text, conv_id, task_id}。
#[tokio::test]
async fn completion_notify_returns_report() {
let node = AiAgentNode::new(empty_db().await, spawn_delivering("任务完成报告"));
let ctx = make_ctx(json!({ "task_id": "t1" }));
let out = node.execute(ctx).await.unwrap();
assert_eq!(out.data["text"], json!("任务完成报告"));
assert_eq!(out.data["conv_id"], json!("conv-1"));
assert_eq!(out.data["task_id"], json!("t1"));
}
/// deadline 超时:发送端保活不投递 + 短超时 → Err 超时(工作流 failed 退回路径)。
#[tokio::test]
async fn timeout_fails_node() {
let node = AiAgentNode::new(empty_db().await, spawn_silent());
let ctx = make_ctx(json!({ "timeout_secs": 1 }));
let err = node.execute(ctx).await.unwrap_err().to_string();
assert!(err.contains("超时"), "应报超时, 实际: {}", err);
assert!(err.contains("conv-silent"), "超时信息应带 conv_id, 实际: {}", err);
}
/// 取消轮询:500ms tick 命中 is_cancelled → Err 取消(状态保持 Cancelled 由 executor 保证)。
#[tokio::test]
async fn cancelled_poll_fails_node() {
let sm = StateMachine::new();
let sm_external = sm.clone();
let node = AiAgentNode::new(empty_db().await, spawn_silent());
let handle = tokio::spawn(async move { node.execute(make_ctx_with(json!({}), sm)).await });
// 等 execute 进入 select! 轮询后再取消(过早 set 会因 tick 未启动而漏检一轮,仍会被下轮命中)
tokio::time::sleep(Duration::from_millis(50)).await;
sm_external.set_cancelled("node-a".to_string());
let err = handle.await.unwrap().unwrap_err().to_string();
assert!(err.contains("取消"), "应报取消, 实际: {}", err);
}
/// 启动失败:spawn 返 Err(provider 缺失等)→ 节点 Err 带原因。
#[tokio::test]
async fn spawn_failure_fails_node() {
let spawn: SpawnFn = Arc::new(|_task: String| {
Box::pin(async { Err("未配置 AI Provider".to_string()) })
});
let node = AiAgentNode::new(empty_db().await, spawn);
let err = node.execute(make_ctx(json!({}))).await.unwrap_err().to_string();
assert!(err.contains("启动失败"), "应报启动失败, 实际: {}", err);
assert!(err.contains("未配置 AI Provider"), "应携带原因, 实际: {}", err);
}
/// 信号丢失:发送端被 drop 且未投递 → Err 信号丢失(不误判为成功)。
#[tokio::test]
async fn dropped_sender_fails_node() {
let spawn: SpawnFn = Arc::new(|_task: String| {
Box::pin(async move {
let (tx, rx) = tokio::sync::oneshot::channel::<String>();
drop(tx);
Ok(("conv-drop".to_string(), rx))
})
});
let node = AiAgentNode::new(empty_db().await, spawn);
let err = node.execute(make_ctx(json!({}))).await.unwrap_err().to_string();
assert!(err.contains("信号丢失"), "应报信号丢失, 实际: {}", err);
}
/// 空报告:投递了纯空白文本 → Err 无输出(不作为有效产出推进任务)。
#[tokio::test]
async fn blank_report_fails_node() {
let spawn: SpawnFn = Arc::new(|_task: String| {
Box::pin(async move {
let (tx, rx) = tokio::sync::oneshot::channel::<String>();
let _ = tx.send(" ".to_string());
Ok(("conv-blank".to_string(), rx))
})
});
let node = AiAgentNode::new(empty_db().await, spawn);
let err = node.execute(make_ctx(json!({}))).await.unwrap_err().to_string();
assert!(err.contains("无输出"), "应报无输出, 实际: {}", err);
}
/// 有 task_id 落 task.output_json(与 AiNode 同一条 update_field 白名单路径),
/// 且任务书兜底链生效:config 无 prompt 时按 task_id 读任务生成。
#[tokio::test]
async fn task_output_json_written_and_fallback_prompt_used() {
let (db, task_id) = setup_task_db().await;
let node = AiAgentNode::new(db.clone(), spawn_delivering("登录接口已完成"));
// config 只有 task_id(推进链模板形态),prompt 走兜底生成
let ctx = make_ctx(json!({ "task_id": task_id }));
let out = node.execute(ctx).await.unwrap();
assert_eq!(out.data["text"], json!("登录接口已完成"));
let after = TaskRepo::new(&db)
.get_by_id("t1")
.await
.unwrap()
.expect("task 存在");
let saved = after.output_json.expect("output_json 应被写入");
let parsed: serde_json::Value = serde_json::from_str(&saved).unwrap();
assert_eq!(parsed["text"], json!("登录接口已完成"));
assert_eq!(parsed["task_id"], json!("t1"));
assert!(parsed["conv_id"].as_str().is_some(), "conv_id 应落 output_json");
}
/// 默认编排超时 1800s(30min),与 HumanNode 默认审批窗口同量级。
#[test]
fn default_timeout_is_1800_secs() {
assert_eq!(DEFAULT_TIMEOUT_SECS, 1800, "默认编排超时应为 1800s(30min)");
}
}
+4 -2
View File
@@ -108,7 +108,8 @@ pub(crate) async fn resolve_and_parse(
/// ///
/// 无 task_id / 查询失败 / task 不存在 → None,调用方降级到 `default_fallback_prompt`。 /// 无 task_id / 查询失败 / task 不存在 → None,调用方降级到 `default_fallback_prompt`。
/// description 空只取 title;非空拼「标题(描述)」。truncate 防 token 滥用。 /// description 空只取 title;非空拼「标题(描述)」。truncate 防 token 滥用。
async fn resolve_fallback_prompt(db: &Arc<Database>, config: &serde_json::Value) -> Option<String> { /// pub(crate):AiAgentNode 复用同款任务书兜底(与 AiNode 取值语义一致)。
pub(crate) async fn resolve_fallback_prompt(db: &Arc<Database>, config: &serde_json::Value) -> Option<String> {
let task_id = config.get("task_id").and_then(|v| v.as_str())?; let task_id = config.get("task_id").and_then(|v| v.as_str())?;
if task_id.is_empty() { if task_id.is_empty() {
return None; return None;
@@ -130,7 +131,8 @@ async fn resolve_fallback_prompt(db: &Arc<Database>, config: &serde_json::Value)
} }
/// P0-B 通用兜底 prompt:无 task_id 时用(独立 AiNode 调用 / config 无 task 关联)。 /// P0-B 通用兜底 prompt:无 task_id 时用(独立 AiNode 调用 / config 无 task 关联)。
fn default_fallback_prompt() -> String { /// pub(crate):AiAgentNode 复用同款通用兜底。
pub(crate) fn default_fallback_prompt() -> String {
"请根据上下文完成当前任务,产出可执行结果并自检。若需更多信息请说明。".to_string() "请根据上下文完成当前任务,产出可执行结果并自检。若需更多信息请说明。".to_string()
} }
+1
View File
@@ -1,5 +1,6 @@
//! df-nodes: 内置节点集合 — AI、脚本、人工审批、Git、Docker、HTTP、通知 //! df-nodes: 内置节点集合 — AI、脚本、人工审批、Git、Docker、HTTP、通知
pub mod ai_agent_node;
pub mod ai_node; pub mod ai_node;
pub mod ai_self_review_node; pub mod ai_self_review_node;
mod ai_node_helpers; mod ai_node_helpers;
+23 -6
View File
@@ -37,14 +37,17 @@ pub fn template_for(target_status: &str) -> Option<DagDef> {
/// todo → in_progress:单 AiNode 执行任务。 /// todo → in_progress:单 AiNode 执行任务。
/// ///
/// 拓扑:1 个 ai 节点。完成回调(②-3)推进 status 到 in_progress。 /// 拓扑:1 个 ai 节点。完成回调(②-3)推进 status 到 in_progress。
///
/// mode:"agent" → ai 节点工厂构造 AiAgentNode(直连 agentic 会话编排执行,多轮工具
/// 调用后取最终报告);缺省/"ask" 走旧 AiNode(单次补全,旧调用方零回归)。
/// 其余运行时参数(prompt/provider/task_id)仍由 run_workflow 全局 config 注入
/// (④-1 deep_merge 合并),模板只描述拓扑 + 执行模式。
fn in_progress_template() -> DagDef { fn in_progress_template() -> DagDef {
let mut dag = DagDef::new(); let mut dag = DagDef::new();
// config 留空:prompt / provider / task_id 均由 run_workflow 全局 config 注入
// (④-1 deep_merge 合并),模板只描述拓扑。
dag.add_node( dag.add_node(
"ai_execute", "ai_execute",
"ai", "ai",
serde_json::json!({}), serde_json::json!({ "mode": "agent" }),
); );
dag dag
} }
@@ -120,10 +123,24 @@ mod tests {
assert!(dag.edges.is_empty(), "in_progress: 无边"); assert!(dag.edges.is_empty(), "in_progress: 无边");
let node = dag.nodes.get("ai_execute").expect("ai_execute 节点存在"); let node = dag.nodes.get("ai_execute").expect("ai_execute 节点存在");
assert_eq!(node.node_type, "ai"); assert_eq!(node.node_type, "ai");
// config 留空(运行时注入) // mode:"agent" → 工作流编排执行(AiAgentNode 直连 agentic 会话);
// 其余运行时参数仍留空(由 run_workflow 全局 config 注入)。
assert_eq!(
node.config.get("mode").and_then(|v| v.as_str()),
Some("agent"),
"ai_execute 应以 mode=agent 走编排节点"
);
let non_mode_keys: Vec<&String> = node
.config
.as_object()
.expect("config 应为对象")
.keys()
.filter(|k| *k != "mode")
.collect();
assert!( assert!(
node.config.as_object().map(|o| o.is_empty()).unwrap_or(true), non_mode_keys.is_empty(),
"ai_execute config 应为空对象" "ai_execute 除 mode 外 config 应留空(运行时注入),实际: {:?}",
non_mode_keys
); );
} }
+3
View File
@@ -18,6 +18,9 @@ tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-window-state = "2" tauri-plugin-window-state = "2"
# P1 bg 化补完:系统通知。AI 完成/失败/需介入三类事件,主窗隐藏到托盘时补位提醒,
# Rust 侧条件发送(notify.rs):主窗可见聚焦不发 + 同类 5 秒节流 + 失败静默
tauri-plugin-notification = "2"
# Phase3 单实例:二次启动唤起已有进程主窗口,防多实例抢 18765 端口 # Phase3 单实例:二次启动唤起已有进程主窗口,防多实例抢 18765 端口
tauri-plugin-single-instance = "2" tauri-plugin-single-instance = "2"
serde.workspace = true serde.workspace = true
+2 -1
View File
@@ -23,6 +23,7 @@
"core:window:allow-set-decorations", "core:window:allow-set-decorations",
"core:webview:allow-create-webview-window", "core:webview:allow-create-webview-window",
"dialog:default", "dialog:default",
"window-state:default" "window-state:default",
"notification:default"
] ]
} }
@@ -10,12 +10,13 @@
use std::time::{Duration, SystemTime}; use std::time::{Duration, SystemTime};
use tauri::{AppHandle, Emitter}; use tauri::AppHandle;
use crate::state::AppState; use crate::state::AppState;
use super::super::{ 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)); .replace_tool_result_content(&tc_id, &format!("审批超时({}分钟)已自动取消,请改换其他方案", timeout_minutes));
} }
// 3. emit AiApprovalResult(让前端清除 pending UI) // 3. emit AiApprovalResult(让前端清除 pending UI,emit+publish 双写经正典 helper)
let ev = AiChatEvent::AiApprovalResult { emit_ai_chat_event(
app,
AiChatEvent::AiApprovalResult {
id: tc_id.clone(), id: tc_id.clone(),
approved: false, approved: false,
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
}; },
let _ = app.emit("ai-chat-event", ev.clone()); );
let _ = state.ai_event_bus.publish_event(ev);
// 4. audit_finalize 记入审计表 // 4. audit_finalize 记入审计表
audit_finalize( audit_finalize(
@@ -32,17 +32,18 @@
use std::sync::Arc; use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager}; use tauri::AppHandle;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary}; use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary};
use df_ai::provider::ChatMessage; use df_ai::provider::ChatMessage;
use df_storage::models::AiProviderRecord; use df_storage::models::AiProviderRecord;
use crate::state::{AppState, LlmConcurrency}; use crate::state::LlmConcurrency;
// compress_via_llm 位于 commands::ai::compress(mod.rs 的 super::compress)。 // compress_via_llm 位于 commands::ai::compress(mod.rs 的 super::compress)。
use super::super::compress::compress_via_llm; use super::super::compress::compress_via_llm;
use super::super::event_bus::emit_ai_chat_event;
use super::{AiChatEvent, AiSession}; use super::{AiChatEvent, AiSession};
/// 压缩保护区条数(对齐 clear/compress IPC 的 PROTECT_COUNT=6)。 /// 压缩保护区条数(对齐 clear/compress IPC 的 PROTECT_COUNT=6)。
@@ -109,13 +110,10 @@ pub(super) async fn maybe_auto_compress(
}; };
if should_compress { if should_compress {
// emit 压缩开始 + 置位防重入 + 读 active 克隆(不改 status)。 // emit 压缩开始(emit+publish 双写经 emit_ai_chat_event)+ 置位防重入 + 读 active 克隆(不改 status)。
let ev = AiChatEvent::AiCompressing { emit_ai_chat_event(app_handle, AiChatEvent::AiCompressing {
conversation_id: Some(conv_id.to_string()), 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::<AppState>().ai_event_bus.publish_event(ev);
let (mut active_msgs, lang) = { let (mut active_msgs, lang) = {
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
let conv = session.conv(&conv_id); 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); session.conv(&conv_id).messages.set_compressing(false);
} }
} }
if KEYWORD_FALLBACK_ENABLED { keyword_fallback_commit(
// 同 LLM 失败兜底:标 compressed 释放 token + 关键词摘要塞首位(若非空)。 &session_arc,
let inserted = { &conv_id,
let mut session = session_arc.lock().await; protect_start,
if !session.per_conv.contains_key(&conv_id) { &keyword_fallback,
return; pre_compress_tokens,
} "压缩 provider 重建失败",
let conv = session.conv(&conv_id); )
let _compressed = conv.messages.compress_old_messages(protect_start); .await;
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)"
);
}
}
return; return;
} }
}; };
@@ -286,13 +269,10 @@ pub(super) async fn maybe_auto_compress(
// 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体), // 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体),
// 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。 // 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。
// miniapp 仍插摘要气泡(对端发生压缩告知用户)。 // miniapp 仍插摘要气泡(对端发生压缩告知用户)。
let ev = AiChatEvent::AiAutoCompressed { emit_ai_chat_event(&app_handle, AiChatEvent::AiAutoCompressed {
conversation_id: Some(conv_id.clone()), conversation_id: Some(conv_id.clone()),
summary: summary.nl_summary, summary: summary.nl_summary,
}; });
let _ = app_handle.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber透传 miniapp
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
} }
Ok(None) => { Ok(None) => {
// 保护区外无 active 可压缩(已全 compressed/archived)→ noop,仅复位 is_compressing。 // 保护区外无 active 可压缩(已全 compressed/archived)→ noop,仅复位 is_compressing。
@@ -329,30 +309,15 @@ pub(super) async fn maybe_auto_compress(
"[ai] 自动压缩失败(后台),降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})", "[ai] 自动压缩失败(后台),降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
KEYWORD_FALLBACK_ENABLED, KEYWORD_FALLBACK_ENABLED,
); );
if KEYWORD_FALLBACK_ENABLED { keyword_fallback_commit(
// 标 compressed 释放 token + 关键词摘要塞首位(若非空)。 &session_arc,
let inserted = { &conv_id,
let mut session = session_arc.lock().await; protect_start,
if !session.per_conv.contains_key(&conv_id) { &keyword_fallback,
return; pre_compress_tokens,
} "压缩失败",
let conv = session.conv(&conv_id); )
let _compressed = conv.messages.compress_old_messages(protect_start); .await;
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)"
);
}
}
} }
} }
}); });
@@ -360,3 +325,47 @@ pub(super) async fn maybe_auto_compress(
} }
false 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<Mutex<AiSession>>,
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
);
}
}
+10 -11
View File
@@ -11,8 +11,9 @@
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc; 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 crate::commands::ai::AiChatEvent;
use super::conv_state::ConvState; 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)); /// epoch 语义:`loop_epoch` 是 per_conv 的并发 owner token(每 spawn 新 loop 前 fetch_add(1));
/// `my_epoch` 是本 guard 对应 loop 捕获的 epoch。复位只认 owner(epoch 匹配), /// `my_epoch` 是本 guard 对应 loop 捕获的 epoch。复位只认 owner(epoch 匹配),
/// 旧 loop 复位跳过(防 clobber 新 loop 状态)。 /// 旧 loop 复位跳过(防 clobber 新 loop 状态)。
pub(super) struct GeneratingGuard { pub(crate) struct GeneratingGuard {
conv_id: String, conv_id: String,
done: bool, done: bool,
/// per_conv.loop_epoch 引用(锁无关原子读,owner 判定用)。 /// per_conv.loop_epoch 引用(锁无关原子读,owner 判定用)。
@@ -37,7 +38,7 @@ pub(super) struct GeneratingGuard {
} }
impl GeneratingGuard { impl GeneratingGuard {
pub(super) fn new( pub(crate) fn new(
conv_id: String, conv_id: String,
loop_epoch: Arc<AtomicU64>, loop_epoch: Arc<AtomicU64>,
my_epoch: u64, my_epoch: u64,
@@ -58,7 +59,7 @@ impl GeneratingGuard {
/// ///
/// 只认 owner:本 loop epoch 匹配当前 loop_epoch 才迁移 Idle; /// 只认 owner:本 loop epoch 匹配当前 loop_epoch 才迁移 Idle;
/// 不匹配(旧 loop)则仅置 done(跳过迁移,防 clobber 新 loop 的 Generating)。 /// 不匹配(旧 loop)则仅置 done(跳过迁移,防 clobber 新 loop 的 Generating)。
pub(super) async fn reset(&mut self) { pub(crate) async fn reset(&mut self) {
if self.done { if self.done {
return; return;
} }
@@ -76,7 +77,7 @@ impl GeneratingGuard {
} }
/// 解除 Drop 兜底(审批等待保持 Generating)。 /// 解除 Drop 兜底(审批等待保持 Generating)。
pub(super) fn disarm(&mut self) { pub(crate) fn disarm(&mut self) {
self.done = true; self.done = true;
} }
@@ -102,12 +103,10 @@ impl GeneratingGuard {
} }
}; };
if let Some(ev) = ev { if let Some(ev) = ev {
let _ = self.app_handle.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&self.app_handle, ev);
let _ = self // P1 bg 化:迁移成功即同步托盘运行态(读 active_convs 快照刷菜单项/tooltip,
.app_handle // set_text/set_tooltip 均同步 API,此处保持同步不引入 await)
.state::<crate::state::AppState>() crate::tray::update_tray_runtime(&self.app_handle);
.ai_event_bus
.publish_event(ev);
} }
} }
} }
+155 -135
View File
@@ -1,14 +1,51 @@
//! 审批后循环恢复 — 从 agentic/mod.rs 拆分。 //! 审批后循环恢复 — 从 agentic/mod.rs 拆分。
//! 原代码被嵌套在 run_agentic_loop 内部导致 pub(crate) 不可见。 //! 原代码被嵌套在 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 crate::state::AppState;
use super::approval_timeout; use super::approval_timeout;
use crate::commands::ai::prompt::{build_system_prompt_parts, cache_enabled, get_active_provider}; use crate::commands::ai::event_bus::emit_ai_chat_event;
use crate::commands::ai::prompt::{
build_system_prompt_parts, cache_enabled, cache_split_provider_type, get_active_provider,
};
use crate::commands::ai::knowledge_inject::{build_knowledge_context_for_conv, inject_knowledge_into_prompt}; 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; 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.push(system) → tracing::info」。
/// conv 已删(per_conv 无条目)→ 跳过插入与日志(对齐原守卫语义)。
///
/// **尾部追加而非 insert_at(0)**:历史头部插入会位移整条前缀,叠加警示文本逐条不同,
/// 自动前缀缓存(DeepSeek/GLM/Qwen)与 Anthropic 显式缓存全部失效。警示语义都是
/// 「提示当前轮」,追加到消息流末尾对 LLM 同样可见(紧邻本轮 tool_result 之后),
/// 且不破坏既有前缀字节。文本统一不含轮次计数(同类警示文本字节稳定)。
///
/// `log_tag` 为日志正文(不带 "[ai] " 前缀,helper 统一补);各站点特有字段
/// (iteration/loop_count 等)由调用方编入 tag 文本保留定位信息,helper 统一打 conv_id 字段。
pub(crate) async fn insert_system_notice(
session_arc: &Arc<Mutex<AiSession>>,
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.push(ChatMessage::system(text));
tracing::info!(conv_id = %conv_id, "[ai] {}", log_tag);
}
}
/// try_continue_agent_loop 续跑判定所需 session 字段的一次性快照。 /// try_continue_agent_loop 续跑判定所需 session 字段的一次性快照。
struct ContinueSnapshot { struct ContinueSnapshot {
is_generating: bool, is_generating: bool,
@@ -59,17 +96,15 @@ pub async fn try_continue_agent_loop(
Some(cid) => cid, Some(cid) => cid,
None => conv_id.to_string(), 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, total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0, prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0,
// 零 token 收敛信号,非估算(无真实 LLM 调用)。
is_estimated: false, is_estimated: false,
incomplete: None, incomplete: None,
conversation_id: Some(emit_conv_id), conversation_id: Some(emit_conv_id),
pinned_goals: snap.pinned_goals_snapshot.clone(), pinned_goals: snap.pinned_goals_snapshot.clone(),
}; });
let _ = app.emit("ai-chat-event", ev.clone());
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
} }
return; return;
} }
@@ -81,12 +116,11 @@ pub async fn try_continue_agent_loop(
if let Err(e2) = state.conv_states.transition(conv_id, ConvState::Idle) { if let Err(e2) = state.conv_states.transition(conv_id, ConvState::Idle) {
tracing::warn!(conv_id = %conv_id, error = %e2, "conv_states→Idle 非法"); tracing::warn!(conv_id = %conv_id, error = %e2, "conv_states→Idle 非法");
} }
let _ = app.emit("ai-chat-event", AiChatEvent::AiError { // emit/publish 原两端 error_type 不一致(emit=ProviderConfig / publish=None,
error: e.clone(), error_type: Some(ErrorType::ProviderConfig), // 手抄漂移),收敛 emit_ai_chat_event 后统一 ProviderConfig(对齐 emit_fatal_error 先例)。
conversation_id: Some(conv_id.to_string()), emit_ai_chat_event(app, AiChatEvent::AiError {
}); error: e,
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(AiChatEvent::AiError { error_type: Some(ErrorType::ProviderConfig),
error: e, error_type: None,
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
}); });
return; return;
@@ -94,10 +128,10 @@ pub async fn try_continue_agent_loop(
}; };
let lang = snap.agent_language.clone().unwrap_or_else(|| "zh-CN".to_string()); let lang = snap.agent_language.clone().unwrap_or_else(|| "zh-CN".to_string());
let conv_id_owned = conv_id.to_string(); let conv_id_owned = conv_id.to_string();
// Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价;仅 anthropic provider 拆分, // prompt caching 拆分模式组装(开关关 → 与旧路径逐字等价;anthropic 系显式缓存 /
// OpenAI 零影响)。续跑路径无新 @ mention,不注入 augmentation(与旧路径一致)。 // openai_compat 前缀稳定)。续跑路径无新 @ mention,不注入 augmentation(与旧路径一致)。
let cache_on = cache_enabled(state).await let cache_on = cache_enabled(state).await
&& matches!(provider_config.provider_type.as_str(), "anthropic" | "anthropic_compat"); && cache_split_provider_type(&provider_config.provider_type);
let (system_prompt, base_volatile) = let (system_prompt, base_volatile) =
build_system_prompt_parts(state, &lang, &[], &[], cache_on).await; build_system_prompt_parts(state, &lang, &[], &[], cache_on).await;
@@ -133,24 +167,19 @@ pub async fn try_continue_agent_loop(
// B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争,无需 session lock)。 // B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争,无需 session lock)。
let still_generating = state.conv_states.is_active(conv_id); let still_generating = state.conv_states.is_active(conv_id);
if !still_generating { 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, total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0, prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0,
// 零 token 收敛信号,非估算(无真实 LLM 调用)。
is_estimated: false, is_estimated: false,
incomplete: None, incomplete: None,
conversation_id: Some(conv_id_owned.clone()), conversation_id: Some(conv_id_owned.clone()),
pinned_goals: snap.pinned_goals_snapshot.clone(), pinned_goals: snap.pinned_goals_snapshot.clone(),
}; });
let _ = app.emit("ai-chat-event", ev.clone());
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
return; return;
} }
let _ = app.emit("ai-chat-event", AiChatEvent::AiAgentRound { emit_ai_chat_event(app, AiChatEvent::AiAgentRound {
round: 0, conversation_id: Some(conv_id_owned.clone()),
});
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(AiChatEvent::AiAgentRound {
round: 0, conversation_id: Some(conv_id_owned.clone()), round: 0, conversation_id: Some(conv_id_owned.clone()),
}); });
@@ -168,63 +197,19 @@ pub async fn try_continue_agent_loop(
/// 从 LLM 本轮调用的工具中推理目标描述(本轮可能多个,全部提取)。 /// 从 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<u32, crate::commands::ai::ToolCallDraft>) -> Vec<String> { pub(crate) fn infer_goal_from_tool_calls(tool_calls: &std::collections::HashMap<u32, crate::commands::ai::ToolCallDraft>) -> Vec<String> {
if tool_calls.is_empty() { return vec![]; } if tool_calls.is_empty() { return vec![]; }
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
let mut goals: Vec<String> = Vec::new(); let mut goals: Vec<String> = Vec::new();
for tc in tool_calls.values() { for tc in tool_calls.values() {
let goal = match tc.name.as_str() { let parsed = serde_json::from_str::<serde_json::Value>(&tc.args).ok();
"patch_file" | "write_file" | "edit_file" | "append_file" => { let goal = df_ai::ai_tools::goal_of(&tc.name)
serde_json::from_str::<serde_json::Value>(&tc.args).ok() .and_then(|g| df_ai::ai_tools::goal_text(g, parsed.as_ref()));
.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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&tc.args).ok()
.and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| format!("浏览 {}", p)))
}
_ => None,
};
if let Some(g) = goal { if !goals.contains(&g) { goals.push(g); } } if let Some(g) = goal { if !goals.contains(&g) { goals.push(g); } }
} }
goals goals
@@ -259,56 +244,22 @@ fn arg_str(args: &serde_json::Value, key: &str) -> String {
/// 选「决定意图」参数(决定这次调用"去哪儿查什么"的参数),非决定参数(如 case_sensitive/ /// 选「决定意图」参数(决定这次调用"去哪儿查什么"的参数),非决定参数(如 case_sensitive/
/// show_line_numbers/timeout 等开关/格式选项)忽略——它们变体不构成漂移。 /// show_line_numbers/timeout 等开关/格式选项)忽略——它们变体不构成漂移。
/// ///
/// 归一规则(参数从 args JSON 取,缺失用空串): /// 归一规则(DRY 债 #5 收敛:参数组查注册元数据 sig 维度,在 declare_tool! 注册点声明):
/// - grep/search_files/search: `pattern`(或 `query`)+ `path`(或 `glob`) /// - grep/search_files: `pattern`(或 `query` 回退)+ `path`(或 `glob` 回退)
/// — 同 path 换 pattern 是正常换词;同 pattern 同 path 才算重复。 /// — 同 path 换 pattern 是正常换词;同 pattern 同 path 才算重复。
/// - read_file: `path` + `offset` + `limit` /// - read_file: `path` + `offset` + `limit`(后两为数值键,按 JSON 形态序列化)
/// — **同段反复才算重复**;不同 offset = 正常分段读大文件(不算)。 /// — **同段反复才算重复**;不同 offset = 正常分段读大文件(不算)。
/// - run_command: `command`(整条命令,含参数)。 /// - run_command: `command`(整条命令,含参数)。
/// - list_dir/list_directory: `path`。 /// - list_directory: `path`。
/// - http_request: `url` + `method`。 /// - http_request: `url` + `method`;fetch_url: `url`。
/// - fetch_url: `url`。 /// - 其他/未声明: `name` + args 全 JSON 序列化(无明确语义时保守全量,避免漏判)。
/// - 其他/兜底: `name` + args 全 JSON 序列化(无明确语义时保守全量,避免漏判)。
/// ///
/// 返回 `"name:k1=v1,k2=v2"` 形式。输入 args 通常来自 LLM 的 tool_call function.arguments /// 返回 `"name:k1=v1,k2=v2"` 形式。输入 args 通常来自 LLM 的 tool_call function.arguments
/// (JSON 字符串),调用方先 from_str 成 Value 再传入。 /// (JSON 字符串),调用方先 from_str 成 Value 再传入。
/// 旧表中的幽灵分支(search/list_dir,注册表无此工具)随 match 删除一并清理。
pub(crate) fn tool_call_signature(name: &str, args: &serde_json::Value) -> String { pub(crate) fn tool_call_signature(name: &str, args: &serde_json::Value) -> String {
let pair = |k: &str, v: &str| format!("{}={}", k, v); crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
let sig = match name { df_ai::ai_tools::tool_signature(name, args)
"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)
} }
/// G2 重复检测纯函数:判定最近 N 个工具调用签名是否构成「卡住反复」。 /// G2 重复检测纯函数:判定最近 N 个工具调用签名是否构成「卡住反复」。
@@ -372,26 +323,47 @@ pub(crate) fn normalize_loop_path(p: &str) -> String {
p.replace('\\', "/").to_lowercase() p.replace('\\', "/").to_lowercase()
} }
/// 从工具调用提取回环样本:write_file/delete_file 取 path 参数规范化,其他工具 None 占位。 /// 从工具调用提取回环样本:write_file/delete_file 记规范化 path;read_file 记规范化 path
/// path 缺失/非字符串 → None(无路径无从判同路径,退化为占位)。 /// (该路径的「使用」证据);run_command 记整条命令(判定时按「命令含路径」匹配使用);
/// 其他工具 None 占位。path/command 缺失/非字符串 → None(无从判,退化为占位)。
pub(crate) fn destructive_loop_sample(name: &str, args: &serde_json::Value) -> DestructiveLoopSample { pub(crate) fn destructive_loop_sample(name: &str, args: &serde_json::Value) -> DestructiveLoopSample {
match name { let target = match name {
"write_file" | "delete_file" => { "write_file" | "delete_file" | "read_file" => arg_str(args, "path"),
let p = arg_str(args, "path"); "run_command" => arg_str(args, "command"),
if p.is_empty() { None } else { Some((name.to_string(), normalize_loop_path(&p))) } _ => return None,
} };
_ => None, if target.is_empty() { None } else { Some((name.to_string(), normalize_loop_path(&target))) }
}
} }
/// 破坏性回环检测:窗口内「write_file(P) 后 GAP 步内 delete_file(P)」或反向 → 命中路径。 /// 破坏性回环检测:窗口内「write_file(P) 后 GAP 步内 delete_file(P)」或反向 → 命中路径。
/// ///
/// GAP=6(全工具调用序位):写删交替紧凑才判回环,间隔远的写与删是正常迭代修复不算。 /// GAP=6(全工具调用序位):写删交替紧凑才判回环,间隔远的写与删是正常迭代修复不算。
/// 两类豁免(降误伤,治 .trash 临时脚本卫生流程被误熔断):
/// - 路径命中临时/回收模式(.trash、/temp/、/tmp/、/target/、.cache 等):写完即删是
/// 产物卫生,整对豁免;
/// - 写删对之间有对该路径的使用(read 精确命中 / run 命令含路径):用后清理,该对豁免。
/// 真「写完即删用户文件」(非临时路径且中间无使用)仍拦。
/// 返回首个命中对的规范化路径(警示/熔断文本可引用),无命中 → None。 /// 返回首个命中对的规范化路径(警示/熔断文本可引用),无命中 → None。
pub(crate) fn detect_destructive_loop(samples: &[DestructiveLoopSample]) -> Option<String> { pub(crate) fn detect_destructive_loop(samples: &[DestructiveLoopSample]) -> Option<String> {
/// 回环对最大间隔步数(按全部工具调用序位)。 /// 回环对最大间隔步数(按全部工具调用序位)。
const DESTRUCTIVE_LOOP_GAP: usize = 6; const DESTRUCTIVE_LOOP_GAP: usize = 6;
// 临时/回收产物路径(入参须为 normalize_loop_path 后的形式:分隔符统一 /、小写)。
let is_temp_recycle = |p: &str| -> bool {
const MARKERS: &[&str] = &[".trash", "/temp/", "/tmp/", "/target/", ".cache"];
MARKERS.iter().any(|m| p.contains(m))
|| p.starts_with("temp/")
|| p.starts_with("tmp/")
};
// 写删对之间对该路径的使用:read 精确命中;run 按命令文本含路径匹配。
let is_use_between = |s: &DestructiveLoopSample, p: &str| -> bool {
match s.as_ref() {
Some((n, v)) if n == "read_file" => v == p,
Some((n, v)) if n == "run_command" => !p.is_empty() && v.contains(p),
_ => false,
}
};
for i in 0..samples.len() { for i in 0..samples.len() {
let (na, pa) = match samples[i].as_ref() { let (na, pa) = match samples[i].as_ref() {
Some(s) => s, Some(s) => s,
@@ -402,8 +374,14 @@ pub(crate) fn detect_destructive_loop(samples: &[DestructiveLoopSample]) -> Opti
"delete_file" => "write_file", "delete_file" => "write_file",
_ => continue, _ => continue,
}; };
if is_temp_recycle(pa) {
continue;
}
// j 限 (i, i+GAP]:紧凑间隔内找反向同路径操作。 // j 限 (i, i+GAP]:紧凑间隔内找反向同路径操作。
for j in (i + 1)..samples.len().min(i + 1 + DESTRUCTIVE_LOOP_GAP) { for j in (i + 1)..samples.len().min(i + 1 + DESTRUCTIVE_LOOP_GAP) {
if is_use_between(&samples[j], pa) {
break; // 对内有使用=用后清理,该写删对豁免
}
if let Some((nb, pb)) = samples[j].as_ref() { if let Some((nb, pb)) = samples[j].as_ref() {
if pb == pa && nb == opposite { if pb == pa && nb == opposite {
return Some(pa.clone()); return Some(pa.clone());
@@ -479,9 +457,12 @@ mod tests {
} }
#[test] #[test]
fn sig_list_dir_takes_path() { fn sig_list_directory_takes_path() {
let args = serde_json::json!({"path": "/tmp"}); 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] #[test]
@@ -531,7 +512,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": "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": "c.rs", "offset": 0, "limit": 50})),
tool_call_signature("read_file", &serde_json::json!({"path": "d.rs", "offset": 100, "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("grep", &serde_json::json!({"pattern": "STALL", "path": "src"})),
tool_call_signature("read_file", &serde_json::json!({"path": "e.rs", "offset": 0, "limit": 50})), tool_call_signature("read_file", &serde_json::json!({"path": "e.rs", "offset": 0, "limit": 50})),
]; ];
@@ -562,7 +543,7 @@ mod tests {
fn scenario_repeat_same_chunk_repetitive() { fn scenario_repeat_same_chunk_repetitive() {
let same = tool_call_signature("read_file", &serde_json::json!({"path": "a.rs", "offset": 0, "limit": 50})); 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 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]; let sigs = vec![same.clone(), same.clone(), same.clone(), same, other1, other2];
assert!(is_repetitive_exploration(&sigs), "反复读同段应判重复"); assert!(is_repetitive_exploration(&sigs), "反复读同段应判重复");
} }
@@ -660,6 +641,45 @@ mod tests {
assert_eq!(detect_destructive_loop(&samples), Some("p.txt".to_string()), "间隔 6 步 = GAP 应命中"); assert_eq!(detect_destructive_loop(&samples), Some("p.txt".to_string()), "间隔 6 步 = GAP 应命中");
} }
/// 豁免一:临时/回收路径(.trash、/tmp/ 等)写删是产物卫生流程,非自毁,不判回环。
#[test]
fn loop_temp_recycle_path_exempted() {
for p in [".trash/analyze.py", "C:/data/tmp/analyze.py", ".cache/scratch.txt"] {
let samples = loop_samples_of(&[
("write_file", serde_json::json!({"path": p, "content": "x"})),
("delete_file", serde_json::json!({"path": p})),
]);
assert_eq!(detect_destructive_loop(&samples), None, "临时路径 {} 写删应豁免", p);
}
}
/// 豁免二:写删对之间有对该路径的成功使用(run/read)是用后清理,非自毁。
#[test]
fn loop_use_between_pair_not_detected() {
let run_between = loop_samples_of(&[
("write_file", serde_json::json!({"path": "scripts/a.py"})),
("run_command", serde_json::json!({"command": "python scripts/a.py"})),
("delete_file", serde_json::json!({"path": "scripts/a.py"})),
]);
assert_eq!(detect_destructive_loop(&run_between), None, "写后运行过再删是用后清理");
let read_between = loop_samples_of(&[
("write_file", serde_json::json!({"path": "scripts/a.py"})),
("read_file", serde_json::json!({"path": "scripts/a.py"})),
("delete_file", serde_json::json!({"path": "scripts/a.py"})),
]);
assert_eq!(detect_destructive_loop(&read_between), None, "写后读过再删是确认后清理");
}
/// 真自毁仍拦:非临时路径写完即删,对内无任何使用。
#[test]
fn loop_bare_write_delete_still_detected() {
let samples = loop_samples_of(&[
("write_file", serde_json::json!({"path": "out.txt", "content": "x"})),
("delete_file", serde_json::json!({"path": "out.txt"})),
]);
assert_eq!(detect_destructive_loop(&samples), Some("out.txt".to_string()), "无使用的写完即删仍判自毁");
}
/// 场景样本不足:仅 3 个签名(即使全同)。判 false(不判)。 /// 场景样本不足:仅 3 个签名(即使全同)。判 false(不判)。
#[test] #[test]
fn scenario_insufficient_sample_not_repetitive() { fn scenario_insufficient_sample_not_repetitive() {
File diff suppressed because it is too large Load Diff
@@ -46,8 +46,9 @@ pub(crate) fn check_retry_exhausted(
} }
// ── count_recent_failures: L1 断路器失败计数(扁平抽自原嵌套 5 层块) ── // ── count_recent_failures: L1 断路器失败计数(扁平抽自原嵌套 5 层块) ──
// 读末尾连续 Tool 消息,失败包按结构化信号(tool_name + 错误类别)归一 key, // 读末尾连续 Tool 消息,失败包按结构化信号(tool_name + 参数指纹 + 错误类别)归一 key,
// 追加进滚动窗口 fail_window(超窗 CIRCUIT_BREAKER_WINDOW 淘汰最旧)。 // 追加进滚动窗口 fail_window(超窗 CIRCUIT_BREAKER_WINDOW 淘汰最旧)。
// 成功断连:只统计最近一段连续失败段,段中遇过成功先清空窗口(自适应达成即清零)。
// 返回 (max_count, sample_key):窗口内同 key 最高频次 + 该 key;max_count=0 表示无失败。 // 返回 (max_count, sample_key):窗口内同 key 最高频次 + 该 key;max_count=0 表示无失败。
// //
// 两处相对原实现的关键修正(原 key=内容前 40 字符 + 全 loop 累加不衰减): // 两处相对原实现的关键修正(原 key=内容前 40 字符 + 全 loop 累加不衰减):
@@ -72,7 +73,7 @@ pub(crate) async fn count_recent_failures(
None => Vec::new(), None => Vec::new(),
} }
}; };
// 末尾连续 Tool 消息(本轮工具回填结果)。 // 末尾连续 Tool 消息(本轮工具回填结果),最新在前。
let recent_tool_results: Vec<&ChatMessage> = messages let recent_tool_results: Vec<&ChatMessage> = messages
.iter().rev() .iter().rev()
.take_while(|m| matches!(m.role, MessageRole::Tool)) .take_while(|m| matches!(m.role, MessageRole::Tool))
@@ -81,21 +82,20 @@ pub(crate) async fn count_recent_failures(
return (0u32, String::new()); return (0u32, String::new());
} }
let id_to_name = build_id_to_name(&messages); let id_to_name = build_id_to_name(&messages);
// 本轮失败的归一 key 追加进窗口(顺序:消息正序,即工具执行顺序)。 // 成功断连:尾部回扫遇成功 tool_result 即停,只统计「最近一段连续失败段」内的失败;
for m in recent_tool_results.into_iter().rev() { // 段中遇过成功即自适应达成,清空窗口重计。红绿修复循环(失败失败成功失败)的成功
if is_failure_content(&m.content) { // 会切断与历史失败的累计,不再误熔断。
let tool_name = m let (segment_keys, saw_success) = latest_failure_segment(&recent_tool_results, &id_to_name);
.tool_call_id if saw_success {
.as_deref() fail_window.clear();
.and_then(|id| id_to_name.get(id).map(|s| s.as_str())) }
.unwrap_or("unknown_tool"); // 本段失败的归一 key 追加进窗口(顺序:消息正序,即工具执行顺序)。
let key = failure_key(tool_name, &m.content); for key in segment_keys {
fail_window.push_back(key); fail_window.push_back(key);
while fail_window.len() > CIRCUIT_BREAKER_WINDOW { while fail_window.len() > CIRCUIT_BREAKER_WINDOW {
fail_window.pop_front(); fail_window.pop_front();
} }
} }
}
// 窗口内同 key 频次最高者 = 熔断候选。 // 窗口内同 key 频次最高者 = 熔断候选。
let mut freq: std::collections::HashMap<&str, u32> = std::collections::HashMap::new(); let mut freq: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();
for k in fail_window.iter() { for k in fail_window.iter() {
@@ -107,20 +107,59 @@ pub(crate) async fn count_recent_failures(
.unwrap_or((0u32, String::new())) .unwrap_or((0u32, String::new()))
} }
/// 归一失败 key:tool_name + 错误类别。保证同一工具的同类错误稳定映射到同一 key。 /// 提取「最近一段连续失败段」的归一 key(最新在前扫描,遇首个非失败结果即停)。
/// ///
/// - run_command 等带 exit_code → `tool_name::exit=N`(非零退出码即失败类别)。 /// 返回 (段内失败 key 按执行顺序, 回扫是否遇过成功)。成功=模型自适应达成,
/// - status=failed/error envelope → `tool_name::error:<错误文本稳定哈希>`: /// 调用方据第二个返回值清空累计窗口,使红绿修复循环不再与历史失败叠加。
fn latest_failure_segment(
results_newest_first: &[&ChatMessage],
id_map: &std::collections::HashMap<String, (String, String)>,
) -> (Vec<String>, bool) {
let mut keys = Vec::new();
let mut saw_success = false;
for m in results_newest_first {
if !is_failure_content(&m.content) {
saw_success = true;
break;
}
let (tool_name, args_fp) = m
.tool_call_id
.as_deref()
.and_then(|id| id_map.get(id))
.map(|(n, fp)| (n.as_str(), fp.as_str()))
.unwrap_or(("unknown_tool", ""));
keys.push(failure_key(tool_name, args_fp, &m.content));
}
keys.reverse();
(keys, saw_success)
}
/// 参数指纹:args 稳定哈希(8 位十六进制)。解析成功按 serde_json 规范化序列化后哈希
/// (键序稳定),解析失败按原文哈希兜底。参数变=指纹变=失败 key 变:模型改参重试是
/// 学习行为,不应与旧参数的失败累计同 key。指纹非敏感信息,仅断路器分组用。
fn args_fingerprint(args: &str) -> String {
let canonical = serde_json::from_str::<serde_json::Value>(args)
.map(|v| v.to_string())
.unwrap_or_else(|_| args.to_string());
format!("{:08x}", fnv1a_32(canonical.as_bytes()))
}
/// 归一失败 key:tool_name + 参数指纹 + 错误类别。同工具同参数的同类错误稳定映射到
/// 同一 key;参数变=新 key=不累计(改参重试是学习行为)。
///
/// - run_command 等带 exit_code → `tool_name@指纹::exit=N`(非零退出码即失败类别)。
/// - status=failed/error envelope → `tool_name@指纹::failed:<错误文本稳定哈希>`:
/// 用 fxhash 风格简单 a 哈希避免裸截内容前缀(前 40 字符易把同类失败拆多 key /// 用 fxhash 风格简单 a 哈希避免裸截内容前缀(前 40 字符易把同类失败拆多 key
/// 或异类误聚)。哈希值非敏感信息(仅断路器分组用),无碰撞顾虑(N=20 窗口内几无碰撞)。 /// 或异类误聚)。哈希值非敏感信息(仅断路器分组用),无碰撞顾虑(N=20 窗口内几无碰撞)。
/// - 内容解析失败(非 JSON)→ is_failure_content 已返回 false 不会进本函数, /// - 内容解析失败(非 JSON)→ is_failure_content 已返回 false 不会进本函数,
/// 此处兜底返回 `tool_name::unknown` 防御。 /// 此处兜底返回 `tool_name@指纹::unknown` 防御。
fn failure_key(tool_name: &str, content: &str) -> String { fn failure_key(tool_name: &str, args_fp: &str, content: &str) -> String {
let base = format!("{}@{}", tool_name, args_fp);
let Ok(v) = serde_json::from_str::<serde_json::Value>(content) else { let Ok(v) = serde_json::from_str::<serde_json::Value>(content) else {
return format!("{}::unknown", tool_name); return format!("{}::unknown", base);
}; };
if let Some(exit) = v.get("exit_code").and_then(|x| x.as_i64()) { if let Some(exit) = v.get("exit_code").and_then(|x| x.as_i64()) {
return format!("{}::exit={}", tool_name, exit); return format!("{}::exit={}", base, exit);
} }
// 取 error 字段文本做稳定哈希;无 error 字段则按 status 兜底。 // 取 error 字段文本做稳定哈希;无 error 字段则按 status 兜底。
let bucket = match v.get("status").and_then(|s| s.as_str()) { let bucket = match v.get("status").and_then(|s| s.as_str()) {
@@ -134,23 +173,26 @@ fn failure_key(tool_name: &str, content: &str) -> String {
.unwrap_or("") .unwrap_or("")
.trim(); .trim();
if err_text.is_empty() { if err_text.is_empty() {
return format!("{}::{}", tool_name, bucket); return format!("{}::{}", base, bucket);
} }
let h = fnv1a_32(err_text.as_bytes()); let h = fnv1a_32(err_text.as_bytes());
format!("{}::{}:{:08x}", tool_name, bucket, h) format!("{}::{}:{:08x}", base, bucket, h)
} }
/// 构建 tool_call_id → tool_name 反查表(断路器失败分组与 tool_result 摘要压缩共用)。 /// 构建 tool_call_id → (tool_name, 参数指纹) 反查表(断路器失败分组与摘要压缩共用)。
/// ///
/// Tool 结果消息只带 tool_call_id,真实工具名在前驱 Assistant 消息的 /// Tool 结果消息只带 tool_call_id,真实工具名与参数在前驱 Assistant 消息的
/// tool_calls[].function.name 里;查不到的 id 由调用方按各自语义兜底。 /// tool_calls[].function 里;查不到的 id 由调用方按各自语义兜底。
fn build_id_to_name(messages: &[ChatMessage]) -> std::collections::HashMap<String, String> { fn build_id_to_name(
messages: &[ChatMessage],
) -> std::collections::HashMap<String, (String, String)> {
let mut id_to_name = std::collections::HashMap::new(); let mut id_to_name = std::collections::HashMap::new();
for m in messages { for m in messages {
if let MessageRole::Assistant = m.role { if let MessageRole::Assistant = m.role {
if let Some(calls) = m.tool_calls.as_ref() { if let Some(calls) = m.tool_calls.as_ref() {
for c in calls { for c in calls {
id_to_name.insert(c.id.clone(), c.function.name.clone()); let fp = args_fingerprint(&c.function.arguments);
id_to_name.insert(c.id.clone(), (c.function.name.clone(), fp));
} }
} }
} }
@@ -209,23 +251,34 @@ pub(crate) async fn last_round_has_match_evidence(
// 失败 content 由 audit/mod.rs 包成 JSON envelope {"status":"failed","error":...}; // 失败 content 由 audit/mod.rs 包成 JSON envelope {"status":"failed","error":...};
// 成功 content 是 result JSON(run_command 含 exit_code,其他工具无 status 字段即成功)或 namespace:// 占位符。 // 成功 content 是 result JSON(run_command 含 exit_code,其他工具无 status 字段即成功)或 namespace:// 占位符。
// 非 JSON(namespace 占位符 / 重试 guard skip 纯文本)→ 无结构化失败信号 → 不判(false)。 // 非 JSON(namespace 占位符 / 重试 guard skip 纯文本)→ 无结构化失败信号 → 不判(false)。
// run_command 的 exit_code!=0 是脚本语义(测试红绿/grep 无匹配是有效结果,模型改参重试即
// 自适应),不计失败;仅 envelope status=failed(超时/启动失败/拒绝执行)计失败。同命令
// 反复死磕由 G2 签名熔断兜底(run_command 签名=整条命令)。status 先判,防失败 envelope
// 兼带 exit_code 时被脚本语义豁免。
fn is_failure_content(content: &str) -> bool { fn is_failure_content(content: &str) -> bool {
let Ok(v) = serde_json::from_str::<serde_json::Value>(content) else { let Ok(v) = serde_json::from_str::<serde_json::Value>(content) else {
return false; // namespace:// 占位符 / 重试 guard skip 等 → 无结构化失败信号,不判 return false; // namespace:// 占位符 / 重试 guard skip 等 → 无结构化失败信号,不判
}; };
if let Some(exit) = v.get("exit_code").and_then(|x| x.as_i64()) { if matches!(v.get("status").and_then(|s| s.as_str()), Some("failed") | Some("error")) {
return exit != 0; // run_command 等:非零退出 = 真失败 return true;
} }
matches!(v.get("status").and_then(|s| s.as_str()), Some("failed") | Some("error")) if v.get("exit_code").is_some() {
|| v.get("error").is_some() return false; // 脚本语义:结果 JSON 带 exit_code 即已正常执行,非零退出不算失败
}
v.get("error").is_some()
} }
// ── summarize_tool_results: tool_result view-only 摘要压缩(扁平抽自原嵌套 map 块) ── // ── summarize_tool_results: tool_result view-only 摘要压缩(扁平抽自原嵌套 map 块) ──
// 超阈值的 Tool 消息用 extract_key_info 压缩 content;非 Tool / 未超阈值 原样返回。 // 超阈值的 Tool 消息用 extract_key_info 压缩 content;非 Tool / 未超阈值 原样返回。
// 仅改入参 Vec(clone),不改 ContextManager 持久化(下次 build_for_request 从 DB 全量重建)。 // 仅改入参 Vec(clone),不改 ContextManager 持久化(下次 build_for_request 从 DB 全量重建)。
//
// 压缩决策冻结(前缀稳定):判定结果按消息内容哈希记入 `view_decisions`(首次见到时按
// 当轮阈值判定并写入),后续轮只查表沿用、不再随 history_tokens 增长翻转——否则同一条
// 历史第 N 轮发全文、第 N+1 轮变摘要,轮中前缀必断(自动前缀缓存/显式缓存全失效)。
pub(crate) fn summarize_tool_results( pub(crate) fn summarize_tool_results(
messages: Vec<ChatMessage>, messages: Vec<ChatMessage>,
history_tokens_snapshot: u32, history_tokens_snapshot: u32,
view_decisions: &mut std::collections::HashMap<u64, bool>,
conv_id: &str, conv_id: &str,
iteration: usize, iteration: usize,
) -> Vec<ChatMessage> { ) -> Vec<ChatMessage> {
@@ -246,19 +299,16 @@ pub(crate) fn summarize_tool_results(
return m; return m;
} }
let content_len = m.content.len(); let content_len = m.content.len();
let content_tokens = est.estimate_text(&m.content); // 解析真实工具名:tool_call_id 查表(值含参数指纹,此处只取名);查不到退化为 "tool"。
if !should_summarize_tool_result(content_len, history_tokens_snapshot, content_tokens) {
return m;
}
// 解析真实工具名:tool_call_id 查表;查不到(异常/老数据)退化为 "tool"。
let resolved_name = m let resolved_name = m
.tool_call_id .tool_call_id
.as_ref() .as_ref()
.and_then(|id| id_to_name.get(id)) .and_then(|id| id_to_name.get(id))
.map(|s| s.as_str()) .map(|(n, _)| n.as_str())
.unwrap_or("tool"); .unwrap_or("tool");
// read_file 豁免压缩:AI 定向读代码,压缩 content 阉割代码分析能力。 // read_file 豁免压缩:AI 定向读代码,压缩 content 阉割代码分析能力。
// read_file handler 自带 limit 硬上限 2000 行,无爆 prompt 风险。 // read_file handler 自带 limit 硬上限 2000 行,无爆 prompt 风险。
// 豁免判定在决策记录之前(豁免消息不进决策表,每轮恒豁免无漂移)。
if resolved_name == "read_file" { if resolved_name == "read_file" {
tracing::debug!( tracing::debug!(
conv_id = %conv_id, iteration, conv_id = %conv_id, iteration,
@@ -267,6 +317,21 @@ pub(crate) fn summarize_tool_results(
); );
return m; return m;
} }
// 冻结决策:首次见到按当轮阈值判定并记录,后续轮直接沿用(不再随阈值翻转)。
let key = tool_view_decision_key(&m.content);
let compress = match view_decisions.get(&key) {
Some(&decided) => decided,
None => {
let content_tokens = est.estimate_text(&m.content);
let decided =
should_summarize_tool_result(content_len, history_tokens_snapshot, content_tokens);
view_decisions.insert(key, decided);
decided
}
};
if !compress {
return m;
}
original_bytes += content_len; original_bytes += content_len;
let compressed = extract_key_info(&m.content, resolved_name); let compressed = extract_key_info(&m.content, resolved_name);
compressed_bytes += compressed.len(); compressed_bytes += compressed.len();
@@ -283,3 +348,94 @@ pub(crate) fn summarize_tool_results(
} }
msgs msgs
} }
/// tool_result 压缩决策键:内容稳定哈希(u64)。键只做「同内容同决策」关联,
/// 非密码学用途;碰撞的后果仅是 view-only 视图多压/少压一条,可自愈。
fn tool_view_decision_key(content: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
content.hash(&mut h);
h.finish()
}
#[cfg(test)]
mod tests {
use super::*;
use df_ai::provider::ToolCall;
/// 构造 assistant(tool_calls) 一条,返回消息与 id 反查表。
fn map_of(calls: Vec<ToolCall>) -> std::collections::HashMap<String, (String, String)> {
let msgs = vec![ChatMessage::assistant_with_tools("", calls)];
build_id_to_name(&msgs)
}
// B1:参数变计新 key,同参数稳定同 key。
#[test]
fn failure_key_changes_when_args_change() {
let map = map_of(vec![
ToolCall::new("c1", "run_command", r#"{"command":"cargo test foo"}"#),
ToolCall::new("c2", "run_command", r#"{"command":"cargo test bar"}"#),
ToolCall::new("c3", "run_command", r#"{"command":"cargo test foo"}"#),
]);
let (n1, f1) = map.get("c1").unwrap().clone();
let (n2, f2) = map.get("c2").unwrap().clone();
let (n3, f3) = map.get("c3").unwrap().clone();
assert_eq!(n1, "run_command");
assert_ne!(f1, f2, "参数变=指纹变");
assert_eq!(f1, f3, "同参数指纹稳定");
let body = r#"{"status":"failed","error":"boom"}"#;
assert_ne!(
failure_key(&n1, &f1, body),
failure_key(&n2, &f2, body),
"同工具不同参数的失败应计新 key(改参重试是学习行为)"
);
assert_eq!(
failure_key(&n1, &f1, body),
failure_key(&n3, &f3, body),
"同工具同参数同错误应稳定同 key"
);
}
// B2:尾部回扫遇成功即停,只统计最近一段连续失败段。
#[test]
fn latest_failure_segment_stops_at_success() {
let map = map_of(vec![ToolCall::new("c1", "run_command", r#"{"command":"x"}"#)]);
let fail = r#"{"status":"failed","error":"boom"}"#;
let ok = r#"{"exit_code":0,"stdout":"done"}"#;
let msgs = vec![
ChatMessage::tool_result("c1", ok), // 最旧:成功
ChatMessage::tool_result("c1", fail),
ChatMessage::tool_result("c1", fail), // 最新
];
let newest_first: Vec<&ChatMessage> = msgs.iter().rev().collect();
let (keys, saw_success) = latest_failure_segment(&newest_first, &map);
assert_eq!(keys.len(), 2, "只统计最近连续失败段(2 条)");
assert!(saw_success, "回扫遇过成功=自适应达成");
// 最新一条即成功:段空,窗口应清零。
let msgs2 = vec![ChatMessage::tool_result("c1", fail), ChatMessage::tool_result("c1", ok)];
let newest_first2: Vec<&ChatMessage> = msgs2.iter().rev().collect();
let (keys2, saw2) = latest_failure_segment(&newest_first2, &map);
assert!(keys2.is_empty());
assert!(saw2);
// 窗口内无成功:段完整保留,saw_success=false(跨轮累计语义不变)。
let msgs3 = vec![ChatMessage::tool_result("c1", fail), ChatMessage::tool_result("c1", fail)];
let newest_first3: Vec<&ChatMessage> = msgs3.iter().rev().collect();
let (keys3, saw3) = latest_failure_segment(&newest_first3, &map);
assert_eq!(keys3.len(), 2);
assert!(!saw3);
}
// B3:run_command 非零退出是脚本语义不计失败;envelope status=failed 仍计失败。
#[test]
fn run_command_exit_exempt_but_envelope_counts() {
assert!(!is_failure_content(r#"{"command":"cargo test","exit_code":1,"stdout":"FAILED"}"#));
assert!(!is_failure_content(r#"{"command":"grep x .","exit_code":0}"#));
assert!(is_failure_content(r#"{"status":"failed","error":"command timed out after 60s"}"#));
// status 先判:失败 envelope 兼带 exit_code 不被脚本语义豁免。
assert!(is_failure_content(r#"{"status":"failed","exit_code":-1,"error":"spawn failed"}"#));
// 含 error 字样的成功结果(读代码/搜失败字样)仍不判失败。
assert!(!is_failure_content(r#"{"exit_code":0,"stdout":"// error handling"}"#));
}
}
@@ -0,0 +1,667 @@
//! 单轮流式中继 — 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<u32, ToolCallDraft>,
usage: df_ai::provider::TokenUsage,
incomplete: bool,
resolved_model: String,
/// DeepSeek thinking 模式推理内容(需回传到下一轮请求)
reasoning_content: Option<String>,
},
/// 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<String>,
agentic_req: &TaskRequirements,
last_reasoning_content: &Option<String>,
) -> 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<dyn LlmProvider> = 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<dyn LlmProvider>,
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<String>,
) -> 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::<serde_json::Value>(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<u32, ToolCallDraft>,
/// 本轮补全后 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<String>`: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<Mutex<AiSession>>,
db: &Arc<Database>,
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<String>,
agentic_req: &TaskRequirements,
estimated_prompt: u32,
// &mut loop 级状态(函数内修改,caller 可见)
last_round_estimated: &mut bool,
last_reasoning_content: &mut Option<String>,
resolved_model: &mut String,
provider_config: &mut AiProviderRecord,
provider_saturation: &mut HashMap<String, usize>,
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<std::sync::atomic::AtomicU64>,
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<AiProviderRecord> = 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<u32, ToolCallDraft>, df_ai::provider::TokenUsage, bool, Option<String>)> = None;
// 追踪最后一个 Exhausted candidate 的诊断文本,供「全 candidate 耗尽」时 emit 最终 AiError。
// Fatal 分支即时 emit(终态不切候选),Exhausted 分支仅记录 error 不 emit(可能切下一 candidate 成功,
// emit 会留残留气泡)。
let mut last_exhausted_error: Option<String> = 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,
true,
).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<ChatMessage>, tail: &str) {
if tail.is_empty() {
return;
}
messages.push(ChatMessage::user(tail));
}
@@ -0,0 +1,801 @@
//! 子代理循环(spawn_agent v1) — 纯内存子会话 + 拼装既有积木的独立 agentic 循环。
//!
//! 架构定稿:子代理 = 纯内存子会话,不落库、不进侧栏列表、无标题/知识提炼、无终态事件,
//! 最终轮文本即报告经返回值交付(Claude Code Task 语义同构:隔离上下文、一轮回传报告)。
//!
//! ## 零落库机制(单点复用)
//! 子会话 conv_id 启动即写入 `AiSession.deleted_convs`,`save_conversation_inner` 入口检测
//! 到该集合命中即跳过全部 DB 写入。既有路径(含 stream_round 异常分支内的兜底 save)对该
//! conv 一律不产生落库,无需给共享链路加子代理分支。
//!
//! ## 循环拼装(全部复用既有积木,不另起炉灶)
//! - provider 解析:AiProviderRepo.list_all + ProviderPool::select(override 亲和)+ 候选链;
//! - 消息构建:ContextManager::build_for_request(预算裁剪)+ namespace 展开 + 占位配对断言;
//! - 流式调用:stream_round(conv_id 全参数化,candidate 链 + 重试退避 + MidStream 保文);
//! - 工具执行:process_tool_calls(ToolExecPolicy::SubAgent 策略,见 audit/mod.rs);
//! - 熔断:L1 失败断路器与 G2 探索熔断读 per_conv 尾部消息,子循环内天然生效。
//!
//! ## 三重熔断 + 级联取消
//! - max_rounds(默认 6,spawn 参数可调,上限钳制)/ 累计 token 上限 / 挂钟超时,任一触发截断;
//! - 每轮停止检查点兼查:own_stop || 父 stop_flag || 父 per_conv 条目已不存在
//! (父侧 ai_chat_stop / ai_chat_force_send / ai_conversation_delete 经 SubAgentRegistry 级联)。
//!
//! ## 事件与展示边界
//! 仅在结束时挂父会话 emit 一条 AiAgentFinished(前端展示二期消费);不 emit AiCompleted /
//! AiMaxRounds 终态事件、不 spawn 标题/知识提炼。stream_round 复用带来的残留:其 Fatal 分支
//! 会向子 conv emit AiError、MidStream 分支会触发一次子 conv 的后台标题任务(写库被上者拦,
//! 仅一次无效 LLM 调用),均为网络异常罕见路径,接受并记录于此。
use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tauri::AppHandle;
use tokio::sync::Mutex;
use df_ai::ai_tools::AiToolRegistry;
use df_ai::context::{ContextManager, TokenEstimator};
use df_ai::context_helpers::PLACEHOLDER_INTEGRITY_ENABLED;
use df_ai::provider::{ChatMessage, MessageRole, ToolDefinition};
use df_ai::router::{Modality, TaskRequirements};
use df_storage::crud::AiProviderRepo;
use df_storage::db::Database;
use crate::commands::ai::agentic::{
CIRCUIT_BREAKER_ENABLED, CIRCUIT_BREAKER_THRESHOLD, DEFAULT_MAX_AGENT_RETRIES,
STALL_BREAKER_ENABLED,
};
use crate::commands::ai::audit::{process_tool_calls, ToolExecPolicy};
use crate::commands::ai::conversation::{truncate_for_persist, TokenAccumulator};
use crate::commands::ai::event_bus::emit_ai_chat_event;
use crate::commands::ai::AiChatEvent;
use crate::state::LlmConcurrency;
use super::guard::GeneratingGuard;
use crate::commands::ai::provider_pool::ProviderPool;
use super::router_setup::resolve_model_with_override;
use super::{
check_stall_breaker, count_recent_failures, push_assistant_message, stream_round,
summarize_tool_results, tokens_snapshot, RoundOutcome,
};
// ============================================================
// 预算常量(三重熔断)
// ============================================================
/// 子代理默认最大工具循环轮数(spawn 参数 max_rounds 缺省值,透传可调)。
pub(crate) const SUB_AGENT_DEFAULT_MAX_ROUNDS: u32 = 6;
/// max_rounds 入口钳制上限(防 spawn 参数放大预算失控)。
pub(crate) const SUB_AGENT_MAX_ROUNDS_CAP: u32 = 20;
/// 累计 token 上限(prompt+completion 总量,超即截断退出)。
/// 对齐主对话 64 万 prompt 实证事故的量级收敛:子代理预算显著小于主会话。
pub(crate) const SUB_AGENT_MAX_TOTAL_TOKENS: u64 = 200_000;
/// 挂钟超时(整个子循环,含 LLM 流式 + 工具执行)。
/// 与审批超时默认值(15 分钟)同量级:父侧 ai_approve / execute_with_heartbeat 的
/// 外层超时对 spawn_agent 取同值,保证外层不先于子循环自腰斩。
pub(crate) const SUB_AGENT_WALL_TIMEOUT_SECS: u64 = 900;
// ============================================================
// 结果类型
// ============================================================
/// 子代理终态。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SubAgentStatus {
/// 收敛轮正常结束(末轮无 tool_calls,full_text 即报告)
Completed,
/// 任一熔断/取消/流中断截断(报告为末轮文本或可读说明)
Truncated,
/// 启动失败或运行期致命错误(报告为错误说明)
Failed,
}
impl SubAgentStatus {
pub(crate) fn as_str(self) -> &'static str {
match self {
SubAgentStatus::Completed => "completed",
SubAgentStatus::Truncated => "truncated",
SubAgentStatus::Failed => "failed",
}
}
}
/// 子代理执行结果(经 spawn_agent 工具返回值交付给父 LLM)。
#[derive(Debug, Clone)]
pub(crate) struct SubAgentOutcome {
pub(crate) status: SubAgentStatus,
pub(crate) summary: String,
pub(crate) rounds: u32,
pub(crate) tokens: u64,
}
impl SubAgentOutcome {
/// 启动期失败快捷构造(rounds/tokens 为 0)。
pub(crate) fn failed(msg: impl Into<String>) -> Self {
Self {
status: SubAgentStatus::Failed,
summary: msg.into(),
rounds: 0,
tokens: 0,
}
}
}
// ============================================================
// 级联取消注册表(parent conv_id → children child conv_ids)
// ============================================================
/// 短临界区读写(注册/注销/遍历均为无 await 纯内存操作),用 std Mutex 避免跨 await 持锁。
pub(crate) type SubAgentRegistry = Arc<std::sync::Mutex<HashMap<String, Vec<String>>>>;
/// 构造空注册表(AppState::init 调用一次,Arc 进 AppState 与 SpawnAgentCtx 共享)。
pub(crate) fn new_registry() -> SubAgentRegistry {
Arc::new(std::sync::Mutex::new(HashMap::new()))
}
/// spawn 前注册(parent → child 幂等追加)。
pub(crate) fn sub_agents_register(reg: &SubAgentRegistry, parent: &str, child: &str) {
let mut map = reg.lock().unwrap_or_else(|e| e.into_inner());
let entry = map.entry(parent.to_string()).or_default();
if !entry.iter().any(|c| c == child) {
entry.push(child.to_string());
}
}
/// 子循环结束后注销(child 移除,父条目空则整条清掉,防 HashMap 无限增长)。
pub(crate) fn sub_agents_remove(reg: &SubAgentRegistry, parent: &str, child: &str) {
let mut map = reg.lock().unwrap_or_else(|e| e.into_inner());
if let Some(entry) = map.get_mut(parent) {
entry.retain(|c| c != child);
if entry.is_empty() {
map.remove(parent);
}
}
}
/// 取父会话当前派生的子代理列表(级联取消入口用;无条目返空 Vec)。
pub(crate) fn sub_agents_children(reg: &SubAgentRegistry, parent: &str) -> Vec<String> {
reg.lock()
.unwrap_or_else(|e| e.into_inner())
.get(parent)
.cloned()
.unwrap_or_default()
}
/// 级联取消:父会话被停止/强制发送/删除时遍历 children 置 stop_flag + notify。
///
/// 调用方(ai_chat_stop / ai_chat_force_send / ai_conversation_delete)须已持 session 锁,
/// 本函数短临界区纯内存操作(无 await),与「禁止持 session 锁跨 await」纪律兼容。
/// 仅触碰已存在的子会话条目(不惰性重建,防取消动作反而制造空条目残留);
/// 子循环停止检查点读 own stop_flag 即退出,父侧条目消失/父 stop 是兜底判据。
pub(crate) fn cascade_stop_children_locked(
session: &mut crate::commands::ai::AiSession,
reg: &SubAgentRegistry,
parent: &str,
) {
for child in sub_agents_children(reg, parent) {
if let Some(conv) = session.per_conv.get_mut(&child) {
conv.stop_flag.store(true, Ordering::SeqCst);
conv.notify.notify_one();
}
}
}
// ============================================================
// child_id 与纯函数
// ============================================================
/// 子代理序号(进程级单调递增,保证 child_id 全程唯一,防 per_conv 条目串号)。
static SUB_AGENT_SEQ: AtomicU64 = AtomicU64::new(0);
/// 生成子会话 id:形态 `{parent}::agent-{n}`,同父会话内单调递增。
pub(crate) fn next_child_id(parent: &str) -> String {
let n = SUB_AGENT_SEQ.fetch_add(1, Ordering::SeqCst) + 1;
format!("{}::agent-{}", parent, n)
}
/// 白名单钳制(写入死在 spawn 入口,不依赖 LLM 自觉):
/// - 候选 = tools 参数非空时取其与注册表 Low 风险工具集的交集;缺省时取全量 Low 工具集;
/// - 恒剔除 spawn_agent 自身(禁递归);
/// - 返回 (allowed 白名单, trimmed 被裁剪项[供任务书尾注说明])。两侧均去重排序(确定性)。
pub(crate) fn clamp_tool_whitelist(
registry: &AiToolRegistry,
requested: &[String],
) -> (Vec<String>, Vec<String>) {
let low_set: std::collections::BTreeSet<String> = registry
.tool_names()
.into_iter()
.filter(|n| {
registry
.get(n)
.map(|t| matches!(t.risk_level, df_ai::ai_tools::RiskLevel::Low))
.unwrap_or(false)
})
.collect();
let base: std::collections::BTreeSet<String> = if requested.is_empty() {
low_set.iter().cloned().collect()
} else {
requested
.iter()
.filter(|n| low_set.contains(*n))
.cloned()
.collect()
};
let allowed: Vec<String> = base
.into_iter()
.filter(|n| n != "spawn_agent")
.collect();
let trimmed: Vec<String> = if requested.is_empty() {
Vec::new()
} else {
requested
.iter()
.filter(|n| !allowed.contains(*n))
.cloned()
.collect()
};
(allowed, trimmed)
}
/// 单轮预算熔断判定(prepare 段前调用)。返回 Some(原因) 即应截断退出,None 继续。
/// 纯函数抽出供单测锁定三路熔断语义(挂钟超时 / token 上限 / 轮次上限)。
pub(crate) fn round_budget_stop(
round: u32,
max_rounds: u32,
tokens_total: u64,
deadline: tokio::time::Instant,
now: tokio::time::Instant,
) -> Option<String> {
if now >= deadline {
return Some(format!("挂钟超时({}s)", SUB_AGENT_WALL_TIMEOUT_SECS));
}
if tokens_total >= SUB_AGENT_MAX_TOTAL_TOKENS {
return Some(format!("累计 token 达上限({})", SUB_AGENT_MAX_TOTAL_TOKENS));
}
if round >= max_rounds {
return Some(format!("达最大轮次上限({})", max_rounds));
}
None
}
/// 子代理专用 system prompt(要点:面向父任务执行、最终回复即结论报告、无审批通道)。
pub(crate) fn sub_agent_system_prompt() -> String {
"你是 DevFlow 主会话派生的子代理,在隔离上下文中独立执行父任务交办的任务书。\n\
## 定位\n\
- 面向父任务执行:只做任务书范围内的事,用户看不到你的过程,无法与你交互或审批\n\
- 探索/检索/核对类工作用工具完成,结论写进最终回复\n\
- 你没有审批通道:路径授权申请与中高风险工具会被直接拒绝并返回失败结果,\n\
遇到被拒绝的操作改用已授权方式完成,或在报告中说明缺口\n\
- 控制轮次:默认最多 6 轮,收敛即停,不做任务书之外的无关探索\n\
## 输出要求\n\
- 最终回复就是交付给父任务的结论报告,不要寒暄、不要向用户提问\n\
- 报告结构:结论先行,然后列关键发现/证据/数据,最后列未尽事项与建议"
.to_string()
}
// ============================================================
// 主循环
// ============================================================
/// 运行一个子代理循环(由 spawn_agent 工具同步 await,报告经返回值交付)。
///
/// 单一出口:inner 跑完后统一 emit AiAgentFinished(挂父会话)+ 清理子会话内存条目,
/// 保证启动失败路径也有一致的终态事件与无残留清理。
#[allow(clippy::too_many_arguments)]
pub(crate) async fn run_sub_agent_loop(
session_arc: Arc<Mutex<crate::commands::ai::AiSession>>,
tools_arc: Arc<AiToolRegistry>,
db: Arc<Database>,
app_handle: AppHandle,
parent_conv_id: String,
child_conv_id: String,
system_prompt: String,
task_prompt: String,
tool_defs: Vec<ToolDefinition>,
max_rounds: u32,
model_override: Option<String>,
llm_concurrency: LlmConcurrency,
) -> SubAgentOutcome {
let outcome = run_sub_agent_loop_inner(
session_arc.clone(), tools_arc, db, app_handle.clone(),
parent_conv_id.clone(), child_conv_id.clone(),
system_prompt, task_prompt, tool_defs,
max_rounds, model_override, llm_concurrency.clone(),
).await;
// 统一出口:挂父会话 emit 终态(前端展示二期消费;报告本体经父 tool_result 返回值交付)
emit_ai_chat_event(&app_handle, AiChatEvent::AiAgentFinished {
agent_id: child_conv_id.clone(),
conversation_id: Some(parent_conv_id),
summary: outcome.summary.clone(),
status: outcome.status.as_str().to_string(),
rounds: outcome.rounds,
tokens: outcome.tokens,
});
// 清理子会话内存条目(报告已提取,纯内存子会话不留残迹;审计表记录保留可追溯)
{
let mut session = session_arc.lock().await;
session.per_conv.remove(&child_conv_id);
session.save_locks.remove(&child_conv_id);
}
llm_concurrency.release_conv(&child_conv_id).await;
outcome
}
/// 子循环本体(结果型返回,终态事件与清理由 run_sub_agent_loop 统一收口)。
#[allow(clippy::too_many_arguments)]
async fn run_sub_agent_loop_inner(
session_arc: Arc<Mutex<crate::commands::ai::AiSession>>,
tools_arc: Arc<AiToolRegistry>,
db: Arc<Database>,
app_handle: AppHandle,
parent_conv_id: String,
child_conv_id: String,
system_prompt: String,
task_prompt: String,
tool_defs: Vec<ToolDefinition>,
max_rounds: u32,
model_override: Option<String>,
llm_concurrency: LlmConcurrency,
) -> SubAgentOutcome {
// 挂钟 deadline(三重熔断之三)
let deadline = tokio::time::Instant::now()
+ std::time::Duration::from_secs(SUB_AGENT_WALL_TIMEOUT_SECS);
// 并发份额:global + per_conv(child) 双非阻塞 try_acquire,任一耗尽即 failed
// (子代理不阻塞等待,防占满父 loop 的执行槽与全局配额)
let (_global_permit, _conv_permit) = match (
llm_concurrency.try_acquire_global().await,
llm_concurrency.try_acquire_per_conv(&child_conv_id).await,
) {
(Some(g), Some(c)) => (g, c),
_ => {
return SubAgentOutcome::failed(
"子代理启动失败:LLM 并发份额已占满(global 或 per_conv),请稍后重试或调大并发配置",
);
}
};
// ── 子会话初始化(一次锁):建 child per_conv + push 任务书 + deleted_convs 零落库标记 ──
let (child_stop, child_notify, child_epoch) = {
let mut session = session_arc.lock().await;
// 父会话已不存在(被删除/清理)→ 不启动
if !session.per_conv.contains_key(&parent_conv_id) {
return SubAgentOutcome::failed("子代理启动失败:父会话已不存在(可能已被删除)");
}
// 零落库(关键):deleted_convs 使 save_conversation 入口对该 conv 一律跳过,
// 覆盖 stream_round 异常分支兜底 save 等所有既有写库路径(见模块头文档)。
// 先于 conv() 调用(insert 需可变借 session,避免与 conv 借用冲突)
session.deleted_convs.insert(child_conv_id.clone());
let conv = session.conv(&child_conv_id);
conv.messages.push(ChatMessage::user(&task_prompt));
conv.stop_flag.store(false, Ordering::SeqCst);
conv.iteration_used = 0;
(conv.stop_flag.clone(), conv.notify.clone(), conv.loop_epoch.clone())
};
let my_epoch = child_epoch.load(Ordering::SeqCst);
// 生成态 guard:复用主循环 RAII(Generating→Idle 迁移 + 异常 Drop 兜底),子会话状态机一致
let mut guard =
GeneratingGuard::new(child_conv_id.clone(), child_epoch.clone(), my_epoch, app_handle.clone());
// ── provider 解析(30s 超时,对齐主 loop 入口;失败走 failed 返回值,不 emit AiError)──
let (primary_provider, candidates) = {
let resolve = tokio::time::timeout(std::time::Duration::from_secs(30), async {
let provider_repo = AiProviderRepo::new(&db);
let pool = match provider_repo.list_all().await {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "[sub-agent] list_all providers 失败,空池处理");
Vec::new()
}
};
// override 作为亲和键(用户指定模型优先选含该模型的 provider),与主 loop 同款
let ranked = ProviderPool::select(&pool, model_override.as_deref());
match ranked.split_first() {
Some((first, rest)) => Some((first.clone(), rest.to_vec())),
None => None,
}
})
.await;
match resolve {
Ok(Some((primary, candidates))) => (primary, candidates),
Ok(None) => {
return SubAgentOutcome::failed("子代理启动失败:无可用 AI provider(请检查 provider 配置)");
}
Err(_) => {
return SubAgentOutcome::failed("子代理启动失败:provider 解析超时(30s),请检查数据库/钥匙串状态");
}
}
};
// resolved_model 初值 + 后续 candidate 切换覆盖(与主 loop 同款;
// resolve_model_with_override 内部 select_model_id 池空兜底 default_model + override 穿透)
let req_probe = TaskRequirements {
modalities: vec![Modality::Text],
needs_tool_use: true,
estimated_context: 0,
tier: None,
};
let mut resolved_model = resolve_model_with_override(&req_probe, &primary_provider, &model_override);
let mut provider_config = primary_provider;
// system_prompt 是整个子循环的不变量,token 估算一次缓存复用(对齐主 loop)
let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt);
// 循环级累计状态(对齐主 loop 同名变量语义)
let mut tokens = TokenAccumulator::default();
let mut saved_token_snapshot = df_ai::provider::TokenUsage::default();
let mut last_round_estimated = false;
let mut last_reasoning_content: Option<String> = None;
let mut provider_saturation: HashMap<String, usize> = HashMap::new();
let mut fail_window: VecDeque<String> = VecDeque::new();
let (mut stall_count, mut stall_warned, mut loop_count, mut loop_warned) = (0u32, false, 0u32, false);
let mut rounds_done: u32 = 0;
// for 耗尽(每轮都有 tool_calls 未收敛)默认截断;各 break 点按语义覆写
let mut status = SubAgentStatus::Truncated;
let mut stop_reason = String::new();
let mut report = String::new();
for round in 0..max_rounds {
// ── 停止检查点 + 预算检查(均在 prepare 段前)──
if let Some(reason) = cancellation_reason(&session_arc, &child_conv_id, &parent_conv_id).await {
status = SubAgentStatus::Truncated;
stop_reason = format!("已被取消({})", reason);
break;
}
let total_now = tokens_snapshot(&tokens).total_tokens as u64;
if let Some(reason) = round_budget_stop(round, max_rounds, total_now, deadline, tokio::time::Instant::now()) {
status = SubAgentStatus::Truncated;
stop_reason = reason;
break;
}
// ── prepare:build_for_request(预算裁剪)+ system prompt + namespace 展开 +
// tool_result 视图压缩 + 占位配对断言 + 输入 token 估值(对齐主 loop prepare 段)──
let (messages, estimated_prompt) = {
// mut:压缩决策冻结写回 per_conv(短锁段,无 await 夹杂)
let mut session = session_arc.lock().await;
let Some(conv) = session.conv_read(&child_conv_id) else {
status = SubAgentStatus::Failed;
stop_reason = "子会话条目已消失".to_string();
break;
};
let (mut history_msgs, _trimmed) = conv.messages.build_for_request(sys_tokens);
// namespace 引用展开为真实内容(view-only,仅本轮请求视图,对齐主 loop)
for m in &mut history_msgs {
if df_ai::namespace_store::is_namespace_ref(&m.content) {
let path = m.content.clone();
match session.namespace_store.read_only(&path) {
Some(original) => m.content = original.to_string(),
None => {
m.content = df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string();
}
}
}
}
let mut msgs = vec![ChatMessage::system(&system_prompt)];
msgs.extend(history_msgs);
let history_tokens = conv.messages.history_tokens();
// tool_result 大输出 view-only 摘要 + 占位配对断言(与主 loop 同款防线)。
// 压缩决策同主 loop 冻结在 per_conv(子会话条目),跨轮不随阈值翻转。
let mut view_decisions = conv.tool_view_decisions.clone();
let msgs = summarize_tool_results(msgs, history_tokens, &mut view_decisions, &child_conv_id, round as usize);
if !view_decisions.is_empty() {
session.conv(&child_conv_id).tool_view_decisions = view_decisions;
}
let msgs = ContextManager::assert_placeholder_pairing(msgs, PLACEHOLDER_INTEGRITY_ENABLED);
let est = TokenEstimator::default();
let estimated_prompt: u32 = msgs.iter().map(|m| est.estimate_message(m)).sum();
(msgs, estimated_prompt)
};
// ── 流式消费(复用 stream_round:candidate 链 + 重试退避 + MidStream 保文)──
let agentic_req = TaskRequirements {
modalities: vec![Modality::Text],
needs_tool_use: true,
estimated_context: estimated_prompt as usize,
tier: None,
};
match stream_round(
&session_arc, &db, &child_conv_id,
&messages, &tool_defs, &app_handle,
&child_stop, &child_notify,
DEFAULT_MAX_AGENT_RETRIES, &model_override, &agentic_req, estimated_prompt,
&mut last_round_estimated, &mut last_reasoning_content,
&mut resolved_model, &mut provider_config, &mut provider_saturation,
&mut tokens, &mut saved_token_snapshot, &mut guard,
&candidates, &llm_concurrency,
// 子会话无目标钉扎,空快照(该参数仅供主 loop AiCompleted 快照透传)
&[],
&child_epoch, my_epoch,
).await {
RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated } => {
rounds_done += 1;
tokens.add_usage(&round_usage);
let has_tool_calls = !tool_calls.is_empty();
// assistant 消息入子会话(供下轮 build_for_request 与熔断读尾部;
// 不落库,deleted_convs 拦截)
{
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(&child_conv_id) {
push_assistant_message(
&mut session, &child_conv_id, has_tool_calls, &tool_calls,
&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 let Some(reason) = cancellation_reason(&session_arc, &child_conv_id, &parent_conv_id).await {
status = SubAgentStatus::Truncated;
stop_reason = format!("已被取消({})", reason);
break;
}
if !has_tool_calls {
// 收敛轮:末轮 full_text 即报告自摘要
status = SubAgentStatus::Completed;
report = full_text;
break;
}
// ── 工具执行(SubAgent 策略:NeedsAuth/中高风险自动 deny,零待审批挂起)──
let pending = process_tool_calls(
&session_arc, tool_calls, &tools_arc, &db, &app_handle, &child_conv_id,
ToolExecPolicy::SubAgent,
).await;
if pending > 0 {
// 防御:SubAgent 策略下不应产生挂起;出现即终止(子代理无审批恢复通道)
status = SubAgentStatus::Failed;
stop_reason = format!("子代理策略下出现意外审批挂起({} 项),终止", pending);
break;
}
// L1 失败断路器(读子会话尾部消息,机制与主 loop 一致)
let (max_count, sample_key) =
count_recent_failures(&session_arc, &child_conv_id, &mut fail_window).await;
if CIRCUIT_BREAKER_ENABLED && max_count >= CIRCUIT_BREAKER_THRESHOLD {
status = SubAgentStatus::Failed;
stop_reason = format!(
"连续同类工具失败 {} 次(如 {}),已熔断",
max_count, sample_key
);
break;
}
// G2 探索熔断 + 写删回环熔断(读子会话尾部签名,机制与主 loop 一致)
let (tripped, nc, nw, lc, lw) = check_stall_breaker(
&session_arc, &app_handle, &child_conv_id, round as usize,
stall_count, stall_warned, loop_count, loop_warned,
).await;
stall_count = nc;
stall_warned = nw;
loop_count = lc;
loop_warned = lw;
if STALL_BREAKER_ENABLED && tripped {
status = SubAgentStatus::Failed;
stop_reason = "重复工具调用/写删回环探索熔断".to_string();
break;
}
}
// stream_round 异常路径已完成其既有收尾(见模块头"事件与展示边界"残留说明)
RoundOutcome::Fatal => {
status = SubAgentStatus::Failed;
stop_reason = "LLM 调用失败(全候选 provider 耗尽或致命错误)".to_string();
break;
}
RoundOutcome::MidStreamSaved => {
status = SubAgentStatus::Truncated;
stop_reason = "流中途中断,已保文".to_string();
break;
}
RoundOutcome::StaleAfterStream => {
status = SubAgentStatus::Failed;
stop_reason = "子会话状态被外部接管".to_string();
break;
}
}
}
// for 正常耗尽(每轮均有工具调用未收敛)→ 补截断原因
if status == SubAgentStatus::Truncated && stop_reason.is_empty() {
stop_reason = format!("达最大轮次上限({})", max_rounds);
}
// ── 报告提取:收敛轮 full_text 优先;截断/失败取末条 assistant 文本附终止说明;再无则用原因 ──
let total_tokens = tokens_snapshot(&tokens).total_tokens as u64;
let summary = if !report.trim().is_empty() {
// 父侧注入前超长截断复用现有 truncate_for_persist(头尾保留语义一致)
truncate_for_persist(report.trim())
} else {
match last_assistant_text(&session_arc, &child_conv_id).await {
Some(text) => {
let combined = if stop_reason.is_empty() {
text
} else {
format!("{}\n\n(子代理提前终止:{})", text, stop_reason)
};
truncate_for_persist(combined.trim())
}
None if !stop_reason.is_empty() => format!("子代理未产出报告:{}", stop_reason),
None => "子代理未产出报告".to_string(),
}
};
tracing::info!(
parent = %parent_conv_id,
agent = %child_conv_id,
status = status.as_str(),
rounds = rounds_done,
tokens = total_tokens,
summary_len = summary.len(),
"[sub-agent] 子代理结束"
);
guard.reset().await;
SubAgentOutcome {
status,
summary,
rounds: rounds_done,
tokens: total_tokens,
}
}
/// 停止检查点:own_stop || 父 stop_flag || 父 per_conv 条目已不存在(级联取消判据)。
/// 短锁段纯读,无 await 于锁内。
async fn cancellation_reason(
session_arc: &Arc<Mutex<crate::commands::ai::AiSession>>,
child_id: &str,
parent_id: &str,
) -> Option<String> {
let session = session_arc.lock().await;
match session.conv_read(parent_id) {
None => Some("父会话已不存在".to_string()),
Some(parent) if parent.stop_flag.load(Ordering::SeqCst) => Some("父会话已停止".to_string()),
_ => match session.conv_read(child_id) {
Some(child) if child.stop_flag.load(Ordering::SeqCst) => Some("收到停止信号".to_string()),
None => Some("子会话条目已消失".to_string()),
_ => None,
},
}
}
/// 取子会话末条非空 assistant 文本(截断/失败路径的报告兜底)。
async fn last_assistant_text(
session_arc: &Arc<Mutex<crate::commands::ai::AiSession>>,
child_id: &str,
) -> Option<String> {
let session = session_arc.lock().await;
let msgs = session.conv_read(child_id)?.messages.all_messages_clone();
msgs.into_iter()
.rev()
.find(|m| matches!(m.role, MessageRole::Assistant))
.map(|m| m.content)
.filter(|c| !c.trim().is_empty())
}
// ============================================================
// 单元测试:白名单钳制 / max_rounds 熔断 / registry 级联注册移除
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
use df_ai::ai_tools::{object_schema, RiskLevel};
/// 构造测试注册表:含 Low/Medium/High 各档 + 一个 Low 的 spawn_agent(验证恒剔除)。
///
/// 测试工具名用 t_ 前缀隔离:register 会把 ToolMeta::default 写入进程级全局元数据表,
/// 若复用生产工具名(read_file 等)会覆盖真实元数据,令依赖全局表的消费方单测
/// (cache/path_auth/helpers 经 ensure_tool_meta_seeded)出现顺序依赖污染。
/// spawn_agent 保留真名:恒剔除语义锁的就是这个名字,且无任何测试断言其元数据。
fn test_registry() -> AiToolRegistry {
// 每 register 独立构造 handler(AiToolHandler 是 Box 非 Copy,复用同一实例会 move 报错)
fn dummy_handler() -> df_ai::ai_tools::AiToolHandler {
Box::new(|_args| Box::pin(async { Ok(serde_json::json!({})) }))
}
let mut reg = AiToolRegistry::new();
reg.register("t_read_only", "只读", object_schema(vec![]), RiskLevel::Low, dummy_handler());
reg.register("spawn_agent", "派发", object_schema(vec![]), RiskLevel::Low, dummy_handler());
reg.register("t_create", "创建", object_schema(vec![]), RiskLevel::Medium, dummy_handler());
reg.register("t_exec", "执行", object_schema(vec![]), RiskLevel::High, dummy_handler());
reg
}
/// 白名单钳制:显式请求中剔除高风险与 spawn_agent 自身,未知名进 trimmed。
#[test]
fn clamp_drops_high_risk_and_self() {
let reg = test_registry();
let requested = vec![
"t_read_only".to_string(),
"t_exec".to_string(),
"t_create".to_string(),
"spawn_agent".to_string(),
"nonexistent".to_string(),
];
let (allowed, trimmed) = clamp_tool_whitelist(&reg, &requested);
assert_eq!(allowed, vec!["t_read_only".to_string()], "仅 Low 工具可保留");
assert!(trimmed.contains(&"t_exec".to_string()), "High 应被裁剪");
assert!(trimmed.contains(&"t_create".to_string()), "Medium 应被裁剪");
assert!(trimmed.contains(&"spawn_agent".to_string()), "spawn_agent 应恒剔除(禁递归)");
assert!(trimmed.contains(&"nonexistent".to_string()), "未注册名应进裁剪说明");
}
/// 白名单钳制:tools 缺省 = 全量 Low 工具集(仍剔 spawn_agent)。
#[test]
fn clamp_default_is_low_set_without_self() {
let reg = test_registry();
let (allowed, trimmed) = clamp_tool_whitelist(&reg, &[]);
assert_eq!(allowed, vec!["t_read_only".to_string()]);
assert!(trimmed.is_empty(), "缺省请求不产生裁剪尾注");
}
/// max_rounds 熔断:轮次/token/挂钟三路均能触发且带可读原因;预算内返 None。
#[test]
fn round_budget_trips_on_rounds_tokens_and_deadline() {
let now = tokio::time::Instant::now();
let deadline = now + std::time::Duration::from_secs(60);
// 预算内:继续
assert_eq!(round_budget_stop(0, 6, 0, deadline, now), None);
// 轮次熔断
let r = round_budget_stop(6, 6, 0, deadline, now).unwrap();
assert!(r.contains("轮次"), "轮次原因: {}", r);
// token 熔断
let r = round_budget_stop(1, 6, SUB_AGENT_MAX_TOTAL_TOKENS, deadline, now).unwrap();
assert!(r.contains("token"), "token 原因: {}", r);
// 挂钟超时熔断(优先级最高)
let past = now - std::time::Duration::from_secs(1);
let r = round_budget_stop(1, 6, 0, past, now).unwrap();
assert!(r.contains("超时"), "超时原因: {}", r);
}
/// registry 级联:注册幂等追加、children 可查、注销移除且父条目空时整条清掉。
#[test]
fn sub_agent_registry_register_remove() {
let reg = new_registry();
sub_agents_register(&reg, "parent", "parent::agent-1");
sub_agents_register(&reg, "parent", "parent::agent-2");
sub_agents_register(&reg, "parent", "parent::agent-1"); // 幂等
assert_eq!(
sub_agents_children(&reg, "parent"),
vec!["parent::agent-1".to_string(), "parent::agent-2".to_string()]
);
assert!(sub_agents_children(&reg, "other").is_empty(), "无条目父返空");
sub_agents_remove(&reg, "parent", "parent::agent-1");
assert_eq!(
sub_agents_children(&reg, "parent"),
vec!["parent::agent-2".to_string()]
);
sub_agents_remove(&reg, "parent", "parent::agent-2");
// 父条目空后整条清除,不残留空键
{
let map = reg.lock().unwrap();
assert!(map.is_empty(), "父条目空应整条清除");
}
// 注销不存在的条目不 panic
sub_agents_remove(&reg, "ghost", "ghost::agent-1");
}
/// child_id 形态:单调递增且含父前缀。
#[test]
fn next_child_id_shape() {
let a = next_child_id("conv-x");
let b = next_child_id("conv-x");
assert!(a.starts_with("conv-x::agent-"));
assert!(b.starts_with("conv-x::agent-"));
assert_ne!(a, b, "同父连续生成的 child_id 必须唯一");
}
/// 子代理 system prompt:含面向父任务与最终回复即报告的关键约束。
#[test]
fn sub_agent_prompt_mentions_report_contract() {
let p = sub_agent_system_prompt();
assert!(p.contains("子代理"));
assert!(p.contains("最终回复"));
assert!(p.contains("审批通道"), "应说明无审批通道(防挂死设计的前提告知)");
}
}
+15 -9
View File
@@ -7,7 +7,7 @@
use std::sync::Arc; use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager}; use tauri::AppHandle;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
@@ -16,6 +16,7 @@ use df_storage::crud::AiToolExecutionRepo;
use df_storage::db::Database; use df_storage::db::Database;
use super::cache::{cache_hit_warning, find_cached_high_risk_result, pending_placeholder_for}; 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::diff::build_write_file_diff;
use super::reason::build_approval_reason; use super::reason::build_approval_reason;
use super::record::audit_tool_call; 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()), result: serde_json::Value::String(skip_msg.clone()),
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
}; };
let _ = app_handle.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&app_handle, ev);
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(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; 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; return;
} }
@@ -214,8 +214,16 @@ pub(super) async fn insert_pending_approval(
diff: approval_diff, diff: approval_diff,
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
}; };
let _ = app_handle.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&app_handle, ev);
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev); // P1 bg 化:审批挂起需用户介入,主窗隐藏到托盘时系统通知补位(条件发送,失败静默)。
// 审批场景前端已有 ApprovalOverlay 与 approval-popup,通知只补用户不在场(托盘化)场景。
crate::commands::ai::notify::notify_session_event(
&app_handle,
crate::commands::ai::notify::NotifyKind::NeedAction,
conv_id,
"等待工具审批",
&format!("工具 {} 已暂停,批准后继续执行", draft.name),
);
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id).await; audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id).await;
} }
@@ -322,8 +330,7 @@ pub(super) async fn handle_approval_tool(
dir: dir_label.clone(), dir: dir_label.clone(),
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
}; };
let _ = app_handle.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&app_handle, ev);
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
// 收集后循环外 spawn 执行,对齐 Low risk 不持锁(原注释 L593 声称"锁外"但代码持锁, // 收集后循环外 spawn 执行,对齐 Low risk 不持锁(原注释 L593 声称"锁外"但代码持锁,
// run_command 慢命令会阻塞同会话所有触 state.ai_session 的 IPC,CR-51 修此) // run_command 慢命令会阻塞同会话所有触 state.ai_session 的 IPC,CR-51 修此)
trust_hits.push((draft, args, dir_label, risk_level)); trust_hits.push((draft, args, dir_label, risk_level));
@@ -357,8 +364,7 @@ pub(super) async fn handle_approval_tool(
result: serde_json::Value::String(warned_content.clone()), result: serde_json::Value::String(warned_content.clone()),
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
}; };
let _ = app_handle.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&app_handle, ev);
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
// 审计:去重命中记一条(status 透传缓存来源 completed/rejected/failed,SW-260618-16;decided_by=auto_dedup),不进 pending // 审计:去重命中记一条(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; 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; return;
+33 -34
View File
@@ -56,8 +56,12 @@ pub(crate) fn is_pending_placeholder(content: &str) -> bool {
/// AC-2 ②:list_directory 目录级去重(cache key 只取 path,同目录换参数也命中)后, /// AC-2 ②:list_directory 目录级去重(cache key 只取 path,同目录换参数也命中)后,
/// 通用文案「换参数或换工具」对同目录无效(换 max_depth 也仍命中),故目录类工具 /// 通用文案「换参数或换工具」对同目录无效(换 max_depth 也仍命中),故目录类工具
/// 用专用文案:明确告知「目录已列过 + 内容未变」,引导换 grep/search_files 定向搜索。 /// 用专用文案:明确告知「目录已列过 + 内容未变」,引导换 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 { 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!( format!(
"[重复调用拦截] 目录 {} 此前已列过(第 {} 次命中缓存),目录内容未变,以下为缓存结果。不要重复列同一目录,如需查找文件请改用 grep/search_files 定向搜索,或换其他目录。\n\n", "[重复调用拦截] 目录 {} 此前已列过(第 {} 次命中缓存),目录内容未变,以下为缓存结果。不要重复列同一目录,如需查找文件请改用 grep/search_files 定向搜索,或换其他目录。\n\n",
tool_name, hit_count 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 不同)也判重复—— /// list_directory 同目录换参数(recursive/max_depth/skip_noise_dirs 不同)也判重复——
/// 目录内容没变,换深度/递归参数重列是重复探索(实证 09e7abfa:同一目录列 3 次)。 /// 目录内容没变,换深度/递归参数重列是重复探索(实证 09e7abfa:同一目录列 3 次)。
/// 故 list_directory 的缓存 key 只取 path(忽略其他参数),同目录即命中缓存, /// 故声明 cache_path_key 的工具(list_directory,DRY 债 #5 收敛后查注册元数据)
/// 缓存 key 只取 path(忽略其他参数),同目录即命中缓存,
/// 后续命中返回缓存 + 专用警告(cache_hit_warning 目录分支)。 /// 后续命中返回缓存 + 专用警告(cache_hit_warning 目录分支)。
/// 其他只读工具仍走全参 JSON 归一(键序无关,见 canonical_args_key)。 /// 其他只读工具仍走全参 JSON 归一(键序无关,见 canonical_args_key)。
/// ///
/// pub(crate):供 audit/mod.rs 轮内去重复用同一 key 口径(同源同 key, /// pub(crate):供 audit/mod.rs 轮内去重复用同一 key 口径(同源同 key,
/// 跨轮缓存 + 轮内去重判定一致,防两处 key 漂移致去重误判)。 /// 跨轮缓存 + 轮内去重判定一致,防两处 key 漂移致去重误判)。
pub(crate) fn readonly_cache_args_key(tool_name: &str, args: &serde_json::Value) -> String { 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") args.get("path")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or_default() .unwrap_or_default()
@@ -241,9 +247,9 @@ pub(crate) fn readonly_cache_args_key(tool_name: &str, args: &serde_json::Value)
/// 跳过真实执行(也跳过 emit Started/Completed——直接走缓存回填路径)。 /// 跳过真实执行(也跳过 emit Started/Completed——直接走缓存回填路径)。
/// ///
/// **安全边界**(对照 find_cached_high_risk_result 严格收紧): /// **安全边界**(对照 find_cached_high_risk_result 严格收紧):
/// - 仅匹配白名单只读幂等工具(READONLY_CACHE_TOOLS):read_file/list_directory/ /// - 仅匹配只读幂等工具(readonly 元数据维度,DRY 债 #5 收敛后查注册元数据,
/// search_files/grep/read_symbol。写工具(write_file/patch_file/run_command 等) /// 现 read_file/read_symbol/list_directory/search_files/grep 声明)。
/// 永不缓存,避免误伤。 /// 写工具(write_file/patch_file/run_command 等)未声明,永不缓存,避免误伤。
/// - 仅匹配 **status=completed** 的成功结果(失败/拒绝不缓存,可能重试成功)。 /// - 仅匹配 **status=completed** 的成功结果(失败/拒绝不缓存,可能重试成功)。
/// - 仅匹配 **非占位** 结果(排除 pending_placeholder 占位)。 /// - 仅匹配 **非占位** 结果(排除 pending_placeholder 占位)。
/// - args 走 JSON 规范化比较(键序无关),与 find_cached_high_risk_result 一致。 /// - args 走 JSON 规范化比较(键序无关),与 find_cached_high_risk_result 一致。
@@ -260,8 +266,9 @@ pub(crate) async fn find_cached_readonly_result(
) -> Option<(String, u32)> { ) -> Option<(String, u32)> {
use df_ai::provider::MessageRole; 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; return None;
} }
@@ -332,17 +339,8 @@ pub(crate) async fn find_cached_readonly_result(
Some((content, hit_count)) Some((content, hit_count))
} }
/// 可缓存的只读幂等工具白名单。 /// 只读缓存白名单已收敛为注册元数据(readonly 维度,DRY 债 #5):
/// 仅含纯读取/查询类工具:同参多次调用结果一致,缓存命中安全。 /// find_cached_readonly_result 改查 df_ai::ai_tools::is_readonly,本文件不再持名单。
/// 写工具/有副作用工具(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 → 同结果)
];
/// 递归对 JSON 对象的键做字典序排序(就地),数组成员也递归排序。 /// 递归对 JSON 对象的键做字典序排序(就地),数组成员也递归排序。
fn sort_object_keys(v: &mut serde_json::Value) { fn sort_object_keys(v: &mut serde_json::Value) {
@@ -421,15 +419,18 @@ fn contains_listing_command(command: &str) -> bool {
|| contains_word(&lower, "ls") || contains_word(&lower, "ls")
} }
/// 扫描会话历史,取所有 list_directory 已列过的目录路径(去重,保留原始大小写)。 /// 扫描会话历史,取所有目录级缓存工具(cache_path_key 声明者,现即 list_directory)
/// 已列过的目录路径(去重,保留原始大小写)。
/// ///
/// 与 find_cached_readonly_result 同源读 assistant tool_calls;仅取 list_directory /// 与 find_cached_readonly_result 同源读 assistant tool_calls;仅取 args.path,
/// 的 args.path,忽略递归/深度参数(② 同语义:同目录即算已列过)。 /// 忽略递归/深度参数(② 同语义:同目录即算已列过)。工具判定查注册元数据
/// (cache_path_key 维度,DRY 债 #5 收敛),与目录级缓存 key 同一判据。
async fn listed_directory_paths( async fn listed_directory_paths(
session_arc: &Arc<Mutex<AiSession>>, session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str, conv_id: &str,
) -> Vec<String> { ) -> Vec<String> {
use df_ai::provider::MessageRole; use df_ai::provider::MessageRole;
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
let session = session_arc.lock().await; let session = session_arc.lock().await;
let Some(conv) = session.conv_read(conv_id) else { return Vec::new() }; let Some(conv) = session.conv_read(conv_id) else { return Vec::new() };
let mut dirs: Vec<String> = Vec::new(); let mut dirs: Vec<String> = Vec::new();
@@ -440,7 +441,7 @@ async fn listed_directory_paths(
} }
let Some(tcs) = m.tool_calls.as_ref() else { continue }; let Some(tcs) = m.tool_calls.as_ref() else { continue };
for tc in tcs { for tc in tcs {
if tc.function.name != "list_directory" { if !df_ai::ai_tools::cache_path_key_only(&tc.function.name) {
continue; continue;
} }
let Ok(args) = serde_json::from_str::<serde_json::Value>(&tc.function.arguments) else { let Ok(args) = serde_json::from_str::<serde_json::Value>(&tc.function.arguments) else {
@@ -494,9 +495,10 @@ pub(crate) async fn detect_listing_bypass(
None 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( pub(crate) async fn insert_listing_bypass_warning(
session_arc: &Arc<Mutex<AiSession>>, session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str, conv_id: &str,
@@ -506,16 +508,13 @@ pub(crate) async fn insert_listing_bypass_warning(
"⚠ 检测到用 run_command 列目录({}) 绕行 list_directory 去重。列目录请用 list_directory 工具,如需查找文件请用 grep/search_files 定向搜索。", "⚠ 检测到用 run_command 列目录({}) 绕行 list_directory 去重。列目录请用 list_directory 工具,如需查找文件请用 grep/search_files 定向搜索。",
dir dir
); );
let mut session = session_arc.lock().await; crate::commands::ai::agentic::helpers::insert_system_notice(
if session.per_conv.contains_key(conv_id) { session_arc,
let conv = session.conv(conv_id); conv_id,
conv.messages.insert_at(0, ChatMessage::system(&warn_text)); &warn_text,
tracing::info!( &format!("AC-2 目录列举绕行:注入警告(只警告不硬拒) dir={}", dir),
conv_id = %conv_id, )
dir = %dir, .await;
"[ai] AC-2 目录列举绕行:注入警告(只警告不硬拒)"
);
}
} }
#[cfg(test)] #[cfg(test)]
+6 -26
View File
@@ -11,33 +11,13 @@ use tauri::{AppHandle, Emitter};
/// AR-11(方案A):工具名 → (entity, action) 映射,用于数据变更联动刷新。 /// AR-11(方案A):工具名 → (entity, action) 映射,用于数据变更联动刷新。
/// ///
/// 仅数据变更类工具返回 Some,其余工具返回 None(不 emit df-data-changed)。 /// 仅数据变更类工具返回 Some,其余工具返回 None(不 emit df-data-changed)。
/// 映射规则: /// 映射查注册元数据(entity_action 维度,DRY 债 #5 收敛),在 declare_tool! 注册点声明:
/// - create_project/create_idea/create_task → (project/idea/task, create) /// create_*(project/idea/task, create)、update_*(..., update)、delete_*/purge_project
/// - update_project/update_task/update_idea → (..., update) /// (..., delete)、restore_project/bind_directory → (project, update)、advance_task →
/// - delete_project/delete_task/delete_idea/purge_project → (..., delete) /// (task, update)(状态推进对齐 update_task)、write_file/patch_file → (file, update)。
/// - restore_project → (project, update)(恢复等同状态变更,前端走 update 刷新)
/// - bind_directory → (project, update)(绑定目录属项目字段更新)
pub(super) fn data_change_for_tool(name: &str) -> Option<(&'static str, &'static str)> { pub(super) fn data_change_for_tool(name: &str) -> Option<(&'static str, &'static str)> {
let (entity, action) = match name { crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
"create_project" => ("project", "create"), df_ai::ai_tools::entity_action(name)
"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))
} }
/// AR-11(方案A):工具执行成功后按映射 emit "df-data-changed" { entity, action }。 /// AR-11(方案A):工具执行成功后按映射 emit "df-data-changed" { entity, action }。
+125 -67
View File
@@ -3,7 +3,7 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::sync::Arc; use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager}; use tauri::{AppHandle, Manager};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
@@ -11,6 +11,8 @@ use df_ai::provider::ChatMessage;
use df_storage::crud::AiToolExecutionRepo; use df_storage::crud::AiToolExecutionRepo;
use df_storage::db::Database; 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::{AiChatEvent, AiSession, ApprovalKind, PathAuthRequest, PendingApproval, ToolCallDraft};
use super::tools::entity_resolve; use super::tools::entity_resolve;
@@ -94,6 +96,57 @@ pub(crate) use idea_source::maybe_fill_idea_source;
// 工具调用心跳 + 审批处理 // 工具调用心跳 + 审批处理
// ============================================================ // ============================================================
/// 工具执行策略(子代理 v1 防挂死的核心闸门)。
///
/// Normal:主对话既有流程(Low 自动执行 / Medium+High 进审批门控 / 路径授权挂起等人工介入)。
/// SubAgent:子代理无审批通道也无用户交互面,任何需要人工介入的路径(路径授权挂起/
/// 黑名单拒绝/Medium/High 风险)一律就地产出失败 tool_result,绝不 insert pending。
/// 挂起对主对话是「等用户」,对子代理是永久停摆(没人能审批它),故必须就地拒绝。
/// 调用方(spawn_agent 子循环)断言本策略下返回的 pending 恒为 0,非 0 即内部错误。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ToolExecPolicy {
/// 主对话默认策略(行为与历史版本完全一致)。
Normal,
/// 子代理策略(需人工介入的路径全部就地拒绝,零挂起)。
SubAgent,
}
/// SubAgent 策略的就地拒绝单点:失败 tool_result(结构化 envelope,断路器可判)+
/// emit AiToolCallCompleted + 审计留痕(decided_by=sub_agent_denied)。
/// 不 insert pending_approvals(子代理无审批通道,挂起即永久停摆)。
async fn deny_sub_agent_tool(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
draft: &ToolCallDraft,
tools_arc: &Arc<AiToolRegistry>,
audit_repo: &AiToolExecutionRepo,
reason: &str,
current_message_id: Option<&str>,
app_handle: &AppHandle,
) {
let msg = serde_json::json!({
"status": "rejected",
"reason": "sub_agent_denied",
"message": format!("子代理无权执行该工具({}):仅低风险只读且路径已授权的工具可在子代理中自动执行,如确需该操作请写入报告交由主会话完成", reason)
}).to_string();
// 短 lock 段:push tool_result(纯写,无 await)
{
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, &msg));
log_slow_lock(__lock_t, "process_tool_calls:deny_sub_agent");
}
let ev = AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(msg.clone()),
conversation_id: Some(conv_id.to_string()),
};
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,
"rejected", risk_level, Some(msg), Some("sub_agent_denied"), current_message_id).await;
}
/// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控 /// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控
/// 返回待审批的工具数量(0 = 全部自动执行完成) /// 返回待审批的工具数量(0 = 全部自动执行完成)
/// 工具执行 + 心跳保活:execute 期间每 30s emit AiHeartbeat(对齐 stream_recv.rs /// 工具执行 + 心跳保活:execute 期间每 30s emit AiHeartbeat(对齐 stream_recv.rs
@@ -142,8 +195,7 @@ async fn execute_with_heartbeat(
break; break;
} }
let ev = AiChatEvent::AiHeartbeat { conversation_id: Some(conv.clone()) }; let ev = AiChatEvent::AiHeartbeat { conversation_id: Some(conv.clone()) };
let _ = app_h.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&app_h, ev);
let _ = app_h.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
} }
}); });
// RAII:execute 无论 Ok/Err/panic(unwind),_guard drop 自动 stop+abort,无心跳 task 泄漏。 // RAII:execute 无论 Ok/Err/panic(unwind),_guard drop 自动 stop+abort,无心跳 task 泄漏。
@@ -158,7 +210,12 @@ async fn execute_with_heartbeat(
// run_command 分支从 args.timeout_secs 取值(clamp [60,600],默认 60,与 tool_registry.rs:2707 // run_command 分支从 args.timeout_secs 取值(clamp [60,600],默认 60,与 tool_registry.rs:2707
// run_command 内部 clamp 同源),其余工具仍 60s。run_command 内部还有自己的子 timeout, // run_command 内部 clamp 同源),其余工具仍 60s。run_command 内部还有自己的子 timeout,
// 外层只需 >= 内部 timeout 即不抢断(run_command 内部超时会返带语义的 tool_result,优于外层裸中止)。 // 外层只需 >= 内部 timeout 即不抢断(run_command 内部超时会返带语义的 tool_result,优于外层裸中止)。
let outer_secs: u64 = if name == "run_command" { let outer_secs: u64 = if name == "spawn_agent" {
// 子代理:外层取子循环挂钟预算(SUB_AGENT_WALL_TIMEOUT_SECS)加缓冲。
// 外层必须晚于子循环自身三重熔断(轮次/token/挂钟)触发,让超预算的子代理
// 以截断报告优雅收尾,而非被外层硬腰斩成裸超时错误(报告蒸发)。
crate::commands::ai::agentic::sub_agent::SUB_AGENT_WALL_TIMEOUT_SECS + 60
} else if name == "run_command" {
args.get("timeout_secs") args.get("timeout_secs")
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
.unwrap_or(60) .unwrap_or(60)
@@ -179,6 +236,12 @@ async fn execute_with_heartbeat(
None None
}; };
let exec_fut = crate::commands::ai::command_stream::scope(sink, tools.execute(name, args)); let exec_fut = crate::commands::ai::command_stream::scope(sink, tools.execute(name, args));
// 父会话 task-local 注入:spawn_agent handler 经 caller_conv() 定位发起调用的父会话
// (直调防御兜底用)。与上方 command_stream 的 SINK scope 嵌套,两个 task-local 互不干扰。
let exec_fut = crate::commands::ai::tools::spawn_agent::scope_caller_conv(
conv_id.to_string(),
exec_fut,
);
match tokio::time::timeout(Duration::from_secs(outer_secs), exec_fut).await { match tokio::time::timeout(Duration::from_secs(outer_secs), exec_fut).await {
Ok(result) => result, Ok(result) => result,
Err(_elapsed) => { Err(_elapsed) => {
@@ -201,7 +264,10 @@ pub(crate) async fn process_tool_calls(
db: &Arc<Database>, db: &Arc<Database>,
app_handle: &AppHandle, app_handle: &AppHandle,
conv_id: &str, conv_id: &str,
policy: ToolExecPolicy,
) -> usize { ) -> usize {
// 策略快照(SubAgent:须人工介入的路径全部就地拒绝,见 ToolExecPolicy 文档)
let is_sub_agent = matches!(policy, ToolExecPolicy::SubAgent);
let mut tc_list: Vec<_> = tool_calls_acc.into_iter().collect(); let mut tc_list: Vec<_> = tool_calls_acc.into_iter().collect();
tc_list.sort_unstable_by_key(|(i, _)| *i); tc_list.sort_unstable_by_key(|(i, _)| *i);
// 治本兜底:LLM 异常复用同 tool_use.id(stream_recv 按 content_block index 分桶, // 治本兜底:LLM 异常复用同 tool_use.id(stream_recv 按 content_block index 分桶,
@@ -233,10 +299,7 @@ pub(crate) async fn process_tool_calls(
let session = session_arc.lock().await; let session = session_arc.lock().await;
let __id = session.conv_read(conv_id) let __id = session.conv_read(conv_id)
.and_then(|c| c.messages.last_assistant_message_id()); .and_then(|c| c.messages.last_assistant_message_id());
let __hold = __lock_t.elapsed(); log_slow_lock(__lock_t, "process_tool_calls:192");
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] process_tool_calls:192 持锁 {:?} (含 lock 等待)", __hold);
}
__id __id
}, },
).await { ).await {
@@ -260,8 +323,7 @@ pub(crate) async fn process_tool_calls(
args: args.clone(), args: args.clone(),
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
}; };
let _ = app_handle.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&app_handle, ev);
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
(idx, draft, args) (idx, draft, args)
}) })
.collect(); .collect();
@@ -283,18 +345,14 @@ pub(crate) async fn process_tool_calls(
let __lock_t = std::time::Instant::now(); let __lock_t = std::time::Instant::now();
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &err_payload)); session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &err_payload));
let __hold = __lock_t.elapsed(); log_slow_lock(__lock_t, "process_tool_calls:268");
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] process_tool_calls:268 持锁 {:?} (含 lock 等待)", __hold);
}
} }
let ev = AiChatEvent::AiToolCallCompleted { let ev = AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(), id: draft.id.clone(),
result: serde_json::Value::String(err_payload.clone()), result: serde_json::Value::String(err_payload.clone()),
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
}; };
let _ = app_handle.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&app_handle, ev);
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
let risk = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High); 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, 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; "failed", risk, Some(err_payload.clone()), Some("auto_resolve_fail"), current_message_id).await;
@@ -306,16 +364,12 @@ pub(crate) async fn process_tool_calls(
// 自动执行范围三档读取(前置到路径授权预校验之前:两处门控共用)。 // 自动执行范围三档读取(前置到路径授权预校验之前:两处门控共用)。
// KV df-ai-auto-execute-mode 取值 low/medium/all,默认 low; // KV df-ai-auto-execute-mode 取值 low/medium/all,默认 low;
// low 仅 Low 自动(等价旧行为),medium Low+Medium,all 全自动无审批(完全 AI 接管)。 // low 仅 Low 自动(等价旧行为),medium Low+Medium,all 全自动无审批(完全 AI 接管)。
// get_string_norm 归一去引号(前端 JSON.stringify 落库带引号),不归一则 medium/all 档静默失效全走审批。
let auto_exec_mode = app_handle let auto_exec_mode = app_handle
.state::<crate::state::AppState>() .state::<crate::state::AppState>()
.settings .settings
.get("df-ai-auto-execute-mode") .get_string_norm("df-ai-auto-execute-mode")
.await .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()); .unwrap_or_else(|| "low".to_string());
// ── 文件工具路径授权预校验 ── // ── 文件工具路径授权预校验 ──
@@ -334,22 +388,20 @@ pub(crate) async fn process_tool_calls(
// 「信任所有目录」总开关(KV df-ai-path-auth-bypass,默认关):开启后白名单外路径不再挂起 // 「信任所有目录」总开关(KV df-ai-path-auth-bypass,默认关):开启后白名单外路径不再挂起
// 授权弹窗(NeedsAuth 时直接放行),黑名单拦截也一并放行(产品拍板:体验优先,含敏感目录)。 // 授权弹窗(NeedsAuth 时直接放行),黑名单拦截也一并放行(产品拍板:体验优先,含敏感目录)。
// 读取失败按关处理(fail-safe,默认行为不变);前端设置页改后下一轮 process_tool_calls 即生效。 // 读取失败按关处理(fail-safe,默认行为不变);前端设置页改后下一轮 process_tool_calls 即生效。
// get_bool_norm 归一比对(前端 JSON.stringify 落库带引号),默认 false 由调用方显式传参。
let path_auth_bypass = app_handle let path_auth_bypass = app_handle
.state::<crate::state::AppState>() .state::<crate::state::AppState>()
.settings .settings
.get(PATH_AUTH_BYPASS_KEY) .get_bool_norm(PATH_AUTH_BYPASS_KEY, false)
.await .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);
// 同步进程级黑名单放行标志:validate_path(tool_registry)无 bypass 参数,靠此标志放行黑名单; // 同步进程级黑名单放行标志:validate_path(tool_registry)无 bypass 参数,靠此标志放行黑名单;
// 每轮工具执行前刷新,与下方 check_file_tool_auth 的入参 bypass 同源同值,两处拦截点语义一致。 // 每轮工具执行前刷新,与下方 check_file_tool_auth 的入参 bypass 同源同值,两处拦截点语义一致。
path_auth::set_blacklist_bypass(path_auth_bypass); path_auth::set_blacklist_bypass(path_auth_bypass);
// 收集 NeedsAuth/Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环) // 收集 NeedsAuth/Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环)
let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new(); let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new();
let mut path_denied: Vec<(ToolCallDraft, String)> = Vec::new(); let mut path_denied: Vec<(ToolCallDraft, String)> = Vec::new();
// SubAgent 策略:须授权/黑名单拒绝统一收集,后续就地拒绝(绝不挂起)
let mut sub_agent_path_denied: Vec<(ToolCallDraft, String)> = Vec::new();
let mut authorized_drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = Vec::new(); let mut authorized_drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = Vec::new();
for (idx, draft, args) in drafts { for (idx, draft, args) in drafts {
// 会话级临时授权目录在 allowed_guard.session 内(进程级,随 active 会话切换清空), // 会话级临时授权目录在 allowed_guard.session 内(进程级,随 active 会话切换清空),
@@ -357,6 +409,11 @@ pub(crate) async fn process_tool_calls(
match check_file_tool_auth(&draft.name, &args, &allowed_guard, path_auth_bypass) { match check_file_tool_auth(&draft.name, &args, &allowed_guard, path_auth_bypass) {
FileToolAuthOutcome::Authorized => authorized_drafts.push((idx, draft, args)), FileToolAuthOutcome::Authorized => authorized_drafts.push((idx, draft, args)),
FileToolAuthOutcome::NeedsAuth(req) => { FileToolAuthOutcome::NeedsAuth(req) => {
// SubAgent 策略:子代理无授权弹窗通道,挂起即永久停摆 → 就地拒绝。
if is_sub_agent {
sub_agent_path_denied.push((draft, "路径未授权且子代理无授权弹窗通道".to_string()));
continue;
}
// all 档全自动(用户拍板):路径授权弹窗也不打断,与 RiskLevel 门同语义。 // all 档全自动(用户拍板):路径授权弹窗也不打断,与 RiskLevel 门同语义。
// 黑名单 Denied 不受影响——系统敏感目录是绝对安全线,全接管也不放行。 // 黑名单 Denied 不受影响——系统敏感目录是绝对安全线,全接管也不放行。
// 放行的 draft 走 authorized_drafts → RiskLevel 分类 → all 放行 → 审计留痕闭环。 // 放行的 draft 走 authorized_drafts → RiskLevel 分类 → all 放行 → 审计留痕闭环。
@@ -366,7 +423,13 @@ pub(crate) async fn process_tool_calls(
path_auth_pending.push((draft, args, req)); path_auth_pending.push((draft, args, req));
} }
} }
FileToolAuthOutcome::Denied(reason) => path_denied.push((draft, reason)), FileToolAuthOutcome::Denied(reason) => {
if is_sub_agent {
sub_agent_path_denied.push((draft, reason));
} else {
path_denied.push((draft, reason));
}
}
} }
} }
let drafts = authorized_drafts; let drafts = authorized_drafts;
@@ -385,10 +448,7 @@ pub(crate) async fn process_tool_calls(
let __lock_t = std::time::Instant::now(); let __lock_t = std::time::Instant::now();
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &err_msg)); session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &err_msg));
let __hold = __lock_t.elapsed(); log_slow_lock(__lock_t, "process_tool_calls:262");
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] process_tool_calls:262 持锁 {:?} (含 lock 等待)", __hold);
}
} }
// L3 emit 双写:路径黑名单拒绝 emit Completed 双路发布。 // L3 emit 双写:路径黑名单拒绝 emit Completed 双路发布。
let ev = AiChatEvent::AiToolCallCompleted { let ev = AiChatEvent::AiToolCallCompleted {
@@ -396,8 +456,7 @@ pub(crate) async fn process_tool_calls(
result: serde_json::Value::String(err_msg.clone()), result: serde_json::Value::String(err_msg.clone()),
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
}; };
let _ = app_handle.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&app_handle, ev);
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
// 审计:路径黑名单拒绝(decided_by=auto_blacklist),留痕可追溯 // 审计:路径黑名单拒绝(decided_by=auto_blacklist),留痕可追溯
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High); let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
audit_tool_call( audit_tool_call(
@@ -407,6 +466,14 @@ pub(crate) async fn process_tool_calls(
).await; ).await;
} }
// SubAgent 策略:须授权/黑名单拒绝就地产出失败 tool_result(零挂起,防子循环永久停摆)。
for (draft, reason) in sub_agent_path_denied {
deny_sub_agent_tool(
session_arc, conv_id, &draft, tools_arc, &audit_repo,
&reason, current_message_id, app_handle,
).await;
}
// Phase B: NeedsAuth 路径 → 挂起 loop(insert pending + emit AiDirAuthRequired + push 占位 tool_result)。 // Phase B: NeedsAuth 路径 → 挂起 loop(insert pending + emit AiDirAuthRequired + push 占位 tool_result)。
// 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。 // 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。
// pending_count 计入(让 agentic loop 检测到挂起并暂停,等 ai_authorize_dir → try_continue 恢复)。 // pending_count 计入(让 agentic loop 检测到挂起并暂停,等 ai_authorize_dir → try_continue 恢复)。
@@ -433,18 +500,14 @@ pub(crate) async fn process_tool_calls(
let __lock_t = std::time::Instant::now(); let __lock_t = std::time::Instant::now();
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg)); session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg));
let __hold = __lock_t.elapsed(); log_slow_lock(__lock_t, "process_tool_calls:305");
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] process_tool_calls:305 持锁 {:?} (含 lock 等待)", __hold);
}
} }
let ev = AiChatEvent::AiToolCallCompleted { let ev = AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(), id: draft.id.clone(),
result: serde_json::Value::String(skip_msg.clone()), result: serde_json::Value::String(skip_msg.clone()),
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
}; };
let _ = app_handle.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&app_handle, ev);
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High); let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
audit_tool_call( audit_tool_call(
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
@@ -473,10 +536,7 @@ pub(crate) async fn process_tool_calls(
); );
// 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果。 // 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果。
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id))); session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
let __hold = __lock_t.elapsed(); log_slow_lock(__lock_t, "process_tool_calls:325");
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] process_tool_calls:325 持锁 {:?} (含 lock 等待)", __hold);
}
} }
tracing::debug!(target: "ai_dirauth", conv = %conv_id, tool = %draft.name, tc_id = %draft.id, "emit AiDirAuthRequired(路径授权挂起,等 ai_authorize_dir 恢复)"); tracing::debug!(target: "ai_dirauth", conv = %conv_id, tool = %draft.name, tc_id = %draft.id, "emit AiDirAuthRequired(路径授权挂起,等 ai_authorize_dir 恢复)");
let ev = AiChatEvent::AiDirAuthRequired { let ev = AiChatEvent::AiDirAuthRequired {
@@ -486,8 +546,7 @@ pub(crate) async fn process_tool_calls(
dir: dir_str, dir: dir_str,
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
}; };
let _ = app_handle.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&app_handle, ev);
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High); let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
audit_tool_call( audit_tool_call(
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
@@ -507,16 +566,12 @@ pub(crate) async fn process_tool_calls(
// 也进此向量,需透传真实等级,审计方可追溯「all 模式执行了多少高危命令」。 // 也进此向量,需透传真实等级,审计方可追溯「all 模式执行了多少高危命令」。
let mut low_risk: Vec<(ToolCallDraft, serde_json::Value, RiskLevel)> = Vec::new(); let mut low_risk: Vec<(ToolCallDraft, serde_json::Value, RiskLevel)> = Vec::new();
// F-#97 诊断:用户报「选 all 仍审批」时辅助定位 KV 是否真落库。 // F-#97 诊断:用户报「选 all 仍审批」时辅助定位 KV 是否真落库。
// df-ai-trace-approve 开关控制(默认开)。常见误以为已生效但实际未点确认按钮致 KV 未落。 // df-ai-trace-approve 开关控制(默认开,get_bool_norm 默认值调用方传参)。常见误以为已生效但实际未点确认按钮致 KV 未落。
let trace_on = app_handle let trace_on = app_handle
.state::<crate::state::AppState>() .state::<crate::state::AppState>()
.settings .settings
.get("df-ai-trace-approve") .get_bool_norm("df-ai-trace-approve", true)
.await .await;
.ok()
.flatten()
.map(|v| { let v = v.trim_matches('"'); v == "true" || v == "1" })
.unwrap_or(true);
if trace_on { if trace_on {
tracing::info!( tracing::info!(
conv_id = %conv_id, conv_id = %conv_id,
@@ -530,6 +585,15 @@ pub(crate) async fn process_tool_calls(
let mut trust_hits: Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)> = Vec::new(); let mut trust_hits: Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)> = Vec::new();
for (_, draft, args) in drafts { for (_, draft, args) in drafts {
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High); let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
// SubAgent 策略:Medium/High 就地拒绝(无审批通道,挂起即子循环永久停摆)。
// 白名单钳制已使子代理 LLM 只见 Low 工具定义,此处拦的是幻觉调用非白名单工具的残面。
if is_sub_agent && risk_level != RiskLevel::Low {
deny_sub_agent_tool(
session_arc, conv_id, &draft, tools_arc, &audit_repo,
"工具风险等级超出子代理权限", current_message_id, app_handle,
).await;
continue;
}
// AC-2 ③:run_command 目录列举绕行检测(最保守版,只警告不硬拒)。 // AC-2 ③:run_command 目录列举绕行检测(最保守版,只警告不硬拒)。
// 实证 09e7abfa:list_directory 后 LLM 用 run_command Get-ChildItem 再探同目录, // 实证 09e7abfa:list_directory 后 LLM 用 run_command Get-ChildItem 再探同目录,
// 绕过 ② 的目录级去重。检测到绕行 → insert 一条 system 警告(非静默),命令照常 // 绕过 ② 的目录级去重。检测到绕行 → insert 一条 system 警告(非静默),命令照常
@@ -570,8 +634,7 @@ pub(crate) async fn process_tool_calls(
result: serde_json::Value::String(content.clone()), result: serde_json::Value::String(content.clone()),
conversation_id: Some(conv_clone), conversation_id: Some(conv_clone),
}; };
let _ = app_clone.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&app_clone, ev);
let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
// 数据变更类工具执行成功后 emit df-data-changed 联动刷新 // 数据变更类工具执行成功后 emit df-data-changed 联动刷新
// (write_file/run_command 不在 data_change_for_tool 映射内,emit_data_changed 内部 None 即 noop) // (write_file/run_command 不在 data_change_for_tool 映射内,emit_data_changed 内部 None 即 noop)
emit_data_changed(&app_clone, &draft.name); emit_data_changed(&app_clone, &draft.name);
@@ -585,8 +648,7 @@ pub(crate) async fn process_tool_calls(
result: serde_json::Value::String(content.clone()), result: serde_json::Value::String(content.clone()),
conversation_id: Some(conv_clone), conversation_id: Some(conv_clone),
}; };
let _ = app_clone.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&app_clone, ev);
let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
(draft, risk_level, Err(content)) (draft, risk_level, Err(content))
} }
} }
@@ -668,8 +730,7 @@ pub(crate) async fn process_tool_calls(
result: serde_json::Value::String(warned_content.clone()), result: serde_json::Value::String(warned_content.clone()),
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
}; };
let _ = app_handle.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&app_handle, ev);
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
// 短 lock 段:push tool_result(纯写,无 await) // 短 lock 段:push tool_result(纯写,无 await)
{ {
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
@@ -709,8 +770,7 @@ pub(crate) async fn process_tool_calls(
result: val.clone(), result: val.clone(),
conversation_id: Some(conv_clone), conversation_id: Some(conv_clone),
}; };
let _ = app_clone.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&app_clone, ev);
let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
// AR-11(方案A):数据变更工具执行成功后 emit df-data-changed, // AR-11(方案A):数据变更工具执行成功后 emit df-data-changed,
// 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。 // 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。
emit_data_changed(&app_clone, &draft.name); emit_data_changed(&app_clone, &draft.name);
@@ -729,8 +789,7 @@ pub(crate) async fn process_tool_calls(
result: serde_json::Value::String(err_msg.clone()), result: serde_json::Value::String(err_msg.clone()),
conversation_id: Some(conv_clone), conversation_id: Some(conv_clone),
}; };
let _ = app_clone.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&app_clone, ev);
let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
(draft, serde_json::Value::Null, risk_level, Err(err_msg)) (draft, serde_json::Value::Null, risk_level, Err(err_msg))
} }
} }
@@ -831,8 +890,7 @@ pub(crate) async fn process_tool_calls(
result: serde_json::Value::String(warned_content.clone()), result: serde_json::Value::String(warned_content.clone()),
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
}; };
let _ = app_handle.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&app_handle, ev);
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
// 短 lock 段:push tool_result(纯写,无 await) // 短 lock 段:push tool_result(纯写,无 await)
{ {
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
+8 -22
View File
@@ -40,28 +40,13 @@ pub(crate) static BLACKLIST_BYPASS_TEST_LOCK: std::sync::Mutex<()> = std::sync::
/// 提取文件工具的路径参数(用于路径授权预校验)。 /// 提取文件工具的路径参数(用于路径授权预校验)。
/// ///
/// 仅对走 resolve_workspace_path 校验的文件工具返回路径;非文件工具返回空 Vec(不预校验)。 /// 仅对走 resolve_workspace_path 校验的文件工具返回路径;非文件工具返回空 Vec(不预校验)。
/// - 单路径工具(read_file/write_file/list_directory/patch_file/file_info/append_file/delete_file/search_files/grep) /// 路径参数键查注册元数据(path_args 维度,DRY 债 #5 收敛):单路径工具(read_file/
/// 取 args["path"] /// write_file/list_directory/patch_file/file_info/append_file/delete_file/search_files/grep)
/// - rename_file 双路径取 args["from"] + args["to"](两个都需授权) /// 声明 ["path"],rename_file 声明 ["from","to"](两个都需授权),其余工具默认空(不预校验)。
/// - run_command 不返回(它只走 validate_path 黑名单,不限定 workspace,High risk 靠人工审批) /// run_command 不声明(只走 validate_path 黑名单,不限定 workspace,High risk 靠人工审批)。
pub(crate) fn extract_file_tool_paths(tool_name: &str, args: &serde_json::Value) -> Vec<String> { pub(crate) fn extract_file_tool_paths(tool_name: &str, args: &serde_json::Value) -> Vec<String> {
match tool_name { crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
"rename_file" => { df_ai::ai_tools::extract_paths(df_ai::ai_tools::path_arg_keys(tool_name), args)
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(),
}
} }
/// 路径授权预校验结果。 /// 路径授权预校验结果。
@@ -124,10 +109,11 @@ pub(crate) fn check_file_tool_auth(
} }
if pending_dirs.is_empty() { if pending_dirs.is_empty() {
FileToolAuthOutcome::Authorized FileToolAuthOutcome::Authorized
} else if tool_name == "search_files" { } else if df_ai::ai_tools::denies_unauthorized(tool_name) {
// 核心设计5:search_files 兜底 — 盲搜语义应拒不应询。 // 核心设计5:search_files 兜底 — 盲搜语义应拒不应询。
// search_files 仅在已授权目录可用;用户已通过 @项目 提供项目上下文,无需搜索文件系统。 // search_files 仅在已授权目录可用;用户已通过 @项目 提供项目上下文,无需搜索文件系统。
// (黑名单已在上方 for 循环内短路返 Denied,走到此处均为 NeedsAuthorization 场景。) // (黑名单已在上方 for 循环内短路返 Denied,走到此处均为 NeedsAuthorization 场景。)
// 拒绝名单查注册元数据(deny_unauthorized 维度,DRY 债 #5 收敛,现仅 search_files 声明);
// 其他文件工具保持原 NeedsAuth 弹窗逻辑完全不变。 // 其他文件工具保持原 NeedsAuth 弹窗逻辑完全不变。
FileToolAuthOutcome::Denied( FileToolAuthOutcome::Denied(
"search_files 仅在已授权目录可用;请用 @[项目] 引用或提供绝对路径,不要盲搜文件系统。".to_string(), "search_files 仅在已授权目录可用;请用 @[项目] 引用或提供绝对路径,不要盲搜文件系统。".to_string(),
+3 -3
View File
@@ -27,8 +27,9 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex as StdMutex; use std::sync::Mutex as StdMutex;
use std::time::Duration; use std::time::Duration;
use tauri::{AppHandle, Emitter, Manager}; use tauri::AppHandle;
use super::event_bus::emit_ai_chat_event;
use super::AiChatEvent; use super::AiChatEvent;
use df_execute::shell::StreamKind; use df_execute::shell::StreamKind;
@@ -172,8 +173,7 @@ impl CommandSink {
line: line.to_string(), line: line.to_string(),
conversation_id: conversation_id.clone(), conversation_id: conversation_id.clone(),
}; };
let _ = app.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(app, ev);
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
} }
} }
+304 -213
View File
@@ -13,21 +13,22 @@
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use serde::Serialize; use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager, State}; use tauri::{AppHandle, Manager, State};
// F-260801 Phase2:anthropic 协议 url→base64 预拉(base64 内嵌编码,generate_image.rs 同款用法)。 // F-260801 Phase2:anthropic 协议 url→base64 预拉(base64 内嵌编码,generate_image.rs 同款用法)。
use base64::{engine::general_purpose::STANDARD, Engine as _}; use base64::{engine::general_purpose::STANDARD, Engine as _};
use df_ai::provider::{ChatMessage, ContentPart, MessageStatus}; use df_ai::provider::{ChatMessage, ContentPart, MessageStatus};
use df_storage::models::AiProviderRecord; 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 df_types::types::new_id;
use crate::state::AppState; use crate::state::{AppState, log_slow_lock};
use crate::commands::{err_str, now_millis}; use crate::commands::{err_str, now_millis};
// chat.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。 // 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::agentic::{run_agentic_loop, try_continue_agent_loop};
use super::super::event_bus::emit_ai_chat_event;
#[cfg(test)] #[cfg(test)]
use super::super::agentic::GOAL_MAX_CHARS; use super::super::agentic::GOAL_MAX_CHARS;
// ConvState 枚举变体引用(Idle 等),用于 B-Phase2 写侧 conv_states.transition 调用。 // ConvState 枚举变体引用(Idle 等),用于 B-Phase2 写侧 conv_states.transition 调用。
@@ -38,7 +39,7 @@ use super::super::augmentation::build_augmentation_segment;
use super::super::augmentation::sanitize; use super::super::augmentation::sanitize;
use super::super::conversation::save_conversation; use super::super::conversation::save_conversation;
use super::super::knowledge_inject::{build_knowledge_context_for_conv, inject_knowledge_into_prompt}; use super::super::knowledge_inject::{build_knowledge_context_for_conv, inject_knowledge_into_prompt};
use super::super::prompt::{build_system_prompt_parts, cache_enabled}; use super::super::prompt::{build_system_prompt_parts, cache_enabled, cache_split_provider_type};
use super::super::{AiChatEvent, ApprovalKind, SessionState}; use super::super::{AiChatEvent, ApprovalKind, SessionState};
@@ -152,10 +153,12 @@ fn find_mention_close(chars: &[char], open: usize, kinds: &[&str]) -> Option<usi
/// ///
/// 空结果返 `""`,调用方据之跳过拼接(不污染 prompt)。 /// 空结果返 `""`,调用方据之跳过拼接(不污染 prompt)。
/// ///
/// 注:regenerate / edit 路径当前传 `skill=&None, spans=&None`(无新注入诉求), /// 注:regenerate / edit 路径经 [`backtrack_leading_skill`] 回溯末条 user 前导 /技能名后传入,
/// 若后续需从历史末条 user 消息的 mentionSpans resolve,改这两处调用即可。 /// spans 仍传 `&None`(重生成不重放历史 @ mention)。
async fn resolve_and_inject( async fn resolve_and_inject(
app: &AppHandle,
state: &AppState, state: &AppState,
conv_id: &str,
provider: &AiProviderRecord, provider: &AiProviderRecord,
skill: &Option<String>, skill: &Option<String>,
spans: &Option<Vec<MentionSpanDto>>, spans: &Option<Vec<MentionSpanDto>>,
@@ -188,9 +191,78 @@ async fn resolve_and_inject(
errors 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) 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<String> {
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::<Vec<String>>();
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 驱动)。 /// 单条 [`MentionSpanDto`] → [`MentionRef`] 转换(kind 驱动)。
/// ///
/// 未知 kind / 空 ref_id 返 None(静默跳过,不中断整批注入——与 resolve_all 失败收集语义一致)。 /// 未知 kind / 空 ref_id 返 None(静默跳过,不中断整批注入——与 resolve_all 失败收集语义一致)。
@@ -240,11 +312,6 @@ fn excluded_ids_from_mentions(spans: &Option<Vec<MentionSpanDto>>) -> (Vec<Strin
(proj, task) (proj, task)
} }
/// 当前 provider 是否 anthropic 协议(仅此类型启用 prompt caching 拆分,OpenAI 路径零影响)。
fn is_anthropic_type(provider_type: &str) -> bool {
matches!(provider_type, "anthropic" | "anthropic_compat")
}
/// 把段追加到易变尾段(以 `\n\n---\n` 分隔,对齐旧 system 拼段风格)。首段直接赋。 /// 把段追加到易变尾段(以 `\n\n---\n` 分隔,对齐旧 system 拼段风格)。首段直接赋。
fn append_volatile(volatile: String, seg: &str) -> String { fn append_volatile(volatile: String, seg: &str) -> String {
if volatile.is_empty() { if volatile.is_empty() {
@@ -317,8 +384,8 @@ pub async fn ai_regenerate(
let lang = language.unwrap_or_else(|| "zh-CN".to_string()); let lang = language.unwrap_or_else(|| "zh-CN".to_string());
// conv_id 直接用 IPC 参数 conversation_id(F-260616-09 B 批4,读 per_conv.messages)。 // conv_id 直接用 IPC 参数 conversation_id(F-260616-09 B 批4,读 per_conv.messages)。
let conv_id = conversation_id.clone(); let conv_id = conversation_id.clone();
// Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。 // prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。
let cache_on = cache_enabled(&state).await && is_anthropic_type(&provider_config.provider_type); let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type);
let (mut system_prompt, mut volatile) = let (mut system_prompt, mut volatile) =
build_system_prompt_parts(&state, &lang, &[], &[], cache_on).await; build_system_prompt_parts(&state, &lang, &[], &[], cache_on).await;
// 知识注入:取末条 active user 消息文本做检索(与 send 同款,语义命中刷新上下文)。 // 知识注入:取末条 active user 消息文本做检索(与 send 同款,语义命中刷新上下文)。
@@ -336,9 +403,10 @@ pub async fn ai_regenerate(
system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await; system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await;
} }
} }
// Augmentation 注入:regenerate 路径无新 @ mention / 技能,传 None 走空路径(不污染 prompt)。 // Augmentation 注入:regenerate 回溯末条 user 前导 /技能名(原文在 user 消息里,复用
// 若未来需从末条 user 消息的 mentionSpans resolve(验证 #8),改此处传入即可。 // parse_leading_skill);@ mention spans 不重放,传 None 走空路径(不污染 prompt)。
let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await; 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 !aug_seg.is_empty() {
if cache_on { if cache_on {
volatile = append_volatile(volatile, &aug_seg); volatile = append_volatile(volatile, &aug_seg);
@@ -598,8 +666,51 @@ pub async fn ai_chat_send(
); );
} }
} }
// 共用核心收口(先例 execute_run_workflow_for_tool):provider 解析 → conv 占用检查 →
// push user 消息 → system prompt 组装 → loop_epoch → spawn run_agentic_loop。
// IPC 返回值语义不变:Ok 映射回 "ok"(前端契约),conv_id 仅供编排节点侧消费。
spawn_agentic_session(
&app,
&state,
message,
language,
skill,
model_override,
conversation_id,
parts,
mention_spans,
true,
)
.await
.map(|_| "ok".to_string())
}
/// 发送消息并启动 agentic 会话的共用核心(纯搬迁自 ai_chat_send,行为逐字等价)。
///
/// 调用方:ai_chat_send IPC(前端对话,pin_active=true)/ 工作流 AiAgentNode 编排会话
/// (orchestration_session_spawner,pin_active=false)。两方共用同一 provider 解析/conv 占用/
/// system prompt 组装/loop_epoch/spawn 链路,不重复实现。
///
/// `pin_active` 唯一行为开关:true 时保持原「active_conversation_id 同步到 target」语义
/// (用户当前面板即 target);false(编排会话,显式传独立新 conv_id)跳过同步,不劫持用户
/// 活跃面板。其余路径与原实现逐字一致。
///
/// 返回 Ok(conv_id):编排节点用它追踪会话/等待完成通知;ai_chat_send 映射回 "ok"。
#[allow(clippy::too_many_arguments)]
pub(crate) async fn spawn_agentic_session(
app: &AppHandle,
state: &AppState,
message: String,
language: Option<String>,
skill: Option<String>,
model_override: Option<String>,
conversation_id: Option<String>,
parts: Option<Vec<ContentPart>>,
mention_spans: Option<Vec<MentionSpanDto>>,
pin_active: bool,
) -> Result<String, String> {
// 获取活跃提供商(只读,失败可直接返回,不影响生成标志) // 获取活跃提供商(只读,失败可直接返回,不影响生成标志)
let provider_config = super::super::prompt::get_active_provider(&state).await?; let provider_config = super::super::prompt::get_active_provider(state).await?;
// anthropic 协议需 url→base64 预拉(base64 内嵌,URL 不可达;OpenAI 兼容原生 URL 直传零改动)。 // anthropic 协议需 url→base64 预拉(base64 内嵌,URL 不可达;OpenAI 兼容原生 URL 直传零改动)。
// 拉取在 lock 之前做,不持锁网络 I/O;结果供 lock 内 user_parts 使用。 // 拉取在 lock 之前做,不持锁网络 I/O;结果供 lock 内 user_parts 使用。
@@ -636,7 +747,8 @@ pub async fn ai_chat_send(
}; };
// active_conversation_id 同步到 target(用户当前面板即 target,路由键跟随)。 // active_conversation_id 同步到 target(用户当前面板即 target,路由键跟随)。
// 决策 e:旧 conv 的后台 loop 不受 active 变更影响(写自己的 per_conv)。 // 决策 e:旧 conv 的后台 loop 不受 active 变更影响(写自己的 per_conv)。
if session.active_conversation_id.as_deref() != Some(target.as_str()) { // pin_active=false(工作流编排会话)跳过:编排 conv 不劫持用户活跃面板。
if pin_active && session.active_conversation_id.as_deref() != Some(target.as_str()) {
session.active_conversation_id = Some(target.clone()); session.active_conversation_id = Some(target.clone());
// 切到已有 conv(非本次懒建)时,确保其 active_conv_created_at 已设(switch 路径已设)。 // 切到已有 conv(非本次懒建)时,确保其 active_conv_created_at 已设(switch 路径已设)。
if session.active_conv_created_at.is_none() { if session.active_conv_created_at.is_none() {
@@ -710,13 +822,13 @@ pub async fn ai_chat_send(
// (被@实体已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)。 // (被@实体已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)。
// mention_spans 为 None/空时返回两个空 vec(行为退化为无去重的全貌清单)。 // mention_spans 为 None/空时返回两个空 vec(行为退化为无去重的全貌清单)。
let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans); let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans);
// Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。 // prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。
let cache_on = cache_enabled(&state).await && is_anthropic_type(&provider_config.provider_type); let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type);
let (mut system_prompt, mut volatile) = let (mut system_prompt, mut volatile) =
build_system_prompt_parts(&state, &lang, &excl_proj, &excl_task, cache_on).await; build_system_prompt_parts(&state, &lang, &excl_proj, &excl_task, cache_on).await;
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影后拼到 system prompt 前。 // Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影后拼到 system prompt 前。
// 隔离标注(build_augmentation_segment 头尾包裹,FR-S4 风格)防 prompt injection 与用户指令/行为准则混淆。 // 隔离标注(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 !aug_seg.is_empty() {
if cache_on { if cache_on {
volatile = append_volatile(volatile, &aug_seg); volatile = append_volatile(volatile, &aug_seg);
@@ -759,6 +871,8 @@ pub async fn ai_chat_send(
let app_handle = app.clone(); let app_handle = app.clone();
let knowledge_config = state.knowledge_config.lock().await.clone(); let knowledge_config = state.knowledge_config.lock().await.clone();
let llm_concurrency = state.llm_concurrency.clone(); let llm_concurrency = state.llm_concurrency.clone();
// 返回值用 clone(conv_id 本体 move 进 spawn 闭包,搬运自原实现保持不动)
let conv_id_return = conv_id.clone();
// loop 入口 load 快照,当前 loop 锁定边界(热改下次发消息生效) // loop 入口 load 快照,当前 loop 锁定边界(热改下次发消息生效)
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst); let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
// 流式失败重试次数快照 // 流式失败重试次数快照
@@ -774,7 +888,53 @@ pub async fn ai_chat_send(
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch).await; run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch).await;
}); });
Ok("ok".to_string()) // 返回 conv_id(共用核心:IPC 层映射回 "ok",编排节点消费 conv_id 等待完成通知)
Ok(conv_id_return)
}
/// 工作流 AiAgentNode 的编排会话启动通道(节点工厂经 build_registry 注入,见 state.rs)。
///
/// 流程:取进程级 AppHandle(spawn_agent 子代理同款 OnceLock,lib.rs setup 注入)→ 生成
/// 新独立 conv_id(不与用户活跃会话共用,防 epoch 互踩/active 面板劫持)→ 注册完成通知桥
/// (AppState.agent_completion_bridge)→ 复用 [`spawn_agentic_session`] 起独立 agentic 会话
/// → 返回 (conv_id, 完成通知接收端)。
///
/// 注册先于 spawn:完成通知最早发生在 loop 收敛出口(秒级之后),不存在注册前触发竞态;
/// 顺序对调则有「先收敛后注册」丢通知窗口。返回的接收端由节点 select! 等待;发送端挂在
/// 通知桥,agentic loop 收敛统一出口(emit_ai_completed_once)投递最终 assistant 报告。
pub(crate) fn orchestration_session_spawner() -> df_nodes::ai_agent_node::SpawnFn {
std::sync::Arc::new(|task: String| -> df_nodes::ai_agent_node::SpawnFuture {
Box::pin(async move {
let app = super::super::tools::spawn_agent::app_handle()
.ok_or_else(|| "应用句柄未就绪(启动初始化未完成)".to_string())?;
let conv_id = new_id();
let (tx, rx) = tokio::sync::oneshot::channel::<String>();
let state = app.state::<AppState>();
state.register_agent_completion(conv_id.clone(), tx);
match spawn_agentic_session(
&app,
&state,
task,
// 编排会话:无语言/技能/模型覆盖/多模态/@mention;pin_active=false 不动用户活跃面板
None,
None,
None,
Some(conv_id.clone()),
None,
None,
false,
)
.await
{
Ok(_) => Ok((conv_id, rx)),
// 启动失败:接收端随本 Err 返回被丢弃,通知桥条目同步清掉(防残留死条目)
Err(e) => {
state.notify_agent_completion(&conv_id, String::new());
Err(e)
}
}
})
})
} }
/// 批准/拒绝挂起的工具调用 /// 批准/拒绝挂起的工具调用
@@ -814,8 +974,7 @@ pub async fn ai_approve(
approved: false, approved: false,
conversation_id: rec.conversation_id.clone(), conversation_id: rec.conversation_id.clone(),
}; };
let _ = app.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&app, ev);
let _ = state.ai_event_bus.publish_event(ev);
return Ok(format!("已处理({})", rec.status)); return Ok(format!("已处理({})", rec.status));
} }
rec.status // 其他状态(如 pending/interrupted/skipped_retry)继续走未找到报错 rec.status // 其他状态(如 pending/interrupted/skipped_retry)继续走未找到报错
@@ -863,10 +1022,8 @@ pub async fn ai_approve(
approved: false, approved: false,
conversation_id: conv_id.clone(), conversation_id: conv_id.clone(),
}; };
let _ = app.emit("ai-chat-event", ev.clone()); // app.emit 前端 + publish_event tunnel 两路独立,emit 保留不删(双写收敛进 emit_ai_chat_event)。
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp;publish 内 EVENT_BUS_ENABLED 门控, emit_ai_chat_event(&app, ev);
// 无消费者空转。app.emit 前端 + publish_event tunnel 两路独立,emit 保留不删。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
drop(session); drop(session);
// 拒绝结果立即落库(含 recovered 积压审批)——switch 时已 restore_from_messages 载完整历史, // 拒绝结果立即落库(含 recovered 积压审批)——switch 时已 restore_from_messages 载完整历史,
// messages 非空,save 不会污染老对话;原 if !recovered 守卫前提不成立已移除。 // messages 非空,save 不会污染老对话;原 if !recovered 守卫前提不成立已移除。
@@ -885,10 +1042,7 @@ pub async fn ai_approve(
.and_then(|c| session.conv_read(c)) .and_then(|c| session.conv_read(c))
.map(|c| c.iteration_used) .map(|c| c.iteration_used)
.unwrap_or(0); .unwrap_or(0);
let __hold610 = __lock_t610.elapsed(); log_slow_lock(__lock_t610, "ai_approve:610");
if __hold610 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_approve:610 持锁 {:?} (含 lock 等待)", __hold610);
}
v v
}; };
// 所有待审批处理完毕后恢复 agentic 循环 // 所有待审批处理完毕后恢复 agentic 循环
@@ -921,10 +1075,7 @@ pub async fn ai_approve(
); );
} }
} }
let __hold534 = __lock_t534.elapsed(); log_slow_lock(__lock_t534, "ai_approve:534");
if __hold534 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_approve:534 持锁 {:?} (含 lock 等待)", __hold534);
}
drop(session); // 释放锁后再执行 drop(session); // 释放锁后再执行
// B-260617-01 决策 a(方案 a2 后端分支):run_workflow 特殊处理。 // B-260617-01 决策 a(方案 a2 后端分支):run_workflow 特殊处理。
@@ -954,14 +1105,27 @@ pub async fn ai_approve(
)) ))
} else { None }; } else { None };
let exec_fut = super::super::command_stream::scope(sink, state.ai_tools.execute(&approval.tool_name, args.clone())); let exec_fut = super::super::command_stream::scope(sink, state.ai_tools.execute(&approval.tool_name, args.clone()));
// spawn_agent 父会话定位:审批执行路径同样经 task-local 注入父 conv
// (handler 兜底 active_conversation_id 不可靠:审批可能发生在用户已切会话后)。
let exec_fut = super::super::tools::spawn_agent::scope_caller_conv(
conv_id.clone().unwrap_or_default(),
exec_fut,
);
// spawn_agent 外层超时对齐其挂钟预算加缓冲:子循环自身三重熔断先触发,
// 以截断报告优雅收尾,不被 60s 裸超时腰斩(报告蒸发)。
let approve_outer_secs: u64 = if approval.tool_name == "spawn_agent" {
crate::commands::ai::agentic::sub_agent::SUB_AGENT_WALL_TIMEOUT_SECS + 60
} else {
60
};
match tokio::time::timeout( match tokio::time::timeout(
std::time::Duration::from_secs(60), std::time::Duration::from_secs(approve_outer_secs),
exec_fut, exec_fut,
).await { ).await {
Ok(r) => r, Ok(r) => r,
// 任务2: ai_approve 路径同步 run_command 失败提示(超时为最常见失败场景)。 // 任务2: ai_approve 路径同步 run_command 失败提示(超时为最常见失败场景)。
// 仅对 run_command 追加(其余工具无 shell 适配问题),避免污染其他工具错误语义。 // 仅对 run_command 追加(其余工具无 shell 适配问题),避免污染其他工具错误语义。
Err(_) => Err(anyhow::anyhow!("工具执行超时(60s): {}{}", approval.tool_name, Err(_) => Err(anyhow::anyhow!("工具执行超时({}s): {}{}", approve_outer_secs, approval.tool_name,
if approval.tool_name == "run_command" && cfg!(windows) { if approval.tool_name == "run_command" && cfg!(windows) {
"\n提示: PowerShell 下路径用正斜杠或双引号包裹,命令间用 `;` 而非 `&&`(PS5)" "\n提示: PowerShell 下路径用正斜杠或双引号包裹,命令间用 `;` 而非 `&&`(PS5)"
} else { "" })), } else { "" })),
@@ -1002,21 +1166,14 @@ pub async fn ai_approve(
result: result_val.clone(), result: result_val.clone(),
conversation_id: conv_id.clone(), conversation_id: conv_id.clone(),
}; };
let _ = app.emit("ai-chat-event", ev_completed.clone()); emit_ai_chat_event(&app, ev_completed);
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_completed);
let ev_approval = AiChatEvent::AiApprovalResult { let ev_approval = AiChatEvent::AiApprovalResult {
id, id,
approved: true, approved: true,
conversation_id: conv_id.clone(), conversation_id: conv_id.clone(),
}; };
let _ = app.emit("ai-chat-event", ev_approval.clone()); emit_ai_chat_event(&app, ev_approval);
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 log_slow_lock(__lock_t704, "ai_approve:704");
let _ = app.state::<AppState>().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);
}
drop(session); drop(session);
// F-260619-04 P2(方案 B): create_idea source 消息级溯源补全。 // F-260619-04 P2(方案 B): create_idea source 消息级溯源补全。
@@ -1032,10 +1189,7 @@ pub async fn ai_approve(
let v = conv_id.as_deref().and_then(|cid| { let v = conv_id.as_deref().and_then(|cid| {
session.conv_read(cid).and_then(|c| c.messages.last_assistant_message_id()) session.conv_read(cid).and_then(|c| c.messages.last_assistant_message_id())
}); });
let __hold735 = __lock_t735.elapsed(); log_slow_lock(__lock_t735, "ai_approve:735");
if __hold735 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_approve:735 持锁 {:?} (含 lock 等待)", __hold735);
}
v v
}; };
super::super::audit::maybe_fill_idea_source( super::super::audit::maybe_fill_idea_source(
@@ -1062,10 +1216,7 @@ pub async fn ai_approve(
let __lock_t761 = std::time::Instant::now(); let __lock_t761 = std::time::Instant::now();
let session = state.ai_session.lock().await; let session = state.ai_session.lock().await;
let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0); let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0);
let __hold761 = __lock_t761.elapsed(); log_slow_lock(__lock_t761, "ai_approve:761");
if __hold761 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_approve:761 持锁 {:?} (含 lock 等待)", __hold761);
}
v v
}; };
// 所有待审批处理完毕后恢复 agentic 循环(recovered 无 live loop,try_continue 因 generating=false 自然不续) // 所有待审批处理完毕后恢复 agentic 循环(recovered 无 live loop,try_continue 因 generating=false 自然不续)
@@ -1187,8 +1338,7 @@ pub async fn ai_authorize_dir(
approved: false, approved: false,
conversation_id: rec.conversation_id.clone(), conversation_id: rec.conversation_id.clone(),
}; };
let _ = app.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&app, ev);
let _ = state.ai_event_bus.publish_event(ev);
return Ok(format!("已处理({})", rec.status)); return Ok(format!("已处理({})", rec.status));
} }
_ => {} _ => {}
@@ -1196,10 +1346,7 @@ pub async fn ai_authorize_dir(
return Err(format!("未找到路径授权挂起: {}", tool_call_id)); return Err(format!("未找到路径授权挂起: {}", tool_call_id));
} }
}; };
let __hold864 = __lock_t864.elapsed(); log_slow_lock(__lock_t864, "ai_authorize_dir:864");
if __hold864 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_authorize_dir:864 持锁 {:?} (含 lock 等待)", __hold864);
}
v v
}; };
// 阶段3a 决策层分离:ai_authorize_dir 只消费 kind==Path(路径授权挂起,once/always/deny)。 // 阶段3a 决策层分离:ai_authorize_dir 只消费 kind==Path(路径授权挂起,once/always/deny)。
@@ -1213,10 +1360,7 @@ pub async fn ai_authorize_dir(
let __lock_t878 = std::time::Instant::now(); let __lock_t878 = std::time::Instant::now();
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
session.pending_approvals.insert(tool_call_id.clone(), approval); session.pending_approvals.insert(tool_call_id.clone(), approval);
let __hold878 = __lock_t878.elapsed(); log_slow_lock(__lock_t878, "ai_authorize_dir:878");
if __hold878 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_authorize_dir:878 持锁 {:?} (含 lock 等待)", __hold878);
}
return Err(format!( return Err(format!(
"tool_call_id={} 非路径授权挂起(普通审批请用 ai_approve)", "tool_call_id={} 非路径授权挂起(普通审批请用 ai_approve)",
tool_call_id tool_call_id
@@ -1245,27 +1389,20 @@ pub async fn ai_authorize_dir(
if let Some(ref cid) = conv_id { if let Some(ref cid) = conv_id {
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &err_msg); session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &err_msg);
} }
let __hold903 = __lock_t903.elapsed(); log_slow_lock(__lock_t903, "ai_authorize_dir:903");
if __hold903 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_authorize_dir:903 持锁 {:?} (含 lock 等待)", __hold903);
}
} }
let ev_completed = AiChatEvent::AiToolCallCompleted { let ev_completed = AiChatEvent::AiToolCallCompleted {
id: tool_call_id.clone(), id: tool_call_id.clone(),
result: serde_json::Value::String(err_msg.clone()), result: serde_json::Value::String(err_msg.clone()),
conversation_id: conv_id.clone(), conversation_id: conv_id.clone(),
}; };
let _ = app.emit("ai-chat-event", ev_completed.clone()); emit_ai_chat_event(&app, ev_completed);
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_completed);
let ev_approval = AiChatEvent::AiApprovalResult { let ev_approval = AiChatEvent::AiApprovalResult {
id: tool_call_id.clone(), id: tool_call_id.clone(),
approved: false, approved: false,
conversation_id: conv_id.clone(), conversation_id: conv_id.clone(),
}; };
let _ = app.emit("ai-chat-event", ev_approval.clone()); emit_ai_chat_event(&app, ev_approval);
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_approval);
audit_finalize(&state, &tool_call_id, "rejected", Some(err_msg)).await; audit_finalize(&state, &tool_call_id, "rejected", Some(err_msg)).await;
if let Some(ref cid) = conv_id { if let Some(ref cid) = conv_id {
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await; save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
@@ -1275,10 +1412,7 @@ pub async fn ai_authorize_dir(
let __lock_t930 = std::time::Instant::now(); let __lock_t930 = std::time::Instant::now();
let session = state.ai_session.lock().await; let session = state.ai_session.lock().await;
let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0); let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0);
let __hold930 = __lock_t930.elapsed(); log_slow_lock(__lock_t930, "ai_authorize_dir:930");
if __hold930 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_authorize_dir:930 持锁 {:?} (含 lock 等待)", __hold930);
}
v v
}; };
try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await; try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
@@ -1371,27 +1505,20 @@ pub async fn ai_authorize_dir(
if let Some(ref cid) = conv_id { if let Some(ref cid) = conv_id {
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &result_val.to_string()); session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &result_val.to_string());
} }
let __hold1018 = __lock_t1018.elapsed(); log_slow_lock(__lock_t1018, "ai_authorize_dir:1018");
if __hold1018 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_authorize_dir:1018 持锁 {:?} (含 lock 等待)", __hold1018);
}
} }
let ev_completed = AiChatEvent::AiToolCallCompleted { let ev_completed = AiChatEvent::AiToolCallCompleted {
id: tool_call_id.clone(), id: tool_call_id.clone(),
result: result_val.clone(), result: result_val.clone(),
conversation_id: conv_id.clone(), conversation_id: conv_id.clone(),
}; };
let _ = app.emit("ai-chat-event", ev_completed.clone()); emit_ai_chat_event(&app, ev_completed);
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_completed);
let ev_approval = AiChatEvent::AiApprovalResult { let ev_approval = AiChatEvent::AiApprovalResult {
id: tool_call_id.clone(), id: tool_call_id.clone(),
approved: true, approved: true,
conversation_id: conv_id.clone(), conversation_id: conv_id.clone(),
}; };
let _ = app.emit("ai-chat-event", ev_approval.clone()); emit_ai_chat_event(&app, ev_approval);
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_approval);
if let Some(ref cid) = conv_id { if let Some(ref cid) = conv_id {
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await; save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
} }
@@ -1400,10 +1527,7 @@ pub async fn ai_authorize_dir(
let __lock_t1044 = std::time::Instant::now(); let __lock_t1044 = std::time::Instant::now();
let session = state.ai_session.lock().await; let session = state.ai_session.lock().await;
let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0); let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0);
let __hold1044 = __lock_t1044.elapsed(); log_slow_lock(__lock_t1044, "ai_authorize_dir:1044");
if __hold1044 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_authorize_dir:1044 持锁 {:?} (含 lock 等待)", __hold1044);
}
v v
}; };
try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await; try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
@@ -1428,18 +1552,18 @@ pub struct PendingToolCallInfo {
#[tauri::command] #[tauri::command]
pub async fn ai_pending_tool_calls( pub async fn ai_pending_tool_calls(
state: State<'_, AppState>, state: State<'_, AppState>,
conv_id: String, conversation_id: String,
) -> Result<Vec<PendingToolCallInfo>, String> { ) -> Result<Vec<PendingToolCallInfo>, String> {
let __lock_t1071 = std::time::Instant::now(); let __lock_t1071 = std::time::Instant::now();
let session = state.ai_session.lock().await; let session = state.ai_session.lock().await;
// 阶段3a 单真相源合并:单 pending_approvals 表按 conv_id 过滤,kind 字段透传给前端。 // 阶段3a 单真相源合并:单 pending_approvals 表按 conversation_id 过滤,kind 字段透传给前端。
// 阶段4:返 kind 字段,前端 switchConversation 恢复 pendingApprovals 时按 kind 渲染 // 阶段4:返 kind 字段,前端 switchConversation 恢复 pendingApprovals 时按 kind 渲染
// (risk→approve/reject,path→once/always/deny),对齐阶段3b 统一审批模型。 // (risk→approve/reject,path→once/always/deny),对齐阶段3b 统一审批模型。
// (阶段3a 时前端仅恢复 kind==Risk 走 toolCard;阶段3b 前端切后 path 类也归一渲染,后端两 kind 都返。) // (阶段3a 时前端仅恢复 kind==Risk 走 toolCard;阶段3b 前端切后 path 类也归一渲染,后端两 kind 都返。)
let list = session let list = session
.pending_approvals .pending_approvals
.values() .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 { .map(|a| PendingToolCallInfo {
tool_call_id: a.tool_call_id.clone(), tool_call_id: a.tool_call_id.clone(),
conversation_id: a.conversation_id.clone(), conversation_id: a.conversation_id.clone(),
@@ -1449,10 +1573,7 @@ pub async fn ai_pending_tool_calls(
}, },
}) })
.collect(); .collect();
let __hold1071 = __lock_t1071.elapsed(); log_slow_lock(__lock_t1071, "ai_pending_tool_calls:1071");
if __hold1071 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_pending_tool_calls:1071 持锁 {:?} (含 lock 等待)", __hold1071);
}
Ok(list) Ok(list)
} }
@@ -1469,7 +1590,7 @@ pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> {
let __lock_t1095 = std::time::Instant::now(); let __lock_t1095 = std::time::Instant::now();
// ── ① 先停 loop 防并发 save ── // ── ① 先停 loop 防并发 save ──
// 取活跃对话 id 后释放锁(避免持 session 锁调 DB)。stop_flag 置位 + notify 唤醒: // 取活跃对话 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 不冲突)。 // 让读侧 is_active 立即归零(Idle 自环幂等,guard.reset 不冲突)。
let active_id = { let active_id = {
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
@@ -1488,10 +1609,7 @@ pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> {
); );
} }
} }
let __hold1095 = __lock_t1095.elapsed(); log_slow_lock(__lock_t1095, "ai_chat_clear:1095");
if __hold1095 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_clear:1095 持锁 {:?} (含 lock 等待)", __hold1095);
}
active_id active_id
}; };
// ── ② DB 单事务清空(clear_messages + delete_range 一次 transaction 原子)── // ── ② DB 单事务清空(clear_messages + delete_range 一次 transaction 原子)──
@@ -1551,9 +1669,7 @@ pub async fn ai_chat_clear_context(
let ev = AiChatEvent::AiContextCleared { let ev = AiChatEvent::AiContextCleared {
conversation_id: Some(conversation_id.clone()), conversation_id: Some(conversation_id.clone()),
}; };
let _ = app.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&app, ev);
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
return Ok(()); return Ok(());
} }
// 对保护区外 active 消息标 archived_segment(messages_mut 直接改 status)。 // 对保护区外 active 消息标 archived_segment(messages_mut 直接改 status)。
@@ -1569,10 +1685,7 @@ pub async fn ai_chat_clear_context(
if dirtied { if dirtied {
conv.messages.mark_needs_full_rewrite(); conv.messages.mark_needs_full_rewrite();
} }
let __hold1145 = __lock_t1145.elapsed(); log_slow_lock(__lock_t1145, "ai_chat_clear_context:1145");
if __hold1145 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_clear_context:1145 持锁 {:?} (含 lock 等待)", __hold1145);
}
drop(session); drop(session);
conversation_id conversation_id
}; };
@@ -1581,20 +1694,20 @@ pub async fn ai_chat_clear_context(
let ev = AiChatEvent::AiContextCleared { let ev = AiChatEvent::AiContextCleared {
conversation_id: Some(conv_id), conversation_id: Some(conv_id),
}; };
let _ = app.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&app, ev);
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
Ok(()) Ok(())
} }
/// 手动 LLM 压缩上下文(F-15 阶段2):摘要保护区外消息并插入首位。 /// 手动 LLM 压缩上下文(F-15 阶段2):摘要保护区外消息并插入首位。
/// ///
/// 流程(失败不阻塞,消息状态不变): /// 流程(失败非阻断降级):
/// 1. emit AiCompressing + set_compressing(true) 防重入 /// 1. emit AiCompressing + set_compressing(true) 防重入
/// 2. 算 compress_end(保护区外,同 clear_context 的 protect_start) /// 2. 算 compress_end(保护区外,同 clear_context 的 protect_start)
/// 3. 先取保护区外 active 消息的克隆(读不改)→ 喂 compress_via_llm /// 3. 先取保护区外 active 消息的克隆(读不改)→ 喂 compress_via_llm
/// 4. LLM 成功 → compress_old_messages 标 compressed(扣 token) + insert_at(0, system 摘要) /// 4. LLM 成功 → compress_old_messages 标 compressed(扣 token) + insert_at(0, system 摘要)
/// 5. LLM 失败 → set_compressing(false) + emit AiError(不含 api_key) + 返回 Err;消息状态不变 /// 5. LLM 失败 → 与自动压缩同款兜底:标 compressed 释放 token + 关键词摘要插首位 +
/// toast(AiManualCompressed),不发 AiError 红卡(前端 AiError 无条件复位 streaming,
/// 手动压缩失败也非生成期错误)
/// ///
/// 空会话/无 active 可压缩消息 → set_compressing(false) 后 emit AiCompressing noop 返回 Ok。 /// 空会话/无 active 可压缩消息 → set_compressing(false) 后 emit AiCompressing noop 返回 Ok。
#[tauri::command] #[tauri::command]
@@ -1622,9 +1735,7 @@ pub async fn ai_chat_compress_context(
let ev = AiChatEvent::AiCompressing { let ev = AiChatEvent::AiCompressing {
conversation_id: Some(conversation_id.clone()), conversation_id: Some(conversation_id.clone()),
}; };
let _ = app.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&app, ev);
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
conv.messages.set_compressing(true); conv.messages.set_compressing(true);
let protect_start = conv.messages.len().saturating_sub(PROTECT_COUNT); let protect_start = conv.messages.len().saturating_sub(PROTECT_COUNT);
@@ -1638,9 +1749,7 @@ pub async fn ai_chat_compress_context(
conversation_id: cid, conversation_id: cid,
summary: String::new(), summary: String::new(),
}; };
let _ = app.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&app, ev);
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
return Ok(()); return Ok(());
} }
// 读 active 克隆(不改 status):compress_old_messages 留到 LLM 成功后才调 // 读 active 克隆(不改 status):compress_old_messages 留到 LLM 成功后才调
@@ -1652,10 +1761,7 @@ pub async fn ai_chat_compress_context(
.map(|t| t.message.clone()) .map(|t| t.message.clone())
.collect(); .collect();
let cid = session.active_conversation_id.clone().unwrap_or_else(|| conversation_id.clone()); let cid = session.active_conversation_id.clone().unwrap_or_else(|| conversation_id.clone());
let __hold1205 = __lock_t1205.elapsed(); log_slow_lock(__lock_t1205, "ai_chat_compress_context:1205");
if __hold1205 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1205 持锁 {:?} (含 lock 等待)", __hold1205);
}
(cid, active_msgs, protect_start) (cid, active_msgs, protect_start)
}; };
@@ -1664,19 +1770,14 @@ pub async fn ai_chat_compress_context(
let __lock_t1250 = std::time::Instant::now(); let __lock_t1250 = std::time::Instant::now();
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
session.conv(&conv_id).messages.set_compressing(false); session.conv(&conv_id).messages.set_compressing(false);
let __hold1250 = __lock_t1250.elapsed(); log_slow_lock(__lock_t1250, "ai_chat_compress_context:1250");
if __hold1250 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1250 持锁 {:?} (含 lock 等待)", __hold1250);
}
drop(session); drop(session);
// 治 Task#1:手动 IPC 降级(无 active 可压缩)用 AiManualCompressed(前端弹 toast)。 // 治 Task#1:手动 IPC 降级(无 active 可压缩)用 AiManualCompressed(前端弹 toast)。
let ev = AiChatEvent::AiManualCompressed { let ev = AiChatEvent::AiManualCompressed {
conversation_id: Some(conv_id), conversation_id: Some(conv_id),
summary: String::new(), summary: String::new(),
}; };
let _ = app.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&app, ev);
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
return Ok(()); return Ok(());
} }
@@ -1688,23 +1789,21 @@ pub async fn ai_chat_compress_context(
let __lock_t1269 = std::time::Instant::now(); let __lock_t1269 = std::time::Instant::now();
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
session.conv(&conv_id).messages.set_compressing(false); session.conv(&conv_id).messages.set_compressing(false);
let __hold1269 = __lock_t1269.elapsed(); log_slow_lock(__lock_t1269, "ai_chat_compress_context:1269");
if __hold1269 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1269 持锁 {:?} (含 lock 等待)", __hold1269);
}
drop(session); drop(session);
let ev = AiChatEvent::AiError { let ev = AiChatEvent::AiError {
error: format!("压缩失败: {}", e), error: format!("压缩失败: {}", e),
error_type: Some(super::super::ErrorType::Auth), error_type: Some(super::super::ErrorType::Auth),
conversation_id: Some(conv_id), conversation_id: Some(conv_id),
}; };
let _ = app.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&app, ev);
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
return Err(format!("压缩失败: {}", e)); return Err(format!("压缩失败: {}", e));
} }
}; };
let llm_concurrency = state.llm_concurrency.clone(); let llm_concurrency = state.llm_concurrency.clone();
// LLM 失败兜底文本须在 active_msgs move 进 compress_via_llm 前算好
// (同 context_lifecycle 自动压缩先算兜底再喂 LLM 的次序)。
let keyword_fallback = df_ai::context_helpers::extract_keyword_summary(&active_msgs);
let summary = match super::super::compress::compress_via_llm( let summary = match super::super::compress::compress_via_llm(
provider.as_ref(), provider.as_ref(),
&provider_config, &provider_config,
@@ -1715,24 +1814,38 @@ pub async fn ai_chat_compress_context(
).await { ).await {
Ok(s) => s.nl_summary, Ok(s) => s.nl_summary,
Err(e) => { Err(e) => {
// LLM 失败:不阻塞,set_compressing(false),消息状态不变(未调 compress_old_messages) // LLM 失败非阻断降级(与自动压缩 context_lifecycle 同款兜底):标 compressed 释放
// token + 关键词摘要插首位作续接锚点,不再 emit AiError 红卡(前端 AiError 无条件
// 复位 streaming 误判生成结束,且手动压缩失败非生成期错误)。toast 走
// AiManualCompressed 告知兜底结果。
tracing::warn!(
conv_id = %conv_id,
error = %e,
fallback_len = keyword_fallback.len(),
"[ai] 手动压缩 LLM 失败,降级关键词摘要兜底(非阻断)"
);
let __lock_t1295 = std::time::Instant::now(); let __lock_t1295 = std::time::Instant::now();
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
session.conv(&conv_id).messages.set_compressing(false); let conv = session.conv(&conv_id);
let __hold1295 = __lock_t1295.elapsed(); let _compressed = conv.messages.compress_old_messages(compress_end);
if __hold1295 > std::time::Duration::from_millis(30) { if !keyword_fallback.is_empty() {
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1295 持锁 {:?} (含 lock 等待)", __hold1295); conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback));
} }
conv.messages.set_compressing(false);
log_slow_lock(__lock_t1295, "ai_chat_compress_context:1295");
drop(session); drop(session);
let ev = AiChatEvent::AiError { save_conversation(&state.ai_session, &state.db, &conv_id, None, None, false).await;
error: format!("压缩失败: {}", e), let toast_summary = if keyword_fallback.is_empty() {
error_type: Some(super::super::ErrorType::Unknown), "压缩失败,历史消息已直接释放(可重试)".to_string()
conversation_id: Some(conv_id), } else {
keyword_fallback.clone()
}; };
let _ = app.emit("ai-chat-event", ev.clone()); let ev = AiChatEvent::AiManualCompressed {
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 conversation_id: Some(conv_id),
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev); summary: toast_summary,
return Err(format!("压缩失败: {}", e)); };
emit_ai_chat_event(&app, ev);
return Ok(());
} }
}; };
@@ -1744,10 +1857,7 @@ pub async fn ai_chat_compress_context(
let _compressed = conv.messages.compress_old_messages(compress_end); let _compressed = conv.messages.compress_old_messages(compress_end);
conv.messages.insert_at(0, ChatMessage::system(&summary)); conv.messages.insert_at(0, ChatMessage::system(&summary));
conv.messages.set_compressing(false); conv.messages.set_compressing(false);
let __hold1312 = __lock_t1312.elapsed(); log_slow_lock(__lock_t1312, "ai_chat_compress_context:1312");
if __hold1312 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1312 持锁 {:?} (含 lock 等待)", __hold1312);
}
} }
save_conversation(&state.ai_session, &state.db, &conv_id, None, None, false).await; save_conversation(&state.ai_session, &state.db, &conv_id, None, None, false).await;
// 治 Task#1:手动 IPC 成功主路径用 AiManualCompressed(Task#1 期望保留 toast+刷新)。 // 治 Task#1:手动 IPC 成功主路径用 AiManualCompressed(Task#1 期望保留 toast+刷新)。
@@ -1755,9 +1865,7 @@ pub async fn ai_chat_compress_context(
conversation_id: Some(conv_id), conversation_id: Some(conv_id),
summary, summary,
}; };
let _ = app.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&app, ev);
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
Ok(()) Ok(())
} }
@@ -1812,8 +1920,8 @@ pub async fn ai_chat_edit(
let lang = language.unwrap_or_else(|| "zh-CN".to_string()); let lang = language.unwrap_or_else(|| "zh-CN".to_string());
// conv_id 直接用 IPC 参数 conversation_id(F-260616-09 B 批4,读 per_conv.messages)。 // conv_id 直接用 IPC 参数 conversation_id(F-260616-09 B 批4,读 per_conv.messages)。
let conv_id = conversation_id.clone(); let conv_id = conversation_id.clone();
// Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。 // prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。
let cache_on = cache_enabled(&state).await && is_anthropic_type(&provider_config.provider_type); let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type);
let (mut system_prompt, mut volatile) = let (mut system_prompt, mut volatile) =
build_system_prompt_parts(&state, &lang, &[], &[], cache_on).await; build_system_prompt_parts(&state, &lang, &[], &[], cache_on).await;
// 知识注入:用新 user 文本检索(与 send/regenerate 同款)。 // 知识注入:用新 user 文本检索(与 send/regenerate 同款)。
@@ -1831,8 +1939,10 @@ pub async fn ai_chat_edit(
system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await; system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await;
} }
} }
// Augmentation 注入:edit 路径无新 @ mention / 技能,传 None 走空路径(不污染 prompt)。 // Augmentation 注入:edit 回溯末条 user(已被 replace_last_active_user_content 写为新文本)
let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await; // 前导 /技能名,复用 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 !aug_seg.is_empty() {
if cache_on { if cache_on {
volatile = append_volatile(volatile, &aug_seg); volatile = append_volatile(volatile, &aug_seg);
@@ -1979,6 +2089,12 @@ pub async fn ai_chat_force_send(
conv.notify.notify_one(); conv.notify.notify_one();
conv.loop_epoch.fetch_add(1, Ordering::SeqCst) + 1 conv.loop_epoch.fetch_add(1, Ordering::SeqCst) + 1
}; };
// spawn_agent 级联取消:强制发送复位父 loop,其派生的子代理同步置停。
// force_send 随后会把父 stop_flag 复位 false(新 loop 起跑),子循环不能依赖父
// flag 长期置位,own stop_flag 是子循环主判据(父条目消失为兜底)。
crate::commands::ai::agentic::sub_agent::cascade_stop_children_locked(
&mut session, &state.sub_agent_registry, &target,
);
// ② 同锁内立即占用 + 追加用户消息(逻辑照 ai_chat_send,无生成态拦截—— // ② 同锁内立即占用 + 追加用户消息(逻辑照 ai_chat_send,无生成态拦截——
// 这是"强制"语义本身,前面已主动复位) // 这是"强制"语义本身,前面已主动复位)
let conv = session.conv(&target); let conv = session.conv(&target);
@@ -2026,10 +2142,7 @@ pub async fn ai_chat_force_send(
// F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。 // F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。
let user_msg_id = conv.messages.last_user_message_id(); let user_msg_id = conv.messages.last_user_message_id();
// G1 目标钉扎已迁移至 run_agentic_loop 工具调用推理(提取方式同 ai_chat_send) // G1 目标钉扎已迁移至 run_agentic_loop 工具调用推理(提取方式同 ai_chat_send)
let __hold1473 = __lock_t1473.elapsed(); log_slow_lock(__lock_t1473, "ai_chat_force_send:1473");
if __hold1473 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_force_send:1473 持锁 {:?} (含 lock 等待)", __hold1473);
}
(was_gen.then_some(target.clone()), target, user_msg_id, conv.pinned_goals.clone(), new_epoch) (was_gen.then_some(target.clone()), target, user_msg_id, conv.pinned_goals.clone(), new_epoch)
}; };
@@ -2059,9 +2172,7 @@ pub async fn ai_chat_force_send(
conversation_id: Some(cid.clone()), conversation_id: Some(cid.clone()),
pinned_goals: old_pinned_goals.clone(), pinned_goals: old_pinned_goals.clone(),
}; };
let _ = app.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&app, ev);
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
} }
// system prompt 构建(照 ai_chat_send):augmentation 注入 + 知识注入顺序一致。 // system prompt 构建(照 ai_chat_send):augmentation 注入 + 知识注入顺序一致。
@@ -2069,12 +2180,12 @@ pub async fn ai_chat_force_send(
let lang = language.unwrap_or_else(|| "zh-CN".to_string()); let lang = language.unwrap_or_else(|| "zh-CN".to_string());
// 去重:同 ai_chat_send,从 mention_spans 提取被@的 project/task id 从清单排除。 // 去重:同 ai_chat_send,从 mention_spans 提取被@的 project/task id 从清单排除。
let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans); let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans);
// Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。 // prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。
let cache_on = cache_enabled(&state).await && is_anthropic_type(&provider_config.provider_type); let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type);
let (mut system_prompt, mut volatile) = let (mut system_prompt, mut volatile) =
build_system_prompt_parts(&state, &lang, &excl_proj, &excl_task, cache_on).await; build_system_prompt_parts(&state, &lang, &excl_proj, &excl_task, cache_on).await;
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影(语义同 ai_chat_send)。 // 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 !aug_seg.is_empty() {
if cache_on { if cache_on {
volatile = append_volatile(volatile, &aug_seg); volatile = append_volatile(volatile, &aug_seg);
@@ -2144,10 +2255,7 @@ pub async fn ai_chat_stop(
.unwrap_or_default(); .unwrap_or_default();
if target.is_empty() { if target.is_empty() {
// 无目标 conv 且无 active:无任何 conv 在跑(批4 顶层 generating 已退役),直接返回。 // 无目标 conv 且无 active:无任何 conv 在跑(批4 顶层 generating 已退役),直接返回。
let __hold1611 = __lock_t1611.elapsed(); log_slow_lock(__lock_t1611, "ai_chat_stop:1611");
if __hold1611 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_stop:1611 持锁 {:?} (含 lock 等待)", __hold1611);
}
drop(session); drop(session);
return Ok(()); return Ok(());
} }
@@ -2178,10 +2286,7 @@ pub async fn ai_chat_stop(
} }
conv.stop_flag.store(true, Ordering::SeqCst); // 双保险:防 try_continue 误判重启 conv.stop_flag.store(true, Ordering::SeqCst); // 双保险:防 try_continue 误判重启
let pinned_goals = conv.pinned_goals.clone(); let pinned_goals = conv.pinned_goals.clone();
let __hold1611 = __lock_t1611.elapsed(); log_slow_lock(__lock_t1611, "ai_chat_stop:1611");
if __hold1611 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_stop:1611 持锁 {:?} (含 lock 等待)", __hold1611);
}
drop(session); drop(session);
let ev = AiChatEvent::AiCompleted { let ev = AiChatEvent::AiCompleted {
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
@@ -2192,9 +2297,7 @@ pub async fn ai_chat_stop(
conversation_id: Some(target), conversation_id: Some(target),
pinned_goals, pinned_goals,
}; };
let _ = app.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&app, ev);
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
return Ok(()); return Ok(());
} }
// 流式生成中:置位让目标 conv 的 loop 自行收尾 // 流式生成中:置位让目标 conv 的 loop 自行收尾
@@ -2203,15 +2306,18 @@ pub async fn ai_chat_stop(
conv.stop_flag.store(true, Ordering::SeqCst); conv.stop_flag.store(true, Ordering::SeqCst);
conv.notify.clone() conv.notify.clone()
}; };
// spawn_agent 级联取消:父 loop 收到停止,其派生的子代理同步置停
// (子循环停止检查点读 own stop_flag 即退出,不等挂钟超时)。
// 短临界区纯内存操作(无 await),不延长 session 持锁。
crate::commands::ai::agentic::sub_agent::cascade_stop_children_locked(
&mut session, &state.sub_agent_registry, &target,
);
// 置位后立即 notify_one 唤醒阻塞在 stream.next() 的 select!, // 置位后立即 notify_one 唤醒阻塞在 stream.next() 的 select!,
// 不再等 30s 心跳 tick 或 120s idle timeout 才轮到 stop_flag 检查。 // 不再等 30s 心跳 tick 或 120s idle timeout 才轮到 stop_flag 检查。
// Notify 仅承载「即时唤醒」,停止真值仍由 stop_flag 决定(stream_llm 唤醒后再判 flag)。 // Notify 仅承载「即时唤醒」,停止真值仍由 stop_flag 决定(stream_llm 唤醒后再判 flag)。
notify.notify_one(); notify.notify_one();
let conv_id = Some(target); let conv_id = Some(target);
let __hold1611 = __lock_t1611.elapsed(); log_slow_lock(__lock_t1611, "ai_chat_stop:1611");
if __hold1611 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_stop:1611 持锁 {:?} (含 lock 等待)", __hold1611);
}
drop(session); drop(session);
// B-260615-13 兜底任务(F2 加固,2026-08-05):loop 若 panic/异常退出漏发收尾,stop_flag 无人读, // B-260615-13 兜底任务(F2 加固,2026-08-05):loop 若 panic/异常退出漏发收尾,stop_flag 无人读,
@@ -2302,9 +2408,8 @@ pub async fn ai_chat_stop(
conversation_id: conv_id.clone(), conversation_id: conv_id.clone(),
pinned_goals, pinned_goals,
}; };
let _ = app_handle.emit("ai-chat-event", ev.clone()); // 闭包内 app_handle 仍可访问 AppState(双写收敛进 emit_ai_chat_event)。
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。闭包内 app_handle 仍可访问 AppState。 emit_ai_chat_event(&app_handle, ev);
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
return; return;
} }
}); });
@@ -2336,10 +2441,7 @@ pub async fn ai_continue_loop(
// (含切走后的后台 conv),移除 active 一致性校验(真并发下后台 conv 也应可续跑)。 // (含切走后的后台 conv),移除 active 一致性校验(真并发下后台 conv 也应可续跑)。
// B-Phase3:读侧切无锁 conv_states。 // B-Phase3:读侧切无锁 conv_states。
if !state.conv_states.is_active(&conversation_id) { if !state.conv_states.is_active(&conversation_id) {
let __hold1744 = __lock_t1744.elapsed(); log_slow_lock(__lock_t1744, "ai_continue_loop:1744");
if __hold1744 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_continue_loop:1744 持锁 {:?} (含 lock 等待)", __hold1744);
}
return Err("AI 未在暂停态,无需继续".to_string()); return Err("AI 未在暂停态,无需继续".to_string());
} }
let conv = session.conv(&conversation_id); let conv = session.conv(&conversation_id);
@@ -2348,10 +2450,7 @@ pub async fn ai_continue_loop(
// F-260616-11 决策 a: 达 max 续跑重计 iteration(F-260616-03 决策 a,用户点继续=授权重来)。 // F-260616-11 决策 a: 达 max 续跑重计 iteration(F-260616-03 决策 a,用户点继续=授权重来)。
// 审批续跑(ai_approve)累计不重置见另路径;本路径 start_iteration 传 0,loop 从头计数。 // 审批续跑(ai_approve)累计不重置见另路径;本路径 start_iteration 传 0,loop 从头计数。
conv.iteration_used = 0; conv.iteration_used = 0;
let __hold1744 = __lock_t1744.elapsed(); log_slow_lock(__lock_t1744, "ai_continue_loop:1744");
if __hold1744 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_continue_loop:1744 持锁 {:?} (含 lock 等待)", __hold1744);
}
} }
// 复用审批恢复续 loop 入口(不重写 loop),其内部 spawn run_agentic_loop // 复用审批恢复续 loop 入口(不重写 loop),其内部 spawn run_agentic_loop
// F-09 B 批2:传 conv_id(IPC 参数 conversation_id)。 // F-09 B 批2:传 conv_id(IPC 参数 conversation_id)。
@@ -2381,10 +2480,7 @@ pub async fn ai_stop_loop(
// (真并发下后台 conv 也应可停止)。 // (真并发下后台 conv 也应可停止)。
// B-Phase3:读侧切无锁 conv_states。 // B-Phase3:读侧切无锁 conv_states。
if !state.conv_states.is_active(&conversation_id) { if !state.conv_states.is_active(&conversation_id) {
let __hold1782 = __lock_t1782.elapsed(); log_slow_lock(__lock_t1782, "ai_stop_loop:1782");
if __hold1782 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_stop_loop:1782 持锁 {:?} (含 lock 等待)", __hold1782);
}
return Err("AI 未在暂停态,无需停止".to_string()); return Err("AI 未在暂停态,无需停止".to_string());
} }
let conv = session.conv(&conversation_id); let conv = session.conv(&conversation_id);
@@ -2399,10 +2495,7 @@ pub async fn ai_stop_loop(
); );
} }
pinned_goals = conv.pinned_goals.clone(); pinned_goals = conv.pinned_goals.clone();
let __hold1782 = __lock_t1782.elapsed(); log_slow_lock(__lock_t1782, "ai_stop_loop:1782");
if __hold1782 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_stop_loop:1782 持锁 {:?} (含 lock 等待)", __hold1782);
}
} }
// 暂停态进入前已 save_conversation,此处零 token 上报仅作收敛信号(与 try_continue 补发 AiCompleted 一致) // 暂停态进入前已 save_conversation,此处零 token 上报仅作收敛信号(与 try_continue 补发 AiCompleted 一致)
let ev = AiChatEvent::AiCompleted { let ev = AiChatEvent::AiCompleted {
@@ -2418,9 +2511,7 @@ pub async fn ai_stop_loop(
conversation_id: Some(conversation_id), conversation_id: Some(conversation_id),
pinned_goals, pinned_goals,
}; };
let _ = app.emit("ai-chat-event", ev.clone()); emit_ai_chat_event(&app, ev);
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
Ok("ok".to_string()) Ok("ok".to_string())
} }
+15 -5
View File
@@ -1,8 +1,10 @@
//! skills + config 域 IPC — 5 个 `#[tauri::command]` 函数 //! skills + config 域 IPC — 5 个 `#[tauri::command]` 函数
//! //!
//! 原 `commands/ai/commands/mod.rs` 收敛到 re-export only 后,本文件承接最后一批 IPC: //! 原 `commands/ai/commands/mod.rs` 收敛到 re-export only 后,本文件承接最后一批 IPC:
//! - `ai_list_skills` — 列出本机 Claude 技能(skills + commands + plugins),供前端 `/` 联想 //! - `ai_list_skills` — 列出本机 Claude 技能(project + skills + commands + plugins,可选
//! - `ai_reload_skills` — 热重载技能列表(核心设计6:invalidate + 重扫,不重启生效) //! project_path 启用项目级扫描),供前端 `/` 联想
//! - `ai_reload_skills` — 热重载技能列表(核心设计6:invalidate + 重扫,不重启生效;
//! 项目级按已设 PROJECT_SKILLS_ROOT 一并重扫)
//! - `ai_set_concurrency_config` — LLM 并发上限运行时调整(global/per-conv,立即生效) //! - `ai_set_concurrency_config` — LLM 并发上限运行时调整(global/per-conv,立即生效)
//! - `ai_set_agent_max_iterations` — Agentic 循环最大轮次(下次发消息生效) //! - `ai_set_agent_max_iterations` — Agentic 循环最大轮次(下次发消息生效)
//! - `ai_set_agent_max_retries` — 流式对话失败自动重试次数(下次发消息生效) //! - `ai_set_agent_max_retries` — 流式对话失败自动重试次数(下次发消息生效)
@@ -28,10 +30,18 @@ use df_ai::ai_tools::RiskLevel;
use crate::state::AppState; use crate::state::AppState;
/// 列出本机 Claude 技能(skills + commands + plugins 三类),供前端 `/` 联想 /// 列出本机 Claude 技能(project + skills + commands + plugins),供前端 `/` 联想。
///
/// `project_path`(可选):当前项目根目录,Some 时扫 `<project_path>/.claude/skills/*/SKILL.md`
/// (source="project",同名覆盖 home 级)。变更才 invalidate 触发重扫(同值幂等零开销);
/// None/空 = 沿用已设 root 不清除(remote_bridge 零参调用与 FE 无项目语境时不传均安全)。
#[tauri::command] #[tauri::command]
pub async fn ai_list_skills() -> Result<Vec<SkillInfo>, String> { pub async fn ai_list_skills(project_path: Option<String>) -> Result<Vec<SkillInfo>, String> {
// 命中进程内缓存(skills_cached 已 owned Vec,直接返回) // 项目根变更才置缓存 None,skills_cached 慢路径重扫(含新项目级技能);
// 命中进程内缓存时零扫盘(skills_cached 已 owned Vec,直接返回)。
if super::super::skills::set_project_skills_root(project_path) {
invalidate_skills();
}
Ok(skills_cached().await) Ok(skills_cached().await)
} }
@@ -630,6 +630,12 @@ pub async fn ai_conversation_delete(
super::chat::finalize_pending_placeholders(&mut *session, &conversation_id, "会话已删除"); super::chat::finalize_pending_placeholders(&mut *session, &conversation_id, "会话已删除");
// 阶段3a 单真相源合并:单表 retain(kind 不区分)。 // 阶段3a 单真相源合并:单表 retain(kind 不区分)。
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id)); session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id));
// spawn_agent 级联取消:父会话删除,其派生的子代理同步置停(子循环停止检查点
// 读 own stop_flag 即退出;父 per_conv 条目随下方 remove 消失是兜底判据)。
// 短临界区纯内存操作(无 await),不延长 session 持锁。
crate::commands::ai::agentic::sub_agent::cascade_stop_children_locked(
&mut session, &state.sub_agent_registry, &conversation_id,
);
// 删除 conv 时移除其 per_conv 条目(loop 检测 conv 不存在即退出)。 // 删除 conv 时移除其 per_conv 条目(loop 检测 conv 不存在即退出)。
session.per_conv.remove(&conversation_id); session.per_conv.remove(&conversation_id);
// 顺带清理该 conv 的 save 串行锁条目(防 HashMap 无限增长)。 // 顺带清理该 conv 的 save 串行锁条目(防 HashMap 无限增长)。
+19
View File
@@ -36,6 +36,7 @@
//! (仅 `WorkflowEvent`),本总线是面向 AI 域的通用 pub-sub(透传 AiChatEvent 全量 Value)。 //! (仅 `WorkflowEvent`),本总线是面向 AI 域的通用 pub-sub(透传 AiChatEvent 全量 Value)。
//! AppState 分两个字段:`event_bus`(工作流)+ `ai_event_bus`(AI 域),互不干扰。 //! AppState 分两个字段:`event_bus`(工作流)+ `ai_event_bus`(AI 域),互不干扰。
use tauri::{AppHandle, Emitter, Manager};
use tokio::sync::broadcast; use tokio::sync::broadcast;
use super::AiChatEvent; use super::AiChatEvent;
@@ -69,6 +70,24 @@ pub const EVENT_BUS_ENABLED: bool = true;
#[allow(dead_code)] #[allow(dead_code)]
pub const DEFAULT_BUS_CAPACITY: usize = 256; 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::<crate::state::AppState>().ai_event_bus.publish_event(ev);
}
// ============================================================ // ============================================================
// EventBus — 基于 tokio::sync::broadcast 的 pub-sub 总线(载荷 serde_json::Value) // EventBus — 基于 tokio::sync::broadcast 的 pub-sub 总线(载荷 serde_json::Value)
// //
+10 -9
View File
@@ -224,7 +224,7 @@ async fn fetch_static(
markdown = cleanup_markdown(&markdown); markdown = cleanup_markdown(&markdown);
// ── 截断(按 char 边界) ── // ── 截断(按 char 边界) ──
let (markdown, truncated) = truncate_chars(&markdown, max_length); let (markdown, truncated) = truncate_markdown(&markdown, max_length);
Ok(json!({ Ok(json!({
"url": final_url, "url": final_url,
@@ -481,7 +481,7 @@ fn build_obscura_json(
) -> Value { ) -> Value {
let html_bytes_est = markdown_raw.len(); // obscura 不给原始 HTML 字节数,用 markdown 长度近似(仅信息字段) let html_bytes_est = markdown_raw.len(); // obscura 不给原始 HTML 字节数,用 markdown 长度近似(仅信息字段)
// 截断(按 char 边界,与静态一致) // 截断(按 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 elapsed_ms = started.elapsed().as_millis() as u64;
let title = extract_title_from_markdown(&markdown); let title = extract_title_from_markdown(&markdown);
// scheme:obscura 路径下重新 validate_url 拿 scheme(已在入口校验过,这里仅取字段值,失败兜底 https)。 // scheme:obscura 路径下重新 validate_url 拿 scheme(已在入口校验过,这里仅取字段值,失败兜底 https)。
@@ -652,7 +652,8 @@ fn cleanup_markdown(md: &str) -> String {
/// 按 char 边界截断 markdown。返回 (截断后 String, 是否截断)。 /// 按 char 边界截断 markdown。返回 (截断后 String, 是否截断)。
/// 不切多字节 UTF-8 中间(char_indices 保证落在 char 边界)。 /// 不切多字节 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(); let char_count = md.chars().count();
if char_count <= max { if char_count <= max {
return (md.to_string(), false); 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 测试策略)。 // handler 集成层走真实网络 #[ignore](对齐 http.rs 测试策略)。
// ============================================================ // ============================================================
@@ -792,12 +793,12 @@ mod tests {
assert_eq!(extract_title_from_markdown(" \n\n "), None); assert_eq!(extract_title_from_markdown(" \n\n "), None);
} }
// ── truncate_chars ── // ── truncate_markdown ──
#[test] #[test]
fn test_truncate_under_limit_unchanged() { fn test_truncate_under_limit_unchanged() {
let md = "short content"; let md = "short content";
let (out, trunc) = truncate_chars(md, 1000); let (out, trunc) = truncate_markdown(md, 1000);
assert!(!trunc); assert!(!trunc);
assert_eq!(out, "short content"); assert_eq!(out, "short content");
} }
@@ -805,7 +806,7 @@ mod tests {
#[test] #[test]
fn test_truncate_over_limit_marks_truncated() { fn test_truncate_over_limit_marks_truncated() {
let md = "abcdefghij".repeat(1000); // 10000 chars let md = "abcdefghij".repeat(1000); // 10000 chars
let (out, trunc) = truncate_chars(&md, 100); let (out, trunc) = truncate_markdown(&md, 100);
assert!(trunc); assert!(trunc);
assert!(out.contains("[markdown 已截断")); assert!(out.contains("[markdown 已截断"));
assert!(out.contains("原文 10000 chars")); assert!(out.contains("原文 10000 chars"));
@@ -816,7 +817,7 @@ mod tests {
fn test_truncate_respects_char_boundary_unicode() { fn test_truncate_respects_char_boundary_unicode() {
// 中文每字 3 字节,截 5 char 应得 15 字节(不在中间切) // 中文每字 3 字节,截 5 char 应得 15 字节(不在中间切)
let md = "你好世界测试内容"; // 8 chars,每字 3 字节 let md = "你好世界测试内容"; // 8 chars,每字 3 字节
let (out, trunc) = truncate_chars(md, 5); let (out, trunc) = truncate_markdown(md, 5);
assert!(trunc); assert!(trunc);
// 截断点应在 "你好世界测" 后(5 chars = 15 bytes),无半个字 // 截断点应在 "你好世界测" 后(5 chars = 15 bytes),无半个字
assert!(out.starts_with("你好世界测\n\n")); assert!(out.starts_with("你好世界测\n\n"));
@@ -961,7 +962,7 @@ mod tests {
#[test] #[test]
fn test_build_obscura_json_truncation_applies() { 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 md: String = "abcdefghij".repeat(2000); // 20000 chars
let started = Instant::now(); let started = Instant::now();
let v = build_obscura_json("https://example.com/", &md, 1000, started, "obscura", None); let v = build_obscura_json("https://example.com/", &md, 1000, started, "obscura", None);
+5 -3
View File
@@ -49,6 +49,7 @@ use df_storage::db::Database;
use df_storage::models::AiProviderRecord; use df_storage::models::AiProviderRecord;
use df_storage::secret::resolve_provider_secret; use df_storage::secret::resolve_provider_secret;
use crate::commands::ai::audit::truncate_chars;
use crate::commands::ai::http::{ use crate::commands::ai::http::{
build_client, execute_with_redirects, resolve_and_check_host, validate_url, MAX_REDIRECTS, 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))?; .map_err(|e| anyhow::anyhow!("调用图像生成端点失败 ({}): {}", endpoint, e))?;
let status = resp.status(); let status = resp.status();
if !status.is_success() { if !status.is_success() {
// 错误响应读 body 摘要(截断 500 chars)帮助定位 // 错误响应读 body 摘要(截断 500 chars,截断时尾部补 …)帮助定位
let err_text = resp.text().await.unwrap_or_default(); 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!( anyhow::bail!(
"图像生成端点返回 HTTP {}: {}", "图像生成端点返回 HTTP {}: {}",
status.as_u16(), status.as_u16(),
@@ -455,9 +456,10 @@ async fn write_atomic(output_path: &str, data: &[u8]) -> anyhow::Result<()> {
} }
/// 响应 body 截断为最多 500 chars 的字符串(用于错误信息),供定位用。 /// 响应 body 截断为最多 500 chars 的字符串(用于错误信息),供定位用。
/// 走 audit truncate_chars 正典(截断时尾部补 …);错误文案无硬长度约束,允许多 1 字符。
fn body_snippet(v: &Value) -> String { fn body_snippet(v: &Value) -> String {
let s = v.to_string(); let s = v.to_string();
s.chars().take(500).collect() truncate_chars(&s, 500)
} }
// ============================================================ // ============================================================
+142 -30
View File
@@ -19,8 +19,9 @@ use df_storage::models::{AiProviderRecord, KnowledgeRecord};
use df_types::types::new_id; 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 crate::commands::err_str;
use super::{AiSession}; use super::{AiSession};
@@ -39,7 +40,7 @@ const KNOWLEDGE_QUERY_MIN_ASCII_LEN: usize = 2;
/// 自动提炼窗口:取最近 N 条 user/assistant 消息送 LLM 提炼 /// 自动提炼窗口:取最近 N 条 user/assistant 消息送 LLM 提炼
/// ///
/// 原 6 条窗口过窄,长会话后半程的经验不在提炼视野内;40 条覆盖近期完整上下文, /// 原 6 条窗口过窄,长会话后半程的经验不在提炼视野内;40 条覆盖近期完整上下文,
/// 同时不撑爆提炼调用的 token 预算(max_tokens 2048 的输出侧不受影响,输入侧由 /// 同时不撑爆提炼调用的 token 预算(max_tokens 4096 的输出侧不受影响,输入侧由
/// 提炼模型上下文窗口兜底)。 /// 提炼模型上下文窗口兜底)。
const KNOWLEDGE_EXTRACT_WINDOW: usize = 40; const KNOWLEDGE_EXTRACT_WINDOW: usize = 40;
/// 自动提炼频次:距上次提炼至少新增 N 条用户消息才再提炼 /// 自动提炼频次:距上次提炼至少新增 N 条用户消息才再提炼
@@ -560,8 +561,8 @@ pub(crate) async fn build_knowledge_context(
for r in &results { for r in &results {
let kind = r.kind.clone(); let kind = r.kind.clone();
let title = r.title.clone(); let title = r.title.clone();
// 截断 content 防膨胀(注入侧最多 500 字符) // 截断 content 防膨胀(注入侧最多 500 字符;走 audit 正典,截断时尾部补 … 提示有省略)
let snippet: String = r.content.chars().take(500).collect(); let snippet: String = truncate_chars(&r.content, 500);
out.push_str(&format!("- [{}] {}: {} (复用 {} 次)\n", kind, title, snippet, r.reuse_count)); out.push_str(&format!("- [{}] {}: {} (复用 {} 次)\n", kind, title, snippet, r.reuse_count));
} }
out out
@@ -588,10 +589,7 @@ pub(crate) async fn build_knowledge_context_for_conv(
.iter() .iter()
.rev() .rev()
.find(|m| matches!(m.role, MessageRole::User) && m.is_active()); .find(|m| matches!(m.role, MessageRole::User) && m.is_active());
let __hold = __lock_t.elapsed(); log_slow_lock(__lock_t, "build_knowledge_context_for_conv:582");
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] build_knowledge_context_for_conv 持锁 {:?} (含 lock 等待)", __hold);
}
match found { match found {
Some(m) => (m.content.clone(), m.id.clone()), Some(m) => (m.content.clone(), m.id.clone()),
None => (String::new(), None), None => (String::new(), None),
@@ -688,10 +686,7 @@ pub(crate) async fn maybe_spawn_extraction(
} }
}; };
if blocked { if blocked {
let __hold = __lock_t.elapsed(); log_slow_lock(__lock_t, "maybe_spawn_extraction:692");
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] maybe_spawn_extraction 持锁 {:?} (含 lock 等待)", __hold);
}
tracing::debug!( tracing::debug!(
conv_id = %conv_id, conv_id = %conv_id,
"[knowledge] 跳过自动提炼:提炼中或距上次提炼新增用户消息不足 {} 条(如需立即重提炼用手动按钮)", "[knowledge] 跳过自动提炼:提炼中或距上次提炼新增用户消息不足 {} 条(如需立即重提炼用手动按钮)",
@@ -701,10 +696,7 @@ pub(crate) async fn maybe_spawn_extraction(
} }
// 守卫 2:消息数。conv_read 未建返 0(< min_messages 自然跳过,语义=空对话不注入知识)。 // 守卫 2:消息数。conv_read 未建返 0(< min_messages 自然跳过,语义=空对话不注入知识)。
if msg_count < config.min_messages as usize { if msg_count < config.min_messages as usize {
let __hold = __lock_t.elapsed(); log_slow_lock(__lock_t, "maybe_spawn_extraction:705");
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] maybe_spawn_extraction 持锁 {:?} (含 lock 等待)", __hold);
}
return Ok(()); return Ok(());
} }
// 原子预置位(TOCTOU 核心):释放锁前先占提炼槽位,杜绝并发窗口内重复 spawn。 // 原子预置位(TOCTOU 核心):释放锁前先占提炼槽位,杜绝并发窗口内重复 spawn。
@@ -713,10 +705,7 @@ pub(crate) async fn maybe_spawn_extraction(
.lock() .lock()
.expect("提炼水位互斥锁中毒") .expect("提炼水位互斥锁中毒")
.insert(conv_id.to_string(), ExtractWatermark::InFlight); .insert(conv_id.to_string(), ExtractWatermark::InFlight);
let __hold = __lock_t.elapsed(); log_slow_lock(__lock_t, "maybe_spawn_extraction:717");
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] maybe_spawn_extraction 持锁 {:?} (含 lock 等待)", __hold);
}
} }
let session_arc = session_arc.clone(); let session_arc = session_arc.clone();
@@ -744,28 +733,42 @@ pub(crate) async fn maybe_spawn_extraction(
"[knowledge] 提炼成功,水位记为当前进度,新增用户消息达阈值后才再提炼" "[knowledge] 提炼成功,水位记为当前进度,新增用户消息达阈值后才再提炼"
); );
} else { } else {
// 0 条:清哨兵 + 回退水位,允许下次自动触发重试(对话可能后续补充了有价值内容)。 // 0 条:清哨兵回滚预置位;水位记 Done 保节奏(原回退水位会让下一轮对话结束
// 立即重提炼,同窗口反复失败时形成逐轮风暴;记进度后须再攒够阈值条新用户消息)。
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
let rounds = session
.conv_read(&conv_id)
.map(|c| count_user_rounds(c.messages.iter()))
.unwrap_or(0);
session.conv(&conv_id).knowledge_extracted = false; session.conv(&conv_id).knowledge_extracted = false;
EXTRACT_WATERMARKS EXTRACT_WATERMARKS
.lock() .lock()
.expect("提炼水位互斥锁中毒") .expect("提炼水位互斥锁中毒")
.remove(&conv_id); .insert(conv_id.clone(), ExtractWatermark::Done(rounds));
tracing::info!( tracing::info!(
conv_id = %conv_id, conv_id = %conv_id, rounds,
"[knowledge] 提炼 0 条(无可提炼内容),清哨兵与水位允许下次自动重试" "[knowledge] 提炼 0 条(无可提炼内容),水位记为当前进度,新增用户消息达阈值后才再试"
); );
} }
} }
Err(e) => { Err(e) => {
// 失败:清哨兵 + 回退水位,允许下次触发重试(预置位已在,须回滚否则会话被锁死)。 // 失败:清哨兵回滚预置位;水位同样记 Done 保节奏(原回退水位在持续故障
// (截断/网络/密钥)时逐轮重试刷爆;达阈值后才再试,手动按钮仍可立即重提炼)。
tracing::warn!("知识提取失败(非阻断): {}", e); tracing::warn!("知识提取失败(非阻断): {}", e);
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
let rounds = session
.conv_read(&conv_id)
.map(|c| count_user_rounds(c.messages.iter()))
.unwrap_or(0);
session.conv(&conv_id).knowledge_extracted = false; session.conv(&conv_id).knowledge_extracted = false;
EXTRACT_WATERMARKS EXTRACT_WATERMARKS
.lock() .lock()
.expect("提炼水位互斥锁中毒") .expect("提炼水位互斥锁中毒")
.remove(&conv_id); .insert(conv_id.clone(), ExtractWatermark::Done(rounds));
tracing::info!(
conv_id = %conv_id, rounds,
"[knowledge] 提炼失败,水位记为当前进度,新增用户消息达阈值后才再试"
);
} }
} }
}); });
@@ -832,7 +835,8 @@ const EXTRACTION_SYSTEM_PROMPT: &str = "你是知识提炼引擎,从 AI 对话
1. 如果适用范围有限制(如仅适用特定语言/框架/场景),必须在 content 或 tags 中明确标注\n\ 1. 如果适用范围有限制(如仅适用特定语言/框架/场景),必须在 content 或 tags 中明确标注\n\
2. confidence: high=对话中可直接观察的明确模式, medium=合理推断, low=推测性弱信号\n\ 2. confidence: high=对话中可直接观察的明确模式, medium=合理推断, low=推测性弱信号\n\
3. 无可提炼内容时返回空数组 []\n\ 3. 无可提炼内容时返回空数组 []\n\
4. 输出纯 JSON,无任何额外文字"; 4. 输出纯 JSON,无任何额外文字\n\
5. 最多提取 5 条,按复用价值降序排列(只保留高价值条目,宁缺毋滥)";
/// 从对话中提炼知识,产出 candidate 写入知识库 /// 从对话中提炼知识,产出 candidate 写入知识库
/// ///
@@ -958,7 +962,7 @@ async fn extract_knowledge_from_conversation(
model: extract_model, model: extract_model,
messages: extract_messages, messages: extract_messages,
temperature: Some(0.3), temperature: Some(0.3),
max_tokens: Some(2048), max_tokens: Some(4096),
stream: false, stream: false,
tools: None, tools: None,
tool_choice: None, tool_choice: None,
@@ -981,10 +985,28 @@ async fn extract_knowledge_from_conversation(
let json_str = strip_code_fence(raw); let json_str = strip_code_fence(raw);
let items: Vec<serde_json::Value> = match serde_json::from_str(json_str) { let items: Vec<serde_json::Value> = match serde_json::from_str(json_str) {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => match salvage_truncated_json_array(json_str) {
tracing::warn!("知识提炼 JSON 解析失败,整批丢弃(非阻断): {} | 原始: {}", e, raw); // 截断打捞:max_tokens 截断常把数组拦腰砍断,先救回已完整闭合的条目
Some(salvaged) if !salvaged.is_empty() => {
tracing::warn!("知识提炼截断打捞: 救回 {} 条,丢弃尾部残条", salvaged.len());
salvaged
}
// 打捞后 0 条才整批丢弃;空输出(content 空/null)与解析失败单独留痕区分
_ => {
if raw.is_empty() {
tracing::warn!(
"知识提炼输出为空(content 空或 null),整批丢弃, completion_tokens={}",
resp.usage.completion_tokens
);
} else {
tracing::warn!(
"知识提炼 JSON 解析失败且打捞无果,整批丢弃(非阻断): {} | completion_tokens={} | 原始: {}",
e, resp.usage.completion_tokens, raw
);
}
return Ok(0); return Ok(0);
} }
},
}; };
let knowledge_repo = KnowledgeRepo::new(db); let knowledge_repo = KnowledgeRepo::new(db);
@@ -1069,6 +1091,53 @@ fn strip_code_fence(s: &str) -> &str {
s s
} }
/// 截断 JSON 打捞 — 从被截断的数组输出中救回已完整闭合的顶层条目。
///
/// 背景:提炼输出被 max_tokens 拦腰截断(`[{"a":1},{"b":` )时 serde 整批报错,
/// 此前整批丢弃。扫描跳过字符串字面量内部(含 `\"` 转义)的括号,记录数组元素层
/// (depth 归 1)最后一次 `}` 的位置 — 等价于"从尾向前扫到最后一个完整顶层 `}`"
/// (引号开闭与转义只有正向扫描可可靠判定,正向记录末次闭合与反向扫描结果一致)。
/// 截到该位补 `]` 重解析,serde 通过且非空才由调用方入库;空串/纯垃圾/无完整条目
/// 返回 None(调用方走原丢弃路径)。
fn salvage_truncated_json_array(raw: &str) -> Option<Vec<serde_json::Value>> {
let bytes = raw.as_bytes();
let mut in_string = false;
let mut escaped = false;
let mut depth: i64 = 0;
let mut last_elem_close: Option<usize> = None;
for (i, &b) in bytes.iter().enumerate() {
if in_string {
if escaped {
escaped = false; // 转义字符本身,不参与状态判定
} else if b == b'\\' {
escaped = true;
} else if b == b'"' {
in_string = false;
}
continue;
}
match b {
b'"' => in_string = true,
b'{' | b'[' => depth += 1,
b'}' | b']' => {
depth -= 1;
if depth == 1 && b == b'}' {
// 数组元素层闭合:最后一个可打捞条目的右边界
last_elem_close = Some(i);
}
if depth <= 0 {
// 顶层 `]`(完整数组)或深度为负(垃圾前缀):数组层已到界,停止
break;
}
}
_ => {}
}
}
let cut = last_elem_close?;
let candidate = format!("{}]", &raw[..=cut]);
serde_json::from_str(&candidate).ok()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1282,3 +1351,46 @@ mod tests {
assert!(out.contains(&"失败".to_string())); assert!(out.contains(&"失败".to_string()));
} }
} }
#[cfg(test)]
mod salvage_tests {
use super::*;
/// 截断点在第二个元素中段:已完整闭合的首条目应救回
#[test]
fn salvages_complete_prefix_of_truncated_array() {
let raw = r#"[{"kind":"pitfall","title":"t1","content":"c1"},{"kind":"review_r"#;
let out = salvage_truncated_json_array(raw).expect("应打捞出 1 条");
assert_eq!(out.len(), 1);
assert_eq!(out[0]["title"], "t1");
}
/// 括号与引号出现在字符串字面量内(含转义):不得误判为结构边界
#[test]
fn salvage_respects_string_literals_and_escapes() {
let raw = r#"[{"title":"含}花括号与\"引号","content":"a\\"},{"kind":"pit"#;
let out = salvage_truncated_json_array(raw).expect("应打捞出 1 条");
assert_eq!(out.len(), 1);
assert_eq!(out[0]["title"], "含}花括号与\"引号");
assert_eq!(out[0]["content"], "a\\");
}
/// 完整数组 + 尾部脏字符:全部条目救回(等价重解析)
#[test]
fn complete_array_with_trailing_garbage_salvages_all() {
let raw = r#"[{"kind":"pitfall","title":"t1"},{"kind":"diagnosis","title":"t2"}] 附带文字"#;
let out = salvage_truncated_json_array(raw).expect("应打捞 2 条");
assert_eq!(out.len(), 2);
assert_eq!(out[1]["title"], "t2");
}
/// 垃圾/空输出不误收:返回 None 走原丢弃路径
#[test]
fn garbage_or_empty_yields_none() {
assert!(salvage_truncated_json_array("").is_none(), "空输出无物可打捞");
assert!(salvage_truncated_json_array("模型输出了一段散文,没有 JSON").is_none());
assert!(salvage_truncated_json_array(r#"}{"a":1}"#).is_none(), "垃圾前缀深度为负");
assert!(salvage_truncated_json_array(r#"{"kind":"pitfall""#).is_none(), "无完整闭合元素");
assert!(salvage_truncated_json_array("[").is_none());
}
}
+43
View File
@@ -42,6 +42,8 @@ pub mod download_file;
pub(crate) mod generate_image; pub(crate) mod generate_image;
pub mod get_app_config; pub mod get_app_config;
pub mod knowledge_inject; pub mod knowledge_inject;
// 系统通知(P1 bg 化补完):完成/失败/需介入三类事件,主窗托盘化时补位提醒(条件发送)
pub(crate) mod notify;
pub mod prompt; pub mod prompt;
pub mod provider_pool; pub mod provider_pool;
pub mod remote_bridge; pub mod remote_bridge;
@@ -143,6 +145,13 @@ pub enum AiChatEvent {
/// 与 AiToolCallStarted/Completed 正交——这三个事件描述一次信任放行调用生命周期: /// 与 AiToolCallStarted/Completed 正交——这三个事件描述一次信任放行调用生命周期:
/// Started → AutoApproved(toast 提示用户已自动放行)→ Completed。 /// Started → AutoApproved(toast 提示用户已自动放行)→ Completed。
AiToolAutoApproved { id: String, tool: String, dir: String, conversation_id: Option<String> }, AiToolAutoApproved { id: String, tool: String, dir: String, conversation_id: Option<String> },
/// 技能注入反馈闭环(/ 技能 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<String> },
/// 需要人工审批 /// 需要人工审批
/// diff(AE-2025-03,路径 B):仅 write_file 注入(旧文件 vs 新内容行级 unified diff), /// diff(AE-2025-03,路径 B):仅 write_file 注入(旧文件 vs 新内容行级 unified diff),
/// 供审批卡即时预览。旧文件不存在(新建)或非 write_file 工具为 None。 /// 供审批卡即时预览。旧文件不存在(新建)或非 write_file 工具为 None。
@@ -213,6 +222,23 @@ pub enum AiChatEvent {
/// - 点继续 → ai_continue_loop → try_continue_agent_loop 再跑 max_iterations 轮 /// - 点继续 → ai_continue_loop → try_continue_agent_loop 再跑 max_iterations 轮
/// - 点停止 → ai_stop_loop → 走完成流程(save + AiCompleted + generating=false) /// - 点停止 → ai_stop_loop → 走完成流程(save + AiCompleted + generating=false)
AiMaxRoundsReached { conversation_id: Option<String> }, AiMaxRoundsReached { conversation_id: Option<String> },
/// 子代理结束(spawn_agent v1):每个子循环统一出口经挂父会话 emit 一条,
/// 汇总子代理终态供前端展示(消费二期)。报告本体不经本事件传输(经父 tool_result
/// 返回值交付),此处 summary 仅为终态快照摘要。
///
/// 字段:
/// - `agent_id`:子会话 id(形态 `{parent}::agent-{n}`,与 Started 无对应卡,独立事件)
/// - `conversation_id`:父会话 id(路由键,挂父不挂子,子会话不在侧栏)
/// - `status`:"completed" | "truncated" | "failed"
/// - `rounds`/`tokens`:实际执行轮数与累计 token(启动失败为 0)
AiAgentFinished {
agent_id: String,
conversation_id: Option<String>,
summary: String,
status: String,
rounds: u32,
tokens: u64,
},
/// 流式调用自动重试提示流前失败重试中) /// 流式调用自动重试提示流前失败重试中)
/// ///
/// 每次重试前 emit,前端可在错误气泡内显示「重试 n/m」。 /// 每次重试前 emit,前端可在错误气泡内显示「重试 n/m」。
@@ -977,6 +1003,21 @@ pub struct PerConvState {
pub workflow_id: Option<String>, pub workflow_id: Option<String>,
/// T4 工作流 DAG 摘要(预计算的活跃路径文本,避免运行时加载 DAG) /// T4 工作流 DAG 摘要(预计算的活跃路径文本,避免运行时加载 DAG)
pub workflow_dag_summary: Option<String>, pub workflow_dag_summary: Option<String>,
/// 跨轮工具集冻结(前缀稳定):首次意图裁剪结果按工具名冻结,后续轮只增不减。
///
/// 治「每轮按末条 user 消息重新 intent 裁工具 → tools 数组跨轮变化 → 前缀断」:
/// tools 数组在请求里位于 system 之后、消息之前,数组字节一变,自动前缀缓存
/// (DeepSeek/GLM/Qwen)与 Anthropic 显式缓存全部失效。新意图带来的工具并入冻结集,
/// 已并入的不移除。None = 尚未冻结(首轮裁剪时写入);纯问候空工具集不进本字段
/// (仅作用当轮请求)。随会话销毁不落库(对齐 pinned_goals 语义)。
pub frozen_tool_names: Option<Vec<String>>,
/// tool_result 视图压缩决策冻结(content 稳定哈希 → 是否压缩)。
///
/// 治「压缩阈值随 history_tokens 增长漂移 → 同一条历史第 N 轮发全文、第 N+1 轮变摘要
/// → 轮中前缀断」:决策按消息首次进入请求视图时刻一次性判定并冻结,后续轮沿用同一视图。
/// 仍为 view-only(不落库改历史,仅影响发送视图);键用内容哈希而非位置(裁剪/压缩后
/// 位置漂移,内容不变)。随会话销毁不落库。
pub tool_view_decisions: std::collections::HashMap<u64, bool>,
} }
/// 单个目标记录(含状态追踪)。 /// 单个目标记录(含状态追踪)。
@@ -1040,6 +1081,8 @@ impl PerConvState {
working_context: df_ai::context_helpers::WorkingContext::new(), working_context: df_ai::context_helpers::WorkingContext::new(),
workflow_id: None, workflow_id: None,
workflow_dag_summary: None, workflow_dag_summary: None,
frozen_tool_names: None,
tool_view_decisions: std::collections::HashMap::new(),
} }
} }
} }
+99
View File
@@ -0,0 +1,99 @@
//! 系统通知(P1 bg 化补完):主窗隐藏到托盘时,关键 AI 事件经 OS 通知补位提醒。
//!
//! 三类事件:完成 / 失败 / 需介入。防风暴三则:
//! 1. 主窗可见且聚焦时不发。审批场景已有 ApprovalOverlay 与 approval-popup 双机制,
//! 系统通知只补「用户切走/托盘化」场景,用户在场时前端事件流已足够。
//! 2. 同类事件最短间隔 5 秒。窗口期内到达只计数不发送,下次实际发送时把被抑制数
//! 合并进正文,避免多会话并发收尾时通知刷屏。
//! 3. 所有发送失败静默(let _ =)。通知是锦上添花,绝不侵入 AI 主链路错误传播。
use std::sync::Mutex;
use std::time::{Duration, Instant};
use tauri::{AppHandle, Manager};
use tauri_plugin_notification::NotificationExt;
/// 同类事件两次系统通知的最短间隔。
const MIN_INTERVAL: Duration = Duration::from_secs(5);
/// 通知类别。同时决定节流分组:同类共享同一条间隔记录,互不挤占。
#[derive(Clone, Copy)]
pub(crate) enum NotifyKind {
/// 轮次收尾完成(含 Coordinator 合并、MidStream 保文等完成出口)
Completed,
/// Fatal / 重试耗尽等失败
Failed,
/// 审批挂起 / 达轮次上限 / 熔断求助等需要用户介入
NeedAction,
}
impl NotifyKind {
/// 节流表下标(per-kind 独立计数)。
fn slot(self) -> usize {
match self {
NotifyKind::Completed => 0,
NotifyKind::Failed => 1,
NotifyKind::NeedAction => 2,
}
}
}
/// 同类事件节流表:per-kind 记 (上次发送时刻, 窗口期内被抑制的到达数)。
static THROTTLE: Mutex<[Option<(Instant, usize)>; 3]> = Mutex::new([None, None, None]);
/// 条件发送会话事件系统通知。
///
/// 触发点:emit_ai_completed_once 完成出口 / emit_fatal_error / 审批挂起 /
/// 达轮次上限 / 熔断求助。见模块文档防风暴三则。
pub(crate) fn notify_session_event(
app: &AppHandle,
kind: NotifyKind,
conv_id: &str,
title: &str,
body: &str,
) {
// 防风暴 1:主窗可见且聚焦 = 用户在场,跳过
if is_main_visible_and_focused(app) {
return;
}
// 防风暴 2:同类 5 秒窗口内只计数,返回;窗口外正常发送并把被抑制数带回合并
let suppressed = {
// 锁中毒(持锁线程 panic)不应永久禁用通知,拿回内部数据继续
let mut slots = THROTTLE.lock().unwrap_or_else(|e| e.into_inner());
let now = Instant::now();
let entry = &mut slots[kind.slot()];
if let Some((last, _)) = *entry {
if now.duration_since(last) < MIN_INTERVAL {
if let Some((_, count)) = entry.as_mut() {
*count += 1;
}
return;
}
}
let suppressed = entry.as_ref().map_or(0, |(_, count)| *count);
*entry = Some((now, 0));
suppressed
};
// 窗口期合并:被抑制的同类事件并入本次正文一次说清
let body = if suppressed > 0 {
format!("{}\n另有 {} 条同类会话动态已合并提醒", body, suppressed)
} else {
body.to_string()
};
tracing::debug!(conv_id = %conv_id, "[notify] 系统通知: {}", title);
// 防风暴 3:失败静默,不向上传播
let _ = app
.notification()
.builder()
.title(title)
.body(&body)
.show();
}
/// 主窗是否可见且聚焦(用户正盯着主窗)。
/// 主窗不存在(异常)按不在场处理,允许通知兜底。
fn is_main_visible_and_focused(app: &AppHandle) -> bool {
app.get_webview_window("main")
.map(|w| w.is_visible().unwrap_or(false) && w.is_focused().unwrap_or(false))
.unwrap_or(false)
}
+26 -12
View File
@@ -7,7 +7,7 @@ use df_storage::db::Database;
use df_storage::models::AiProviderRecord; use df_storage::models::AiProviderRecord;
use crate::commands::err_str; use crate::commands::err_str;
use crate::state::AppState; use crate::state::{AppState, SettingsNorm};
/// 获取当前活跃提供商配置 /// 获取当前活跃提供商配置
pub(crate) async fn get_active_provider(state: &AppState) -> Result<AiProviderRecord, String> { pub(crate) async fn get_active_provider(state: &AppState) -> Result<AiProviderRecord, String> {
@@ -486,22 +486,36 @@ async fn build_context_lists(
/// 自定义提示词段(设置 custom_prompt,追加到 system prompt 末尾)。无/空返回空串。 /// 自定义提示词段(设置 custom_prompt,追加到 system prompt 末尾)。无/空返回空串。
async fn build_custom_section(state: &AppState) -> String { async fn build_custom_section(state: &AppState) -> String {
if let Ok(Some(custom)) = state.settings.get("custom_prompt").await { // get_string_norm 归一去引号(前端 JSON.stringify 落库带引号),再 trim 空白;空返回空段。
if !custom.is_empty() { let clean = state
let clean = custom.trim().trim_matches('"'); .settings
if !clean.is_empty() { .get_string_norm("custom_prompt")
return format!("\n## 自定义指令\n{}\n", clean); .await
} .unwrap_or_default();
} if !clean.trim().is_empty() {
return format!("\n## 自定义指令\n{}\n", clean.trim());
} }
String::new() String::new()
} }
/// 读取 Anthropic prompt caching 开关(AppState settings KV,默认关)并同步 df-ai 进程级开关。 /// 当前 provider 是否支持 prompt caching 拆分(同一 settings 开关下的两类受益方):
/// - anthropic / anthropic_compat:显式缓存(system+消息级 cache_control 断点);
/// - openai_compat:自动前缀缓存(DeepSeek/GLM/Qwen 等,无需 cache_control 标记,
/// 只要求前缀字节稳定,拆分后易变段挪到消息流末尾即命中)。
pub(crate) fn cache_split_provider_type(provider_type: &str) -> bool {
matches!(
provider_type,
"anthropic" | "anthropic_compat" | "openai_compat"
)
}
/// 读取 prompt caching 拆分开关(AppState settings KV `df-ai-anthropic-cache`,默认关)
/// 并同步 df-ai 进程级显式缓存开关。
/// ///
/// 值语义:非 "true"/"1"(含缺省/空)一律视为关——默认关、用户对支持的 anthropic 端点显式开启。 /// 值语义:非 "true"/"1"(含缺省/空)一律视为关——默认关、用户对支持的端点显式开启。
/// 同步:anthropic_compat::convert_request 读取同一进程级开关决定 system 形态 /// 同步:anthropic_compat::convert_request 读取同一进程级开关决定 system 形态
/// (数组+cache_control / 纯字符串),保证开关关时非官方网关零风险。 /// (数组+cache_control / 纯字符串),保证开关关时非官方网关零风险。
/// 键名保持历史名(向后兼容,已有键不换名),语义已扩展覆盖 openai_compat 前缀稳定拆分。
pub(crate) async fn cache_enabled(state: &AppState) -> bool { pub(crate) async fn cache_enabled(state: &AppState) -> bool {
let v = state let v = state
.settings .settings
@@ -525,8 +539,8 @@ pub(crate) async fn cache_enabled(state: &AppState) -> bool {
/// 首条 user:任何 system 之后的消息级缓存断点都在易变段之前,每日/每请求变化的易变内容 /// 首条 user:任何 system 之后的消息级缓存断点都在易变段之前,每日/每请求变化的易变内容
/// 不会失效已缓存前缀(跨天缓存仍命中)。 /// 不会失效已缓存前缀(跨天缓存仍命中)。
/// ///
/// 注意:调用方须先按「开关开 AND 当前 provider 为 anthropic 类型」gate 再传 `cache_enabled`, /// 注意:调用方须先按「开关开 AND 当前 provider 支持缓存拆分(anthropic 系/openai_compat)」
/// 保证 OpenAI 路径零影响(开关开也不改动 openai 请求结构)。 /// gate 再传 `cache_enabled`,保证开关关时全部 provider 零影响(逐字等价旧路径)。
pub(crate) async fn build_system_prompt_parts( pub(crate) async fn build_system_prompt_parts(
state: &AppState, state: &AppState,
lang: &str, lang: &str,
+12 -13
View File
@@ -65,8 +65,8 @@ use crate::state::AppState;
use crate::commands::ai::{ use crate::commands::ai::{
ai_approve, ai_authorize_dir, ai_chat_clear_context, ai_chat_compress_context, ai_chat_edit, 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_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, ai_list_skills, ai_regenerate, ai_stop_loop, event_bus::emit_ai_chat_event,
ConvSummary, ModelInfo, PendingApproval, record_to_message, AiChatEvent, ApprovalKind, ConvSummary, ModelInfo, PendingApproval,
}; };
// list_models 路由:get_active_provider(prompt.rs 已 pub(crate))读活跃 provider。 // list_models 路由:get_active_provider(prompt.rs 已 pub(crate))读活跃 provider。
// 不走 commands 模块(provider 获取是 prompt 子模块职责,对齐 chat.rs 同源调用路径)。 // 不走 commands 模块(provider 获取是 prompt 子模块职责,对齐 chat.rs 同源调用路径)。
@@ -581,8 +581,8 @@ async fn route_sync_pending(state: &State<'_, AppState>, conversation_id: String
/// `list_skills` 路由 —— 读本机 Claude 技能进程内缓存,跨端推回 miniapp。 /// `list_skills` 路由 —— 读本机 Claude 技能进程内缓存,跨端推回 miniapp。
/// ///
/// 流程: /// 流程:
/// 1. `ai_list_skills().await`(零参,直接读 `skills_cached()` 进程内缓存,不重复扫盘) /// 1. `ai_list_skills(None).await`(直接读 `skills_cached()` 进程内缓存,不重复扫盘)
/// → `Vec<SkillInfo>`(已按 skills>commands>plugins 优先级去重) /// → `Vec<SkillInfo>`(已按 project>skills>commands>plugins 优先级去重)
/// 2. `state.ai_event_bus.publish_event(AiSkillList)` 跨端透传 /// 2. `state.ai_event_bus.publish_event(AiSkillList)` 跨端透传
/// (经 EventBus→tunnel subscriber→relay→miniapp,非 app.emit 仅桌面前端) /// (经 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 兜底) /// 错误兜底:ai_list_skills 返 Err(skills_cached 内部理论上不失败,但签名返 Result 兜底)
/// → publish 空 Vec(对齐 route_list_conversations 失败推空列表语义)。 /// → 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 的直接调用模式)。 /// 不经 IPC invoke(对齐 route_send_message 调 ai_chat_send 的直接调用模式)。
async fn route_list_skills(state: &State<'_, AppState>) { async fn route_list_skills(state: &State<'_, AppState>) {
let skills: Vec<SkillInfo> = match ai_list_skills().await { // project_path 传 None:沿用已设项目根(桌面端 ai_list_skills 已设则项目级技能一并透传)
let skills: Vec<SkillInfo> = match ai_list_skills(None).await {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
tracing::warn!(error = %e, "[remote_bridge] list_skills 读技能缓存失败,推送空列表"); 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 端不会把此事件当入站命令回灌(命令/事件分轨)。 // 注:publish 走事件通道,device 端不会把此事件当入站命令回灌(命令/事件分轨)。
// 前端 handleEvent 去重防双气泡(末条已是同 content user 跳过)。 // 前端 handleEvent 去重防双气泡(末条已是同 content user 跳过)。
// BUG-260805-02 双写修复:桌面端只 listen app.emit('ai-chat-event'),publish_event(ai_event_bus) // BUG-260805-02 双写修复:桌面端只 listen app.emit('ai-chat-event'),publish_event(ai_event_bus)
// 桌面前端收不到 → 微信 user 消息桌面不可见。补 emit 双写(桌面收),保留 publish(微信自回灌去重 + // 桌面前端收不到 → 微信 user 消息桌面不可见。emit+publish 双写两行式已收敛为
// tunnel 透传其他 miniapp 订阅者)。 // emit_ai_chat_event(2026-10-01 DRY,两通道各收同一份事件值,语义不变)。
let user_msg_ev = AiChatEvent::AiUserMessage { let user_msg_ev = AiChatEvent::AiUserMessage {
message: message.clone(), message: message.clone(),
// 用 resolve 后真实 conv_id(新会话是强制新建 id,旧会话是入参/active), // 用 resolve 后真实 conv_id(新会话是强制新建 id,旧会话是入参/active),
// 桌面端才能正确归属用户气泡到会话(此前广播原始 null 致无法归属)。 // 桌面端才能正确归属用户气泡到会话(此前广播原始 null 致无法归属)。
conversation_id: resolved_conv_id.clone(), conversation_id: resolved_conv_id.clone(),
}; };
let _ = app.emit("ai-chat-event", user_msg_ev.clone()); emit_ai_chat_event(app, user_msg_ev);
let _ = state.ai_event_bus.publish_event(user_msg_ev);
let result = ai_chat_send( let result = ai_chat_send(
app.clone(), app.clone(),
state.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 消息, // 广播 AiUserMessage(同 route_send_message:force_send 追加新 user 消息,
// 桌面端需据此补 user 气泡,防孤立 assistant 响应)。 // 桌面端需据此补 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 { let user_msg_ev = AiChatEvent::AiUserMessage {
message: message.clone(), message: message.clone(),
conversation_id: conversation_id.clone(), conversation_id: conversation_id.clone(),
}; };
let _ = app.emit("ai-chat-event", user_msg_ev.clone()); emit_ai_chat_event(app, user_msg_ev);
let _ = state.ai_event_bus.publish_event(user_msg_ev);
let result = ai_chat_force_send( let result = ai_chat_force_send(
app.clone(), app.clone(),
state.clone(), state.clone(),
+71 -6
View File
@@ -6,7 +6,8 @@
//! - `OnceLock<Vec>` → `RwLock<Option<Vec>>` + 快路径(读锁命中 clone)/ 慢路径(重扫) //! - `OnceLock<Vec>` → `RwLock<Option<Vec>>` + 快路径(读锁命中 clone)/ 慢路径(重扫)
//! + `invalidate_skills` 写锁置 None,支持进程内 `ai_reload_skills` 热重载 //! + `invalidate_skills` 写锁置 None,支持进程内 `ai_reload_skills` 热重载
//! - `scan_skills` 返 `ScanResult{skills, conflicts}`:同名收集所有 path 入 conflicts, //! - `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::collections::HashSet;
use std::fs; use std::fs;
@@ -15,6 +16,8 @@ use std::sync::{RwLock, RwLockReadGuard};
use serde::Serialize; use serde::Serialize;
use crate::commands::ai::tool_registry::{is_noise_entry, NoiseDirTier};
/// 技能元信息(前端 `/` 联想 + 后端注入用) /// 技能元信息(前端 `/` 联想 + 后端注入用)
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct SkillInfo { pub struct SkillInfo {
@@ -51,6 +54,58 @@ fn claude_home() -> Option<PathBuf> {
.map(|h| h.join(".claude")) .map(|h| h.join(".claude"))
} }
/// 项目级技能根目录(进程内,前端经 `ai_list_skills(project_path)` 设置)。
///
/// Some(root) 时 `scan_skills` 额外扫 `<root>/.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<Option<PathBuf>> = RwLock::new(None);
/// 更新项目级技能根目录。返回是否发生变更(调用方据 true 调 `invalidate_skills` 触发重扫)。
///
/// `None`/空串 = 不改动当前值(`ai_list_skills` 不传参沿用已设 root,remote_bridge 零参
/// 调用不受影响);重复传同值幂等(不触发重扫)。
pub(crate) fn set_project_skills_root(path: Option<String>) -> 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<PathBuf> {
// 锁中毒降级 None(仅丢项目级技能,home 级不受影响)。
RwLock::read(&PROJECT_SKILLS_ROOT)
.ok()
.and_then(|g| g.clone())
}
/// 扫描项目级技能目录 `<root>/.claude/skills/*/SKILL.md`(单层,与 home skills 同构)。
fn scan_project_skills(root: &Path, out: &mut Vec<SkillInfo>) {
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 块,返回正文。 /// 剥离 markdown 首个 `---...---` frontmatter 块,返回正文。
/// ///
/// 状态机:用行索引推进;首个 `---`(trim 后)进入 in_fm,遇到下个 `---` 退出,余为正文。 /// 状态机:用行索引推进;首个 `---`(trim 后)进入 in_fm,遇到下个 `---` 退出,余为正文。
@@ -159,9 +214,9 @@ fn collect_skill_files(dir: &Path, out: &mut Vec<PathBuf>) {
for entry in entries.flatten() { for entry in entries.flatten() {
let p = entry.path(); let p = entry.path();
if p.is_dir() { if p.is_dir() {
// 跳过依赖/版本目录,避免递归爆炸 // 跳过依赖/VCS 目录,避免递归爆炸(SkillScan 最小集档,名单统一走 is_noise_entry)
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(""); 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; continue;
} }
collect_skill_files(&p, out); collect_skill_files(&p, out);
@@ -172,7 +227,10 @@ fn collect_skill_files(dir: &Path, out: &mut Vec<PathBuf>) {
} }
} }
/// 扫描三类来源,按 name 去重(skills 优先 > commands > plugins)。 /// 扫描全部来源,按 name 去重(project 优先 > skills > commands > plugins)。
///
/// 项目级:`PROJECT_SKILLS_ROOT` 已设时额外扫 `<root>/.claude/skills/*/SKILL.md`
/// (source="project"),排在首位(项目级同名覆盖 home 级,对齐 Claude Code 项目技能语义)。
/// ///
/// 返 `ScanResult`:`skills` 为去重后首份(优先级保留),`conflicts` 收集所有同名 path /// 返 `ScanResult`:`skills` 为去重后首份(优先级保留),`conflicts` 收集所有同名 path
/// (仅当同名 > 1 时入列,前端提示用)。 /// (仅当同名 > 1 时入列,前端提示用)。
@@ -187,8 +245,15 @@ fn scan_skills() -> ScanResult {
} }
}; };
// 三类来源按优先级顺序收集(skills > commands > plugins) // 各来源按优先级顺序收集(project > skills > commands > plugins)
let mut all: Vec<Vec<SkillInfo>> = Vec::with_capacity(3); let mut all: Vec<Vec<SkillInfo>> = Vec::with_capacity(4);
// 0. <project>/.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 // 1. ~/.claude/skills/*/SKILL.md
let mut batch_skill = Vec::new(); let mut batch_skill = Vec::new();
+58 -9
View File
@@ -5,11 +5,12 @@ use std::sync::Arc;
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use std::time::Duration; use std::time::Duration;
use tauri::{AppHandle, Emitter, Manager}; use tauri::AppHandle;
use tracing::warn; use tracing::warn;
use df_ai::provider::{CompletionRequest, LlmProvider}; use df_ai::provider::{CompletionRequest, LlmProvider};
use super::event_bus::emit_ai_chat_event;
use super::{AiChatEvent, ToolCallDraft}; use super::{AiChatEvent, ToolCallDraft};
/// 从 anyhow 错误中尽力提取 HTTP 状态码/错误分类,供诊断拼接。 /// 从 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::<crate::state::AppState>().ai_event_bus.publish_event(ev);
}
/// flush 合批的 AiTextDelta(双写 emit + event_bus)。空 buffer 不 emit。 /// flush 合批的 AiTextDelta(双写 emit + event_bus)。空 buffer 不 emit。
fn flush_delta(app_handle: &AppHandle, conv_id: &str, pending_delta: &mut String) { fn flush_delta(app_handle: &AppHandle, conv_id: &str, pending_delta: &mut String) {
if pending_delta.is_empty() { if pending_delta.is_empty() {
@@ -587,7 +582,8 @@ pub(crate) async fn stream_llm(
/// - HTTP 429 / 5xx → retryable=true(复用 retry::is_status_retryable 逻辑) /// - HTTP 429 / 5xx → retryable=true(复用 retry::is_status_retryable 逻辑)
/// - HTTP 4xx(非429) → retryable=false Fatal(401/403/404/422 等) /// - HTTP 4xx(非429) → retryable=false Fatal(401/403/404/422 等)
/// - timeout / connect → retryable=true(瞬态,可重试) /// - timeout / connect → retryable=true(瞬态,可重试)
/// - unknown / 其他 → retryable=true(保守可重试,避免误判 Fatal 错杀) /// - unknown → 交 classify_status_with_body 合并 body 按传输关键词(reset/decode/eof 等)判定,
/// 无命中则 Fatal(对齐 retry::is_status_retryable 未知不可重试语义)
/// ///
/// 抽公共分类函数(决策 F-260616-07 a1 "复用 retry.rs 错误分类逻辑或镜像"), /// 抽公共分类函数(决策 F-260616-07 a1 "复用 retry.rs 错误分类逻辑或镜像"),
/// 避免 stream_recv 与 agentic 各自重写状态码解析。 /// 避免 stream_recv 与 agentic 各自重写状态码解析。
@@ -619,7 +615,17 @@ fn classify_status_or_class(status_or_class: &str) -> bool {
// BUG-2026-07-17 根治:SSE 解码/传输错误(error decoding response body / Transport error) // BUG-2026-07-17 根治:SSE 解码/传输错误(error decoding response body / Transport error)
// 是传输层错误,不是 Fatal。原实现未识别这些关键词,返 unknown→Fatal,致连续重试 4 次均 // 是传输层错误,不是 Fatal。原实现未识别这些关键词,返 unknown→Fatal,致连续重试 4 次均
// 立即放弃。现补全传输层关键词,让这类错误走可重试路径。 // 立即放弃。现补全传输层关键词,让这类错误走可重试路径。
let lower = status_or_class.to_lowercase(); transport_keyword_hit(&status_or_class.to_lowercase())
}
/// 传输层可重试关键词判定(小写文本)。
///
/// 词表与 df-ai crate::send_error_class 的类词契约对应(改任一侧需同步)。
/// 抽独立纯函数供 classify_status_or_class 与 classify_status_with_body 的 unknown 分支共用:
/// body 原文常含任意数字串(os error 10054/时间戳),过上方三位码扫描器会被误当 HTTP 码
/// (如 "100" → is_status_retryable(100)=false)——这正是历史 reset/decode 关键词分支
/// 永远够不着的第二层死因,unknown 类需绕过扫描器直接走词表。
fn transport_keyword_hit(lower: &str) -> bool {
lower.contains("timeout") lower.contains("timeout")
|| lower.contains("connect") || lower.contains("connect")
|| lower.contains("transport") || lower.contains("transport")
@@ -652,10 +658,19 @@ fn is_quota_exhaustion(combined: &str) -> bool {
/// 额度耗尽型 429 → Fatal(确定性,不重试);否则交 classify_status_or_class 正常判定。 /// 额度耗尽型 429 → Fatal(确定性,不重试);否则交 classify_status_or_class 正常判定。
/// 入参 body:错误体原文(provider bail 文本 / error 帧 message)。 /// 入参 body:错误体原文(provider bail 文本 / error 帧 message)。
///
/// 传输层关键词(reset/decode/eof/broken pipe 等)只出现在 body 原文,而 status_or_class
/// 经 extract_error_diag_from_str 只会是 HTTP 码/timeout/connect/unknown——只喂 status 时
/// 关键词分支永远够不着(dead code,历史修复未生效)。unknown(无码无类)时合并 body 直接走
/// 传输词表(绕过三位码扫描器,body 的 os error 码/时间戳会被误当 HTTP 码);HTTP 码/
/// timeout/connect 显式类保持只喂 status,body 不反转既有判定(401 带 reset 字样仍 Fatal)。
fn classify_status_with_body(status_or_class: &str, body: &str) -> bool { fn classify_status_with_body(status_or_class: &str, body: &str) -> bool {
if is_quota_exhaustion(&format!("{status_or_class} {body}")) { if is_quota_exhaustion(&format!("{status_or_class} {body}")) {
return false; return false;
} }
if status_or_class == "unknown" {
return transport_keyword_hit(&format!("{status_or_class} {body}").to_lowercase());
}
classify_status_or_class(status_or_class) classify_status_or_class(status_or_class)
} }
@@ -839,6 +854,40 @@ mod tests {
assert!(classify_status_with_body("HTTP 500", "Internal Server Error")); assert!(classify_status_with_body("HTTP 500", "Internal Server Error"));
} }
// ---- classify_status_with_body:unknown 类合并 body 喂入,激活传输关键词分支 ----
/// unknown 类 + body 含传输关键词 → 可重试(原只喂 status 恒 Fatal 的 dead code 根治:
/// reset/decode/eof/broken pipe 只出现在 body 原文,status_or_class 永远不带)。
#[test]
fn classify_unknown_with_transport_body_retryable() {
assert!(classify_status_with_body("unknown", "Connection reset by peer (os error 10054)"));
assert!(classify_status_with_body("unknown", "error decoding response body"));
assert!(classify_status_with_body("unknown", "stream ended unexpectedly: unexpected eof"));
assert!(classify_status_with_body("unknown", "failed to send request: broken pipe"));
}
/// unknown 类 + body 无传输关键词 → 仍 Fatal(不回退到无脑可重试空耗)。
#[test]
fn classify_unknown_without_transport_keywords_fatal() {
assert!(!classify_status_with_body("unknown", "服务开小差了"));
assert!(!classify_status_with_body("unknown", ""));
}
/// 显式 HTTP 4xx 即便 body 带传输关键词也保持 Fatal(body 不反转状态码判定)。
#[test]
fn classify_4xx_transport_body_still_fatal() {
assert!(!classify_status_with_body("HTTP 401", "Connection reset by peer"));
assert!(!classify_status_with_body("HTTP 403", "error decoding response body"));
assert!(!classify_status_with_body("HTTP 404", "broken pipe"));
}
/// timeout/connect 显式类不受 body 影响(timeout 串碰含 404 字样的 body 仍可重试)。
#[test]
fn classify_explicit_class_unaffected_by_body() {
assert!(classify_status_with_body("timeout", "HTTP 404 not found"));
assert!(classify_status_with_body("connect", "connection refused"));
}
/// 普通瞬时限流 429 的标准 type=rate_limit_error 不应误判额度耗尽(仍可重试,防误伤) /// 普通瞬时限流 429 的标准 type=rate_limit_error 不应误判额度耗尽(仍可重试,防误伤)
#[test] #[test]
fn standard_rate_limit_error_not_quota() { fn standard_rate_limit_error_not_quota() {
+387 -103
View File
@@ -516,6 +516,7 @@ pub fn build_ai_tool_registry(
data_dir: PathBuf, data_dir: PathBuf,
get_app_config_ctx: super::get_app_config::GetAppConfigCtx, get_app_config_ctx: super::get_app_config::GetAppConfigCtx,
local_proxy_ctx: super::tools::local_proxy::LocalProxyCtx, local_proxy_ctx: super::tools::local_proxy::LocalProxyCtx,
spawn_agent_ctx: super::tools::spawn_agent::SpawnAgentCtx,
) -> AiToolRegistry { ) -> AiToolRegistry {
let mut registry = AiToolRegistry::new(); let mut registry = AiToolRegistry::new();
register_data_tools(&mut registry, db); register_data_tools(&mut registry, db);
@@ -526,9 +527,37 @@ pub fn build_ai_tool_registry(
register_generate_image_tool(&mut registry, db, allowed_dirs); register_generate_image_tool(&mut registry, db, allowed_dirs);
register_get_app_config_tool(&mut registry, get_app_config_ctx); register_get_app_config_tool(&mut registry, get_app_config_ctx);
register_local_proxy_tools(&mut registry, local_proxy_ctx); register_local_proxy_tools(&mut registry, local_proxy_ctx);
register_read_skill_tool(&mut registry);
register_spawn_agent_tool(&mut registry, spawn_agent_ctx);
registry registry
} }
/// spawn_agent AI 工具注册(1 个:派发子代理,隔离上下文执行专项任务)。
///
/// 工具职责:LLM 调 spawn_agent(task, tools?, max_rounds?, model?) 派发子代理,
/// 子代理在纯内存子会话跑独立 agentic 循环(agentic/sub_agent.rs),报告经 tool_result
/// 返回值交付。风险 Medium(派发需用户审批,与 create_task 同档)。
///
/// ctx 绕 AppState 自引用(同 GetAppConfigCtx 先例):session/conv_states/llm_concurrency/
/// db/sub_agents 全 Arc 字段,由 state.rs init 在 build registry 前提前构建注入,
/// AppState struct 字段复用同一 Arc。工具注册表自身经进程级 OnceLock
/// (init_global_tool_registry)在 state.rs init 构建完成后回注,供白名单钳制查询。
fn register_spawn_agent_tool(
registry: &mut AiToolRegistry,
ctx: super::tools::spawn_agent::SpawnAgentCtx,
) {
super::tools::spawn_agent::register(registry, ctx);
}
/// 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)。 /// 网络层 AI 工具注册(1 个:http_request)— 结构化 HTTP(GET/POST/PUT/PATCH/DELETE)。
/// 不持 db,纯 reqwest 调用。SSRF 防护 + 重定向每跳校验见 commands/ai/http.rs。 /// 不持 db,纯 reqwest 调用。SSRF 防护 + 重定向每跳校验见 commands/ai/http.rs。
/// 风险:GET=Medium(只读但触发外发)/ 写方法=High(须人工批准,对齐 audit.rs risk 分流)。 /// 风险:GET=Medium(只读但触发外发)/ 写方法=High(须人工批准,对齐 audit.rs risk 分流)。
@@ -869,7 +898,7 @@ pub(crate) fn list_dir_recursive<'a>(
} }
let name = entry.file_name().to_string_lossy().to_string(); let name = entry.file_name().to_string_lossy().to_string();
// 噪音目录(.git/node_modules/target 等)既不深入、也不作 entry 返回(避免污染 AI 工具上下文) // 噪音目录(.git/node_modules/target 等)既不深入、也不作 entry 返回(避免污染 AI 工具上下文)
if skip_noise && is_noise_dir(&name) { if skip_noise && is_noise_entry(&name, NoiseDirTier::AiContext) {
continue; continue;
} }
// 临时文件(.bak/.tmp-write 等)也不返回:write_file 原子写过程的副产物, // 临时文件(.bak/.tmp-write 等)也不返回:write_file 原子写过程的副产物,
@@ -889,7 +918,7 @@ pub(crate) fn list_dir_recursive<'a>(
"depth": depth, "depth": depth,
})); }));
// 仅递归真实目录(symlink 不递归,防符号链接逃逸到 workspace 外) // 仅递归真实目录(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 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?; let truncated = list_dir_recursive(&child_path, true, depth + 1, max_depth, max_entries, skip_noise, result).await?;
if truncated { if truncated {
@@ -906,72 +935,86 @@ pub(crate) fn list_dir_recursive<'a>(
/// 返回 Option<(&str, &[&str])>:(动作前缀模板, 关键参数名列表)。 /// 返回 Option<(&str, &[&str])>:(动作前缀模板, 关键参数名列表)。
/// 模板中 `{}` 占位符由 build_approval_reason 按参数列表从 args 取值填充。 /// 模板中 `{}` 占位符由 build_approval_reason 按参数列表从 args 取值填充。
/// 未注册的工具返回 None,由调用方 fallback 到通用风险提示。 /// 未注册的工具返回 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])> { pub(crate) fn display_hint_for_tool(name: &str) -> Option<(&'static str, &'static [&'static str])> {
let (template, keys): (&str, &[&str]) = match name { ensure_tool_meta_seeded();
"delete_project" => ("删除项目{}", &["id"]), df_ai::ai_tools::approval_hint(name)
"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))
} }
/// 工具名 → 中文动作前缀(轻量映射,供 build_approval_reason 快速取可读标签)。 /// 工具名 → 中文动作前缀(轻量映射,供 build_approval_reason 快速取可读标签)。
/// ///
/// 仅返回动作动词+对象名(如 "创建任务"、"写入文件"),不含参数细节。 /// 仅返回动作动词+对象名(如 "创建任务"、"写入文件"),不含参数细节。
/// 未注册的工具返回 None,由调用方 fallback 到 display_hint_for_tool 模板填充或硬编码。 /// 未注册的工具返回 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> { pub(crate) fn tool_display_hint(name: &str) -> Option<&'static str> {
match name { ensure_tool_meta_seeded();
"create_task" => Some("创建任务"), df_ai::ai_tools::display_verb(name)
"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,
}
} }
/// 判断是否为不应深入递归的噪音目录(构建产物/依赖/缓存等) /// 元数据种子(仅测试构建):元数据查询依赖注册期写入的全局表。
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(),
make_test_spawn_agent_ctx(&db),
);
});
});
}
/// 生产恒空(注册先于查询,无种子需求)。
#[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", ".git", "node_modules", "target", "dist", "build",
".next", ".cache", "__pycache__", ".venv", "venv", ".idea", ".next", ".cache", "__pycache__", ".venv", "venv", ".idea",
".trash", // delete_file 软删除回收站,不污染 list_directory ".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 的噪音文件(临时/备份/编辑器产物)。 /// 判断是否为不应返回给 AI 的噪音文件(临时/备份/编辑器产物)。
@@ -1062,7 +1105,7 @@ pub(crate) fn compile_glob_to_regex(glob: &str) -> anyhow::Result<regex::Regex>
/// 递归跨文件内容搜索(grep -rn 模式) /// 递归跨文件内容搜索(grep -rn 模式)
/// ///
/// 遍历 `dir`(深度 `depth`,上限 `max_depth`)下所有文件: /// 遍历 `dir`(深度 `depth`,上限 `max_depth`)下所有文件:
/// - 跳过噪音目录(is_noise_dir)/噪音文件(is_noise_file)/symlink(file_type 不跟随) /// - 跳过噪音目录(is_noise_entry)/噪音文件(is_noise_file)/symlink(file_type 不跟随)
/// - glob 过滤器(可选):命中文件名才读内容 /// - glob 过滤器(可选):命中文件名才读内容
/// - 二进制文件跳过:前 8KB 含 \0 视为二进制(对齐 read_file/list_directory) /// - 二进制文件跳过:前 8KB 含 \0 视为二进制(对齐 read_file/list_directory)
/// - 单文件 1MB 上限跳过(对齐 read_file,防读超大文件撑爆) /// - 单文件 1MB 上限跳过(对齐 read_file,防读超大文件撑爆)
@@ -1110,7 +1153,7 @@ pub(crate) fn grep_recursive<'a>(
} }
if file_type.is_dir() { if file_type.is_dir() {
// 噪音目录(.git/node_modules/target/.trash 等)不深入 // 噪音目录(.git/node_modules/target/.trash 等)不深入
if is_noise_dir(&file_name) { if is_noise_entry(&file_name, NoiseDirTier::AiContext) {
continue; continue;
} }
if depth < max_depth { if depth < max_depth {
@@ -1281,20 +1324,20 @@ 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)] #[cfg(test)]
mod tests { use std::sync::atomic::{AtomicU64, AtomicUsize};
use super::*; #[cfg(test)]
use std::fs; use crate::state::LlmConcurrency;
// 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)。 /// 测试用 GetAppConfigCtx 构造器(默认值对齐 AppState::init:iter=10/retries=3/timeout=15/concurrency 3,2)。
/// ///
/// build_ai_tool_registry 第 4 参,5 处测试调用共用,避免每处内联 5 行重复。 /// build_ai_tool_registry 第 4 参,5 处测试调用共用,避免每处内联 5 行重复。
/// Arc 句柄独立于 AppState(测试无 AppState),仅满足注册期类型签名,handler 不执行。 /// Arc 句柄独立于 AppState(测试无 AppState),仅满足注册期类型签名,handler 不执行。
fn make_test_get_app_config_ctx(db: &Arc<Database>) -> crate::commands::ai::get_app_config::GetAppConfigCtx { #[cfg(test)]
fn make_test_get_app_config_ctx(db: &Arc<Database>) -> crate::commands::ai::get_app_config::GetAppConfigCtx {
crate::commands::ai::get_app_config::GetAppConfigCtx { crate::commands::ai::get_app_config::GetAppConfigCtx {
db: db.clone(), db: db.clone(),
agent_max_iterations: Arc::new(AtomicUsize::new( agent_max_iterations: Arc::new(AtomicUsize::new(
@@ -1306,15 +1349,36 @@ mod tests {
approval_timeout_minutes: Arc::new(AtomicU64::new(15)), approval_timeout_minutes: Arc::new(AtomicU64::new(15)),
llm_concurrency: LlmConcurrency::new(3, 2), llm_concurrency: LlmConcurrency::new(3, 2),
} }
} }
/// build_ai_tool_registry 第 5 参(local_proxy 工具代理 ctx),测试调用共用。 /// build_ai_tool_registry 第 5 参(local_proxy 工具代理 ctx),测试调用共用。
/// gateway_url 用默认本地网关即可,handler 不实际执行无需真实代理。 /// gateway_url 用默认本地网关即可,handler 不实际执行无需真实代理。
fn make_test_local_proxy_ctx() -> crate::commands::ai::tools::local_proxy::LocalProxyCtx { #[cfg(test)]
fn make_test_local_proxy_ctx() -> crate::commands::ai::tools::local_proxy::LocalProxyCtx {
crate::commands::ai::tools::local_proxy::LocalProxyCtx { crate::commands::ai::tools::local_proxy::LocalProxyCtx {
gateway_url: "http://127.0.0.1:8899".to_string(), gateway_url: "http://127.0.0.1:8899".to_string(),
} }
}
/// build_ai_tool_registry 第 6 参(spawn_agent 派发 ctx),测试调用共用。
/// Arc 句柄独立于 AppState(测试无 AppState),仅满足注册期类型签名,handler 不执行。
#[cfg(test)]
fn make_test_spawn_agent_ctx(db: &Arc<Database>) -> crate::commands::ai::tools::spawn_agent::SpawnAgentCtx {
crate::commands::ai::tools::spawn_agent::SpawnAgentCtx {
session: Arc::new(tokio::sync::Mutex::new(crate::commands::ai::AiSession::new())),
conv_states: Arc::new(
crate::commands::ai::agentic::conv_state::ConvStateStore::new(),
),
llm_concurrency: LlmConcurrency::new(3, 2),
db: db.clone(),
sub_agents: crate::commands::ai::agentic::sub_agent::new_registry(),
} }
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
// ============================================================ // ============================================================
// 工具注册基线测试(SMELL-P0-2 拆分防护) // 工具注册基线测试(SMELL-P0-2 拆分防护)
@@ -1324,7 +1388,7 @@ mod tests {
// 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。 // 任一层漏移 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 应注册恰好 78 个工具(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 + 1 spawn_agent),且工具名集合稳定。
/// ///
/// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db, /// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db,
// 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。 // 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。
@@ -1335,7 +1399,7 @@ mod tests {
// F-260619-03 Phase A: build_ai_tool_registry 新增 allowed_dirs 形参, // F-260619-03 Phase A: build_ai_tool_registry 新增 allowed_dirs 形参,
// 测试用 default_with_root(仅 workspace_root),零回归(白名单含 workspace_root)。 // 测试用 default_with_root(仅 workspace_root),零回归(白名单含 workspace_root)。
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root())); 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()); let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx(), make_test_spawn_agent_ctx(&db));
// 总量基线:41(27 data + 13 file + 1 http)。拆分前后必须一致。 // 总量基线:41(27 data + 13 file + 1 http)。拆分前后必须一致。
// file 层 10→11(新增 grep 跨文件内容搜索工具)。 // file 层 10→11(新增 grep 跨文件内容搜索工具)。
@@ -1395,10 +1459,18 @@ mod tests {
// knowledge_promote(2026-10-01): data 层 44→45(新增候选知识显式转正工具, // knowledge_promote(2026-10-01): data 层 44→45(新增候选知识显式转正工具,
// candidate→published 单向,补齐自动晋升默认关后的对话内审核通道,Medium)。 // 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。 // 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。
// spawn_agent(2026-10-01): spawn_agent 层 0→1(派发子代理隔离上下文执行专项任务,
// Medium 审批档;独立 agentic 循环在 agentic/sub_agent.rs,报告经返回值交付。
// SpawnAgentCtx 绕 AppState 自引用同 GetAppConfigCtx 先例,注册表自引用走进程级 OnceLock)。
// 78 = 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 + 1 spawn_agent。
assert_eq!( assert_eq!(
registry.len(), registry.len(),
76, 78,
"工具总数应为 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() "工具总数应为 78(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 + 1 spawn_agent),实际 {}", registry.len()
); );
// 工具名集合基线:防 rename / 漏注册 / 误删除。 // 工具名集合基线:防 rename / 漏注册 / 误删除。
@@ -1463,6 +1535,12 @@ mod tests {
"ssh_exec", "mongo_find", "mongo_insert", "ssh_exec", "mongo_find", "mongo_insert",
"mongo_update", "mongo_delete", "redis_get", "mongo_update", "mongo_delete", "redis_get",
"redis_set", "redis_del", "redis_set", "redis_del",
// ── read_skill 层 (1) ──(按名读本机 Claude 技能规程正文,只读 Low,
// 与 / 技能注入链共用 skills.rs 进程内缓存,声明式注册 tools/read_skill.rs)
"read_skill",
// ── spawn_agent 层 (1) ──(派发子代理隔离上下文执行专项任务,Medium 审批档,
// 独立 agentic 循环 agentic/sub_agent.rs,声明式注册 tools/spawn_agent.rs)
"spawn_agent",
]; ];
expected.sort_unstable(); expected.sort_unstable();
@@ -1476,39 +1554,57 @@ mod tests {
); );
} }
/// is_noise_dir 纯函数:覆盖 .git/.gitignore 区分(目录是噪音,.gitignore 文件名不是) /// is_noise_entry 纯函数:覆盖 .git/.gitignore 区分(目录是噪音,.gitignore 文件名不是)
#[test] #[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_entry(".git", NoiseDirTier::AiContext));
assert!(is_noise_dir("node_modules")); assert!(is_noise_entry("node_modules", NoiseDirTier::AiContext));
assert!(is_noise_dir("target")); assert!(is_noise_entry("target", NoiseDirTier::AiContext));
assert!(is_noise_dir("dist")); assert!(is_noise_entry("dist", NoiseDirTier::AiContext));
assert!(is_noise_dir("build")); assert!(is_noise_entry("build", NoiseDirTier::AiContext));
// .gitignore / .gitattributes 等文件名不应命中(只匹配纯目录名 .git) // .gitignore / .gitattributes 等文件名不应命中(只匹配纯目录名 .git)
assert!(!is_noise_dir(".gitignore")); assert!(!is_noise_entry(".gitignore", NoiseDirTier::AiContext));
assert!(!is_noise_dir(".gitattributes")); assert!(!is_noise_entry(".gitattributes", NoiseDirTier::AiContext));
assert!(!is_noise_dir(".gitkeep")); assert!(!is_noise_entry(".gitkeep", NoiseDirTier::AiContext));
} }
/// is_noise_dir 纯函数:普通目录不命中 /// is_noise_entry 纯函数:普通目录不命中
#[test] #[test]
fn test_is_noise_dir_normal_dirs_not_matched() { fn test_is_noise_entry_normal_dirs_not_matched() {
assert!(!is_noise_dir("src")); assert!(!is_noise_entry("src", NoiseDirTier::AiContext));
assert!(!is_noise_dir("tests")); assert!(!is_noise_entry("tests", NoiseDirTier::AiContext));
assert!(!is_noise_dir("docs")); assert!(!is_noise_entry("docs", NoiseDirTier::AiContext));
assert!(!is_noise_dir("main.rs")); assert!(!is_noise_entry("main.rs", NoiseDirTier::AiContext));
assert!(!is_noise_dir("")); assert!(!is_noise_entry("", NoiseDirTier::AiContext));
} }
/// is_noise_dir 纯函数:大小写敏感(不靠 to_lowercase 误命中 Target) /// is_noise_entry 纯函数:大小写敏感(不靠 to_lowercase 误命中 Target)
#[test] #[test]
fn test_is_noise_dir_case_sensitive() { fn test_is_noise_entry_case_sensitive() {
// 文件系统在 Windows 上大小写不敏感,但函数本身用精确匹配; // 文件系统在 Windows 上大小写不敏感,但函数本身用精确匹配;
// 锁定当前语义:大写变体不命中(避免后续误改 to_lowercase 引入行为变化) // 锁定当前语义:大写变体不命中(避免后续误改 to_lowercase 引入行为变化)
assert!(!is_noise_dir("Target")); assert!(!is_noise_entry("Target", NoiseDirTier::AiContext));
assert!(!is_noise_dir("DIST")); assert!(!is_noise_entry("DIST", NoiseDirTier::AiContext));
assert!(!is_noise_dir("NODE_MODULES")); 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 副产物 / 编辑器临时文件命中 /// is_noise_file 纯函数:write_file 副产物 / 编辑器临时文件命中
@@ -1664,7 +1760,7 @@ mod tests {
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败"); let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
let db = Arc::new(db); let db = Arc::new(db);
let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx()); let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx(), make_test_spawn_agent_ctx(&db));
let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string(); let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
let args = serde_json::json!({ "path": canon_file, "limit": 15 }); let args = serde_json::json!({ "path": canon_file, "limit": 15 });
let res = registry.execute("read_file", args).await.expect("read_file 执行失败"); let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
@@ -1691,7 +1787,7 @@ mod tests {
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败"); let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
let db = Arc::new(db); let db = Arc::new(db);
let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx()); let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx(), make_test_spawn_agent_ctx(&db));
let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string(); let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
let args = serde_json::json!({ "path": canon_file }); let args = serde_json::json!({ "path": canon_file });
let res = registry.execute("read_file", args).await.expect("read_file 执行失败"); let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
@@ -1770,7 +1866,7 @@ mod tests {
persistent.insert(tmp.clone()); persistent.insert(tmp.clone());
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default(), once: Default::default() })); let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default(), once: Default::default() }));
let db = Arc::new(Database::open_in_memory().await.expect("in-memory db 初始化失败")); let db = Arc::new(Database::open_in_memory().await.expect("in-memory db 初始化失败"));
let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx()); let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx(), make_test_spawn_agent_ctx(&db));
let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string(); let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
let args = serde_json::json!({ "path": canon_file }); let args = serde_json::json!({ "path": canon_file });
@@ -1799,7 +1895,7 @@ mod tests {
persistent.insert(tmp.clone()); persistent.insert(tmp.clone());
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default(), once: Default::default() })); let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default(), once: Default::default() }));
let db = Arc::new(Database::open_in_memory().await.expect("in-memory db 初始化失败")); let db = Arc::new(Database::open_in_memory().await.expect("in-memory db 初始化失败"));
let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx()); let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx(), make_test_spawn_agent_ctx(&db));
let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string(); let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
let args = serde_json::json!({ "path": canon_file }); let args = serde_json::json!({ "path": canon_file });
@@ -2281,7 +2377,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root())); 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()); let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx(), make_test_spawn_agent_ctx(&db));
(db, registry) (db, registry)
} }
@@ -2381,4 +2477,192 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(staging["total"], 0); 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(),
make_test_spawn_agent_ctx(&db),
);
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<serde_json::Value>| -> Option<String> {
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);
}
} }
+5 -1
View File
@@ -10,7 +10,7 @@
use std::sync::Arc; 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 df_ai::declare_tool;
/// 注册 fetch_url 工具到 `$registry`(无 db 捕获,纯网络 GET + htmd 转换)。 /// 注册 fetch_url 工具到 `$registry`(无 db 捕获,纯网络 GET + htmd 转换)。
@@ -42,6 +42,10 @@ pub fn register(registry: &mut AiToolRegistry) {
"fetch_url", "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", "获取网页 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, RiskLevel::Low,
meta: ToolMeta {
sig: Some(SigMeta { groups: &[(&["url"], SigValKind::Str)] }),
..Default::default()
},
schema: schema, schema: schema,
args => { args => {
// 转调 fetch_url.rs handler(SSRF 防护 + htmd 转换 + 去噪音 + 截断全在那) // 转调 fetch_url.rs handler(SSRF 防护 + htmd 转换 + 去噪音 + 截断全在那)
+85 -2
View File
@@ -14,7 +14,7 @@
//! validate_path / generate_diff / apply_line_range / resolve_anchor_to_lines / //! validate_path / generate_diff / apply_line_range / resolve_anchor_to_lines /
//! resolve_workspace_path_with_allowed / rename_or_cross_volume_copy / compute_file_hash / //! resolve_workspace_path_with_allowed / rename_or_cross_volume_copy / compute_file_hash /
//! FILE_LOCKS / probe_executable / list_dir_recursive / compile_glob_to_regex / //! 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。 //! FileGrepHit / LineMatch。
//! //!
//! 单捕获宏适配(declare_tool! 宏仅支持单 capture 变量): //! 单捕获宏适配(declare_tool! 宏仅支持单 capture 变量):
@@ -30,7 +30,7 @@
use std::sync::Arc; 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 df_ai::declare_tool;
use tokio::sync::RwLock; use tokio::sync::RwLock;
@@ -73,6 +73,14 @@ pub fn register(
"read_file", "read_file",
"读取文件内容,返回文本内容。支持 offset/limit 分页;传入 search 则在文件内容中搜索匹配行(大小写敏感,字符串包含匹配),返回 matches 数组(限50条)。仅需函数/类型符号的签名或定义体时优先用 read_symbol(骨架/全文,避免 prompt 爆)", "读取文件内容,返回文本内容。支持 offset/limit 分页;传入 search 则在文件内容中搜索匹配行(大小写敏感,字符串包含匹配),返回 matches 数组(限50条)。仅需函数/类型符号的签名或定义体时优先用 read_symbol(骨架/全文,避免 prompt 爆)",
RiskLevel::Low, 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)]), schema: object_schema(vec![("path", "string", true), ("offset", "integer", false), ("limit", "integer", false), ("search", "string", false)]),
args => { args => {
let snap = allowed_dirs.read().await.clone(); let snap = allowed_dirs.read().await.clone();
@@ -208,6 +216,10 @@ pub fn register(
"read_symbol", "read_symbol",
"AST 符号解析(信息密度驱动,治 read_file 全文回灌 prompt 爆)。提取函数/结构体/类等符号:默认返回骨架(签名+行范围+内部调用,极小高密度);传 full=true 取完整定义体。不支持/未找到时回退提示用 grep+read_file。Phase1 支持 .rs/.ts/.tsx/.js/.jsx/.vue。", "AST 符号解析(信息密度驱动,治 read_file 全文回灌 prompt 爆)。提取函数/结构体/类等符号:默认返回骨架(签名+行范围+内部调用,极小高密度);传 full=true 取完整定义体。不支持/未找到时回退提示用 grep+read_file。Phase1 支持 .rs/.ts/.tsx/.js/.jsx/.vue。",
RiskLevel::Low, 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)]), schema: object_schema(vec![("path", "string", true), ("symbol", "string", true), ("full", "boolean", false), ("kind", "string", false), ("drill", "string", false)]),
args => { args => {
let snap = allowed_dirs.read().await.clone(); let snap = allowed_dirs.read().await.clone();
@@ -308,6 +320,15 @@ pub fn register(
"list_directory", "list_directory",
"列出目录内容,返回文件和子目录列表(名称、类型、大小)。path 可选,不传时返回引导提示", "列出目录内容,返回文件和子目录列表(名称、类型、大小)。path 可选,不传时返回引导提示",
RiskLevel::Low, 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)]), schema: object_schema(vec![("path", "string", false), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false), ("max_depth", "integer", false)]),
args => { args => {
let p = args["path"].as_str().unwrap_or(""); let p = args["path"].as_str().unwrap_or("");
@@ -335,6 +356,13 @@ pub fn register(
"write_file", "write_file",
"写入或创建文件,自动创建不存在的父目录。支持 encoding=\"base64\" 写入二进制/非 UTF-8 内容(图片/PDF/Excel 等),content 传 base64 编码字符串;默认 encoding=\"utf-8\" 传纯文本", "写入或创建文件,自动创建不存在的父目录。支持 encoding=\"base64\" 写入二进制/非 UTF-8 内容(图片/PDF/Excel 等),content 传 base64 编码字符串;默认 encoding=\"utf-8\" 传纯文本",
RiskLevel::Medium, 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![ schema: object_schema(vec![
("path", "string", true), ("path", "string", true),
("content", "string", true), ("content", "string", true),
@@ -453,6 +481,14 @@ pub fn register(
"patch_file", "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 表可追溯。", "局部更新文件内容(三模式互斥)。模式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, 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, schema: patch_file_schema,
args => { args => {
let snap = allowed_dirs.read().await.clone(); let snap = allowed_dirs.read().await.clone();
@@ -637,6 +673,11 @@ pub fn register(
"file_info", "file_info",
"获取文件或目录的元信息(是否存在、大小、行数、修改时间、是否二进制、是否目录),不读取文件内容", "获取文件或目录的元信息(是否存在、大小、行数、修改时间、是否二进制、是否目录),不读取文件内容",
RiskLevel::Low, 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)]), schema: object_schema(vec![("path", "string", true)]),
args => { args => {
let snap = allowed_dirs.read().await.clone(); let snap = allowed_dirs.read().await.clone();
@@ -691,6 +732,12 @@ pub fn register(
"append_file", "append_file",
"向文件末尾追加内容,文件不存在则自动创建。返回写入字数和新文件大小", "向文件末尾追加内容,文件不存在则自动创建。返回写入字数和新文件大小",
RiskLevel::Medium, 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)]), schema: object_schema(vec![("path", "string", true), ("content", "string", true)]),
args => { args => {
let snap = allowed_dirs.read().await.clone(); let snap = allowed_dirs.read().await.clone();
@@ -730,6 +777,13 @@ pub fn register(
"delete_file", "delete_file",
"删除文件。默认(permanent=false)软删除:移到工作区 .trash/ 目录保留可恢复;permanent=true 硬删除不可恢复。删除前备份,回收站机制对齐 list_trash。高风险,须人工批准", "删除文件。默认(permanent=false)软删除:移到工作区 .trash/ 目录保留可恢复;permanent=true 硬删除不可恢复。删除前备份,回收站机制对齐 list_trash。高风险,须人工批准",
RiskLevel::High, 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![ schema: object_schema(vec![
("path", "string", true), ("path", "string", true),
("permanent", "boolean", false), ("permanent", "boolean", false),
@@ -810,6 +864,13 @@ pub fn register(
"rename_file", "rename_file",
"重命名或移动文件(同一工具覆盖 rename 和 move)。参数:from(源路径)+ to(目标路径)+ overwrite(默认 false,目标存在则拒绝)。同卷走 tokio::fs::rename(原子),跨卷自动降级 copy+remove(非原子,失败回滚删 to 保 from 完整)。须人工批准", "重命名或移动文件(同一工具覆盖 rename 和 move)。参数:from(源路径)+ to(目标路径)+ overwrite(默认 false,目标存在则拒绝)。同卷走 tokio::fs::rename(原子),跨卷自动降级 copy+remove(非原子,失败回滚删 to 保 from 完整)。须人工批准",
RiskLevel::Medium, 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![ schema: object_schema(vec![
("from", "string", true), ("from", "string", true),
("to", "string", true), ("to", "string", true),
@@ -902,6 +963,14 @@ pub fn register(
"grep", "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)", "跨文件内容搜索(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, 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, schema: grep_schema,
args => { args => {
let p = args["path"].as_str().unwrap_or(""); let p = args["path"].as_str().unwrap_or("");
@@ -1078,6 +1147,15 @@ pub fn register(
"search_files", "search_files",
"在指定目录下搜索匹配模式(字符串包含匹配)的文件名,支持 offset/limit 分页。返回 results、total、has_more。默认 limit=50", "在指定目录下搜索匹配模式(字符串包含匹配)的文件名,支持 offset/limit 分页。返回 results、total、has_more。默认 limit=50",
RiskLevel::Low, 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)]), schema: object_schema(vec![("path", "string", false), ("pattern", "string", true), ("recursive", "boolean", false), ("offset", "integer", false), ("limit", "integer", false)]),
args => { args => {
let p = args["path"].as_str().unwrap_or(""); let p = args["path"].as_str().unwrap_or("");
@@ -1122,6 +1200,11 @@ pub fn register(
"run_command", "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 判失败。", "在指定工作目录执行 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, 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![ schema: object_schema(vec![
("command", "string", true), ("command", "string", true),
("working_dir", "string", false), ("working_dir", "string", false),
+7 -1
View File
@@ -23,7 +23,7 @@
use std::sync::Arc; 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 df_ai::declare_tool;
use tokio::sync::RwLock; use tokio::sync::RwLock;
@@ -65,6 +65,12 @@ pub fn register(registry: &mut AiToolRegistry, allowed_dirs: &Arc<RwLock<Allowed
"http_request", "http_request",
"发起结构化 HTTP 请求(GET/POST/PUT/PATCH/DELETE),用于查询外部 API。参数:method(默认 GET)、url(http/https)、headers(对象 map)、body(请求体字符串)、timeout_secs(默认 30,上限 60)、parse(json/text/auto,默认 auto)、output_file(响应落盘路径,适合大响应,全量写不截断)、max_response_chars(body 截断阈值字节,默认 50KB,clamp [1KB,10MB])。安全:仅 http/https 协议,拒绝私网/保留 IP(SSRF 防护含 DNS resolve 后校验防重绑定),重定向≤3 跳且每跳重校验。响应 body 默认截断 50KB(可 max_response_chars 覆盖或 output_file 落盘)。GET 为只读但触发外发网络,POST/PUT/PATCH/DELETE 有副作用,统一按高风险须人工批准", "发起结构化 HTTP 请求(GET/POST/PUT/PATCH/DELETE),用于查询外部 API。参数:method(默认 GET)、url(http/https)、headers(对象 map)、body(请求体字符串)、timeout_secs(默认 30,上限 60)、parse(json/text/auto,默认 auto)、output_file(响应落盘路径,适合大响应,全量写不截断)、max_response_chars(body 截断阈值字节,默认 50KB,clamp [1KB,10MB])。安全:仅 http/https 协议,拒绝私网/保留 IP(SSRF 防护含 DNS resolve 后校验防重绑定),重定向≤3 跳且每跳重校验。响应 body 默认截断 50KB(可 max_response_chars 覆盖或 output_file 落盘)。GET 为只读但触发外发网络,POST/PUT/PATCH/DELETE 有副作用,统一按高风险须人工批准",
RiskLevel::High, RiskLevel::High,
meta: ToolMeta {
approval_hint: Some(("HTTP 请求:{} {}", &["method", "url"])),
display_verb: Some("发起 HTTP 请求"),
sig: Some(SigMeta { groups: &[(&["url"], SigValKind::Str), (&["method"], SigValKind::Str)] }),
..Default::default()
},
schema: schema, schema: schema,
args => { args => {
// allowed_dirs read lock clone(output_file 路径校验用;无 output_file 时校验不触发) // allowed_dirs read lock clone(output_file 路径校验用;无 output_file 时校验不触发)
+18 -1
View File
@@ -18,7 +18,7 @@
use std::sync::Arc; 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_ai::declare_tool;
use df_storage::db::Database; use df_storage::db::Database;
use df_storage::models::IdeaRecord; use df_storage::models::IdeaRecord;
@@ -61,6 +61,13 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
"create_idea", "create_idea",
"捕获一个新灵感", "捕获一个新灵感",
RiskLevel::Medium, 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![ schema: object_schema(vec![
("title", "string", true), ("description", "string", false), ("title", "string", true), ("description", "string", false),
("tags", "string", false), ("source", "string", false), ("tags", "string", false), ("source", "string", false),
@@ -99,6 +106,11 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
"update_idea", "update_idea",
"更新灵感的指定字段(title/description/priority/tags/source/score 等),需要提供灵感 ID、字段名和新值。", "更新灵感的指定字段(title/description/priority/tags/source/score 等),需要提供灵感 ID、字段名和新值。",
RiskLevel::Medium, 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)]), schema: object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]),
args => { args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
@@ -124,6 +136,11 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
"delete_idea", "delete_idea",
"删除灵感(软删除,移入回收站,可恢复)", "删除灵感(软删除,移入回收站,可恢复)",
RiskLevel::Medium, RiskLevel::Medium,
meta: ToolMeta {
entity_action: Some(("idea", "delete")),
display_verb: Some("删除灵感"),
..Default::default()
},
schema: object_schema(vec![("id", "string", true)]), schema: object_schema(vec![("id", "string", true)]),
args => { args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
+4
View File
@@ -26,3 +26,7 @@ pub mod trash;
pub mod file; pub mod file;
pub mod download_file; pub mod download_file;
pub mod entity_resolve; pub mod entity_resolve;
pub mod read_skill;
// spawn_agent 子代理派发(独立 agentic 循环入口,audit 与 ai_approve 经本模块
// scope_caller_conv 注入父会话 task-local,故需 pub 可达)。
pub mod spawn_agent;
+38 -1
View File
@@ -17,7 +17,7 @@
use std::sync::Arc; 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_ai::declare_tool;
use df_storage::db::Database; use df_storage::db::Database;
use df_storage::models::ProjectRecord; use df_storage::models::ProjectRecord;
@@ -42,6 +42,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
"update_project", "update_project",
"更新项目的指定字段(name/status/description/path/stack),需要提供项目 ID、字段名和新值。绑定代码目录推荐改用 bind_directory", "更新项目的指定字段(name/status/description/path/stack),需要提供项目 ID、字段名和新值。绑定代码目录推荐改用 bind_directory",
RiskLevel::Medium, 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)]), schema: object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]),
args => { args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
@@ -69,6 +75,13 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
"create_project", "create_project",
"创建新项目,可选传 path/stack 一步完成创建+绑定目录(无需再调 bind_directory)", "创建新项目,可选传 path/stack 一步完成创建+绑定目录(无需再调 bind_directory)",
RiskLevel::Medium, 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![ schema: object_schema(vec![
("name", "string", true), ("description", "string", false), ("name", "string", true), ("description", "string", false),
("path", "string", false), ("stack", "string", false), ("path", "string", false), ("stack", "string", false),
@@ -104,6 +117,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
"bind_directory", "bind_directory",
"为项目绑定代码目录(自动探测技术栈,防重复绑定)。path 应为**项目根路径**(单仓库=仓库根;多工程 monorepo=工作区根,非单个子工程路径;子工程信息走 list_project_modules/update_project_module 维护)。多次调用会覆盖 path 字段,monorepo 场景勿为每个子工程重复绑定。可选 stack 手动指定技术栈(如 'Go / Gin / Vue3'),不传则自动探测。", "为项目绑定代码目录(自动探测技术栈,防重复绑定)。path 应为**项目根路径**(单仓库=仓库根;多工程 monorepo=工作区根,非单个子工程路径;子工程信息走 list_project_modules/update_project_module 维护)。多次调用会覆盖 path 字段,monorepo 场景勿为每个子工程重复绑定。可选 stack 手动指定技术栈(如 'Go / Gin / Vue3'),不传则自动探测。",
RiskLevel::Medium, 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)]), schema: object_schema(vec![("id", "string", true), ("path", "string", true), ("stack", "string", false)]),
args => { args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id 参数"))?; let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id 参数"))?;
@@ -126,6 +145,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
"delete_project", "delete_project",
"删除项目(移入回收站,可恢复。永久删除用 purge_project)", "删除项目(移入回收站,可恢复。永久删除用 purge_project)",
RiskLevel::High, 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)]), schema: object_schema(vec![("id", "string", true)]),
args => { args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
@@ -142,6 +167,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
"restore_project", "restore_project",
"从回收站恢复已删除项目", "从回收站恢复已删除项目",
RiskLevel::High, 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)]), schema: object_schema(vec![("id", "string", true)]),
args => { args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
@@ -158,6 +189,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
"purge_project", "purge_project",
"永久删除项目及关联数据(不可恢复)", "永久删除项目及关联数据(不可恢复)",
RiskLevel::High, 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)]), schema: object_schema(vec![("id", "string", true)]),
args => { args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
@@ -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 }))
}
);
}
@@ -0,0 +1,348 @@
//! spawn_agent AI 工具声明式注册(派发子代理,隔离上下文执行专项任务,报告经返回值交付)。
//!
//! 架构:子代理 = 纯内存子会话 + 独立 agentic 循环(agentic/sub_agent.rs),本工具是唯一入口:
//! 1. 解析参数(task/tools/max_rounds/model),白名单钳制写死在入口(tools ∩ Low 风险 ∖ spawn_agent);
//! 2. 组装任务书(父上下文头 + 白名单说明 + 被裁剪尾注),注册级联取消条目(parent→children);
//! 3. 同步等待子循环跑完,报告作为 tool_result 返回值交付(Claude Code Task 语义同构)。
//!
//! ## 自引用绕法(两条进程级 OnceLock,先例 GLOBAL_TOOL_META)
//! - 工具注册表:白名单钳制需要 AiToolRegistry 自身,而注册期无法捕获自身 Arc,
//! state.rs init 在 build_ai_tool_registry 返回后调 init_global_tool_registry 注入;
//! - AppHandle:子循环 emit/guard 需要,lib.rs setup 阶段调 init_app_handle 注入。
//!
//! ## 父会话定位
//! 工具 handler 签名只收 args,父 loop 的 conv_id 经 task-local 注入(复用 command_stream
//! 的 task-local 范式):process_tool_calls / ai_approve 执行工具前 scope_caller_conv 注入,
//! handler 内 caller_conv() 读取;缺省兜底 active_conversation_id(直调防御)。
use std::sync::Arc;
use tokio::sync::Mutex;
use df_ai::ai_tools::{
AiToolRegistry, GoalMeta, GoalStyle, RiskLevel, SigMeta, SigValKind, ToolMeta,
};
use df_ai::declare_tool;
use df_ai::provider::ToolDefinition;
use crate::commands::ai::agentic::conv_state::ConvStateStore;
use crate::commands::ai::agentic::sub_agent::{
clamp_tool_whitelist, next_child_id, run_sub_agent_loop, sub_agent_system_prompt,
sub_agents_register, sub_agents_remove, SubAgentRegistry,
SUB_AGENT_DEFAULT_MAX_ROUNDS, SUB_AGENT_MAX_ROUNDS_CAP,
};
use crate::commands::ai::AiSession;
use crate::state::LlmConcurrency;
use df_storage::db::Database;
/// spawn_agent 工具捕获上下文(Arc 全字段,Clone 廉价;先例 GetAppConfigCtx)。
///
/// 各 Arc 由 state.rs init 在 build_ai_tool_registry **之前**提前构建并 clone 注入,
/// AppState struct 字段复用同一 Arc(注册先于 ai_session 构建的自引用绕法)。
/// derive Clone:declare_tool! 把捕获变量 clone 进工具闭包。
#[derive(Clone)]
pub struct SpawnAgentCtx {
/// 父/子会话状态(读父 model_override / active_conversation_id,子会话建条)
pub session: Arc<Mutex<AiSession>>,
/// 会话生成态存储(子循环 guard 经 AppHandle 侧使用,此处预留一致生命周期;
/// 与 AppState.conv_states 共享同一 Arc,随子代理消费场景接入启用)
#[allow(dead_code)]
pub conv_states: Arc<ConvStateStore>,
/// LLM 并发控制(子循环 global + per_conv(child) 双份额)
pub llm_concurrency: LlmConcurrency,
/// 数据库句柄(子循环 provider 解析)
pub db: Arc<Database>,
/// 级联取消注册表(parent→children,与 AppState.sub_agent_registry 共享同一 Arc)
pub sub_agents: SubAgentRegistry,
}
// ── 进程级 OnceLock:工具注册表 + AppHandle(注册期无法捕获,运行期注入,见模块头) ──
static GLOBAL_SPAWN_TOOL_REGISTRY: std::sync::OnceLock<Arc<AiToolRegistry>> =
std::sync::OnceLock::new();
static GLOBAL_APP: std::sync::OnceLock<tauri::AppHandle> = std::sync::OnceLock::new();
/// state.rs init 注入工具注册表(白名单钳制数据源;重复注入忽略,首次生效)。
pub(crate) fn init_global_tool_registry(registry: Arc<AiToolRegistry>) {
let _ = GLOBAL_SPAWN_TOOL_REGISTRY.set(registry);
}
/// handler 读取工具注册表(未初始化返 None,handler 报明确错误)。
fn tool_registry() -> Option<Arc<AiToolRegistry>> {
GLOBAL_SPAWN_TOOL_REGISTRY.get().cloned()
}
/// lib.rs setup 注入 AppHandle(子循环 emit/guard 所需;重复注入忽略,首次生效)。
pub(crate) fn init_app_handle(app: tauri::AppHandle) {
let _ = GLOBAL_APP.set(app);
}
/// handler 读取 AppHandle(未初始化返 None,handler 报明确错误)。
/// pub(crate):编排会话启动通道(chat.rs orchestration_session_spawner)同款复用。
pub(crate) fn app_handle() -> Option<tauri::AppHandle> {
GLOBAL_APP.get().cloned()
}
// ── 父会话 task-local(复用 command_stream 的 task-local 范式) ──
tokio::task_local! {
static CALLER_CONV: String;
}
/// 在注入父会话 id 的作用域内执行 future(process_tool_calls / ai_approve 调用工具前包裹)。
pub(crate) async fn scope_caller_conv<F, R>(conv_id: String, fut: F) -> R
where
F: std::future::Future<Output = R>,
{
CALLER_CONV.scope(conv_id, fut).await
}
/// handler 读取发起调用的父会话 id(未经 scope 直调时返 None,调用方走兜底)。
pub(crate) fn caller_conv() -> Option<String> {
CALLER_CONV.try_with(|c| c.clone()).ok()
}
// ── 参数解析与任务书组装(纯函数,单测锁定) ──
/// 解析 max_rounds:缺省默认 6,合法域钳制 [1, 20](防参数放大预算失控)。
fn parse_max_rounds(raw: Option<u64>) -> u32 {
raw.map(|v| v as u32)
.unwrap_or(SUB_AGENT_DEFAULT_MAX_ROUNDS)
.clamp(1, SUB_AGENT_MAX_ROUNDS_CAP)
}
/// 组装子代理任务书:父上下文头 + 任务正文 + 工具白名单说明 + 被裁剪项尾注。
fn build_task_prompt(task: &str, allowed: &[String], trimmed: &[String]) -> String {
let mut p = String::new();
p.push_str("[父任务上下文] 你是主会话派生的子代理,以下是父任务交办的任务书。\n\n<task>\n");
p.push_str(task);
p.push_str("\n</task>\n\n");
if allowed.is_empty() {
p.push_str("可用工具:无(白名单为空),请直接基于推理输出结论报告。\n");
} else {
p.push_str(&format!(
"可用工具白名单(仅限以下工具,且需授权/高风险操作会被自动拒绝):{}\n",
allowed.join(", ")
));
}
if !trimmed.is_empty() {
p.push_str(&format!(
"(以下请求的工具因超出子代理权限或未注册被剔除,如确需请写入报告交由主会话完成:{})\n",
trimmed.join(", ")
));
}
p
}
/// 注册 spawn_agent 工具(RiskLevel::Medium,派发需用户审批,与 create_task 同档)。
pub fn register(registry: &mut AiToolRegistry, ctx: SpawnAgentCtx) {
// schema 手写 JSON(task 必填 + 三个可选参数,描述面向 LLM)
let schema = serde_json::json!({
"type": "object",
"properties": {
"task": {
"type": "string",
"description": "自包含任务书:背景/目标/完成标准。子代理看不到父对话,任务书必须自带全部所需上下文"
},
"tools": {
"type": "array",
"items": { "type": "string" },
"description": "可选,工具名白名单。仅低风险只读工具可保留,其余被剔除并在任务书尾注说明;缺省=全部低风险工具"
},
"max_rounds": {
"type": "integer",
"description": "可选,工具循环轮数上限,默认 6,最大 20"
},
"model": {
"type": "string",
"description": "可选,子代理用模型 id;缺省继承当前会话模型"
}
},
"required": ["task"]
});
declare_tool!(
registry,
ctx: SpawnAgentCtx,
"spawn_agent",
"派发一个子代理在隔离上下文中独立完成专项任务(不与用户交互),最终返回结论报告。适用:探索检索/多文件阅读汇总/资料核对等可独立收敛的子任务。参数 task 为自包含任务书(子代理看不到父对话);tools 为可选工具白名单;max_rounds 可选轮数上限(默认 6);model 可选模型(缺省继承当前会话)。注意:子代理无审批通道,写文件/命令执行等高风险工具会被自动拒绝,结论以 report 字段文本返回",
RiskLevel::Medium,
meta: ToolMeta {
// 审批卡文案模板(模板 {} 按位置填充,键取 task)
approval_hint: Some(("派发子代理: {}", &["task"])),
display_verb: Some("派发"),
// G1 目标推理:任务书截断展示(Truncate 25 字符,对齐 run_command 风格)
goal: Some(GoalMeta {
verb: "派发子代理",
keys: &["task"],
style: GoalStyle::Truncate,
fallback: Some("派发子代理执行专项任务"),
}),
// G2 探索熔断签名:task 进签名,同任务书重复派发会被判重复打转熔断
sig: Some(SigMeta {
groups: &[(&["task"], SigValKind::Str)],
}),
..Default::default()
},
schema: schema,
args => {
spawn_agent_handler(&ctx, &args).await
}
);
}
/// spawn_agent handler:钳制白名单 → 注册级联条目 → 同步跑子循环 → 报告作返回值。
async fn spawn_agent_handler(
ctx: &SpawnAgentCtx,
args: &serde_json::Value,
) -> anyhow::Result<serde_json::Value> {
// 参数解析
let task = args
.get("task")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("参数 task 不能为空:请传自包含任务书(背景/目标/完成标准)"))?;
let max_rounds = parse_max_rounds(args.get("max_rounds").and_then(|v| v.as_u64()));
let model = args
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let requested: Vec<String> = args
.get("tools")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default();
// 白名单钳制(写死在入口,不依赖 LLM 自觉)+ 工具定义投影
let registry = tool_registry()
.ok_or_else(|| anyhow::anyhow!("子代理工具注册表未就绪(应用初始化未完成)"))?;
let (allowed, trimmed) = clamp_tool_whitelist(&registry, &requested);
let tool_defs: Vec<ToolDefinition> = registry
.tool_definitions()
.into_iter()
.filter(|d| allowed.contains(&d.function.name))
.collect();
// 父会话定位:task-local 注入优先,兜底 active(直调防御)
let parent_conv_id = match caller_conv() {
Some(p) if !p.is_empty() => p,
_ => ctx
.session
.lock()
.await
.active_conversation_id
.clone()
.unwrap_or_default(),
};
if parent_conv_id.is_empty() {
return Err(anyhow::anyhow!("spawn_agent 无法定位父会话:只能在会话内调用"));
}
// model 不传 → 继承父会话模型 override(与父对话同模型语义)
let child_model = match model {
Some(m) => Some(m),
None => ctx
.session
.lock()
.await
.conv_read(&parent_conv_id)
.and_then(|c| c.model_override.clone()),
};
let child_id = next_child_id(&parent_conv_id);
let task_prompt = build_task_prompt(&task, &allowed, &trimmed);
let system_prompt = sub_agent_system_prompt();
// 级联注册(父停止/强制发送/删除会遍历 children 级联取消)
sub_agents_register(&ctx.sub_agents, &parent_conv_id, &child_id);
let app = match app_handle() {
Some(h) => h,
None => {
sub_agents_remove(&ctx.sub_agents, &parent_conv_id, &child_id);
return Err(anyhow::anyhow!("应用句柄未就绪(启动初始化未完成)"));
}
};
tracing::info!(
parent = %parent_conv_id,
agent = %child_id,
task_chars = task.chars().count(),
tools = allowed.len(),
max_rounds,
model = ?child_model,
"[sub-agent] 派发子代理"
);
// 同步等待子循环(报告经返回值交付;外层超时对 spawn_agent 取子代理挂钟预算同款上限)
let outcome = run_sub_agent_loop(
ctx.session.clone(),
registry.clone(),
ctx.db.clone(),
app,
parent_conv_id.clone(),
child_id.clone(),
system_prompt,
task_prompt,
tool_defs,
max_rounds,
child_model,
ctx.llm_concurrency.clone(),
)
.await;
// 级联注销(空父条目整条清除)
sub_agents_remove(&ctx.sub_agents, &parent_conv_id, &child_id);
Ok(serde_json::json!({
"status": outcome.status.as_str(),
"agent_id": child_id,
"rounds": outcome.rounds,
"tokens": outcome.tokens,
"report": outcome.summary,
}))
}
#[cfg(test)]
mod tests {
use super::*;
/// parse_max_rounds:缺省 6,合法域钳制 [1,20]。
#[test]
fn parse_max_rounds_clamps() {
assert_eq!(parse_max_rounds(None), SUB_AGENT_DEFAULT_MAX_ROUNDS);
assert_eq!(parse_max_rounds(Some(1)), 1);
assert_eq!(parse_max_rounds(Some(6)), 6);
assert_eq!(parse_max_rounds(Some(0)), 1, "0 钳到下界 1");
assert_eq!(parse_max_rounds(Some(999)), SUB_AGENT_MAX_ROUNDS_CAP, "超上限钳到 20");
}
/// 任务书:含父上下文头、任务正文、白名单说明与被裁剪尾注。
#[test]
fn task_prompt_carries_context_and_notes() {
let p = build_task_prompt(
"统计 crates 下 TODO 数量",
&["read_file".to_string(), "grep".to_string()],
&["run_command".to_string()],
);
assert!(p.contains("[父任务上下文]"), "应有父上下文头");
assert!(p.contains("<task>\n统计 crates 下 TODO 数量\n</task>"), "任务正文应入 task 标签");
assert!(p.contains("read_file, grep"), "应列白名单");
assert!(p.contains("run_command"), "被裁剪项应有尾注说明");
// 空白名单走"无工具"说明
let empty = build_task_prompt("t", &[], &[]);
assert!(empty.contains("白名单为空"));
}
/// task-local 父会话注入/读取:scope 内可读,scope 外读不到。
#[tokio::test]
async fn caller_conv_scoped() {
assert!(caller_conv().is_none(), "scope 外应读不到");
let seen = scope_caller_conv("conv-p::agent-9".to_string(), async {
caller_conv()
}).await;
assert_eq!(seen.as_deref(), Some("conv-p::agent-9"));
}
}
+23 -1
View File
@@ -16,7 +16,7 @@
use std::sync::Arc; 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_ai::declare_tool;
use df_storage::db::Database; use df_storage::db::Database;
use df_storage::models::TaskRecord; use df_storage::models::TaskRecord;
@@ -67,6 +67,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
"create_task", "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)", "在指定项目下创建新任务,可选传 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, RiskLevel::Medium,
meta: ToolMeta {
entity_action: Some(("task", "create")),
approval_hint: Some(("创建任务:{}(项目{})", &["title", "project_id"])),
display_verb: Some("创建任务"),
..Default::default()
},
schema: object_schema(vec![ schema: object_schema(vec![
("project_id", "string", true), ("title", "string", true), ("project_id", "string", true), ("title", "string", true),
("description", "string", false), ("priority", "integer", false), ("description", "string", false), ("priority", "integer", false),
@@ -129,6 +135,11 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
"update_task", "update_task",
"更新任务的指定字段(title/description/priority/assignee 等),需要提供任务 ID、字段名和新值。注意:status 改动须走 advance_task 工具(状态机推进,8 态 todo/in_progress/in_review/testing/blocked/done/cancelled/deferred,按 target_status 推进不可跳跃),本工具不接受 status 字段(防绕过状态机)", "更新任务的指定字段(title/description/priority/assignee 等),需要提供任务 ID、字段名和新值。注意:status 改动须走 advance_task 工具(状态机推进,8 态 todo/in_progress/in_review/testing/blocked/done/cancelled/deferred,按 target_status 推进不可跳跃),本工具不接受 status 字段(防绕过状态机)",
RiskLevel::Medium, 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)]), schema: object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]),
args => { args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
@@ -168,6 +179,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
"advance_task", "advance_task",
"推进任务状态,必须走状态机(8 态: todo/in_progress/in_review/testing/blocked/done/cancelled/deferred),按 target_status 推进,不可跳跃(非法转换会被状态机拦截)。退回转换(in_review→in_progress 等)自动累加 review_rounds。返回推进后的最新 TaskRecord", "推进任务状态,必须走状态机(8 态: todo/in_progress/in_review/testing/blocked/done/cancelled/deferred),按 target_status 推进,不可跳跃(非法转换会被状态机拦截)。退回转换(in_review→in_progress 等)自动累加 review_rounds。返回推进后的最新 TaskRecord",
RiskLevel::Medium, 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)]), schema: object_schema(vec![("id", "string", true), ("target_status", "string", true)]),
args => { args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
@@ -211,6 +228,11 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
"delete_task", "delete_task",
"删除任务(移入回收站,可恢复)", "删除任务(移入回收站,可恢复)",
RiskLevel::High, RiskLevel::High,
meta: ToolMeta {
entity_action: Some(("task", "delete")),
display_verb: Some("删除任务"),
..Default::default()
},
schema: object_schema(vec![("id", "string", true)]), schema: object_schema(vec![("id", "string", true)]),
args => { args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
+7 -1
View File
@@ -15,7 +15,7 @@
use std::sync::Arc; 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_ai::declare_tool;
/// 注册 run_workflow 工具到 `$registry`(无 db 捕获,防御兜底)。 /// 注册 run_workflow 工具到 `$registry`(无 db 捕获,防御兜底)。
@@ -35,6 +35,12 @@ pub fn register(registry: &mut AiToolRegistry) {
"run_workflow", "run_workflow",
"按任务 target_status 推进对应工作流(含 AiNode 自审 / HumanNode 核对闸门)。参数 task_id + target_status 同时提供才联动任务推进(完成后按 target_status 推进任务,失败按退回态回滚)。属高风险操作(触发工作流引擎执行),须人工批准。审批通过后由后端直接执行工作流引擎并联动推进任务,返回 execution_id", "按任务 target_status 推进对应工作流(含 AiNode 自审 / HumanNode 核对闸门)。参数 task_id + target_status 同时提供才联动任务推进(完成后按 target_status 推进任务,失败按退回态回滚)。属高风险操作(触发工作流引擎执行),须人工批准。审批通过后由后端直接执行工作流引擎并联动推进任务,返回 execution_id",
RiskLevel::High, 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)]), schema: object_schema(vec![("task_id", "string", true), ("target_status", "string", true)]),
args => { args => {
let task_id = args["task_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 task_id"))?; let task_id = args["task_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 task_id"))?;
+8 -17
View File
@@ -27,6 +27,7 @@ use df_types::types::new_id;
use df_project::scan::detect_stack; use df_project::scan::detect_stack;
// 文件符号概览(file_outline)复用 code_intel 的 AST 符号提取 // 文件符号概览(file_outline)复用 code_intel 的 AST 符号提取
use crate::commands::ai::code_intel; use crate::commands::ai::code_intel;
use crate::commands::ai::tool_registry::{is_noise_entry, NoiseDirTier};
use crate::state::AppState; use crate::state::AppState;
@@ -652,22 +653,12 @@ fn run_git_cmd(cwd: &std::path::Path, args: &[&str], timeout: std::time::Duratio
// 设计要点(对标设计 §五 + Batch 10): // 设计要点(对标设计 §五 + Batch 10):
// - **路径安全**:sub_path / file_path 均拒含 `..`(防目录穿越)。先 join 再 canonicalize // - **路径安全**:sub_path / file_path 均拒含 `..`(防目录穿越)。先 join 再 canonicalize
// 校验结果仍位于 module.path 子树内(双重防御,即便 `..` 漏网也无法逃出工程根)。 // 校验结果仍位于 module.path 子树内(双重防御,即便 `..` 漏网也无法逃出工程根)。
// - **噪音过滤**:`get_module_file_tree` 跳过 node_modules/target/.git/__pycache__/dist/.vite // - **噪音过滤**:`get_module_file_tree` 经 `is_noise_entry`(FileTree 档)过滤依赖/缓存目录,
// (对齐需求清单,前端文件树不展示构建产物/VCS 元数据)。 // 隐藏文件(`.` 开头)另有过滤;构建产物 build 与 venv 保留展示给用户。
// - **Git 状态合并**:跑 `git status --porcelain` 得 {rel_path: status} 映射,按条目相对路径 // - **Git 状态合并**:跑 `git status --porcelain` 得 {rel_path: status} 映射,按条目相对路径
// 查表填充 git_status。文件夹一律 git_status = None(只标文件,符合需求)。 // 查表填充 git_status。文件夹一律 git_status = None(只标文件,符合需求)。
// - **二进制检测**:read_module_file 读 1MB 上限,前 8KB 含 \0 视作二进制(is_binary=true,content 留空)。 // - **二进制检测**: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` 展开实际展示一致)。 /// 判断目录是否含可见子项(过滤隐藏文件/噪音目录后,与 `get_module_file_tree` 展开实际展示一致)。
/// ///
/// 供 `FileTreeEntry.has_children` 用:前端未展开目录即可据此在行内标注空目录, /// 供 `FileTreeEntry.has_children` 用:前端未展开目录即可据此在行内标注空目录,
@@ -684,7 +675,7 @@ fn dir_has_visible_children(dir: &Path) -> bool {
if name.starts_with('.') { if name.starts_with('.') {
return false; return false;
} }
if NOISE_DIRS.iter().any(|n| *n == name.as_ref()) { if is_noise_entry(&name, NoiseDirTier::FileTree) {
return false; return false;
} }
// 与列目录一致:无法判定类型的条目(断链符号链接等)视为不可见 // 与列目录一致:无法判定类型的条目(断链符号链接等)视为不可见
@@ -786,8 +777,8 @@ pub async fn get_module_file_tree(
for entry in read.flatten() { for entry in read.flatten() {
let file_name = entry.file_name(); let file_name = entry.file_name();
let name = file_name.to_string_lossy().to_string(); let name = file_name.to_string_lossy().to_string();
// 噪音过滤(无论文件/目录,凡名命中即跳)。 // 噪音过滤(无论文件/目录,凡名命中即跳;名单统一走 is_noise_entry FileTree 档)。
if NOISE_DIRS.iter().any(|n| *n == name.as_str()) { if is_noise_entry(&name, NoiseDirTier::FileTree) {
continue; continue;
} }
let ft = match entry.file_type() { 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 }; let ft = match entry.file_type() { Ok(t) => t, Err(_) => continue };
if !ft.is_dir() { continue; } if !ft.is_dir() { continue; }
let name = entry.file_name().to_string_lossy().to_string(); let name = entry.file_name().to_string_lossy().to_string();
// 跳过隐藏目录和噪音目录 // 跳过隐藏目录和噪音目录(名单统一走 is_noise_entry FileTree 档)
if name.starts_with('.') || name == "node_modules" || name == "target" || name == "__pycache__" || name == "dist" { if name.starts_with('.') || is_noise_entry(&name, NoiseDirTier::FileTree) {
continue; continue;
} }
let child_path = entry.path(); let child_path = entry.path();
+3 -5
View File
@@ -127,7 +127,7 @@ pub async fn set_plan_execution(enabled: bool) -> Result<(), String> {
/// 读取多 ReAct Plan 执行开关状态。 /// 读取多 ReAct Plan 执行开关状态。
#[tauri::command] #[tauri::command]
pub async fn get_plan_execution() -> Result<bool, String> { pub async fn is_plan_execution_enabled() -> Result<bool, String> {
Ok(df_ai::plan_executor::plan_execution_enabled()) Ok(df_ai::plan_executor::plan_execution_enabled())
} }
@@ -234,7 +234,6 @@ pub async fn resolve_conflict(
resolved_by: String, resolved_by: String,
conversation_id: Option<String>, conversation_id: Option<String>,
) -> Result<(), String> { ) -> Result<(), String> {
use tauri::Emitter;
let repo = df_storage::crud::ConflictRepo::new(&state.db); let repo = df_storage::crud::ConflictRepo::new(&state.db);
let now = df_types::now_millis().to_string(); let now = df_types::now_millis().to_string();
let ok = repo.resolve(&conflict_id, &resolution, &resolved_by, &now) let ok = repo.resolve(&conflict_id, &resolution, &resolved_by, &now)
@@ -249,7 +248,7 @@ pub async fn resolve_conflict(
resolved_by = %resolved_by, resolved_by = %resolved_by,
"[CONFLICT] 冲突已解决" "[CONFLICT] 冲突已解决"
); );
// emit AiConflictResolved 事件 // emit AiConflictResolved 事件(双写收敛经 emit_ai_chat_event)
let ev = crate::commands::ai::AiChatEvent::AiConflictResolved { let ev = crate::commands::ai::AiChatEvent::AiConflictResolved {
conflict_id, conflict_id,
plan_id, plan_id,
@@ -257,7 +256,6 @@ pub async fn resolve_conflict(
resolved_by, resolved_by,
conversation_id, conversation_id,
}; };
let _ = app_handle.emit("ai-chat-event", ev.clone()); crate::commands::ai::event_bus::emit_ai_chat_event(&app_handle, ev);
let _ = state.ai_event_bus.publish_event(ev);
Ok(()) Ok(())
} }
+3 -3
View File
@@ -40,7 +40,7 @@ struct WorkflowEventPayload {
/// broadcast `Lagged` 不暴露被丢事件类型,关键终态事件(WorkflowCompleted/Failed)可能已丢, /// broadcast `Lagged` 不暴露被丢事件类型,关键终态事件(WorkflowCompleted/Failed)可能已丢,
/// forward 循环会永久等不到。达阈值时查 DB 终态补发。三种"未命中"(仍 running / 无记录 / /// forward 循环会永久等不到。达阈值时查 DB 终态补发。三种"未命中"(仍 running / 无记录 /
/// 查询失败)统一返回 None 并各自 warn,主循环据此重置 `lagged_total` 继续等。 /// 查询失败)统一返回 None 并各自 warn,主循环据此重置 `lagged_total` 继续等。
async fn probe_lagged_terminal( async fn probe_missed_terminal_event(
db: &df_storage::db::Database, db: &df_storage::db::Database,
exec_id: &ExecutionId, exec_id: &ExecutionId,
) -> Option<(WorkflowEvent, String)> { ) -> Option<(WorkflowEvent, String)> {
@@ -264,8 +264,8 @@ pub async fn run_workflow_inner(
if lagged_total < LAGGED_PROBE_THRESHOLD { if lagged_total < LAGGED_PROBE_THRESHOLD {
continue; continue;
} }
// 累计/单次达阈值:查 DB 终态兜底(probe_lagged_terminal 收口三种未命中情况) // 累计/单次达阈值:查 DB 终态兜底(probe_missed_terminal_event 收口三种未命中情况)
match probe_lagged_terminal(&forward_db, &forward_exec_id).await { match probe_missed_terminal_event(&forward_db, &forward_exec_id).await {
Some((synth_event, status)) => { Some((synth_event, status)) => {
let payload = WorkflowEventPayload { let payload = WorkflowEventPayload {
execution_id: forward_exec_id.clone(), execution_id: forward_exec_id.clone(),
+31 -11
View File
@@ -28,6 +28,9 @@ impl tracing_subscriber::fmt::time::FormatTime for LocalTimer {
// Phase3 跨端隧道:TunnelClient trait(connect/send_raw_event/disconnect 方法)需在作用域内 // Phase3 跨端隧道:TunnelClient trait(connect/send_raw_event/disconnect 方法)需在作用域内
use df_tunnel::TunnelClient; use df_tunnel::TunnelClient;
/// 关窗进行中提示哨兵:tray-hide-notice 每进程只发一次(反复关窗不重复弹气泡)。
static CLOSE_NOTICE_SENT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
// 初始化文件日志:追加到 %TEMP%/devflow-trace.log,RUST_LOG 控制级别(默认 info) // 初始化文件日志:追加到 %TEMP%/devflow-trace.log,RUST_LOG 控制级别(默认 info)
@@ -56,6 +59,8 @@ pub fn run() {
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_window_state::Builder::default().build()) .plugin(tauri_plugin_window_state::Builder::default().build())
// P1 bg 化:系统通知插件(Rust 侧 notify.rs 条件发送:主窗可见聚焦不发 + 同类节流 + 失败静默)
.plugin(tauri_plugin_notification::init())
// Phase3 单实例:二次启动不新开进程,唤起已有实例主窗口(防多实例抢 MCP 18765 端口) // Phase3 单实例:二次启动不新开进程,唤起已有实例主窗口(防多实例抢 MCP 18765 端口)
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
tray::show_main(app); tray::show_main(app);
@@ -79,6 +84,9 @@ pub fn run() {
// L0 握手 — 提前 clone ai_session(Arc),因 manage() 会 move app_state // L0 握手 — 提前 clone ai_session(Arc),因 manage() 会 move app_state
let session_for_handshake = app_state.ai_session.clone(); let session_for_handshake = app_state.ai_session.clone();
app.manage(app_state); app.manage(app_state);
// spawn_agent 子代理循环需要 AppHandle(emit/guard),工具注册期无法捕获,
// setup 阶段回注进程级 OnceLock(重复注入忽略,首次生效)。
crate::commands::ai::tools::spawn_agent::init_app_handle(app.handle().clone());
// ── Phase3: 内嵌 HTTP MCP server + 托盘常驻 + 单实例 ── // ── Phase3: 内嵌 HTTP MCP server + 托盘常驻 + 单实例 ──
// 1) MCP 运行时状态:先 manage 初始 running=false,spawn 绑定成功后更新为 true。 // 1) MCP 运行时状态:先 manage 初始 running=false,spawn 绑定成功后更新为 true。
@@ -200,20 +208,15 @@ pub fn run() {
} }
}; };
// relay_url:KV 取,无则默认本地(localhost:8080,本地开发 df-relay 默认监听)。 // relay_url:KV 取,无则默认本地(localhost:8080,本地开发 df-relay 默认监听)。
// settings value 契约模糊(JSON 序列化带引号或裸值并存,见 SettingsRepo get/set 不解析 JSON): // settings value 契约模糊(JSON 序列化带引号或裸值并存):经 get_string_norm
// 统一解析去引号 —— serde_json::from_str 处理 JSON 字符串值,失败(裸值)则 trim 首尾引号兜底, // 归一去引号(此处 from_str 写法已收敛为 state.rs SettingsNorm 正典,2026-10-01 DRY #2),
// 确保拿到纯净 URL 供 connect_async(带引号 URL 解析失败致 connect 静默失败,relay 无 device 接入)。 // 确保拿到纯净 URL 供 connect_async(带引号 URL 解析失败致 connect 静默失败,relay 无 device 接入)。
use state::SettingsNorm;
let relay_url = state let relay_url = state
.settings .settings
.get("relay_url") .get_string_norm("relay_url")
.await .await
.ok()
.flatten()
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.map(|raw| {
serde_json::from_str::<String>(&raw)
.unwrap_or_else(|_| raw.trim_matches('"').to_string())
})
.unwrap_or_else(|| "ws://localhost:8080/ws/device".to_string()); .unwrap_or_else(|| "ws://localhost:8080/ws/device".to_string());
// token:固定常量(三端同源,对齐 df-relay relay.rs:37 DEFAULT_TOKEN) // token:固定常量(三端同源,对齐 df-relay relay.rs:37 DEFAULT_TOKEN)
let token = "devflow-relay-default-token".to_string(); let token = "devflow-relay-default-token".to_string();
@@ -499,7 +502,7 @@ pub fn run() {
commands::settings::set_script_safety, commands::settings::set_script_safety,
// Plan 执行开关 // Plan 执行开关
commands::settings::set_plan_execution, commands::settings::set_plan_execution,
commands::settings::get_plan_execution, commands::settings::is_plan_execution_enabled,
// 冲突解决 // 冲突解决
commands::settings::resolve_conflict, commands::settings::resolve_conflict,
// 模板管理 // 模板管理
@@ -514,13 +517,30 @@ pub fn run() {
// 关主窗口不退出,隐藏到托盘常驻(托盘「退出」菜单才真正结束进程)。 // 关主窗口不退出,隐藏到托盘常驻(托盘「退出」菜单才真正结束进程)。
// 仅拦 main:approval-popup / ai-detached / fe-detached 等 JS 创建窗口必须放行 // 仅拦 main:approval-popup / ai-detached / fe-detached 等 JS 创建窗口必须放行
// 真正关闭,否则 close 被吞成隐藏僵尸窗,浮窗「正在关闭…」卡死、新审批推给不可见窗口。 // 真正关闭,否则 close 被吞成隐藏僵尸窗,浮窗「正在关闭…」卡死、新审批推给不可见窗口。
// P1 bg 化:有 AI 会话运行中时,首次关窗 emit tray-hide-notice 提示后台仍在跑(每进程一次),
// 异步等 2 秒再 hide(同步 sleep 卡主线程渲染,气泡画不出来);无运行会话保持立即 hide 零回归。
.on_window_event(|window, event| { .on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event { if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if window.label() != "main" { if window.label() != "main" {
return; // 非主窗口放行真正销毁 return; // 非主窗口放行真正销毁
} }
api.prevent_close(); // 拦截真正关闭 api.prevent_close(); // 拦截真正关闭
let _ = window.hide(); // 隐藏到托盘 let app = window.app_handle().clone();
let active = app.state::<AppState>().conv_states.active_convs().len();
if active == 0 {
let _ = window.hide(); // 无运行会话:隐藏到托盘(原行为)
return;
}
// 有运行会话:每进程一次给主窗发提示事件(payload 带运行数),前端画气泡
if !CLOSE_NOTICE_SENT.swap(true, std::sync::atomic::Ordering::SeqCst) {
let _ = app.emit_to("main", "tray-hide-notice", serde_json::json!({ "active": active }));
}
// 异步等 2 秒再 hide:气泡可见窗口,禁用 std::thread::sleep(同步阻塞卡渲染)
let win = window.clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let _ = win.hide();
});
} }
}) })
.run(tauri::generate_context!()) .run(tauri::generate_context!())
+182 -4
View File
@@ -31,7 +31,7 @@ use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::time::Duration; use std::time::{Duration, Instant};
use anyhow::Result; use anyhow::Result;
use tokio::sync::{Mutex, RwLock}; use tokio::sync::{Mutex, RwLock};
@@ -52,6 +52,82 @@ use crate::commands::ai::augmentation::ResolverRegistry;
use crate::commands::ai::AiSession; use crate::commands::ai::AiSession;
use df_types::types::ExecutionId; 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<String>;
/// 读 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<String> {
let raw = self.get(key).await.ok().flatten()?;
Some(
serde_json::from_str::<String>(&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::<String>(&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>` 取用 /// 应用全局状态 — 通过 `app.manage()` 注入,command 中以 `State<'_, AppState>` 取用
pub struct AppState { pub struct AppState {
/// 数据库句柄(Arc 包装,便于在异步任务中重建 Repo) /// 数据库句柄(Arc 包装,便于在异步任务中重建 Repo)
@@ -125,6 +201,14 @@ pub struct AppState {
/// guard/reset/ai_is_generating 全部零锁操作。PerConvState.conv_state 字段已删。 /// guard/reset/ai_is_generating 全部零锁操作。PerConvState.conv_state 字段已删。
/// 治卡死连环(AiCompleted 延迟 / 工具后中断 / 第二条进队列同源根因)。 /// 治卡死连环(AiCompleted 延迟 / 工具后中断 / 第二条进队列同源根因)。
pub conv_states: Arc<crate::commands::ai::agentic::conv_state::ConvStateStore>, pub conv_states: Arc<crate::commands::ai::agentic::conv_state::ConvStateStore>,
/// 编排会话完成通知桥(conv_id → 完成信号 oneshot 发送端,值=最终 assistant 报告文本)。
///
/// 工作流 AiAgentNode 经编排会话启动通道(chat.rs orchestration_session_spawner)
/// 在 spawn 前注册等待;agentic loop 收敛统一出口(agentic/mod.rs emit_ai_completed_once,
/// epoch 守卫已由调用方保证,仅 owner loop 到达)投递并移除条目。
/// std Mutex:临界区为纯内存 insert/remove,无 await,不持锁跨等待点。
pub agent_completion_bridge:
Arc<std::sync::Mutex<HashMap<String, tokio::sync::oneshot::Sender<String>>>>,
// ── 知识库 ── // ── 知识库 ──
/// 知识库 Repo /// 知识库 Repo
pub knowledge: KnowledgeRepo, pub knowledge: KnowledgeRepo,
@@ -168,6 +252,14 @@ pub struct AppState {
/// workspace_root 始终在白名单(向后兼容)。 /// workspace_root 始终在白名单(向后兼容)。
/// Phase B 将扩展为 persistent + session(会话临时授权) + 弹窗挂起。 /// Phase B 将扩展为 persistent + session(会话临时授权) + 弹窗挂起。
pub allowed_dirs: Arc<RwLock<AllowedDirs>>, pub allowed_dirs: Arc<RwLock<AllowedDirs>>,
/// spawn_agent 级联取消注册表(parent conv_id → children child conv_ids)。
///
/// spawn 前注册、子循环结束注销(tools/spawn_agent.rs);父会话被停止/强制发送/删除时,
/// ai_chat_stop / ai_chat_force_send / ai_conversation_delete 遍历 children 置
/// stop_flag + notify 级联取消(子循环停止检查点兼查 own_stop/父 stop/父条目消失)。
/// 与 SpawnAgentCtx.sub_agents 共享同一 Arc(state.rs init 提前构建)。
pub sub_agent_registry:
crate::commands::ai::agentic::sub_agent::SubAgentRegistry,
// ── 跨端隧道(Phase3 Layer1)── // ── 跨端隧道(Phase3 Layer1)──
/// 桌面端出站 WS 隧道客户端,连 df-relay(/ws/device)透传 ai_event_bus → miniapp /// 桌面端出站 WS 隧道客户端,连 df-relay(/ws/device)透传 ai_event_bus → miniapp
/// + 转发 miniapp 下行指令 → remote_bridge。lib.rs setup spawn task 解析 /// + 转发 miniapp 下行指令 → remote_bridge。lib.rs setup spawn task 解析
@@ -261,9 +353,26 @@ impl AppState {
gateway_url: std::env::var("DF_LOCAL_PROXY_BASE") gateway_url: std::env::var("DF_LOCAL_PROXY_BASE")
.unwrap_or_else(|_| "http://127.0.0.1:8899".to_string()), .unwrap_or_else(|_| "http://127.0.0.1:8899".to_string()),
}; };
// spawn_agent(2026-10-01):子代理派发工具 ctx 提前构建(同 get_app_config 绕 AppState
// 自引用先例)。session/conv_states/sub_agents 三个 Arc 先建,下方 state struct 字段
// 复用同一实例(级联取消注册表三方共享:AppState / SpawnAgentCtx / 级联入口)。
let ai_session = Arc::new(Mutex::new(AiSession::new()));
let conv_states = Arc::new(crate::commands::ai::agentic::conv_state::ConvStateStore::new());
let sub_agent_registry = crate::commands::ai::agentic::sub_agent::new_registry();
let spawn_agent_ctx = crate::commands::ai::tools::spawn_agent::SpawnAgentCtx {
session: ai_session.clone(),
conv_states: conv_states.clone(),
llm_concurrency: llm_concurrency.clone(),
db: db.clone(),
sub_agents: sub_agent_registry.clone(),
};
let ai_tools = Arc::new(crate::commands::ai::build_ai_tool_registry( let ai_tools = Arc::new(crate::commands::ai::build_ai_tool_registry(
&db, &allowed_dirs, data_dir.clone(), get_app_config_ctx, local_proxy_ctx, &db, &allowed_dirs, data_dir.clone(), get_app_config_ctx, local_proxy_ctx,
spawn_agent_ctx,
)); ));
// spawn_agent 白名单钳制需要注册表自身 Arc,注册期无法捕获(自引用),
// 构建完成后回注进程级 OnceLock(handler 运行期读取)。
crate::commands::ai::tools::spawn_agent::init_global_tool_registry(ai_tools.clone());
// Input Augmentation 层(核心设计2):ResolverRegistry 启动期注册四 resolver。 // Input Augmentation 层(核心设计2):ResolverRegistry 启动期注册四 resolver。
// resolver 持 Arc<Database>(非 AppState,避免循环依赖:AppState 持 Arc<ResolverRegistry>), // resolver 持 Arc<Database>(非 AppState,避免循环依赖:AppState 持 Arc<ResolverRegistry>),
// 在 struct 字段 `db` move 前 clone 注入,与 build_ai_tool_registry 同侧。 // 在 struct 字段 `db` move 前 clone 注入,与 build_ai_tool_registry 同侧。
@@ -288,11 +397,15 @@ impl AppState {
ai_conversations: AiConversationRepo::new(&db), ai_conversations: AiConversationRepo::new(&db),
ai_messages: AiMessageRepo::new(&db), ai_messages: AiMessageRepo::new(&db),
ai_tool_executions: AiToolExecutionRepo::new(&db), ai_tool_executions: AiToolExecutionRepo::new(&db),
ai_session: Arc::new(Mutex::new(AiSession::new())), // 复用上方提前构建的 Arc(spawn_agent ctx 注入同一实例,共享底层会话状态)
ai_session,
// L3 批2b:AI 事件总线入 AppState(独立于工作流 event_bus)。emit 双写留批3。 // L3 批2b:AI 事件总线入 AppState(独立于工作流 event_bus)。emit 双写留批3。
ai_event_bus: crate::commands::ai::event_bus::EventBus::new(), ai_event_bus: crate::commands::ai::event_bus::EventBus::new(),
// B-Phase1: ConvStateStore 无锁存储(空构造;双轨期无写点,B-Phase2 写侧切入)。 // B-Phase1: ConvStateStore 无锁存储(空构造;双轨期无写点,B-Phase2 写侧切入)。
conv_states: Arc::new(crate::commands::ai::agentic::conv_state::ConvStateStore::new()), // 复用上方提前构建的 Arc(spawn_agent ctx 注入同一实例)。
conv_states,
// 编排会话完成通知桥(空表起步,AiAgentNode 会话启动时注册,loop 收敛出口投递)。
agent_completion_bridge: Arc::new(std::sync::Mutex::new(HashMap::new())),
// Phase3 Layer1:tunnel 客户端未连接实例,lib.rs setup 内 connect // Phase3 Layer1:tunnel 客户端未连接实例,lib.rs setup 内 connect
tunnel: std::sync::Arc::new(df_tunnel::WsTunnelClient::new()), tunnel: std::sync::Arc::new(df_tunnel::WsTunnelClient::new()),
knowledge: KnowledgeRepo::new(&db), knowledge: KnowledgeRepo::new(&db),
@@ -308,6 +421,9 @@ impl AppState {
workflow_state_registry: Arc::new(Mutex::new(HashMap::new())), workflow_state_registry: Arc::new(Mutex::new(HashMap::new())),
// F-260619-03 Phase A: 与 ai_tools registry 共享同一 Arc(构建时注入同一句柄) // F-260619-03 Phase A: 与 ai_tools registry 共享同一 Arc(构建时注入同一句柄)
allowed_dirs: allowed_dirs.clone(), allowed_dirs: allowed_dirs.clone(),
// spawn_agent 级联取消注册表(parent→children):ai_chat_stop / ai_chat_force_send /
// ai_conversation_delete 命中父 conv 时遍历 children 置 stop_flag,与 SpawnAgentCtx 共享。
sub_agent_registry,
db, db,
event_bus: EventBus::new(), event_bus: EventBus::new(),
registry, registry,
@@ -579,6 +695,46 @@ impl AppState {
pub async fn clear_once_allowed_dirs(&self) { pub async fn clear_once_allowed_dirs(&self) {
self.allowed_dirs.write().await.once.clear(); self.allowed_dirs.write().await.once.clear();
} }
/// 注册编排会话完成等待(工作流 AiAgentNode 会话 spawn 前调用)。
///
/// 同 conv 重复注册覆盖旧条目(旧 oneshot 发送端随值 drop,等待方收 Err 判定信号丢失)。
/// 锁中毒降级 into_inner 取数据继续(桥内只有内存条目,无一致性风险)。
pub fn register_agent_completion(&self, conv_id: String, tx: tokio::sync::oneshot::Sender<String>) {
let mut map = self
.agent_completion_bridge
.lock()
.unwrap_or_else(|p| p.into_inner());
map.insert(conv_id, tx);
}
/// 是否存在挂起的编排会话等待方(收敛出口短路判据,普通用户会话零后续开销)。
pub fn has_agent_completion_waiter(&self, conv_id: &str) -> bool {
let map = self
.agent_completion_bridge
.lock()
.unwrap_or_else(|p| p.into_inner());
map.contains_key(conv_id)
}
/// 投递编排会话完成通知并移除条目(无等待方 no-op)。
///
/// 条目无论投递成败都移除:接收端已 drop(节点超时/取消)时 send 失败静默,不残留。
/// 返回是否找到等待方(供调用方日志)。
pub fn notify_agent_completion(&self, conv_id: &str, report: String) -> bool {
let taken = self
.agent_completion_bridge
.lock()
.unwrap_or_else(|p| p.into_inner())
.remove(conv_id);
match taken {
Some(tx) => {
let _ = tx.send(report);
true
}
None => false,
}
}
} }
/// 构建节点注册表 — 注册内置节点 /// 构建节点注册表 — 注册内置节点
@@ -600,9 +756,31 @@ fn build_registry(db: Arc<Database>) -> NodeRegistry {
// AiNode(df_nodes::ai_node, impl Node trait): // AiNode(df_nodes::ai_node, impl Node trait):
// 决策 a(AiNode 自审闭环)步骤②:AiNode 持 Arc<Database>,execute 完成后若有 task_id // 决策 a(AiNode 自审闭环)步骤②:AiNode 持 Arc<Database>,execute 完成后若有 task_id
// 则把产出落 task.output_json。工厂闭包 move 捕获 db 句柄(Arc clone 廉价)。 // 则把产出落 task.output_json。工厂闭包 move 捕获 db 句柄(Arc clone 廉价)。
// mode 开关(工作流 AiAgentNode 直连 agentic 会话):config.mode == "agent" → 构造
// AiAgentNode(编排会话),缺省/"ask" 走旧 AiNode(单次补全,旧调用方零回归)。
// 工厂期 AppHandle 经进程级 OnceLock 读取(spawn_agent 同款,lib.rs setup 已注入,
// 工厂调用点在 run_workflow → build_dag,时序晚于 setup,句柄必已就绪)。
let ai_db = db.clone(); let ai_db = db.clone();
registry.register("ai", move |_config| { registry.register("ai", move |config| {
if config.get("mode").and_then(|v| v.as_str()) == Some("agent") {
Box::new(df_nodes::ai_agent_node::AiAgentNode::new(
ai_db.clone(),
crate::commands::ai::commands::chat::orchestration_session_spawner(),
))
} else {
Box::new(df_nodes::ai_node::AiNode::new(ai_db.clone())) Box::new(df_nodes::ai_node::AiNode::new(ai_db.clone()))
}
});
// AiAgentNode(df_nodes::ai_agent_node, impl Node trait):
// 独立注册类型:节点 execute 时以编程方式起独立 agentic 会话跑任务,收敛后取最终
// assistant 报告回写。spawn 通道经 orchestration_session_spawner 注入(内部经
// AppHandle OnceLock 取 AppState),db 句柄供 task_id 落 output_json(ai_db 同款)。
let agent_db = db.clone();
registry.register("ai_agent", move |_config| {
Box::new(df_nodes::ai_agent_node::AiAgentNode::new(
agent_db.clone(),
crate::commands::ai::commands::chat::orchestration_session_spawner(),
))
}); });
// AiSelfReviewNode(df_nodes::ai_self_review_node, impl Node trait): // AiSelfReviewNode(df_nodes::ai_self_review_node, impl Node trait):
// 决策 a 步骤③:四维度自审(prompt 固定+JSON 解析兜底),写回 output_json 加 review 子字段, // 决策 a 步骤③:四维度自审(prompt 固定+JSON 解析兜底),写回 output_json 加 review 子字段,
+12
View File
@@ -133,6 +133,18 @@ impl LlmConcurrency {
sema.acquire_owned().await.expect("llm global semaphore closed") sema.acquire_owned().await.expect("llm global semaphore closed")
} }
/// 取全局 LLM 调用并发 permit 的**非阻塞**版本(对齐 try_acquire_per_conv 语义)。
///
/// 用途:spawn_agent 子代理循环入口一次性取 global + per_conv(child) 双份额,
/// 任一耗尽即 failed(子代理不阻塞等待,防占满父 loop 的执行槽)。
/// 锁持有短(不含 try_acquire)。
pub async fn try_acquire_global(&self) -> Option<tokio::sync::OwnedSemaphorePermit> {
let sema = self.global.lock().await.clone();
// try_acquire_owned 非阻塞:Err(NoPermits) 返 None,不阻塞调用方。
// Semaphore 不会 close(无 close() 调用),Err 仅可能为 NoPermits。
sema.try_acquire_owned().ok()
}
/// 取单对话内并发 permit(F-09 B 批5):按 conv_id 取/建 Semaphore,permits=当前 per_conv_permits(默认 2)。 /// 取单对话内并发 permit(F-09 B 批5):按 conv_id 取/建 Semaphore,permits=当前 per_conv_permits(默认 2)。
/// lock HashMap → 无则建 → clone Arc → 释放 lock → acquire_owned。锁持有短(不含 await acquire)。 /// lock HashMap → 无则建 → clone Arc → 释放 lock → acquire_owned。锁持有短(不含 await acquire)。
pub async fn acquire_per_conv(&self, conv_id: &str) -> tokio::sync::OwnedSemaphorePermit { pub async fn acquire_per_conv(&self, conv_id: &str) -> tokio::sync::OwnedSemaphorePermit {
+44 -8
View File
@@ -1,24 +1,34 @@
//! 托盘常驻:图标 + 菜单[显示主窗口/退出]。关窗口不退出,托盘退出才真正结束进程。 //! 托盘常驻:图标 + 菜单[运行态(纯展示)/显示主窗口/退出]。关窗口不退出,托盘退出才真正结束进程。
//! //!
//! - 主窗口 CloseRequested 被 lib.rs 拦截(prevent_close + hide),进程常驻托盘 //! - 主窗口 CloseRequested 被 lib.rs 拦截(prevent_close + hide),进程常驻托盘
//! - 托盘菜单「显示主窗口」唤起 / 「退出」app.exit(0) 真正结束 //! - 托盘菜单「显示主窗口」唤起 / 「退出」app.exit(0) 真正结束
//! - TrayHandle 经 app.manage 保活,防托盘图标被 GC 移除 //! - TrayHandle 经 app.manage 保活,防托盘图标被 GC 移除
//! - P1 bg 化:运行态菜单项 + tooltip 随 ConvState 迁移刷新(guard.transition 挂钩 update_tray_runtime)
use tauri::{ use tauri::{
AppHandle, Manager, AppHandle, Manager,
menu::{Menu, MenuItem}, menu::{Menu, MenuItem},
tray::TrayIconBuilder, tray::TrayIconBuilder,
}; };
/// managed state 保活,防 GC 移除托盘图标。 use crate::state::AppState;
/// 字段不读仅用于持有 TrayIcon(保活);零调用方≠垃圾,预留保留(对齐 state.rs releases 标法)。
#[allow(dead_code)]
pub struct TrayHandle(pub tauri::tray::TrayIcon);
/// 创建托盘:图标 + 菜单(显示主窗口/退出)。 /// managed state 保活,防 GC 移除托盘图标。
/// tray 持 TrayIcon 本体(保活为主,update_tray_runtime 借用刷 tooltip);
/// status 为运行态纯展示项句柄,供 update_tray_runtime 刷文本。
pub struct TrayHandle {
/// TrayIcon 本体,主要职责是经 manage 保活防 GC。
pub tray: tauri::tray::TrayIcon,
/// 运行态菜单项(enabled=false 纯展示),文本由 update_tray_runtime 随会话运行数刷新。
pub status: MenuItem<tauri::Wry>,
}
/// 创建托盘:图标 + 菜单(运行态/显示主窗口/退出)。
pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> { pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
// 运行态纯展示项:enabled=false 置灰不响应点击,文本由 update_tray_runtime 维护
let status = MenuItem::with_id(app, "status", "无运行中会话", false, None::<&str>)?;
let show = MenuItem::with_id(app, "show", "显示主窗口", true, None::<&str>)?; let show = MenuItem::with_id(app, "show", "显示主窗口", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &quit])?; let menu = Menu::with_items(app, &[&status, &show, &quit])?;
// 复用窗口图标(tauri.conf.json bundle.icon 已配置),缺则 panic 提示配置 // 复用窗口图标(tauri.conf.json bundle.icon 已配置),缺则 panic 提示配置
let icon = app let icon = app
.default_window_icon() .default_window_icon()
@@ -35,10 +45,36 @@ pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
_ => {} _ => {}
}) })
.build(app)?; .build(app)?;
app.manage(TrayHandle(tray)); app.manage(TrayHandle { tray, status });
Ok(()) Ok(())
} }
/// 同步托盘运行态:状态项文本 + tooltip 反映当前运行中 AI 会话数。
///
/// 挂钩点:guard.rs GeneratingGuard::transition(每次 ConvState 迁移必经且已持 app_handle)。
/// set_text/set_tooltip 均为同步 API,本函数保持同步,同步上下文直调不引入 await。
/// AppState / TrayHandle 任一未注入(setup 早期)静默跳过。
pub fn update_tray_runtime(app: &AppHandle) {
let (Some(handle), Some(state)) =
(app.try_state::<TrayHandle>(), app.try_state::<AppState>())
else {
return;
};
let n = state.conv_states.active_convs().len();
let text = if n > 0 {
format!("运行中 {} 个会话", n)
} else {
"无运行中会话".to_string()
};
let tooltip = if n > 0 {
format!("DevFlow (运行中 {} 个会话)", n)
} else {
"DevFlow (MCP server running)".to_string()
};
let _ = handle.status.set_text(text);
let _ = handle.tray.set_tooltip(Some(tooltip));
}
/// 唤起主窗口:show + unminimize + set_focus(单实例二次启动 / 托盘菜单共用)。 /// 唤起主窗口:show + unminimize + set_focus(单实例二次启动 / 托盘菜单共用)。
pub(crate) fn show_main(app: &AppHandle) { pub(crate) fn show_main(app: &AppHandle) {
if let Some(w) = app.get_webview_window("main") { if let Some(w) = app.get_webview_window("main") {
+31
View File
@@ -34,6 +34,12 @@
<div v-if="errorMsg" class="df-toast">⚠ {{ errorMsg }}</div> <div v-if="errorMsg" class="df-toast">⚠ {{ errorMsg }}</div>
</transition> </transition>
<!-- 关窗后台运行气泡(主窗关闭被拦截且仍有会话运行时,后端 emit tray-hide-notice):
告知会话转入后台继续生成、托盘图标可随时唤起,复用 df-toast 形态 -->
<transition name="toast">
<div v-if="trayNoticeMsg" class="df-toast df-toast--info">{{ trayNoticeMsg }}</div>
</transition>
<!-- 审批待办浮层(应用内右上角,窗口聚焦/可见时作快速提示)。 <!-- 审批待办浮层(应用内右上角,窗口聚焦/可见时作快速提示)。
仅在主窗口与 ai-detached 窗口挂载(file-explorer / approval-popup 自身不挂)。 仅在主窗口与 ai-detached 窗口挂载(file-explorer / approval-popup 自身不挂)。
应用失焦/最小化时的系统级提示由 useApprovalPopup 启动的独立浮窗(approval-popup)负责。 --> 应用失焦/最小化时的系统级提示由 useApprovalPopup 启动的独立浮窗(approval-popup)负责。 -->
@@ -101,6 +107,12 @@ watch(() => projectStore.error, (err) => {
errorTimer = setTimeout(() => { errorMsg.value = '' }, 3500) errorTimer = setTimeout(() => { errorMsg.value = '' }, 3500)
}) })
// ── 关窗后台运行气泡(主窗关闭被拦截且仍有会话运行时,后端 emit tray-hide-notice)──
// 与错误 toast 同款 df-toast 形态,信息语义走 --info 变体底色,不与错误红混用
const trayNoticeMsg = ref('')
let trayNoticeTimer: ReturnType<typeof setTimeout> | null = null
let _unlistenTrayHideNotice: (() => void) | null = null
// ── 人工审批对话框(全局,工作流推进到 HumanNode 审批时,任意页面都应能响应)── // ── 人工审批对话框(全局,工作流推进到 HumanNode 审批时,任意页面都应能响应)──
// 原 ProjectDetail 局部渲染 → TaskDetail 无 ApprovalDialog 致 store.pendingApproval 被设但 // 原 ProjectDetail 局部渲染 → TaskDetail 无 ApprovalDialog 致 store.pendingApproval 被设但
// 用户在 TaskDetail 页无法操作 → HumanNode 超时失败。提到 App.vue 全局化:任何路由响应审批。 // 用户在 TaskDetail 页无法操作 → HumanNode 超时失败。提到 App.vue 全局化:任何路由响应审批。
@@ -208,6 +220,20 @@ onMounted(async () => {
} catch (e) { } catch (e) {
console.error('启动数据变更监听失败:', e) console.error('启动数据变更监听失败:', e)
} }
// 关窗后台运行提示:主窗关闭被拦截且有会话运行时,后端 emit tray-hide-notice → 全局气泡告知。
// 包 try/catch 防 Tauri listen 失败 reject onMounted(对齐 startDataChangedListener 模式)
try {
const { listen } = await import('@tauri-apps/api/event')
_unlistenTrayHideNotice = await listen<{ running: number }>('tray-hide-notice', (e) => {
const running = e.payload?.running ?? 0
if (running <= 0) return
trayNoticeMsg.value = `${running} 个会话将继续在后台运行,托盘图标可随时唤起`
if (trayNoticeTimer) clearTimeout(trayNoticeTimer)
trayNoticeTimer = setTimeout(() => { trayNoticeMsg.value = '' }, 3500)
})
} catch (e) {
console.error('关窗后台运行提示监听失败:', e)
}
// 系统级审批浮窗:仅主窗口启动(approval-popup 自身路由不启动,避免重复 watch)。 // 系统级审批浮窗:仅主窗口启动(approval-popup 自身路由不启动,避免重复 watch)。
// 设计见 useApprovalPopup.ts:主窗口 watch pendingApprovals → 弹/更新/关浮窗(单一真相源)。 // 设计见 useApprovalPopup.ts:主窗口 watch pendingApprovals → 弹/更新/关浮窗(单一真相源)。
if (route.path !== '/approval-popup') { if (route.path !== '/approval-popup') {
@@ -274,6 +300,9 @@ onUnmounted(() => {
_unlistenPopupReady?.() _unlistenPopupReady?.()
_stopApprovalPopup = null _stopApprovalPopup = null
_unlistenPopupReady = null _unlistenPopupReady = null
_unlistenTrayHideNotice?.()
_unlistenTrayHideNotice = null
if (trayNoticeTimer) clearTimeout(trayNoticeTimer)
}) })
</script> </script>
@@ -294,4 +323,6 @@ onUnmounted(() => {
} }
.toast-enter-active, .toast-leave-active { transition: all 0.25s var(--df-ease); } .toast-enter-active, .toast-leave-active { transition: all 0.25s var(--df-ease); }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translate(-50%, 10px); } .toast-enter-from, .toast-leave-to { opacity: 0; transform: translate(-50%, 10px); }
/* 后台运行提示:同 df-toast 形态,信息语义用 accent 底色与错误红区分 */
.df-toast--info { background: var(--df-accent); }
</style> </style>
+10 -5
View File
@@ -103,8 +103,8 @@ export const aiApi = {
/** 查询某对话积压的待审批工具(重启后恢复 toolCard pending_approval 态用)。 /** 查询某对话积压的待审批工具(重启后恢复 toolCard pending_approval 态用)。
* 返 kind 字段(risk/path),前端按 kind 渲染不同决策按钮。 */ * 返 kind 字段(risk/path),前端按 kind 渲染不同决策按钮。 */
pendingToolCalls(convId: string): Promise<{ tool_call_id: string; conversation_id: string | null; kind: 'risk' | 'path' }[]> { pendingToolCalls(conversationId: string): Promise<{ tool_call_id: string; conversation_id: string | null; kind: 'risk' | 'path' }[]> {
return invoke('ai_pending_tool_calls', { convId }) return invoke('ai_pending_tool_calls', { conversationId })
}, },
/** 清空对话 */ /** 清空对话 */
@@ -251,9 +251,14 @@ export const aiApi = {
return invoke('ai_set_agent_max_retries', { value }) return invoke('ai_set_agent_max_retries', { value })
}, },
/** 列出本机 Claude 技能(skills + commands + plugins) */ /**
listSkills(): Promise<SkillInfo[]> { * 列出本机 Claude 技能(project + skills + commands + plugins)。
return invoke('ai_list_skills') * @param projectPath 当前项目根目录(可选)。传入时后端额外扫项目级
* `.claude/skills` 各技能目录下的 SKILL.md(source="project",同名覆盖 home 级);
* 不传沿用已设项目根(无则仅 home 级)。
*/
listSkills(projectPath?: string): Promise<SkillInfo[]> {
return invoke('ai_list_skills', { projectPath: projectPath ?? null })
}, },
/** /**
+1 -1
View File
@@ -38,6 +38,6 @@ export const settingsApi = {
/** 读取多 ReAct Plan 执行开关 */ /** 读取多 ReAct Plan 执行开关 */
getPlanExecution(): Promise<boolean> { getPlanExecution(): Promise<boolean> {
return invoke<boolean>('get_plan_execution') return invoke<boolean>('is_plan_execution_enabled')
}, },
} }
+18 -1
View File
@@ -418,6 +418,10 @@ export type AiChatEvent = ({
// AE-2025-04 会话级信任:同会话已批准过同类操作(同工具+同目录)自动放行,前端显示轻量 toast。 // AE-2025-04 会话级信任:同会话已批准过同类操作(同工具+同目录)自动放行,前端显示轻量 toast。
// 不写消息/不动 pending(Started/Completed 仍独立发),仅提示性事件。 // 不写消息/不动 pending(Started/Completed 仍独立发),仅提示性事件。
type: 'AiToolAutoApproved'; id: string; tool: string; dir: string type: 'AiToolAutoApproved'; id: string; tool: string; dir: string
} | {
// 技能注入反馈闭环:/ 技能 resolve 成功(ok=true)/失败(ok=false,未找到或读失败)通知,
// 前端轻 toast(成功「已注入技能《name》」/失败「技能 name 未找到」)。仅提示,不改消息流。
type: 'AiSkillInjected'; name: string; ok: boolean
} | { } | {
// path_auth 审批链阶段3b(统一审批模型):AiApprovalRequired 携带 kind 字段区分 // path_auth 审批链阶段3b(统一审批模型):AiApprovalRequired 携带 kind 字段区分
// 'path'(F-260619-03 Phase B 路径授权挂起,走 once/session/always/deny)vs 'risk'(普通 RiskLevel 审批, // 'path'(F-260619-03 Phase B 路径授权挂起,走 once/session/always/deny)vs 'risk'(普通 RiskLevel 审批,
@@ -523,7 +527,20 @@ export interface AiMessage {
id: MessageId id: MessageId
role: 'user' | 'assistant' | 'tool' | 'system' role: 'user' | 'assistant' | 'tool' | 'system'
content: string content: string
/**
* 推理过程文本(DeepSeek reasoning_content / Anthropic thinking 同通道,后端落库
* ai_messages.reasoning_content)。undefined=该轮无思考内容。历史消息 reload 时
* 由 useAiConversations parseConvMessages 透传;思考 pill 以此为数据源,content 兜底。
*/
reasoning_content?: string
isError?: boolean isError?: boolean
/**
* 流式重试过渡标记(AiStreamRetry 推送的过渡气泡,前端内存专用不落库)。
* true=重试进行中(MessageList 渲染为琥珀单行细条,不走错误红卡);终态错误
* (AiError)不带此标记,仍走错误卡与重试按钮。会话收尾清残留按此标记匹配,
* 不再依赖文案子串(多语言切换不影响清理)。
*/
isRetry?: boolean
/** /**
* 消息上下文状态(F-15 手动上下文管理)。 * 消息上下文状态(F-15 手动上下文管理)。
* null | undefined | 'active' → 正常消息 * null | undefined | 'active' → 正常消息
@@ -772,7 +789,7 @@ export interface SkillInfo {
name: string name: string
description: string description: string
argument_hint?: string argument_hint?: string
/** skill | command | plugin */ /** skill | project | command | plugin(project 为项目级 .claude/skills 扫描来源) */
source: string source: string
/** SKILL.md 绝对路径(后端注入时读全文) */ /** SKILL.md 绝对路径(后端注入时读全文) */
path: string path: string
+13
View File
@@ -195,6 +195,8 @@ let _unlistenToolSlow: (() => void) | null = null
let _unlistenAutoApproved: (() => void) | null = null let _unlistenAutoApproved: (() => void) | null = null
// P1-e-d: 审批失败 toast unlistener(useAiApproval 审批 IPC 未送达经事件总线 emit,同 _unlistenToolSlow 模式) // P1-e-d: 审批失败 toast unlistener(useAiApproval 审批 IPC 未送达经事件总线 emit,同 _unlistenToolSlow 模式)
let _unlistenAiToast: (() => void) | null = null let _unlistenAiToast: (() => void) | null = null
// 技能注入反馈 toast unlistener(后端 AiSkillInjected 经 ai-chat-event 直达,见 onMounted)
let _unlistenSkillInjected: (() => void) | null = null
// 第二批抽离: inputText/inputEl/图片输入/技能/实体联想/handleSend 已迁移至 ChatInput.vue // 第二批抽离: inputText/inputEl/图片输入/技能/实体联想/handleSend 已迁移至 ChatInput.vue
// chatInputRef: 父调 expose(focus/startEditFocus/clearInput/hasUnsentInput/sendExamplePrompt) // chatInputRef: 父调 expose(focus/startEditFocus/clearInput/hasUnsentInput/sendExamplePrompt)
@@ -477,6 +479,7 @@ onBeforeUnmount(() => {
if (_unlistenToolSlow) { _unlistenToolSlow(); _unlistenToolSlow = null } // B-260616-12 if (_unlistenToolSlow) { _unlistenToolSlow(); _unlistenToolSlow = null } // B-260616-12
if (_unlistenAutoApproved) { _unlistenAutoApproved(); _unlistenAutoApproved = null } // AE-2025-04 if (_unlistenAutoApproved) { _unlistenAutoApproved(); _unlistenAutoApproved = null } // AE-2025-04
if (_unlistenAiToast) { _unlistenAiToast(); _unlistenAiToast = null } // P1-e-d if (_unlistenAiToast) { _unlistenAiToast(); _unlistenAiToast = null } // P1-e-d
if (_unlistenSkillInjected) { _unlistenSkillInjected(); _unlistenSkillInjected = null } // 技能注入反馈 toast
// toast timer 清理由 useToast composable 的 onUnmounted 自动管理 // toast timer 清理由 useToast composable 的 onUnmounted 自动管理
}) })
@@ -520,6 +523,16 @@ onMounted(async () => {
_unlistenAiToast = await listen<{ msg: string; type: 'info' | 'error' | 'warning' | 'success' }>('ai-toast', (e) => { _unlistenAiToast = await listen<{ msg: string; type: 'info' | 'error' | 'warning' | 'success' }>('ai-toast', (e) => {
showToast(e.payload.msg, e.payload.type) showToast(e.payload.msg, e.payload.type)
}) })
// 技能注入反馈闭环(注入成功/失败轻 toast):后端 resolve_and_inject 双写 ai-chat-event,
// 此处独立 listen 过滤 AiSkillInjected(不动 useAiEvents 分发器,toast 归组件本地副作用)。
_unlistenSkillInjected = await listen<{ name: string; ok: boolean }>('ai-chat-event', (e) => {
const p = e.payload as { type?: string; name?: string; ok?: boolean }
if (p?.type !== 'AiSkillInjected' || !p.name) return
showToast(
p.ok ? t('aiChat.skillInjectedToast', { name: p.name }) : t('aiChat.skillNotFoundToast', { name: p.name }),
p.ok ? 'success' : 'warning',
)
})
// F-15 阶段2: 注册上下文管理事件监听器(清空/压缩生命周期事件 → toast/刷新) // F-15 阶段2: 注册上下文管理事件监听器(清空/压缩生命周期事件 → toast/刷新)
await initContextEventListeners() await initContextEventListeners()
// 恢复生成中态(刷新后/分离窗口接管): // 恢复生成中态(刷新后/分离窗口接管):
+54 -9
View File
@@ -14,10 +14,10 @@
<div class="ai-tool-header-text"> <div class="ai-tool-header-text">
<span class="ai-tool-name">{{ toolDisplayName(tc) }}</span> <span class="ai-tool-name">{{ toolDisplayName(tc) }}</span>
<span v-if="tc.status === 'running'" class="ai-tool-sub">{{ $t('aiTool.running') }}</span> <span v-if="tc.status === 'running'" class="ai-tool-sub">{{ $t('aiTool.running') }}</span>
<span v-else-if="isFailed" class="ai-tool-sub ai-tool-sub--failed">{{ $t('aiTool.executionFailed') }}</span> <span v-else-if="isFailed" class="ai-tool-sub ai-tool-sub--failed" :title="summaryTitle">{{ $t('aiTool.executionFailed') }}</span>
<span v-else-if="toolResultSummary(tc)" class="ai-tool-sub">{{ toolResultSummary(tc) }}</span> <span v-else-if="toolResultSummary(tc)" class="ai-tool-sub" :title="summaryTitle">{{ toolResultSummary(tc) }}</span>
</div> </div>
<span class="ai-tool-status-dot" :class="'ai-tool-status-dot--' + (isFailed ? 'failed' : tc.status)"></span> <span class="ai-tool-status-dot" :class="'ai-tool-status-dot--' + (isFailed ? 'failed' : tc.status)" :title="statusTitle"></span>
<!-- 统一卡形态第4要素:耗时(前端 startedAt/completedAt 差;历史消息无时间戳隐藏不显 0)。 <!-- 统一卡形态第4要素:耗时(前端 startedAt/completedAt 差;历史消息无时间戳隐藏不显 0)。
hover 显绝对起止时刻,相对时间不再占卡片行(B 聚合卡统一)。 --> hover 显绝对起止时刻,相对时间不再占卡片行(B 聚合卡统一)。 -->
<span <span
@@ -144,7 +144,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, toRef } from 'vue' import { computed, ref, toRef, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { import {
shouldKeepOpen, shouldKeepOpen,
@@ -152,12 +152,14 @@ import {
toolCategory, toolCategory,
toolIcon, toolIcon,
toolResultSummary, toolResultSummary,
argString,
} from '@/composables/ai/useToolCard' } from '@/composables/ai/useToolCard'
import { parseDiffLines } from '@/composables/ai/useToolCardRender' import { parseDiffLines } from '@/composables/ai/useToolCardRender'
import { useToolCardHeader } from '@/composables/ai/useToolCardHeader' import { useToolCardHeader } from '@/composables/ai/useToolCardHeader'
import { useToolApproval } from '@/composables/ai/useToolApproval' import { useToolApproval } from '@/composables/ai/useToolApproval'
import { useTimerOwnership } from '@/composables/useTimerOwnership'
import { callDurationText } from '@/composables/ai/useTurnTrace' import { callDurationText } from '@/composables/ai/useTurnTrace'
import { formatDate } from '@/utils/time' import { formatDate, formatDuration } from '@/utils/time'
import type { AiToolCallInfo } from '@/api/types' import type { AiToolCallInfo } from '@/api/types'
import ConfirmDialog from './ConfirmDialog.vue' import ConfirmDialog from './ConfirmDialog.vue'
import ToolResultBody from './ToolResultBody.vue' import ToolResultBody from './ToolResultBody.vue'
@@ -241,14 +243,57 @@ const isFailed = computed(() => {
/** write_file 审批 diff 行解析(复用 parseDiffLines 纯函数,数据源 props.tc.diff) */ /** write_file 审批 diff 行解析(复用 parseDiffLines 纯函数,数据源 props.tc.diff) */
const diffLines = computed(() => parseDiffLines(props.tc.diff)) const diffLines = computed(() => parseDiffLines(props.tc.diff))
/** 单卡耗时(前端时间差;历史消息无 startedAt/completedAt 返空串隐藏) */ // 执行中实时走表:进入 running 每秒刷新 nowTick,离开时清理(定时器经 useTimerOwnership
const durationText = computed(() => callDurationText(props.tc.startedAt, props.tc.completedAt)) // 注册,onUnmounted 自动全清,防组件卸载后计时器仍触发)
/** hover 提示:绝对起止时刻(相对时长 hover 可溯具体时间) */ const { setOwnInterval, clearOwnInterval } = useTimerOwnership()
const nowTick = ref(Date.now())
let runTimerId: ReturnType<typeof setInterval> | null = null
watch(() => props.tc.status, (s) => {
if (s === 'running') {
nowTick.value = Date.now()
if (runTimerId === null) {
runTimerId = setOwnInterval(() => { nowTick.value = Date.now() }, 1000)
}
} else if (runTimerId !== null) {
clearOwnInterval(runTimerId)
runTimerId = null
}
}, { immediate: true })
/** 单卡耗时(前端时间差;历史消息无 startedAt/completedAt 返空串隐藏);
* running 态以 nowTick 实时走表(需 startedAt,与完成态共用同一时间差口径) */
const durationText = computed(() => {
const tc = props.tc
if (tc.status === 'running' && tc.startedAt != null) {
return formatDuration(nowTick.value - tc.startedAt, true)
}
return callDurationText(tc.startedAt, tc.completedAt)
})
/** hover 提示:后端实测毫秒 + 绝对起止时刻(run_command 取 duration_ms、http_request 取
* elapsed_ms,与前端时间差口径互证;无后端耗时的工具只显起止时刻) */
const durationTitle = computed(() => { const durationTitle = computed(() => {
const tc = props.tc const tc = props.tc
if (tc.startedAt == null || tc.completedAt == null) return '' if (tc.startedAt == null || tc.completedAt == null) return ''
return `${formatDate(tc.startedAt)} ~ ${formatDate(tc.completedAt)}` const r = parsed.value
const backendMs = tc.name === 'run_command'
? (typeof r?.duration_ms === 'number' ? r.duration_ms : null)
: (tc.name === 'http_request' && typeof r?.elapsed_ms === 'number' ? r.elapsed_ms : null)
const span = `${formatDate(tc.startedAt)} ~ ${formatDate(tc.completedAt)}`
return backendMs != null ? `${backendMs}ms · ${span}` : span
}) })
/** 状态点 tooltip:成功/失败/进行中/待审批/已拒绝(绿红蓝点 hover 可读,不再靠颜色猜) */
const statusTitle = computed(() => {
const s = props.tc.status
if (s === 'running') return t('aiTool.statusRunning')
if (s === 'pending_approval') return t('aiTool.statusPending')
if (s === 'rejected') return t('aiTool.statusRejected')
if (s === 'completed') return isFailed.value ? t('aiTool.statusFailed') : t('aiTool.statusOk')
return ''
})
/** 头部摘要 hover 回看完整路径(摘要统一缩略尾段,全路径走 title;路径类工具才有值) */
const summaryTitle = computed(() => argString(props.tc.args, 'path'))
</script> </script>
<style scoped> <style scoped>
+4 -90
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="ai-tool-calls" ref="rootEl"> <div class="ai-tool-calls" ref="rootEl">
<!-- 顶部操作栏:批量审批(全局收起已下沉到首个可折叠分组标题行,U-260618) --> <!-- 顶部操作栏:批量审批(轨迹卡 toggle 已覆盖整体展开/收起,分组行仅单组导航) -->
<div v-if="pendingCount > 0" class="ai-tool-topbar"> <div v-if="pendingCount > 0" class="ai-tool-topbar">
<!-- 批量操作栏(存在待审批项时显示) --> <!-- 批量操作栏(存在待审批项时显示) -->
<div class="ai-batch-approve"> <div class="ai-batch-approve">
@@ -14,22 +14,12 @@
</div> </div>
<template v-for="(group, gi) in groupedToolCalls" :key="group.name"> <template v-for="(group, gi) in groupedToolCalls" :key="group.name">
<!-- 分组标题:同名工具 >=2 个时显示(B 聚合卡统一:默认行「N 次调用 · 成功 x 失败 y」, <!-- 分组标题:同名工具 >=2 个时显示(仅分组导航语义:点开逐条。
点开逐条;成功/失败与状态点同一套 isToolFailure 判定,文案走 ai.trace 同源 key) --> 调用统计收敛到执行轨迹卡头部一处,分组行不再重复计数) -->
<div v-if="group.calls.length >= 2" class="ai-tool-group-header" @click="toggleGroupCollapse(gi)"> <div v-if="group.calls.length >= 2" class="ai-tool-group-header" @click="toggleGroupCollapse(gi)">
<span class="ai-tool-group-icon" v-html="groupIcon(group.name)"></span> <span class="ai-tool-group-icon" v-html="groupIcon(group.name)"></span>
<span class="ai-tool-group-name">{{ groupDisplayName(group) }}</span> <span class="ai-tool-group-name">{{ groupDisplayName(group) }}</span>
<span class="ai-tool-group-agg">{{ groupAggText(group) }}</span>
<span class="ai-tool-group-chevron" :class="{ 'is-open': !isGroupCollapsed(gi) }">▸</span> <span class="ai-tool-group-chevron" :class="{ 'is-open': !isGroupCollapsed(gi) }">▸</span>
<!-- 全部展开/收起(U-260618:从 topbar 下沉到首个可折叠分组标题行尾巴,与 run command 同行) -->
<div
v-if="gi === firstCollapsibleGi"
class="ai-tool-global-toggle"
@click.stop="toggleAllGroups"
>
<span class="ai-tool-global-toggle-icon">{{ allGroupsExpanded ? '▾' : '▸' }}</span>
<span>{{ allGroupsExpanded ? $t('aiTool.collapseAll') : $t('aiTool.expandAll') }}</span>
</div>
</div> </div>
<ToolCard <ToolCard
v-for="tc in group.calls" v-for="tc in group.calls"
@@ -52,7 +42,6 @@ import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import ToolCard from './ToolCard.vue' import ToolCard from './ToolCard.vue'
import { listen, type UnlistenFn } from '@tauri-apps/api/event' import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { aggregateCalls, aggText } from '@/composables/ai/useTurnTrace'
import type { AiToolCallInfo } from '@/api/types' import type { AiToolCallInfo } from '@/api/types'
// useI18n needed for $t() in template (collapseAll / expandAll) + groupDisplayName(i18n key) // useI18n needed for $t() in template (collapseAll / expandAll) + groupDisplayName(i18n key)
@@ -124,32 +113,11 @@ const groupedToolCalls = computed<ToolCallGroup[]>(() => {
return groups return groups
}) })
/** 可折叠分组数(calls >= 2 的组),控制全局按钮显隐 */
const collapsibleGroupCount = computed(() =>
groupedToolCalls.value.filter(g => g.calls.length >= 2).length
)
/** 首个可折叠分组索引全部展开/收起按钮下沉到此分组标题行尾巴) */
const firstCollapsibleGi = computed(() => {
for (let i = 0; i < groupedToolCalls.value.length; i++) {
if (groupedToolCalls.value[i].calls.length >= 2) return i
}
return -1
})
/** 待审批工具数(status === 'pending_approval'),控制批量操作栏显隐 */ /** 待审批工具数(status === 'pending_approval'),控制批量操作栏显隐 */
const pendingCount = computed(() => const pendingCount = computed(() =>
props.toolCalls.filter(tc => tc.status === 'pending_approval').length props.toolCalls.filter(tc => tc.status === 'pending_approval').length
) )
/** 是否所有可折叠分组都已展开 */
const allGroupsExpanded = computed(() => {
if (collapsibleGroupCount.value === 0) return true
return groupedToolCalls.value.every((g, gi) =>
g.calls.length < 2 || !collapsedGroups.value.has(gi)
)
})
/** /**
* 默认收起补齐:达到多卡阈值(>=2)且用户未手动操作过的组自动收起。 * 默认收起补齐:达到多卡阈值(>=2)且用户未手动操作过的组自动收起。
* 响应式而非一次性初始化——流式场景第一张卡到达即触发旧一次性逻辑, * 响应式而非一次性初始化——流式场景第一张卡到达即触发旧一次性逻辑,
@@ -181,24 +149,6 @@ function toggleGroupCollapse(gi: number): void {
collapsedGroups.value = next collapsedGroups.value = next
} }
/** 全部展开 / 全部收起切换 */
function toggleAllGroups(): void {
const next = new Set<number>()
// 全部记 touched:批量操作针对当前可见组,流式新增组仍走默认收起,
// 但已有组不得被补齐逻辑收回(否则"全部展开"点完即被回退)
for (const g of groupedToolCalls.value) {
if (g.calls.length >= 2) userTouchedGroups.value.add(g.name)
}
if (allGroupsExpanded.value) {
// 当前全展开 → 全部收起
for (let i = 0; i < groupedToolCalls.value.length; i++) {
if (groupedToolCalls.value[i].calls.length >= 2) next.add(i)
}
}
// 当前非全展开 → 全部展开(next 保持空 Set)
collapsedGroups.value = next
}
/** /**
* 分组显示名:工具名 → i18n 友好标签(英文化,原硬编码中文)。 * 分组显示名:工具名 → i18n 友好标签(英文化,原硬编码中文)。
* 用 aiTool.toolGroup.* 子键查表;未命中的工具回退 _ → 空格风格。 * 用 aiTool.toolGroup.* 子键查表;未命中的工具回退 _ → 空格风格。
@@ -228,11 +178,6 @@ function groupDisplayName(group: ToolCallGroup): string {
return group.name.replace(/_/g, ' ') return group.name.replace(/_/g, ' ')
} }
/** 分组聚合行文案:「N 次调用 · 成功 x 失败 y」(零值项省略;与单卡状态点同源 isToolFailure 判定) */
function groupAggText(group: ToolCallGroup): string {
return aggText(aggregateCalls(group.calls))
}
/** /**
* 单卡是否展开: * 单卡是否展开:
* - 分组已折叠 → false(整组隐藏) * - 分组已折叠 → false(整组隐藏)
@@ -414,28 +359,6 @@ function groupIcon(name: string): string {
} }
.ai-btn--danger:hover { filter: brightness(1.1); } .ai-btn--danger:hover { filter: brightness(1.1); }
/* -- 全部展开/收起从 topbar 下沉到分组标题行,移除独立按钮 padding/背景,
高度=文字行高,不撑高分组标题行;融入标题视觉,仅 hover 变色) -- */
.ai-tool-global-toggle {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
user-select: none;
font-size: 11px;
color: var(--df-text-dim);
transition: color 0.15s var(--df-ease);
/* 推到分组标题行尾巴右对齐 */
margin-left: auto;
}
.ai-tool-global-toggle:hover {
color: var(--df-text-secondary);
}
.ai-tool-global-toggle-icon {
font-size: 10px;
transition: transform 0.15s var(--df-ease);
}
/* -- 分组标题 -- */ /* -- 分组标题 -- */
.ai-tool-group-header { .ai-tool-group-header {
display: flex; display: flex;
@@ -464,17 +387,8 @@ function groupIcon(name: string): string {
font-weight: 500; font-weight: 500;
color: var(--df-text-secondary); color: var(--df-text-secondary);
} }
/* 聚合行「N 次调用 · 成功 x 失败 y」:弱化等宽,与单卡耗时同视觉层级 */
.ai-tool-group-agg {
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-tool-group-chevron { .ai-tool-group-chevron {
/* 移除 margin-left:auto,让全部展开/收起按钮(global-toggle)占据行尾右对齐 */ margin-left: auto;
font-size: 10px; font-size: 10px;
color: var(--df-text-dim); color: var(--df-text-dim);
transition: transform 0.15s var(--df-ease); transition: transform 0.15s var(--df-ease);
+7 -6
View File
@@ -6,7 +6,7 @@
<div v-if="tc.name === 'read_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-file-content"> <div v-if="tc.name === 'read_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-file-content">
<div class="ai-tool-file-bar"> <div class="ai-tool-file-bar">
<span class="ai-tool-file-icon" v-html="fileIcon"></span> <span class="ai-tool-file-icon" v-html="fileIcon"></span>
<span class="ai-tool-file-path">{{ parsed?.path }}</span> <span class="ai-tool-file-path" :title="parsed?.path">{{ shortPath(parsed?.path || '') }}</span>
<!-- BUG-260625-02: search 模式返回 matches(命中行)非 content, <!-- BUG-260625-02: search 模式返回 matches(命中行)非 content,
旧模板取 lines/returned_lines 显「0 行」+ 空 content 区,用户误以为读取失败。 旧模板取 lines/returned_lines 显「0 行」+ 空 content 区,用户误以为读取失败。
search 模式显搜索词 + 命中数(复用 grepHitsN 文案);默认模式显行数/大小 + 展开。 --> search 模式显搜索词 + 命中数(复用 grepHitsN 文案);默认模式显行数/大小 + 展开。 -->
@@ -36,7 +36,7 @@
<div v-else-if="tc.name === 'list_directory' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-dir-list"> <div v-else-if="tc.name === 'list_directory' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-dir-list">
<div class="ai-tool-file-bar"> <div class="ai-tool-file-bar">
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span> <span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
<span class="ai-tool-file-path">{{ parsed?.path }}</span> <span class="ai-tool-file-path" :title="parsed?.path">{{ shortPath(parsed?.path || '') }}</span>
<span class="ai-tool-file-meta">{{ parsed?.entries?.length || 0 }} {{ $t('aiTool.items') }}</span> <span class="ai-tool-file-meta">{{ parsed?.entries?.length || 0 }} {{ $t('aiTool.items') }}</span>
<span v-if="parsed?.truncated" class="ai-tool-file-meta ai-tool-file-meta--warn">{{ $t('aiTool.dirTruncated', { n: DIR_TRUNCATE_LIMIT }) }}</span> <span v-if="parsed?.truncated" class="ai-tool-file-meta ai-tool-file-meta--warn">{{ $t('aiTool.dirTruncated', { n: DIR_TRUNCATE_LIMIT }) }}</span>
</div> </div>
@@ -59,7 +59,7 @@
<div v-else-if="tc.name === 'search_files' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-dir-list"> <div v-else-if="tc.name === 'search_files' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-dir-list">
<div class="ai-tool-file-bar"> <div class="ai-tool-file-bar">
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span> <span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
<span class="ai-tool-file-path">{{ parsed?.path }}</span> <span class="ai-tool-file-path" :title="parsed?.path">{{ shortPath(parsed?.path || '') }}</span>
<span v-if="parsed?.pattern" class="ai-tool-file-meta">「{{ parsed?.pattern }}」</span> <span v-if="parsed?.pattern" class="ai-tool-file-meta">「{{ parsed?.pattern }}」</span>
<span class="ai-tool-file-meta">{{ $t('aiTool.foundN', { n: typeof parsed?.total === 'number' ? parsed.total : (parsed?.results?.length || 0) }) }}</span> <span class="ai-tool-file-meta">{{ $t('aiTool.foundN', { n: typeof parsed?.total === 'number' ? parsed.total : (parsed?.results?.length || 0) }) }}</span>
</div> </div>
@@ -77,7 +77,7 @@
<div v-else-if="tc.name === 'grep' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-grep-result"> <div v-else-if="tc.name === 'grep' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-grep-result">
<div class="ai-tool-file-bar"> <div class="ai-tool-file-bar">
<span class="ai-tool-file-icon" v-html="searchIcon"></span> <span class="ai-tool-file-icon" v-html="searchIcon"></span>
<span class="ai-tool-file-path">{{ parsed?.path }}</span> <span class="ai-tool-file-path" :title="parsed?.path">{{ shortPath(parsed?.path || '') }}</span>
<span v-if="parsed?.pattern" class="ai-tool-file-meta">「{{ parsed?.pattern }}」</span> <span v-if="parsed?.pattern" class="ai-tool-file-meta">「{{ parsed?.pattern }}」</span>
<span class="ai-tool-file-meta">{{ grepHitLabel }}</span> <span class="ai-tool-file-meta">{{ grepHitLabel }}</span>
<span v-if="parsed?.truncated" class="ai-tool-file-meta ai-tool-file-meta--warn">{{ $t('aiTool.grepTruncated') }}</span> <span v-if="parsed?.truncated" class="ai-tool-file-meta ai-tool-file-meta--warn">{{ $t('aiTool.grepTruncated') }}</span>
@@ -114,7 +114,7 @@
<!-- write_file 结果 --> <!-- write_file 结果 -->
<div v-else-if="tc.name === 'write_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-write-result"> <div v-else-if="tc.name === 'write_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-write-result">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--df-success)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--df-success)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<span class="ai-tool-write-path">{{ parsed?.path }}</span> <span class="ai-tool-write-path" :title="parsed?.path">{{ shortPath(parsed?.path || '') }}</span>
<span class="ai-tool-write-meta">{{ formatBytes(parsed?.bytes_written) }} {{ $t('aiTool.written') }}</span> <span class="ai-tool-write-meta">{{ formatBytes(parsed?.bytes_written) }} {{ $t('aiTool.written') }}</span>
<span v-if="typeof parsed?.old_size === 'number' && parsed.old_size > 0" class="ai-tool-write-meta">{{ $t('aiTool.writeOverwrite', { old: formatBytes(parsed.old_size), neww: formatBytes(parsed?.bytes_written) }) }}</span> <span v-if="typeof parsed?.old_size === 'number' && parsed.old_size > 0" class="ai-tool-write-meta">{{ $t('aiTool.writeOverwrite', { old: formatBytes(parsed.old_size), neww: formatBytes(parsed?.bytes_written) }) }}</span>
<span v-if="typeof parsed?.encoding === 'string' && parsed.encoding !== 'utf-8'" class="ai-tool-write-meta">{{ $t('aiTool.encodingLabel', { enc: parsed.encoding }) }}</span> <span v-if="typeof parsed?.encoding === 'string' && parsed.encoding !== 'utf-8'" class="ai-tool-write-meta">{{ $t('aiTool.encodingLabel', { enc: parsed.encoding }) }}</span>
@@ -125,7 +125,7 @@
<div v-else-if="tc.name === 'patch_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-patch-result"> <div v-else-if="tc.name === 'patch_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-patch-result">
<div class="ai-tool-file-bar"> <div class="ai-tool-file-bar">
<span class="ai-tool-file-icon" v-html="fileIcon"></span> <span class="ai-tool-file-icon" v-html="fileIcon"></span>
<span class="ai-tool-file-path">{{ parsed?.path }}</span> <span class="ai-tool-file-path" :title="parsed?.path">{{ shortPath(parsed?.path || '') }}</span>
<span class="ai-tool-file-meta">{{ formatSizeDiff(parsed?.size_diff) }} {{ $t('aiTool.bytesUnit') }}</span> <span class="ai-tool-file-meta">{{ formatSizeDiff(parsed?.size_diff) }} {{ $t('aiTool.bytesUnit') }}</span>
<span v-if="parsed?.matches_found && parsed.matches_found > 1" class="ai-tool-file-meta">{{ $t('aiTool.patchedMatches', { n: parsed.matches_found }) }}</span> <span v-if="parsed?.matches_found && parsed.matches_found > 1" class="ai-tool-file-meta">{{ $t('aiTool.patchedMatches', { n: parsed.matches_found }) }}</span>
</div> </div>
@@ -231,6 +231,7 @@ import {
combineOutputs, combineOutputs,
formatToolResult, formatToolResult,
formatGrep, formatGrep,
shortPath,
dirIcon, dirIcon,
fileIcon, fileIcon,
searchIcon, searchIcon,
+412 -63
View File
@@ -21,11 +21,11 @@ import { useMarkdown } from '../../composables/useMarkdown'
import { useMessageScroll } from '../../composables/ai/useMessageScroll' import { useMessageScroll } from '../../composables/ai/useMessageScroll'
import { useStreamRenderer } from '../../composables/ai/useStreamRenderer' import { useStreamRenderer } from '../../composables/ai/useStreamRenderer'
import { loadMoreHistory, getLoadMoreState } from '../../composables/ai/useAiConversations' import { loadMoreHistory, getLoadMoreState } from '../../composables/ai/useAiConversations'
import { collectTurnStats, aggText, type TurnCallStats } from '../../composables/ai/useTurnTrace' import { collectTurnStats, turnSummaryText, sumTurnTokens, formatTokensK, type TurnCallStats } from '../../composables/ai/useTurnTrace'
import ToolCardList from '../ToolCardList.vue' import ToolCardList from '../ToolCardList.vue'
import MessageItem from './MessageItem.vue' import MessageItem from './MessageItem.vue'
import EmptyState from './EmptyState.vue' import EmptyState from './EmptyState.vue'
import { formatRelative, formatDate } from '../../utils/time' import { formatDate, formatAbsolute } from '../../utils/time'
import type { AiMessage } from '../../api/types' import type { AiMessage } from '../../api/types'
defineProps<{ defineProps<{
@@ -107,10 +107,7 @@ function shouldRenderMsg(msg: AiMessage): boolean {
return true return true
} }
/** token 数格式化:<1000 原值,≥1000 用 k(如 1234→1.2k) */ /** token 数格式化收敛到 useTurnTrace.formatTokensK(与轨迹卡头部汇总同一格式化出口) */
function formatTokens(n: number): string {
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
}
// ── token 分项显示(2026-08-02):in/cache/out/reason 分计费 + 详情面板 ── // ── token 分项显示(2026-08-02):in/cache/out/reason 分计费 + 详情面板 ──
// //
@@ -134,6 +131,14 @@ function tokenInOf(m: AiMessage): number {
function tokenCacheOf(m: AiMessage): number { function tokenCacheOf(m: AiMessage): number {
return m.tokenUsage?.cache_hit ?? m.prompt_cache_hit_tokens ?? 0 return m.tokenUsage?.cache_hit ?? m.prompt_cache_hit_tokens ?? 0
} }
/** cache 段是否有真实上报数据(渲染闸):hit/miss 均为 0 时「provider 没报缓存」与
* 「真 0」不可区分(GLM 无缓存口径,估算轮更无真实用量),渲染「0 cache」会误导,不渲染。 */
function tokenCacheVisible(m: AiMessage): boolean {
if (tokenEstimated(m)) return false
const hit = m.tokenUsage?.cache_hit ?? m.prompt_cache_hit_tokens ?? 0
const miss = m.tokenUsage?.cache_miss ?? m.prompt_cache_miss_tokens ?? 0
return hit > 0 || miss > 0
}
/** 取消息的 out token */ /** 取消息的 out token */
function tokenOutOf(m: AiMessage): number { function tokenOutOf(m: AiMessage): number {
return m.tokenUsage?.completion ?? m.completion_tokens ?? 0 return m.tokenUsage?.completion ?? m.completion_tokens ?? 0
@@ -589,6 +594,8 @@ function canOpenSettings(msg: AiMessage): boolean {
* retryable 推断依据后端 error_type(本批前端推断;后端 AiStreamRetry/AiError 加 retryable 字段下批 df-ai 增强)。 * retryable 推断依据后端 error_type(本批前端推断;后端 AiStreamRetry/AiError 加 retryable 字段下批 df-ai 增强)。
*/ */
function canRetry(msg: AiMessage): boolean { function canRetry(msg: AiMessage): boolean {
// 重试过渡气泡(isRetry)非终态错误,不提供重试入口(终态 AiError 才有重试按钮)
if (msg.isRetry) return false
const et = (msg as AiMessageWithErrorType).errorType const et = (msg as AiMessageWithErrorType).errorType
// auth/provider_config = Fatal(鉴权/配置错),重试无意义 // auth/provider_config = Fatal(鉴权/配置错),重试无意义
if (et === 'auth' || et === 'provider_config') return false if (et === 'auth' || et === 'provider_config') return false
@@ -741,9 +748,54 @@ function toggleTrace(key: string): void {
expandedTraceIds.value = next expandedTraceIds.value = next
} }
/** 轨迹卡聚合行:「N 次调用 · 成功 x 失败 y · 总耗时 T」(拼装统一走 useTurnTrace.aggText) */ // UX 密度改造:轨迹内每轮思考单行 pill(灯泡+单行截断+右端 token 小字),点击展开完整思考段落。
// 展开态按 msg.id 记忆,与轨迹卡展开态(expandedTraceIds)互不影响。
const expandedThinkIds = ref<Set<string>>(new Set())
function toggleThink(id: string): void {
const next = new Set(expandedThinkIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
expandedThinkIds.value = next
}
/** 轮次思考纯文本:reasoning_content 优先(DeepSeek/Anthropic thinking 同通道),content 兜底;
* 压平 markdown 换行;两者皆空回退「过程记录」占位(纯工具轮也有锚点) */
function thinkPlainText(m: AiMessage): string {
const src = m.reasoning_content || m.content || ''
return src.replace(/\s+/g, ' ').trim()
}
/** pill 单行文本:纯文本截断 + 空轮回退文案(title 承载全文,此处只管单行) */
function thinkSnippet(m: AiMessage): string {
const txt = thinkPlainText(m)
return txt || t('ai.trace.processOnly')
}
/** pill hover title:完整思考文本(超长截断防 tooltip 卡顿) */
function thinkFullText(m: AiMessage): string {
const txt = thinkPlainText(m)
if (!txt) return t('ai.trace.processOnly')
return txt.length > 500 ? txt.slice(0, 500) + '…' : txt
}
/** 该轮是否有实质思考内容:空内容轮(pill 只会显示「过程记录」占位)不渲染 pill,消除占位噪音;
* 该轮工具卡照常渲染,有实质内容的思考行不受影响。 */
function hasThinkBody(m: AiMessage): boolean {
const txt = thinkPlainText(m)
return !!txt && txt !== t('ai.trace.processOnly')
}
/** 思考 pill 右端 token 小字:「30.3k in(估算) · 12k cache · 157 out」(无 token 数据返回空串隐藏)。
* cache 段仅在 provider 真实上报时渲染(tokenCacheVisible),避免把「无数据」显示成「0 cache」。 */
function roundTokenText(m: AiMessage): string {
if (!m.tokenUsage && m.prompt_tokens == null) return ''
const est = tokenEstimated(m) ? `(${t('aiChat.estimated')})` : ''
const parts = [`${formatTokensK(tokenInOf(m))} in${est}`]
if (tokenCacheVisible(m)) parts.push(`${formatTokensK(tokenCacheOf(m))} cache`)
parts.push(`${formatTokensK(tokenOutOf(m))} out`)
if (tokenReasonOf(m) > 0) parts.push(`${formatTokensK(tokenReasonOf(m))} reason`)
return parts.join(' · ')
}
/** 轨迹卡聚合行:「N 次调用 · 成功 x 失败 y · 总耗时 T」(拼装统一走 useTurnTrace.turnSummaryText) */
function traceAggText(stats: TurnCallStats, startTs: number, endTs: number): string { function traceAggText(stats: TurnCallStats, startTs: number, endTs: number): string {
return aggText(stats, endTs - startTs) return turnSummaryText(stats, endTs - startTs)
} }
/** 轨迹卡 hover:绝对起止时刻(相对时长之外可溯源具体时间) */ /** 轨迹卡 hover:绝对起止时刻(相对时长之外可溯源具体时间) */
@@ -751,9 +803,13 @@ function traceHoverTitle(startTs: number, endTs: number): string {
return `${formatDate(startTs)} ~ ${formatDate(endTs)}` return `${formatDate(startTs)} ~ ${formatDate(endTs)}`
} }
/** 执行中实时步骤摘要:「✓ N 次调用完成 · 正在 {工具} · ⏳ N 项待审批」(由最近工具事件聚合) */ /**
function stepText(s: TurnCallStats): string { * 执行中实时步骤摘要:「第 N 轮 · ✓ N 次调用完成 · 失败 N · 正在 {工具} · ⏳ N 项待审批」。
const parts: string[] = [] * 轮次号与轨迹卡同源(flushBucket 的 traceCount,执行中=将成为的轨迹序号);
* 失败/拒绝/待审批段仅非零时显示;全部无数据回退「过程记录」。
*/
function stepText(s: TurnCallStats, round: number): string {
const parts: string[] = [t('ai.trace.roundN', { n: round })]
const done = s.ok + s.fail const done = s.ok + s.fail
if (done > 0) parts.push(t('ai.trace.stepDone', { n: done })) if (done > 0) parts.push(t('ai.trace.stepDone', { n: done }))
if (s.fail > 0) parts.push(t('ai.trace.fail', { n: s.fail })) if (s.fail > 0) parts.push(t('ai.trace.fail', { n: s.fail }))
@@ -764,11 +820,14 @@ function stepText(s: TurnCallStats): string {
} }
type RenderItem = type RenderItem =
| { kind: 'msg'; key: string; msg: AiMessage } | { kind: 'msg'; key: string; msg: AiMessage; final?: boolean }
| { kind: 'sep'; key: string; seg: MessageSegment & { key: string } } | { kind: 'sep'; key: string; seg: MessageSegment & { key: string } }
// A 过程压缩:完成轮次的执行轨迹卡(traceMsgs 为折叠在内的过程消息)与执行中的实时步骤摘要 // A 过程压缩:完成轮次的执行轨迹卡(traceMsgs 为折叠在内的过程消息)与执行中的实时步骤摘要;
| { kind: 'trace'; key: string; stats: TurnCallStats; startTs: number; endTs: number; traceMsgs: AiMessage[]; expanded: boolean } // round=轨迹序号(卡头「第 N 轮」,与执行中步骤行、轮次导航条同一计数来源)
| { kind: 'step'; key: string; stats: TurnCallStats } | { kind: 'trace'; key: string; round: number; stats: TurnCallStats; startTs: number; endTs: number; tokens: number; traceMsgs: AiMessage[]; expanded: boolean }
| { kind: 'step'; key: string; round: number; stats: TurnCallStats }
// UX 密度改造:相邻消息间隔 ≥30 分钟或跨天的居中时间分隔线(替代 per-block 相对时间)
| { kind: 'timesep'; key: string; ts: number }
/** /**
* 渲染项(A 过程压缩):在归档分段之上叠加轮次折叠。 * 渲染项(A 过程压缩):在归档分段之上叠加轮次折叠。
@@ -776,12 +835,44 @@ type RenderItem =
* 轮次 = 末条 user 之后的连续 assistant 消息(错误气泡/压缩摘要/归档分段不进桶,原样渲染)。 * 轮次 = 末条 user 之后的连续 assistant 消息(错误气泡/压缩摘要/归档分段不进桶,原样渲染)。
* - 执行中:仅末条消息(当前步骤)展开,先前步骤收进一行实时摘要(step 项,由工具事件聚合)。 * - 执行中:仅末条消息(当前步骤)展开,先前步骤收进一行实时摘要(step 项,由工具事件聚合)。
* - 完成:轮次内除最终答复外的过程消息折叠为一张执行轨迹卡(trace 项,默认一行聚合), * - 完成:轮次内除最终答复外的过程消息折叠为一张执行轨迹卡(trace 项,默认一行聚合),
* 点开回看;用户手动展开记入 expandedTraceIds 不被收回。正文区只留最终答复。 * 点开在卡内 rail 面板回看(每轮思考 pill + 工具卡);用户手动展开记入 expandedTraceIds
* 不被收回。正文区只留最终答复。
* 无过程消息的轮次(单轮问答)零改动原样渲染。 * 无过程消息的轮次(单轮问答)零改动原样渲染。
*
* UX 密度改造:相邻消息间隔 ≥30 分钟或跨天时插居中时间分隔线(timesep 项),
* per-block 相对时间已移除。轨距追踪 lastMsgTs 只在消息桶边界推进,
* 轨迹卡与其内部轮次之间不插线(同轮间隔无叙事价值)。轨迹桶自身也不再发
* timesep 行:卡头「第 N 轮」+ 绝对起始时刻已承载时间行职责(两行合一)。
*/ */
const TIME_SEP_GAP_MS = 30 * 60 * 1000
let lastMsgTs = 0
/** 与上一条已渲染消息是否需要时间分隔线(间隔 ≥30 分钟或跨天;首条不插) */
function needsTimeSep(ts: number): boolean {
if (!lastMsgTs || !ts) return false
if (Math.abs(ts - lastMsgTs) >= TIME_SEP_GAP_MS) return true
const a = new Date(lastMsgTs)
const b = new Date(ts)
return a.getFullYear() !== b.getFullYear()
|| a.getMonth() !== b.getMonth()
|| a.getDate() !== b.getDate()
}
/** 需要时插 timesep 项并推进 lastMsgTs(同一出口防漏插/重插) */
function pushTimeSepIfNeeded(items: RenderItem[], ts: number): void {
if (needsTimeSep(ts)) {
items.push({ kind: 'timesep', key: 'tsep-' + ts + '-' + items.length, ts })
}
lastMsgTs = ts
}
const renderItems = computed<RenderItem[]>(() => { const renderItems = computed<RenderItem[]>(() => {
const items: RenderItem[] = [] const items: RenderItem[] = []
let bucket: AiMessage[] = [] let bucket: AiMessage[] = []
lastMsgTs = 0
// 轨迹序号计数:轨迹卡头「第 N 轮」与执行中步骤行同一来源(computed 重算从 0 起,
// 计数即轨迹卡出现顺序,与轮次导航条序号一致)
let traceCount = 0
const flushBucket = () => { const flushBucket = () => {
const msgs = bucket.filter(shouldRenderMsg) const msgs = bucket.filter(shouldRenderMsg)
@@ -790,13 +881,20 @@ const renderItems = computed<RenderItem[]>(() => {
if (isViewingGenerating.value) { if (isViewingGenerating.value) {
// 执行中:当前步骤锚定真实末条消息(agentic 新一轮 push 新空气泡,若按「末条有正文」 // 执行中:当前步骤锚定真实末条消息(agentic 新一轮 push 新空气泡,若按「末条有正文」
// 找锚点会错挂上一轮,致当前轮打字机/光标/工具卡整条不渲染);先前步骤收进一行实时摘要 // 找锚点会错挂上一轮,致当前轮打字机/光标/工具卡整条不渲染);先前步骤收进一行实时摘要。
// 摘要统计取整个轮次桶(含当前消息):执行中工具挂在末条消息上(useAiToolEvents 取
// state.messages 末条),只统计 prev 拿不到 runningName,完成计数也滞后一轮(黑盒主因)
const cur = msgs[msgs.length - 1] const cur = msgs[msgs.length - 1]
const prev = msgs.slice(0, -1) const prev = msgs.slice(0, -1)
if (prev.length > 0) { if (prev.length > 0) {
items.push({ kind: 'step', key: 'step-' + prev[0].id, stats: collectTurnStats(prev) }) pushTimeSepIfNeeded(items, msgs[0].timestamp)
traceCount++
items.push({ kind: 'step', key: 'step-' + prev[0].id, round: traceCount, stats: collectTurnStats(msgs) })
} else {
pushTimeSepIfNeeded(items, cur.timestamp)
} }
items.push({ kind: 'msg', key: 'm-' + cur.id, msg: cur }) items.push({ kind: 'msg', key: 'm-' + cur.id, msg: cur, final: true })
lastMsgTs = cur.timestamp
return return
} }
// 最终答复:末条有正文且非纯 JSON 工具结果的消息;全无正文时保底末条(工具轮可见) // 最终答复:末条有正文且非纯 JSON 工具结果的消息;全无正文时保底末条(工具轮可见)
@@ -806,8 +904,11 @@ const renderItems = computed<RenderItem[]>(() => {
if (c && c.trim() && !isToolResultJson(c)) { fi = i; break } if (c && c.trim() && !isToolResultJson(c)) { fi = i; break }
} }
const finalMsg = fi >= 0 ? msgs[fi] : msgs[msgs.length - 1] const finalMsg = fi >= 0 ? msgs[fi] : msgs[msgs.length - 1]
const traceMsgs = msgs.slice(0, msgs.indexOf(finalMsg)) // 过程消息 = finalMsg 之外全部(含其后尾气泡):异常收尾(错误/停止/流中断)时末轮气泡
// 完成:轨迹折叠为一张卡,展开时再 emit 段内过程消息(与归档分段同一扁平化模式) // 常只有 toolCalls 无正文,按「finalMsg 之前」切片会把尾气泡丢出渲染与轨迹卡,
// 执行中可见的工具卡在收尾瞬间整块消失(用户实测「空白覆盖一大堆内容」)。
const traceMsgs = msgs.filter(m => m !== finalMsg)
// 完成:轨迹折叠为一张卡,轮次内容在卡内 rail 面板展开(不再摊平成独立消息项)
if (traceMsgs.length > 0) { if (traceMsgs.length > 0) {
const key = 'trace-' + traceMsgs[0].id const key = 'trace-' + traceMsgs[0].id
const expanded = expandedTraceIds.value.has(key) const expanded = expandedTraceIds.value.has(key)
@@ -818,29 +919,34 @@ const renderItems = computed<RenderItem[]>(() => {
if (tc.completedAt && tc.completedAt > lastCompleted) lastCompleted = tc.completedAt if (tc.completedAt && tc.completedAt > lastCompleted) lastCompleted = tc.completedAt
} }
} }
// 轨迹桶不再发 timesep 行:卡头「第 N 轮」+ 绝对起始时刻已承载时间行职责;
// 仅推进轨距,保持出口语义(后续消息的间隔判定从本桶起点起算)
lastMsgTs = traceMsgs[0].timestamp
traceCount++
items.push({ items.push({
kind: 'trace', key, kind: 'trace', key,
round: traceCount,
stats: collectTurnStats(traceMsgs), stats: collectTurnStats(traceMsgs),
startTs: traceMsgs[0].timestamp, startTs: traceMsgs[0].timestamp,
endTs: Math.max(lastCompleted, finalMsg.timestamp), endTs: Math.max(lastCompleted, finalMsg.timestamp),
tokens: sumTurnTokens(traceMsgs),
traceMsgs, expanded, traceMsgs, expanded,
}) })
if (expanded) { } else {
for (const m of traceMsgs) { pushTimeSepIfNeeded(items, finalMsg.timestamp)
items.push({ kind: 'msg', key: 'tm-' + m.id, msg: m })
} }
} items.push({ kind: 'msg', key: 'm-' + finalMsg.id, msg: finalMsg, final: true })
} lastMsgTs = finalMsg.timestamp
items.push({ kind: 'msg', key: 'm-' + finalMsg.id, msg: finalMsg })
} }
for (const seg of messageSegments.value) { for (const seg of messageSegments.value) {
if (seg.kind !== 'normal') { if (seg.kind !== 'normal') {
flushBucket() flushBucket()
// 折叠段:总 emit sep;展开时再 emit 段内每条 msg // 折叠段:总 emit sep;展开时再 emit 段内每条 msg(同样按间隔插时间线)
items.push({ kind: 'sep', key: seg.key, seg }) items.push({ kind: 'sep', key: seg.key, seg })
if (expandedSegmentIds.value.has(seg.key)) { if (expandedSegmentIds.value.has(seg.key)) {
for (const m of seg.msgs) { for (const m of seg.msgs) {
pushTimeSepIfNeeded(items, m.timestamp)
items.push({ kind: 'msg', key: 'e-' + seg.key + '-' + m.id, msg: m }) items.push({ kind: 'msg', key: 'e-' + seg.key + '-' + m.id, msg: m })
} }
} }
@@ -852,6 +958,7 @@ const renderItems = computed<RenderItem[]>(() => {
bucket.push(m) bucket.push(m)
} else { } else {
flushBucket() flushBucket()
pushTimeSepIfNeeded(items, m.timestamp)
items.push({ kind: 'msg', key: 'm-' + m.id, msg: m }) items.push({ kind: 'msg', key: 'm-' + m.id, msg: m })
} }
} }
@@ -859,6 +966,18 @@ const renderItems = computed<RenderItem[]>(() => {
return items return items
}) })
// UX 密度改造:轮次导航条(仅多轮长会话 ≥3 张轨迹卡出现,sticky 置顶,只做跳转不做统计)
const traceNav = computed(() =>
renderItems.value.filter((it): it is Extract<RenderItem, { kind: 'trace' }> => it.kind === 'trace')
)
/** 点击轮次 chip 滚动到对应轨迹卡(block:start 卡头对齐视口顶) */
function jumpToTrace(key: string): void {
messagesContainer.value
?.querySelector(`[data-trace-key="${key}"]`)
?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
// 切换对话/新对话时由父取消编辑态;滚动边沿/跟随意图重置在此自管。 // 切换对话/新对话时由父取消编辑态;滚动边沿/跟随意图重置在此自管。
watch(() => store.state.activeConversationId, () => { watch(() => store.state.activeConversationId, () => {
// 切会话重置滚动边沿检测 + 跟随意图(新会话默认跟随底部) // 切会话重置滚动边沿检测 + 跟随意图(新会话默认跟随底部)
@@ -914,38 +1033,99 @@ defineExpose({
@go-to-settings="emit('go-to-settings')" @go-to-settings="emit('go-to-settings')"
/> />
<!-- 轮次导航条(UX 密度改造:仅多轮长会话 ≥3 张轨迹卡出现,sticky 置顶,只做轮次跳转。
调用统计收敛到轨迹卡头部,此处不放数字) -->
<div v-if="traceNav.length >= 3" class="ai-trace-nav">
<button
v-for="tr in traceNav"
:key="tr.key"
class="ai-trace-nav-chip"
:title="$t('ai.trace.roundN', { n: tr.round })"
@click="jumpToTrace(tr.key)"
>{{ tr.round }}</button>
</div>
<!-- 消息列表(F-15 阶段2:按 renderItems 扁平渲染 — 折叠段 emit 'sep' 项, <!-- 消息列表(F-15 阶段2:按 renderItems 扁平渲染 — 折叠段 emit 'sep' 项,
正常消息 + 展开的折叠段内消息 emit 'msg' 项。连续 archived_segment/compressed 正常消息 + 展开的折叠段内消息 emit 'msg' 项。连续 archived_segment/compressed
合并一条分隔条,点击展开/收起看原文) 合并一条分隔条,点击展开/收起看原文)
A 过程压缩:trace 项=完成轮次执行轨迹卡,step 项=执行中实时步骤摘要,均先于 msg 分支拦截。 A 过程压缩:trace 项=完成轮次执行轨迹卡,step 项=执行中实时步骤摘要,均先于 msg 分支拦截。
UX-2025-19: 每项外层包 .ai-msg-slot(B-260618-01 虚拟滚动已移除,现恒渲染; UX-2025-19: 每项外层包 .ai-msg-slot(B-260618-01 虚拟滚动已移除,现恒渲染;
slot 仅作 flex 子占位 + 消息分组的语义容器,gap 由 .ai-messages 承担)。 --> slot 仅作 flex 子占位 + 消息分组的语义容器,gap 由 .ai-messages 承担)。
UX 密度改造:timesep 项=相邻消息间隔 ≥30 分钟或跨天的居中时间分隔线。 -->
<template v-for="item in renderItems" :key="item.key"> <template v-for="item in renderItems" :key="item.key">
<!-- 执行轨迹卡(完成轮次:过程文本+工具卡折叠为一张,默认一行聚合,点开回看; <!-- 执行轨迹卡(完成轮次:过程内容折叠为一张,默认一行聚合,点开在卡内 rail 面板回看
用户手动展开记入 expandedTraceIds 不被自动收回) --> 每轮思考 pill + 工具卡;头部聚合行含调用/耗时/全程 token 汇总) -->
<div v-if="item.kind === 'trace'" class="ai-msg-slot"> <div v-if="item.kind === 'trace'" class="ai-msg-slot">
<div class="ai-trace-card" :class="{ 'ai-trace-card--open': item.expanded }"> <div class="ai-trace-card" :class="{ 'ai-trace-card--open': item.expanded }" :data-trace-key="item.key">
<div class="ai-trace-head" @click="toggleTrace(item.key)"> <div class="ai-trace-head" @click="toggleTrace(item.key)">
<span class="ai-trace-chevron" :class="{ 'is-open': item.expanded }">▸</span> <span class="ai-trace-chevron" :class="{ 'is-open': item.expanded }">▸</span>
<span class="ai-trace-title">{{ $t('ai.trace.title') }}</span> <span class="ai-trace-title">{{ $t('ai.trace.roundN', { n: item.round }) }}</span>
<span class="ai-trace-agg" :title="traceHoverTitle(item.startTs, item.endTs)">{{ traceAggText(item.stats, item.startTs, item.endTs) }}</span> <span class="ai-trace-agg" :title="traceHoverTitle(item.startTs, item.endTs)">{{ traceAggText(item.stats, item.startTs, item.endTs) }}</span>
<span v-if="item.tokens > 0" class="ai-trace-tokens">≈{{ formatTokensK(item.tokens) }} tokens</span>
<!-- 绝对起始时刻(mono 小字):轨迹桶已不发独立时间分隔线,时刻职责由卡头承载 -->
<span class="ai-trace-tokens">{{ formatAbsolute(item.startTs) }}</span>
<span class="ai-trace-toggle">{{ item.expanded ? $t('ai.trace.collapse') : $t('ai.trace.expand') }}</span> <span class="ai-trace-toggle">{{ item.expanded ? $t('ai.trace.collapse') : $t('ai.trace.expand') }}</span>
</div> </div>
<!-- 展开态 rail 面板:每轮=思考单行 pill(点击展开全文)+工具卡,图标锚定左轨连线贯通 -->
<div v-if="item.expanded" class="ai-trace-body">
<div v-for="m in item.traceMsgs" :key="m.id" class="ai-trace-round">
<!-- 空内容轮(只剩「过程记录」占位)不渲染思考 pill,降噪;工具卡照常 -->
<template v-if="hasThinkBody(m)">
<button
class="ai-think-pill"
:title="thinkFullText(m)"
@click="toggleThink(m.id)"
>
<span class="ai-think-ic">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 00-4 12.7c.6.5 1 1.4 1 2.3h6c0-.9.4-1.8 1-2.3A7 7 0 0012 2z"/></svg>
</span>
<span class="ai-think-text">{{ thinkSnippet(m) }}</span>
<span v-if="roundTokenText(m)" class="ai-think-tokens">{{ roundTokenText(m) }}</span>
</button>
<div
v-if="expandedThinkIds.has(m.id) && m.content?.trim()"
class="ai-think-full ai-md"
v-html="renderMd(m.content)"
></div>
</template>
<ToolCardList
v-if="m.toolCalls && m.toolCalls.length > 0"
ref="toolCardListRef"
:toolCalls="m.toolCalls"
@approve="({ id, approved, decision }) => store.approveToolCall(id, approved, decision)"
@batch-approve="(decision) => store.batchApprove(decision)"
@install-obscura="onInstallObscura"
/>
</div>
</div>
</div> </div>
</div> </div>
<!-- 执行中实时步骤摘要(已完成步骤收进一行,由最近工具事件聚合;仅当前步骤展开) --> <!-- 执行中实时步骤摘要(已完成步骤收进一行,由最近工具事件聚合;仅当前步骤展开) -->
<div v-else-if="item.kind === 'step'" class="ai-msg-slot"> <div v-else-if="item.kind === 'step'" class="ai-msg-slot">
<div class="ai-step-line"> <div class="ai-step-line">
<span class="ai-step-dot"></span> <span class="ai-step-dot"></span>
<span class="ai-step-text">{{ stepText(item.stats) }}</span> <span class="ai-step-text">{{ stepText(item.stats, item.round) }}</span>
</div>
</div>
<!-- 居中时间分隔线(UX 密度改造:相邻消息间隔 ≥30 分钟或跨天;当天只 HH:mm,跨天 MM-DD HH:mm) -->
<div v-else-if="item.kind === 'timesep'" class="ai-time-sep">
<span>{{ formatAbsolute(item.ts) }}</span>
</div>
<!-- 重试过渡细条(AiStreamRetry 过渡气泡,isRetry 标记):琥珀点+单行中性文案,
不走 avatar/错误红卡/操作条;重试耗尽的终态错误(AiError,无 isRetry)仍走红卡 -->
<div v-else-if="item.kind === 'msg' && item.msg.isRetry" class="ai-msg-slot">
<div class="ai-retry-line">
<span class="ai-retry-dot"></span>
<span class="ai-retry-text">{{ item.msg.content }}</span>
</div> </div>
</div> </div>
<!-- B-260618-19: 纯空 assistant(无 content/error/toolCalls 且非末条流式)整 slot 不渲染, <!-- B-260618-19: 纯空 assistant(无 content/error/toolCalls 且非末条流式)整 slot 不渲染,
消除空 div + flex gap 堆积的大片空白;sep 始终渲染;有 toolCalls 的空 assistant 消除空 div + flex gap 堆积的大片空白;sep 始终渲染;有 toolCalls 的空 assistant
仍渲染(工具卡可见——修复 agentic 工具轮 LLM 无文本时工具卡消失)。 --> 仍渲染(工具卡可见——修复 agentic 工具轮 LLM 无文本时工具卡消失)。
UX 密度改造:per-block 相对时间已移除,精确时刻上移到消息容器 title(hover 可查)。 -->
<div <div
v-else-if="item.kind === 'sep' ? true : shouldRenderMsg(item.msg)" v-else-if="item.kind === 'sep' ? true : shouldRenderMsg(item.msg)"
class="ai-msg-slot" class="ai-msg-slot"
:title="item.kind === 'msg' ? formatDate(item.msg.timestamp) : undefined"
> >
<!-- 折叠分隔条(归档段)— 轻量,始终渲染 --> <!-- 折叠分隔条(归档段)— 轻量,始终渲染 -->
<div <div
@@ -1015,7 +1195,7 @@ defineExpose({
<div <div
v-else-if="(item.msg.content && item.msg.content.trim()) || (isLastAi(item.msg) && store.state.streaming && streamingBlocks.length > 0)" v-else-if="(item.msg.content && item.msg.content.trim()) || (isLastAi(item.msg) && store.state.streaming && streamingBlocks.length > 0)"
class="ai-msg-bubble ai-msg-bubble--ai ai-md" class="ai-msg-bubble ai-msg-bubble--ai ai-md"
:class="{ 'ai-msg-bubble--error': item.msg.isError, 'ai-msg-bubble--collapsed': shouldCollapseLong(item.msg) }" :class="{ 'ai-msg-bubble--error': item.msg.isError, 'ai-msg-bubble--collapsed': shouldCollapseLong(item.msg), 'ai-msg-bubble--answer': item.final === true && !item.msg.isError }"
:key="'md-' + item.msg.id + '-' + (isLastAi(item.msg) ? _mdRenderKey : 0)" :key="'md-' + item.msg.id + '-' + (isLastAi(item.msg) ? _mdRenderKey : 0)"
> >
<!-- 流式:分块渲染(已完成块key稳定→DOM不重建)。 <!-- 流式:分块渲染(已完成块key稳定→DOM不重建)。
@@ -1094,14 +1274,17 @@ defineExpose({
:aria-expanded="tokenPopoverMsgId === item.msg.id" :aria-expanded="tokenPopoverMsgId === item.msg.id"
@click="toggleTokenPopover(item.msg, $event)" @click="toggleTokenPopover(item.msg, $event)"
> >
<span>{{ formatTokens(tokenInOf(item.msg)) }} in<template v-if="tokenEstimated(item.msg)"><span class="ai-token-est">({{ $t('aiChat.estimated') }})</span></template></span> <span>{{ formatTokensK(tokenInOf(item.msg)) }} in<template v-if="tokenEstimated(item.msg)"><span class="ai-token-est">({{ $t('aiChat.estimated') }})</span></template></span>
<!-- cache 段仅在 provider 真实上报时渲染(tokenCacheVisible),避免「无数据」显示成「0 cache」 -->
<template v-if="tokenCacheVisible(item.msg)">
<span class="ai-token-sep">·</span> <span class="ai-token-sep">·</span>
<span>{{ formatTokens(tokenCacheOf(item.msg)) }} cache</span> <span>{{ formatTokensK(tokenCacheOf(item.msg)) }} cache</span>
</template>
<span class="ai-token-sep">·</span> <span class="ai-token-sep">·</span>
<span>{{ formatTokens(tokenOutOf(item.msg)) }} out</span> <span>{{ formatTokensK(tokenOutOf(item.msg)) }} out</span>
<template v-if="tokenReasonOf(item.msg) > 0"> <template v-if="tokenReasonOf(item.msg) > 0">
<span class="ai-token-sep">·</span> <span class="ai-token-sep">·</span>
<span>{{ formatTokens(tokenReasonOf(item.msg)) }} reason</span> <span>{{ formatTokensK(tokenReasonOf(item.msg)) }} reason</span>
</template> </template>
</button> </button>
<!-- 详情面板(absolute/right 0/popout 样式,复用 TopBar 风格):完整 usage + 流程 --> <!-- 详情面板(absolute/right 0/popout 样式,复用 TopBar 风格):完整 usage + 流程 -->
@@ -1113,21 +1296,22 @@ defineExpose({
<div class="ai-token-popover-title">{{ $t('aiChat.tokenDetailTitle') }}<template v-if="tokenEstimated(item.msg)"><span class="ai-token-est">({{ $t('aiChat.estimated') }})</span></template></div> <div class="ai-token-popover-title">{{ $t('aiChat.tokenDetailTitle') }}<template v-if="tokenEstimated(item.msg)"><span class="ai-token-est">({{ $t('aiChat.estimated') }})</span></template></div>
<div class="ai-token-popover-row"> <div class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenInLabel') }}</span> <span class="ai-token-popover-label">{{ $t('aiChat.tokenInLabel') }}</span>
<span class="ai-token-popover-val">{{ formatTokens(tokenInOf(item.msg)) }} ({{ tokenInOf(item.msg) }})</span> <span class="ai-token-popover-val">{{ formatTokensK(tokenInOf(item.msg)) }} ({{ tokenInOf(item.msg) }})</span>
</div> </div>
<div class="ai-token-popover-row"> <div v-if="tokenCacheVisible(item.msg)" class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenCacheLabel') }}</span> <span class="ai-token-popover-label">{{ $t('aiChat.tokenCacheLabel') }}</span>
<span class="ai-token-popover-val">{{ formatTokens(tokenCacheOf(item.msg)) }} ({{ tokenCacheOf(item.msg) }})</span> <span class="ai-token-popover-val">{{ formatTokensK(tokenCacheOf(item.msg)) }} ({{ tokenCacheOf(item.msg) }})</span>
</div> </div>
<div class="ai-token-popover-row"> <div class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenOutLabel') }}</span> <span class="ai-token-popover-label">{{ $t('aiChat.tokenOutLabel') }}</span>
<span class="ai-token-popover-val">{{ formatTokens(tokenOutOf(item.msg)) }} ({{ tokenOutOf(item.msg) }})</span> <span class="ai-token-popover-val">{{ formatTokensK(tokenOutOf(item.msg)) }} ({{ tokenOutOf(item.msg) }})</span>
</div> </div>
<div v-if="tokenReasonOf(item.msg) > 0" class="ai-token-popover-row"> <div v-if="tokenReasonOf(item.msg) > 0" class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenReasonLabel') }}</span> <span class="ai-token-popover-label">{{ $t('aiChat.tokenReasonLabel') }}</span>
<span class="ai-token-popover-val">{{ formatTokens(tokenReasonOf(item.msg)) }} ({{ tokenReasonOf(item.msg) }})</span> <span class="ai-token-popover-val">{{ formatTokensK(tokenReasonOf(item.msg)) }} ({{ tokenReasonOf(item.msg) }})</span>
</div> </div>
<div v-if="cacheHitRate(item.msg) != null" class="ai-token-popover-row"> <!-- 命中率与 cache 段同闸:无缓存口径的 provider 不显 0%,防「无数据」误读成「全未命中」 -->
<div v-if="tokenCacheVisible(item.msg) && cacheHitRate(item.msg) != null" class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenCacheRateLabel') }}</span> <span class="ai-token-popover-label">{{ $t('aiChat.tokenCacheRateLabel') }}</span>
<span class="ai-token-popover-val">{{ cacheHitRate(item.msg) }}%</span> <span class="ai-token-popover-val">{{ cacheHitRate(item.msg) }}%</span>
</div> </div>
@@ -1153,13 +1337,6 @@ defineExpose({
@batch-approve="(decision) => store.batchApprove(decision)" @batch-approve="(decision) => store.batchApprove(decision)"
@install-obscura="onInstallObscura" @install-obscura="onInstallObscura"
/> />
<!-- UX-2025-13:消息时间戳(相对时间+hover绝对时间),流式生成中不显示避免抖动 -->
<span
v-if="!(isLastAi(item.msg) && store.state.streaming)"
class="ai-msg-time"
:title="formatDate(item.msg.timestamp)"
>{{ formatRelative(item.msg.timestamp) }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -1395,19 +1572,64 @@ defineExpose({
.ai-msg-chip--idea { background: rgba(255, 255, 255, 0.22); border: 1px solid rgba(255, 255, 255, 0.35); } .ai-msg-chip--idea { background: rgba(255, 255, 255, 0.22); border: 1px solid rgba(255, 255, 255, 0.35); }
.ai-msg-chip--skill { background: rgba(0, 0, 0, 0.18); } .ai-msg-chip--skill { background: rgba(0, 0, 0, 0.18); }
/* ── 消息时间戳── */ /* ── UX 密度改造:居中时间分隔线(相邻消息间隔 ≥30 分钟或跨天;
.ai-msg-time { per-block 相对时间漂移无价值已移除,精确时刻在消息容器 title) ── */
.ai-time-sep {
display: flex;
align-items: center;
gap: 10px;
margin: 2px 0;
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text-dim);
user-select: none;
white-space: nowrap;
}
.ai-time-sep::before,
.ai-time-sep::after {
content: '';
flex: 1;
height: 1px;
background: color-mix(in srgb, var(--df-text-dim) 22%, transparent);
}
/* ── 最终回答卡:左 2px 品牌色强调边条 + 背景微抬升(与轨迹内思考 pill 明确分层) ── */
.ai-msg-bubble--answer {
border-left: 2px solid var(--df-accent);
background: color-mix(in srgb, var(--df-accent) 4%, var(--df-bg-card));
}
/* ── 轮次导航条(仅多轮长会话 ≥3 张轨迹卡出现,sticky 置顶,只做跳转) ── */
.ai-trace-nav {
position: sticky;
top: 0;
z-index: 6;
display: flex;
align-items: center;
gap: 4px;
margin: -6px -2px 0;
padding: 5px 6px;
background: color-mix(in srgb, var(--df-bg) 86%, transparent);
backdrop-filter: blur(6px);
border-bottom: 0.5px solid color-mix(in srgb, var(--df-border) 70%, transparent);
}
.ai-trace-nav-chip {
min-width: 20px;
height: 18px;
padding: 0 5px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
background: var(--df-bg-card);
color: var(--df-text-dim);
font-family: var(--df-font-mono);
font-size: 10px; font-size: 10px;
line-height: 1; line-height: 1;
color: var(--df-text-dim); cursor: pointer;
margin-top: 2px; transition: color 0.15s var(--df-ease), border-color 0.15s var(--df-ease);
align-self: flex-start; /* AI 消息:靠气泡左对齐 */
flex-shrink: 0;
cursor: default;
user-select: none;
} }
.ai-msg-time--user { .ai-trace-nav-chip:hover {
align-self: flex-end; /* 用户消息:靠气泡右对齐 */ color: var(--df-accent);
border-color: var(--df-accent);
} }
/* ── 消息气泡拷贝按钮── */ /* ── 消息气泡拷贝按钮── */
@@ -1709,6 +1931,103 @@ defineExpose({
color: var(--df-text-dim); color: var(--df-text-dim);
} }
.ai-trace-toggle { flex-shrink: 0; font-size: 10px; color: var(--df-accent); opacity: 0.85; } .ai-trace-toggle { flex-shrink: 0; font-size: 10px; color: var(--df-accent); opacity: 0.85; }
/* 全程 token 汇总(轨迹卡头部一处,per-round 明细下沉思考 pill 行) */
.ai-trace-tokens {
flex-shrink: 0;
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text-dim);
}
/* ── 轨迹展开态 rail 面板:左轨竖线贯通,思考 pill 与工具卡图标锚定其上(git-graph 式轮次结构)。
轨 x=22px 对齐工具卡图标中心(卡内 padding 10px + 24px 图标位一半),pill 灯泡图标同 x。 ── */
.ai-trace-body {
position: relative;
padding: 2px 10px 8px 0;
}
.ai-trace-body::before {
content: '';
position: absolute;
left: 22px;
top: 0;
bottom: 0;
width: 1px;
background: color-mix(in srgb, var(--df-text-dim) 22%, transparent);
}
.ai-trace-round {
position: relative;
padding: 2px 0;
}
.ai-trace-round + .ai-trace-round { margin-top: 2px; }
/* ── 思考单行 pill:灯泡锚轨 + 单行截断 + 右端 token 小字
(空间不足先压缩摘要文本段(hover title 有全文),token 尾段不再被截,恒完整可见) ── */
.ai-think-pill {
position: relative;
display: flex;
align-items: center;
gap: 7px;
width: 100%;
min-height: 24px;
padding: 3px 8px 3px 38px;
border: none;
border-radius: var(--df-radius-sm);
background: transparent;
color: var(--df-text-secondary);
font-size: 11px;
line-height: 1.5;
text-align: left;
cursor: pointer;
user-select: none;
transition: background 0.15s var(--df-ease);
}
.ai-think-pill:hover {
background: color-mix(in srgb, var(--df-text-dim) 8%, transparent);
}
.ai-think-ic {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 24px;
border-radius: var(--df-radius-sm);
display: flex;
align-items: center;
justify-content: center;
background: var(--df-accent-bg);
color: var(--df-accent);
flex-shrink: 0;
}
.ai-think-text {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-think-tokens {
flex: 0 0 auto;
white-space: nowrap;
font-family: var(--df-font-mono);
font-size: 9.5px;
color: var(--df-text-dim);
opacity: 0.75;
margin-left: auto;
}
/* 展开的完整思考段落:保持既有卡片样式(气泡卡同底),挂在 pill 下方缩进对齐正文 */
.ai-think-full {
margin: 2px 0 6px 38px;
padding: 8px 12px;
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
font-size: 12px;
line-height: 1.6;
color: var(--df-text-secondary);
max-height: 320px;
overflow-y: auto;
}
/* 执行中实时步骤摘要(已完成步骤收进一行,accent 呼吸点对齐状态栏 .ai-agentic-progress) */ /* 执行中实时步骤摘要(已完成步骤收进一行,accent 呼吸点对齐状态栏 .ai-agentic-progress) */
.ai-step-line { .ai-step-line {
@@ -1742,4 +2061,34 @@ defineExpose({
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.3; } 50% { opacity: 0.3; }
} }
/* 重试过渡细条(isRetry 过渡气泡):琥珀点呼吸+单行中性文案,骨架对齐 .ai-step-line。
warning 色调与错误红卡分层:重试是过渡态非终态失败,终态仍走 .ai-msg-bubble--error */
.ai-retry-line {
display: flex;
align-items: center;
gap: 6px;
max-width: var(--df-msg-max-width);
padding: 4px 10px;
border-radius: var(--df-radius);
background: color-mix(in srgb, var(--df-warning, #f0c75e) 8%, transparent);
font-size: 11px;
color: var(--df-text-secondary);
user-select: none;
}
.ai-retry-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--df-warning, #f0c75e);
flex-shrink: 0;
animation: ai-step-pulse 1.2s ease-in-out infinite;
}
.ai-retry-text {
font-family: var(--df-font-mono);
font-size: 10.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style> </style>
+7 -10
View File
@@ -19,7 +19,7 @@
> >
🚀 {{ $t('ideas.promotedProject') }} → 🚀 {{ $t('ideas.promotedProject') }} →
</router-link> </router-link>
<span class="status-tag" :class="'status-' + idea.status">{{ $t(statusLabelKey(idea.status)) }}</span> <span class="status-tag" :class="'status-' + idea.status">{{ $t(ideaStatusLabelKey(idea.status)) }}</span>
</div> </div>
</div> </div>
<!-- B-260615-25:灵感描述 Markdown 渲染,复用 useMarkdown composable(同 B-24 TaskDetail),空值回退 — --> <!-- B-260615-25:灵感描述 Markdown 渲染,复用 useMarkdown composable(同 B-24 TaskDetail),空值回退 — -->
@@ -268,7 +268,7 @@
:class="'status-' + idea.status" :class="'status-' + idea.status"
@click="statusMenuOpen = !statusMenuOpen" @click="statusMenuOpen = !statusMenuOpen"
> >
{{ $t(statusLabelKey(idea.status)) }} {{ $t(ideaStatusLabelKey(idea.status)) }}
<span class="status-caret" :class="{ 'is-open': statusMenuOpen }">▾</span> <span class="status-caret" :class="{ 'is-open': statusMenuOpen }">▾</span>
</button> </button>
<ul v-show="statusMenuOpen" class="status-menu"> <ul v-show="statusMenuOpen" class="status-menu">
@@ -306,6 +306,7 @@ import { useRendered } from '../../composables/useMarkdown'
import { ideaApi } from '../../api' import { ideaApi } from '../../api'
import { formatDate } from '../../utils/time' import { formatDate } from '../../utils/time'
import { parseScores, assessmentClass, assessmentLabel, scoreTier } from '../../utils/ideaEval' import { parseScores, assessmentClass, assessmentLabel, scoreTier } from '../../utils/ideaEval'
import { IDEA_STATUS_OPTIONS, ideaStatusLabelKey } from '../../constants/project'
import type { IdeaRecord, IdeaStatus, IdeaEvaluationRecord } from '../../api/types' import type { IdeaRecord, IdeaStatus, IdeaEvaluationRecord } from '../../api/types'
const props = defineProps<{ const props = defineProps<{
@@ -314,7 +315,6 @@ const props = defineProps<{
evalError: string evalError: string
promoting: boolean promoting: boolean
deleting: boolean deleting: boolean
statusOptions: { value: IdeaStatus; labelKey: string }[]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -390,11 +390,11 @@ watch(() => props.idea.id, () => {
watch(() => props.idea.description, () => void nextTick(measureDesc)) watch(() => props.idea.description, () => void nextTick(measureDesc))
// P1-④ 状态切换 select → badge 点击:点击当前 badge 展开/收起下拉菜单, // P1-④ 状态切换 select → badge 点击:点击当前 badge 展开/收起下拉菜单,
// 选中项后 emit('status-change') 交父组件确认+落库(沿用既有契约,不破坏 statusOptions 来源)。 // 选中项后 emit('status-change') 交父组件确认+落库(沿用既有契约)。
const statusMenuOpen = ref(false) const statusMenuOpen = ref(false)
// 状态下拉排除 promoted(立项走独立 promoteToProject 流程,列表状态菜单同排除, // 状态下拉排除 promoted(立项走独立 promoteToProject 流程,列表状态菜单同排除,
// 见 Ideas.vue onStatusChange 兜底);statusOptions 仍含 promoted 供 label 映射(已转立项的灵感 badge 正常显示)。 // 见 Ideas.vue onStatusChange 兜底);状态表(含 promoted 供 badge label 映射)单一数据源在 @/constants/project。
const menuStatusOptions = computed(() => props.statusOptions.filter(s => s.value !== 'promoted')) const menuStatusOptions = computed(() => IDEA_STATUS_OPTIONS.filter(s => s.value !== 'promoted'))
function closeStatusMenu() { function closeStatusMenu() {
statusMenuOpen.value = false statusMenuOpen.value = false
} }
@@ -515,10 +515,7 @@ function cancelTagsEdit() {
tagsEditing.value = false tagsEditing.value = false
} }
// 任意 status 字符串 → 对应 i18n key;未知状态回退到原值显示 // 任意 status → i18n key 的映射走 @/constants/project ideaStatusLabelKey(与列表同源)
function statusLabelKey(status: IdeaStatus): string {
return props.statusOptions.find(s => s.value === status)?.labelKey ?? status
}
// 灵感描述 Markdown 渲染(复用 AiChat/TaskDetail 同款渲染器,模块级单例), // 灵感描述 Markdown 渲染(复用 AiChat/TaskDetail 同款渲染器,模块级单例),
// useRendered 封装 computed(读 mdReady 触发响应式 + renderMd)+ ensureLoaded(幂等预热) // useRendered 封装 computed(读 mdReady 触发响应式 + renderMd)+ ensureLoaded(幂等预热)
+4 -11
View File
@@ -147,6 +147,7 @@ import {
knowledgeConfidenceLabel as confidenceLabel, knowledgeConfidenceLabel as confidenceLabel,
} from '@/stores/knowledge' } from '@/stores/knowledge'
import { useRendered } from '@/composables/useMarkdown' import { useRendered } from '@/composables/useMarkdown'
import { formatRelative } from '@/utils/time'
import type { KnowledgeDetailPayload, KnowledgeEventRecord } from '@/api/types' import type { KnowledgeDetailPayload, KnowledgeEventRecord } from '@/api/types'
// 详情面板子组件(Knowledge.vue 拆分,SW-260618-20): // 详情面板子组件(Knowledge.vue 拆分,SW-260618-20):
@@ -317,20 +318,12 @@ const timelineNodes = computed(() => {
return nodes return nodes
}) })
// ===== 标签/时间辅助(kind*/status/confidence label 由 @/stores/knowledge 共用,相对时间本组件自留 30 天截止)===== // ===== 标签/时间辅助(kind*/status/confidence label 由 @/stores/knowledge 共用)=====
// 相对时间:复用 common.* 既有 key(justNow/minutesAgo/hoursAgo/dayAgo),30 天以上回退绝对值 // 相对时间:走 @/utils/time formatRelative(30 天以上回退绝对日期,档位与全局同源)
function relativeTime(millisStr: string): string { function relativeTime(millisStr: string): string {
const ms = Number(millisStr) const ms = Number(millisStr)
if (!ms) return '' if (!ms) return ''
const diff = Date.now() - ms return formatRelative(ms, { absAfterDays: 30 })
const min = Math.floor(diff / 60000)
if (min < 1) return t('common.justNow')
if (min < 60) return t('common.minutesAgo', { n: min })
const hr = Math.floor(min / 60)
if (hr < 24) return t('common.hoursAgo', { n: hr })
const day = Math.floor(hr / 24)
if (day < 30) return t('common.dayAgo', { n: day })
return t('common.ago', { time: new Date(ms).toLocaleDateString() })
} }
// 切换知识时重置引用列表「加载更多」上限 + 折叠态(对齐原 Knowledge.vue selectKnowledge 内 refLimit=10 行为) // 切换知识时重置引用列表「加载更多」上限 + 折叠态(对齐原 Knowledge.vue selectKnowledge 内 refLimit=10 行为)
+33 -2
View File
@@ -51,12 +51,14 @@
<!-- Footer --> <!-- Footer -->
<div class="sidebar-footer"> <div class="sidebar-footer">
<!-- AI 面板切换按钮 --> <!-- AI 面板切换按钮(footer 常驻跨路由渲染,承担后台运行中会话的全局可见标识) -->
<button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()" :title="collapsed ? $t('nav.aiPanel') : undefined"> <button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()" :title="runningConvCount > 0 ? `${runningConvCount} 个会话运行中` : (collapsed ? $t('nav.aiPanel') : undefined)">
<span class="nav-link-icon ai-spark-icon"> <span class="nav-link-icon ai-spark-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 3v3M21 4.5h-3M5 17v3M6.5 18.5h-3"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 3v3M21 4.5h-3M5 17v3M6.5 18.5h-3"/></svg>
</span> </span>
<span v-if="!collapsed" class="nav-link-text">{{ $t('nav.aiPanel') }}</span> <span v-if="!collapsed" class="nav-link-text">{{ $t('nav.aiPanel') }}</span>
<span v-if="!collapsed && runningConvCount > 0" class="nav-link-badge">{{ runningConvCount }}</span>
<span v-if="collapsed && runningConvCount > 0" class="ai-running-dot"></span>
<span v-if="!collapsed" class="ai-toggle-shortcut">⌘I</span> <span v-if="!collapsed" class="ai-toggle-shortcut">⌘I</span>
</button> </button>
<router-link to="/settings" class="nav-link" active-class="nav-link--active" :title="collapsed ? $t('nav.settings') : undefined"> <router-link to="/settings" class="nav-link" active-class="nav-link--active" :title="collapsed ? $t('nav.settings') : undefined">
@@ -83,6 +85,7 @@ import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { useAiStore } from '@/stores/ai' import { useAiStore } from '@/stores/ai'
import { useKnowledgeStore } from '@/stores/knowledge' import { useKnowledgeStore } from '@/stores/knowledge'
import { useAppSettingsStore } from '@/stores/appSettings' import { useAppSettingsStore } from '@/stores/appSettings'
import { convStates } from '@/composables/ai/aiShared'
const aiStore = useAiStore() const aiStore = useAiStore()
const knowledgeStore = useKnowledgeStore() const knowledgeStore = useKnowledgeStore()
@@ -135,6 +138,17 @@ onBeforeUnmount(() => {
// 知识库候选数量(侧栏 badge)— 解包 computed 取值保持响应式 // 知识库候选数量(侧栏 badge)— 解包 computed 取值保持响应式
const candidateCount = computed(() => knowledgeStore.candidateCount) const candidateCount = computed(() => knowledgeStore.candidateCount)
// 全局运行中会话数(AI 面板关闭或切到 Tasks/Ideas 等页面时,后台生成在 footer 常驻按钮上仍可见)。
// 真相源 convStates(reactive Map,消费后端 AiConvStateChanged,写入在 isCurrent 守卫之前故后台会话也更新),
// 非终止三态计入,与 stores/ai.ts isGenerating 桥接语义一致;computed 内遍历 Map 即建立响应式依赖
const runningConvCount = computed(() => {
let n = 0
for (const s of convStates.values()) {
if (s === 'generating' || s === 'stopping' || s === 'compressed') n++
}
return n
})
const primaryNav = [ const primaryNav = [
{ path: '/dashboard', label: 'nav.overview', badge: null, { path: '/dashboard', label: 'nav.overview', badge: null,
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>' }, svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>' },
@@ -349,6 +363,23 @@ const secondaryNav = [
border-radius: var(--df-radius-sm); border-radius: var(--df-radius-sm);
} }
/* 收缩态运行中标识:绿点钉在按钮右上角(展开态走 nav-link-badge 数字徽章,无空间放数字)。
脉冲范式与 ConversationSidebar 的 ai-conv-gen-dot 同源 */
.ai-running-dot {
position: absolute;
top: 2px;
right: 6px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--df-accent);
animation: ai-sidebar-gen-pulse 1.4s ease-in-out infinite;
}
@keyframes ai-sidebar-gen-pulse {
0%, 100% { opacity: 0.35; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1.15); }
}
/* — Footer — */ /* — Footer — */
.sidebar-footer { .sidebar-footer {
padding: 6px 8px; padding: 6px 8px;
+3 -11
View File
@@ -165,6 +165,7 @@
*/ */
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { moduleApi, type GitStatusResult } from '@/api/module' import { moduleApi, type GitStatusResult } from '@/api/module'
import { formatRelative } from '@/utils/time'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
const { t } = useI18n() const { t } = useI18n()
@@ -274,19 +275,10 @@ function statusLabel(s: string): string {
return x.charAt(0) || '?' return x.charAt(0) || '?'
} }
/** 格式化 Unix 时间戳为相对时间或具体时间 */ /** 格式化 Unix 时间戳为相对时间或具体时间(阶梯走 @/utils/time formatRelative:昨天档 + 30 天截断 ISO 日期) */
function formatTime(ts: number): string { function formatTime(ts: number): string {
if (!ts) return '' if (!ts) return ''
const now = Math.floor(Date.now() / 1000) return formatRelative(ts, { yesterday: true, absAfterDays: 30, absStyle: 'iso' })
const diffSec = now - ts
if (diffSec < 60) return t('common.justNow')
if (diffSec < 3600) return t('common.minutesAgo', { n: Math.floor(diffSec / 60) })
if (diffSec < 86400) return t('common.hoursAgo', { n: Math.floor(diffSec / 3600) })
if (diffSec < 172800) return t('common.yesterday')
if (diffSec < 2592000) return t('common.dayAgo', { n: Math.floor(diffSec / 86400) })
// 超过 30 天显示具体日期
const d = new Date(ts * 1000)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
} }
/** 计算文件路径的目录深度(用于缩进)。root 级文件返回 0。 */ /** 计算文件路径的目录深度(用于缩进)。root 级文件返回 0。 */
+10
View File
@@ -382,6 +382,7 @@ export const switchingConvs = new Set<string>()
// 本模块函数运行时惰性取最新 state。 // 本模块函数运行时惰性取最新 state。
export interface AiStateShape { export interface AiStateShape {
currentText: string currentText: string
streaming: boolean
messages: AiMessage[] messages: AiMessage[]
activeConversationId: string | null activeConversationId: string | null
} }
@@ -434,6 +435,15 @@ export function flushCurrentText(): void {
resetDelta() resetDelta()
return return
} }
// 非流式守卫:currentText 仅在流式进行中有意义。终态收尾后的迟到 delta、后台会话
// 完成后切回残留的缓冲,都会让 streaming=false 时 currentText 仍非空;此刻回填会把
// 上一轮已完成回复整条覆盖为残片(用户实测「空白内容覆盖了之前一大堆内容」),
// 故非流式一律只清不写。合法回填点(收尾/新轮/停止/超时)均在 streaming 翻 false 前调用。
if (!s.streaming) {
s.currentText = ''
resetDelta()
return
}
// 从末尾向前找最后一个非 isError assistant 气泡写入(跳过重试错误气泡,保留部分回复) // 从末尾向前找最后一个非 isError assistant 气泡写入(跳过重试错误气泡,保留部分回复)
for (let i = s.messages.length - 1; i >= 0; i--) { for (let i = s.messages.length - 1; i >= 0; i--) {
const m = s.messages[i] const m = s.messages[i]
+2
View File
@@ -176,6 +176,8 @@ function parseConvMessages(rawMsgs: any[], fallbackPrefix: string): AiMessage[]
id: (m.id && m.id !== '') ? m.id : `${fallbackPrefix}-${i}`, id: (m.id && m.id !== '') ? m.id : `${fallbackPrefix}-${i}`,
role: m.role, role: m.role,
content: m.content || '', content: m.content || '',
// 推理过程透传(DeepSeek/Anthropic thinking 同通道落库字段),undefined 保持缺省
reasoning_content: m.reasoning_content || undefined,
model: m.model, model: m.model,
// 用后端持久化的真实时间;老数据无 timestamp 字段回退 Date.now() // 用后端持久化的真实时间;老数据无 timestamp 字段回退 Date.now()
timestamp: typeof m.timestamp === 'number' ? m.timestamp : Date.now(), timestamp: typeof m.timestamp === 'number' ? m.timestamp : Date.now(),
+4
View File
@@ -97,6 +97,10 @@ export function handleEvent(event: AiChatEvent) {
if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') { if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') {
// 非当前会话终止:收敛会话状态(删 Map 项回不在生成) // 非当前会话终止:收敛会话状态(删 Map 项回不在生成)
convStates.delete(convId || '') convStates.delete(convId || '')
// 同步清该会话 per-conv 流式态。残留的 streaming/currentText 是死数据:切回时会被
// switchConversation 恢复进 currentText,下轮发送的防御 flush 又把它写进上一轮气泡,
// 把已完成回复覆盖为残片;回复正文以切换时 DB 加载为准,流式态无需保留。
clearConvStreamState(convId || '')
// FE1:后台会话终止只刷新列表(防抖调度,避免与 AiCompleted 收尾的 notify 双拉)。 // FE1:后台会话终止只刷新列表(防抖调度,避免与 AiCompleted 收尾的 notify 双拉)。
scheduleConversationsRefresh() scheduleConversationsRefresh()
// 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列) // 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列)
+3 -2
View File
@@ -56,10 +56,11 @@ function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | '
clearTextIdleTimer() // 清文本空闲定时器 + 置 textIdle=true(整轮结束不该有活跃信号残留) clearTextIdleTimer() // 清文本空闲定时器 + 置 textIdle=true(整轮结束不该有活跃信号残留)
flushCurrentText() flushCurrentText()
state.currentText = '' state.currentText = ''
// 清「重试中」过渡气泡(成功后残留根治;AiStreamRetry 推的 retry 气泡是过渡态,终态不清会永久残留) // 清「重试中」过渡气泡(成功后残留根治;AiStreamRetry 推的 retry 气泡是过渡态,终态不清会永久残留)。
// 按 isRetry 标记匹配,替代旧中文文案子串匹配(en locale 文案同样生效,文案改版不再漏清)
for (let i = state.messages.length - 1; i >= 0; i--) { for (let i = state.messages.length - 1; i >= 0; i--) {
const m = state.messages[i] const m = state.messages[i]
if (m.isError && typeof m.content === 'string' && m.content.includes('正在重试(')) { if (m.isRetry === true) {
state.messages.splice(i, 1) state.messages.splice(i, 1)
} }
} }
+7 -1
View File
@@ -150,7 +150,13 @@ async function loadProviders() {
} }
} }
/** 拉取本机技能列表(用于 `/` 联想,静默失败) */ /** 拉取本机技能列表(用于 `/` 联想,静默失败)。
*
* 项目级技能接线点(后端+IPC 已就绪):DevFlow 无全局「当前项目」语境(projectsStore 是
* 多项目清单,无 active root),故暂不传 projectPath(仅 home 级扫描)。后续有当前项目
* 上下文时改调 `aiApi.listSkills(projectPath)` 即启用 `<project>/.claude/skills` 扫描,
* 同名覆盖 home 级(source="project")。
*/
async function loadSkills() { async function loadSkills() {
try { try {
state.skills = await aiApi.listSkills() state.skills = await aiApi.listSkills()
+3
View File
@@ -505,6 +505,9 @@ async function sendQueuedNow(index: number) {
* 同时推一条 stopLoopFailed 错误消息让用户知晓停止未生效(可再点或等待收尾)。 * 同时推一条 stopLoopFailed 错误消息让用户知晓停止未生效(可再点或等待收尾)。
*/ */
async function stopChat() { async function stopChat() {
// 本地复位前先回填已流出文本:streaming 翻 false 后流式分块即清,迟到的 AiCompleted
// 又会因 flushCurrentText 的非流式守卫跳过回填,不在此处 flush 则部分回复随停止丢失。
flushCurrentText()
setStreaming(false, { convId: state.activeConversationId, reason: 'stopChat' }) // 本地先复位,不依赖后端 AiCompleted(审批态看门狗已 clear,竞态丢失则卡死) setStreaming(false, { convId: state.activeConversationId, reason: 'stopChat' }) // 本地先复位,不依赖后端 AiCompleted(审批态看门狗已 clear,竞态丢失则卡死)
clearStreamWatchdog() // 清残留看门狗 clearStreamWatchdog() // 清残留看门狗
// UX-260616-05 决策 a(逐条续发):不再清队列,保留供 AiCompleted 链式触发 drainQueue 续发。 // UX-260616-05 决策 a(逐条续发):不再清队列,保留供 AiCompleted 链式触发 drainQueue 续发。
+5 -1
View File
@@ -13,7 +13,7 @@
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers' import { t } from '@/i18n/i18n-helpers'
import { nextMsgId, convStates } from './aiShared' import { nextMsgId, convStates, flushCurrentText } from './aiShared'
import type { MessageId } from '@/api/types' import type { MessageId } from '@/api/types'
import { forceResetStreaming } from './streamingGuard' import { forceResetStreaming } from './streamingGuard'
import { emit } from '@tauri-apps/api/event' import { emit } from '@tauri-apps/api/event'
@@ -67,6 +67,10 @@ export function onStreamTimeout(convId?: string) {
// 不管 stillGenerating 检测(即使已经 false 也再清理一次,完备包底)。 // 不管 stillGenerating 检测(即使已经 false 也再清理一次,完备包底)。
console.warn(`[AI-Watchdog] 超时触发,强制收敛状态${convId ? `(convId=${convId})` : '(全局)'}`) console.warn(`[AI-Watchdog] 超时触发,强制收敛状态${convId ? `(convId=${convId})` : '(全局)'}`)
// 先把已流出的部分回复回填气泡再清态:forceResetStreaming 会清 currentText,streaming
// 翻 false 又清流式分块,不 flush 则屏幕上已显示的部分正文一并蒸发(内容空白消失)。
// flushCurrentText 只认当前活跃会话的流式态,仅本会话是当前视图时才 flush。
if (!convId || convId === state.activeConversationId) flushCurrentText()
// 彻底清理三处状态源,保证状态机收敛到 idle // 彻底清理三处状态源,保证状态机收敛到 idle
forceResetStreaming('onStreamTimeout(无数据包底)', convId) forceResetStreaming('onStreamTimeout(无数据包底)', convId)
if (convId) { if (convId) {
+5 -1
View File
@@ -63,17 +63,21 @@ export function handleStreamingEvent(event: AiChatEvent): boolean {
return true return true
case 'AiStreamRetry': { case 'AiStreamRetry': {
// 重试中——首条重试推新错误气泡,后续仅更新其 content(单气泡聚合,根治 N+1 气泡) // 重试中——首条重试推过渡气泡(isRetry 标记,MessageList 渲染为琥珀细条而非错误红卡),
// 后续仅更新其 content(单气泡聚合,根治 N+1 气泡)。isError 保持 true:
// flushCurrentText 靠它跳过本气泡,部分流式回复仍回填前一条 assistant 消息。
const lastMsg = state.messages[state.messages.length - 1] const lastMsg = state.messages[state.messages.length - 1]
const retryText = t('ai.aiStreamRetry', { attempt: event.attempt, max: event.max_attempts }) const retryText = t('ai.aiStreamRetry', { attempt: event.attempt, max: event.max_attempts })
if (lastMsg && lastMsg.isError) { if (lastMsg && lastMsg.isError) {
lastMsg.content = retryText lastMsg.content = retryText
lastMsg.isRetry = true
} else { } else {
state.messages.push({ state.messages.push({
id: `err-${nextMsgId()}` as MessageId, id: `err-${nextMsgId()}` as MessageId,
role: 'assistant', role: 'assistant',
content: retryText, content: retryText,
isError: true, isError: true,
isRetry: true,
timestamp: Date.now(), timestamp: Date.now(),
} as AiMessage) } as AiMessage)
} }
+3 -10
View File
@@ -12,6 +12,7 @@ import { useI18n } from 'vue-i18n'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
import { aiApi } from '@/api' import { aiApi } from '@/api'
import { getApprovalRemainingMs } from './aiShared' import { getApprovalRemainingMs } from './aiShared'
import { formatWaitTime, waitLevelOf, type WaitLevel } from '@/utils/time'
import type { AiToolCallInfo } from '@/api/types' import type { AiToolCallInfo } from '@/api/types'
/** /**
@@ -115,17 +116,9 @@ export function useToolApproval(tc: Ref<AiToolCallInfo>, emit: ToolApprovalEmit)
waitSecs.value = 0 waitSecs.value = 0
} }
function formatWaitTime(secs: number): string { // formatWaitTime/waitLevelOf 收敛至 @/utils/time(原与 ApprovalPopup 逐字双写)
const m = Math.floor(secs / 60)
const s = secs % 60
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
const waitLevel = computed<'normal' | 'long' | 'urgent'>(() => { const waitLevel = computed<WaitLevel>(() => waitLevelOf(waitSecs.value))
if (waitSecs.value >= 300) return 'urgent' // 5分钟
if (waitSecs.value >= 120) return 'long' // 2分钟
return 'normal'
})
// ── 审批到期倒计时(BUG-260624-02)── // ── 审批到期倒计时(BUG-260624-02)──
// 剩余 = aiShared 审批超时计时器真实起点推算(自动拒绝时刻精确对齐,非本地近似)。 // 剩余 = aiShared 审批超时计时器真实起点推算(自动拒绝时刻精确对齐,非本地近似)。
+12 -7
View File
@@ -21,6 +21,7 @@
//! - AiToolCallInfo(@/api/types) //! - AiToolCallInfo(@/api/types)
import { t } from '@/i18n/i18n-helpers' import { t } from '@/i18n/i18n-helpers'
import { formatDuration } from '@/utils/time'
import type { AiToolCallInfo } from '@/api/types' import type { AiToolCallInfo } from '@/api/types'
/** 工具结果 JSON 已知字段(全可选;list_* 运行时返回数组,靠 Array.isArray 区分) */ /** 工具结果 JSON 已知字段(全可选;list_* 运行时返回数组,靠 Array.isArray 区分) */
@@ -326,8 +327,9 @@ export function combineAndTruncateLines(stdout: string | undefined, stderr: stri
export function formatCommandResult(r: ToolResult): string { export function formatCommandResult(r: ToolResult): string {
const code = typeof r.exit_code === 'number' ? r.exit_code : -1 const code = typeof r.exit_code === 'number' ? r.exit_code : -1
const ms = typeof r.duration_ms === 'number' ? r.duration_ms : 0 const ms = typeof r.duration_ms === 'number' ? r.duration_ms : 0
// 耗时预格式化为人话时长(与卡片右侧耗时同源 formatDuration),不再双显两种口径
const head = code === 0 const head = code === 0
? t('aiTool.commandOk', { ms }) ? t('aiTool.commandOk', { time: formatDuration(ms) })
: t('aiTool.commandFailed', { code }) : t('aiTool.commandFailed', { code })
// 合并 stdout + stderr(实际 stderr 通常含报错),各自取前若干行 // 合并 stdout + stderr(实际 stderr 通常含报错),各自取前若干行
const MAX_LINES = 20 const MAX_LINES = 20
@@ -554,8 +556,9 @@ export function formatHttpResult(r: ToolResult): string {
const url = typeof r.url === 'string' ? r.url : '' const url = typeof r.url === 'string' ? r.url : ''
const ms = typeof r.elapsed_ms === 'number' ? r.elapsed_ms : 0 const ms = typeof r.elapsed_ms === 'number' ? r.elapsed_ms : 0
const ok = status >= 200 && status < 300 const ok = status >= 200 && status < 300
// 耗时预格式化为人话时长(与卡片右侧耗时同源 formatDuration),不再双显两种口径
const head = ok const head = ok
? t('aiTool.httpOk', { method, status, ms }) ? t('aiTool.httpOk', { method, status, time: formatDuration(ms) })
: t('aiTool.httpFailed', { method, status }) : t('aiTool.httpFailed', { method, status })
const parts: string[] = [head, url] const parts: string[] = [head, url]
const body = typeof r.body === 'string' ? r.body : '' const body = typeof r.body === 'string' ? r.body : ''
@@ -671,14 +674,15 @@ function summaryListDir(r: ToolResult): string {
function summaryRunCommand(r: ToolResult): string { function summaryRunCommand(r: ToolResult): string {
const code = typeof r.exit_code === 'number' ? r.exit_code : -1 const code = typeof r.exit_code === 'number' ? r.exit_code : -1
const ms = typeof r.duration_ms === 'number' ? r.duration_ms : 0 const ms = typeof r.duration_ms === 'number' ? r.duration_ms : 0
return code === 0 ? t('aiTool.commandOk', { ms }) : t('aiTool.commandFailed', { code }) // 耗时预格式化为人话时长(与卡片右侧耗时同源 formatDuration),不再双显两种口径
return code === 0 ? t('aiTool.commandOk', { time: formatDuration(ms) }) : t('aiTool.commandFailed', { code })
} }
/** file_info summary 版:存在性 + 大小(不带 modified/lines/binary extras) */ /** file_info summary 版:存在性 + 大小(不带 modified/lines/binary extras;路径与其他卡统一缩略尾段,全路径走头部 title) */
function summaryFileInfo(r: ToolResult): string { function summaryFileInfo(r: ToolResult): string {
return r.exists === false return r.exists === false
? t('aiTool.fileInfoMissing', { path: r.path || '' }) ? t('aiTool.fileInfoMissing', { path: shortPath(r.path || '') })
: t('aiTool.fileInfoSize', { path: r.path || '', size: formatBytes(typeof r.size === 'number' ? r.size : 0) }) : t('aiTool.fileInfoSize', { path: shortPath(r.path || ''), size: formatBytes(typeof r.size === 'number' ? r.size : 0) })
} }
/** rename_file summary 版:renamed===false 返空串(body 版走 formatJson) */ /** rename_file summary 版:renamed===false 返空串(body 版走 formatJson) */
@@ -712,8 +716,9 @@ function summaryHttpRequest(r: ToolResult): string {
const method = typeof r.method === 'string' ? r.method : 'HTTP' const method = typeof r.method === 'string' ? r.method : 'HTTP'
const ms = typeof r.elapsed_ms === 'number' ? r.elapsed_ms : 0 const ms = typeof r.elapsed_ms === 'number' ? r.elapsed_ms : 0
const ok = status >= 200 && status < 300 const ok = status >= 200 && status < 300
// 耗时预格式化为人话时长(与卡片右侧耗时同源 formatDuration),不再双显两种口径
return ok return ok
? t('aiTool.httpOk', { method, status, ms }) ? t('aiTool.httpOk', { method, status, time: formatDuration(ms) })
: t('aiTool.httpFailed', { method, status }) : t('aiTool.httpFailed', { method, status })
} }
+27 -22
View File
@@ -12,8 +12,13 @@
import { t } from '@/i18n/i18n-helpers' import { t } from '@/i18n/i18n-helpers'
import { isToolFailure } from '@/composables/ai/useToolCard' import { isToolFailure } from '@/composables/ai/useToolCard'
import { toolDisplayName } from '@/composables/ai/useToolCardHeader' import { toolDisplayName } from '@/composables/ai/useToolCardHeader'
import { formatDuration } from '@/utils/time'
import type { AiMessage, AiToolCallInfo } from '@/api/types' import type { AiMessage, AiToolCallInfo } from '@/api/types'
// formatDuration 下沉至 utils/time.ts(纯格式化函数归属);此处 re-export 保持既有引用兼容
// (本模块已 import useToolCard 的 isToolFailure,反向提供 formatDuration 会成循环依赖)
export { formatDuration } from '@/utils/time'
/** 一次执行轨迹(轮次)的工具调用聚合统计 */ /** 一次执行轨迹(轮次)的工具调用聚合统计 */
export interface TurnCallStats { export interface TurnCallStats {
/** 调用总数(含 running/pending/failed/rejected) */ /** 调用总数(含 running/pending/failed/rejected) */
@@ -96,7 +101,7 @@ export function statsTail(s: TurnCallStats): string {
* 头部 total=0 显「过程记录」;零值段省略;durationMs 可选(轨迹卡传起止差,分组行不传)。 * 头部 total=0 显「过程记录」;零值段省略;durationMs 可选(轨迹卡传起止差,分组行不传)。
* 供 MessageList 轨迹卡与 ToolCardList 聚合分组行共用,拼装语义单一出口。 * 供 MessageList 轨迹卡与 ToolCardList 聚合分组行共用,拼装语义单一出口。
*/ */
export function aggText(s: TurnCallStats, durationMs?: number): string { export function turnSummaryText(s: TurnCallStats, durationMs?: number): string {
const parts: string[] = [] const parts: string[] = []
parts.push(s.total > 0 ? t('ai.trace.calls', { n: s.total }) : t('ai.trace.processOnly')) parts.push(s.total > 0 ? t('ai.trace.calls', { n: s.total }) : t('ai.trace.processOnly'))
const tail = statsTail(s) const tail = statsTail(s)
@@ -106,29 +111,29 @@ export function aggText(s: TurnCallStats, durationMs?: number): string {
return parts.join(' · ') return parts.join(' · ')
} }
/**
* 时间跨度格式化。precise=true(单卡耗时)亚秒显毫秒;false(轨迹总耗时)亚秒归 <1s。
* 超时跨度进位到分/时。负值/非有限值返回空串(调用方隐藏该段)。
*/
export function formatDuration(ms: number | undefined | null, precise = false): string {
if (ms == null || !Number.isFinite(ms) || ms < 0) return ''
if (ms < 1000) return precise ? `${Math.round(ms)}ms` : '<1s'
const sec = ms / 1000
if (sec < 60) return `${sec.toFixed(sec < 10 ? 1 : 0)}s`
let min = Math.floor(sec / 60)
let remainSec = Math.round(sec % 60)
// 秒四舍五入进位(sec∈[119.5,120) 得 60s):并入分,避免显「1m 60s」
if (remainSec === 60) {
min++
remainSec = 0
}
if (min < 60) return `${min}m ${remainSec}s`
const hour = Math.floor(min / 60)
return `${hour}h ${min % 60}m`
}
/** 单次调用耗时文本(前端 startedAt/completedAt 差;历史消息无时间戳返回空串隐藏) */ /** 单次调用耗时文本(前端 startedAt/completedAt 差;历史消息无时间戳返回空串隐藏) */
export function callDurationText(startedAt?: number, completedAt?: number): string { export function callDurationText(startedAt?: number, completedAt?: number): string {
if (startedAt == null || completedAt == null) return '' if (startedAt == null || completedAt == null) return ''
return formatDuration(completedAt - startedAt, true) return formatDuration(completedAt - startedAt, true)
} }
/**
* 全程 token 汇总:一组消息的 in(prompt 全量)+cache+out+reason 求和(与 MessageList
* token 分项同源 fallback 链 tokenUsage → prompt_tokens 系列字段,不改后端协议)。
* 供执行轨迹卡头部「≈61k tokens」一处汇总,per-round 明细归思考 pill 行。
*/
export function sumTurnTokens(msgs: AiMessage[]): number {
let sum = 0
for (const m of msgs) {
sum += m.tokenUsage?.prompt ?? m.prompt_tokens ?? 0
sum += m.tokenUsage?.cache_hit ?? m.prompt_cache_hit_tokens ?? 0
sum += m.tokenUsage?.completion ?? m.completion_tokens ?? 0
sum += m.tokenUsage?.reasoning ?? m.reasoning_tokens ?? 0
}
return sum
}
/** token 数格式化:<1000 原值,≥1000 用 k 保留一位小数(1234→1.2k) */
export function formatTokensK(n: number): string {
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
}
+3 -3
View File
@@ -16,7 +16,7 @@ export function useTimerOwnership() {
const timeouts = new Set<ReturnType<typeof setTimeout>>() const timeouts = new Set<ReturnType<typeof setTimeout>>()
const intervals = new Set<ReturnType<typeof setInterval>>() const intervals = new Set<ReturnType<typeof setInterval>>()
/** 注册一次性 timeout;触发后自动从注册表移除。返回可交给 clearOwn 的 id。 */ /** 注册一次性 timeout;触发后自动从注册表移除。返回可交给 clearOwnTimeout 的 id。 */
function setOwnTimeout(fn: () => void, ms: number) { function setOwnTimeout(fn: () => void, ms: number) {
const id = setTimeout(() => { const id = setTimeout(() => {
timeouts.delete(id) timeouts.delete(id)
@@ -34,7 +34,7 @@ export function useTimerOwnership() {
} }
/** 清理指定 timeout(id 为 null/undefined 时 no-op)。 */ /** 清理指定 timeout(id 为 null/undefined 时 no-op)。 */
function clearOwn(id?: ReturnType<typeof setTimeout> | null) { function clearOwnTimeout(id?: ReturnType<typeof setTimeout> | null) {
if (id != null) { if (id != null) {
clearTimeout(id) clearTimeout(id)
timeouts.delete(id) timeouts.delete(id)
@@ -56,5 +56,5 @@ export function useTimerOwnership() {
intervals.clear() intervals.clear()
}) })
return { setOwnTimeout, setOwnInterval, clearOwn, clearOwnInterval } return { setOwnTimeout, setOwnInterval, clearOwnTimeout, clearOwnInterval }
} }
+3 -3
View File
@@ -34,14 +34,14 @@ export function useToast(defaultDurationMs = 3000) {
// 会清掉组件 A 的 timer,致 A 的 toast 永不隐藏(跨实例 bug)。 // 会清掉组件 A 的 timer,致 A 的 toast 永不隐藏(跨实例 bug)。
// 已下沉为「每实例独立 timer」:useTimerOwnership 每实例注册表 + onUnmounted 只清自己。 // 已下沉为「每实例独立 timer」:useTimerOwnership 每实例注册表 + onUnmounted 只清自己。
// 不再保留模块级兜底 —— 模块级共享正是串扰根源,每实例自清理即正确语义。 // 不再保留模块级兜底 —— 模块级共享正是串扰根源,每实例自清理即正确语义。
const { setOwnTimeout, clearOwn } = useTimerOwnership() const { setOwnTimeout, clearOwnTimeout } = useTimerOwnership()
let _timer: ReturnType<typeof setTimeout> | null = null let _timer: ReturnType<typeof setTimeout> | null = null
function showToast(msg: string, type: ToastType = 'info', durationMs?: number) { function showToast(msg: string, type: ToastType = 'info', durationMs?: number) {
toast.msg = msg toast.msg = msg
toast.type = type toast.type = type
toast.visible = true toast.visible = true
if (_timer) clearOwn(_timer) if (_timer) clearOwnTimeout(_timer)
_timer = setOwnTimeout(() => { _timer = setOwnTimeout(() => {
_timer = null _timer = null
toast.visible = false toast.visible = false
@@ -50,7 +50,7 @@ export function useToast(defaultDurationMs = 3000) {
function hideToast() { function hideToast() {
if (_timer) { if (_timer) {
clearOwn(_timer) clearOwnTimeout(_timer)
_timer = null _timer = null
} }
toast.visible = false toast.visible = false
+20
View File
@@ -4,6 +4,8 @@
//! 根治此前三套状态映射互相矛盾、任务文案两处不一致的问题。 //! 根治此前三套状态映射互相矛盾、任务文案两处不一致的问题。
//! 项目进度不在此处(按 stage 拍百分比已废弃),由任务完成率计算,见 store.projectTaskProgress。 //! 项目进度不在此处(按 stage 拍百分比已废弃),由任务完成率计算,见 store.projectTaskProgress。
import type { IdeaStatus } from '@/api/types'
// ── 项目状态 ── // ── 项目状态 ──
// 值为 i18n key,实际文案走 $t('projects.status.<key>')(见 src/i18n/{zh-CN,en}/projects.ts) // 值为 i18n key,实际文案走 $t('projects.status.<key>')(见 src/i18n/{zh-CN,en}/projects.ts)
@@ -221,3 +223,21 @@ export function priorityLabel(p: number): string {
export function priorityClass(p: number): string { export function priorityClass(p: number): string {
return PRIORITY_CLASSES[p] ?? 'priority-low' return PRIORITY_CLASSES[p] ?? 'priority-low'
} }
// ── 灵感状态 ──
// 值为 i18n key,实际文案走 $t(见 src/i18n/{zh-CN,en}/ideas.ts)。
// 单一数据源:Ideas.vue 列表 badge/确认文案 与 IdeaDetail.vue 详情 badge/状态下拉共用
// (原 Ideas.vue 自持 + 经 props 透传 IdeaDetail 双写,已收敛至此;archived 无菜单入口不入表)。
export const IDEA_STATUS_OPTIONS: { value: IdeaStatus; labelKey: string }[] = [
{ value: 'draft', labelKey: 'ideas.status.draft' },
{ value: 'pending_review', labelKey: 'ideas.status.pending_review' },
{ value: 'approved', labelKey: 'ideas.status.approved' },
{ value: 'promoted', labelKey: 'ideas.status.promoted' },
{ value: 'rejected', labelKey: 'ideas.status.rejected' },
]
/** 任意 status → 对应 i18n key;未知状态(含 archived)回退到原值显示 */
export function ideaStatusLabelKey(status: IdeaStatus): string {
return IDEA_STATUS_OPTIONS.find(s => s.value === status)?.labelKey ?? status
}
+4 -3
View File
@@ -22,9 +22,9 @@ export default {
errorAuth: 'Request failed: the API key is invalid or lacks permission.', errorAuth: 'Request failed: the API key is invalid or lacks permission.',
errorTimeout: 'The response timed out. Please try again.', errorTimeout: 'The response timed out. Please try again.',
errorNetwork: 'Network connection failed. Please check your network.', errorNetwork: 'Network connection failed. Please check your network.',
// CR-30-2 / F-260616-07(d) / decision a1: stream pre-failure retry bubble copy // CR-30-2 / F-260616-07(d) / decision a1: copy for the auto-retry transition line
// clarify this is an AI response (network/rate-limit) retry, not a tool failure (shown in card) // (neutral wording: a retry is transient, not a terminal failure; exhausted retries still surface the AiError card)
aiStreamRetry: '⚠ AI response failed, retrying ({attempt}/{max})…', aiStreamRetry: 'Connection interrupted, auto-retrying ({attempt}/{max})…',
// Fatal errors (4xx/auth) are not retryable — prompt user to check settings instead // Fatal errors (4xx/auth) are not retryable — prompt user to check settings instead
notRetryable: 'This error cannot be resolved by retrying. Please check the configuration.', notRetryable: 'This error cannot be resolved by retrying. Please check the configuration.',
streamInterruptedAfterTool: '⚠ The tool finished, but the following reply was interrupted (no data stream for a long time). You can click Continue to retry.', streamInterruptedAfterTool: '⚠ The tool finished, but the following reply was interrupted (no data stream for a long time). You can click Continue to retry.',
@@ -67,6 +67,7 @@ export default {
duration: 'total {time}', duration: 'total {time}',
expand: 'Expand trace', expand: 'Expand trace',
collapse: 'Collapse trace', collapse: 'Collapse trace',
roundN: 'Round {n}',
processOnly: 'Process log', processOnly: 'Process log',
stepDone: '✓ {n} calls done', stepDone: '✓ {n} calls done',
stepRunning: 'running {name}', stepRunning: 'running {name}',
+4
View File
@@ -250,6 +250,10 @@ export default {
// Auto-approval toast (same-session already approved same-kind op: same tool + same dir) // Auto-approval toast (same-session already approved same-kind op: same tool + same dir)
autoApprovedToast: 'Auto-approved: {tool}({dir})', autoApprovedToast: 'Auto-approved: {tool}({dir})',
// ── Skill injection feedback (success/failure light toast for /skill resolve) ──
skillInjectedToast: 'Skill "{name}" injected',
skillNotFoundToast: 'Skill {name} not found',
// ── CIStatus.vue (commit CI checks status card) ── // ── CIStatus.vue (commit CI checks status card) ──
ciTitle: 'CI Checks', ciTitle: 'CI Checks',
ciNFailed: '{n} failed', ciNFailed: '{n} failed',
+9 -5
View File
@@ -15,14 +15,18 @@ export default {
// tool execution failure marker (AR-6 keeps status=completed, red to distinguish from success) // tool execution failure marker (AR-6 keeps status=completed, red to distinguish from success)
executionFailed: 'Failed', executionFailed: 'Failed',
executionFailedHint: 'Tool execution failed, see details below', executionFailedHint: 'Tool execution failed, see details below',
// Singular expand/collapse (ToolCard single-card content, command output toggle; expandAll/collapseAll are for ToolCardList global multi-group) // Singular expand/collapse (ToolCard single-card content, command output toggle)
expand: 'Expand', expand: 'Expand',
collapse: 'Collapse', collapse: 'Collapse',
expandAll: 'Expand all',
collapseAll: 'Collapse all',
written: 'written', written: 'written',
items: 'items', items: 'items',
lines: 'lines', lines: 'lines',
// Status dot tooltips (ToolCard green/red/blue dot hover, zh-en paired)
statusOk: 'Success',
statusFailed: 'Failed',
statusRunning: 'Running',
statusPending: 'Pending approval',
statusRejected: 'Rejected',
// UX-260618-08/11: truncation hints (read_file paged truncation / list_directory 1000-entry cap) // UX-260618-08/11: truncation hints (read_file paged truncation / list_directory 1000-entry cap)
linesTruncated: '{shown}/{total} lines, truncated', linesTruncated: '{shown}/{total} lines, truncated',
dirTruncated: 'Truncated, first {n} items only', dirTruncated: 'Truncated, first {n} items only',
@@ -74,7 +78,7 @@ export default {
advancedTask: 'Task「{title}」 advanced to {status}', advancedTask: 'Task「{title}」 advanced to {status}',
// advance_task return-round accumulation (shown only when >0); {title} = task name (TaskRecord), fallback id // advance_task return-round accumulation (shown only when >0); {title} = task name (TaskRecord), fallback id
advancedTaskWithRounds: 'Task「{title}」 advanced to {status} (returned {n} times)', advancedTaskWithRounds: 'Task「{title}」 advanced to {status} (returned {n} times)',
commandOk: 'Command succeeded ({ms} ms)', commandOk: 'Command succeeded ({time})',
commandFailed: 'Command failed (exit code {code})', commandFailed: 'Command failed (exit code {code})',
commandOutputLines: '{n} lines of output', commandOutputLines: '{n} lines of output',
patchedFile: 'Patched {path} ({diff} bytes)', patchedFile: 'Patched {path} ({diff} bytes)',
@@ -90,7 +94,7 @@ export default {
writeOverwrite: 'overwrote {old} → {neww}', writeOverwrite: 'overwrote {old} → {neww}',
encodingLabel: 'encoding: {enc}', encodingLabel: 'encoding: {enc}',
// http_request result summary (method + status + elapsed; body in expanded view) // http_request result summary (method + status + elapsed; body in expanded view)
httpOk: '{method} {status} OK ({ms} ms)', httpOk: '{method} {status} OK ({time})',
httpFailed: '{method} {status} failed', httpFailed: '{method} {status} failed',
httpTruncated: 'Response truncated ({bytes}), see details', httpTruncated: 'Response truncated ({bytes}), see details',
httpRequestFallback: 'HTTP Request', httpRequestFallback: 'HTTP Request',
+5
View File
@@ -203,6 +203,11 @@ export default {
pathAuthBypassConfirmTitle: 'Enable "Trust all directories"?', pathAuthBypassConfirmTitle: 'Enable "Trust all directories"?',
pathAuthBypassConfirmBody: 'Once enabled, AI tools will no longer ask for per-access authorization and can directly reach any path, including sensitive directories such as .ssh and .aws. Sensitive credentials may end up in the conversation context; proceed at your own risk. Are you sure you want to enable it?', pathAuthBypassConfirmBody: 'Once enabled, AI tools will no longer ask for per-access authorization and can directly reach any path, including sensitive directories such as .ssh and .aws. Sensitive credentials may end up in the conversation context; proceed at your own risk. Are you sure you want to enable it?',
// Anthropic prompt caching (Settings - AI Model; KV df-ai-anthropic-cache, default off, effective immediately)
panelAnthropicCache: 'AI Prompt Caching',
labelAnthropicCache: 'Enable prompt caching',
descAnthropicCache: 'When enabled, Anthropic-protocol endpoints get explicit cache breakpoints on the system prompt and conversation history, greatly reducing token cost for long sessions; OpenAI-compatible endpoints also get prefix-stable splitting (volatile content moves to the end of the message stream so automatic prefix caches can hit). Only enable for endpoints that support caching; gateways without support may reject the cache_control field.',
// ===== ScriptNode command execution safety ===== // ===== ScriptNode command execution safety =====
panelScriptSafety: '🛡️ Command Execution Safety', panelScriptSafety: '🛡️ Command Execution Safety',
descScriptSafety: 'Configure ScriptNode command whitelist/blacklist. Command name is the first token (e.g. `rm -rf /` -> `rm`), compared trimmed and lowercase. Blacklist takes precedence over whitelist.', descScriptSafety: 'Configure ScriptNode command whitelist/blacklist. Command name is the first token (e.g. `rm -rf /` -> `rm`), compared trimmed and lowercase. Blacklist takes precedence over whitelist.',
+4 -3
View File
@@ -22,9 +22,9 @@ export default {
errorAuth: '调用失败:API Key 无效或无权限', errorAuth: '调用失败:API Key 无效或无权限',
errorTimeout: '响应超时,请重试', errorTimeout: '响应超时,请重试',
errorNetwork: '网络连接失败,请检查网络', errorNetwork: '网络连接失败,请检查网络',
// CR-30-2 / F-260616-07(d) / 决策 a1: 流前失败重试中错误气泡内更新文案 // CR-30-2 / F-260616-07(d) / 决策 a1: 流中断自动重试的过渡细条文案(中性描述:
// 明确这是 AI 响应(网络/限流)重试,非工具执行失败(工具失败在卡片内显示) // 重试是过渡态非终态失败,不用错误警示语气;重试耗尽的终态失败仍走 AiError 错误卡)
aiStreamRetry: '⚠ AI 响应失败,正在重试({attempt}/{max})…', aiStreamRetry: '与上游连接中断,自动重试中({attempt}/{max})…',
// Fatal 类错误(4xx/鉴权)不可重试,提示用户去设置而非重试 // Fatal 类错误(4xx/鉴权)不可重试,提示用户去设置而非重试
notRetryable: '此错误无法通过重试解决,请检查配置', notRetryable: '此错误无法通过重试解决,请检查配置',
streamInterruptedAfterTool: '⚠ 工具已执行完成,后续回复中断(长时间无数据流)。可点继续重试。', streamInterruptedAfterTool: '⚠ 工具已执行完成,后续回复中断(长时间无数据流)。可点继续重试。',
@@ -67,6 +67,7 @@ export default {
duration: '总耗时 {time}', duration: '总耗时 {time}',
expand: '展开轨迹', expand: '展开轨迹',
collapse: '收起轨迹', collapse: '收起轨迹',
roundN: '第 {n} 轮',
processOnly: '过程记录', processOnly: '过程记录',
stepDone: '✓ {n} 次调用完成', stepDone: '✓ {n} 次调用完成',
stepRunning: '正在 {name}', stepRunning: '正在 {name}',
+4
View File
@@ -251,6 +251,10 @@ export default {
// 自动放行 toast(同会话已批准过同类操作:同工具+同目录,轻量 info 非审批气泡) // 自动放行 toast(同会话已批准过同类操作:同工具+同目录,轻量 info 非审批气泡)
autoApprovedToast: '已自动放行: {tool}({dir})', autoApprovedToast: '已自动放行: {tool}({dir})',
// ── 技能注入反馈闭环(/ 技能 resolve 成功/失败轻 toast) ──
skillInjectedToast: '已注入技能《{name}》',
skillNotFoundToast: '技能 {name} 未找到',
// ── CIStatus.vue(代码提交 CI 检查状态卡)── // ── CIStatus.vue(代码提交 CI 检查状态卡)──
ciTitle: 'CI 检查', ciTitle: 'CI 检查',
ciNFailed: '{n} 项失败', ciNFailed: '{n} 项失败',
+8 -4
View File
@@ -16,12 +16,16 @@ export default {
executionFailed: '执行失败', executionFailed: '执行失败',
executionFailedHint: '工具执行失败,详情见下方', executionFailedHint: '工具执行失败,详情见下方',
collapse: '收起', collapse: '收起',
expandAll: '展开全部',
expand: '展开', expand: '展开',
collapseAll: '全部收起',
written: '已写入', written: '已写入',
items: '项', items: '项',
lines: '行', lines: '行',
// 状态点 tooltip(ToolCard 绿/红/蓝点 hover,中英成对)
statusOk: '成功',
statusFailed: '失败',
statusRunning: '进行中',
statusPending: '待审批',
statusRejected: '已拒绝',
// UX-260618-08/11:截断提示(read_file 分页截断 / list_directory 1000 上限截断) // UX-260618-08/11:截断提示(read_file 分页截断 / list_directory 1000 上限截断)
linesTruncated: '{shown}/{total} 行,已截断', linesTruncated: '{shown}/{total} 行,已截断',
dirTruncated: '已截断,仅前 {n} 项', dirTruncated: '已截断,仅前 {n} 项',
@@ -75,7 +79,7 @@ export default {
advancedTask: '任务「{title}」已推进至 {status}', advancedTask: '任务「{title}」已推进至 {status}',
// advance_task 退回累加轮数(>0 才显);{title} 显任务名(后端返 TaskRecord),回退 id // advance_task 退回累加轮数(>0 才显);{title} 显任务名(后端返 TaskRecord),回退 id
advancedTaskWithRounds: '任务「{title}」已推进至 {status}(已退回 {n} 次)', advancedTaskWithRounds: '任务「{title}」已推进至 {status}(已退回 {n} 次)',
commandOk: '命令成功({ms} 毫秒)', commandOk: '命令成功({time})',
commandFailed: '命令失败(退出码 {code})', commandFailed: '命令失败(退出码 {code})',
commandOutputLines: '{n} 行输出', commandOutputLines: '{n} 行输出',
patchedFile: '已修改 {path}(增减 {diff} 字节)', patchedFile: '已修改 {path}(增减 {diff} 字节)',
@@ -91,7 +95,7 @@ export default {
writeOverwrite: '覆盖 {old} → {neww}', writeOverwrite: '覆盖 {old} → {neww}',
encodingLabel: '编码:{enc}', encodingLabel: '编码:{enc}',
// http_request 结果摘要(method + 状态码 + 耗时;body 在展开态显示) // http_request 结果摘要(method + 状态码 + 耗时;body 在展开态显示)
httpOk: '{method} {status} 成功({ms} 毫秒)', httpOk: '{method} {status} 成功({time})',
httpFailed: '{method} {status} 失败', httpFailed: '{method} {status} 失败',
httpTruncated: '响应已截断({bytes}),详见下方', httpTruncated: '响应已截断({bytes}),详见下方',
httpRequestFallback: 'HTTP 请求', httpRequestFallback: 'HTTP 请求',
+5
View File
@@ -202,6 +202,11 @@ export default {
pathAuthBypassConfirmTitle: '开启「信任所有目录」?', pathAuthBypassConfirmTitle: '开启「信任所有目录」?',
pathAuthBypassConfirmBody: '开启后 AI 工具将无需逐次授权,直接访问包括 .ssh、.aws 等敏感目录在内的任意路径,敏感凭据可能进入对话上下文,风险自担。确定开启?', pathAuthBypassConfirmBody: '开启后 AI 工具将无需逐次授权,直接访问包括 .ssh、.aws 等敏感目录在内的任意路径,敏感凭据可能进入对话上下文,风险自担。确定开启?',
// Anthropic prompt caching(设置-AI 模型;KV df-ai-anthropic-cache,默认关,改后即时生效)
panelAnthropicCache: 'AI Prompt 缓存',
labelAnthropicCache: '启用 prompt 缓存',
descAnthropicCache: '开启后 Anthropic 协议端点在 system 与历史段打显式缓存断点,长会话显著省 token;OpenAI 兼容端点同时启用前缀稳定拆分(易变内容挪到消息流末尾,自动前缀缓存可命中)。仅对支持缓存的端点开启,不支持的网关可能拒收 cache_control 字段。',
// ===== ScriptNode 命令执行安全 ===== // ===== ScriptNode 命令执行安全 =====
panelScriptSafety: '🛡️ 命令执行安全', panelScriptSafety: '🛡️ 命令执行安全',
descScriptSafety: '配置 ScriptNode 命令白/黑名单。命令名取首词(如 `rm -rf /` 取 `rm`),按 trim + 小写比较。黑名单优先于白名单。', descScriptSafety: '配置 ScriptNode 命令白/黑名单。命令名取首词(如 `rm -rf /` 取 `rm`),按 trim + 小写比较。黑名单优先于白名单。',
+1 -1
View File
@@ -253,7 +253,7 @@ const SCALAR_COMMANDS: Record<string, () => unknown> = {
settings_get_all: () => ({}), settings_get_all: () => ({}),
settings_set: () => true, settings_set: () => true,
settings_delete: () => true, settings_delete: () => true,
get_plan_execution: () => false, is_plan_execution_enabled: () => false,
get_data_dir: () => 'C:\\devflow-browser-mock-data', get_data_dir: () => 'C:\\devflow-browser-mock-data',
ai_is_generating: () => false, ai_is_generating: () => false,
list_tool_executions: () => ({ items: [], total: 0, has_more: false }), list_tool_executions: () => ({ items: [], total: 0, has_more: false }),
+74 -2
View File
@@ -44,8 +44,19 @@ export function formatDate(v: string | number | null | undefined): string {
return d.toLocaleDateString(locale) + ' ' + d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }) return d.toLocaleDateString(locale) + ' ' + d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })
} }
/** 相对时间(走 i18n:justNow/minutesAgo/hoursAgo/dayAgo;en/zh 双语);无效返回 '—' */ /** 相对时间分档差异参数(formatRelative 选项;不传 = 原默认阶梯) */
export function formatRelative(v: string | number | null | undefined): string { export interface FormatRelativeOpts {
/** 开启「昨天」档:24~48h 显示 common.yesterday(如 GitChanges 提交时间),默认关 */
yesterday?: boolean
/** 超过 N 天回退绝对日期;缺省不截断,一直显示「N 天前」 */
absAfterDays?: number
/** 绝对日期回退样式:'ago' = 「{date}前」(i18n,默认);'iso' = YYYY-MM-DD(定宽,列表对齐用) */
absStyle?: 'ago' | 'iso'
}
/** 相对时间(走 i18n:justNow/minutesAgo/hoursAgo/dayAgo;en/zh 双语);无效返回 '—'。
* 分档差异(yesterday 档 / N 天截断回退绝对日期)经 opts 表达,不传保持原行为 */
export function formatRelative(v: string | number | null | undefined, opts?: FormatRelativeOpts): string {
const ms = parseTs(v) const ms = parseTs(v)
if (ms == null) return '—' if (ms == null) return '—'
const diffMin = Math.floor((Date.now() - ms) / 60000) const diffMin = Math.floor((Date.now() - ms) / 60000)
@@ -54,5 +65,66 @@ export function formatRelative(v: string | number | null | undefined): string {
const diffH = Math.floor(diffMin / 60) const diffH = Math.floor(diffMin / 60)
if (diffH < 24) return t('common.hoursAgo', { n: diffH }) if (diffH < 24) return t('common.hoursAgo', { n: diffH })
const diffD = Math.floor(diffH / 24) const diffD = Math.floor(diffH / 24)
if (opts?.yesterday && diffD < 2) return t('common.yesterday')
if (opts?.absAfterDays != null && diffD >= opts.absAfterDays) {
const d = new Date(ms)
if (opts.absStyle === 'iso') {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
return t('common.ago', { time: d.toLocaleDateString() })
}
return t('common.dayAgo', { n: diffD }) return t('common.dayAgo', { n: diffD })
} }
/** 消息间隔分隔线用绝对时间:当天只 HH:mm,跨天 MM-DD HH:mm;无效返回 '—'(格式定宽不随 locale) */
export function formatAbsolute(v: string | number | null | undefined): string {
const ms = parseTs(v)
if (ms == null) return '—'
const d = new Date(ms)
const now = new Date()
const sameDay = d.getFullYear() === now.getFullYear()
&& d.getMonth() === now.getMonth()
&& d.getDate() === now.getDate()
const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
if (sameDay) return hm
return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${hm}`
}
/**
* 时间跨度格式化。precise=true(单卡耗时)亚秒显毫秒;false(轨迹总耗时)亚秒归 <1s。
* 超时跨度进位到分/时。负值/非有限值返回空串(调用方隐藏该段)。
*/
export function formatDuration(ms: number | undefined | null, precise = false): string {
if (ms == null || !Number.isFinite(ms) || ms < 0) return ''
if (ms < 1000) return precise ? `${Math.round(ms)}ms` : '<1s'
const sec = ms / 1000
if (sec < 60) return `${sec.toFixed(sec < 10 ? 1 : 0)}s`
let min = Math.floor(sec / 60)
let remainSec = Math.round(sec % 60)
// 秒四舍五入进位(sec∈[119.5,120) 得 60s):并入分,避免显「1m 60s」
if (remainSec === 60) {
min++
remainSec = 0
}
if (min < 60) return `${min}m ${remainSec}s`
const hour = Math.floor(min / 60)
return `${hour}h ${min % 60}m`
}
// ── 审批等待时长(ToolCard / ApprovalPopup 双写收敛) ──
export type WaitLevel = 'normal' | 'long' | 'urgent'
/** 秒 → MM:SS(分秒两位补零,审批挂起时长展示) */
export function formatWaitTime(secs: number): string {
const m = Math.floor(secs / 60)
const s = secs % 60
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
/** 等待分级阈值(与后端 APPROVAL_TIMEOUT_MS 5min 静默拒绝对齐):≥300s urgent / ≥120s long */
export function waitLevelOf(secs: number): WaitLevel {
if (secs >= 300) return 'urgent'
if (secs >= 120) return 'long'
return 'normal'
}
+4 -15
View File
@@ -55,7 +55,7 @@
<!-- H1 修复:审批挂起时长提示(对齐 ToolCard: >2min 橙色 / >5min 红色脉冲)。 <!-- H1 修复:审批挂起时长提示(对齐 ToolCard: >2min 橙色 / >5min 红色脉冲)。
后端 APPROVAL_TIMEOUT_MS 5min 静默拒绝,浮窗原本无计时,用户误以为还有时间。 后端 APPROVAL_TIMEOUT_MS 5min 静默拒绝,浮窗原本无计时,用户误以为还有时间。
基于审批到达时间(popup 首次见到该 tc.id 的时刻)计时,与 ToolCard 同款语义同款样式。 --> 基于审批到达时间(popup 首次见到该 tc.id 的时刻)计时,与 ToolCard 同款语义同款样式。 -->
<div v-if="waitSecsFor(tc.id) > 0" class="popup-item-wait" :class="'is-' + waitLevelFor(tc.id)"> <div v-if="waitSecsFor(tc.id) > 0" class="popup-item-wait" :class="'is-' + waitLevelOf(waitSecsFor(tc.id))">
{{ $t('aiTool.waitTime', { time: formatWaitTime(waitSecsFor(tc.id)) }) }} {{ $t('aiTool.waitTime', { time: formatWaitTime(waitSecsFor(tc.id)) }) }}
</div> </div>
<!-- path 类:目录提示 --> <!-- path 类:目录提示 -->
@@ -153,6 +153,7 @@ import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { toolDisplayName } from '@/composables/ai/useToolCardHeader' import { toolDisplayName } from '@/composables/ai/useToolCardHeader'
import { dispatchApprovalIPC } from '@/composables/ai/useToolApproval' import { dispatchApprovalIPC } from '@/composables/ai/useToolApproval'
import { ApprovalPopupEvents } from '@/composables/ai/useApprovalPopup' import { ApprovalPopupEvents } from '@/composables/ai/useApprovalPopup'
import { formatWaitTime, waitLevelOf } from '@/utils/time'
import ConfirmDialog from '@/components/ConfirmDialog.vue' import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import type { AiToolCallInfo } from '@/api/types' import type { AiToolCallInfo } from '@/api/types'
@@ -189,30 +190,18 @@ function answerConfirm(ok: boolean) {
// ── H1:审批挂起时长计时(对齐 ToolCard useToolApproval 的 waitSecs/waitLevel)── // ── H1:审批挂起时长计时(对齐 ToolCard useToolApproval 的 waitSecs/waitLevel)──
// 浮窗独立于主窗口,不直接复用 useToolApproval(那个绑定单 tc + 完整状态机 emit/防抖, // 浮窗独立于主窗口,不直接复用 useToolApproval(那个绑定单 tc + 完整状态机 emit/防抖,
// 浮窗已用 processingId/runApproval 自管)。这里基于「到达时间」(popup 首次见到 tc.id 的时刻) // 浮窗已用 processingId/runApproval 自管)。这里基于「到达时间」(popup 首次见到 tc.id 的时刻)
// 每秒刷新。语义/样式/阈值与 ToolCard 同款:120s long / 300s urgent。 // 每秒刷新。格式化(formatWaitTime)与分级阈值(waitLevelOf,120s long / 300s urgent)
// 收敛至 @/utils/time,与 ToolCard 同源。
/** tc.id → 到达时刻(ms)。审批首次出现在 approvals 即记时;移除即清。 */ /** tc.id → 到达时刻(ms)。审批首次出现在 approvals 即记时;移除即清。 */
const arrivalTimes = reactive(new Map<string, number>()) const arrivalTimes = reactive(new Map<string, number>())
/** tc.id → 已等待秒数(每秒 tick 更新)。 */ /** tc.id → 已等待秒数(每秒 tick 更新)。 */
const waitSecsMap = reactive(new Map<string, number>()) const waitSecsMap = reactive(new Map<string, number>())
let _waitTimer: ReturnType<typeof setInterval> | null = null let _waitTimer: ReturnType<typeof setInterval> | null = null
/** 格式化秒为 MM:SS(与 useToolApproval.formatWaitTime 同款) */
function formatWaitTime(secs: number): string {
const m = Math.floor(secs / 60)
const s = secs % 60
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
/** 取某条审批已等待秒数(无记录返回 0) */ /** 取某条审批已等待秒数(无记录返回 0) */
function waitSecsFor(id: string): number { function waitSecsFor(id: string): number {
return waitSecsMap.get(id) ?? 0 return waitSecsMap.get(id) ?? 0
} }
/** 取等待级别(对齐 ToolCard: 300s urgent / 120s long / 其余 normal) */
function waitLevelFor(id: string): 'normal' | 'long' | 'urgent' {
const s = waitSecsFor(id)
if (s >= 300) return 'urgent'
if (s >= 120) return 'long'
return 'normal'
}
/** 每秒刷新所有挂起审批的 waitSecs(单 timer 驱动多 tc,避免每条一个 interval) */ /** 每秒刷新所有挂起审批的 waitSecs(单 timer 驱动多 tc,避免每条一个 interval) */
function _tickWaitSecs() { function _tickWaitSecs() {
const now = Date.now() const now = Date.now()

Some files were not shown because too many files have changed in this diff Show More