Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26d9a1af1e | ||
|
|
1e6db6f310 | ||
|
|
bf0db10ef7 | ||
|
|
7dcbdb1feb | ||
|
|
4417b3ddd3 | ||
|
|
b8416dea63 | ||
|
|
423acfdd00 | ||
|
|
2187ffe173 | ||
|
|
524120b110 | ||
|
|
e1dd0bc3a3 | ||
|
|
66c6553cdf | ||
|
|
0560137840 | ||
|
|
6ef15a0940 | ||
|
|
75c5e23523 |
Generated
+122
-12
@@ -872,6 +872,7 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-window-state",
|
||||
@@ -2527,6 +2528,20 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "markup5ever"
|
||||
version = "0.38.0"
|
||||
@@ -2688,6 +2703,20 @@ dependencies = [
|
||||
"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]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -4455,7 +4484,7 @@ dependencies = [
|
||||
"tao-macros",
|
||||
"unicode-segmentation",
|
||||
"url",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-version",
|
||||
"x11-dl",
|
||||
@@ -4526,7 +4555,7 @@ dependencies = [
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"window-vibrancy",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4649,6 +4678,25 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.4"
|
||||
@@ -4667,7 +4715,7 @@ dependencies = [
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"zbus 5.16.0",
|
||||
]
|
||||
|
||||
@@ -4724,7 +4772,7 @@ dependencies = [
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4749,7 +4797,7 @@ dependencies = [
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"wry",
|
||||
]
|
||||
|
||||
@@ -4802,6 +4850,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -5846,7 +5905,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
|
||||
dependencies = [
|
||||
"webview2-com-macros",
|
||||
"webview2-com-sys",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
@@ -5870,7 +5929,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
@@ -5926,11 +5985,23 @@ version = "0.61.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-collections 0.2.0",
|
||||
"windows-core 0.61.2",
|
||||
"windows-future",
|
||||
"windows-future 0.2.1",
|
||||
"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]]
|
||||
@@ -5942,6 +6013,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "windows-core"
|
||||
version = "0.61.2"
|
||||
@@ -5976,7 +6056,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
|
||||
dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
"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]]
|
||||
@@ -6023,6 +6114,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
@@ -6172,6 +6273,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "windows-version"
|
||||
version = "0.1.7"
|
||||
@@ -6491,7 +6601,7 @@ dependencies = [
|
||||
"webkit2gtk",
|
||||
"webkit2gtk-sys",
|
||||
"webview2-com",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-version",
|
||||
"x11-dl",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{LazyLock, RwLock};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -40,10 +41,226 @@ pub struct AiTool {
|
||||
pub definition: ToolDefinition,
|
||||
/// 风险级别
|
||||
pub risk_level: RiskLevel,
|
||||
/// 工具元数据(DRY 债 #5 收敛:注册点声明,消费方按条目或全局表查询,不再散布 match 表)
|
||||
pub meta: ToolMeta,
|
||||
/// 执行处理器
|
||||
pub handler: AiToolHandler,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具元数据(DRY 债 #5)
|
||||
//
|
||||
// 此前工具元数据以 5 处手写 match 表散布在调用侧:
|
||||
// path_auth.rs(路径参数键) / agentic/helpers.rs 两处(目标描述 + 漂移签名) /
|
||||
// audit/data_change.rs(entity-action) / audit/cache.rs(只读缓存白名单 + 目录级 key) /
|
||||
// tool_registry.rs(审批文案模板 + 轻量动词标签),新增工具需人肉同步 5-6 处且已漂移。
|
||||
// 收敛后元数据仅在 declare_tool! 注册点声明一次,上述消费方全部改查本表;
|
||||
// 未注册工具走各消费方原保守 default(路径不校验/不缓存/不联动)。
|
||||
//
|
||||
// 维度按 5 处旧表的实际消费盘点,不为用不上的维度加字段:
|
||||
// - path_args:路径授权预校验提取哪些参数键(rename_file 为 from+to,其余文件工具为 path)
|
||||
// - readonly:只读幂等工具(结果可缓存)
|
||||
// - cache_path_key:结果缓存 key 只取 path(目录级去重,同目录换递归参数也判重复)
|
||||
// - deny_unauthorized:路径未授权时硬拒而非弹窗(search_files 盲搜语义应拒不应询)
|
||||
// - entity_action:数据变更联动刷新的 (entity, action)
|
||||
// - approval_hint:审批卡文案模板 + 关键参数键
|
||||
// - display_verb:轻量中文动作标签
|
||||
// - goal:从工具调用推理目标描述(动词 + 参数键 + 取值风格 + 缺参兜底文案)
|
||||
// - sig:漂移检测签名取哪些参数组(组内多键为取值回退序,数值键按 JSON 形态序列化)
|
||||
// ============================================================
|
||||
|
||||
/// 取值风格(goal 维度):从参数值到展示文本的取法
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GoalStyle {
|
||||
/// 取路径末段文件名(与旧实现一致按 / 切分)
|
||||
FileName,
|
||||
/// 原样
|
||||
Raw,
|
||||
/// 超 25 字节截断(字符边界回退,防多字节字符中间切 panic)
|
||||
Truncate,
|
||||
}
|
||||
|
||||
/// 目标描述元数据:infer_goal_from_tool_calls 消费
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct GoalMeta {
|
||||
/// 动词前缀(如 "修改"/"查看"/"搜索")
|
||||
pub verb: &'static str,
|
||||
/// 参数键,按序取第一个存在的键
|
||||
pub keys: &'static [&'static str],
|
||||
/// 取值风格
|
||||
pub style: GoalStyle,
|
||||
/// 键缺失/解析失败时的兜底文案(如 "搜索文件");None 表示返回 None
|
||||
pub fallback: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// 签名取值类型:字符串键取原始串,数值键按 JSON 序列化形态
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SigValKind {
|
||||
/// 字符串取值(as_str,缺失为空串)
|
||||
Str,
|
||||
/// Value::to_string 形态(数字 50 -> "50",缺失为空串)
|
||||
Json,
|
||||
}
|
||||
|
||||
/// 漂移检测签名元数据:tool_call_signature 消费
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct SigMeta {
|
||||
/// 有序参数组;每组内多键表示取值回退序(如 pattern 缺失落 query)
|
||||
pub groups: &'static [(&'static [&'static str], SigValKind)],
|
||||
}
|
||||
|
||||
/// 工具元数据(全部为编译期静态值,Copy 廉价传递)
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct ToolMeta {
|
||||
/// 路径授权预校验的参数键;空 = 非文件工具(不预校验,保守 default)
|
||||
pub path_args: &'static [&'static str],
|
||||
/// 只读幂等工具(结果可安全缓存)
|
||||
pub readonly: bool,
|
||||
/// 结果缓存 key 只取 path(目录级去重)
|
||||
pub cache_path_key: bool,
|
||||
/// 路径未授权时硬拒(不弹授权窗)
|
||||
pub deny_unauthorized: bool,
|
||||
/// 数据变更联动 (entity, action)
|
||||
pub entity_action: Option<(&'static str, &'static str)>,
|
||||
/// 审批文案模板 + 关键参数键(模板 {} 按位置填充)
|
||||
pub approval_hint: Option<(&'static str, &'static [&'static str])>,
|
||||
/// 轻量中文动作标签
|
||||
pub display_verb: Option<&'static str>,
|
||||
/// 目标描述元数据
|
||||
pub goal: Option<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 结果
|
||||
pub type AiToolHandler =
|
||||
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(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
@@ -73,19 +290,47 @@ impl AiToolRegistry {
|
||||
parameters: Value,
|
||||
risk_level: RiskLevel,
|
||||
handler: AiToolHandler,
|
||||
) {
|
||||
self.register_meta(name, description, parameters, risk_level, ToolMeta::default(), handler);
|
||||
}
|
||||
|
||||
/// 注册一个工具并写入元数据。
|
||||
///
|
||||
/// 元数据在同一调用点写入两处,无双源漂移:
|
||||
/// - 注册表条目 AiTool.meta(持有 registry 实例的消费方查询)
|
||||
/// - 进程级 GLOBAL_TOOL_META 表(path_auth/cache/agentic helpers 等无
|
||||
/// registry 句柄的自由函数按工具名查询)
|
||||
pub fn register_meta(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
description: impl Into<String>,
|
||||
parameters: Value,
|
||||
risk_level: RiskLevel,
|
||||
meta: ToolMeta,
|
||||
handler: AiToolHandler,
|
||||
) {
|
||||
let name_str = name.into();
|
||||
let definition = ToolDefinition::function(&name_str, description, parameters);
|
||||
GLOBAL_TOOL_META
|
||||
.write()
|
||||
.expect("全局工具元数据表锁")
|
||||
.insert(name_str.clone(), meta);
|
||||
self.tools.insert(
|
||||
name_str,
|
||||
AiTool {
|
||||
definition,
|
||||
risk_level,
|
||||
meta,
|
||||
handler,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 查询工具元数据条目(持有 registry 实例的消费方用)
|
||||
pub fn meta(&self, name: &str) -> Option<ToolMeta> {
|
||||
self.tools.get(name).map(|t| t.meta)
|
||||
}
|
||||
|
||||
/// 获取所有工具定义(发送给 LLM 的 tools 参数)
|
||||
pub fn tool_definitions(&self) -> Vec<ToolDefinition> {
|
||||
self.tools.values().map(|t| t.definition.clone()).collect()
|
||||
@@ -236,4 +481,68 @@ mod tests {
|
||||
assert_eq!(required.len(), 1);
|
||||
assert_eq!(required[0], "name");
|
||||
}
|
||||
|
||||
/// register_meta:条目与全局表同步可查,未注册名走保守 default。
|
||||
#[test]
|
||||
fn register_meta_stores_entry_and_global() {
|
||||
let mut reg = AiToolRegistry::new();
|
||||
let meta = ToolMeta {
|
||||
path_args: &["from", "to"],
|
||||
readonly: true,
|
||||
entity_action: Some(("task", "update")),
|
||||
..Default::default()
|
||||
};
|
||||
reg.register_meta("meta_probe", "测试", object_schema(vec![]), RiskLevel::Low, meta, dummy_handler());
|
||||
assert_eq!(reg.meta("meta_probe"), Some(meta));
|
||||
assert_eq!(tool_meta("meta_probe"), Some(meta));
|
||||
assert_eq!(path_arg_keys("meta_probe"), &["from", "to"]);
|
||||
assert!(is_readonly("meta_probe"));
|
||||
assert_eq!(entity_action("meta_probe"), Some(("task", "update")));
|
||||
// 未注册名保守 default:无路径键/非只读/无联动
|
||||
assert_eq!(path_arg_keys("never_registered"), &[] as &[&str]);
|
||||
assert!(!is_readonly("never_registered"));
|
||||
assert_eq!(entity_action("never_registered"), None);
|
||||
// 老签名 register 等价 default 元数据
|
||||
reg.register("plain_probe", "测试", object_schema(vec![]), RiskLevel::Low, dummy_handler());
|
||||
assert_eq!(reg.meta("plain_probe"), Some(ToolMeta::default()));
|
||||
}
|
||||
|
||||
/// goal_text 三种取值风格 + 键回退 + 兜底文案。
|
||||
#[test]
|
||||
fn goal_text_styles_and_fallback() {
|
||||
use serde_json::json;
|
||||
let file_name = GoalMeta { verb: "查看", keys: &["path"], style: GoalStyle::FileName, fallback: None };
|
||||
assert_eq!(goal_text(file_name, Some(&json!({"path": "a/b/c.rs"}))), Some("查看 c.rs".to_string()));
|
||||
let raw = GoalMeta { verb: "浏览", keys: &["path"], style: GoalStyle::Raw, fallback: None };
|
||||
assert_eq!(goal_text(raw, Some(&json!({"path": "x/y"}))), Some("浏览 x/y".to_string()));
|
||||
// Truncate:ASCII 超 25 字节截 25;多字节字符回退到字符边界不 panic
|
||||
let trunc = GoalMeta { verb: "执行", keys: &["command"], style: GoalStyle::Truncate, fallback: Some("执行命令") };
|
||||
let long = json!({"command": "a".repeat(40)});
|
||||
assert_eq!(goal_text(trunc, Some(&long)), Some(format!("执行 {}", "a".repeat(25))));
|
||||
let multibyte = json!({"command": "构建数据库连接池"});
|
||||
let out = goal_text(trunc, Some(&multibyte)).expect("多字节应正常产出");
|
||||
assert!(out.starts_with("执行 构建数据库"));
|
||||
// 键缺失走兜底;无兜底返 None
|
||||
assert_eq!(goal_text(trunc, Some(&json!({}))), Some("执行命令".to_string()));
|
||||
assert_eq!(goal_text(file_name, Some(&json!({}))), None);
|
||||
assert_eq!(goal_text(file_name, None), None);
|
||||
// 多键回退序:首键缺失取次键
|
||||
let multi = GoalMeta { verb: "搜索", keys: &["pattern", "query"], style: GoalStyle::Raw, fallback: Some("搜索文件") };
|
||||
assert_eq!(goal_text(multi, Some(&json!({"query": "todo"}))), Some("搜索 todo".to_string()));
|
||||
}
|
||||
|
||||
/// sig_body:组内字符串键回退 + Json 数值键形态 + 缺省空串。
|
||||
#[test]
|
||||
fn sig_body_groups_and_kinds() {
|
||||
use serde_json::json;
|
||||
let grep_sig = SigMeta { groups: &[(&["pattern", "query"], SigValKind::Str), (&["path", "glob"], SigValKind::Str)] };
|
||||
assert_eq!(sig_body(grep_sig, &json!({"pattern": "MAX", "path": "src"})), "pattern=MAX,path=src");
|
||||
assert_eq!(sig_body(grep_sig, &json!({"query": "TODO", "glob": "*.rs"})), "pattern=TODO,path=*.rs");
|
||||
assert_eq!(sig_body(grep_sig, &json!({})), "pattern=,path=");
|
||||
let read_sig = SigMeta { groups: &[(&["path"], SigValKind::Str), (&["offset"], SigValKind::Json), (&["limit"], SigValKind::Json)] };
|
||||
assert_eq!(sig_body(read_sig, &json!({"path": "a", "offset": 50})), "path=a,offset=50,limit=");
|
||||
// 未注册工具签名回退全参 JSON
|
||||
let sig = tool_signature("custom_tool", &json!({"x": 1}));
|
||||
assert!(sig.starts_with("custom_tool:name=custom_tool,args="));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,16 +54,47 @@
|
||||
/// - `$name`: 工具名 `&str`。
|
||||
/// - `$desc`: 工具描述 `&str`(发给 LLM)。
|
||||
/// - `$risk`: `RiskLevel`(如 `RiskLevel::Low`)。
|
||||
/// - `meta: $meta`(可选段): `ToolMeta` 字面量(DRY 债 #5)。带此段走 `register_meta` 落
|
||||
/// 条目 + 全局元数据表;不带则 default 元数据(全空,消费方走保守 default 分支)。
|
||||
/// - `$schema`: 参数 JSON Schema(`serde_json::Value`),常用 `object_schema(...)`。
|
||||
/// - `$handler`: 一个 **block 表达式**(花括号体),返回 `anyhow::Result<serde_json::Value>`。
|
||||
/// 体内在 `$args`(serde_json::Value)与 `$capture`(克隆后的捕获变量)上工作。
|
||||
/// 宏负责把它包进 `async move { ... }` 并 `Box::pin`——故调用方只写「同步语义的体」,
|
||||
/// 不写 `async move`/`Box::pin`/`Box::new` 三层样板。
|
||||
///
|
||||
/// 注意:此宏不替代 `register`,而是包装它——`register` 仍是 `AiToolRegistry` 的唯一注册入口,
|
||||
/// 宏仅是语法糖。现有 48 个手写 `register` 调用不动,新工具改用 `declare_tool!`。
|
||||
/// 注意:此宏不替代 `register`,而是包装它——`register`/`register_meta` 仍是
|
||||
/// `AiToolRegistry` 的唯一注册入口,宏仅是语法糖。
|
||||
#[macro_export]
|
||||
macro_rules! declare_tool {
|
||||
(
|
||||
$registry:expr,
|
||||
$capture:ident : $cap_ty:ty,
|
||||
$name:expr,
|
||||
$desc:expr,
|
||||
$risk:expr,
|
||||
meta: $meta:expr,
|
||||
schema: $schema:expr,
|
||||
$args:ident => $handler:block
|
||||
) => {{
|
||||
let __cap: $cap_ty = $capture.clone();
|
||||
$registry.register_meta(
|
||||
$name, $desc, $schema, $risk, $meta,
|
||||
{
|
||||
let $capture = __cap.clone();
|
||||
::std::boxed::Box::new(move |$args: ::serde_json::Value| {
|
||||
let $capture = $capture.clone();
|
||||
::std::boxed::Box::pin(async move {
|
||||
let $capture: $cap_ty = $capture;
|
||||
$handler
|
||||
})
|
||||
as ::std::pin::Pin<::std::boxed::Box<
|
||||
dyn ::std::future::Future<Output = ::anyhow::Result<::serde_json::Value>>
|
||||
+ ::std::marker::Send,
|
||||
>>
|
||||
})
|
||||
},
|
||||
);
|
||||
}};
|
||||
(
|
||||
$registry:expr,
|
||||
$capture:ident : $cap_ty:ty,
|
||||
@@ -74,8 +105,8 @@ macro_rules! declare_tool {
|
||||
$args:ident => $handler:block
|
||||
) => {{
|
||||
let __cap: $cap_ty = $capture.clone();
|
||||
$registry.register(
|
||||
$name, $desc, $schema, $risk,
|
||||
$registry.register_meta(
|
||||
$name, $desc, $schema, $risk, $crate::ai_tools::ToolMeta::default(),
|
||||
{
|
||||
let $capture = __cap.clone();
|
||||
::std::boxed::Box::new(move |$args: ::serde_json::Value| {
|
||||
@@ -151,4 +182,38 @@ mod tests {
|
||||
let out = reg.execute("const_tool", serde_json::json!({})).await.unwrap();
|
||||
assert_eq!(out["ok"], true);
|
||||
}
|
||||
|
||||
/// 带 meta 段的宏形态:元数据落条目 + 全局表;不带 meta 段落 default。
|
||||
#[tokio::test]
|
||||
async fn declare_tool_with_and_without_meta() {
|
||||
use crate::ai_tools::ToolMeta;
|
||||
let mut reg = AiToolRegistry::new();
|
||||
let dummy: std::sync::Arc<()> = std::sync::Arc::new(());
|
||||
declare_tool!(
|
||||
reg,
|
||||
dummy: std::sync::Arc<()>,
|
||||
"meta_variant_tool",
|
||||
"带元数据声明的工具",
|
||||
RiskLevel::Low,
|
||||
meta: ToolMeta { path_args: &["path"], readonly: true, ..Default::default() },
|
||||
schema: object_schema(vec![]),
|
||||
_args => { Ok(serde_json::json!({ "ok": true })) }
|
||||
);
|
||||
declare_tool!(
|
||||
reg,
|
||||
dummy: std::sync::Arc<()>,
|
||||
"plain_variant_tool",
|
||||
"不带元数据声明的工具",
|
||||
RiskLevel::Low,
|
||||
schema: object_schema(vec![]),
|
||||
_args => { Ok(serde_json::json!({ "ok": true })) }
|
||||
);
|
||||
let with = reg.meta("meta_variant_tool").expect("meta 变体应已注册");
|
||||
assert_eq!(with.path_args, &["path"]);
|
||||
assert!(with.readonly);
|
||||
assert_eq!(crate::ai_tools::path_arg_keys("meta_variant_tool"), &["path"]);
|
||||
let plain = reg.meta("plain_variant_tool").expect("无 meta 变体应已注册");
|
||||
assert_eq!(plain, ToolMeta::default());
|
||||
assert_eq!(crate::ai_tools::path_arg_keys("plain_variant_tool"), &[] as &[&str]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,8 +43,9 @@ use crate::anthropic_helpers::{
|
||||
/// API 网关——**cache_control 字段可能被非官方网关拒收**,故必须默认关、用户对支持的端点显式开启。
|
||||
///
|
||||
/// 开关开:`convert_request` 把 system 包成 blocks 数组并附加 `cache_control:{type:ephemeral}`
|
||||
/// (配合上层把易变段挪出 system,让 system 稳定段真正命中缓存)。
|
||||
/// 开关关:system 保持纯字符串(现状行为,零风险兼容网关)。
|
||||
/// (配合上层把易变段挪出 system,让 system 稳定段真正命中缓存),
|
||||
/// 并在历史段稳定边界补消息级断点(使 system 之后的对话历史也可命中)。
|
||||
/// 开关关:system 保持纯字符串、消息不打断点(现状行为,零风险兼容网关)。
|
||||
///
|
||||
/// 进程级全局而非 per-provider:上层(AppState settings)按「用户对当前启用的 anthropic 端点
|
||||
/// 是否支持缓存」一次性开启,所有 anthropic 请求一致生效。默认关。
|
||||
@@ -239,6 +240,12 @@ impl AnthropicCompatProvider {
|
||||
// (正常会话首条本就是 user),远优于丢弃上下文。
|
||||
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| {
|
||||
defs.into_iter()
|
||||
.map(|d| AnthropicToolDef {
|
||||
@@ -316,6 +323,45 @@ impl AnthropicCompatProvider {
|
||||
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 数组)。
|
||||
/// Anthropic 协议要求 user/assistant 严格交替,连续 user 触发 1214。
|
||||
/// 触发场景:flush_tool_results 把 tool_result 转 user 后紧跟新 user(drainQueue 续发,
|
||||
@@ -554,7 +600,9 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
source = ?std::error::Error::source(&e),
|
||||
"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::Fatal(format!(
|
||||
@@ -580,10 +628,17 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
}
|
||||
let resp: AnthropicResponse = match resp.json().await {
|
||||
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 reasoning = String::new();
|
||||
let mut tool_calls: Vec<ToolCall> = Vec::new();
|
||||
// CR-空 id:按 tool_use 块在数组中的顺序计数(仅 tool_use 递增),用于 fallback index。
|
||||
// 用独立计数器而非 for enumerate,避免 text/unknown 块占用 index 致 fallback 编号跳号。
|
||||
@@ -595,6 +650,15 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
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" => {
|
||||
// CR-空 id:原逻辑空 id 直接 continue 跳过整个块(丢工具调用)。
|
||||
// 改为兜底:id 非空原样,空 → `gen_anthropic_{idx}` fallback(DRY 共用
|
||||
@@ -634,7 +698,8 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
model: resp.model,
|
||||
usage,
|
||||
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 {
|
||||
Ok(Ok(r)) => r,
|
||||
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!(
|
||||
is_timeout = e.is_timeout(),
|
||||
is_connect = e.is_connect(),
|
||||
is_body = e.is_body(),
|
||||
is_request = e.is_request(),
|
||||
class = class,
|
||||
url = %self.messages_url(),
|
||||
source = ?std::error::Error::source(&e),
|
||||
"Anthropic 流式 send 失败"
|
||||
);
|
||||
anyhow::bail!(
|
||||
"send 失败: {} [timeout={} connect={} body={} request={} src={:?}]",
|
||||
"send 失败({}): {} [t={} c={} b={} r={} src={:?}]",
|
||||
class,
|
||||
e,
|
||||
e.is_timeout(),
|
||||
e.is_connect(),
|
||||
@@ -752,6 +824,14 @@ mod tests {
|
||||
use super::*;
|
||||
// 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 事件
|
||||
fn message_start(input_tokens: u32) -> String {
|
||||
format!(
|
||||
@@ -894,6 +974,31 @@ mod tests {
|
||||
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避免残缺响应被当正常完成入库,
|
||||
/// 由 stream_llm 识别 error 非空发 AiError + 丢弃残缺,与 OpenAI 路径 Err 一致)
|
||||
#[test]
|
||||
@@ -945,6 +1050,7 @@ mod tests {
|
||||
/// 含图 user 消息 → content blocks(text + image source.base64)
|
||||
#[test]
|
||||
fn anthropic_convert_multimodal_user_blocks() {
|
||||
let _convert = convert_lock();
|
||||
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
|
||||
let req = CompletionRequest {
|
||||
model: "claude-3-5-sonnet".into(),
|
||||
@@ -980,6 +1086,7 @@ mod tests {
|
||||
/// 纯文本 user 消息 → content 仍是字符串简写(无图不数组化)
|
||||
#[test]
|
||||
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 req = CompletionRequest {
|
||||
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"));
|
||||
}
|
||||
|
||||
// ---------- 消息级缓存断点(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 占位,保留上下文)----------
|
||||
|
||||
/// 辅助:构造 assistant(tool_use) 消息
|
||||
@@ -1016,6 +1179,7 @@ mod tests {
|
||||
/// (原"砍"策略会把每对三元组砍掉,多轮砍到空,丢失全部工具调用历史——"补"策略零丢失。)
|
||||
#[test]
|
||||
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 req = CompletionRequest {
|
||||
model: "claude-3-5-sonnet".into(),
|
||||
@@ -1054,6 +1218,7 @@ mod tests {
|
||||
/// 首条 assistant 无 tool_use → 补占位,首条 user,原上下文保留。
|
||||
#[test]
|
||||
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 req = CompletionRequest {
|
||||
model: "claude-3-5-sonnet".into(),
|
||||
@@ -1080,6 +1245,7 @@ mod tests {
|
||||
/// 正常序列(user 开头)不补占位——零回归验证。
|
||||
#[test]
|
||||
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 req = CompletionRequest {
|
||||
model: "claude-3-5-sonnet".into(),
|
||||
@@ -1107,6 +1273,7 @@ mod tests {
|
||||
/// 补一个 user 占位后全部保留,验证多轮链不丢数据、precheck 通过(原"砍"策略此场景砍到空)。
|
||||
#[test]
|
||||
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 req = CompletionRequest {
|
||||
model: "claude-3-5-sonnet".into(),
|
||||
@@ -1144,6 +1311,7 @@ mod tests {
|
||||
/// 避免发空 messages 触发 precheck "messages 为空"(降级让会话能继续)。
|
||||
#[test]
|
||||
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 req = CompletionRequest {
|
||||
model: "claude-3-5-sonnet".into(),
|
||||
@@ -1173,6 +1341,7 @@ mod tests {
|
||||
/// (两态共用进程级全局开关,拆两个用例并行跑会互相污染——单测内顺序切换断言,末尾复位。)
|
||||
#[test]
|
||||
fn anthropic_cache_switch_controls_system_shape() {
|
||||
let _convert = convert_lock();
|
||||
set_anthropic_cache_enabled(false); // 兜底复位,防污染其他并行用例
|
||||
|
||||
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
|
||||
|
||||
@@ -72,13 +72,16 @@ pub(crate) struct AnthropicResponse {
|
||||
pub usage: AnthropicUsage,
|
||||
}
|
||||
|
||||
/// 响应 content 块(text 或 tool_use)
|
||||
/// 响应 content 块(text / tool_use / thinking)
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct AnthropicContentBlock {
|
||||
#[serde(rename = "type")]
|
||||
pub block_type: String,
|
||||
#[serde(default)]
|
||||
pub text: Option<String>,
|
||||
/// thinking 块推理正文(端点侧开启扩展思考时出现;本侧请求未启用 thinking,一般缺省)
|
||||
#[serde(default)]
|
||||
pub thinking: Option<String>,
|
||||
/// tool_use 块字段
|
||||
pub id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
@@ -119,7 +122,8 @@ pub(crate) const DEFAULT_MAX_TOKENS: u32 = 4096;
|
||||
/// 按 `type` 字段分发:
|
||||
/// - `message_start` → 用 `message.usage.input_tokens` 初始化累加器(output 置 0)。
|
||||
/// - `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。
|
||||
/// - `message_stop` → 返回 `finished=true` 终态 chunk,`usage` 取自累加器(`take()`)。
|
||||
/// - `error` → 返回 `finished=true` 终态空 chunk。
|
||||
@@ -182,6 +186,17 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
// 文本增量
|
||||
"content_block_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") {
|
||||
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 };
|
||||
@@ -209,6 +224,8 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
}
|
||||
// 工具块开始:带 id + name
|
||||
"content_block_start" => {
|
||||
// thinking / redacted_thinking 块起点无正文要记(正文随后经 thinking_delta 流出;
|
||||
// redacted_thinking 为密文不可解析),落入末尾统一空 chunk 即安静跳过,不告警。
|
||||
if let Some(cb) = v.get("content_block") {
|
||||
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;
|
||||
|
||||
@@ -618,11 +618,24 @@ impl ContextManager {
|
||||
/// 也只扣首次标记的 token。返回的 Vec 仅含**本次新标记**的消息(已 compressed 的不返)。
|
||||
///
|
||||
/// **单向不可逆**:压缩后 DB 原始消息保留,但 LLM 上下文里被 is_active 白名单隔离
|
||||
/// (sanitize step0 过滤)。`compress_end` 越界自动 clamp 到 `messages.len()`。
|
||||
/// (sanitize step0 过滤)。`compress_end` 越界自动 clamp 到 `messages.len()`,且实际
|
||||
/// 压缩边界会回退到最近的对话单元边界(三元组不劈开,见方法内注释)。
|
||||
///
|
||||
/// 返回空 Vec 表示本批次无可压缩消息(全部已 compressed 或范围空),调用方据此跳过 LLM 调用。
|
||||
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();
|
||||
for t in self.messages[..end].iter_mut() {
|
||||
if t.message.is_active() {
|
||||
@@ -656,3 +669,72 @@ impl ContextManager {
|
||||
|
||||
#[cfg(test)]
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +142,8 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
})
|
||||
.collect();
|
||||
|
||||
tracing::warn!(
|
||||
// debug 级:同份畸形历史每请求重复触发,降级防告警洪水;根治=ai_messages 配对清洗(DB 原始保留不动)
|
||||
tracing::debug!(
|
||||
full_drop_heads,
|
||||
partial_rewrite_heads = partial_heads,
|
||||
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 {
|
||||
tracing::warn!(
|
||||
tracing::debug!(
|
||||
healed_orphan_tool_results = healed,
|
||||
unidentifiable_tool_results_kept = unidentifiable_kept,
|
||||
"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);
|
||||
}
|
||||
if healed > 0 {
|
||||
tracing::warn!(
|
||||
tracing::debug!(
|
||||
healed_orphan_tool_results = healed,
|
||||
"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);
|
||||
}
|
||||
if skipped > 0 || merged > 0 {
|
||||
tracing::warn!(
|
||||
tracing::debug!(
|
||||
skipped,
|
||||
merged,
|
||||
"序列修复:skip 开头非 user(含连带丢 orphan result)+ 合并连续同 role(view-only,避免 Anthropic/GLM 1214)"
|
||||
|
||||
@@ -245,6 +245,9 @@ pub enum ToolDomain {
|
||||
/// 命令执行:run_command(shell 命令)。基本盘成员:读写执行为 agent 基本能力
|
||||
/// (2026-09-30 用户拍板,不受意图收敛限制),安全由执行层 validate_path/授权/RiskLevel 兜底
|
||||
Exec,
|
||||
/// 编排:spawn_agent 子代理派发。基本盘成员:委派是编排类基本能力,
|
||||
/// 高置信意图收敛下不登记则工具对 LLM 不可见,安全由子代理侧白名单钳制兜底
|
||||
Agent,
|
||||
/// 网络:HTTP
|
||||
Http,
|
||||
}
|
||||
@@ -288,6 +291,8 @@ impl ToolDomain {
|
||||
// 2026-09-30 用户拍板读写执行为 agent 基本能力,不再仅 Debug 暴露;
|
||||
// 安全由执行层兜底(RiskLevel 审批/授权三道门/路径黑名单),藏工具≠安全。
|
||||
ToolDomain::Exec => &["run_command"],
|
||||
// Agent domain:spawn_agent 单列,编排委派基本盘成员(子代理侧白名单钳制兜底安全)。
|
||||
ToolDomain::Agent => &["spawn_agent"],
|
||||
ToolDomain::Http => &["http_request"],
|
||||
// DataReadOnly:Data domain 的只读子集。
|
||||
// 源于 Code 意图收敛需求:用户在编码场景说"先 list_projects 看下项目结构"
|
||||
@@ -440,7 +445,8 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)>
|
||||
/// 上游应走**全量 fallback**(即不过滤工具,交全量给 LLM)。
|
||||
///
|
||||
/// 设计(2026-09-30 起):基本盘 + 场景增量。
|
||||
/// 基本盘 = File + Exec:读、写、执行命令是 agent 基本能力,不受意图收敛限制(用户拍板),
|
||||
/// 基本盘 = File + Exec + Agent:读、写、执行命令、委派子代理是 agent 基本能力,
|
||||
/// 不受意图收敛限制(用户拍板),
|
||||
/// 所有非空子集意图(Code/Debug/File/Project/Task/Idea/Search/Http)都可见。
|
||||
/// 安全防线在执行层:validate_path 黑名单 + 授权三道门 + RiskLevel 审批,藏工具≠安全;
|
||||
/// 藏工具只会复现断手(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> {
|
||||
// 基本盘:读写执行(RiskLevel/审批/路径授权兜底,藏工具≠安全);场景增量=业务域收敛减少分心
|
||||
const BASE_DOMAINS: &[ToolDomain] = &[ToolDomain::File, ToolDomain::Exec];
|
||||
// 基本盘:读写执行 + 编排委派(RiskLevel/审批/路径授权/子代理白名单钳制兜底,藏工具≠安全);
|
||||
// 场景增量=业务域收敛减少分心
|
||||
const BASE_DOMAINS: &[ToolDomain] = &[ToolDomain::File, ToolDomain::Exec, ToolDomain::Agent];
|
||||
let extra: &[ToolDomain] = match intent {
|
||||
Intent::Code => &[ToolDomain::Http, ToolDomain::DataReadOnly],
|
||||
Intent::Debug => &[ToolDomain::Http, ToolDomain::Data],
|
||||
@@ -1266,9 +1273,10 @@ mod tests {
|
||||
];
|
||||
for intent in all_intents {
|
||||
let subset = tool_subset_for(&intent);
|
||||
// 全 registry 工具名(五 domain 并集:Data + DataReadOnly + File + Exec + Http)
|
||||
// 全 registry 工具名(六 domain 并集:Data + DataReadOnly + File + Exec + Http + Agent)
|
||||
// 注:DataReadOnly 工具名是 Data 的子集,chain 它仅为语义显式(并集去重无副作用),
|
||||
// 防 Code subset 里 list_projects 等 DataReadOnly 工具被判"不在 registry"。
|
||||
// Agent(spawn_agent)是编排委派基本盘成员,子集合法出现,漏 chain 会误报。
|
||||
let registry: std::collections::HashSet<&str> = ToolDomain::Data
|
||||
.tools()
|
||||
.iter()
|
||||
@@ -1276,6 +1284,7 @@ mod tests {
|
||||
.chain(ToolDomain::File.tools().iter())
|
||||
.chain(ToolDomain::Exec.tools().iter())
|
||||
.chain(ToolDomain::Http.tools().iter())
|
||||
.chain(ToolDomain::Agent.tools().iter())
|
||||
.copied()
|
||||
.collect();
|
||||
for name in &subset {
|
||||
|
||||
@@ -64,6 +64,11 @@ pub use df_ai_core;
|
||||
pub(crate) fn build_provider_client() -> Client {
|
||||
Client::builder()
|
||||
.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()
|
||||
.unwrap_or_else(|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)
|
||||
///
|
||||
/// `anthropic` 协议走 AnthropicCompatProvider(GLM 订阅端点 / Claude 官方),
|
||||
|
||||
@@ -424,7 +424,7 @@ fn sanitize_openai_triplets(messages: &mut Vec<OpenAiMessage>) {
|
||||
// 无配对头(id 不在任何保留 assistant 头内)→ orphan tool_result,丢弃。
|
||||
// 含两类:(a) assistant 头被 step2 剥后残留的 tool;(b) 全程无配对头的直构造/DB 残留。
|
||||
dropped_orphan_tools += 1;
|
||||
tracing::warn!(
|
||||
tracing::debug!(
|
||||
tool_call_id = %id,
|
||||
"[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 {
|
||||
tracing::warn!(
|
||||
tracing::debug!(
|
||||
stripped_heads,
|
||||
total_stripped,
|
||||
dropped_orphan_tools,
|
||||
@@ -502,7 +502,9 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
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::Fatal(format!("请求失败(不可重试): {}", e));
|
||||
@@ -520,10 +522,16 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
error!(%status, %body, "LLM API 调用失败(不可重试)");
|
||||
return AttemptOutcome::Fatal(msg);
|
||||
}
|
||||
// body 解析: 解析错属 Fatal(响应已成功送达,重试也会因同样格式失败)
|
||||
// body 解析: serde 结构错属 Fatal(响应已成功送达,重试同样失败);
|
||||
// body 读取中途断连在 reqwest 同归 is_decode,重发幂等安全 → 可重试。
|
||||
let body: OpenAiResponse = match resp.json().await {
|
||||
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() {
|
||||
Some(c) => c,
|
||||
@@ -570,8 +578,23 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!(error = %e, is_timeout = e.is_timeout(), "OpenAI 流式 send 失败");
|
||||
return Err(e.into());
|
||||
// 谓词结构化分类:Display 串不含 source 关键词("error sending request for url"),
|
||||
// 纯串分类必 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) => {
|
||||
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();
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,11 +65,30 @@ pub(crate) struct OpenAiResponse {
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiChoice {
|
||||
pub message: OpenAiMessageResp,
|
||||
// OpenAI 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning
|
||||
#[allow(dead_code)]
|
||||
pub finish_reason: Option<String>,
|
||||
pub struct OpenAiChoice {
|
||||
pub(crate) message: OpenAiMessageResp,
|
||||
/// 调用终止原因。`length` = 输出被 max_tokens 截断(不完整),由 [`FinishReason`]
|
||||
/// 的 Drop 侧效应打 warn 留痕 — 此前该字段无人读、截断不可见,截断的 JSON 会被
|
||||
/// 下游当解析失败整批丢弃而无任何日志线索。
|
||||
#[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)]
|
||||
@@ -97,25 +116,78 @@ pub(crate) struct OpenAiFunctionResp {
|
||||
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)]
|
||||
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 prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
/// DeepSeek 扩展:缓存命中 token(低价,deepseek-chat/reasoner prompt_cache_hit_tokens)。
|
||||
/// OpenAI 官方(o1 等)无此字段 → serde default 0。其他 OpenAI 兼容网关若支持 cache 也用此名。
|
||||
#[serde(default)]
|
||||
/// 缓存命中 token(低价;扁平优先,缺失时回填自嵌套 cached_tokens)
|
||||
pub prompt_cache_hit_tokens: u32,
|
||||
/// DeepSeek 扩展:未命中 token(全价真实输入,prompt_cache_miss_tokens)。
|
||||
/// OpenAI 官方无此字段 → serde default 0。
|
||||
#[serde(default)]
|
||||
/// 缓存未命中 token(全价真实输入;嵌套口径推断时 = prompt - hit)
|
||||
pub prompt_cache_miss_tokens: u32,
|
||||
/// DeepSeek-reasoner / OpenAI o1 扩展:思考 token(隐藏输出,reasoning_tokens)。
|
||||
/// 非 reasoning 模型无此字段 → serde default 0。
|
||||
#[serde(default)]
|
||||
/// 思考 token(隐藏输出)
|
||||
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
|
||||
#[derive(Debug, Deserialize)]
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,8 @@ pub(crate) async fn resolve_and_parse(
|
||||
///
|
||||
/// 无 task_id / 查询失败 / task 不存在 → None,调用方降级到 `default_fallback_prompt`。
|
||||
/// 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())?;
|
||||
if task_id.is_empty() {
|
||||
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 关联)。
|
||||
fn default_fallback_prompt() -> String {
|
||||
/// pub(crate):AiAgentNode 复用同款通用兜底。
|
||||
pub(crate) fn default_fallback_prompt() -> String {
|
||||
"请根据上下文完成当前任务,产出可执行结果并自检。若需更多信息请说明。".to_string()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! df-nodes: 内置节点集合 — AI、脚本、人工审批、Git、Docker、HTTP、通知
|
||||
|
||||
pub mod ai_agent_node;
|
||||
pub mod ai_node;
|
||||
pub mod ai_self_review_node;
|
||||
mod ai_node_helpers;
|
||||
|
||||
@@ -37,14 +37,17 @@ pub fn template_for(target_status: &str) -> Option<DagDef> {
|
||||
/// todo → in_progress:单 AiNode 执行任务。
|
||||
///
|
||||
/// 拓扑: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 {
|
||||
let mut dag = DagDef::new();
|
||||
// config 留空:prompt / provider / task_id 均由 run_workflow 全局 config 注入
|
||||
// (④-1 deep_merge 合并),模板只描述拓扑。
|
||||
dag.add_node(
|
||||
"ai_execute",
|
||||
"ai",
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({ "mode": "agent" }),
|
||||
);
|
||||
dag
|
||||
}
|
||||
@@ -120,10 +123,24 @@ mod tests {
|
||||
assert!(dag.edges.is_empty(), "in_progress: 无边");
|
||||
let node = dag.nodes.get("ai_execute").expect("ai_execute 节点存在");
|
||||
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!(
|
||||
node.config.as_object().map(|o| o.is_empty()).unwrap_or(true),
|
||||
"ai_execute config 应为空对象"
|
||||
non_mode_keys.is_empty(),
|
||||
"ai_execute 除 mode 外 config 应留空(运行时注入),实际: {:?}",
|
||||
non_mode_keys
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
# P1 bg 化补完:系统通知。AI 完成/失败/需介入三类事件,主窗隐藏到托盘时补位提醒,
|
||||
# Rust 侧条件发送(notify.rs):主窗可见聚焦不发 + 同类 5 秒节流 + 失败静默
|
||||
tauri-plugin-notification = "2"
|
||||
# Phase3 单实例:二次启动唤起已有进程主窗口,防多实例抢 18765 端口
|
||||
tauri-plugin-single-instance = "2"
|
||||
serde.workspace = true
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"core:window:allow-set-decorations",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"dialog:default",
|
||||
"window-state:default"
|
||||
"window-state:default",
|
||||
"notification:default"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,12 +10,13 @@
|
||||
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::super::{
|
||||
audit::audit_finalize, conversation::save_conversation, AiChatEvent,
|
||||
audit::audit_finalize, conversation::save_conversation, event_bus::emit_ai_chat_event,
|
||||
AiChatEvent,
|
||||
};
|
||||
|
||||
/// 清理指定会话中超时的待审批。
|
||||
@@ -81,14 +82,15 @@ pub async fn cleanup_expired_approvals(app: &AppHandle, state: &AppState, conv_i
|
||||
.replace_tool_result_content(&tc_id, &format!("审批超时({}分钟)已自动取消,请改换其他方案", timeout_minutes));
|
||||
}
|
||||
|
||||
// 3. emit AiApprovalResult(让前端清除 pending UI)
|
||||
let ev = AiChatEvent::AiApprovalResult {
|
||||
// 3. emit AiApprovalResult(让前端清除 pending UI,emit+publish 双写经正典 helper)
|
||||
emit_ai_chat_event(
|
||||
app,
|
||||
AiChatEvent::AiApprovalResult {
|
||||
id: tc_id.clone(),
|
||||
approved: false,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
let _ = state.ai_event_bus.publish_event(ev);
|
||||
},
|
||||
);
|
||||
|
||||
// 4. audit_finalize 记入审计表
|
||||
audit_finalize(
|
||||
|
||||
@@ -32,17 +32,18 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tauri::AppHandle;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary};
|
||||
use df_ai::provider::ChatMessage;
|
||||
use df_storage::models::AiProviderRecord;
|
||||
|
||||
use crate::state::{AppState, LlmConcurrency};
|
||||
use crate::state::LlmConcurrency;
|
||||
|
||||
// compress_via_llm 位于 commands::ai::compress(mod.rs 的 super::compress)。
|
||||
use super::super::compress::compress_via_llm;
|
||||
use super::super::event_bus::emit_ai_chat_event;
|
||||
use super::{AiChatEvent, AiSession};
|
||||
|
||||
/// 压缩保护区条数(对齐 clear/compress IPC 的 PROTECT_COUNT=6)。
|
||||
@@ -109,13 +110,10 @@ pub(super) async fn maybe_auto_compress(
|
||||
};
|
||||
|
||||
if should_compress {
|
||||
// emit 压缩开始 + 置位防重入 + 读 active 克隆(不改 status)。
|
||||
let ev = AiChatEvent::AiCompressing {
|
||||
// emit 压缩开始(emit+publish 双写经 emit_ai_chat_event)+ 置位防重入 + 读 active 克隆(不改 status)。
|
||||
emit_ai_chat_event(app_handle, AiChatEvent::AiCompressing {
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber透传 miniapp
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
});
|
||||
let (mut active_msgs, lang) = {
|
||||
let mut session = session_arc.lock().await;
|
||||
let conv = session.conv(&conv_id);
|
||||
@@ -220,30 +218,15 @@ pub(super) async fn maybe_auto_compress(
|
||||
session.conv(&conv_id).messages.set_compressing(false);
|
||||
}
|
||||
}
|
||||
if KEYWORD_FALLBACK_ENABLED {
|
||||
// 同 LLM 失败兜底:标 compressed 释放 token + 关键词摘要塞首位(若非空)。
|
||||
let inserted = {
|
||||
let mut session = session_arc.lock().await;
|
||||
if !session.per_conv.contains_key(&conv_id) {
|
||||
return;
|
||||
}
|
||||
let conv = session.conv(&conv_id);
|
||||
let _compressed = conv.messages.compress_old_messages(protect_start);
|
||||
if !keyword_fallback.is_empty() {
|
||||
conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
if inserted {
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
pre_tokens = pre_compress_tokens,
|
||||
"[ai] 压缩 provider 重建失败兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)"
|
||||
);
|
||||
}
|
||||
}
|
||||
keyword_fallback_commit(
|
||||
&session_arc,
|
||||
&conv_id,
|
||||
protect_start,
|
||||
&keyword_fallback,
|
||||
pre_compress_tokens,
|
||||
"压缩 provider 重建失败",
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -286,13 +269,10 @@ pub(super) async fn maybe_auto_compress(
|
||||
// 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体),
|
||||
// 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。
|
||||
// miniapp 仍插摘要气泡(对端发生压缩告知用户)。
|
||||
let ev = AiChatEvent::AiAutoCompressed {
|
||||
emit_ai_chat_event(&app_handle, AiChatEvent::AiAutoCompressed {
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
summary: summary.nl_summary,
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber透传 miniapp
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
});
|
||||
}
|
||||
Ok(None) => {
|
||||
// 保护区外无 active 可压缩(已全 compressed/archived)→ noop,仅复位 is_compressing。
|
||||
@@ -329,30 +309,15 @@ pub(super) async fn maybe_auto_compress(
|
||||
"[ai] 自动压缩失败(后台),降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
|
||||
KEYWORD_FALLBACK_ENABLED,
|
||||
);
|
||||
if KEYWORD_FALLBACK_ENABLED {
|
||||
// 标 compressed 释放 token + 关键词摘要塞首位(若非空)。
|
||||
let inserted = {
|
||||
let mut session = session_arc.lock().await;
|
||||
if !session.per_conv.contains_key(&conv_id) {
|
||||
return;
|
||||
}
|
||||
let conv = session.conv(&conv_id);
|
||||
let _compressed = conv.messages.compress_old_messages(protect_start);
|
||||
if !keyword_fallback.is_empty() {
|
||||
conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
if inserted {
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
pre_tokens = pre_compress_tokens,
|
||||
"[ai] 压缩失败兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)"
|
||||
);
|
||||
}
|
||||
}
|
||||
keyword_fallback_commit(
|
||||
&session_arc,
|
||||
&conv_id,
|
||||
protect_start,
|
||||
&keyword_fallback,
|
||||
pre_compress_tokens,
|
||||
"压缩失败",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -360,3 +325,47 @@ pub(super) async fn maybe_auto_compress(
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 关键词兜底提交单点(DRY #8 收敛,2026-10-01)。
|
||||
///
|
||||
/// 抽自 [`maybe_auto_compress`] 后台 task 内的逐字双胞胎块:压缩 provider 重建失败路径
|
||||
/// 与压缩 LLM 失败路径共用「KEYWORD_FALLBACK_ENABLED 门控 → 锁内 check+mutate
|
||||
/// (标 compressed 释放 token + 关键词摘要插首位,非空才插)→ 成功插入打 info」。
|
||||
/// 两条失败路径只差日志正文,经 `log_head` 参数化(拼为
|
||||
/// "[ai] {log_head}兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)")。
|
||||
///
|
||||
/// conv 已删(per_conv 无条目)→ 直接返回不兜底(对齐原 return 语义:防惰性重建污染新 conv)。
|
||||
async fn keyword_fallback_commit(
|
||||
session_arc: &Arc<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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::commands::ai::event_bus::emit_ai_chat_event;
|
||||
use crate::commands::ai::AiChatEvent;
|
||||
|
||||
use super::conv_state::ConvState;
|
||||
@@ -26,7 +27,7 @@ use super::conv_state::ConvState;
|
||||
/// epoch 语义:`loop_epoch` 是 per_conv 的并发 owner token(每 spawn 新 loop 前 fetch_add(1));
|
||||
/// `my_epoch` 是本 guard 对应 loop 捕获的 epoch。复位只认 owner(epoch 匹配),
|
||||
/// 旧 loop 复位跳过(防 clobber 新 loop 状态)。
|
||||
pub(super) struct GeneratingGuard {
|
||||
pub(crate) struct GeneratingGuard {
|
||||
conv_id: String,
|
||||
done: bool,
|
||||
/// per_conv.loop_epoch 引用(锁无关原子读,owner 判定用)。
|
||||
@@ -37,7 +38,7 @@ pub(super) struct GeneratingGuard {
|
||||
}
|
||||
|
||||
impl GeneratingGuard {
|
||||
pub(super) fn new(
|
||||
pub(crate) fn new(
|
||||
conv_id: String,
|
||||
loop_epoch: Arc<AtomicU64>,
|
||||
my_epoch: u64,
|
||||
@@ -58,7 +59,7 @@ impl GeneratingGuard {
|
||||
///
|
||||
/// 只认 owner:本 loop epoch 匹配当前 loop_epoch 才迁移 Idle;
|
||||
/// 不匹配(旧 loop)则仅置 done(跳过迁移,防 clobber 新 loop 的 Generating)。
|
||||
pub(super) async fn reset(&mut self) {
|
||||
pub(crate) async fn reset(&mut self) {
|
||||
if self.done {
|
||||
return;
|
||||
}
|
||||
@@ -76,7 +77,7 @@ impl GeneratingGuard {
|
||||
}
|
||||
|
||||
/// 解除 Drop 兜底(审批等待保持 Generating)。
|
||||
pub(super) fn disarm(&mut self) {
|
||||
pub(crate) fn disarm(&mut self) {
|
||||
self.done = true;
|
||||
}
|
||||
|
||||
@@ -102,12 +103,10 @@ impl GeneratingGuard {
|
||||
}
|
||||
};
|
||||
if let Some(ev) = ev {
|
||||
let _ = self.app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = self
|
||||
.app_handle
|
||||
.state::<crate::state::AppState>()
|
||||
.ai_event_bus
|
||||
.publish_event(ev);
|
||||
emit_ai_chat_event(&self.app_handle, ev);
|
||||
// P1 bg 化:迁移成功即同步托盘运行态(读 active_convs 快照刷菜单项/tooltip,
|
||||
// set_text/set_tooltip 均同步 API,此处保持同步不引入 await)
|
||||
crate::tray::update_tray_runtime(&self.app_handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,51 @@
|
||||
//! 审批后循环恢复 — 从 agentic/mod.rs 拆分。
|
||||
//! 原代码被嵌套在 run_agentic_loop 内部导致 pub(crate) 不可见。
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::AppHandle;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use df_ai::provider::ChatMessage;
|
||||
|
||||
use crate::state::AppState;
|
||||
use super::approval_timeout;
|
||||
use crate::commands::ai::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::{AiChatEvent, ErrorType, SessionState, GoalEntry};
|
||||
use crate::commands::ai::{AiChatEvent, AiSession, ErrorType, SessionState, GoalEntry};
|
||||
use super::conv_state::ConvState;
|
||||
|
||||
/// 警示 system 消息插入单点(DRY #7 收敛,2026-10-01)。
|
||||
///
|
||||
/// 抽自 4 处同构骨架(insert_destructive_loop_warning / insert_stall_warning /
|
||||
/// insert_tool_call_over_limit_warning / audit::cache insert_listing_bypass_warning):
|
||||
/// 「lock → per_conv.contains_key 守卫 → messages.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 字段的一次性快照。
|
||||
struct ContinueSnapshot {
|
||||
is_generating: bool,
|
||||
@@ -59,17 +96,15 @@ pub async fn try_continue_agent_loop(
|
||||
Some(cid) => cid,
|
||||
None => conv_id.to_string(),
|
||||
};
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
// 零 token 收敛信号,非估算(无真实 LLM 调用)。
|
||||
emit_ai_chat_event(app, AiChatEvent::AiCompleted {
|
||||
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
|
||||
prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0,
|
||||
// 零 token 收敛信号,非估算(无真实 LLM 调用)。
|
||||
is_estimated: false,
|
||||
incomplete: None,
|
||||
conversation_id: Some(emit_conv_id),
|
||||
pinned_goals: snap.pinned_goals_snapshot.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -81,12 +116,11 @@ pub async fn try_continue_agent_loop(
|
||||
if let Err(e2) = state.conv_states.transition(conv_id, ConvState::Idle) {
|
||||
tracing::warn!(conv_id = %conv_id, error = %e2, "conv_states→Idle 非法");
|
||||
}
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
|
||||
error: e.clone(), error_type: Some(ErrorType::ProviderConfig),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(AiChatEvent::AiError {
|
||||
error: e, error_type: None,
|
||||
// emit/publish 原两端 error_type 不一致(emit=ProviderConfig / publish=None,
|
||||
// 手抄漂移),收敛 emit_ai_chat_event 后统一 ProviderConfig(对齐 emit_fatal_error 先例)。
|
||||
emit_ai_chat_event(app, AiChatEvent::AiError {
|
||||
error: e,
|
||||
error_type: Some(ErrorType::ProviderConfig),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
return;
|
||||
@@ -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 conv_id_owned = conv_id.to_string();
|
||||
// Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价;仅 anthropic provider 拆分,
|
||||
// OpenAI 零影响)。续跑路径无新 @ mention,不注入 augmentation(与旧路径一致)。
|
||||
// prompt caching 拆分模式组装(开关关 → 与旧路径逐字等价;anthropic 系显式缓存 /
|
||||
// openai_compat 前缀稳定)。续跑路径无新 @ mention,不注入 augmentation(与旧路径一致)。
|
||||
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) =
|
||||
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)。
|
||||
let still_generating = state.conv_states.is_active(conv_id);
|
||||
if !still_generating {
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
// 零 token 收敛信号,非估算(无真实 LLM 调用)。
|
||||
emit_ai_chat_event(app, AiChatEvent::AiCompleted {
|
||||
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
|
||||
prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0,
|
||||
// 零 token 收敛信号,非估算(无真实 LLM 调用)。
|
||||
is_estimated: false,
|
||||
incomplete: None,
|
||||
conversation_id: Some(conv_id_owned.clone()),
|
||||
pinned_goals: snap.pinned_goals_snapshot.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiAgentRound {
|
||||
round: 0, conversation_id: Some(conv_id_owned.clone()),
|
||||
});
|
||||
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(AiChatEvent::AiAgentRound {
|
||||
emit_ai_chat_event(app, AiChatEvent::AiAgentRound {
|
||||
round: 0, conversation_id: Some(conv_id_owned.clone()),
|
||||
});
|
||||
|
||||
@@ -168,63 +197,19 @@ pub async fn try_continue_agent_loop(
|
||||
|
||||
/// 从 LLM 本轮调用的工具中推理目标描述(本轮可能多个,全部提取)。
|
||||
/// 工具调用是结构化数据(工具名+参数),比从文本中提取更可靠。
|
||||
///
|
||||
/// DRY 债 #5 收敛:动词/参数键/取值风格/缺参兜底不再本文件手写 match,
|
||||
/// 改查注册元数据(goal 维度,在 declare_tool! 注册点声明)。
|
||||
/// 未声明 goal 的工具不产生目标描述(与旧 match 的 _ => None 分支一致)。
|
||||
/// 旧表中的幽灵分支(edit_file,注册表无此工具)随 match 删除一并清理。
|
||||
pub(crate) fn infer_goal_from_tool_calls(tool_calls: &std::collections::HashMap<u32, crate::commands::ai::ToolCallDraft>) -> Vec<String> {
|
||||
if tool_calls.is_empty() { return vec![]; }
|
||||
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
|
||||
let mut goals: Vec<String> = Vec::new();
|
||||
for tc in tool_calls.values() {
|
||||
let goal = match tc.name.as_str() {
|
||||
"patch_file" | "write_file" | "edit_file" | "append_file" => {
|
||||
serde_json::from_str::<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)
|
||||
}))
|
||||
}
|
||||
"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,
|
||||
};
|
||||
let parsed = serde_json::from_str::<serde_json::Value>(&tc.args).ok();
|
||||
let goal = df_ai::ai_tools::goal_of(&tc.name)
|
||||
.and_then(|g| df_ai::ai_tools::goal_text(g, parsed.as_ref()));
|
||||
if let Some(g) = goal { if !goals.contains(&g) { goals.push(g); } }
|
||||
}
|
||||
goals
|
||||
@@ -259,56 +244,22 @@ fn arg_str(args: &serde_json::Value, key: &str) -> String {
|
||||
/// 选「决定意图」参数(决定这次调用"去哪儿查什么"的参数),非决定参数(如 case_sensitive/
|
||||
/// show_line_numbers/timeout 等开关/格式选项)忽略——它们变体不构成漂移。
|
||||
///
|
||||
/// 归一规则(参数从 args JSON 取,缺失用空串):
|
||||
/// - grep/search_files/search: `pattern`(或 `query`)+ `path`(或 `glob`)
|
||||
/// 归一规则(DRY 债 #5 收敛:参数组查注册元数据 sig 维度,在 declare_tool! 注册点声明):
|
||||
/// - grep/search_files: `pattern`(或 `query` 回退)+ `path`(或 `glob` 回退)
|
||||
/// — 同 path 换 pattern 是正常换词;同 pattern 同 path 才算重复。
|
||||
/// - read_file: `path` + `offset` + `limit`
|
||||
/// - read_file: `path` + `offset` + `limit`(后两为数值键,按 JSON 形态序列化)
|
||||
/// — **同段反复才算重复**;不同 offset = 正常分段读大文件(不算)。
|
||||
/// - run_command: `command`(整条命令,含参数)。
|
||||
/// - list_dir/list_directory: `path`。
|
||||
/// - http_request: `url` + `method`。
|
||||
/// - fetch_url: `url`。
|
||||
/// - 其他/兜底: `name` + args 全 JSON 序列化(无明确语义时保守全量,避免漏判)。
|
||||
/// - list_directory: `path`。
|
||||
/// - http_request: `url` + `method`;fetch_url: `url`。
|
||||
/// - 其他/未声明: `name` + args 全 JSON 序列化(无明确语义时保守全量,避免漏判)。
|
||||
///
|
||||
/// 返回 `"name:k1=v1,k2=v2"` 形式。输入 args 通常来自 LLM 的 tool_call function.arguments
|
||||
/// (JSON 字符串),调用方先 from_str 成 Value 再传入。
|
||||
/// 旧表中的幽灵分支(search/list_dir,注册表无此工具)随 match 删除一并清理。
|
||||
pub(crate) fn tool_call_signature(name: &str, args: &serde_json::Value) -> String {
|
||||
let pair = |k: &str, v: &str| format!("{}={}", k, v);
|
||||
let sig = match name {
|
||||
"grep" | "search_files" | "search" => {
|
||||
let q = if args.get("pattern").and_then(|v| v.as_str()).is_some() {
|
||||
arg_str(args, "pattern")
|
||||
} else {
|
||||
arg_str(args, "query")
|
||||
};
|
||||
let p = if args.get("path").and_then(|v| v.as_str()).is_some() {
|
||||
arg_str(args, "path")
|
||||
} else {
|
||||
arg_str(args, "glob")
|
||||
};
|
||||
format!("{},{}", pair("pattern", &q), pair("path", &p))
|
||||
}
|
||||
"read_file" => {
|
||||
// offset/limit 数值字段:as_str 不通,先取再转字符串(缺失→"")。
|
||||
let offset = args.get("offset").map(|v| v.to_string()).unwrap_or_default();
|
||||
let limit = args.get("limit").map(|v| v.to_string()).unwrap_or_default();
|
||||
let path = arg_str(args, "path");
|
||||
format!("{},{},{}", pair("path", &path), pair("offset", &offset), pair("limit", &limit))
|
||||
}
|
||||
"run_command" => pair("command", &arg_str(args, "command")),
|
||||
"list_dir" | "list_directory" => pair("path", &arg_str(args, "path")),
|
||||
"http_request" => {
|
||||
let url = arg_str(args, "url");
|
||||
let method = arg_str(args, "method");
|
||||
format!("{},{}", pair("url", &url), pair("method", &method))
|
||||
}
|
||||
"fetch_url" => pair("url", &arg_str(args, "url")),
|
||||
_ => {
|
||||
// 兜底:工具名 + args 全 JSON 序列化(保守,无明确语义时全量比对)。
|
||||
format!("{},{}", pair("name", name), pair("args", &args.to_string()))
|
||||
}
|
||||
};
|
||||
format!("{}:{}", name, sig)
|
||||
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
|
||||
df_ai::ai_tools::tool_signature(name, args)
|
||||
}
|
||||
|
||||
/// G2 重复检测纯函数:判定最近 N 个工具调用签名是否构成「卡住反复」。
|
||||
@@ -372,26 +323,47 @@ pub(crate) fn normalize_loop_path(p: &str) -> String {
|
||||
p.replace('\\', "/").to_lowercase()
|
||||
}
|
||||
|
||||
/// 从工具调用提取回环样本:write_file/delete_file 取 path 参数规范化,其他工具 None 占位。
|
||||
/// path 缺失/非字符串 → None(无路径无从判同路径,退化为占位)。
|
||||
/// 从工具调用提取回环样本:write_file/delete_file 记规范化 path;read_file 记规范化 path
|
||||
/// (该路径的「使用」证据);run_command 记整条命令(判定时按「命令含路径」匹配使用);
|
||||
/// 其他工具 None 占位。path/command 缺失/非字符串 → None(无从判,退化为占位)。
|
||||
pub(crate) fn destructive_loop_sample(name: &str, args: &serde_json::Value) -> DestructiveLoopSample {
|
||||
match name {
|
||||
"write_file" | "delete_file" => {
|
||||
let p = arg_str(args, "path");
|
||||
if p.is_empty() { None } else { Some((name.to_string(), normalize_loop_path(&p))) }
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
let target = match name {
|
||||
"write_file" | "delete_file" | "read_file" => arg_str(args, "path"),
|
||||
"run_command" => arg_str(args, "command"),
|
||||
_ => return None,
|
||||
};
|
||||
if target.is_empty() { None } else { Some((name.to_string(), normalize_loop_path(&target))) }
|
||||
}
|
||||
|
||||
/// 破坏性回环检测:窗口内「write_file(P) 后 GAP 步内 delete_file(P)」或反向 → 命中路径。
|
||||
///
|
||||
/// GAP=6(全工具调用序位):写删交替紧凑才判回环,间隔远的写与删是正常迭代修复不算。
|
||||
/// 两类豁免(降误伤,治 .trash 临时脚本卫生流程被误熔断):
|
||||
/// - 路径命中临时/回收模式(.trash、/temp/、/tmp/、/target/、.cache 等):写完即删是
|
||||
/// 产物卫生,整对豁免;
|
||||
/// - 写删对之间有对该路径的使用(read 精确命中 / run 命令含路径):用后清理,该对豁免。
|
||||
/// 真「写完即删用户文件」(非临时路径且中间无使用)仍拦。
|
||||
/// 返回首个命中对的规范化路径(警示/熔断文本可引用),无命中 → None。
|
||||
pub(crate) fn detect_destructive_loop(samples: &[DestructiveLoopSample]) -> Option<String> {
|
||||
/// 回环对最大间隔步数(按全部工具调用序位)。
|
||||
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() {
|
||||
let (na, pa) = match samples[i].as_ref() {
|
||||
Some(s) => s,
|
||||
@@ -402,8 +374,14 @@ pub(crate) fn detect_destructive_loop(samples: &[DestructiveLoopSample]) -> Opti
|
||||
"delete_file" => "write_file",
|
||||
_ => continue,
|
||||
};
|
||||
if is_temp_recycle(pa) {
|
||||
continue;
|
||||
}
|
||||
// j 限 (i, i+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 pb == pa && nb == opposite {
|
||||
return Some(pa.clone());
|
||||
@@ -479,9 +457,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sig_list_dir_takes_path() {
|
||||
fn sig_list_directory_takes_path() {
|
||||
let args = serde_json::json!({"path": "/tmp"});
|
||||
assert_eq!(tool_call_signature("list_dir", &args), "list_dir:path=/tmp");
|
||||
assert_eq!(tool_call_signature("list_directory", &args), "list_directory:path=/tmp");
|
||||
// 幽灵名 list_dir 已删分支,走未注册全参兜底
|
||||
let ghost = tool_call_signature("list_dir", &args);
|
||||
assert!(ghost.starts_with("list_dir:name=list_dir,args="), "list_dir 应走兜底分支: {}", ghost);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -531,7 +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": "c.rs", "offset": 0, "limit": 50})),
|
||||
tool_call_signature("read_file", &serde_json::json!({"path": "d.rs", "offset": 100, "limit": 50})),
|
||||
tool_call_signature("list_dir", &serde_json::json!({"path": "src-tauri"})),
|
||||
tool_call_signature("list_directory", &serde_json::json!({"path": "src-tauri"})),
|
||||
tool_call_signature("grep", &serde_json::json!({"pattern": "STALL", "path": "src"})),
|
||||
tool_call_signature("read_file", &serde_json::json!({"path": "e.rs", "offset": 0, "limit": 50})),
|
||||
];
|
||||
@@ -562,7 +543,7 @@ mod tests {
|
||||
fn scenario_repeat_same_chunk_repetitive() {
|
||||
let same = tool_call_signature("read_file", &serde_json::json!({"path": "a.rs", "offset": 0, "limit": 50}));
|
||||
let other1 = tool_call_signature("grep", &serde_json::json!({"pattern": "x", "path": "y"}));
|
||||
let other2 = tool_call_signature("list_dir", &serde_json::json!({"path": "z"}));
|
||||
let other2 = tool_call_signature("list_directory", &serde_json::json!({"path": "z"}));
|
||||
let sigs = vec![same.clone(), same.clone(), same.clone(), same, other1, other2];
|
||||
assert!(is_repetitive_exploration(&sigs), "反复读同段应判重复");
|
||||
}
|
||||
@@ -660,6 +641,45 @@ mod tests {
|
||||
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(不判)。
|
||||
#[test]
|
||||
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 层块) ──
|
||||
// 读末尾连续 Tool 消息,失败包按结构化信号(tool_name + 错误类别)归一 key,
|
||||
// 读末尾连续 Tool 消息,失败包按结构化信号(tool_name + 参数指纹 + 错误类别)归一 key,
|
||||
// 追加进滚动窗口 fail_window(超窗 CIRCUIT_BREAKER_WINDOW 淘汰最旧)。
|
||||
// 成功断连:只统计最近一段连续失败段,段中遇过成功先清空窗口(自适应达成即清零)。
|
||||
// 返回 (max_count, sample_key):窗口内同 key 最高频次 + 该 key;max_count=0 表示无失败。
|
||||
//
|
||||
// 两处相对原实现的关键修正(原 key=内容前 40 字符 + 全 loop 累加不衰减):
|
||||
@@ -72,7 +73,7 @@ pub(crate) async fn count_recent_failures(
|
||||
None => Vec::new(),
|
||||
}
|
||||
};
|
||||
// 末尾连续 Tool 消息(本轮工具回填结果)。
|
||||
// 末尾连续 Tool 消息(本轮工具回填结果),最新在前。
|
||||
let recent_tool_results: Vec<&ChatMessage> = messages
|
||||
.iter().rev()
|
||||
.take_while(|m| matches!(m.role, MessageRole::Tool))
|
||||
@@ -81,21 +82,20 @@ pub(crate) async fn count_recent_failures(
|
||||
return (0u32, String::new());
|
||||
}
|
||||
let id_to_name = build_id_to_name(&messages);
|
||||
// 本轮失败的归一 key 追加进窗口(顺序:消息正序,即工具执行顺序)。
|
||||
for m in recent_tool_results.into_iter().rev() {
|
||||
if is_failure_content(&m.content) {
|
||||
let tool_name = m
|
||||
.tool_call_id
|
||||
.as_deref()
|
||||
.and_then(|id| id_to_name.get(id).map(|s| s.as_str()))
|
||||
.unwrap_or("unknown_tool");
|
||||
let key = failure_key(tool_name, &m.content);
|
||||
// 成功断连:尾部回扫遇成功 tool_result 即停,只统计「最近一段连续失败段」内的失败;
|
||||
// 段中遇过成功即自适应达成,清空窗口重计。红绿修复循环(失败失败成功失败)的成功
|
||||
// 会切断与历史失败的累计,不再误熔断。
|
||||
let (segment_keys, saw_success) = latest_failure_segment(&recent_tool_results, &id_to_name);
|
||||
if saw_success {
|
||||
fail_window.clear();
|
||||
}
|
||||
// 本段失败的归一 key 追加进窗口(顺序:消息正序,即工具执行顺序)。
|
||||
for key in segment_keys {
|
||||
fail_window.push_back(key);
|
||||
while fail_window.len() > CIRCUIT_BREAKER_WINDOW {
|
||||
fail_window.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
// 窗口内同 key 频次最高者 = 熔断候选。
|
||||
let mut freq: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();
|
||||
for k in fail_window.iter() {
|
||||
@@ -107,20 +107,59 @@ pub(crate) async fn count_recent_failures(
|
||||
.unwrap_or((0u32, String::new()))
|
||||
}
|
||||
|
||||
/// 归一失败 key:tool_name + 错误类别。保证同一工具的同类错误稳定映射到同一 key。
|
||||
/// 提取「最近一段连续失败段」的归一 key(最新在前扫描,遇首个非失败结果即停)。
|
||||
///
|
||||
/// - run_command 等带 exit_code → `tool_name::exit=N`(非零退出码即失败类别)。
|
||||
/// - status=failed/error envelope → `tool_name::error:<错误文本稳定哈希>`:
|
||||
/// 返回 (段内失败 key 按执行顺序, 回扫是否遇过成功)。成功=模型自适应达成,
|
||||
/// 调用方据第二个返回值清空累计窗口,使红绿修复循环不再与历史失败叠加。
|
||||
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
|
||||
/// 或异类误聚)。哈希值非敏感信息(仅断路器分组用),无碰撞顾虑(N=20 窗口内几无碰撞)。
|
||||
/// - 内容解析失败(非 JSON)→ is_failure_content 已返回 false 不会进本函数,
|
||||
/// 此处兜底返回 `tool_name::unknown` 防御。
|
||||
fn failure_key(tool_name: &str, content: &str) -> String {
|
||||
/// 此处兜底返回 `tool_name@指纹::unknown` 防御。
|
||||
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 {
|
||||
return format!("{}::unknown", tool_name);
|
||||
return format!("{}::unknown", base);
|
||||
};
|
||||
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 兜底。
|
||||
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("")
|
||||
.trim();
|
||||
if err_text.is_empty() {
|
||||
return format!("{}::{}", tool_name, bucket);
|
||||
return format!("{}::{}", base, bucket);
|
||||
}
|
||||
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_calls[].function.name 里;查不到的 id 由调用方按各自语义兜底。
|
||||
fn build_id_to_name(messages: &[ChatMessage]) -> std::collections::HashMap<String, String> {
|
||||
/// Tool 结果消息只带 tool_call_id,真实工具名与参数在前驱 Assistant 消息的
|
||||
/// tool_calls[].function 里;查不到的 id 由调用方按各自语义兜底。
|
||||
fn build_id_to_name(
|
||||
messages: &[ChatMessage],
|
||||
) -> std::collections::HashMap<String, (String, String)> {
|
||||
let mut id_to_name = std::collections::HashMap::new();
|
||||
for m in messages {
|
||||
if let MessageRole::Assistant = m.role {
|
||||
if let Some(calls) = m.tool_calls.as_ref() {
|
||||
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 是 result JSON(run_command 含 exit_code,其他工具无 status 字段即成功)或 namespace:// 占位符。
|
||||
// 非 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 {
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(content) else {
|
||||
return false; // namespace:// 占位符 / 重试 guard skip 等 → 无结构化失败信号,不判
|
||||
};
|
||||
if let Some(exit) = v.get("exit_code").and_then(|x| x.as_i64()) {
|
||||
return exit != 0; // run_command 等:非零退出 = 真失败
|
||||
if matches!(v.get("status").and_then(|s| s.as_str()), Some("failed") | Some("error")) {
|
||||
return true;
|
||||
}
|
||||
matches!(v.get("status").and_then(|s| s.as_str()), Some("failed") | Some("error"))
|
||||
|| v.get("error").is_some()
|
||||
if v.get("exit_code").is_some() {
|
||||
return false; // 脚本语义:结果 JSON 带 exit_code 即已正常执行,非零退出不算失败
|
||||
}
|
||||
v.get("error").is_some()
|
||||
}
|
||||
|
||||
// ── summarize_tool_results: tool_result view-only 摘要压缩(扁平抽自原嵌套 map 块) ──
|
||||
// 超阈值的 Tool 消息用 extract_key_info 压缩 content;非 Tool / 未超阈值 原样返回。
|
||||
// 仅改入参 Vec(clone),不改 ContextManager 持久化(下次 build_for_request 从 DB 全量重建)。
|
||||
//
|
||||
// 压缩决策冻结(前缀稳定):判定结果按消息内容哈希记入 `view_decisions`(首次见到时按
|
||||
// 当轮阈值判定并写入),后续轮只查表沿用、不再随 history_tokens 增长翻转——否则同一条
|
||||
// 历史第 N 轮发全文、第 N+1 轮变摘要,轮中前缀必断(自动前缀缓存/显式缓存全失效)。
|
||||
pub(crate) fn summarize_tool_results(
|
||||
messages: Vec<ChatMessage>,
|
||||
history_tokens_snapshot: u32,
|
||||
view_decisions: &mut std::collections::HashMap<u64, bool>,
|
||||
conv_id: &str,
|
||||
iteration: usize,
|
||||
) -> Vec<ChatMessage> {
|
||||
@@ -246,19 +299,16 @@ pub(crate) fn summarize_tool_results(
|
||||
return m;
|
||||
}
|
||||
let content_len = m.content.len();
|
||||
let content_tokens = est.estimate_text(&m.content);
|
||||
if !should_summarize_tool_result(content_len, history_tokens_snapshot, content_tokens) {
|
||||
return m;
|
||||
}
|
||||
// 解析真实工具名:tool_call_id 查表;查不到(异常/老数据)退化为 "tool"。
|
||||
// 解析真实工具名:tool_call_id 查表(值含参数指纹,此处只取名);查不到退化为 "tool"。
|
||||
let resolved_name = m
|
||||
.tool_call_id
|
||||
.as_ref()
|
||||
.and_then(|id| id_to_name.get(id))
|
||||
.map(|s| s.as_str())
|
||||
.map(|(n, _)| n.as_str())
|
||||
.unwrap_or("tool");
|
||||
// read_file 豁免压缩:AI 定向读代码,压缩 content 阉割代码分析能力。
|
||||
// read_file handler 自带 limit 硬上限 2000 行,无爆 prompt 风险。
|
||||
// 豁免判定在决策记录之前(豁免消息不进决策表,每轮恒豁免无漂移)。
|
||||
if resolved_name == "read_file" {
|
||||
tracing::debug!(
|
||||
conv_id = %conv_id, iteration,
|
||||
@@ -267,6 +317,21 @@ pub(crate) fn summarize_tool_results(
|
||||
);
|
||||
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;
|
||||
let compressed = extract_key_info(&m.content, resolved_name);
|
||||
compressed_bytes += compressed.len();
|
||||
@@ -283,3 +348,94 @@ pub(crate) fn summarize_tool_results(
|
||||
}
|
||||
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(®, &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(®, &[]);
|
||||
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(®, "parent", "parent::agent-1");
|
||||
sub_agents_register(®, "parent", "parent::agent-2");
|
||||
sub_agents_register(®, "parent", "parent::agent-1"); // 幂等
|
||||
assert_eq!(
|
||||
sub_agents_children(®, "parent"),
|
||||
vec!["parent::agent-1".to_string(), "parent::agent-2".to_string()]
|
||||
);
|
||||
assert!(sub_agents_children(®, "other").is_empty(), "无条目父返空");
|
||||
sub_agents_remove(®, "parent", "parent::agent-1");
|
||||
assert_eq!(
|
||||
sub_agents_children(®, "parent"),
|
||||
vec!["parent::agent-2".to_string()]
|
||||
);
|
||||
sub_agents_remove(®, "parent", "parent::agent-2");
|
||||
// 父条目空后整条清除,不残留空键
|
||||
{
|
||||
let map = reg.lock().unwrap();
|
||||
assert!(map.is_empty(), "父条目空应整条清除");
|
||||
}
|
||||
// 注销不存在的条目不 panic
|
||||
sub_agents_remove(®, "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("审批通道"), "应说明无审批通道(防挂死设计的前提告知)");
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tauri::AppHandle;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
||||
@@ -16,6 +16,7 @@ use df_storage::crud::AiToolExecutionRepo;
|
||||
use df_storage::db::Database;
|
||||
|
||||
use super::cache::{cache_hit_warning, find_cached_high_risk_result, pending_placeholder_for};
|
||||
use super::super::event_bus::emit_ai_chat_event;
|
||||
use super::diff::build_write_file_diff;
|
||||
use super::reason::build_approval_reason;
|
||||
use super::record::audit_tool_call;
|
||||
@@ -180,8 +181,7 @@ pub(super) async fn insert_pending_approval(
|
||||
result: serde_json::Value::String(skip_msg.clone()),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(&app_handle, ev);
|
||||
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"), current_message_id).await;
|
||||
return;
|
||||
}
|
||||
@@ -214,8 +214,16 @@ pub(super) async fn insert_pending_approval(
|
||||
diff: approval_diff,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(&app_handle, 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;
|
||||
}
|
||||
|
||||
@@ -322,8 +330,7 @@ pub(super) async fn handle_approval_tool(
|
||||
dir: dir_label.clone(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(&app_handle, ev);
|
||||
// 收集后循环外 spawn 执行,对齐 Low risk 不持锁(原注释 L593 声称"锁外"但代码持锁,
|
||||
// run_command 慢命令会阻塞同会话所有触 state.ai_session 的 IPC,CR-51 修此)
|
||||
trust_hits.push((draft, args, dir_label, risk_level));
|
||||
@@ -357,8 +364,7 @@ pub(super) async fn handle_approval_tool(
|
||||
result: serde_json::Value::String(warned_content.clone()),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(&app_handle, ev);
|
||||
// 审计:去重命中记一条(status 透传缓存来源 completed/rejected/failed,SW-260618-16;decided_by=auto_dedup),不进 pending
|
||||
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(warned_content), Some("auto_dedup"), current_message_id).await;
|
||||
return;
|
||||
|
||||
@@ -56,8 +56,12 @@ pub(crate) fn is_pending_placeholder(content: &str) -> bool {
|
||||
/// AC-2 ②:list_directory 目录级去重(cache key 只取 path,同目录换参数也命中)后,
|
||||
/// 通用文案「换参数或换工具」对同目录无效(换 max_depth 也仍命中),故目录类工具
|
||||
/// 用专用文案:明确告知「目录已列过 + 内容未变」,引导换 grep/search_files 定向搜索。
|
||||
///
|
||||
/// 目录类判定查注册元数据(cache_path_key 维度,DRY 债 #5 收敛),与
|
||||
/// readonly_cache_args_key 的目录级 key 同一判据,不再各自手写工具名 match。
|
||||
pub(crate) fn cache_hit_warning(tool_name: &str, hit_count: u32) -> String {
|
||||
if matches!(tool_name, "list_directory" | "list_dir") {
|
||||
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
|
||||
if df_ai::ai_tools::cache_path_key_only(tool_name) {
|
||||
format!(
|
||||
"[重复调用拦截] 目录 {} 此前已列过(第 {} 次命中缓存),目录内容未变,以下为缓存结果。不要重复列同一目录,如需查找文件请改用 grep/search_files 定向搜索,或换其他目录。\n\n",
|
||||
tool_name, hit_count
|
||||
@@ -212,14 +216,16 @@ fn canonical_args_key(args: &serde_json::Value) -> String {
|
||||
///
|
||||
/// list_directory 同目录换参数(recursive/max_depth/skip_noise_dirs 不同)也判重复——
|
||||
/// 目录内容没变,换深度/递归参数重列是重复探索(实证 09e7abfa:同一目录列 3 次)。
|
||||
/// 故 list_directory 的缓存 key 只取 path(忽略其他参数),同目录即命中缓存,
|
||||
/// 故声明 cache_path_key 的工具(list_directory,DRY 债 #5 收敛后查注册元数据)
|
||||
/// 缓存 key 只取 path(忽略其他参数),同目录即命中缓存,
|
||||
/// 后续命中返回缓存 + 专用警告(cache_hit_warning 目录分支)。
|
||||
/// 其他只读工具仍走全参 JSON 归一(键序无关,见 canonical_args_key)。
|
||||
///
|
||||
/// pub(crate):供 audit/mod.rs 轮内去重复用同一 key 口径(同源同 key,
|
||||
/// 跨轮缓存 + 轮内去重判定一致,防两处 key 漂移致去重误判)。
|
||||
pub(crate) fn readonly_cache_args_key(tool_name: &str, args: &serde_json::Value) -> String {
|
||||
if matches!(tool_name, "list_directory" | "list_dir") {
|
||||
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
|
||||
if df_ai::ai_tools::cache_path_key_only(tool_name) {
|
||||
args.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
@@ -241,9 +247,9 @@ pub(crate) fn readonly_cache_args_key(tool_name: &str, args: &serde_json::Value)
|
||||
/// 跳过真实执行(也跳过 emit Started/Completed——直接走缓存回填路径)。
|
||||
///
|
||||
/// **安全边界**(对照 find_cached_high_risk_result 严格收紧):
|
||||
/// - 仅匹配白名单只读幂等工具(READONLY_CACHE_TOOLS):read_file/list_directory/
|
||||
/// search_files/grep/read_symbol。写工具(write_file/patch_file/run_command 等)
|
||||
/// 永不缓存,避免误伤。
|
||||
/// - 仅匹配只读幂等工具(readonly 元数据维度,DRY 债 #5 收敛后查注册元数据,
|
||||
/// 现 read_file/read_symbol/list_directory/search_files/grep 声明)。
|
||||
/// 写工具(write_file/patch_file/run_command 等)未声明,永不缓存,避免误伤。
|
||||
/// - 仅匹配 **status=completed** 的成功结果(失败/拒绝不缓存,可能重试成功)。
|
||||
/// - 仅匹配 **非占位** 结果(排除 pending_placeholder 占位)。
|
||||
/// - args 走 JSON 规范化比较(键序无关),与 find_cached_high_risk_result 一致。
|
||||
@@ -260,8 +266,9 @@ pub(crate) async fn find_cached_readonly_result(
|
||||
) -> Option<(String, u32)> {
|
||||
use df_ai::provider::MessageRole;
|
||||
|
||||
// 白名单:只读幂等工具才缓存。写工具/有副作用工具永不缓存。
|
||||
if !READONLY_CACHE_TOOLS.contains(&tool_name) {
|
||||
// 只读幂等工具才缓存。写工具/有副作用工具永不缓存。
|
||||
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
|
||||
if !df_ai::ai_tools::is_readonly(tool_name) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -332,17 +339,8 @@ pub(crate) async fn find_cached_readonly_result(
|
||||
Some((content, hit_count))
|
||||
}
|
||||
|
||||
/// 可缓存的只读幂等工具白名单。
|
||||
/// 仅含纯读取/查询类工具:同参多次调用结果一致,缓存命中安全。
|
||||
/// 写工具/有副作用工具(write_file/patch_file/run_command/create_*/update_*/delete_* 等)
|
||||
/// 永不进此白名单——它们每次执行都可能改变状态,缓存会掩盖变化。
|
||||
const READONLY_CACHE_TOOLS: &[&str] = &[
|
||||
"read_file", // 读文件内容(同 path+offset+limit+search 参数 → 同结果)
|
||||
"read_symbol", // AST 符号读取(同 path+symbol+full → 同结果)
|
||||
"list_directory", // 列目录(AC-2 ②:缓存 key 只取 path,同目录换 recursive/max_depth 也判重复)
|
||||
"search_files", // 搜文件名(同 path+pattern+offset+limit → 同结果)
|
||||
"grep", // 搜文件内容(同 path+pattern+mode → 同结果)
|
||||
];
|
||||
/// 只读缓存白名单已收敛为注册元数据(readonly 维度,DRY 债 #5):
|
||||
/// find_cached_readonly_result 改查 df_ai::ai_tools::is_readonly,本文件不再持名单。
|
||||
|
||||
/// 递归对 JSON 对象的键做字典序排序(就地),数组成员也递归排序。
|
||||
fn sort_object_keys(v: &mut serde_json::Value) {
|
||||
@@ -421,15 +419,18 @@ fn contains_listing_command(command: &str) -> bool {
|
||||
|| contains_word(&lower, "ls")
|
||||
}
|
||||
|
||||
/// 扫描会话历史,取所有 list_directory 已列过的目录路径(去重,保留原始大小写)。
|
||||
/// 扫描会话历史,取所有目录级缓存工具(cache_path_key 声明者,现即 list_directory)
|
||||
/// 已列过的目录路径(去重,保留原始大小写)。
|
||||
///
|
||||
/// 与 find_cached_readonly_result 同源读 assistant tool_calls;仅取 list_directory
|
||||
/// 的 args.path,忽略递归/深度参数(② 同语义:同目录即算已列过)。
|
||||
/// 与 find_cached_readonly_result 同源读 assistant tool_calls;仅取 args.path,
|
||||
/// 忽略递归/深度参数(② 同语义:同目录即算已列过)。工具判定查注册元数据
|
||||
/// (cache_path_key 维度,DRY 债 #5 收敛),与目录级缓存 key 同一判据。
|
||||
async fn listed_directory_paths(
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
conv_id: &str,
|
||||
) -> Vec<String> {
|
||||
use df_ai::provider::MessageRole;
|
||||
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
|
||||
let session = session_arc.lock().await;
|
||||
let Some(conv) = session.conv_read(conv_id) else { return Vec::new() };
|
||||
let mut dirs: Vec<String> = Vec::new();
|
||||
@@ -440,7 +441,7 @@ async fn listed_directory_paths(
|
||||
}
|
||||
let Some(tcs) = m.tool_calls.as_ref() else { continue };
|
||||
for tc in tcs {
|
||||
if tc.function.name != "list_directory" {
|
||||
if !df_ai::ai_tools::cache_path_key_only(&tc.function.name) {
|
||||
continue;
|
||||
}
|
||||
let Ok(args) = serde_json::from_str::<serde_json::Value>(&tc.function.arguments) else {
|
||||
@@ -494,9 +495,10 @@ pub(crate) async fn detect_listing_bypass(
|
||||
None
|
||||
}
|
||||
|
||||
/// AC-2 ③:目录列举绕行警告注入(system 消息首位,对齐 G2/AC-2① 软提示模式)。
|
||||
/// AC-2 ③:目录列举绕行警告注入(system 消息首位)。
|
||||
///
|
||||
/// 只警告不硬拒:命令照常执行/审批,误报最多多一条提示,不阻断工作流。
|
||||
/// 插入骨架经 agentic::helpers::insert_system_notice 复用(DRY #7,与 G2/AC-2① 同一实现)。
|
||||
pub(crate) async fn insert_listing_bypass_warning(
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
conv_id: &str,
|
||||
@@ -506,16 +508,13 @@ pub(crate) async fn insert_listing_bypass_warning(
|
||||
"⚠ 检测到用 run_command 列目录({}) 绕行 list_directory 去重。列目录请用 list_directory 工具,如需查找文件请用 grep/search_files 定向搜索。",
|
||||
dir
|
||||
);
|
||||
let mut session = session_arc.lock().await;
|
||||
if session.per_conv.contains_key(conv_id) {
|
||||
let conv = session.conv(conv_id);
|
||||
conv.messages.insert_at(0, ChatMessage::system(&warn_text));
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
dir = %dir,
|
||||
"[ai] AC-2 目录列举绕行:注入警告(只警告不硬拒)"
|
||||
);
|
||||
}
|
||||
crate::commands::ai::agentic::helpers::insert_system_notice(
|
||||
session_arc,
|
||||
conv_id,
|
||||
&warn_text,
|
||||
&format!("AC-2 目录列举绕行:注入警告(只警告不硬拒) dir={}", dir),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -11,33 +11,13 @@ use tauri::{AppHandle, Emitter};
|
||||
/// AR-11(方案A):工具名 → (entity, action) 映射,用于数据变更联动刷新。
|
||||
///
|
||||
/// 仅数据变更类工具返回 Some,其余工具返回 None(不 emit df-data-changed)。
|
||||
/// 映射规则:
|
||||
/// - create_project/create_idea/create_task → (project/idea/task, create)
|
||||
/// - update_project/update_task/update_idea → (..., update)
|
||||
/// - delete_project/delete_task/delete_idea/purge_project → (..., delete)
|
||||
/// - restore_project → (project, update)(恢复等同状态变更,前端走 update 刷新)
|
||||
/// - bind_directory → (project, update)(绑定目录属项目字段更新)
|
||||
/// 映射查注册元数据(entity_action 维度,DRY 债 #5 收敛),在 declare_tool! 注册点声明:
|
||||
/// create_*(project/idea/task, create)、update_*(..., update)、delete_*/purge_project
|
||||
/// (..., delete)、restore_project/bind_directory → (project, update)、advance_task →
|
||||
/// (task, update)(状态推进对齐 update_task)、write_file/patch_file → (file, update)。
|
||||
pub(super) fn data_change_for_tool(name: &str) -> Option<(&'static str, &'static str)> {
|
||||
let (entity, action) = match name {
|
||||
"create_project" => ("project", "create"),
|
||||
"create_task" => ("task", "create"),
|
||||
"create_idea" => ("idea", "create"),
|
||||
"update_project" => ("project", "update"),
|
||||
"update_task" => ("task", "update"),
|
||||
"update_idea" => ("idea", "update"),
|
||||
// advance_task 改任务状态 = 任务数据变更,MCP 走 HTTP 形态时让 GUI 前端
|
||||
// listen df-data-changed 刷新任务列表(此前不在映射 → 状态推进后 GUI 不刷新)。
|
||||
// 数据变更机制是 entity 级(无 id),映射到 (task, update) 对齐 update_task。
|
||||
"advance_task" => ("task", "update"),
|
||||
"delete_project" | "purge_project" => ("project", "delete"),
|
||||
"delete_task" => ("task", "delete"),
|
||||
"delete_idea" => ("idea", "delete"),
|
||||
"restore_project" => ("project", "update"),
|
||||
"bind_directory" => ("project", "update"),
|
||||
"write_file" | "patch_file" => ("file", "update"),
|
||||
_ => return None,
|
||||
};
|
||||
Some((entity, action))
|
||||
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
|
||||
df_ai::ai_tools::entity_action(name)
|
||||
}
|
||||
|
||||
/// AR-11(方案A):工具执行成功后按映射 emit "df-data-changed" { entity, action }。
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
||||
@@ -11,6 +11,8 @@ use df_ai::provider::ChatMessage;
|
||||
use df_storage::crud::AiToolExecutionRepo;
|
||||
use df_storage::db::Database;
|
||||
|
||||
use super::event_bus::emit_ai_chat_event;
|
||||
use crate::state::{SettingsNorm, log_slow_lock};
|
||||
use super::{AiChatEvent, AiSession, ApprovalKind, PathAuthRequest, PendingApproval, ToolCallDraft};
|
||||
use super::tools::entity_resolve;
|
||||
|
||||
@@ -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 进审批门控
|
||||
/// 返回待审批的工具数量(0 = 全部自动执行完成)
|
||||
/// 工具执行 + 心跳保活:execute 期间每 30s emit AiHeartbeat(对齐 stream_recv.rs
|
||||
@@ -142,8 +195,7 @@ async fn execute_with_heartbeat(
|
||||
break;
|
||||
}
|
||||
let ev = AiChatEvent::AiHeartbeat { conversation_id: Some(conv.clone()) };
|
||||
let _ = app_h.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_h.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(&app_h, ev);
|
||||
}
|
||||
});
|
||||
// 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 内部 clamp 同源),其余工具仍 60s。run_command 内部还有自己的子 timeout,
|
||||
// 外层只需 >= 内部 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")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(60)
|
||||
@@ -179,6 +236,12 @@ async fn execute_with_heartbeat(
|
||||
None
|
||||
};
|
||||
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 {
|
||||
Ok(result) => result,
|
||||
Err(_elapsed) => {
|
||||
@@ -201,7 +264,10 @@ pub(crate) async fn process_tool_calls(
|
||||
db: &Arc<Database>,
|
||||
app_handle: &AppHandle,
|
||||
conv_id: &str,
|
||||
policy: ToolExecPolicy,
|
||||
) -> usize {
|
||||
// 策略快照(SubAgent:须人工介入的路径全部就地拒绝,见 ToolExecPolicy 文档)
|
||||
let is_sub_agent = matches!(policy, ToolExecPolicy::SubAgent);
|
||||
let mut tc_list: Vec<_> = tool_calls_acc.into_iter().collect();
|
||||
tc_list.sort_unstable_by_key(|(i, _)| *i);
|
||||
// 治本兜底: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 __id = session.conv_read(conv_id)
|
||||
.and_then(|c| c.messages.last_assistant_message_id());
|
||||
let __hold = __lock_t.elapsed();
|
||||
if __hold > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] process_tool_calls:192 持锁 {:?} (含 lock 等待)", __hold);
|
||||
}
|
||||
log_slow_lock(__lock_t, "process_tool_calls:192");
|
||||
__id
|
||||
},
|
||||
).await {
|
||||
@@ -260,8 +323,7 @@ pub(crate) async fn process_tool_calls(
|
||||
args: args.clone(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(&app_handle, ev);
|
||||
(idx, draft, args)
|
||||
})
|
||||
.collect();
|
||||
@@ -283,18 +345,14 @@ pub(crate) async fn process_tool_calls(
|
||||
let __lock_t = std::time::Instant::now();
|
||||
let mut session = session_arc.lock().await;
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &err_payload));
|
||||
let __hold = __lock_t.elapsed();
|
||||
if __hold > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] process_tool_calls:268 持锁 {:?} (含 lock 等待)", __hold);
|
||||
}
|
||||
log_slow_lock(__lock_t, "process_tool_calls:268");
|
||||
}
|
||||
let ev = AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
result: serde_json::Value::String(err_payload.clone()),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(&app_handle, ev);
|
||||
let risk = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||||
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||
"failed", risk, Some(err_payload.clone()), Some("auto_resolve_fail"), current_message_id).await;
|
||||
@@ -306,16 +364,12 @@ pub(crate) async fn process_tool_calls(
|
||||
// 自动执行范围三档读取(前置到路径授权预校验之前:两处门控共用)。
|
||||
// KV df-ai-auto-execute-mode 取值 low/medium/all,默认 low;
|
||||
// low 仅 Low 自动(等价旧行为),medium Low+Medium,all 全自动无审批(完全 AI 接管)。
|
||||
// get_string_norm 归一去引号(前端 JSON.stringify 落库带引号),不归一则 medium/all 档静默失效全走审批。
|
||||
let auto_exec_mode = app_handle
|
||||
.state::<crate::state::AppState>()
|
||||
.settings
|
||||
.get("df-ai-auto-execute-mode")
|
||||
.get_string_norm("df-ai-auto-execute-mode")
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
// 前端 appSettings.set 以 JSON.stringify 落库(字符串值带引号,"all" != all),
|
||||
// 不归一则 medium/all 档静默失效全走审批(同 prompt.rs custom_prompt 的 trim 先例)。
|
||||
.map(|v| v.trim_matches('"').to_string())
|
||||
.unwrap_or_else(|| "low".to_string());
|
||||
|
||||
// ── 文件工具路径授权预校验 ──
|
||||
@@ -334,22 +388,20 @@ pub(crate) async fn process_tool_calls(
|
||||
// 「信任所有目录」总开关(KV df-ai-path-auth-bypass,默认关):开启后白名单外路径不再挂起
|
||||
// 授权弹窗(NeedsAuth 时直接放行),黑名单拦截也一并放行(产品拍板:体验优先,含敏感目录)。
|
||||
// 读取失败按关处理(fail-safe,默认行为不变);前端设置页改后下一轮 process_tool_calls 即生效。
|
||||
// get_bool_norm 归一比对(前端 JSON.stringify 落库带引号),默认 false 由调用方显式传参。
|
||||
let path_auth_bypass = app_handle
|
||||
.state::<crate::state::AppState>()
|
||||
.settings
|
||||
.get(PATH_AUTH_BYPASS_KEY)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
// 前端 appSettings.set 以 JSON.stringify 落库(带引号),归一后比对(同 df-ai-trace-approve 先例)
|
||||
.map(|v| { let v = v.trim_matches('"'); v == "true" || v == "1" })
|
||||
.unwrap_or(false);
|
||||
.get_bool_norm(PATH_AUTH_BYPASS_KEY, false)
|
||||
.await;
|
||||
// 同步进程级黑名单放行标志:validate_path(tool_registry)无 bypass 参数,靠此标志放行黑名单;
|
||||
// 每轮工具执行前刷新,与下方 check_file_tool_auth 的入参 bypass 同源同值,两处拦截点语义一致。
|
||||
path_auth::set_blacklist_bypass(path_auth_bypass);
|
||||
// 收集 NeedsAuth/Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环)
|
||||
let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new();
|
||||
let mut path_denied: Vec<(ToolCallDraft, String)> = Vec::new();
|
||||
// SubAgent 策略:须授权/黑名单拒绝统一收集,后续就地拒绝(绝不挂起)
|
||||
let mut sub_agent_path_denied: Vec<(ToolCallDraft, String)> = Vec::new();
|
||||
let mut authorized_drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = Vec::new();
|
||||
for (idx, draft, args) in drafts {
|
||||
// 会话级临时授权目录在 allowed_guard.session 内(进程级,随 active 会话切换清空),
|
||||
@@ -357,6 +409,11 @@ pub(crate) async fn process_tool_calls(
|
||||
match check_file_tool_auth(&draft.name, &args, &allowed_guard, path_auth_bypass) {
|
||||
FileToolAuthOutcome::Authorized => authorized_drafts.push((idx, draft, args)),
|
||||
FileToolAuthOutcome::NeedsAuth(req) => {
|
||||
// SubAgent 策略:子代理无授权弹窗通道,挂起即永久停摆 → 就地拒绝。
|
||||
if is_sub_agent {
|
||||
sub_agent_path_denied.push((draft, "路径未授权且子代理无授权弹窗通道".to_string()));
|
||||
continue;
|
||||
}
|
||||
// all 档全自动(用户拍板):路径授权弹窗也不打断,与 RiskLevel 门同语义。
|
||||
// 黑名单 Denied 不受影响——系统敏感目录是绝对安全线,全接管也不放行。
|
||||
// 放行的 draft 走 authorized_drafts → RiskLevel 分类 → all 放行 → 审计留痕闭环。
|
||||
@@ -366,7 +423,13 @@ pub(crate) async fn process_tool_calls(
|
||||
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;
|
||||
@@ -385,10 +448,7 @@ pub(crate) async fn process_tool_calls(
|
||||
let __lock_t = std::time::Instant::now();
|
||||
let mut session = session_arc.lock().await;
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &err_msg));
|
||||
let __hold = __lock_t.elapsed();
|
||||
if __hold > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] process_tool_calls:262 持锁 {:?} (含 lock 等待)", __hold);
|
||||
}
|
||||
log_slow_lock(__lock_t, "process_tool_calls:262");
|
||||
}
|
||||
// L3 emit 双写:路径黑名单拒绝 emit Completed 双路发布。
|
||||
let ev = AiChatEvent::AiToolCallCompleted {
|
||||
@@ -396,8 +456,7 @@ pub(crate) async fn process_tool_calls(
|
||||
result: serde_json::Value::String(err_msg.clone()),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(&app_handle, ev);
|
||||
// 审计:路径黑名单拒绝(decided_by=auto_blacklist),留痕可追溯
|
||||
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||||
audit_tool_call(
|
||||
@@ -407,6 +466,14 @@ pub(crate) async fn process_tool_calls(
|
||||
).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)。
|
||||
// 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。
|
||||
// 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 mut session = session_arc.lock().await;
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg));
|
||||
let __hold = __lock_t.elapsed();
|
||||
if __hold > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] process_tool_calls:305 持锁 {:?} (含 lock 等待)", __hold);
|
||||
}
|
||||
log_slow_lock(__lock_t, "process_tool_calls:305");
|
||||
}
|
||||
let ev = AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
result: serde_json::Value::String(skip_msg.clone()),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(&app_handle, ev);
|
||||
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||||
audit_tool_call(
|
||||
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||
@@ -473,10 +536,7 @@ pub(crate) async fn process_tool_calls(
|
||||
);
|
||||
// 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果。
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
|
||||
let __hold = __lock_t.elapsed();
|
||||
if __hold > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] process_tool_calls:325 持锁 {:?} (含 lock 等待)", __hold);
|
||||
}
|
||||
log_slow_lock(__lock_t, "process_tool_calls:325");
|
||||
}
|
||||
tracing::debug!(target: "ai_dirauth", conv = %conv_id, tool = %draft.name, tc_id = %draft.id, "emit AiDirAuthRequired(路径授权挂起,等 ai_authorize_dir 恢复)");
|
||||
let ev = AiChatEvent::AiDirAuthRequired {
|
||||
@@ -486,8 +546,7 @@ pub(crate) async fn process_tool_calls(
|
||||
dir: dir_str,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(&app_handle, ev);
|
||||
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||||
audit_tool_call(
|
||||
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||
@@ -507,16 +566,12 @@ pub(crate) async fn process_tool_calls(
|
||||
// 也进此向量,需透传真实等级,审计方可追溯「all 模式执行了多少高危命令」。
|
||||
let mut low_risk: Vec<(ToolCallDraft, serde_json::Value, RiskLevel)> = Vec::new();
|
||||
// F-#97 诊断:用户报「选 all 仍审批」时辅助定位 KV 是否真落库。
|
||||
// df-ai-trace-approve 开关控制(默认开)。常见误以为已生效但实际未点确认按钮致 KV 未落。
|
||||
// df-ai-trace-approve 开关控制(默认开,get_bool_norm 默认值调用方传参)。常见误以为已生效但实际未点确认按钮致 KV 未落。
|
||||
let trace_on = app_handle
|
||||
.state::<crate::state::AppState>()
|
||||
.settings
|
||||
.get("df-ai-trace-approve")
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| { let v = v.trim_matches('"'); v == "true" || v == "1" })
|
||||
.unwrap_or(true);
|
||||
.get_bool_norm("df-ai-trace-approve", true)
|
||||
.await;
|
||||
if trace_on {
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
@@ -530,6 +585,15 @@ pub(crate) async fn process_tool_calls(
|
||||
let mut trust_hits: Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)> = Vec::new();
|
||||
for (_, draft, args) in drafts {
|
||||
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 目录列举绕行检测(最保守版,只警告不硬拒)。
|
||||
// 实证 09e7abfa:list_directory 后 LLM 用 run_command Get-ChildItem 再探同目录,
|
||||
// 绕过 ② 的目录级去重。检测到绕行 → insert 一条 system 警告(非静默),命令照常
|
||||
@@ -570,8 +634,7 @@ pub(crate) async fn process_tool_calls(
|
||||
result: serde_json::Value::String(content.clone()),
|
||||
conversation_id: Some(conv_clone),
|
||||
};
|
||||
let _ = app_clone.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(&app_clone, ev);
|
||||
// 数据变更类工具执行成功后 emit df-data-changed 联动刷新
|
||||
// (write_file/run_command 不在 data_change_for_tool 映射内,emit_data_changed 内部 None 即 noop)
|
||||
emit_data_changed(&app_clone, &draft.name);
|
||||
@@ -585,8 +648,7 @@ pub(crate) async fn process_tool_calls(
|
||||
result: serde_json::Value::String(content.clone()),
|
||||
conversation_id: Some(conv_clone),
|
||||
};
|
||||
let _ = app_clone.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(&app_clone, ev);
|
||||
(draft, risk_level, Err(content))
|
||||
}
|
||||
}
|
||||
@@ -668,8 +730,7 @@ pub(crate) async fn process_tool_calls(
|
||||
result: serde_json::Value::String(warned_content.clone()),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(&app_handle, ev);
|
||||
// 短 lock 段:push tool_result(纯写,无 await)
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
@@ -709,8 +770,7 @@ pub(crate) async fn process_tool_calls(
|
||||
result: val.clone(),
|
||||
conversation_id: Some(conv_clone),
|
||||
};
|
||||
let _ = app_clone.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(&app_clone, ev);
|
||||
// AR-11(方案A):数据变更工具执行成功后 emit df-data-changed,
|
||||
// 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。
|
||||
emit_data_changed(&app_clone, &draft.name);
|
||||
@@ -729,8 +789,7 @@ pub(crate) async fn process_tool_calls(
|
||||
result: serde_json::Value::String(err_msg.clone()),
|
||||
conversation_id: Some(conv_clone),
|
||||
};
|
||||
let _ = app_clone.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(&app_clone, ev);
|
||||
(draft, serde_json::Value::Null, risk_level, Err(err_msg))
|
||||
}
|
||||
}
|
||||
@@ -831,8 +890,7 @@ pub(crate) async fn process_tool_calls(
|
||||
result: serde_json::Value::String(warned_content.clone()),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(&app_handle, ev);
|
||||
// 短 lock 段:push tool_result(纯写,无 await)
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
|
||||
@@ -40,28 +40,13 @@ pub(crate) static BLACKLIST_BYPASS_TEST_LOCK: std::sync::Mutex<()> = std::sync::
|
||||
/// 提取文件工具的路径参数(用于路径授权预校验)。
|
||||
///
|
||||
/// 仅对走 resolve_workspace_path 校验的文件工具返回路径;非文件工具返回空 Vec(不预校验)。
|
||||
/// - 单路径工具(read_file/write_file/list_directory/patch_file/file_info/append_file/delete_file/search_files/grep)
|
||||
/// 取 args["path"]
|
||||
/// - rename_file 双路径取 args["from"] + args["to"](两个都需授权)
|
||||
/// - run_command 不返回(它只走 validate_path 黑名单,不限定 workspace,High risk 靠人工审批)
|
||||
/// 路径参数键查注册元数据(path_args 维度,DRY 债 #5 收敛):单路径工具(read_file/
|
||||
/// write_file/list_directory/patch_file/file_info/append_file/delete_file/search_files/grep)
|
||||
/// 声明 ["path"],rename_file 声明 ["from","to"](两个都需授权),其余工具默认空(不预校验)。
|
||||
/// run_command 不声明(只走 validate_path 黑名单,不限定 workspace,High risk 靠人工审批)。
|
||||
pub(crate) fn extract_file_tool_paths(tool_name: &str, args: &serde_json::Value) -> Vec<String> {
|
||||
match tool_name {
|
||||
"rename_file" => {
|
||||
let mut v = Vec::new();
|
||||
if let Some(from) = args.get("from").and_then(|x| x.as_str()) {
|
||||
v.push(from.to_string());
|
||||
}
|
||||
if let Some(to) = args.get("to").and_then(|x| x.as_str()) {
|
||||
v.push(to.to_string());
|
||||
}
|
||||
v
|
||||
}
|
||||
"read_file" | "write_file" | "list_directory" | "patch_file" | "file_info"
|
||||
| "append_file" | "delete_file" | "search_files" | "grep" => {
|
||||
args.get("path").and_then(|x| x.as_str()).map(|s| vec![s.to_string()]).unwrap_or_default()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
|
||||
df_ai::ai_tools::extract_paths(df_ai::ai_tools::path_arg_keys(tool_name), args)
|
||||
}
|
||||
|
||||
/// 路径授权预校验结果。
|
||||
@@ -124,10 +109,11 @@ pub(crate) fn check_file_tool_auth(
|
||||
}
|
||||
if pending_dirs.is_empty() {
|
||||
FileToolAuthOutcome::Authorized
|
||||
} else if tool_name == "search_files" {
|
||||
} else if df_ai::ai_tools::denies_unauthorized(tool_name) {
|
||||
// 核心设计5:search_files 兜底 — 盲搜语义应拒不应询。
|
||||
// search_files 仅在已授权目录可用;用户已通过 @项目 提供项目上下文,无需搜索文件系统。
|
||||
// (黑名单已在上方 for 循环内短路返 Denied,走到此处均为 NeedsAuthorization 场景。)
|
||||
// 拒绝名单查注册元数据(deny_unauthorized 维度,DRY 债 #5 收敛,现仅 search_files 声明);
|
||||
// 其他文件工具保持原 NeedsAuth 弹窗逻辑完全不变。
|
||||
FileToolAuthOutcome::Denied(
|
||||
"search_files 仅在已授权目录可用;请用 @[项目] 引用或提供绝对路径,不要盲搜文件系统。".to_string(),
|
||||
|
||||
@@ -27,8 +27,9 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tauri::AppHandle;
|
||||
|
||||
use super::event_bus::emit_ai_chat_event;
|
||||
use super::AiChatEvent;
|
||||
use df_execute::shell::StreamKind;
|
||||
|
||||
@@ -172,8 +173,7 @@ impl CommandSink {
|
||||
line: line.to_string(),
|
||||
conversation_id: conversation_id.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(app, ev);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,21 +13,22 @@
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
|
||||
// F-260801 Phase2:anthropic 协议 url→base64 预拉(base64 内嵌编码,generate_image.rs 同款用法)。
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
|
||||
use df_ai::provider::{ChatMessage, ContentPart, MessageStatus};
|
||||
use df_storage::models::AiProviderRecord;
|
||||
use df_types::augmentation::{MentionRef, MentionSpanDto};
|
||||
use df_types::augmentation::{Augmentation, MentionRef, MentionSpanDto, ResolveError};
|
||||
use df_types::types::new_id;
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::state::{AppState, log_slow_lock};
|
||||
use crate::commands::{err_str, now_millis};
|
||||
|
||||
// chat.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。
|
||||
use super::super::agentic::{run_agentic_loop, try_continue_agent_loop};
|
||||
use super::super::event_bus::emit_ai_chat_event;
|
||||
#[cfg(test)]
|
||||
use super::super::agentic::GOAL_MAX_CHARS;
|
||||
// ConvState 枚举变体引用(Idle 等),用于 B-Phase2 写侧 conv_states.transition 调用。
|
||||
@@ -38,7 +39,7 @@ use super::super::augmentation::build_augmentation_segment;
|
||||
use super::super::augmentation::sanitize;
|
||||
use super::super::conversation::save_conversation;
|
||||
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};
|
||||
|
||||
@@ -152,10 +153,12 @@ fn find_mention_close(chars: &[char], open: usize, kinds: &[&str]) -> Option<usi
|
||||
///
|
||||
/// 空结果返 `""`,调用方据之跳过拼接(不污染 prompt)。
|
||||
///
|
||||
/// 注:regenerate / edit 路径当前传 `skill=&None, spans=&None`(无新注入诉求),
|
||||
/// 若后续需从历史末条 user 消息的 mentionSpans resolve,改这两处调用即可。
|
||||
/// 注:regenerate / edit 路径经 [`backtrack_leading_skill`] 回溯末条 user 前导 /技能名后传入,
|
||||
/// spans 仍传 `&None`(重生成不重放历史 @ mention)。
|
||||
async fn resolve_and_inject(
|
||||
app: &AppHandle,
|
||||
state: &AppState,
|
||||
conv_id: &str,
|
||||
provider: &AiProviderRecord,
|
||||
skill: &Option<String>,
|
||||
spans: &Option<Vec<MentionSpanDto>>,
|
||||
@@ -188,9 +191,78 @@ async fn resolve_and_inject(
|
||||
errors
|
||||
);
|
||||
}
|
||||
// 注入反馈闭环:技能 resolve 成功/失败 emit 前端事件(轻 toast 用)。仅在 resolvers
|
||||
// 聚合点(此函数)发,这里恰有 AppHandle 且同时看得到成功(augs)与失败(errors)两侧;
|
||||
// project/task/idea mention 无用户显式调用语义,不发反馈。
|
||||
for aug in &augs {
|
||||
if let Augmentation::Skill { name, .. } = aug {
|
||||
emit_skill_feedback(app, conv_id, name, true);
|
||||
}
|
||||
}
|
||||
for err in &errors {
|
||||
let name = match err {
|
||||
ResolveError::NotFound { kind, ref_id } if kind == "skill" => Some(ref_id),
|
||||
ResolveError::Skip { kind, ref_id, .. } if kind == "skill" => Some(ref_id),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(name) = name {
|
||||
emit_skill_feedback(app, conv_id, name, false);
|
||||
}
|
||||
}
|
||||
build_augmentation_segment(&augs, lang)
|
||||
}
|
||||
|
||||
/// 技能注入成功/失败反馈事件(双写经 [`emit_ai_chat_event`])。
|
||||
///
|
||||
/// 前端 AiChat.vue listen 'ai-chat-event' 过滤本变体做轻 toast(成功「已注入技能《name》」/
|
||||
/// 失败「技能 name 未找到」,i18n 中英成对)。fire-and-forget,emit 失败仅丢弃不阻断注入。
|
||||
fn emit_skill_feedback(app: &AppHandle, conv_id: &str, name: &str, ok: bool) {
|
||||
let ev = AiChatEvent::AiSkillInjected {
|
||||
name: name.to_string(),
|
||||
ok,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
emit_ai_chat_event(app, ev);
|
||||
}
|
||||
|
||||
/// 回溯末条 active user 消息的前导 `/技能名`(regenerate/edit 补技能注入)。
|
||||
///
|
||||
/// 背景:send 路径入口已 parse_leading_skill;regenerate/edit 此前写死 `&None` 丢技能。
|
||||
/// 用户打字 `/name ...` 的原文落库在 user 消息 content 里,反向扫出末条 active user 后
|
||||
/// 复用同一纯函数 [`parse_leading_skill`] 回溯(不新写解析逻辑),解析不到返 None(无注入)。
|
||||
/// 注:edit 路径调用时末条 user 已被替换为编辑后文本,回溯对象即新文本,语义一致。
|
||||
async fn backtrack_leading_skill(state: &AppState, conv_id: &str) -> Option<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 驱动)。
|
||||
///
|
||||
/// 未知 kind / 空 ref_id 返 None(静默跳过,不中断整批注入——与 resolve_all 失败收集语义一致)。
|
||||
@@ -240,11 +312,6 @@ fn excluded_ids_from_mentions(spans: &Option<Vec<MentionSpanDto>>) -> (Vec<Strin
|
||||
(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 拼段风格)。首段直接赋。
|
||||
fn append_volatile(volatile: String, seg: &str) -> String {
|
||||
if volatile.is_empty() {
|
||||
@@ -317,8 +384,8 @@ pub async fn ai_regenerate(
|
||||
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
|
||||
// conv_id 直接用 IPC 参数 conversation_id(F-260616-09 B 批4,读 per_conv.messages)。
|
||||
let conv_id = conversation_id.clone();
|
||||
// Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。
|
||||
let cache_on = cache_enabled(&state).await && is_anthropic_type(&provider_config.provider_type);
|
||||
// prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。
|
||||
let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type);
|
||||
let (mut system_prompt, mut volatile) =
|
||||
build_system_prompt_parts(&state, &lang, &[], &[], cache_on).await;
|
||||
// 知识注入:取末条 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;
|
||||
}
|
||||
}
|
||||
// Augmentation 注入:regenerate 路径无新 @ mention / 技能,传 None 走空路径(不污染 prompt)。
|
||||
// 若未来需从末条 user 消息的 mentionSpans resolve(验证 #8),改此处传入即可。
|
||||
let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await;
|
||||
// Augmentation 注入:regenerate 回溯末条 user 前导 /技能名(原文在 user 消息里,复用
|
||||
// parse_leading_skill);@ mention spans 不重放,传 None 走空路径(不污染 prompt)。
|
||||
let skill = backtrack_leading_skill(&state, &conv_id).await;
|
||||
let aug_seg = resolve_and_inject(&app, &state, &conv_id, &provider_config, &skill, &None, &lang).await;
|
||||
if !aug_seg.is_empty() {
|
||||
if cache_on {
|
||||
volatile = append_volatile(volatile, &aug_seg);
|
||||
@@ -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 直传零改动)。
|
||||
// 拉取在 lock 之前做,不持锁网络 I/O;结果供 lock 内 user_parts 使用。
|
||||
@@ -636,7 +747,8 @@ pub async fn ai_chat_send(
|
||||
};
|
||||
// active_conversation_id 同步到 target(用户当前面板即 target,路由键跟随)。
|
||||
// 决策 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());
|
||||
// 切到已有 conv(非本次懒建)时,确保其 active_conv_created_at 已设(switch 路径已设)。
|
||||
if session.active_conv_created_at.is_none() {
|
||||
@@ -710,13 +822,13 @@ pub async fn ai_chat_send(
|
||||
// (被@实体已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)。
|
||||
// mention_spans 为 None/空时返回两个空 vec(行为退化为无去重的全貌清单)。
|
||||
let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans);
|
||||
// Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。
|
||||
let cache_on = cache_enabled(&state).await && is_anthropic_type(&provider_config.provider_type);
|
||||
// prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。
|
||||
let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type);
|
||||
let (mut system_prompt, mut volatile) =
|
||||
build_system_prompt_parts(&state, &lang, &excl_proj, &excl_task, cache_on).await;
|
||||
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影后拼到 system prompt 前。
|
||||
// 隔离标注(build_augmentation_segment 头尾包裹,FR-S4 风格)防 prompt injection 与用户指令/行为准则混淆。
|
||||
let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await;
|
||||
let aug_seg = resolve_and_inject(&app, &state, &conv_id, &provider_config, &skill, &mention_spans, &lang).await;
|
||||
if !aug_seg.is_empty() {
|
||||
if cache_on {
|
||||
volatile = append_volatile(volatile, &aug_seg);
|
||||
@@ -759,6 +871,8 @@ pub async fn ai_chat_send(
|
||||
let app_handle = app.clone();
|
||||
let knowledge_config = state.knowledge_config.lock().await.clone();
|
||||
let llm_concurrency = state.llm_concurrency.clone();
|
||||
// 返回值用 clone(conv_id 本体 move 进 spawn 闭包,搬运自原实现保持不动)
|
||||
let conv_id_return = conv_id.clone();
|
||||
// loop 入口 load 快照,当前 loop 锁定边界(热改下次发消息生效)
|
||||
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;
|
||||
});
|
||||
|
||||
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,
|
||||
conversation_id: rec.conversation_id.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
let _ = state.ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(&app, ev);
|
||||
return Ok(format!("已处理({})", rec.status));
|
||||
}
|
||||
rec.status // 其他状态(如 pending/interrupted/skipped_retry)继续走未找到报错
|
||||
@@ -863,10 +1022,8 @@ pub async fn ai_approve(
|
||||
approved: false,
|
||||
conversation_id: conv_id.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp;publish 内 EVENT_BUS_ENABLED 门控,
|
||||
// 无消费者空转。app.emit 前端 + publish_event tunnel 两路独立,emit 保留不删。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
// app.emit 前端 + publish_event tunnel 两路独立,emit 保留不删(双写收敛进 emit_ai_chat_event)。
|
||||
emit_ai_chat_event(&app, ev);
|
||||
drop(session);
|
||||
// 拒绝结果立即落库(含 recovered 积压审批)——switch 时已 restore_from_messages 载完整历史,
|
||||
// messages 非空,save 不会污染老对话;原 if !recovered 守卫前提不成立已移除。
|
||||
@@ -885,10 +1042,7 @@ pub async fn ai_approve(
|
||||
.and_then(|c| session.conv_read(c))
|
||||
.map(|c| c.iteration_used)
|
||||
.unwrap_or(0);
|
||||
let __hold610 = __lock_t610.elapsed();
|
||||
if __hold610 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_approve:610 持锁 {:?} (含 lock 等待)", __hold610);
|
||||
}
|
||||
log_slow_lock(__lock_t610, "ai_approve:610");
|
||||
v
|
||||
};
|
||||
// 所有待审批处理完毕后恢复 agentic 循环
|
||||
@@ -921,10 +1075,7 @@ pub async fn ai_approve(
|
||||
);
|
||||
}
|
||||
}
|
||||
let __hold534 = __lock_t534.elapsed();
|
||||
if __hold534 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_approve:534 持锁 {:?} (含 lock 等待)", __hold534);
|
||||
}
|
||||
log_slow_lock(__lock_t534, "ai_approve:534");
|
||||
drop(session); // 释放锁后再执行
|
||||
|
||||
// B-260617-01 决策 a(方案 a2 后端分支):run_workflow 特殊处理。
|
||||
@@ -954,14 +1105,27 @@ pub async fn ai_approve(
|
||||
))
|
||||
} else { None };
|
||||
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(
|
||||
std::time::Duration::from_secs(60),
|
||||
std::time::Duration::from_secs(approve_outer_secs),
|
||||
exec_fut,
|
||||
).await {
|
||||
Ok(r) => r,
|
||||
// 任务2: ai_approve 路径同步 run_command 失败提示(超时为最常见失败场景)。
|
||||
// 仅对 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) {
|
||||
"\n提示: PowerShell 下路径用正斜杠或双引号包裹,命令间用 `;` 而非 `&&`(PS5)"
|
||||
} else { "" })),
|
||||
@@ -1002,21 +1166,14 @@ pub async fn ai_approve(
|
||||
result: result_val.clone(),
|
||||
conversation_id: conv_id.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev_completed.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_completed);
|
||||
emit_ai_chat_event(&app, ev_completed);
|
||||
let ev_approval = AiChatEvent::AiApprovalResult {
|
||||
id,
|
||||
approved: true,
|
||||
conversation_id: conv_id.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev_approval.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<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);
|
||||
}
|
||||
emit_ai_chat_event(&app, ev_approval);
|
||||
log_slow_lock(__lock_t704, "ai_approve:704");
|
||||
drop(session);
|
||||
|
||||
// F-260619-04 P2(方案 B): create_idea source 消息级溯源补全。
|
||||
@@ -1032,10 +1189,7 @@ pub async fn ai_approve(
|
||||
let v = conv_id.as_deref().and_then(|cid| {
|
||||
session.conv_read(cid).and_then(|c| c.messages.last_assistant_message_id())
|
||||
});
|
||||
let __hold735 = __lock_t735.elapsed();
|
||||
if __hold735 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_approve:735 持锁 {:?} (含 lock 等待)", __hold735);
|
||||
}
|
||||
log_slow_lock(__lock_t735, "ai_approve:735");
|
||||
v
|
||||
};
|
||||
super::super::audit::maybe_fill_idea_source(
|
||||
@@ -1062,10 +1216,7 @@ pub async fn ai_approve(
|
||||
let __lock_t761 = std::time::Instant::now();
|
||||
let session = state.ai_session.lock().await;
|
||||
let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0);
|
||||
let __hold761 = __lock_t761.elapsed();
|
||||
if __hold761 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_approve:761 持锁 {:?} (含 lock 等待)", __hold761);
|
||||
}
|
||||
log_slow_lock(__lock_t761, "ai_approve:761");
|
||||
v
|
||||
};
|
||||
// 所有待审批处理完毕后恢复 agentic 循环(recovered 无 live loop,try_continue 因 generating=false 自然不续)
|
||||
@@ -1187,8 +1338,7 @@ pub async fn ai_authorize_dir(
|
||||
approved: false,
|
||||
conversation_id: rec.conversation_id.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
let _ = state.ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(&app, ev);
|
||||
return Ok(format!("已处理({})", rec.status));
|
||||
}
|
||||
_ => {}
|
||||
@@ -1196,10 +1346,7 @@ pub async fn ai_authorize_dir(
|
||||
return Err(format!("未找到路径授权挂起: {}", tool_call_id));
|
||||
}
|
||||
};
|
||||
let __hold864 = __lock_t864.elapsed();
|
||||
if __hold864 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_authorize_dir:864 持锁 {:?} (含 lock 等待)", __hold864);
|
||||
}
|
||||
log_slow_lock(__lock_t864, "ai_authorize_dir:864");
|
||||
v
|
||||
};
|
||||
// 阶段3a 决策层分离:ai_authorize_dir 只消费 kind==Path(路径授权挂起,once/always/deny)。
|
||||
@@ -1213,10 +1360,7 @@ pub async fn ai_authorize_dir(
|
||||
let __lock_t878 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
session.pending_approvals.insert(tool_call_id.clone(), approval);
|
||||
let __hold878 = __lock_t878.elapsed();
|
||||
if __hold878 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_authorize_dir:878 持锁 {:?} (含 lock 等待)", __hold878);
|
||||
}
|
||||
log_slow_lock(__lock_t878, "ai_authorize_dir:878");
|
||||
return Err(format!(
|
||||
"tool_call_id={} 非路径授权挂起(普通审批请用 ai_approve)",
|
||||
tool_call_id
|
||||
@@ -1245,27 +1389,20 @@ pub async fn ai_authorize_dir(
|
||||
if let Some(ref cid) = conv_id {
|
||||
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &err_msg);
|
||||
}
|
||||
let __hold903 = __lock_t903.elapsed();
|
||||
if __hold903 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_authorize_dir:903 持锁 {:?} (含 lock 等待)", __hold903);
|
||||
}
|
||||
log_slow_lock(__lock_t903, "ai_authorize_dir:903");
|
||||
}
|
||||
let ev_completed = AiChatEvent::AiToolCallCompleted {
|
||||
id: tool_call_id.clone(),
|
||||
result: serde_json::Value::String(err_msg.clone()),
|
||||
conversation_id: conv_id.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev_completed.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_completed);
|
||||
emit_ai_chat_event(&app, ev_completed);
|
||||
let ev_approval = AiChatEvent::AiApprovalResult {
|
||||
id: tool_call_id.clone(),
|
||||
approved: false,
|
||||
conversation_id: conv_id.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev_approval.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_approval);
|
||||
emit_ai_chat_event(&app, ev_approval);
|
||||
audit_finalize(&state, &tool_call_id, "rejected", Some(err_msg)).await;
|
||||
if let Some(ref cid) = conv_id {
|
||||
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
|
||||
@@ -1275,10 +1412,7 @@ pub async fn ai_authorize_dir(
|
||||
let __lock_t930 = std::time::Instant::now();
|
||||
let session = state.ai_session.lock().await;
|
||||
let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0);
|
||||
let __hold930 = __lock_t930.elapsed();
|
||||
if __hold930 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_authorize_dir:930 持锁 {:?} (含 lock 等待)", __hold930);
|
||||
}
|
||||
log_slow_lock(__lock_t930, "ai_authorize_dir:930");
|
||||
v
|
||||
};
|
||||
try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
|
||||
@@ -1371,27 +1505,20 @@ pub async fn ai_authorize_dir(
|
||||
if let Some(ref cid) = conv_id {
|
||||
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &result_val.to_string());
|
||||
}
|
||||
let __hold1018 = __lock_t1018.elapsed();
|
||||
if __hold1018 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_authorize_dir:1018 持锁 {:?} (含 lock 等待)", __hold1018);
|
||||
}
|
||||
log_slow_lock(__lock_t1018, "ai_authorize_dir:1018");
|
||||
}
|
||||
let ev_completed = AiChatEvent::AiToolCallCompleted {
|
||||
id: tool_call_id.clone(),
|
||||
result: result_val.clone(),
|
||||
conversation_id: conv_id.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev_completed.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_completed);
|
||||
emit_ai_chat_event(&app, ev_completed);
|
||||
let ev_approval = AiChatEvent::AiApprovalResult {
|
||||
id: tool_call_id.clone(),
|
||||
approved: true,
|
||||
conversation_id: conv_id.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev_approval.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_approval);
|
||||
emit_ai_chat_event(&app, ev_approval);
|
||||
if let Some(ref cid) = conv_id {
|
||||
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
|
||||
}
|
||||
@@ -1400,10 +1527,7 @@ pub async fn ai_authorize_dir(
|
||||
let __lock_t1044 = std::time::Instant::now();
|
||||
let session = state.ai_session.lock().await;
|
||||
let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0);
|
||||
let __hold1044 = __lock_t1044.elapsed();
|
||||
if __hold1044 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_authorize_dir:1044 持锁 {:?} (含 lock 等待)", __hold1044);
|
||||
}
|
||||
log_slow_lock(__lock_t1044, "ai_authorize_dir:1044");
|
||||
v
|
||||
};
|
||||
try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
|
||||
@@ -1428,18 +1552,18 @@ pub struct PendingToolCallInfo {
|
||||
#[tauri::command]
|
||||
pub async fn ai_pending_tool_calls(
|
||||
state: State<'_, AppState>,
|
||||
conv_id: String,
|
||||
conversation_id: String,
|
||||
) -> Result<Vec<PendingToolCallInfo>, String> {
|
||||
let __lock_t1071 = std::time::Instant::now();
|
||||
let session = state.ai_session.lock().await;
|
||||
// 阶段3a 单真相源合并:单 pending_approvals 表按 conv_id 过滤,kind 字段透传给前端。
|
||||
// 阶段3a 单真相源合并:单 pending_approvals 表按 conversation_id 过滤,kind 字段透传给前端。
|
||||
// 阶段4:返 kind 字段,前端 switchConversation 恢复 pendingApprovals 时按 kind 渲染
|
||||
// (risk→approve/reject,path→once/always/deny),对齐阶段3b 统一审批模型。
|
||||
// (阶段3a 时前端仅恢复 kind==Risk 走 toolCard;阶段3b 前端切后 path 类也归一渲染,后端两 kind 都返。)
|
||||
let list = session
|
||||
.pending_approvals
|
||||
.values()
|
||||
.filter(|a| a.conversation_id.as_deref() == Some(conv_id.as_str()))
|
||||
.filter(|a| a.conversation_id.as_deref() == Some(conversation_id.as_str()))
|
||||
.map(|a| PendingToolCallInfo {
|
||||
tool_call_id: a.tool_call_id.clone(),
|
||||
conversation_id: a.conversation_id.clone(),
|
||||
@@ -1449,10 +1573,7 @@ pub async fn ai_pending_tool_calls(
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
let __hold1071 = __lock_t1071.elapsed();
|
||||
if __hold1071 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_pending_tool_calls:1071 持锁 {:?} (含 lock 等待)", __hold1071);
|
||||
}
|
||||
log_slow_lock(__lock_t1071, "ai_pending_tool_calls:1071");
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
@@ -1469,7 +1590,7 @@ pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let __lock_t1095 = std::time::Instant::now();
|
||||
// ── ① 先停 loop 防并发 save ──
|
||||
// 取活跃对话 id 后释放锁(避免持 session 锁调 DB)。stop_flag 置位 + notify 唤醒:
|
||||
// 旧 loop 下一轮边界检测到 stop_flag 即走 finish_round_exit 退出;conv_states→Idle
|
||||
// 旧 loop 下一轮边界检测到 stop_flag 即走 teardown_round 退出;conv_states→Idle
|
||||
// 让读侧 is_active 立即归零(Idle 自环幂等,guard.reset 不冲突)。
|
||||
let active_id = {
|
||||
let mut session = state.ai_session.lock().await;
|
||||
@@ -1488,10 +1609,7 @@ pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> {
|
||||
);
|
||||
}
|
||||
}
|
||||
let __hold1095 = __lock_t1095.elapsed();
|
||||
if __hold1095 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_clear:1095 持锁 {:?} (含 lock 等待)", __hold1095);
|
||||
}
|
||||
log_slow_lock(__lock_t1095, "ai_chat_clear:1095");
|
||||
active_id
|
||||
};
|
||||
// ── ② DB 单事务清空(clear_messages + delete_range 一次 transaction 原子)──
|
||||
@@ -1551,9 +1669,7 @@ pub async fn ai_chat_clear_context(
|
||||
let ev = AiChatEvent::AiContextCleared {
|
||||
conversation_id: Some(conversation_id.clone()),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(&app, ev);
|
||||
return Ok(());
|
||||
}
|
||||
// 对保护区外 active 消息标 archived_segment(messages_mut 直接改 status)。
|
||||
@@ -1569,10 +1685,7 @@ pub async fn ai_chat_clear_context(
|
||||
if dirtied {
|
||||
conv.messages.mark_needs_full_rewrite();
|
||||
}
|
||||
let __hold1145 = __lock_t1145.elapsed();
|
||||
if __hold1145 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_clear_context:1145 持锁 {:?} (含 lock 等待)", __hold1145);
|
||||
}
|
||||
log_slow_lock(__lock_t1145, "ai_chat_clear_context:1145");
|
||||
drop(session);
|
||||
conversation_id
|
||||
};
|
||||
@@ -1581,20 +1694,20 @@ pub async fn ai_chat_clear_context(
|
||||
let ev = AiChatEvent::AiContextCleared {
|
||||
conversation_id: Some(conv_id),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(&app, ev);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 手动 LLM 压缩上下文(F-15 阶段2):摘要保护区外消息并插入首位。
|
||||
///
|
||||
/// 流程(失败不阻塞,消息状态不变):
|
||||
/// 流程(失败非阻断降级):
|
||||
/// 1. emit AiCompressing + set_compressing(true) 防重入
|
||||
/// 2. 算 compress_end(保护区外,同 clear_context 的 protect_start)
|
||||
/// 3. 先取保护区外 active 消息的克隆(读不改)→ 喂 compress_via_llm
|
||||
/// 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。
|
||||
#[tauri::command]
|
||||
@@ -1622,9 +1735,7 @@ pub async fn ai_chat_compress_context(
|
||||
let ev = AiChatEvent::AiCompressing {
|
||||
conversation_id: Some(conversation_id.clone()),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(&app, ev);
|
||||
conv.messages.set_compressing(true);
|
||||
|
||||
let protect_start = conv.messages.len().saturating_sub(PROTECT_COUNT);
|
||||
@@ -1638,9 +1749,7 @@ pub async fn ai_chat_compress_context(
|
||||
conversation_id: cid,
|
||||
summary: String::new(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(&app, ev);
|
||||
return Ok(());
|
||||
}
|
||||
// 读 active 克隆(不改 status):compress_old_messages 留到 LLM 成功后才调
|
||||
@@ -1652,10 +1761,7 @@ pub async fn ai_chat_compress_context(
|
||||
.map(|t| t.message.clone())
|
||||
.collect();
|
||||
let cid = session.active_conversation_id.clone().unwrap_or_else(|| conversation_id.clone());
|
||||
let __hold1205 = __lock_t1205.elapsed();
|
||||
if __hold1205 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1205 持锁 {:?} (含 lock 等待)", __hold1205);
|
||||
}
|
||||
log_slow_lock(__lock_t1205, "ai_chat_compress_context:1205");
|
||||
(cid, active_msgs, protect_start)
|
||||
};
|
||||
|
||||
@@ -1664,19 +1770,14 @@ pub async fn ai_chat_compress_context(
|
||||
let __lock_t1250 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
session.conv(&conv_id).messages.set_compressing(false);
|
||||
let __hold1250 = __lock_t1250.elapsed();
|
||||
if __hold1250 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1250 持锁 {:?} (含 lock 等待)", __hold1250);
|
||||
}
|
||||
log_slow_lock(__lock_t1250, "ai_chat_compress_context:1250");
|
||||
drop(session);
|
||||
// 治 Task#1:手动 IPC 降级(无 active 可压缩)用 AiManualCompressed(前端弹 toast)。
|
||||
let ev = AiChatEvent::AiManualCompressed {
|
||||
conversation_id: Some(conv_id),
|
||||
summary: String::new(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(&app, ev);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -1688,23 +1789,21 @@ pub async fn ai_chat_compress_context(
|
||||
let __lock_t1269 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
session.conv(&conv_id).messages.set_compressing(false);
|
||||
let __hold1269 = __lock_t1269.elapsed();
|
||||
if __hold1269 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1269 持锁 {:?} (含 lock 等待)", __hold1269);
|
||||
}
|
||||
log_slow_lock(__lock_t1269, "ai_chat_compress_context:1269");
|
||||
drop(session);
|
||||
let ev = AiChatEvent::AiError {
|
||||
error: format!("压缩失败: {}", e),
|
||||
error_type: Some(super::super::ErrorType::Auth),
|
||||
conversation_id: Some(conv_id),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(&app, ev);
|
||||
return Err(format!("压缩失败: {}", e));
|
||||
}
|
||||
};
|
||||
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(
|
||||
provider.as_ref(),
|
||||
&provider_config,
|
||||
@@ -1715,24 +1814,38 @@ pub async fn ai_chat_compress_context(
|
||||
).await {
|
||||
Ok(s) => s.nl_summary,
|
||||
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 mut session = state.ai_session.lock().await;
|
||||
session.conv(&conv_id).messages.set_compressing(false);
|
||||
let __hold1295 = __lock_t1295.elapsed();
|
||||
if __hold1295 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1295 持锁 {:?} (含 lock 等待)", __hold1295);
|
||||
let conv = session.conv(&conv_id);
|
||||
let _compressed = conv.messages.compress_old_messages(compress_end);
|
||||
if !keyword_fallback.is_empty() {
|
||||
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);
|
||||
let ev = AiChatEvent::AiError {
|
||||
error: format!("压缩失败: {}", e),
|
||||
error_type: Some(super::super::ErrorType::Unknown),
|
||||
conversation_id: Some(conv_id),
|
||||
save_conversation(&state.ai_session, &state.db, &conv_id, None, None, false).await;
|
||||
let toast_summary = if keyword_fallback.is_empty() {
|
||||
"压缩失败,历史消息已直接释放(可重试)".to_string()
|
||||
} else {
|
||||
keyword_fallback.clone()
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
return Err(format!("压缩失败: {}", e));
|
||||
let ev = AiChatEvent::AiManualCompressed {
|
||||
conversation_id: Some(conv_id),
|
||||
summary: toast_summary,
|
||||
};
|
||||
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);
|
||||
conv.messages.insert_at(0, ChatMessage::system(&summary));
|
||||
conv.messages.set_compressing(false);
|
||||
let __hold1312 = __lock_t1312.elapsed();
|
||||
if __hold1312 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1312 持锁 {:?} (含 lock 等待)", __hold1312);
|
||||
}
|
||||
log_slow_lock(__lock_t1312, "ai_chat_compress_context:1312");
|
||||
}
|
||||
save_conversation(&state.ai_session, &state.db, &conv_id, None, None, false).await;
|
||||
// 治 Task#1:手动 IPC 成功主路径用 AiManualCompressed(Task#1 期望保留 toast+刷新)。
|
||||
@@ -1755,9 +1865,7 @@ pub async fn ai_chat_compress_context(
|
||||
conversation_id: Some(conv_id),
|
||||
summary,
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(&app, ev);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1812,8 +1920,8 @@ pub async fn ai_chat_edit(
|
||||
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
|
||||
// conv_id 直接用 IPC 参数 conversation_id(F-260616-09 B 批4,读 per_conv.messages)。
|
||||
let conv_id = conversation_id.clone();
|
||||
// Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。
|
||||
let cache_on = cache_enabled(&state).await && is_anthropic_type(&provider_config.provider_type);
|
||||
// prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。
|
||||
let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type);
|
||||
let (mut system_prompt, mut volatile) =
|
||||
build_system_prompt_parts(&state, &lang, &[], &[], cache_on).await;
|
||||
// 知识注入:用新 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;
|
||||
}
|
||||
}
|
||||
// Augmentation 注入:edit 路径无新 @ mention / 技能,传 None 走空路径(不污染 prompt)。
|
||||
let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await;
|
||||
// Augmentation 注入:edit 回溯末条 user(已被 replace_last_active_user_content 写为新文本)
|
||||
// 前导 /技能名,复用 parse_leading_skill;@ mention spans 不重放,传 None 走空路径。
|
||||
let skill = backtrack_leading_skill(&state, &conv_id).await;
|
||||
let aug_seg = resolve_and_inject(&app, &state, &conv_id, &provider_config, &skill, &None, &lang).await;
|
||||
if !aug_seg.is_empty() {
|
||||
if cache_on {
|
||||
volatile = append_volatile(volatile, &aug_seg);
|
||||
@@ -1979,6 +2089,12 @@ pub async fn ai_chat_force_send(
|
||||
conv.notify.notify_one();
|
||||
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,无生成态拦截——
|
||||
// 这是"强制"语义本身,前面已主动复位)
|
||||
let conv = session.conv(&target);
|
||||
@@ -2026,10 +2142,7 @@ pub async fn ai_chat_force_send(
|
||||
// F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。
|
||||
let user_msg_id = conv.messages.last_user_message_id();
|
||||
// G1 目标钉扎已迁移至 run_agentic_loop 工具调用推理(提取方式同 ai_chat_send)
|
||||
let __hold1473 = __lock_t1473.elapsed();
|
||||
if __hold1473 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_force_send:1473 持锁 {:?} (含 lock 等待)", __hold1473);
|
||||
}
|
||||
log_slow_lock(__lock_t1473, "ai_chat_force_send:1473");
|
||||
(was_gen.then_some(target.clone()), target, user_msg_id, conv.pinned_goals.clone(), new_epoch)
|
||||
};
|
||||
|
||||
@@ -2059,9 +2172,7 @@ pub async fn ai_chat_force_send(
|
||||
conversation_id: Some(cid.clone()),
|
||||
pinned_goals: old_pinned_goals.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(&app, ev);
|
||||
}
|
||||
|
||||
// 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());
|
||||
// 去重:同 ai_chat_send,从 mention_spans 提取被@的 project/task id 从清单排除。
|
||||
let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans);
|
||||
// Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。
|
||||
let cache_on = cache_enabled(&state).await && is_anthropic_type(&provider_config.provider_type);
|
||||
// prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。
|
||||
let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type);
|
||||
let (mut system_prompt, mut volatile) =
|
||||
build_system_prompt_parts(&state, &lang, &excl_proj, &excl_task, cache_on).await;
|
||||
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影(语义同 ai_chat_send)。
|
||||
let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await;
|
||||
let aug_seg = resolve_and_inject(&app, &state, &conv_id, &provider_config, &skill, &mention_spans, &lang).await;
|
||||
if !aug_seg.is_empty() {
|
||||
if cache_on {
|
||||
volatile = append_volatile(volatile, &aug_seg);
|
||||
@@ -2144,10 +2255,7 @@ pub async fn ai_chat_stop(
|
||||
.unwrap_or_default();
|
||||
if target.is_empty() {
|
||||
// 无目标 conv 且无 active:无任何 conv 在跑(批4 顶层 generating 已退役),直接返回。
|
||||
let __hold1611 = __lock_t1611.elapsed();
|
||||
if __hold1611 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_stop:1611 持锁 {:?} (含 lock 等待)", __hold1611);
|
||||
}
|
||||
log_slow_lock(__lock_t1611, "ai_chat_stop:1611");
|
||||
drop(session);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -2178,10 +2286,7 @@ pub async fn ai_chat_stop(
|
||||
}
|
||||
conv.stop_flag.store(true, Ordering::SeqCst); // 双保险:防 try_continue 误判重启
|
||||
let pinned_goals = conv.pinned_goals.clone();
|
||||
let __hold1611 = __lock_t1611.elapsed();
|
||||
if __hold1611 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_stop:1611 持锁 {:?} (含 lock 等待)", __hold1611);
|
||||
}
|
||||
log_slow_lock(__lock_t1611, "ai_chat_stop:1611");
|
||||
drop(session);
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
|
||||
@@ -2192,9 +2297,7 @@ pub async fn ai_chat_stop(
|
||||
conversation_id: Some(target),
|
||||
pinned_goals,
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(&app, ev);
|
||||
return Ok(());
|
||||
}
|
||||
// 流式生成中:置位让目标 conv 的 loop 自行收尾
|
||||
@@ -2203,15 +2306,18 @@ pub async fn ai_chat_stop(
|
||||
conv.stop_flag.store(true, Ordering::SeqCst);
|
||||
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!,
|
||||
// 不再等 30s 心跳 tick 或 120s idle timeout 才轮到 stop_flag 检查。
|
||||
// Notify 仅承载「即时唤醒」,停止真值仍由 stop_flag 决定(stream_llm 唤醒后再判 flag)。
|
||||
notify.notify_one();
|
||||
let conv_id = Some(target);
|
||||
let __hold1611 = __lock_t1611.elapsed();
|
||||
if __hold1611 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_stop:1611 持锁 {:?} (含 lock 等待)", __hold1611);
|
||||
}
|
||||
log_slow_lock(__lock_t1611, "ai_chat_stop:1611");
|
||||
drop(session);
|
||||
|
||||
// B-260615-13 兜底任务(F2 加固,2026-08-05):loop 若 panic/异常退出漏发收尾,stop_flag 无人读,
|
||||
@@ -2302,9 +2408,8 @@ pub async fn ai_chat_stop(
|
||||
conversation_id: conv_id.clone(),
|
||||
pinned_goals,
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。闭包内 app_handle 仍可访问 AppState。
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
// 闭包内 app_handle 仍可访问 AppState(双写收敛进 emit_ai_chat_event)。
|
||||
emit_ai_chat_event(&app_handle, ev);
|
||||
return;
|
||||
}
|
||||
});
|
||||
@@ -2336,10 +2441,7 @@ pub async fn ai_continue_loop(
|
||||
// (含切走后的后台 conv),移除 active 一致性校验(真并发下后台 conv 也应可续跑)。
|
||||
// B-Phase3:读侧切无锁 conv_states。
|
||||
if !state.conv_states.is_active(&conversation_id) {
|
||||
let __hold1744 = __lock_t1744.elapsed();
|
||||
if __hold1744 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_continue_loop:1744 持锁 {:?} (含 lock 等待)", __hold1744);
|
||||
}
|
||||
log_slow_lock(__lock_t1744, "ai_continue_loop:1744");
|
||||
return Err("AI 未在暂停态,无需继续".to_string());
|
||||
}
|
||||
let conv = session.conv(&conversation_id);
|
||||
@@ -2348,10 +2450,7 @@ pub async fn ai_continue_loop(
|
||||
// F-260616-11 决策 a: 达 max 续跑重计 iteration(F-260616-03 决策 a,用户点继续=授权重来)。
|
||||
// 审批续跑(ai_approve)累计不重置见另路径;本路径 start_iteration 传 0,loop 从头计数。
|
||||
conv.iteration_used = 0;
|
||||
let __hold1744 = __lock_t1744.elapsed();
|
||||
if __hold1744 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_continue_loop:1744 持锁 {:?} (含 lock 等待)", __hold1744);
|
||||
}
|
||||
log_slow_lock(__lock_t1744, "ai_continue_loop:1744");
|
||||
}
|
||||
// 复用审批恢复续 loop 入口(不重写 loop),其内部 spawn run_agentic_loop
|
||||
// F-09 B 批2:传 conv_id(IPC 参数 conversation_id)。
|
||||
@@ -2381,10 +2480,7 @@ pub async fn ai_stop_loop(
|
||||
// (真并发下后台 conv 也应可停止)。
|
||||
// B-Phase3:读侧切无锁 conv_states。
|
||||
if !state.conv_states.is_active(&conversation_id) {
|
||||
let __hold1782 = __lock_t1782.elapsed();
|
||||
if __hold1782 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_stop_loop:1782 持锁 {:?} (含 lock 等待)", __hold1782);
|
||||
}
|
||||
log_slow_lock(__lock_t1782, "ai_stop_loop:1782");
|
||||
return Err("AI 未在暂停态,无需停止".to_string());
|
||||
}
|
||||
let conv = session.conv(&conversation_id);
|
||||
@@ -2399,10 +2495,7 @@ pub async fn ai_stop_loop(
|
||||
);
|
||||
}
|
||||
pinned_goals = conv.pinned_goals.clone();
|
||||
let __hold1782 = __lock_t1782.elapsed();
|
||||
if __hold1782 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_stop_loop:1782 持锁 {:?} (含 lock 等待)", __hold1782);
|
||||
}
|
||||
log_slow_lock(__lock_t1782, "ai_stop_loop:1782");
|
||||
}
|
||||
// 暂停态进入前已 save_conversation,此处零 token 上报仅作收敛信号(与 try_continue 补发 AiCompleted 一致)
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
@@ -2418,9 +2511,7 @@ pub async fn ai_stop_loop(
|
||||
conversation_id: Some(conversation_id),
|
||||
pinned_goals,
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
emit_ai_chat_event(&app, ev);
|
||||
Ok("ok".to_string())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! skills + config 域 IPC — 5 个 `#[tauri::command]` 函数
|
||||
//!
|
||||
//! 原 `commands/ai/commands/mod.rs` 收敛到 re-export only 后,本文件承接最后一批 IPC:
|
||||
//! - `ai_list_skills` — 列出本机 Claude 技能(skills + commands + plugins),供前端 `/` 联想
|
||||
//! - `ai_reload_skills` — 热重载技能列表(核心设计6:invalidate + 重扫,不重启生效)
|
||||
//! - `ai_list_skills` — 列出本机 Claude 技能(project + skills + commands + plugins,可选
|
||||
//! project_path 启用项目级扫描),供前端 `/` 联想
|
||||
//! - `ai_reload_skills` — 热重载技能列表(核心设计6:invalidate + 重扫,不重启生效;
|
||||
//! 项目级按已设 PROJECT_SKILLS_ROOT 一并重扫)
|
||||
//! - `ai_set_concurrency_config` — LLM 并发上限运行时调整(global/per-conv,立即生效)
|
||||
//! - `ai_set_agent_max_iterations` — Agentic 循环最大轮次(下次发消息生效)
|
||||
//! - `ai_set_agent_max_retries` — 流式对话失败自动重试次数(下次发消息生效)
|
||||
@@ -28,10 +30,18 @@ use df_ai::ai_tools::RiskLevel;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// 列出本机 Claude 技能(skills + commands + plugins 三类),供前端 `/` 联想
|
||||
/// 列出本机 Claude 技能(project + skills + commands + plugins),供前端 `/` 联想。
|
||||
///
|
||||
/// `project_path`(可选):当前项目根目录,Some 时扫 `<project_path>/.claude/skills/*/SKILL.md`
|
||||
/// (source="project",同名覆盖 home 级)。变更才 invalidate 触发重扫(同值幂等零开销);
|
||||
/// None/空 = 沿用已设 root 不清除(remote_bridge 零参调用与 FE 无项目语境时不传均安全)。
|
||||
#[tauri::command]
|
||||
pub async fn ai_list_skills() -> Result<Vec<SkillInfo>, String> {
|
||||
// 命中进程内缓存(skills_cached 已 owned Vec,直接返回)
|
||||
pub async fn ai_list_skills(project_path: Option<String>) -> Result<Vec<SkillInfo>, String> {
|
||||
// 项目根变更才置缓存 None,skills_cached 慢路径重扫(含新项目级技能);
|
||||
// 命中进程内缓存时零扫盘(skills_cached 已 owned Vec,直接返回)。
|
||||
if super::super::skills::set_project_skills_root(project_path) {
|
||||
invalidate_skills();
|
||||
}
|
||||
Ok(skills_cached().await)
|
||||
}
|
||||
|
||||
|
||||
@@ -630,6 +630,12 @@ pub async fn ai_conversation_delete(
|
||||
super::chat::finalize_pending_placeholders(&mut *session, &conversation_id, "会话已删除");
|
||||
// 阶段3a 单真相源合并:单表 retain(kind 不区分)。
|
||||
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 不存在即退出)。
|
||||
session.per_conv.remove(&conversation_id);
|
||||
// 顺带清理该 conv 的 save 串行锁条目(防 HashMap 无限增长)。
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
//! (仅 `WorkflowEvent`),本总线是面向 AI 域的通用 pub-sub(透传 AiChatEvent 全量 Value)。
|
||||
//! AppState 分两个字段:`event_bus`(工作流)+ `ai_event_bus`(AI 域),互不干扰。
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use super::AiChatEvent;
|
||||
@@ -69,6 +70,24 @@ pub const EVENT_BUS_ENABLED: bool = true;
|
||||
#[allow(dead_code)]
|
||||
pub const DEFAULT_BUS_CAPACITY: usize = 256;
|
||||
|
||||
/// emit+publish 双写:AI 事件对外发布的唯一出口(2026-10-01 DRY #1 收敛,正典自 stream_recv.rs)。
|
||||
///
|
||||
/// 两路通道受众不同,缺一不可:
|
||||
/// - `app.emit("ai-chat-event")`:桌面前端(前端只 listen 这一路)
|
||||
/// - `ai_event_bus.publish_event`:总线订阅者(tunnel → miniapp 跨端透传 / 跨模块订阅)
|
||||
///
|
||||
/// **为什么必须双写**(BUG-260805-02 教训):桌面前端只 listen `app.emit('ai-chat-event')`,
|
||||
/// 只 publish_event 桌面前端收不到(事故:微信端发的 user 消息桌面不可见);反之只 emit 不
|
||||
/// publish 则 tunnel/miniapp 订阅者收不到。**禁止**为省事手抄单路 emit 或单路 publish 替代本 helper,
|
||||
/// 也**禁止**再手抄两行式(emit + publish)——历史 50+ 处手抄已漂移出 error_type 两端不一致等 bug。
|
||||
///
|
||||
/// 事件值语义:emit 与 publish 收到**同一份事件值**(内部 clone),对齐原手抄 `ev.clone()` 两行式。
|
||||
/// 消费 ev(调用方无需再 clone)。
|
||||
pub(crate) fn emit_ai_chat_event(app: &AppHandle, ev: AiChatEvent) {
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// EventBus — 基于 tokio::sync::broadcast 的 pub-sub 总线(载荷 serde_json::Value)
|
||||
//
|
||||
|
||||
@@ -224,7 +224,7 @@ async fn fetch_static(
|
||||
markdown = cleanup_markdown(&markdown);
|
||||
|
||||
// ── 截断(按 char 边界) ──
|
||||
let (markdown, truncated) = truncate_chars(&markdown, max_length);
|
||||
let (markdown, truncated) = truncate_markdown(&markdown, max_length);
|
||||
|
||||
Ok(json!({
|
||||
"url": final_url,
|
||||
@@ -481,7 +481,7 @@ fn build_obscura_json(
|
||||
) -> Value {
|
||||
let html_bytes_est = markdown_raw.len(); // obscura 不给原始 HTML 字节数,用 markdown 长度近似(仅信息字段)
|
||||
// 截断(按 char 边界,与静态一致)
|
||||
let (markdown, truncated) = truncate_chars(markdown_raw, max_length);
|
||||
let (markdown, truncated) = truncate_markdown(markdown_raw, max_length);
|
||||
let elapsed_ms = started.elapsed().as_millis() as u64;
|
||||
let title = extract_title_from_markdown(&markdown);
|
||||
// scheme:obscura 路径下重新 validate_url 拿 scheme(已在入口校验过,这里仅取字段值,失败兜底 https)。
|
||||
@@ -652,7 +652,8 @@ fn cleanup_markdown(md: &str) -> String {
|
||||
|
||||
/// 按 char 边界截断 markdown。返回 (截断后 String, 是否截断)。
|
||||
/// 不切多字节 UTF-8 中间(char_indices 保证落在 char 边界)。
|
||||
fn truncate_chars(md: &str, max: usize) -> (String, bool) {
|
||||
/// 与 audit 的 `truncate_chars`(补 … 正典)语义不同,改名消同名陷阱(DRY #11)。
|
||||
fn truncate_markdown(md: &str, max: usize) -> (String, bool) {
|
||||
let char_count = md.chars().count();
|
||||
if char_count <= max {
|
||||
return (md.to_string(), false);
|
||||
@@ -678,7 +679,7 @@ fn truncate_chars(md: &str, max: usize) -> (String, bool) {
|
||||
// ============================================================
|
||||
// 单元测试
|
||||
//
|
||||
// 覆盖纯函数(extract_title / cleanup_markdown / truncate_chars),确定性零网络。
|
||||
// 覆盖纯函数(extract_title / cleanup_markdown / truncate_markdown),确定性零网络。
|
||||
// handler 集成层走真实网络 #[ignore](对齐 http.rs 测试策略)。
|
||||
// ============================================================
|
||||
|
||||
@@ -792,12 +793,12 @@ mod tests {
|
||||
assert_eq!(extract_title_from_markdown(" \n\n "), None);
|
||||
}
|
||||
|
||||
// ── truncate_chars ──
|
||||
// ── truncate_markdown ──
|
||||
|
||||
#[test]
|
||||
fn test_truncate_under_limit_unchanged() {
|
||||
let md = "short content";
|
||||
let (out, trunc) = truncate_chars(md, 1000);
|
||||
let (out, trunc) = truncate_markdown(md, 1000);
|
||||
assert!(!trunc);
|
||||
assert_eq!(out, "short content");
|
||||
}
|
||||
@@ -805,7 +806,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_truncate_over_limit_marks_truncated() {
|
||||
let md = "abcdefghij".repeat(1000); // 10000 chars
|
||||
let (out, trunc) = truncate_chars(&md, 100);
|
||||
let (out, trunc) = truncate_markdown(&md, 100);
|
||||
assert!(trunc);
|
||||
assert!(out.contains("[markdown 已截断"));
|
||||
assert!(out.contains("原文 10000 chars"));
|
||||
@@ -816,7 +817,7 @@ mod tests {
|
||||
fn test_truncate_respects_char_boundary_unicode() {
|
||||
// 中文每字 3 字节,截 5 char 应得 15 字节(不在中间切)
|
||||
let md = "你好世界测试内容"; // 8 chars,每字 3 字节
|
||||
let (out, trunc) = truncate_chars(md, 5);
|
||||
let (out, trunc) = truncate_markdown(md, 5);
|
||||
assert!(trunc);
|
||||
// 截断点应在 "你好世界测" 后(5 chars = 15 bytes),无半个字
|
||||
assert!(out.starts_with("你好世界测\n\n"));
|
||||
@@ -961,7 +962,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_build_obscura_json_truncation_applies() {
|
||||
// build_obscura_json 内部应走 truncate_chars:超长 markdown 截断标记
|
||||
// build_obscura_json 内部应走 truncate_markdown:超长 markdown 截断标记
|
||||
let md: String = "abcdefghij".repeat(2000); // 20000 chars
|
||||
let started = Instant::now();
|
||||
let v = build_obscura_json("https://example.com/", &md, 1000, started, "obscura", None);
|
||||
|
||||
@@ -49,6 +49,7 @@ use df_storage::db::Database;
|
||||
use df_storage::models::AiProviderRecord;
|
||||
use df_storage::secret::resolve_provider_secret;
|
||||
|
||||
use crate::commands::ai::audit::truncate_chars;
|
||||
use crate::commands::ai::http::{
|
||||
build_client, execute_with_redirects, resolve_and_check_host, validate_url, MAX_REDIRECTS,
|
||||
};
|
||||
@@ -181,9 +182,9 @@ pub(crate) async fn execute_generate_image(
|
||||
.map_err(|e| anyhow::anyhow!("调用图像生成端点失败 ({}): {}", endpoint, e))?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
// 错误响应读 body 摘要(截断 500 chars)帮助定位
|
||||
// 错误响应读 body 摘要(截断 500 chars,截断时尾部补 …)帮助定位
|
||||
let err_text = resp.text().await.unwrap_or_default();
|
||||
let snippet: String = err_text.chars().take(500).collect();
|
||||
let snippet: String = truncate_chars(&err_text, 500);
|
||||
anyhow::bail!(
|
||||
"图像生成端点返回 HTTP {}: {}",
|
||||
status.as_u16(),
|
||||
@@ -455,9 +456,10 @@ async fn write_atomic(output_path: &str, data: &[u8]) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
/// 响应 body 截断为最多 500 chars 的字符串(用于错误信息),供定位用。
|
||||
/// 走 audit truncate_chars 正典(截断时尾部补 …);错误文案无硬长度约束,允许多 1 字符。
|
||||
fn body_snippet(v: &Value) -> String {
|
||||
let s = v.to_string();
|
||||
s.chars().take(500).collect()
|
||||
truncate_chars(&s, 500)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -19,8 +19,9 @@ use df_storage::models::{AiProviderRecord, KnowledgeRecord};
|
||||
|
||||
use df_types::types::new_id;
|
||||
|
||||
use crate::state::{AppState, ExtractTrigger, LlmConcurrency};
|
||||
use crate::state::{AppState, ExtractTrigger, LlmConcurrency, log_slow_lock};
|
||||
|
||||
use crate::commands::ai::audit::truncate_chars;
|
||||
use crate::commands::err_str;
|
||||
|
||||
use super::{AiSession};
|
||||
@@ -39,7 +40,7 @@ const KNOWLEDGE_QUERY_MIN_ASCII_LEN: usize = 2;
|
||||
/// 自动提炼窗口:取最近 N 条 user/assistant 消息送 LLM 提炼
|
||||
///
|
||||
/// 原 6 条窗口过窄,长会话后半程的经验不在提炼视野内;40 条覆盖近期完整上下文,
|
||||
/// 同时不撑爆提炼调用的 token 预算(max_tokens 2048 的输出侧不受影响,输入侧由
|
||||
/// 同时不撑爆提炼调用的 token 预算(max_tokens 4096 的输出侧不受影响,输入侧由
|
||||
/// 提炼模型上下文窗口兜底)。
|
||||
const KNOWLEDGE_EXTRACT_WINDOW: usize = 40;
|
||||
/// 自动提炼频次:距上次提炼至少新增 N 条用户消息才再提炼
|
||||
@@ -560,8 +561,8 @@ pub(crate) async fn build_knowledge_context(
|
||||
for r in &results {
|
||||
let kind = r.kind.clone();
|
||||
let title = r.title.clone();
|
||||
// 截断 content 防膨胀(注入侧最多 500 字符)
|
||||
let snippet: String = r.content.chars().take(500).collect();
|
||||
// 截断 content 防膨胀(注入侧最多 500 字符;走 audit 正典,截断时尾部补 … 提示有省略)
|
||||
let snippet: String = truncate_chars(&r.content, 500);
|
||||
out.push_str(&format!("- [{}] {}: {} (复用 {} 次)\n", kind, title, snippet, r.reuse_count));
|
||||
}
|
||||
out
|
||||
@@ -588,10 +589,7 @@ pub(crate) async fn build_knowledge_context_for_conv(
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| matches!(m.role, MessageRole::User) && m.is_active());
|
||||
let __hold = __lock_t.elapsed();
|
||||
if __hold > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] build_knowledge_context_for_conv 持锁 {:?} (含 lock 等待)", __hold);
|
||||
}
|
||||
log_slow_lock(__lock_t, "build_knowledge_context_for_conv:582");
|
||||
match found {
|
||||
Some(m) => (m.content.clone(), m.id.clone()),
|
||||
None => (String::new(), None),
|
||||
@@ -688,10 +686,7 @@ pub(crate) async fn maybe_spawn_extraction(
|
||||
}
|
||||
};
|
||||
if blocked {
|
||||
let __hold = __lock_t.elapsed();
|
||||
if __hold > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] maybe_spawn_extraction 持锁 {:?} (含 lock 等待)", __hold);
|
||||
}
|
||||
log_slow_lock(__lock_t, "maybe_spawn_extraction:692");
|
||||
tracing::debug!(
|
||||
conv_id = %conv_id,
|
||||
"[knowledge] 跳过自动提炼:提炼中或距上次提炼新增用户消息不足 {} 条(如需立即重提炼用手动按钮)",
|
||||
@@ -701,10 +696,7 @@ pub(crate) async fn maybe_spawn_extraction(
|
||||
}
|
||||
// 守卫 2:消息数。conv_read 未建返 0(< min_messages 自然跳过,语义=空对话不注入知识)。
|
||||
if msg_count < config.min_messages as usize {
|
||||
let __hold = __lock_t.elapsed();
|
||||
if __hold > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] maybe_spawn_extraction 持锁 {:?} (含 lock 等待)", __hold);
|
||||
}
|
||||
log_slow_lock(__lock_t, "maybe_spawn_extraction:705");
|
||||
return Ok(());
|
||||
}
|
||||
// 原子预置位(TOCTOU 核心):释放锁前先占提炼槽位,杜绝并发窗口内重复 spawn。
|
||||
@@ -713,10 +705,7 @@ pub(crate) async fn maybe_spawn_extraction(
|
||||
.lock()
|
||||
.expect("提炼水位互斥锁中毒")
|
||||
.insert(conv_id.to_string(), ExtractWatermark::InFlight);
|
||||
let __hold = __lock_t.elapsed();
|
||||
if __hold > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] maybe_spawn_extraction 持锁 {:?} (含 lock 等待)", __hold);
|
||||
}
|
||||
log_slow_lock(__lock_t, "maybe_spawn_extraction:717");
|
||||
}
|
||||
|
||||
let session_arc = session_arc.clone();
|
||||
@@ -744,28 +733,42 @@ pub(crate) async fn maybe_spawn_extraction(
|
||||
"[knowledge] 提炼成功,水位记为当前进度,新增用户消息达阈值后才再提炼"
|
||||
);
|
||||
} else {
|
||||
// 0 条:清哨兵 + 回退水位,允许下次自动触发重试(对话可能后续补充了有价值内容)。
|
||||
// 0 条:清哨兵回滚预置位;水位记 Done 保节奏(原回退水位会让下一轮对话结束
|
||||
// 立即重提炼,同窗口反复失败时形成逐轮风暴;记进度后须再攒够阈值条新用户消息)。
|
||||
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;
|
||||
EXTRACT_WATERMARKS
|
||||
.lock()
|
||||
.expect("提炼水位互斥锁中毒")
|
||||
.remove(&conv_id);
|
||||
.insert(conv_id.clone(), ExtractWatermark::Done(rounds));
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
"[knowledge] 提炼 0 条(无可提炼内容),清哨兵与水位允许下次自动重试"
|
||||
conv_id = %conv_id, rounds,
|
||||
"[knowledge] 提炼 0 条(无可提炼内容),水位记为当前进度,新增用户消息达阈值后才再试"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// 失败:清哨兵 + 回退水位,允许下次触发重试(预置位已在,须回滚否则会话被锁死)。
|
||||
// 失败:清哨兵回滚预置位;水位同样记 Done 保节奏(原回退水位在持续故障
|
||||
// (截断/网络/密钥)时逐轮重试刷爆;达阈值后才再试,手动按钮仍可立即重提炼)。
|
||||
tracing::warn!("知识提取失败(非阻断): {}", e);
|
||||
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;
|
||||
EXTRACT_WATERMARKS
|
||||
.lock()
|
||||
.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\
|
||||
2. confidence: high=对话中可直接观察的明确模式, medium=合理推断, low=推测性弱信号\n\
|
||||
3. 无可提炼内容时返回空数组 []\n\
|
||||
4. 输出纯 JSON,无任何额外文字";
|
||||
4. 输出纯 JSON,无任何额外文字\n\
|
||||
5. 最多提取 5 条,按复用价值降序排列(只保留高价值条目,宁缺毋滥)";
|
||||
|
||||
/// 从对话中提炼知识,产出 candidate 写入知识库
|
||||
///
|
||||
@@ -958,7 +962,7 @@ async fn extract_knowledge_from_conversation(
|
||||
model: extract_model,
|
||||
messages: extract_messages,
|
||||
temperature: Some(0.3),
|
||||
max_tokens: Some(2048),
|
||||
max_tokens: Some(4096),
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
@@ -981,10 +985,28 @@ async fn extract_knowledge_from_conversation(
|
||||
let json_str = strip_code_fence(raw);
|
||||
let items: Vec<serde_json::Value> = match serde_json::from_str(json_str) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("知识提炼 JSON 解析失败,整批丢弃(非阻断): {} | 原始: {}", e, raw);
|
||||
Err(e) => match salvage_truncated_json_array(json_str) {
|
||||
// 截断打捞: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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let knowledge_repo = KnowledgeRepo::new(db);
|
||||
@@ -1069,6 +1091,53 @@ fn strip_code_fence(s: &str) -> &str {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1282,3 +1351,46 @@ mod tests {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ pub mod download_file;
|
||||
pub(crate) mod generate_image;
|
||||
pub mod get_app_config;
|
||||
pub mod knowledge_inject;
|
||||
// 系统通知(P1 bg 化补完):完成/失败/需介入三类事件,主窗托盘化时补位提醒(条件发送)
|
||||
pub(crate) mod notify;
|
||||
pub mod prompt;
|
||||
pub mod provider_pool;
|
||||
pub mod remote_bridge;
|
||||
@@ -143,6 +145,13 @@ pub enum AiChatEvent {
|
||||
/// 与 AiToolCallStarted/Completed 正交——这三个事件描述一次信任放行调用生命周期:
|
||||
/// Started → AutoApproved(toast 提示用户已自动放行)→ Completed。
|
||||
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),
|
||||
/// 供审批卡即时预览。旧文件不存在(新建)或非 write_file 工具为 None。
|
||||
@@ -213,6 +222,23 @@ pub enum AiChatEvent {
|
||||
/// - 点继续 → ai_continue_loop → try_continue_agent_loop 再跑 max_iterations 轮
|
||||
/// - 点停止 → ai_stop_loop → 走完成流程(save + AiCompleted + generating=false)
|
||||
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」。
|
||||
@@ -977,6 +1003,21 @@ pub struct PerConvState {
|
||||
pub workflow_id: Option<String>,
|
||||
/// T4 工作流 DAG 摘要(预计算的活跃路径文本,避免运行时加载 DAG)
|
||||
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(),
|
||||
workflow_id: None,
|
||||
workflow_dag_summary: None,
|
||||
frozen_tool_names: None,
|
||||
tool_view_decisions: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use df_storage::db::Database;
|
||||
use df_storage::models::AiProviderRecord;
|
||||
|
||||
use crate::commands::err_str;
|
||||
use crate::state::AppState;
|
||||
use crate::state::{AppState, SettingsNorm};
|
||||
|
||||
/// 获取当前活跃提供商配置
|
||||
pub(crate) async fn get_active_provider(state: &AppState) -> Result<AiProviderRecord, String> {
|
||||
@@ -486,22 +486,36 @@ async fn build_context_lists(
|
||||
|
||||
/// 自定义提示词段(设置 custom_prompt,追加到 system prompt 末尾)。无/空返回空串。
|
||||
async fn build_custom_section(state: &AppState) -> String {
|
||||
if let Ok(Some(custom)) = state.settings.get("custom_prompt").await {
|
||||
if !custom.is_empty() {
|
||||
let clean = custom.trim().trim_matches('"');
|
||||
if !clean.is_empty() {
|
||||
return format!("\n## 自定义指令\n{}\n", clean);
|
||||
}
|
||||
}
|
||||
// get_string_norm 归一去引号(前端 JSON.stringify 落库带引号),再 trim 空白;空返回空段。
|
||||
let clean = state
|
||||
.settings
|
||||
.get_string_norm("custom_prompt")
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if !clean.trim().is_empty() {
|
||||
return format!("\n## 自定义指令\n{}\n", clean.trim());
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// 读取 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 形态
|
||||
/// (数组+cache_control / 纯字符串),保证开关关时非官方网关零风险。
|
||||
/// 键名保持历史名(向后兼容,已有键不换名),语义已扩展覆盖 openai_compat 前缀稳定拆分。
|
||||
pub(crate) async fn cache_enabled(state: &AppState) -> bool {
|
||||
let v = state
|
||||
.settings
|
||||
@@ -525,8 +539,8 @@ pub(crate) async fn cache_enabled(state: &AppState) -> bool {
|
||||
/// 首条 user:任何 system 之后的消息级缓存断点都在易变段之前,每日/每请求变化的易变内容
|
||||
/// 不会失效已缓存前缀(跨天缓存仍命中)。
|
||||
///
|
||||
/// 注意:调用方须先按「开关开 AND 当前 provider 为 anthropic 类型」gate 再传 `cache_enabled`,
|
||||
/// 保证 OpenAI 路径零影响(开关开也不改动 openai 请求结构)。
|
||||
/// 注意:调用方须先按「开关开 AND 当前 provider 支持缓存拆分(anthropic 系/openai_compat)」
|
||||
/// gate 再传 `cache_enabled`,保证开关关时全部 provider 零影响(逐字等价旧路径)。
|
||||
pub(crate) async fn build_system_prompt_parts(
|
||||
state: &AppState,
|
||||
lang: &str,
|
||||
|
||||
@@ -65,8 +65,8 @@ use crate::state::AppState;
|
||||
use crate::commands::ai::{
|
||||
ai_approve, ai_authorize_dir, ai_chat_clear_context, ai_chat_compress_context, ai_chat_edit,
|
||||
ai_chat_force_send, ai_chat_send, ai_chat_stop, ai_conversation_rename, ai_continue_loop,
|
||||
ai_list_skills, ai_regenerate, ai_stop_loop, record_to_message, AiChatEvent, ApprovalKind,
|
||||
ConvSummary, ModelInfo, PendingApproval,
|
||||
ai_list_skills, ai_regenerate, ai_stop_loop, event_bus::emit_ai_chat_event,
|
||||
record_to_message, AiChatEvent, ApprovalKind, ConvSummary, ModelInfo, PendingApproval,
|
||||
};
|
||||
// list_models 路由:get_active_provider(prompt.rs 已 pub(crate))读活跃 provider。
|
||||
// 不走 commands 模块(provider 获取是 prompt 子模块职责,对齐 chat.rs 同源调用路径)。
|
||||
@@ -581,8 +581,8 @@ async fn route_sync_pending(state: &State<'_, AppState>, conversation_id: String
|
||||
/// `list_skills` 路由 —— 读本机 Claude 技能进程内缓存,跨端推回 miniapp。
|
||||
///
|
||||
/// 流程:
|
||||
/// 1. `ai_list_skills().await`(零参,直接读 `skills_cached()` 进程内缓存,不重复扫盘)
|
||||
/// → `Vec<SkillInfo>`(已按 skills>commands>plugins 优先级去重)
|
||||
/// 1. `ai_list_skills(None).await`(直接读 `skills_cached()` 进程内缓存,不重复扫盘)
|
||||
/// → `Vec<SkillInfo>`(已按 project>skills>commands>plugins 优先级去重)
|
||||
/// 2. `state.ai_event_bus.publish_event(AiSkillList)` 跨端透传
|
||||
/// (经 EventBus→tunnel subscriber→relay→miniapp,非 app.emit 仅桌面前端)
|
||||
///
|
||||
@@ -591,10 +591,11 @@ async fn route_sync_pending(state: &State<'_, AppState>, conversation_id: String
|
||||
/// 错误兜底:ai_list_skills 返 Err(skills_cached 内部理论上不失败,但签名返 Result 兜底)
|
||||
/// → publish 空 Vec(对齐 route_list_conversations 失败推空列表语义)。
|
||||
///
|
||||
/// 注:ai_list_skills 是 `#[tauri::command]` 零参函数(不需要 app/state),此处直接 async 调,
|
||||
/// 注:ai_list_skills 是 `#[tauri::command]` 函数(不需要 app/state),此处直接 async 调,
|
||||
/// 不经 IPC invoke(对齐 route_send_message 调 ai_chat_send 的直接调用模式)。
|
||||
async fn route_list_skills(state: &State<'_, AppState>) {
|
||||
let skills: Vec<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,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "[remote_bridge] list_skills 读技能缓存失败,推送空列表");
|
||||
@@ -840,16 +841,15 @@ async fn route_send_message(app: &AppHandle, state: &State<'_, AppState>, args:
|
||||
// 注:publish 走事件通道,device 端不会把此事件当入站命令回灌(命令/事件分轨)。
|
||||
// 前端 handleEvent 去重防双气泡(末条已是同 content user 跳过)。
|
||||
// BUG-260805-02 双写修复:桌面端只 listen app.emit('ai-chat-event'),publish_event(ai_event_bus)
|
||||
// 桌面前端收不到 → 微信 user 消息桌面不可见。补 emit 双写(桌面收),保留 publish(微信自回灌去重 +
|
||||
// tunnel 透传其他 miniapp 订阅者)。
|
||||
// 桌面前端收不到 → 微信 user 消息桌面不可见。emit+publish 双写两行式已收敛为
|
||||
// emit_ai_chat_event(2026-10-01 DRY,两通道各收同一份事件值,语义不变)。
|
||||
let user_msg_ev = AiChatEvent::AiUserMessage {
|
||||
message: message.clone(),
|
||||
// 用 resolve 后真实 conv_id(新会话是强制新建 id,旧会话是入参/active),
|
||||
// 桌面端才能正确归属用户气泡到会话(此前广播原始 null 致无法归属)。
|
||||
conversation_id: resolved_conv_id.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", user_msg_ev.clone());
|
||||
let _ = state.ai_event_bus.publish_event(user_msg_ev);
|
||||
emit_ai_chat_event(app, user_msg_ev);
|
||||
let result = ai_chat_send(
|
||||
app.clone(),
|
||||
state.clone(),
|
||||
@@ -924,13 +924,12 @@ async fn route_force_send(app: &AppHandle, state: &State<'_, AppState>, args: Va
|
||||
|
||||
// 广播 AiUserMessage(同 route_send_message:force_send 追加新 user 消息,
|
||||
// 桌面端需据此补 user 气泡,防孤立 assistant 响应)。
|
||||
// BUG-260805-02 双写:桌面端只 listen app.emit,补 emit 让桌面收到(对齐 route_send_message)。
|
||||
// BUG-260805-02 双写:emit+publish 两行式已收敛 emit_ai_chat_event(对齐 route_send_message)。
|
||||
let user_msg_ev = AiChatEvent::AiUserMessage {
|
||||
message: message.clone(),
|
||||
conversation_id: conversation_id.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", user_msg_ev.clone());
|
||||
let _ = state.ai_event_bus.publish_event(user_msg_ev);
|
||||
emit_ai_chat_event(app, user_msg_ev);
|
||||
let result = ai_chat_force_send(
|
||||
app.clone(),
|
||||
state.clone(),
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
//! - `OnceLock<Vec>` → `RwLock<Option<Vec>>` + 快路径(读锁命中 clone)/ 慢路径(重扫)
|
||||
//! + `invalidate_skills` 写锁置 None,支持进程内 `ai_reload_skills` 热重载
|
||||
//! - `scan_skills` 返 `ScanResult{skills, conflicts}`:同名收集所有 path 入 conflicts,
|
||||
//! 保留首份(优先级 skills>commands>plugins);`SkillInfo.duplicates` 仅冲突时填。
|
||||
//! 保留首份(优先级 project>skills>commands>plugins,项目级经 `ai_list_skills(project_path)`
|
||||
//! 设 `PROJECT_SKILLS_ROOT` 后生效);`SkillInfo.duplicates` 仅冲突时填。
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
@@ -15,6 +16,8 @@ use std::sync::{RwLock, RwLockReadGuard};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::commands::ai::tool_registry::{is_noise_entry, NoiseDirTier};
|
||||
|
||||
/// 技能元信息(前端 `/` 联想 + 后端注入用)
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SkillInfo {
|
||||
@@ -51,6 +54,58 @@ fn claude_home() -> Option<PathBuf> {
|
||||
.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 块,返回正文。
|
||||
///
|
||||
/// 状态机:用行索引推进;首个 `---`(trim 后)进入 in_fm,遇到下个 `---` 退出,余为正文。
|
||||
@@ -159,9 +214,9 @@ fn collect_skill_files(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
for entry in entries.flatten() {
|
||||
let p = entry.path();
|
||||
if p.is_dir() {
|
||||
// 跳过依赖/版本目录,避免递归爆炸
|
||||
// 跳过依赖/VCS 目录,避免递归爆炸(SkillScan 最小集档,名单统一走 is_noise_entry)
|
||||
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
if name == "node_modules" || name == ".git" {
|
||||
if is_noise_entry(name, NoiseDirTier::SkillScan) {
|
||||
continue;
|
||||
}
|
||||
collect_skill_files(&p, out);
|
||||
@@ -172,7 +227,10 @@ fn collect_skill_files(dir: &Path, out: &mut Vec<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
|
||||
/// (仅当同名 > 1 时入列,前端提示用)。
|
||||
@@ -187,8 +245,15 @@ fn scan_skills() -> ScanResult {
|
||||
}
|
||||
};
|
||||
|
||||
// 三类来源按优先级顺序收集(skills > commands > plugins)
|
||||
let mut all: Vec<Vec<SkillInfo>> = Vec::with_capacity(3);
|
||||
// 各来源按优先级顺序收集(project > skills > commands > plugins)
|
||||
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
|
||||
let mut batch_skill = Vec::new();
|
||||
|
||||
@@ -5,11 +5,12 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tauri::AppHandle;
|
||||
use tracing::warn;
|
||||
|
||||
use df_ai::provider::{CompletionRequest, LlmProvider};
|
||||
|
||||
use super::event_bus::emit_ai_chat_event;
|
||||
use super::{AiChatEvent, ToolCallDraft};
|
||||
|
||||
/// 从 anyhow 错误中尽力提取 HTTP 状态码/错误分类,供诊断拼接。
|
||||
@@ -148,12 +149,6 @@ fn accumulate_tool_calls(
|
||||
}
|
||||
}
|
||||
|
||||
/// 双写 emit: Tauri 事件 + ai_event_bus 总线(跨端透传)。消费 ev。
|
||||
fn emit_ai_chat_event(app_handle: &AppHandle, ev: AiChatEvent) {
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
|
||||
/// flush 合批的 AiTextDelta(双写 emit + event_bus)。空 buffer 不 emit。
|
||||
fn flush_delta(app_handle: &AppHandle, conv_id: &str, pending_delta: &mut String) {
|
||||
if pending_delta.is_empty() {
|
||||
@@ -587,7 +582,8 @@ pub(crate) async fn stream_llm(
|
||||
/// - HTTP 429 / 5xx → retryable=true(复用 retry::is_status_retryable 逻辑)
|
||||
/// - HTTP 4xx(非429) → retryable=false Fatal(401/403/404/422 等)
|
||||
/// - 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 错误分类逻辑或镜像"),
|
||||
/// 避免 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)
|
||||
// 是传输层错误,不是 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("connect")
|
||||
|| lower.contains("transport")
|
||||
@@ -652,10 +658,19 @@ fn is_quota_exhaustion(combined: &str) -> bool {
|
||||
|
||||
/// 额度耗尽型 429 → Fatal(确定性,不重试);否则交 classify_status_or_class 正常判定。
|
||||
/// 入参 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 {
|
||||
if is_quota_exhaustion(&format!("{status_or_class} {body}")) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -839,6 +854,40 @@ mod tests {
|
||||
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 不应误判额度耗尽(仍可重试,防误伤)
|
||||
#[test]
|
||||
fn standard_rate_limit_error_not_quota() {
|
||||
|
||||
@@ -516,6 +516,7 @@ pub fn build_ai_tool_registry(
|
||||
data_dir: PathBuf,
|
||||
get_app_config_ctx: super::get_app_config::GetAppConfigCtx,
|
||||
local_proxy_ctx: super::tools::local_proxy::LocalProxyCtx,
|
||||
spawn_agent_ctx: super::tools::spawn_agent::SpawnAgentCtx,
|
||||
) -> AiToolRegistry {
|
||||
let mut registry = AiToolRegistry::new();
|
||||
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_get_app_config_tool(&mut registry, get_app_config_ctx);
|
||||
register_local_proxy_tools(&mut registry, local_proxy_ctx);
|
||||
register_read_skill_tool(&mut registry);
|
||||
register_spawn_agent_tool(&mut registry, spawn_agent_ctx);
|
||||
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)。
|
||||
/// 不持 db,纯 reqwest 调用。SSRF 防护 + 重定向每跳校验见 commands/ai/http.rs。
|
||||
/// 风险: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();
|
||||
// 噪音目录(.git/node_modules/target 等)既不深入、也不作 entry 返回(避免污染 AI 工具上下文)
|
||||
if skip_noise && is_noise_dir(&name) {
|
||||
if skip_noise && is_noise_entry(&name, NoiseDirTier::AiContext) {
|
||||
continue;
|
||||
}
|
||||
// 临时文件(.bak/.tmp-write 等)也不返回:write_file 原子写过程的副产物,
|
||||
@@ -889,7 +918,7 @@ pub(crate) fn list_dir_recursive<'a>(
|
||||
"depth": depth,
|
||||
}));
|
||||
// 仅递归真实目录(symlink 不递归,防符号链接逃逸到 workspace 外)
|
||||
if recursive && is_dir && depth < max_depth && !(skip_noise && is_noise_dir(&name)) {
|
||||
if recursive && is_dir && depth < max_depth && !(skip_noise && is_noise_entry(&name, NoiseDirTier::AiContext)) {
|
||||
let child_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned();
|
||||
let truncated = list_dir_recursive(&child_path, true, depth + 1, max_depth, max_entries, skip_noise, result).await?;
|
||||
if truncated {
|
||||
@@ -906,72 +935,86 @@ pub(crate) fn list_dir_recursive<'a>(
|
||||
/// 返回 Option<(&str, &[&str])>:(动作前缀模板, 关键参数名列表)。
|
||||
/// 模板中 `{}` 占位符由 build_approval_reason 按参数列表从 args 取值填充。
|
||||
/// 未注册的工具返回 None,由调用方 fallback 到通用风险提示。
|
||||
///
|
||||
/// DRY 债 #5 收敛:模板不再本文件手写 match,改查注册元数据(approval_hint 维度),
|
||||
/// 模板在 declare_tool! 注册点(tools/project.rs 等)声明。
|
||||
pub(crate) fn display_hint_for_tool(name: &str) -> Option<(&'static str, &'static [&'static str])> {
|
||||
let (template, keys): (&str, &[&str]) = match name {
|
||||
"delete_project" => ("删除项目{}", &["id"]),
|
||||
"restore_project" => ("从回收站恢复项目{}", &["id"]),
|
||||
"purge_project" => ("永久删除项目及关联数据,不可恢复{}", &["id"]),
|
||||
"update_project" => ("修改项目{}字段「{}」", &["id", "field"]),
|
||||
"bind_directory" => ("绑定目录:{}(项目{})", &["path", "id"]),
|
||||
"create_task" => ("创建任务:{}(项目{})", &["title", "project_id"]),
|
||||
"create_project" => ("创建项目:「{}」", &["name"]),
|
||||
"create_idea" => ("捕获灵感:{}", &["title"]),
|
||||
"patch_file" => ("修改文件:{}", &["path"]),
|
||||
"delete_file" => ("删除文件:{}({})", &["path", "permanent"]),
|
||||
"rename_file" => ("重命名/移动:{} → {}", &["from", "to"]),
|
||||
// F-03 收口:推进链工具审批文案(advance_task/run_workflow)
|
||||
"advance_task" => ("推进任务状态:{} → {}", &["id", "target_status"]),
|
||||
"run_workflow" => ("触发工作流:任务{} 推进到 {}", &["task_id", "target_status"]),
|
||||
// http_request 审批文案:method + url(看是什么请求打到哪)
|
||||
"http_request" => ("HTTP 请求:{} {}", &["method", "url"]),
|
||||
_ => return None,
|
||||
};
|
||||
Some((template, keys))
|
||||
ensure_tool_meta_seeded();
|
||||
df_ai::ai_tools::approval_hint(name)
|
||||
}
|
||||
|
||||
/// 工具名 → 中文动作前缀(轻量映射,供 build_approval_reason 快速取可读标签)。
|
||||
///
|
||||
/// 仅返回动作动词+对象名(如 "创建任务"、"写入文件"),不含参数细节。
|
||||
/// 未注册的工具返回 None,由调用方 fallback 到 display_hint_for_tool 模板填充或硬编码。
|
||||
///
|
||||
/// DRY 债 #5 收敛:标签不再本文件手写 match,改查注册元数据(display_verb 维度)。
|
||||
/// advance_task 刻意无标签:轻量命中会丢 target_status 详情,审批文案须走模板,与旧表一致。
|
||||
pub(crate) fn tool_display_hint(name: &str) -> Option<&'static str> {
|
||||
match name {
|
||||
"create_task" => Some("创建任务"),
|
||||
"write_file" => Some("写入文件"),
|
||||
"read_file" => Some("读取文件"),
|
||||
"list_directory" => Some("列出目录"),
|
||||
"search_files" => Some("搜索文件"),
|
||||
"grep" => Some("搜索代码内容"),
|
||||
"create_project" => Some("创建项目"),
|
||||
"update_project" => Some("修改项目"),
|
||||
"delete_project" => Some("删除项目"),
|
||||
"restore_project" => Some("恢复项目"),
|
||||
"purge_project" => Some("永久删除项目"),
|
||||
"bind_directory" => Some("绑定目录"),
|
||||
"create_idea" => Some("捕获灵感"),
|
||||
"update_task" => Some("修改任务"),
|
||||
// advance_task 不在此轻量映射:让 build_approval_reason 走 display_hint_for_tool
|
||||
// 模板(含 target_status 详情),否则轻量命中"推进任务状态"丢失目标态信息
|
||||
"run_workflow" => Some("触发工作流"),
|
||||
"delete_task" => Some("删除任务"),
|
||||
"update_idea" => Some("修改灵感"),
|
||||
"delete_idea" => Some("删除灵感"),
|
||||
"patch_file" => Some("修改文件"),
|
||||
"append_file" => Some("追加写入"),
|
||||
"delete_file" => Some("删除文件"),
|
||||
"rename_file" => Some("重命名/移动"),
|
||||
"http_request" => Some("发起 HTTP 请求"),
|
||||
_ => None,
|
||||
}
|
||||
ensure_tool_meta_seeded();
|
||||
df_ai::ai_tools::display_verb(name)
|
||||
}
|
||||
|
||||
/// 判断是否为不应深入递归的噪音目录(构建产物/依赖/缓存等)
|
||||
pub(crate) fn is_noise_dir(name: &str) -> bool {
|
||||
const NOISE_DIRS: &[&str] = &[
|
||||
/// 元数据种子(仅测试构建):元数据查询依赖注册期写入的全局表。
|
||||
///
|
||||
/// 生产 build_ai_tool_registry 先于任何查询执行,表必非空,本函数恒空操作;
|
||||
/// 单测可能不经 build 直接调消费方自由函数(extract_file_tool_paths/tool_call_signature 等),
|
||||
/// 表尚空时用 in-memory db 构建一次真实注册表,让 declare_tool! 注册点把元数据写入全局表。
|
||||
/// Once 保证并发测试只种一次;表已非空(任一测试已 build)则跳过。
|
||||
#[cfg(test)]
|
||||
pub(crate) fn ensure_tool_meta_seeded() {
|
||||
static SEED: std::sync::Once = std::sync::Once::new();
|
||||
SEED.call_once(|| {
|
||||
if !df_ai::ai_tools::meta_table_is_empty() {
|
||||
return;
|
||||
}
|
||||
let rt = tokio::runtime::Runtime::new().expect("元数据种子 runtime");
|
||||
rt.block_on(async {
|
||||
let db = Arc::new(Database::open_in_memory().await.expect("in-memory db 初始化失败"));
|
||||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
|
||||
let _ = build_ai_tool_registry(
|
||||
&db,
|
||||
&allowed_dirs,
|
||||
PathBuf::from(""),
|
||||
make_test_get_app_config_ctx(&db),
|
||||
make_test_local_proxy_ctx(),
|
||||
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",
|
||||
".next", ".cache", "__pycache__", ".venv", "venv", ".idea",
|
||||
".trash", // delete_file 软删除回收站,不污染 list_directory
|
||||
];
|
||||
NOISE_DIRS.contains(&name)
|
||||
match tier {
|
||||
NoiseDirTier::AiContext => FULL.contains(&name),
|
||||
// 文件树口径 = 全量名单放行 build(用户要看构建产物)与 venv(历史未过滤,行为保持)
|
||||
NoiseDirTier::FileTree => FULL.contains(&name) && !matches!(name, "build" | "venv"),
|
||||
// 技能扫描最小集:只防依赖/VCS 递归爆炸,与历史两项一致
|
||||
NoiseDirTier::SkillScan => matches!(name, "node_modules" | ".git"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断是否为不应返回给 AI 的噪音文件(临时/备份/编辑器产物)。
|
||||
@@ -1062,7 +1105,7 @@ pub(crate) fn compile_glob_to_regex(glob: &str) -> anyhow::Result<regex::Regex>
|
||||
/// 递归跨文件内容搜索(grep -rn 模式)
|
||||
///
|
||||
/// 遍历 `dir`(深度 `depth`,上限 `max_depth`)下所有文件:
|
||||
/// - 跳过噪音目录(is_noise_dir)/噪音文件(is_noise_file)/symlink(file_type 不跟随)
|
||||
/// - 跳过噪音目录(is_noise_entry)/噪音文件(is_noise_file)/symlink(file_type 不跟随)
|
||||
/// - glob 过滤器(可选):命中文件名才读内容
|
||||
/// - 二进制文件跳过:前 8KB 含 \0 视为二进制(对齐 read_file/list_directory)
|
||||
/// - 单文件 1MB 上限跳过(对齐 read_file,防读超大文件撑爆)
|
||||
@@ -1110,7 +1153,7 @@ pub(crate) fn grep_recursive<'a>(
|
||||
}
|
||||
if file_type.is_dir() {
|
||||
// 噪音目录(.git/node_modules/target/.trash 等)不深入
|
||||
if is_noise_dir(&file_name) {
|
||||
if is_noise_entry(&file_name, NoiseDirTier::AiContext) {
|
||||
continue;
|
||||
}
|
||||
if depth < max_depth {
|
||||
@@ -1281,19 +1324,19 @@ pub(crate) fn search_files_recursive<'a>(
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
// get_app_config baseline 测试辅助:构造 GetAppConfigCtx(与 AppState init 同款默认值,
|
||||
// 仅用于 build_ai_tool_registry 注册阶段定义完整性断言,handler 不实际执行故无需真实配置)。
|
||||
// 仅用于 build_ai_tool_registry 注册阶段,handler 不实际执行故无需真实配置)。
|
||||
// 提到 tests 模块外(file 级 cfg(test)):ensure_tool_meta_seeded 种子路径也要用。
|
||||
#[cfg(test)]
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize};
|
||||
#[cfg(test)]
|
||||
use crate::state::LlmConcurrency;
|
||||
|
||||
/// 测试用 GetAppConfigCtx 构造器(默认值对齐 AppState::init:iter=10/retries=3/timeout=15/concurrency 3,2)。
|
||||
///
|
||||
/// build_ai_tool_registry 第 4 参,5 处测试调用共用,避免每处内联 5 行重复。
|
||||
/// Arc 句柄独立于 AppState(测试无 AppState),仅满足注册期类型签名,handler 不执行。
|
||||
#[cfg(test)]
|
||||
fn make_test_get_app_config_ctx(db: &Arc<Database>) -> crate::commands::ai::get_app_config::GetAppConfigCtx {
|
||||
crate::commands::ai::get_app_config::GetAppConfigCtx {
|
||||
db: db.clone(),
|
||||
@@ -1310,12 +1353,33 @@ mod tests {
|
||||
|
||||
/// build_ai_tool_registry 第 5 参(local_proxy 工具代理 ctx),测试调用共用。
|
||||
/// gateway_url 用默认本地网关即可,handler 不实际执行无需真实代理。
|
||||
#[cfg(test)]
|
||||
fn make_test_local_proxy_ctx() -> crate::commands::ai::tools::local_proxy::LocalProxyCtx {
|
||||
crate::commands::ai::tools::local_proxy::LocalProxyCtx {
|
||||
gateway_url: "http://127.0.0.1:8899".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 拆分防护)
|
||||
//
|
||||
@@ -1324,7 +1388,7 @@ mod tests {
|
||||
// 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。
|
||||
// ============================================================
|
||||
|
||||
/// build_ai_tool_registry 应注册恰好 76 个工具(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy),且工具名集合稳定。
|
||||
/// build_ai_tool_registry 应注册恰好 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,
|
||||
// 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。
|
||||
@@ -1335,7 +1399,7 @@ mod tests {
|
||||
// F-260619-03 Phase A: build_ai_tool_registry 新增 allowed_dirs 形参,
|
||||
// 测试用 default_with_root(仅 workspace_root),零回归(白名单含 workspace_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)。拆分前后必须一致。
|
||||
// file 层 10→11(新增 grep 跨文件内容搜索工具)。
|
||||
@@ -1395,10 +1459,18 @@ mod tests {
|
||||
// knowledge_promote(2026-10-01): data 层 44→45(新增候选知识显式转正工具,
|
||||
// candidate→published 单向,补齐自动晋升默认关后的对话内审核通道,Medium)。
|
||||
// 76 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。
|
||||
// read_skill(2026-10-01): read_skill 层 0→1(按名读本机 Claude 技能规程正文,只读 Low,
|
||||
// 与 / 技能注入链共用 skills.rs 进程内缓存;未命中返明确错误,LLM 可向用户澄清。
|
||||
// 声明式注册在 tools/read_skill.rs,register_read_skill_tool thin 委托)。
|
||||
// 77 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill。
|
||||
// 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!(
|
||||
registry.len(),
|
||||
76,
|
||||
"工具总数应为 76(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy),实际 {}", registry.len()
|
||||
78,
|
||||
"工具总数应为 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 / 漏注册 / 误删除。
|
||||
@@ -1463,6 +1535,12 @@ mod tests {
|
||||
"ssh_exec", "mongo_find", "mongo_insert",
|
||||
"mongo_update", "mongo_delete", "redis_get",
|
||||
"redis_set", "redis_del",
|
||||
// ── read_skill 层 (1) ──(按名读本机 Claude 技能规程正文,只读 Low,
|
||||
// 与 / 技能注入链共用 skills.rs 进程内缓存,声明式注册 tools/read_skill.rs)
|
||||
"read_skill",
|
||||
// ── spawn_agent 层 (1) ──(派发子代理隔离上下文执行专项任务,Medium 审批档,
|
||||
// 独立 agentic 循环 agentic/sub_agent.rs,声明式注册 tools/spawn_agent.rs)
|
||||
"spawn_agent",
|
||||
];
|
||||
expected.sort_unstable();
|
||||
|
||||
@@ -1476,39 +1554,57 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// is_noise_dir 纯函数:覆盖 .git/.gitignore 区分(目录是噪音,.gitignore 文件名不是)
|
||||
/// is_noise_entry 纯函数:覆盖 .git/.gitignore 区分(目录是噪音,.gitignore 文件名不是)
|
||||
#[test]
|
||||
fn test_is_noise_dir_distinguishes_dir_and_gitignore_file() {
|
||||
fn test_is_noise_entry_distinguishes_dir_and_gitignore_file() {
|
||||
// 噪音目录命中
|
||||
assert!(is_noise_dir(".git"));
|
||||
assert!(is_noise_dir("node_modules"));
|
||||
assert!(is_noise_dir("target"));
|
||||
assert!(is_noise_dir("dist"));
|
||||
assert!(is_noise_dir("build"));
|
||||
assert!(is_noise_entry(".git", NoiseDirTier::AiContext));
|
||||
assert!(is_noise_entry("node_modules", NoiseDirTier::AiContext));
|
||||
assert!(is_noise_entry("target", NoiseDirTier::AiContext));
|
||||
assert!(is_noise_entry("dist", NoiseDirTier::AiContext));
|
||||
assert!(is_noise_entry("build", NoiseDirTier::AiContext));
|
||||
// .gitignore / .gitattributes 等文件名不应命中(只匹配纯目录名 .git)
|
||||
assert!(!is_noise_dir(".gitignore"));
|
||||
assert!(!is_noise_dir(".gitattributes"));
|
||||
assert!(!is_noise_dir(".gitkeep"));
|
||||
assert!(!is_noise_entry(".gitignore", NoiseDirTier::AiContext));
|
||||
assert!(!is_noise_entry(".gitattributes", NoiseDirTier::AiContext));
|
||||
assert!(!is_noise_entry(".gitkeep", NoiseDirTier::AiContext));
|
||||
}
|
||||
|
||||
/// is_noise_dir 纯函数:普通目录不命中
|
||||
/// is_noise_entry 纯函数:普通目录不命中
|
||||
#[test]
|
||||
fn test_is_noise_dir_normal_dirs_not_matched() {
|
||||
assert!(!is_noise_dir("src"));
|
||||
assert!(!is_noise_dir("tests"));
|
||||
assert!(!is_noise_dir("docs"));
|
||||
assert!(!is_noise_dir("main.rs"));
|
||||
assert!(!is_noise_dir(""));
|
||||
fn test_is_noise_entry_normal_dirs_not_matched() {
|
||||
assert!(!is_noise_entry("src", NoiseDirTier::AiContext));
|
||||
assert!(!is_noise_entry("tests", NoiseDirTier::AiContext));
|
||||
assert!(!is_noise_entry("docs", NoiseDirTier::AiContext));
|
||||
assert!(!is_noise_entry("main.rs", NoiseDirTier::AiContext));
|
||||
assert!(!is_noise_entry("", NoiseDirTier::AiContext));
|
||||
}
|
||||
|
||||
/// is_noise_dir 纯函数:大小写敏感(不靠 to_lowercase 误命中 Target)
|
||||
/// is_noise_entry 纯函数:大小写敏感(不靠 to_lowercase 误命中 Target)
|
||||
#[test]
|
||||
fn test_is_noise_dir_case_sensitive() {
|
||||
fn test_is_noise_entry_case_sensitive() {
|
||||
// 文件系统在 Windows 上大小写不敏感,但函数本身用精确匹配;
|
||||
// 锁定当前语义:大写变体不命中(避免后续误改 to_lowercase 引入行为变化)
|
||||
assert!(!is_noise_dir("Target"));
|
||||
assert!(!is_noise_dir("DIST"));
|
||||
assert!(!is_noise_dir("NODE_MODULES"));
|
||||
assert!(!is_noise_entry("Target", NoiseDirTier::AiContext));
|
||||
assert!(!is_noise_entry("DIST", NoiseDirTier::AiContext));
|
||||
assert!(!is_noise_entry("NODE_MODULES", NoiseDirTier::AiContext));
|
||||
}
|
||||
|
||||
/// is_noise_entry 档位差异锁定:文件树放行 build/venv,技能扫描仅两项
|
||||
#[test]
|
||||
fn test_is_noise_entry_tier_deltas() {
|
||||
// FileTree:全量名单放行 build 与 venv(用户浏览要见构建产物),其余与 AiContext 一致
|
||||
assert!(!is_noise_entry("build", NoiseDirTier::FileTree));
|
||||
assert!(!is_noise_entry("venv", NoiseDirTier::FileTree));
|
||||
assert!(is_noise_entry("node_modules", NoiseDirTier::FileTree));
|
||||
assert!(is_noise_entry("target", NoiseDirTier::FileTree));
|
||||
assert!(is_noise_entry("dist", NoiseDirTier::FileTree));
|
||||
assert!(is_noise_entry("__pycache__", NoiseDirTier::FileTree));
|
||||
assert!(!is_noise_entry("src", NoiseDirTier::FileTree));
|
||||
// SkillScan:最小集,仅依赖/VCS
|
||||
assert!(is_noise_entry("node_modules", NoiseDirTier::SkillScan));
|
||||
assert!(is_noise_entry(".git", NoiseDirTier::SkillScan));
|
||||
assert!(!is_noise_entry("target", NoiseDirTier::SkillScan));
|
||||
assert!(!is_noise_entry("build", NoiseDirTier::SkillScan));
|
||||
}
|
||||
|
||||
/// is_noise_file 纯函数:write_file 副产物 / 编辑器临时文件命中
|
||||
@@ -1664,7 +1760,7 @@ mod tests {
|
||||
|
||||
let db = Database::open_in_memory().await.expect("in-memory 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 args = serde_json::json!({ "path": canon_file, "limit": 15 });
|
||||
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 = 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 args = serde_json::json!({ "path": canon_file });
|
||||
let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
|
||||
@@ -1770,7 +1866,7 @@ mod tests {
|
||||
persistent.insert(tmp.clone());
|
||||
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 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 args = serde_json::json!({ "path": canon_file });
|
||||
@@ -1799,7 +1895,7 @@ mod tests {
|
||||
persistent.insert(tmp.clone());
|
||||
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 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 args = serde_json::json!({ "path": canon_file });
|
||||
@@ -2281,7 +2377,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -2381,4 +2477,192 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(staging["total"], 0);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DRY 债 #5 对照验证:5 处旧 match 表收敛到注册元数据后逐工具比对。
|
||||
//
|
||||
// 旧表值在本测试内固化为期望数据(收敛前从各 match 表逐行抄录):
|
||||
// path_auth 路径参数键 / cache 只读白名单 + 目录级 key / data_change entity-action /
|
||||
// tool_registry 审批模板 + 轻量标签 / helpers 目标描述 + 漂移签名(按样例 args 比对输出)。
|
||||
// 任何一维漂移此测试立即红。
|
||||
// ============================================================
|
||||
#[tokio::test]
|
||||
async fn test_tool_meta_parity_with_legacy_tables() {
|
||||
// 真实构建一次注册表(条目与全局表一致性断言用)
|
||||
let db = Arc::new(Database::open_in_memory().await.expect("in-memory db 初始化失败"));
|
||||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
|
||||
let registry = build_ai_tool_registry(
|
||||
&db, &allowed_dirs, PathBuf::from(""),
|
||||
make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx(),
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
||||
use df_ai::ai_tools::{AiToolRegistry, RiskLevel, SigMeta, SigValKind, ToolMeta};
|
||||
use df_ai::declare_tool;
|
||||
|
||||
/// 注册 fetch_url 工具到 `$registry`(无 db 捕获,纯网络 GET + htmd 转换)。
|
||||
@@ -42,6 +42,10 @@ pub fn register(registry: &mut AiToolRegistry) {
|
||||
"fetch_url",
|
||||
"获取网页 URL 内容并转为 markdown(HTML 清洗 + 截断),用于高效理解网页文档(API 文档/博客/技术资料)。只读 GET,自动剥离 script/style/nav 等噪声,提取 title,按 max_length 截断。返回 {url, title, markdown, length, truncated, render_mode, ?hint}。安全:仅 http/https,拒绝私网/保留 IP(SSRF 防护含 DNS resolve 后校验),重定向≤3 跳。render=true 用 obscura 渲染 JS(SPA/反爬,需装 obscura,未装自动回退静态)。如需 POST/鉴权/原始响应用 http_request",
|
||||
RiskLevel::Low,
|
||||
meta: ToolMeta {
|
||||
sig: Some(SigMeta { groups: &[(&["url"], SigValKind::Str)] }),
|
||||
..Default::default()
|
||||
},
|
||||
schema: schema,
|
||||
args => {
|
||||
// 转调 fetch_url.rs handler(SSRF 防护 + htmd 转换 + 去噪音 + 截断全在那)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
//! validate_path / generate_diff / apply_line_range / resolve_anchor_to_lines /
|
||||
//! resolve_workspace_path_with_allowed / rename_or_cross_volume_copy / compute_file_hash /
|
||||
//! FILE_LOCKS / probe_executable / list_dir_recursive / compile_glob_to_regex /
|
||||
//! grep_recursive / grep_one_file / search_files_recursive / is_noise_dir / is_noise_file /
|
||||
//! grep_recursive / grep_one_file / search_files_recursive / is_noise_entry / is_noise_file /
|
||||
//! FileGrepHit / LineMatch。
|
||||
//!
|
||||
//! 单捕获宏适配(declare_tool! 宏仅支持单 capture 变量):
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
|
||||
use df_ai::ai_tools::{object_schema, AiToolRegistry, GoalMeta, GoalStyle, RiskLevel, SigMeta, SigValKind, ToolMeta};
|
||||
use df_ai::declare_tool;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
@@ -73,6 +73,14 @@ pub fn register(
|
||||
"read_file",
|
||||
"读取文件内容,返回文本内容。支持 offset/limit 分页;传入 search 则在文件内容中搜索匹配行(大小写敏感,字符串包含匹配),返回 matches 数组(限50条)。仅需函数/类型符号的签名或定义体时优先用 read_symbol(骨架/全文,避免 prompt 爆)",
|
||||
RiskLevel::Low,
|
||||
meta: ToolMeta {
|
||||
path_args: &["path"],
|
||||
readonly: true,
|
||||
display_verb: Some("读取文件"),
|
||||
goal: Some(GoalMeta { verb: "查看", keys: &["path"], style: GoalStyle::FileName, fallback: None }),
|
||||
sig: Some(SigMeta { groups: &[(&["path"], SigValKind::Str), (&["offset"], SigValKind::Json), (&["limit"], SigValKind::Json)] }),
|
||||
..Default::default()
|
||||
},
|
||||
schema: object_schema(vec![("path", "string", true), ("offset", "integer", false), ("limit", "integer", false), ("search", "string", false)]),
|
||||
args => {
|
||||
let snap = allowed_dirs.read().await.clone();
|
||||
@@ -208,6 +216,10 @@ pub fn register(
|
||||
"read_symbol",
|
||||
"AST 符号解析(信息密度驱动,治 read_file 全文回灌 prompt 爆)。提取函数/结构体/类等符号:默认返回骨架(签名+行范围+内部调用,极小高密度);传 full=true 取完整定义体。不支持/未找到时回退提示用 grep+read_file。Phase1 支持 .rs/.ts/.tsx/.js/.jsx/.vue。",
|
||||
RiskLevel::Low,
|
||||
meta: ToolMeta {
|
||||
readonly: true,
|
||||
..Default::default()
|
||||
},
|
||||
schema: object_schema(vec![("path", "string", true), ("symbol", "string", true), ("full", "boolean", false), ("kind", "string", false), ("drill", "string", false)]),
|
||||
args => {
|
||||
let snap = allowed_dirs.read().await.clone();
|
||||
@@ -308,6 +320,15 @@ pub fn register(
|
||||
"list_directory",
|
||||
"列出目录内容,返回文件和子目录列表(名称、类型、大小)。path 可选,不传时返回引导提示",
|
||||
RiskLevel::Low,
|
||||
meta: ToolMeta {
|
||||
path_args: &["path"],
|
||||
readonly: true,
|
||||
cache_path_key: true,
|
||||
display_verb: Some("列出目录"),
|
||||
goal: Some(GoalMeta { verb: "浏览", keys: &["path"], style: GoalStyle::Raw, fallback: None }),
|
||||
sig: Some(SigMeta { groups: &[(&["path"], SigValKind::Str)] }),
|
||||
..Default::default()
|
||||
},
|
||||
schema: object_schema(vec![("path", "string", false), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false), ("max_depth", "integer", false)]),
|
||||
args => {
|
||||
let p = args["path"].as_str().unwrap_or("");
|
||||
@@ -335,6 +356,13 @@ pub fn register(
|
||||
"write_file",
|
||||
"写入或创建文件,自动创建不存在的父目录。支持 encoding=\"base64\" 写入二进制/非 UTF-8 内容(图片/PDF/Excel 等),content 传 base64 编码字符串;默认 encoding=\"utf-8\" 传纯文本",
|
||||
RiskLevel::Medium,
|
||||
meta: ToolMeta {
|
||||
path_args: &["path"],
|
||||
entity_action: Some(("file", "update")),
|
||||
display_verb: Some("写入文件"),
|
||||
goal: Some(GoalMeta { verb: "修改", keys: &["path"], style: GoalStyle::FileName, fallback: None }),
|
||||
..Default::default()
|
||||
},
|
||||
schema: object_schema(vec![
|
||||
("path", "string", true),
|
||||
("content", "string", true),
|
||||
@@ -453,6 +481,14 @@ pub fn register(
|
||||
"patch_file",
|
||||
"局部更新文件内容(三模式互斥)。模式1 old_text:精确匹配原文替换(含空格/缩进,CAS 语义)。模式2 replace_lines:按行号区间 {start,end}(1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor:按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文。三模式均需 path+new_text,expected_hash 可选通用。注意:若文件已被外部修改,请先重新 read_file 获取最新内容。所有 patch_file 操作自动放行不阻塞 AI 工作流,写入会落 audit 表可追溯。",
|
||||
RiskLevel::Medium,
|
||||
meta: ToolMeta {
|
||||
path_args: &["path"],
|
||||
entity_action: Some(("file", "update")),
|
||||
approval_hint: Some(("修改文件:{}", &["path"])),
|
||||
display_verb: Some("修改文件"),
|
||||
goal: Some(GoalMeta { verb: "修改", keys: &["path"], style: GoalStyle::FileName, fallback: None }),
|
||||
..Default::default()
|
||||
},
|
||||
schema: patch_file_schema,
|
||||
args => {
|
||||
let snap = allowed_dirs.read().await.clone();
|
||||
@@ -637,6 +673,11 @@ pub fn register(
|
||||
"file_info",
|
||||
"获取文件或目录的元信息(是否存在、大小、行数、修改时间、是否二进制、是否目录),不读取文件内容",
|
||||
RiskLevel::Low,
|
||||
meta: ToolMeta {
|
||||
path_args: &["path"],
|
||||
goal: Some(GoalMeta { verb: "查看", keys: &["path"], style: GoalStyle::FileName, fallback: None }),
|
||||
..Default::default()
|
||||
},
|
||||
schema: object_schema(vec![("path", "string", true)]),
|
||||
args => {
|
||||
let snap = allowed_dirs.read().await.clone();
|
||||
@@ -691,6 +732,12 @@ pub fn register(
|
||||
"append_file",
|
||||
"向文件末尾追加内容,文件不存在则自动创建。返回写入字数和新文件大小",
|
||||
RiskLevel::Medium,
|
||||
meta: ToolMeta {
|
||||
path_args: &["path"],
|
||||
display_verb: Some("追加写入"),
|
||||
goal: Some(GoalMeta { verb: "修改", keys: &["path"], style: GoalStyle::FileName, fallback: None }),
|
||||
..Default::default()
|
||||
},
|
||||
schema: object_schema(vec![("path", "string", true), ("content", "string", true)]),
|
||||
args => {
|
||||
let snap = allowed_dirs.read().await.clone();
|
||||
@@ -730,6 +777,13 @@ pub fn register(
|
||||
"delete_file",
|
||||
"删除文件。默认(permanent=false)软删除:移到工作区 .trash/ 目录保留可恢复;permanent=true 硬删除不可恢复。删除前备份,回收站机制对齐 list_trash。高风险,须人工批准",
|
||||
RiskLevel::High,
|
||||
meta: ToolMeta {
|
||||
path_args: &["path"],
|
||||
approval_hint: Some(("删除文件:{}({})", &["path", "permanent"])),
|
||||
display_verb: Some("删除文件"),
|
||||
goal: Some(GoalMeta { verb: "处理", keys: &["path"], style: GoalStyle::FileName, fallback: None }),
|
||||
..Default::default()
|
||||
},
|
||||
schema: object_schema(vec![
|
||||
("path", "string", true),
|
||||
("permanent", "boolean", false),
|
||||
@@ -810,6 +864,13 @@ pub fn register(
|
||||
"rename_file",
|
||||
"重命名或移动文件(同一工具覆盖 rename 和 move)。参数:from(源路径)+ to(目标路径)+ overwrite(默认 false,目标存在则拒绝)。同卷走 tokio::fs::rename(原子),跨卷自动降级 copy+remove(非原子,失败回滚删 to 保 from 完整)。须人工批准",
|
||||
RiskLevel::Medium,
|
||||
meta: ToolMeta {
|
||||
path_args: &["from", "to"],
|
||||
approval_hint: Some(("重命名/移动:{} → {}", &["from", "to"])),
|
||||
display_verb: Some("重命名/移动"),
|
||||
goal: Some(GoalMeta { verb: "处理", keys: &["path"], style: GoalStyle::FileName, fallback: None }),
|
||||
..Default::default()
|
||||
},
|
||||
schema: object_schema(vec![
|
||||
("from", "string", true),
|
||||
("to", "string", true),
|
||||
@@ -902,6 +963,14 @@ pub fn register(
|
||||
"grep",
|
||||
"跨文件内容搜索(grep -rn 模式)。参数:pattern(正则,大小写敏感,无特殊字符时等价字面包含)、path(搜索根,可选,不传时返回引导提示)、glob(可选文件名过滤如 *.rs)、output_mode(content/files_with_matches/count)、-n(行号默认 true)、-i(大小写不敏感默认 false)、-C(上下文行数默认 0)、context_chars(字符级窗口,大单行如 minified JS/CSS 命中行超阈值时截匹配位置 ±N 字符窗口替代整行,默认 0 不截;与 -C 互补:-C 行级前后 N 行,context_chars 同长行内字符级)、max_results(上限默认 50)。跳过噪音目录/噪音文件/symlink/二进制文件。返回 matches(files_with_matches 模式)或 matches(含 file/line/content/context,content 模式)+ total + truncated。授权目录内放行,未授权触发目录授权申请(AiDirAuthRequired)",
|
||||
RiskLevel::Low,
|
||||
meta: ToolMeta {
|
||||
path_args: &["path"],
|
||||
readonly: true,
|
||||
display_verb: Some("搜索代码内容"),
|
||||
goal: Some(GoalMeta { verb: "搜索", keys: &["pattern", "query"], style: GoalStyle::Truncate, fallback: Some("搜索文件") }),
|
||||
sig: Some(SigMeta { groups: &[(&["pattern", "query"], SigValKind::Str), (&["path", "glob"], SigValKind::Str)] }),
|
||||
..Default::default()
|
||||
},
|
||||
schema: grep_schema,
|
||||
args => {
|
||||
let p = args["path"].as_str().unwrap_or("");
|
||||
@@ -1078,6 +1147,15 @@ pub fn register(
|
||||
"search_files",
|
||||
"在指定目录下搜索匹配模式(字符串包含匹配)的文件名,支持 offset/limit 分页。返回 results、total、has_more。默认 limit=50",
|
||||
RiskLevel::Low,
|
||||
meta: ToolMeta {
|
||||
path_args: &["path"],
|
||||
readonly: true,
|
||||
deny_unauthorized: true,
|
||||
display_verb: Some("搜索文件"),
|
||||
goal: Some(GoalMeta { verb: "搜索", keys: &["pattern", "query"], style: GoalStyle::Truncate, fallback: Some("搜索文件") }),
|
||||
sig: Some(SigMeta { groups: &[(&["pattern", "query"], SigValKind::Str), (&["path", "glob"], SigValKind::Str)] }),
|
||||
..Default::default()
|
||||
},
|
||||
schema: object_schema(vec![("path", "string", false), ("pattern", "string", true), ("recursive", "boolean", false), ("offset", "integer", false), ("limit", "integer", false)]),
|
||||
args => {
|
||||
let p = args["path"].as_str().unwrap_or("");
|
||||
@@ -1122,6 +1200,11 @@ pub fn register(
|
||||
"run_command",
|
||||
"在指定工作目录执行 shell 命令,返回 stdout/stderr/exit_code。仅用于命令执行场景:跑测试套件、构建项目、运行二进制/脚本验证行为。读取文件用 read_file,编辑文件用 patch_file/write_file,列目录用 list_directory,搜索文件名用 search_files——不要用本工具完成这些操作。高风险,须人工批准。命令需自包含(非交互式,避免需用户输入的程序)。默认超时 60 秒。返回含 succeeded 布尔(按 exit_code==0 判定)。注意:部分命令非零退出属正常语义(如 git diff 无差异返回 1),此时以 stdout/stderr 内容为准,勿仅凭 exit_code≠0 判失败。",
|
||||
RiskLevel::High,
|
||||
meta: ToolMeta {
|
||||
goal: Some(GoalMeta { verb: "执行", keys: &["command"], style: GoalStyle::Truncate, fallback: Some("执行命令") }),
|
||||
sig: Some(SigMeta { groups: &[(&["command"], SigValKind::Str)] }),
|
||||
..Default::default()
|
||||
},
|
||||
schema: object_schema(vec![
|
||||
("command", "string", true),
|
||||
("working_dir", "string", false),
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
||||
use df_ai::ai_tools::{AiToolRegistry, RiskLevel, SigMeta, SigValKind, ToolMeta};
|
||||
use df_ai::declare_tool;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
@@ -65,6 +65,12 @@ pub fn register(registry: &mut AiToolRegistry, allowed_dirs: &Arc<RwLock<Allowed
|
||||
"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 有副作用,统一按高风险须人工批准",
|
||||
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,
|
||||
args => {
|
||||
// allowed_dirs read lock clone(output_file 路径校验用;无 output_file 时校验不触发)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
|
||||
use df_ai::ai_tools::{object_schema, AiToolRegistry, GoalMeta, GoalStyle, RiskLevel, ToolMeta};
|
||||
use df_ai::declare_tool;
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::IdeaRecord;
|
||||
@@ -61,6 +61,13 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
"create_idea",
|
||||
"捕获一个新灵感",
|
||||
RiskLevel::Medium,
|
||||
meta: ToolMeta {
|
||||
entity_action: Some(("idea", "create")),
|
||||
approval_hint: Some(("捕获灵感:{}", &["title"])),
|
||||
display_verb: Some("捕获灵感"),
|
||||
goal: Some(GoalMeta { verb: "捕获灵感", keys: &["title"], style: GoalStyle::Raw, fallback: None }),
|
||||
..Default::default()
|
||||
},
|
||||
schema: object_schema(vec![
|
||||
("title", "string", true), ("description", "string", false),
|
||||
("tags", "string", false), ("source", "string", false),
|
||||
@@ -99,6 +106,11 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
"update_idea",
|
||||
"更新灵感的指定字段(title/description/priority/tags/source/score 等),需要提供灵感 ID、字段名和新值。",
|
||||
RiskLevel::Medium,
|
||||
meta: ToolMeta {
|
||||
entity_action: Some(("idea", "update")),
|
||||
display_verb: Some("修改灵感"),
|
||||
..Default::default()
|
||||
},
|
||||
schema: object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]),
|
||||
args => {
|
||||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||||
@@ -124,6 +136,11 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
"delete_idea",
|
||||
"删除灵感(软删除,移入回收站,可恢复)",
|
||||
RiskLevel::Medium,
|
||||
meta: ToolMeta {
|
||||
entity_action: Some(("idea", "delete")),
|
||||
display_verb: Some("删除灵感"),
|
||||
..Default::default()
|
||||
},
|
||||
schema: object_schema(vec![("id", "string", true)]),
|
||||
args => {
|
||||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||||
|
||||
@@ -26,3 +26,7 @@ pub mod trash;
|
||||
pub mod file;
|
||||
pub mod download_file;
|
||||
pub mod entity_resolve;
|
||||
pub mod read_skill;
|
||||
// spawn_agent 子代理派发(独立 agentic 循环入口,audit 与 ai_approve 经本模块
|
||||
// scope_caller_conv 注入父会话 task-local,故需 pub 可达)。
|
||||
pub mod spawn_agent;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
|
||||
use df_ai::ai_tools::{object_schema, AiToolRegistry, GoalMeta, GoalStyle, RiskLevel, ToolMeta};
|
||||
use df_ai::declare_tool;
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::ProjectRecord;
|
||||
@@ -42,6 +42,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
"update_project",
|
||||
"更新项目的指定字段(name/status/description/path/stack),需要提供项目 ID、字段名和新值。绑定代码目录推荐改用 bind_directory",
|
||||
RiskLevel::Medium,
|
||||
meta: ToolMeta {
|
||||
entity_action: Some(("project", "update")),
|
||||
approval_hint: Some(("修改项目{}字段「{}」", &["id", "field"])),
|
||||
display_verb: Some("修改项目"),
|
||||
..Default::default()
|
||||
},
|
||||
schema: object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]),
|
||||
args => {
|
||||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||||
@@ -69,6 +75,13 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
"create_project",
|
||||
"创建新项目,可选传 path/stack 一步完成创建+绑定目录(无需再调 bind_directory)",
|
||||
RiskLevel::Medium,
|
||||
meta: ToolMeta {
|
||||
entity_action: Some(("project", "create")),
|
||||
approval_hint: Some(("创建项目:「{}」", &["name"])),
|
||||
display_verb: Some("创建项目"),
|
||||
goal: Some(GoalMeta { verb: "创建项目", keys: &["name"], style: GoalStyle::Raw, fallback: None }),
|
||||
..Default::default()
|
||||
},
|
||||
schema: object_schema(vec![
|
||||
("name", "string", true), ("description", "string", false),
|
||||
("path", "string", false), ("stack", "string", false),
|
||||
@@ -104,6 +117,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
"bind_directory",
|
||||
"为项目绑定代码目录(自动探测技术栈,防重复绑定)。path 应为**项目根路径**(单仓库=仓库根;多工程 monorepo=工作区根,非单个子工程路径;子工程信息走 list_project_modules/update_project_module 维护)。多次调用会覆盖 path 字段,monorepo 场景勿为每个子工程重复绑定。可选 stack 手动指定技术栈(如 'Go / Gin / Vue3'),不传则自动探测。",
|
||||
RiskLevel::Medium,
|
||||
meta: ToolMeta {
|
||||
entity_action: Some(("project", "update")),
|
||||
approval_hint: Some(("绑定目录:{}(项目{})", &["path", "id"])),
|
||||
display_verb: Some("绑定目录"),
|
||||
..Default::default()
|
||||
},
|
||||
schema: object_schema(vec![("id", "string", true), ("path", "string", true), ("stack", "string", false)]),
|
||||
args => {
|
||||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id 参数"))?;
|
||||
@@ -126,6 +145,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
"delete_project",
|
||||
"删除项目(移入回收站,可恢复。永久删除用 purge_project)",
|
||||
RiskLevel::High,
|
||||
meta: ToolMeta {
|
||||
entity_action: Some(("project", "delete")),
|
||||
approval_hint: Some(("删除项目{}", &["id"])),
|
||||
display_verb: Some("删除项目"),
|
||||
..Default::default()
|
||||
},
|
||||
schema: object_schema(vec![("id", "string", true)]),
|
||||
args => {
|
||||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||||
@@ -142,6 +167,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
"restore_project",
|
||||
"从回收站恢复已删除项目",
|
||||
RiskLevel::High,
|
||||
meta: ToolMeta {
|
||||
entity_action: Some(("project", "update")),
|
||||
approval_hint: Some(("从回收站恢复项目{}", &["id"])),
|
||||
display_verb: Some("恢复项目"),
|
||||
..Default::default()
|
||||
},
|
||||
schema: object_schema(vec![("id", "string", true)]),
|
||||
args => {
|
||||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||||
@@ -158,6 +189,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
"purge_project",
|
||||
"永久删除项目及关联数据(不可恢复)",
|
||||
RiskLevel::High,
|
||||
meta: ToolMeta {
|
||||
entity_action: Some(("project", "delete")),
|
||||
approval_hint: Some(("永久删除项目及关联数据,不可恢复{}", &["id"])),
|
||||
display_verb: Some("永久删除项目"),
|
||||
..Default::default()
|
||||
},
|
||||
schema: object_schema(vec![("id", "string", true)]),
|
||||
args => {
|
||||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||||
|
||||
@@ -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(®istry, &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"));
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
|
||||
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel, ToolMeta};
|
||||
use df_ai::declare_tool;
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::TaskRecord;
|
||||
@@ -67,6 +67,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
"create_task",
|
||||
"在指定项目下创建新任务,可选传 idea_id 关联灵感(1对1 单向)。知识图谱 Phase 1 扩展三个可选参数:queue(管理维度池 backlog/todo/decision,默认 todo,新建不可直接落 active/done 须经 move_task_queue 流转)、parent_id(父任务 ID,限 1 级嵌套,父任务自身不能有 parent_id,IPC 层校验防孙任务)、content_json(结构化需求规格 JSON 字符串 {background,acceptance_criteria[],scope[],technical_design},须合法 JSON)",
|
||||
RiskLevel::Medium,
|
||||
meta: ToolMeta {
|
||||
entity_action: Some(("task", "create")),
|
||||
approval_hint: Some(("创建任务:{}(项目{})", &["title", "project_id"])),
|
||||
display_verb: Some("创建任务"),
|
||||
..Default::default()
|
||||
},
|
||||
schema: object_schema(vec![
|
||||
("project_id", "string", true), ("title", "string", true),
|
||||
("description", "string", false), ("priority", "integer", false),
|
||||
@@ -129,6 +135,11 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
"update_task",
|
||||
"更新任务的指定字段(title/description/priority/assignee 等),需要提供任务 ID、字段名和新值。注意:status 改动须走 advance_task 工具(状态机推进,8 态 todo/in_progress/in_review/testing/blocked/done/cancelled/deferred,按 target_status 推进不可跳跃),本工具不接受 status 字段(防绕过状态机)",
|
||||
RiskLevel::Medium,
|
||||
meta: ToolMeta {
|
||||
entity_action: Some(("task", "update")),
|
||||
display_verb: Some("修改任务"),
|
||||
..Default::default()
|
||||
},
|
||||
schema: object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]),
|
||||
args => {
|
||||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||||
@@ -168,6 +179,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
"advance_task",
|
||||
"推进任务状态,必须走状态机(8 态: todo/in_progress/in_review/testing/blocked/done/cancelled/deferred),按 target_status 推进,不可跳跃(非法转换会被状态机拦截)。退回转换(in_review→in_progress 等)自动累加 review_rounds。返回推进后的最新 TaskRecord",
|
||||
RiskLevel::Medium,
|
||||
// display_verb 刻意不设:轻量标签缺位让审批文案走模板(含 target_status 详情),与旧表一致
|
||||
meta: ToolMeta {
|
||||
entity_action: Some(("task", "update")),
|
||||
approval_hint: Some(("推进任务状态:{} → {}", &["id", "target_status"])),
|
||||
..Default::default()
|
||||
},
|
||||
schema: object_schema(vec![("id", "string", true), ("target_status", "string", true)]),
|
||||
args => {
|
||||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||||
@@ -211,6 +228,11 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
"delete_task",
|
||||
"删除任务(移入回收站,可恢复)",
|
||||
RiskLevel::High,
|
||||
meta: ToolMeta {
|
||||
entity_action: Some(("task", "delete")),
|
||||
display_verb: Some("删除任务"),
|
||||
..Default::default()
|
||||
},
|
||||
schema: object_schema(vec![("id", "string", true)]),
|
||||
args => {
|
||||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
|
||||
use df_ai::ai_tools::{object_schema, AiToolRegistry, GoalMeta, GoalStyle, RiskLevel, ToolMeta};
|
||||
use df_ai::declare_tool;
|
||||
|
||||
/// 注册 run_workflow 工具到 `$registry`(无 db 捕获,防御兜底)。
|
||||
@@ -35,6 +35,12 @@ pub fn register(registry: &mut AiToolRegistry) {
|
||||
"run_workflow",
|
||||
"按任务 target_status 推进对应工作流(含 AiNode 自审 / HumanNode 核对闸门)。参数 task_id + target_status 同时提供才联动任务推进(完成后按 target_status 推进任务,失败按退回态回滚)。属高风险操作(触发工作流引擎执行),须人工批准。审批通过后由后端直接执行工作流引擎并联动推进任务,返回 execution_id",
|
||||
RiskLevel::High,
|
||||
meta: ToolMeta {
|
||||
approval_hint: Some(("触发工作流:任务{} 推进到 {}", &["task_id", "target_status"])),
|
||||
display_verb: Some("触发工作流"),
|
||||
goal: Some(GoalMeta { verb: "执行工作流", keys: &[], style: GoalStyle::Raw, fallback: Some("执行工作流") }),
|
||||
..Default::default()
|
||||
},
|
||||
schema: object_schema(vec![("task_id", "string", true), ("target_status", "string", true)]),
|
||||
args => {
|
||||
let task_id = args["task_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 task_id"))?;
|
||||
|
||||
@@ -27,6 +27,7 @@ use df_types::types::new_id;
|
||||
use df_project::scan::detect_stack;
|
||||
// 文件符号概览(file_outline)复用 code_intel 的 AST 符号提取
|
||||
use crate::commands::ai::code_intel;
|
||||
use crate::commands::ai::tool_registry::{is_noise_entry, NoiseDirTier};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -652,22 +653,12 @@ fn run_git_cmd(cwd: &std::path::Path, args: &[&str], timeout: std::time::Duratio
|
||||
// 设计要点(对标设计 §五 + Batch 10):
|
||||
// - **路径安全**:sub_path / file_path 均拒含 `..`(防目录穿越)。先 join 再 canonicalize
|
||||
// 校验结果仍位于 module.path 子树内(双重防御,即便 `..` 漏网也无法逃出工程根)。
|
||||
// - **噪音过滤**:`get_module_file_tree` 跳过 node_modules/target/.git/__pycache__/dist/.vite
|
||||
// (对齐需求清单,前端文件树不展示构建产物/VCS 元数据)。
|
||||
// - **噪音过滤**:`get_module_file_tree` 经 `is_noise_entry`(FileTree 档)过滤依赖/缓存目录,
|
||||
// 隐藏文件(`.` 开头)另有过滤;构建产物 build 与 venv 保留展示给用户。
|
||||
// - **Git 状态合并**:跑 `git status --porcelain` 得 {rel_path: status} 映射,按条目相对路径
|
||||
// 查表填充 git_status。文件夹一律 git_status = None(只标文件,符合需求)。
|
||||
// - **二进制检测**:read_module_file 读 1MB 上限,前 8KB 含 \0 视作二进制(is_binary=true,content 留空)。
|
||||
|
||||
/// 噪音目录名(列表级过滤,read_dir 看到这些名字跳过)。
|
||||
const NOISE_DIRS: &[&str] = &[
|
||||
"node_modules",
|
||||
"target",
|
||||
".git",
|
||||
"__pycache__",
|
||||
"dist",
|
||||
".vite",
|
||||
];
|
||||
|
||||
/// 判断目录是否含可见子项(过滤隐藏文件/噪音目录后,与 `get_module_file_tree` 展开实际展示一致)。
|
||||
///
|
||||
/// 供 `FileTreeEntry.has_children` 用:前端未展开目录即可据此在行内标注空目录,
|
||||
@@ -684,7 +675,7 @@ fn dir_has_visible_children(dir: &Path) -> bool {
|
||||
if name.starts_with('.') {
|
||||
return false;
|
||||
}
|
||||
if NOISE_DIRS.iter().any(|n| *n == name.as_ref()) {
|
||||
if is_noise_entry(&name, NoiseDirTier::FileTree) {
|
||||
return false;
|
||||
}
|
||||
// 与列目录一致:无法判定类型的条目(断链符号链接等)视为不可见
|
||||
@@ -786,8 +777,8 @@ pub async fn get_module_file_tree(
|
||||
for entry in read.flatten() {
|
||||
let file_name = entry.file_name();
|
||||
let name = file_name.to_string_lossy().to_string();
|
||||
// 噪音过滤(无论文件/目录,凡名命中即跳)。
|
||||
if NOISE_DIRS.iter().any(|n| *n == name.as_str()) {
|
||||
// 噪音过滤(无论文件/目录,凡名命中即跳;名单统一走 is_noise_entry FileTree 档)。
|
||||
if is_noise_entry(&name, NoiseDirTier::FileTree) {
|
||||
continue;
|
||||
}
|
||||
let ft = match entry.file_type() {
|
||||
@@ -1131,8 +1122,8 @@ pub async fn scan_project_modules(
|
||||
let ft = match entry.file_type() { Ok(t) => t, Err(_) => continue };
|
||||
if !ft.is_dir() { continue; }
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
// 跳过隐藏目录和噪音目录
|
||||
if name.starts_with('.') || name == "node_modules" || name == "target" || name == "__pycache__" || name == "dist" {
|
||||
// 跳过隐藏目录和噪音目录(名单统一走 is_noise_entry FileTree 档)
|
||||
if name.starts_with('.') || is_noise_entry(&name, NoiseDirTier::FileTree) {
|
||||
continue;
|
||||
}
|
||||
let child_path = entry.path();
|
||||
|
||||
@@ -127,7 +127,7 @@ pub async fn set_plan_execution(enabled: bool) -> Result<(), String> {
|
||||
|
||||
/// 读取多 ReAct Plan 执行开关状态。
|
||||
#[tauri::command]
|
||||
pub async fn get_plan_execution() -> Result<bool, String> {
|
||||
pub async fn is_plan_execution_enabled() -> Result<bool, String> {
|
||||
Ok(df_ai::plan_executor::plan_execution_enabled())
|
||||
}
|
||||
|
||||
@@ -234,7 +234,6 @@ pub async fn resolve_conflict(
|
||||
resolved_by: String,
|
||||
conversation_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
use tauri::Emitter;
|
||||
let repo = df_storage::crud::ConflictRepo::new(&state.db);
|
||||
let now = df_types::now_millis().to_string();
|
||||
let ok = repo.resolve(&conflict_id, &resolution, &resolved_by, &now)
|
||||
@@ -249,7 +248,7 @@ pub async fn resolve_conflict(
|
||||
resolved_by = %resolved_by,
|
||||
"[CONFLICT] 冲突已解决"
|
||||
);
|
||||
// emit AiConflictResolved 事件
|
||||
// emit AiConflictResolved 事件(双写收敛经 emit_ai_chat_event)
|
||||
let ev = crate::commands::ai::AiChatEvent::AiConflictResolved {
|
||||
conflict_id,
|
||||
plan_id,
|
||||
@@ -257,7 +256,6 @@ pub async fn resolve_conflict(
|
||||
resolved_by,
|
||||
conversation_id,
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = state.ai_event_bus.publish_event(ev);
|
||||
crate::commands::ai::event_bus::emit_ai_chat_event(&app_handle, ev);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ struct WorkflowEventPayload {
|
||||
/// broadcast `Lagged` 不暴露被丢事件类型,关键终态事件(WorkflowCompleted/Failed)可能已丢,
|
||||
/// forward 循环会永久等不到。达阈值时查 DB 终态补发。三种"未命中"(仍 running / 无记录 /
|
||||
/// 查询失败)统一返回 None 并各自 warn,主循环据此重置 `lagged_total` 继续等。
|
||||
async fn probe_lagged_terminal(
|
||||
async fn probe_missed_terminal_event(
|
||||
db: &df_storage::db::Database,
|
||||
exec_id: &ExecutionId,
|
||||
) -> Option<(WorkflowEvent, String)> {
|
||||
@@ -264,8 +264,8 @@ pub async fn run_workflow_inner(
|
||||
if lagged_total < LAGGED_PROBE_THRESHOLD {
|
||||
continue;
|
||||
}
|
||||
// 累计/单次达阈值:查 DB 终态兜底(probe_lagged_terminal 收口三种未命中情况)
|
||||
match probe_lagged_terminal(&forward_db, &forward_exec_id).await {
|
||||
// 累计/单次达阈值:查 DB 终态兜底(probe_missed_terminal_event 收口三种未命中情况)
|
||||
match probe_missed_terminal_event(&forward_db, &forward_exec_id).await {
|
||||
Some((synth_event, status)) => {
|
||||
let payload = WorkflowEventPayload {
|
||||
execution_id: forward_exec_id.clone(),
|
||||
|
||||
+31
-11
@@ -28,6 +28,9 @@ impl tracing_subscriber::fmt::time::FormatTime for LocalTimer {
|
||||
// Phase3 跨端隧道:TunnelClient trait(connect/send_raw_event/disconnect 方法)需在作用域内
|
||||
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)]
|
||||
pub fn run() {
|
||||
// 初始化文件日志:追加到 %TEMP%/devflow-trace.log,RUST_LOG 控制级别(默认 info)
|
||||
@@ -56,6 +59,8 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
// P1 bg 化:系统通知插件(Rust 侧 notify.rs 条件发送:主窗可见聚焦不发 + 同类节流 + 失败静默)
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
// Phase3 单实例:二次启动不新开进程,唤起已有实例主窗口(防多实例抢 MCP 18765 端口)
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
tray::show_main(app);
|
||||
@@ -79,6 +84,9 @@ pub fn run() {
|
||||
// L0 握手 — 提前 clone ai_session(Arc),因 manage() 会 move app_state
|
||||
let session_for_handshake = app_state.ai_session.clone();
|
||||
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 + 托盘常驻 + 单实例 ──
|
||||
// 1) MCP 运行时状态:先 manage 初始 running=false,spawn 绑定成功后更新为 true。
|
||||
@@ -200,20 +208,15 @@ pub fn run() {
|
||||
}
|
||||
};
|
||||
// relay_url:KV 取,无则默认本地(localhost:8080,本地开发 df-relay 默认监听)。
|
||||
// settings value 契约模糊(JSON 序列化带引号或裸值并存,见 SettingsRepo get/set 不解析 JSON):
|
||||
// 统一解析去引号 —— serde_json::from_str 处理 JSON 字符串值,失败(裸值)则 trim 首尾引号兜底,
|
||||
// settings value 契约模糊(JSON 序列化带引号或裸值并存):经 get_string_norm
|
||||
// 归一去引号(此处 from_str 写法已收敛为 state.rs SettingsNorm 正典,2026-10-01 DRY #2),
|
||||
// 确保拿到纯净 URL 供 connect_async(带引号 URL 解析失败致 connect 静默失败,relay 无 device 接入)。
|
||||
use state::SettingsNorm;
|
||||
let relay_url = state
|
||||
.settings
|
||||
.get("relay_url")
|
||||
.get_string_norm("relay_url")
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|raw| {
|
||||
serde_json::from_str::<String>(&raw)
|
||||
.unwrap_or_else(|_| raw.trim_matches('"').to_string())
|
||||
})
|
||||
.unwrap_or_else(|| "ws://localhost:8080/ws/device".to_string());
|
||||
// token:固定常量(三端同源,对齐 df-relay relay.rs:37 DEFAULT_TOKEN)
|
||||
let token = "devflow-relay-default-token".to_string();
|
||||
@@ -499,7 +502,7 @@ pub fn run() {
|
||||
commands::settings::set_script_safety,
|
||||
// Plan 执行开关
|
||||
commands::settings::set_plan_execution,
|
||||
commands::settings::get_plan_execution,
|
||||
commands::settings::is_plan_execution_enabled,
|
||||
// 冲突解决
|
||||
commands::settings::resolve_conflict,
|
||||
// 模板管理
|
||||
@@ -514,13 +517,30 @@ pub fn run() {
|
||||
// 关主窗口不退出,隐藏到托盘常驻(托盘「退出」菜单才真正结束进程)。
|
||||
// 仅拦 main:approval-popup / ai-detached / fe-detached 等 JS 创建窗口必须放行
|
||||
// 真正关闭,否则 close 被吞成隐藏僵尸窗,浮窗「正在关闭…」卡死、新审批推给不可见窗口。
|
||||
// P1 bg 化:有 AI 会话运行中时,首次关窗 emit tray-hide-notice 提示后台仍在跑(每进程一次),
|
||||
// 异步等 2 秒再 hide(同步 sleep 卡主线程渲染,气泡画不出来);无运行会话保持立即 hide 零回归。
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
if window.label() != "main" {
|
||||
return; // 非主窗口放行真正销毁
|
||||
}
|
||||
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!())
|
||||
|
||||
+182
-4
@@ -31,7 +31,7 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
@@ -52,6 +52,82 @@ use crate::commands::ai::augmentation::ResolverRegistry;
|
||||
use crate::commands::ai::AiSession;
|
||||
use df_types::types::ExecutionId;
|
||||
|
||||
// ============================================================
|
||||
// Settings KV 引号归一读取(DRY #2 收敛,2026-10-01)
|
||||
//
|
||||
// 背景:前端 appSettings.set 以 JSON.stringify 落库(字符串值带引号),但 KV 里也并存
|
||||
// 历史裸值,读侧此前散布三种归一策略(trim_matches / serde_json::from_str / bool 比对),
|
||||
// 已漂移出行为差异(from_str 版能正确处理转义,trim 版不能)。
|
||||
// 正典 = lib.rs 原 relay_url 的 from_str 版,收敛为本 trait 两个方法,新代码禁止手抄归一。
|
||||
// ============================================================
|
||||
|
||||
/// SettingsRepo 的引号归一读扩展(KV 值契约模糊:JSON 序列化带引号或裸值并存)。
|
||||
///
|
||||
/// 只加只读归一方法,不碰 SettingsRepo 本身的 get/set 语义。
|
||||
pub trait SettingsNorm {
|
||||
/// 读字符串值并归一去引号。
|
||||
///
|
||||
/// 策略(正典,原 lib.rs relay_url 写法):先按 JSON 字符串解析(正确处理 `\` 转义),
|
||||
/// 失败(裸值)再 trim 首尾 `"` 兜底。key 缺失或读库失败返 None(调用方决定默认值)。
|
||||
async fn get_string_norm(&self, key: &str) -> Option<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>` 取用
|
||||
pub struct AppState {
|
||||
/// 数据库句柄(Arc 包装,便于在异步任务中重建 Repo)
|
||||
@@ -125,6 +201,14 @@ pub struct AppState {
|
||||
/// guard/reset/ai_is_generating 全部零锁操作。PerConvState.conv_state 字段已删。
|
||||
/// 治卡死连环(AiCompleted 延迟 / 工具后中断 / 第二条进队列同源根因)。
|
||||
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
|
||||
pub knowledge: KnowledgeRepo,
|
||||
@@ -168,6 +252,14 @@ pub struct AppState {
|
||||
/// workspace_root 始终在白名单(向后兼容)。
|
||||
/// Phase B 将扩展为 persistent + session(会话临时授权) + 弹窗挂起。
|
||||
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)──
|
||||
/// 桌面端出站 WS 隧道客户端,连 df-relay(/ws/device)透传 ai_event_bus → miniapp
|
||||
/// + 转发 miniapp 下行指令 → remote_bridge。lib.rs setup spawn task 解析
|
||||
@@ -261,9 +353,26 @@ impl AppState {
|
||||
gateway_url: std::env::var("DF_LOCAL_PROXY_BASE")
|
||||
.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(
|
||||
&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。
|
||||
// resolver 持 Arc<Database>(非 AppState,避免循环依赖:AppState 持 Arc<ResolverRegistry>),
|
||||
// 在 struct 字段 `db` move 前 clone 注入,与 build_ai_tool_registry 同侧。
|
||||
@@ -288,11 +397,15 @@ impl AppState {
|
||||
ai_conversations: AiConversationRepo::new(&db),
|
||||
ai_messages: AiMessageRepo::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。
|
||||
ai_event_bus: crate::commands::ai::event_bus::EventBus::new(),
|
||||
// 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
|
||||
tunnel: std::sync::Arc::new(df_tunnel::WsTunnelClient::new()),
|
||||
knowledge: KnowledgeRepo::new(&db),
|
||||
@@ -308,6 +421,9 @@ impl AppState {
|
||||
workflow_state_registry: Arc::new(Mutex::new(HashMap::new())),
|
||||
// F-260619-03 Phase A: 与 ai_tools registry 共享同一 Arc(构建时注入同一句柄)
|
||||
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,
|
||||
event_bus: EventBus::new(),
|
||||
registry,
|
||||
@@ -579,6 +695,46 @@ impl AppState {
|
||||
pub async fn clear_once_allowed_dirs(&self) {
|
||||
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):
|
||||
// 决策 a(AiNode 自审闭环)步骤②:AiNode 持 Arc<Database>,execute 完成后若有 task_id
|
||||
// 则把产出落 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();
|
||||
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()))
|
||||
}
|
||||
});
|
||||
// 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):
|
||||
// 决策 a 步骤③:四维度自审(prompt 固定+JSON 解析兜底),写回 output_json 加 review 子字段,
|
||||
|
||||
@@ -133,6 +133,18 @@ impl LlmConcurrency {
|
||||
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)。
|
||||
/// lock HashMap → 无则建 → clone Arc → 释放 lock → acquire_owned。锁持有短(不含 await acquire)。
|
||||
pub async fn acquire_per_conv(&self, conv_id: &str) -> tokio::sync::OwnedSemaphorePermit {
|
||||
|
||||
+44
-8
@@ -1,24 +1,34 @@
|
||||
//! 托盘常驻:图标 + 菜单[显示主窗口/退出]。关窗口不退出,托盘退出才真正结束进程。
|
||||
//! 托盘常驻:图标 + 菜单[运行态(纯展示)/显示主窗口/退出]。关窗口不退出,托盘退出才真正结束进程。
|
||||
//!
|
||||
//! - 主窗口 CloseRequested 被 lib.rs 拦截(prevent_close + hide),进程常驻托盘
|
||||
//! - 托盘菜单「显示主窗口」唤起 / 「退出」app.exit(0) 真正结束
|
||||
//! - TrayHandle 经 app.manage 保活,防托盘图标被 GC 移除
|
||||
//! - P1 bg 化:运行态菜单项 + tooltip 随 ConvState 迁移刷新(guard.transition 挂钩 update_tray_runtime)
|
||||
use tauri::{
|
||||
AppHandle, Manager,
|
||||
menu::{Menu, MenuItem},
|
||||
tray::TrayIconBuilder,
|
||||
};
|
||||
|
||||
/// managed state 保活,防 GC 移除托盘图标。
|
||||
/// 字段不读仅用于持有 TrayIcon(保活);零调用方≠垃圾,预留保留(对齐 state.rs releases 标法)。
|
||||
#[allow(dead_code)]
|
||||
pub struct TrayHandle(pub tauri::tray::TrayIcon);
|
||||
use crate::state::AppState;
|
||||
|
||||
/// 创建托盘:图标 + 菜单(显示主窗口/退出)。
|
||||
/// 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<()> {
|
||||
// 运行态纯展示项: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 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 提示配置
|
||||
let icon = app
|
||||
.default_window_icon()
|
||||
@@ -35,10 +45,36 @@ pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
|
||||
_ => {}
|
||||
})
|
||||
.build(app)?;
|
||||
app.manage(TrayHandle(tray));
|
||||
app.manage(TrayHandle { tray, status });
|
||||
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(单实例二次启动 / 托盘菜单共用)。
|
||||
pub(crate) fn show_main(app: &AppHandle) {
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
|
||||
+31
@@ -34,6 +34,12 @@
|
||||
<div v-if="errorMsg" class="df-toast">⚠ {{ errorMsg }}</div>
|
||||
</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 自身不挂)。
|
||||
应用失焦/最小化时的系统级提示由 useApprovalPopup 启动的独立浮窗(approval-popup)负责。 -->
|
||||
@@ -101,6 +107,12 @@ watch(() => projectStore.error, (err) => {
|
||||
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 审批时,任意页面都应能响应)──
|
||||
// 原 ProjectDetail 局部渲染 → TaskDetail 无 ApprovalDialog 致 store.pendingApproval 被设但
|
||||
// 用户在 TaskDetail 页无法操作 → HumanNode 超时失败。提到 App.vue 全局化:任何路由响应审批。
|
||||
@@ -208,6 +220,20 @@ onMounted(async () => {
|
||||
} catch (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)。
|
||||
// 设计见 useApprovalPopup.ts:主窗口 watch pendingApprovals → 弹/更新/关浮窗(单一真相源)。
|
||||
if (route.path !== '/approval-popup') {
|
||||
@@ -274,6 +300,9 @@ onUnmounted(() => {
|
||||
_unlistenPopupReady?.()
|
||||
_stopApprovalPopup = null
|
||||
_unlistenPopupReady = null
|
||||
_unlistenTrayHideNotice?.()
|
||||
_unlistenTrayHideNotice = null
|
||||
if (trayNoticeTimer) clearTimeout(trayNoticeTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -294,4 +323,6 @@ onUnmounted(() => {
|
||||
}
|
||||
.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); }
|
||||
/* 后台运行提示:同 df-toast 形态,信息语义用 accent 底色与错误红区分 */
|
||||
.df-toast--info { background: var(--df-accent); }
|
||||
</style>
|
||||
|
||||
+10
-5
@@ -103,8 +103,8 @@ export const aiApi = {
|
||||
|
||||
/** 查询某对话积压的待审批工具(重启后恢复 toolCard pending_approval 态用)。
|
||||
* 返 kind 字段(risk/path),前端按 kind 渲染不同决策按钮。 */
|
||||
pendingToolCalls(convId: string): Promise<{ tool_call_id: string; conversation_id: string | null; kind: 'risk' | 'path' }[]> {
|
||||
return invoke('ai_pending_tool_calls', { convId })
|
||||
pendingToolCalls(conversationId: string): Promise<{ tool_call_id: string; conversation_id: string | null; kind: 'risk' | 'path' }[]> {
|
||||
return invoke('ai_pending_tool_calls', { conversationId })
|
||||
},
|
||||
|
||||
/** 清空对话 */
|
||||
@@ -251,9 +251,14 @@ export const aiApi = {
|
||||
return invoke('ai_set_agent_max_retries', { value })
|
||||
},
|
||||
|
||||
/** 列出本机 Claude 技能(skills + commands + plugins) */
|
||||
listSkills(): Promise<SkillInfo[]> {
|
||||
return invoke('ai_list_skills')
|
||||
/**
|
||||
* 列出本机 Claude 技能(project + skills + commands + plugins)。
|
||||
* @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
@@ -38,6 +38,6 @@ export const settingsApi = {
|
||||
|
||||
/** 读取多 ReAct Plan 执行开关 */
|
||||
getPlanExecution(): Promise<boolean> {
|
||||
return invoke<boolean>('get_plan_execution')
|
||||
return invoke<boolean>('is_plan_execution_enabled')
|
||||
},
|
||||
}
|
||||
|
||||
+18
-1
@@ -418,6 +418,10 @@ export type AiChatEvent = ({
|
||||
// AE-2025-04 会话级信任:同会话已批准过同类操作(同工具+同目录)自动放行,前端显示轻量 toast。
|
||||
// 不写消息/不动 pending(Started/Completed 仍独立发),仅提示性事件。
|
||||
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'(F-260619-03 Phase B 路径授权挂起,走 once/session/always/deny)vs 'risk'(普通 RiskLevel 审批,
|
||||
@@ -523,7 +527,20 @@ export interface AiMessage {
|
||||
id: MessageId
|
||||
role: 'user' | 'assistant' | 'tool' | 'system'
|
||||
content: string
|
||||
/**
|
||||
* 推理过程文本(DeepSeek reasoning_content / Anthropic thinking 同通道,后端落库
|
||||
* ai_messages.reasoning_content)。undefined=该轮无思考内容。历史消息 reload 时
|
||||
* 由 useAiConversations parseConvMessages 透传;思考 pill 以此为数据源,content 兜底。
|
||||
*/
|
||||
reasoning_content?: string
|
||||
isError?: boolean
|
||||
/**
|
||||
* 流式重试过渡标记(AiStreamRetry 推送的过渡气泡,前端内存专用不落库)。
|
||||
* true=重试进行中(MessageList 渲染为琥珀单行细条,不走错误红卡);终态错误
|
||||
* (AiError)不带此标记,仍走错误卡与重试按钮。会话收尾清残留按此标记匹配,
|
||||
* 不再依赖文案子串(多语言切换不影响清理)。
|
||||
*/
|
||||
isRetry?: boolean
|
||||
/**
|
||||
* 消息上下文状态(F-15 手动上下文管理)。
|
||||
* null | undefined | 'active' → 正常消息
|
||||
@@ -772,7 +789,7 @@ export interface SkillInfo {
|
||||
name: string
|
||||
description: string
|
||||
argument_hint?: string
|
||||
/** skill | command | plugin */
|
||||
/** skill | project | command | plugin(project 为项目级 .claude/skills 扫描来源) */
|
||||
source: string
|
||||
/** SKILL.md 绝对路径(后端注入时读全文) */
|
||||
path: string
|
||||
|
||||
@@ -195,6 +195,8 @@ let _unlistenToolSlow: (() => void) | null = null
|
||||
let _unlistenAutoApproved: (() => void) | null = null
|
||||
// P1-e-d: 审批失败 toast unlistener(useAiApproval 审批 IPC 未送达经事件总线 emit,同 _unlistenToolSlow 模式)
|
||||
let _unlistenAiToast: (() => void) | null = null
|
||||
// 技能注入反馈 toast unlistener(后端 AiSkillInjected 经 ai-chat-event 直达,见 onMounted)
|
||||
let _unlistenSkillInjected: (() => void) | null = null
|
||||
|
||||
// 第二批抽离: inputText/inputEl/图片输入/技能/实体联想/handleSend 已迁移至 ChatInput.vue
|
||||
// chatInputRef: 父调 expose(focus/startEditFocus/clearInput/hasUnsentInput/sendExamplePrompt)
|
||||
@@ -477,6 +479,7 @@ onBeforeUnmount(() => {
|
||||
if (_unlistenToolSlow) { _unlistenToolSlow(); _unlistenToolSlow = null } // B-260616-12
|
||||
if (_unlistenAutoApproved) { _unlistenAutoApproved(); _unlistenAutoApproved = null } // AE-2025-04
|
||||
if (_unlistenAiToast) { _unlistenAiToast(); _unlistenAiToast = null } // P1-e-d
|
||||
if (_unlistenSkillInjected) { _unlistenSkillInjected(); _unlistenSkillInjected = null } // 技能注入反馈 toast
|
||||
// toast timer 清理由 useToast composable 的 onUnmounted 自动管理
|
||||
})
|
||||
|
||||
@@ -520,6 +523,16 @@ onMounted(async () => {
|
||||
_unlistenAiToast = await listen<{ msg: string; type: 'info' | 'error' | 'warning' | 'success' }>('ai-toast', (e) => {
|
||||
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/刷新)
|
||||
await initContextEventListeners()
|
||||
// 恢复生成中态(刷新后/分离窗口接管):
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
<div class="ai-tool-header-text">
|
||||
<span class="ai-tool-name">{{ toolDisplayName(tc) }}</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="toolResultSummary(tc)" class="ai-tool-sub">{{ toolResultSummary(tc) }}</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" :title="summaryTitle">{{ toolResultSummary(tc) }}</span>
|
||||
</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)。
|
||||
hover 显绝对起止时刻,相对时间不再占卡片行(B 聚合卡统一)。 -->
|
||||
<span
|
||||
@@ -144,7 +144,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef } from 'vue'
|
||||
import { computed, ref, toRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
shouldKeepOpen,
|
||||
@@ -152,12 +152,14 @@ import {
|
||||
toolCategory,
|
||||
toolIcon,
|
||||
toolResultSummary,
|
||||
argString,
|
||||
} from '@/composables/ai/useToolCard'
|
||||
import { parseDiffLines } from '@/composables/ai/useToolCardRender'
|
||||
import { useToolCardHeader } from '@/composables/ai/useToolCardHeader'
|
||||
import { useToolApproval } from '@/composables/ai/useToolApproval'
|
||||
import { useTimerOwnership } from '@/composables/useTimerOwnership'
|
||||
import { callDurationText } from '@/composables/ai/useTurnTrace'
|
||||
import { formatDate } from '@/utils/time'
|
||||
import { formatDate, formatDuration } from '@/utils/time'
|
||||
import type { AiToolCallInfo } from '@/api/types'
|
||||
import ConfirmDialog from './ConfirmDialog.vue'
|
||||
import ToolResultBody from './ToolResultBody.vue'
|
||||
@@ -241,14 +243,57 @@ const isFailed = computed(() => {
|
||||
/** write_file 审批 diff 行解析(复用 parseDiffLines 纯函数,数据源 props.tc.diff) */
|
||||
const diffLines = computed(() => parseDiffLines(props.tc.diff))
|
||||
|
||||
/** 单卡耗时(前端时间差;历史消息无 startedAt/completedAt 返空串隐藏) */
|
||||
const durationText = computed(() => callDurationText(props.tc.startedAt, props.tc.completedAt))
|
||||
/** hover 提示:绝对起止时刻(相对时长 hover 可溯具体时间) */
|
||||
// 执行中实时走表:进入 running 每秒刷新 nowTick,离开时清理(定时器经 useTimerOwnership
|
||||
// 注册,onUnmounted 自动全清,防组件卸载后计时器仍触发)
|
||||
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 tc = props.tc
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="ai-tool-calls" ref="rootEl">
|
||||
<!-- 顶部操作栏:批量审批(全局收起已下沉到首个可折叠分组标题行,U-260618) -->
|
||||
<!-- 顶部操作栏:批量审批(轨迹卡 toggle 已覆盖整体展开/收起,分组行仅单组导航) -->
|
||||
<div v-if="pendingCount > 0" class="ai-tool-topbar">
|
||||
<!-- 批量操作栏(存在待审批项时显示) -->
|
||||
<div class="ai-batch-approve">
|
||||
@@ -14,22 +14,12 @@
|
||||
</div>
|
||||
|
||||
<template v-for="(group, gi) in groupedToolCalls" :key="group.name">
|
||||
<!-- 分组标题:同名工具 >=2 个时显示(B 聚合卡统一:默认行「N 次调用 · 成功 x 失败 y」,
|
||||
点开逐条;成功/失败与状态点同一套 isToolFailure 判定,文案走 ai.trace 同源 key) -->
|
||||
<!-- 分组标题:同名工具 >=2 个时显示(仅分组导航语义:点开逐条。
|
||||
调用统计收敛到执行轨迹卡头部一处,分组行不再重复计数) -->
|
||||
<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-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>
|
||||
<!-- 全部展开/收起(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>
|
||||
<ToolCard
|
||||
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 ToolCard from './ToolCard.vue'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { aggregateCalls, aggText } from '@/composables/ai/useTurnTrace'
|
||||
import type { AiToolCallInfo } from '@/api/types'
|
||||
|
||||
// useI18n needed for $t() in template (collapseAll / expandAll) + groupDisplayName(i18n key)
|
||||
@@ -124,32 +113,11 @@ const groupedToolCalls = computed<ToolCallGroup[]>(() => {
|
||||
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'),控制批量操作栏显隐 */
|
||||
const pendingCount = computed(() =>
|
||||
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)且用户未手动操作过的组自动收起。
|
||||
* 响应式而非一次性初始化——流式场景第一张卡到达即触发旧一次性逻辑,
|
||||
@@ -181,24 +149,6 @@ function toggleGroupCollapse(gi: number): void {
|
||||
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 友好标签(英文化,原硬编码中文)。
|
||||
* 用 aiTool.toolGroup.* 子键查表;未命中的工具回退 _ → 空格风格。
|
||||
@@ -228,11 +178,6 @@ function groupDisplayName(group: ToolCallGroup): string {
|
||||
return group.name.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
/** 分组聚合行文案:「N 次调用 · 成功 x 失败 y」(零值项省略;与单卡状态点同源 isToolFailure 判定) */
|
||||
function groupAggText(group: ToolCallGroup): string {
|
||||
return aggText(aggregateCalls(group.calls))
|
||||
}
|
||||
|
||||
/**
|
||||
* 单卡是否展开:
|
||||
* - 分组已折叠 → false(整组隐藏)
|
||||
@@ -414,28 +359,6 @@ function groupIcon(name: string): string {
|
||||
}
|
||||
.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 {
|
||||
display: flex;
|
||||
@@ -464,17 +387,8 @@ function groupIcon(name: string): string {
|
||||
font-weight: 500;
|
||||
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 {
|
||||
/* 移除 margin-left:auto,让全部展开/收起按钮(global-toggle)占据行尾右对齐 */
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
transition: transform 0.15s var(--df-ease);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div v-if="tc.name === 'read_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-file-content">
|
||||
<div class="ai-tool-file-bar">
|
||||
<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,
|
||||
旧模板取 lines/returned_lines 显「0 行」+ 空 content 区,用户误以为读取失败。
|
||||
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 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-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 v-if="parsed?.truncated" class="ai-tool-file-meta ai-tool-file-meta--warn">{{ $t('aiTool.dirTruncated', { n: DIR_TRUNCATE_LIMIT }) }}</span>
|
||||
</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 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-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 class="ai-tool-file-meta">{{ $t('aiTool.foundN', { n: typeof parsed?.total === 'number' ? parsed.total : (parsed?.results?.length || 0) }) }}</span>
|
||||
</div>
|
||||
@@ -77,7 +77,7 @@
|
||||
<div v-else-if="tc.name === 'grep' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-grep-result">
|
||||
<div class="ai-tool-file-bar">
|
||||
<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 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>
|
||||
@@ -114,7 +114,7 @@
|
||||
<!-- write_file 结果 -->
|
||||
<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>
|
||||
<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 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>
|
||||
@@ -125,7 +125,7 @@
|
||||
<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">
|
||||
<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 v-if="parsed?.matches_found && parsed.matches_found > 1" class="ai-tool-file-meta">{{ $t('aiTool.patchedMatches', { n: parsed.matches_found }) }}</span>
|
||||
</div>
|
||||
@@ -231,6 +231,7 @@ import {
|
||||
combineOutputs,
|
||||
formatToolResult,
|
||||
formatGrep,
|
||||
shortPath,
|
||||
dirIcon,
|
||||
fileIcon,
|
||||
searchIcon,
|
||||
|
||||
@@ -21,11 +21,11 @@ import { useMarkdown } from '../../composables/useMarkdown'
|
||||
import { useMessageScroll } from '../../composables/ai/useMessageScroll'
|
||||
import { useStreamRenderer } from '../../composables/ai/useStreamRenderer'
|
||||
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 MessageItem from './MessageItem.vue'
|
||||
import EmptyState from './EmptyState.vue'
|
||||
import { formatRelative, formatDate } from '../../utils/time'
|
||||
import { formatDate, formatAbsolute } from '../../utils/time'
|
||||
import type { AiMessage } from '../../api/types'
|
||||
|
||||
defineProps<{
|
||||
@@ -107,10 +107,7 @@ function shouldRenderMsg(msg: AiMessage): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
/** token 数格式化:<1000 原值,≥1000 用 k(如 1234→1.2k) */
|
||||
function formatTokens(n: number): string {
|
||||
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
|
||||
}
|
||||
/** token 数格式化收敛到 useTurnTrace.formatTokensK(与轨迹卡头部汇总同一格式化出口) */
|
||||
|
||||
// ── token 分项显示(2026-08-02):in/cache/out/reason 分计费 + 详情面板 ──
|
||||
//
|
||||
@@ -134,6 +131,14 @@ function tokenInOf(m: AiMessage): number {
|
||||
function tokenCacheOf(m: AiMessage): number {
|
||||
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 */
|
||||
function tokenOutOf(m: AiMessage): number {
|
||||
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 增强)。
|
||||
*/
|
||||
function canRetry(msg: AiMessage): boolean {
|
||||
// 重试过渡气泡(isRetry)非终态错误,不提供重试入口(终态 AiError 才有重试按钮)
|
||||
if (msg.isRetry) return false
|
||||
const et = (msg as AiMessageWithErrorType).errorType
|
||||
// auth/provider_config = Fatal(鉴权/配置错),重试无意义
|
||||
if (et === 'auth' || et === 'provider_config') return false
|
||||
@@ -741,9 +748,54 @@ function toggleTrace(key: string): void {
|
||||
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 {
|
||||
return aggText(stats, endTs - startTs)
|
||||
return turnSummaryText(stats, endTs - startTs)
|
||||
}
|
||||
|
||||
/** 轨迹卡 hover:绝对起止时刻(相对时长之外可溯源具体时间) */
|
||||
@@ -751,9 +803,13 @@ function traceHoverTitle(startTs: number, endTs: number): string {
|
||||
return `${formatDate(startTs)} ~ ${formatDate(endTs)}`
|
||||
}
|
||||
|
||||
/** 执行中实时步骤摘要:「✓ N 次调用完成 · 正在 {工具} · ⏳ N 项待审批」(由最近工具事件聚合) */
|
||||
function stepText(s: TurnCallStats): string {
|
||||
const parts: string[] = []
|
||||
/**
|
||||
* 执行中实时步骤摘要:「第 N 轮 · ✓ N 次调用完成 · 失败 N · 正在 {工具} · ⏳ N 项待审批」。
|
||||
* 轮次号与轨迹卡同源(flushBucket 的 traceCount,执行中=将成为的轨迹序号);
|
||||
* 失败/拒绝/待审批段仅非零时显示;全部无数据回退「过程记录」。
|
||||
*/
|
||||
function stepText(s: TurnCallStats, round: number): string {
|
||||
const parts: string[] = [t('ai.trace.roundN', { n: round })]
|
||||
const done = s.ok + s.fail
|
||||
if (done > 0) parts.push(t('ai.trace.stepDone', { n: done }))
|
||||
if (s.fail > 0) parts.push(t('ai.trace.fail', { n: s.fail }))
|
||||
@@ -764,11 +820,14 @@ function stepText(s: TurnCallStats): string {
|
||||
}
|
||||
|
||||
type RenderItem =
|
||||
| { kind: 'msg'; key: string; msg: AiMessage }
|
||||
| { kind: 'msg'; key: string; msg: AiMessage; final?: boolean }
|
||||
| { kind: 'sep'; key: string; seg: MessageSegment & { key: string } }
|
||||
// A 过程压缩:完成轮次的执行轨迹卡(traceMsgs 为折叠在内的过程消息)与执行中的实时步骤摘要
|
||||
| { kind: 'trace'; key: string; stats: TurnCallStats; startTs: number; endTs: number; traceMsgs: AiMessage[]; expanded: boolean }
|
||||
| { kind: 'step'; key: string; stats: TurnCallStats }
|
||||
// A 过程压缩:完成轮次的执行轨迹卡(traceMsgs 为折叠在内的过程消息)与执行中的实时步骤摘要;
|
||||
// round=轨迹序号(卡头「第 N 轮」,与执行中步骤行、轮次导航条同一计数来源)
|
||||
| { 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 过程压缩):在归档分段之上叠加轮次折叠。
|
||||
@@ -776,12 +835,44 @@ type RenderItem =
|
||||
* 轮次 = 末条 user 之后的连续 assistant 消息(错误气泡/压缩摘要/归档分段不进桶,原样渲染)。
|
||||
* - 执行中:仅末条消息(当前步骤)展开,先前步骤收进一行实时摘要(step 项,由工具事件聚合)。
|
||||
* - 完成:轮次内除最终答复外的过程消息折叠为一张执行轨迹卡(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 items: RenderItem[] = []
|
||||
let bucket: AiMessage[] = []
|
||||
lastMsgTs = 0
|
||||
// 轨迹序号计数:轨迹卡头「第 N 轮」与执行中步骤行同一来源(computed 重算从 0 起,
|
||||
// 计数即轨迹卡出现顺序,与轮次导航条序号一致)
|
||||
let traceCount = 0
|
||||
|
||||
const flushBucket = () => {
|
||||
const msgs = bucket.filter(shouldRenderMsg)
|
||||
@@ -790,13 +881,20 @@ const renderItems = computed<RenderItem[]>(() => {
|
||||
|
||||
if (isViewingGenerating.value) {
|
||||
// 执行中:当前步骤锚定真实末条消息(agentic 新一轮 push 新空气泡,若按「末条有正文」
|
||||
// 找锚点会错挂上一轮,致当前轮打字机/光标/工具卡整条不渲染);先前步骤收进一行实时摘要
|
||||
// 找锚点会错挂上一轮,致当前轮打字机/光标/工具卡整条不渲染);先前步骤收进一行实时摘要。
|
||||
// 摘要统计取整个轮次桶(含当前消息):执行中工具挂在末条消息上(useAiToolEvents 取
|
||||
// state.messages 末条),只统计 prev 拿不到 runningName,完成计数也滞后一轮(黑盒主因)
|
||||
const cur = msgs[msgs.length - 1]
|
||||
const prev = msgs.slice(0, -1)
|
||||
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
|
||||
}
|
||||
// 最终答复:末条有正文且非纯 JSON 工具结果的消息;全无正文时保底末条(工具轮可见)
|
||||
@@ -806,8 +904,11 @@ const renderItems = computed<RenderItem[]>(() => {
|
||||
if (c && c.trim() && !isToolResultJson(c)) { fi = i; break }
|
||||
}
|
||||
const finalMsg = fi >= 0 ? msgs[fi] : msgs[msgs.length - 1]
|
||||
const traceMsgs = msgs.slice(0, msgs.indexOf(finalMsg))
|
||||
// 完成:轨迹折叠为一张卡,展开时再 emit 段内过程消息(与归档分段同一扁平化模式)
|
||||
// 过程消息 = finalMsg 之外全部(含其后尾气泡):异常收尾(错误/停止/流中断)时末轮气泡
|
||||
// 常只有 toolCalls 无正文,按「finalMsg 之前」切片会把尾气泡丢出渲染与轨迹卡,
|
||||
// 执行中可见的工具卡在收尾瞬间整块消失(用户实测「空白覆盖一大堆内容」)。
|
||||
const traceMsgs = msgs.filter(m => m !== finalMsg)
|
||||
// 完成:轨迹折叠为一张卡,轮次内容在卡内 rail 面板展开(不再摊平成独立消息项)
|
||||
if (traceMsgs.length > 0) {
|
||||
const key = 'trace-' + traceMsgs[0].id
|
||||
const expanded = expandedTraceIds.value.has(key)
|
||||
@@ -818,29 +919,34 @@ const renderItems = computed<RenderItem[]>(() => {
|
||||
if (tc.completedAt && tc.completedAt > lastCompleted) lastCompleted = tc.completedAt
|
||||
}
|
||||
}
|
||||
// 轨迹桶不再发 timesep 行:卡头「第 N 轮」+ 绝对起始时刻已承载时间行职责;
|
||||
// 仅推进轨距,保持出口语义(后续消息的间隔判定从本桶起点起算)
|
||||
lastMsgTs = traceMsgs[0].timestamp
|
||||
traceCount++
|
||||
items.push({
|
||||
kind: 'trace', key,
|
||||
round: traceCount,
|
||||
stats: collectTurnStats(traceMsgs),
|
||||
startTs: traceMsgs[0].timestamp,
|
||||
endTs: Math.max(lastCompleted, finalMsg.timestamp),
|
||||
tokens: sumTurnTokens(traceMsgs),
|
||||
traceMsgs, expanded,
|
||||
})
|
||||
if (expanded) {
|
||||
for (const m of traceMsgs) {
|
||||
items.push({ kind: 'msg', key: 'tm-' + m.id, msg: m })
|
||||
} else {
|
||||
pushTimeSepIfNeeded(items, finalMsg.timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
items.push({ kind: 'msg', key: 'm-' + finalMsg.id, msg: finalMsg })
|
||||
items.push({ kind: 'msg', key: 'm-' + finalMsg.id, msg: finalMsg, final: true })
|
||||
lastMsgTs = finalMsg.timestamp
|
||||
}
|
||||
|
||||
for (const seg of messageSegments.value) {
|
||||
if (seg.kind !== 'normal') {
|
||||
flushBucket()
|
||||
// 折叠段:总 emit sep;展开时再 emit 段内每条 msg
|
||||
// 折叠段:总 emit sep;展开时再 emit 段内每条 msg(同样按间隔插时间线)
|
||||
items.push({ kind: 'sep', key: seg.key, seg })
|
||||
if (expandedSegmentIds.value.has(seg.key)) {
|
||||
for (const m of seg.msgs) {
|
||||
pushTimeSepIfNeeded(items, m.timestamp)
|
||||
items.push({ kind: 'msg', key: 'e-' + seg.key + '-' + m.id, msg: m })
|
||||
}
|
||||
}
|
||||
@@ -852,6 +958,7 @@ const renderItems = computed<RenderItem[]>(() => {
|
||||
bucket.push(m)
|
||||
} else {
|
||||
flushBucket()
|
||||
pushTimeSepIfNeeded(items, m.timestamp)
|
||||
items.push({ kind: 'msg', key: 'm-' + m.id, msg: m })
|
||||
}
|
||||
}
|
||||
@@ -859,6 +966,18 @@ const renderItems = computed<RenderItem[]>(() => {
|
||||
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, () => {
|
||||
// 切会话重置滚动边沿检测 + 跟随意图(新会话默认跟随底部)
|
||||
@@ -914,38 +1033,99 @@ defineExpose({
|
||||
@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' 项,
|
||||
正常消息 + 展开的折叠段内消息 emit 'msg' 项。连续 archived_segment/compressed
|
||||
合并一条分隔条,点击展开/收起看原文)
|
||||
A 过程压缩:trace 项=完成轮次执行轨迹卡,step 项=执行中实时步骤摘要,均先于 msg 分支拦截。
|
||||
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">
|
||||
<!-- 执行轨迹卡(完成轮次:过程文本+工具卡折叠为一张,默认一行聚合,点开回看;
|
||||
用户手动展开记入 expandedTraceIds 不被自动收回) -->
|
||||
<!-- 执行轨迹卡(完成轮次:过程内容折叠为一张,默认一行聚合,点开在卡内 rail 面板回看
|
||||
每轮思考 pill + 工具卡;头部聚合行含调用/耗时/全程 token 汇总) -->
|
||||
<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)">
|
||||
<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 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>
|
||||
</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 v-else-if="item.kind === 'step'" class="ai-msg-slot">
|
||||
<div class="ai-step-line">
|
||||
<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>
|
||||
<!-- B-260618-19: 纯空 assistant(无 content/error/toolCalls 且非末条流式)整 slot 不渲染,
|
||||
消除空 div + flex gap 堆积的大片空白;sep 始终渲染;有 toolCalls 的空 assistant
|
||||
仍渲染(工具卡可见——修复 agentic 工具轮 LLM 无文本时工具卡消失)。 -->
|
||||
仍渲染(工具卡可见——修复 agentic 工具轮 LLM 无文本时工具卡消失)。
|
||||
UX 密度改造:per-block 相对时间已移除,精确时刻上移到消息容器 title(hover 可查)。 -->
|
||||
<div
|
||||
v-else-if="item.kind === 'sep' ? true : shouldRenderMsg(item.msg)"
|
||||
class="ai-msg-slot"
|
||||
:title="item.kind === 'msg' ? formatDate(item.msg.timestamp) : undefined"
|
||||
>
|
||||
<!-- 折叠分隔条(归档段)— 轻量,始终渲染 -->
|
||||
<div
|
||||
@@ -1015,7 +1195,7 @@ defineExpose({
|
||||
<div
|
||||
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--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稳定→DOM不重建)。
|
||||
@@ -1094,14 +1274,17 @@ defineExpose({
|
||||
:aria-expanded="tokenPopoverMsgId === item.msg.id"
|
||||
@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>{{ formatTokens(tokenCacheOf(item.msg)) }} cache</span>
|
||||
<span>{{ formatTokensK(tokenCacheOf(item.msg)) }} cache</span>
|
||||
</template>
|
||||
<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">
|
||||
<span class="ai-token-sep">·</span>
|
||||
<span>{{ formatTokens(tokenReasonOf(item.msg)) }} reason</span>
|
||||
<span>{{ formatTokensK(tokenReasonOf(item.msg)) }} reason</span>
|
||||
</template>
|
||||
</button>
|
||||
<!-- 详情面板(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-row">
|
||||
<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 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-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 class="ai-token-popover-row">
|
||||
<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 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-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 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-val">{{ cacheHitRate(item.msg) }}%</span>
|
||||
</div>
|
||||
@@ -1153,13 +1337,6 @@ defineExpose({
|
||||
@batch-approve="(decision) => store.batchApprove(decision)"
|
||||
@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>
|
||||
@@ -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--skill { background: rgba(0, 0, 0, 0.18); }
|
||||
|
||||
/* ── 消息时间戳── */
|
||||
.ai-msg-time {
|
||||
/* ── UX 密度改造:居中时间分隔线(相邻消息间隔 ≥30 分钟或跨天;
|
||||
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;
|
||||
line-height: 1;
|
||||
color: var(--df-text-dim);
|
||||
margin-top: 2px;
|
||||
align-self: flex-start; /* AI 消息:靠气泡左对齐 */
|
||||
flex-shrink: 0;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s var(--df-ease), border-color 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-msg-time--user {
|
||||
align-self: flex-end; /* 用户消息:靠气泡右对齐 */
|
||||
.ai-trace-nav-chip:hover {
|
||||
color: var(--df-accent);
|
||||
border-color: var(--df-accent);
|
||||
}
|
||||
|
||||
/* ── 消息气泡拷贝按钮── */
|
||||
@@ -1709,6 +1931,103 @@ defineExpose({
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
.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) */
|
||||
.ai-step-line {
|
||||
@@ -1742,4 +2061,34 @@ defineExpose({
|
||||
0%, 100% { opacity: 1; }
|
||||
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>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
>
|
||||
🚀 {{ $t('ideas.promotedProject') }} →
|
||||
</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>
|
||||
<!-- B-260615-25:灵感描述 Markdown 渲染,复用 useMarkdown composable(同 B-24 TaskDetail),空值回退 — -->
|
||||
@@ -268,7 +268,7 @@
|
||||
:class="'status-' + idea.status"
|
||||
@click="statusMenuOpen = !statusMenuOpen"
|
||||
>
|
||||
{{ $t(statusLabelKey(idea.status)) }}
|
||||
{{ $t(ideaStatusLabelKey(idea.status)) }}
|
||||
<span class="status-caret" :class="{ 'is-open': statusMenuOpen }">▾</span>
|
||||
</button>
|
||||
<ul v-show="statusMenuOpen" class="status-menu">
|
||||
@@ -306,6 +306,7 @@ import { useRendered } from '../../composables/useMarkdown'
|
||||
import { ideaApi } from '../../api'
|
||||
import { formatDate } from '../../utils/time'
|
||||
import { parseScores, assessmentClass, assessmentLabel, scoreTier } from '../../utils/ideaEval'
|
||||
import { IDEA_STATUS_OPTIONS, ideaStatusLabelKey } from '../../constants/project'
|
||||
import type { IdeaRecord, IdeaStatus, IdeaEvaluationRecord } from '../../api/types'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -314,7 +315,6 @@ const props = defineProps<{
|
||||
evalError: string
|
||||
promoting: boolean
|
||||
deleting: boolean
|
||||
statusOptions: { value: IdeaStatus; labelKey: string }[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -390,11 +390,11 @@ watch(() => props.idea.id, () => {
|
||||
watch(() => props.idea.description, () => void nextTick(measureDesc))
|
||||
|
||||
// P1-④ 状态切换 select → badge 点击:点击当前 badge 展开/收起下拉菜单,
|
||||
// 选中项后 emit('status-change') 交父组件确认+落库(沿用既有契约,不破坏 statusOptions 来源)。
|
||||
// 选中项后 emit('status-change') 交父组件确认+落库(沿用既有契约)。
|
||||
const statusMenuOpen = ref(false)
|
||||
// 状态下拉排除 promoted(立项走独立 promoteToProject 流程,列表状态菜单同排除,
|
||||
// 见 Ideas.vue onStatusChange 兜底);statusOptions 仍含 promoted 供 label 映射(已转立项的灵感 badge 正常显示)。
|
||||
const menuStatusOptions = computed(() => props.statusOptions.filter(s => s.value !== 'promoted'))
|
||||
// 见 Ideas.vue onStatusChange 兜底);状态表(含 promoted 供 badge label 映射)单一数据源在 @/constants/project。
|
||||
const menuStatusOptions = computed(() => IDEA_STATUS_OPTIONS.filter(s => s.value !== 'promoted'))
|
||||
function closeStatusMenu() {
|
||||
statusMenuOpen.value = false
|
||||
}
|
||||
@@ -515,10 +515,7 @@ function cancelTagsEdit() {
|
||||
tagsEditing.value = false
|
||||
}
|
||||
|
||||
// 任意 status 字符串 → 对应 i18n key;未知状态回退到原值显示
|
||||
function statusLabelKey(status: IdeaStatus): string {
|
||||
return props.statusOptions.find(s => s.value === status)?.labelKey ?? status
|
||||
}
|
||||
// 任意 status → i18n key 的映射走 @/constants/project ideaStatusLabelKey(与列表同源)
|
||||
|
||||
// 灵感描述 Markdown 渲染(复用 AiChat/TaskDetail 同款渲染器,模块级单例),
|
||||
// useRendered 封装 computed(读 mdReady 触发响应式 + renderMd)+ ensureLoaded(幂等预热)
|
||||
|
||||
@@ -147,6 +147,7 @@ import {
|
||||
knowledgeConfidenceLabel as confidenceLabel,
|
||||
} from '@/stores/knowledge'
|
||||
import { useRendered } from '@/composables/useMarkdown'
|
||||
import { formatRelative } from '@/utils/time'
|
||||
import type { KnowledgeDetailPayload, KnowledgeEventRecord } from '@/api/types'
|
||||
|
||||
// 详情面板子组件(Knowledge.vue 拆分,SW-260618-20):
|
||||
@@ -317,20 +318,12 @@ const timelineNodes = computed(() => {
|
||||
return nodes
|
||||
})
|
||||
|
||||
// ===== 标签/时间辅助(kind*/status/confidence label 由 @/stores/knowledge 共用,相对时间本组件自留 30 天截止)=====
|
||||
// 相对时间:复用 common.* 既有 key(justNow/minutesAgo/hoursAgo/dayAgo),30 天以上回退绝对值
|
||||
// ===== 标签/时间辅助(kind*/status/confidence label 由 @/stores/knowledge 共用)=====
|
||||
// 相对时间:走 @/utils/time formatRelative(30 天以上回退绝对日期,档位与全局同源)
|
||||
function relativeTime(millisStr: string): string {
|
||||
const ms = Number(millisStr)
|
||||
if (!ms) return ''
|
||||
const diff = Date.now() - ms
|
||||
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() })
|
||||
return formatRelative(ms, { absAfterDays: 30 })
|
||||
}
|
||||
|
||||
// 切换知识时重置引用列表「加载更多」上限 + 折叠态(对齐原 Knowledge.vue selectKnowledge 内 refLimit=10 行为)
|
||||
|
||||
@@ -51,12 +51,14 @@
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="sidebar-footer">
|
||||
<!-- AI 面板切换按钮 -->
|
||||
<button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()" :title="collapsed ? $t('nav.aiPanel') : undefined">
|
||||
<!-- AI 面板切换按钮(footer 常驻跨路由渲染,承担后台运行中会话的全局可见标识) -->
|
||||
<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">
|
||||
<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 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>
|
||||
</button>
|
||||
<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 { useKnowledgeStore } from '@/stores/knowledge'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { convStates } from '@/composables/ai/aiShared'
|
||||
|
||||
const aiStore = useAiStore()
|
||||
const knowledgeStore = useKnowledgeStore()
|
||||
@@ -135,6 +138,17 @@ onBeforeUnmount(() => {
|
||||
// 知识库候选数量(侧栏 badge)— 解包 computed 取值保持响应式
|
||||
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 = [
|
||||
{ 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>' },
|
||||
@@ -349,6 +363,23 @@ const secondaryNav = [
|
||||
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 — */
|
||||
.sidebar-footer {
|
||||
padding: 6px 8px;
|
||||
|
||||
@@ -165,6 +165,7 @@
|
||||
*/
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { moduleApi, type GitStatusResult } from '@/api/module'
|
||||
import { formatRelative } from '@/utils/time'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -274,19 +275,10 @@ function statusLabel(s: string): string {
|
||||
return x.charAt(0) || '?'
|
||||
}
|
||||
|
||||
/** 格式化 Unix 时间戳为相对时间或具体时间 */
|
||||
/** 格式化 Unix 时间戳为相对时间或具体时间(阶梯走 @/utils/time formatRelative:昨天档 + 30 天截断 ISO 日期) */
|
||||
function formatTime(ts: number): string {
|
||||
if (!ts) return ''
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
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')}`
|
||||
return formatRelative(ts, { yesterday: true, absAfterDays: 30, absStyle: 'iso' })
|
||||
}
|
||||
|
||||
/** 计算文件路径的目录深度(用于缩进)。root 级文件返回 0。 */
|
||||
|
||||
@@ -382,6 +382,7 @@ export const switchingConvs = new Set<string>()
|
||||
// 本模块函数运行时惰性取最新 state。
|
||||
export interface AiStateShape {
|
||||
currentText: string
|
||||
streaming: boolean
|
||||
messages: AiMessage[]
|
||||
activeConversationId: string | null
|
||||
}
|
||||
@@ -434,6 +435,15 @@ export function flushCurrentText(): void {
|
||||
resetDelta()
|
||||
return
|
||||
}
|
||||
// 非流式守卫:currentText 仅在流式进行中有意义。终态收尾后的迟到 delta、后台会话
|
||||
// 完成后切回残留的缓冲,都会让 streaming=false 时 currentText 仍非空;此刻回填会把
|
||||
// 上一轮已完成回复整条覆盖为残片(用户实测「空白内容覆盖了之前一大堆内容」),
|
||||
// 故非流式一律只清不写。合法回填点(收尾/新轮/停止/超时)均在 streaming 翻 false 前调用。
|
||||
if (!s.streaming) {
|
||||
s.currentText = ''
|
||||
resetDelta()
|
||||
return
|
||||
}
|
||||
// 从末尾向前找最后一个非 isError assistant 气泡写入(跳过重试错误气泡,保留部分回复)
|
||||
for (let i = s.messages.length - 1; i >= 0; i--) {
|
||||
const m = s.messages[i]
|
||||
|
||||
@@ -176,6 +176,8 @@ function parseConvMessages(rawMsgs: any[], fallbackPrefix: string): AiMessage[]
|
||||
id: (m.id && m.id !== '') ? m.id : `${fallbackPrefix}-${i}`,
|
||||
role: m.role,
|
||||
content: m.content || '',
|
||||
// 推理过程透传(DeepSeek/Anthropic thinking 同通道落库字段),undefined 保持缺省
|
||||
reasoning_content: m.reasoning_content || undefined,
|
||||
model: m.model,
|
||||
// 用后端持久化的真实时间;老数据无 timestamp 字段回退 Date.now()
|
||||
timestamp: typeof m.timestamp === 'number' ? m.timestamp : Date.now(),
|
||||
|
||||
@@ -97,6 +97,10 @@ export function handleEvent(event: AiChatEvent) {
|
||||
if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') {
|
||||
// 非当前会话终止:收敛会话状态(删 Map 项回不在生成)
|
||||
convStates.delete(convId || '')
|
||||
// 同步清该会话 per-conv 流式态。残留的 streaming/currentText 是死数据:切回时会被
|
||||
// switchConversation 恢复进 currentText,下轮发送的防御 flush 又把它写进上一轮气泡,
|
||||
// 把已完成回复覆盖为残片;回复正文以切换时 DB 加载为准,流式态无需保留。
|
||||
clearConvStreamState(convId || '')
|
||||
// FE1:后台会话终止只刷新列表(防抖调度,避免与 AiCompleted 收尾的 notify 双拉)。
|
||||
scheduleConversationsRefresh()
|
||||
// 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列)
|
||||
|
||||
@@ -56,10 +56,11 @@ function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | '
|
||||
clearTextIdleTimer() // 清文本空闲定时器 + 置 textIdle=true(整轮结束不该有活跃信号残留)
|
||||
flushCurrentText()
|
||||
state.currentText = ''
|
||||
// 清「重试中」过渡气泡(成功后残留根治;AiStreamRetry 推的 retry 气泡是过渡态,终态不清会永久残留)
|
||||
// 清「重试中」过渡气泡(成功后残留根治;AiStreamRetry 推的 retry 气泡是过渡态,终态不清会永久残留)。
|
||||
// 按 isRetry 标记匹配,替代旧中文文案子串匹配(en locale 文案同样生效,文案改版不再漏清)
|
||||
for (let i = state.messages.length - 1; i >= 0; 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
try {
|
||||
state.skills = await aiApi.listSkills()
|
||||
|
||||
@@ -505,6 +505,9 @@ async function sendQueuedNow(index: number) {
|
||||
* 同时推一条 stopLoopFailed 错误消息让用户知晓停止未生效(可再点或等待收尾)。
|
||||
*/
|
||||
async function stopChat() {
|
||||
// 本地复位前先回填已流出文本:streaming 翻 false 后流式分块即清,迟到的 AiCompleted
|
||||
// 又会因 flushCurrentText 的非流式守卫跳过回填,不在此处 flush 则部分回复随停止丢失。
|
||||
flushCurrentText()
|
||||
setStreaming(false, { convId: state.activeConversationId, reason: 'stopChat' }) // 本地先复位,不依赖后端 AiCompleted(审批态看门狗已 clear,竞态丢失则卡死)
|
||||
clearStreamWatchdog() // 清残留看门狗
|
||||
// UX-260616-05 决策 a(逐条续发):不再清队列,保留供 AiCompleted 链式触发 drainQueue 续发。
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import { state } from '@/stores/ai'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { nextMsgId, convStates } from './aiShared'
|
||||
import { nextMsgId, convStates, flushCurrentText } from './aiShared'
|
||||
import type { MessageId } from '@/api/types'
|
||||
import { forceResetStreaming } from './streamingGuard'
|
||||
import { emit } from '@tauri-apps/api/event'
|
||||
@@ -67,6 +67,10 @@ export function onStreamTimeout(convId?: string) {
|
||||
// 不管 stillGenerating 检测(即使已经 false 也再清理一次,完备包底)。
|
||||
console.warn(`[AI-Watchdog] 超时触发,强制收敛状态${convId ? `(convId=${convId})` : '(全局)'}`)
|
||||
|
||||
// 先把已流出的部分回复回填气泡再清态:forceResetStreaming 会清 currentText,streaming
|
||||
// 翻 false 又清流式分块,不 flush 则屏幕上已显示的部分正文一并蒸发(内容空白消失)。
|
||||
// flushCurrentText 只认当前活跃会话的流式态,仅本会话是当前视图时才 flush。
|
||||
if (!convId || convId === state.activeConversationId) flushCurrentText()
|
||||
// 彻底清理三处状态源,保证状态机收敛到 idle
|
||||
forceResetStreaming('onStreamTimeout(无数据包底)', convId)
|
||||
if (convId) {
|
||||
|
||||
@@ -63,17 +63,21 @@ export function handleStreamingEvent(event: AiChatEvent): boolean {
|
||||
return true
|
||||
|
||||
case 'AiStreamRetry': {
|
||||
// 重试中——首条重试推新错误气泡,后续仅更新其 content(单气泡聚合,根治 N+1 气泡)
|
||||
// 重试中——首条重试推过渡气泡(isRetry 标记,MessageList 渲染为琥珀细条而非错误红卡),
|
||||
// 后续仅更新其 content(单气泡聚合,根治 N+1 气泡)。isError 保持 true:
|
||||
// flushCurrentText 靠它跳过本气泡,部分流式回复仍回填前一条 assistant 消息。
|
||||
const lastMsg = state.messages[state.messages.length - 1]
|
||||
const retryText = t('ai.aiStreamRetry', { attempt: event.attempt, max: event.max_attempts })
|
||||
if (lastMsg && lastMsg.isError) {
|
||||
lastMsg.content = retryText
|
||||
lastMsg.isRetry = true
|
||||
} else {
|
||||
state.messages.push({
|
||||
id: `err-${nextMsgId()}` as MessageId,
|
||||
role: 'assistant',
|
||||
content: retryText,
|
||||
isError: true,
|
||||
isRetry: true,
|
||||
timestamp: Date.now(),
|
||||
} as AiMessage)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { aiApi } from '@/api'
|
||||
import { getApprovalRemainingMs } from './aiShared'
|
||||
import { formatWaitTime, waitLevelOf, type WaitLevel } from '@/utils/time'
|
||||
import type { AiToolCallInfo } from '@/api/types'
|
||||
|
||||
/**
|
||||
@@ -115,17 +116,9 @@ export function useToolApproval(tc: Ref<AiToolCallInfo>, emit: ToolApprovalEmit)
|
||||
waitSecs.value = 0
|
||||
}
|
||||
|
||||
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')}`
|
||||
}
|
||||
// formatWaitTime/waitLevelOf 收敛至 @/utils/time(原与 ApprovalPopup 逐字双写)
|
||||
|
||||
const waitLevel = computed<'normal' | 'long' | 'urgent'>(() => {
|
||||
if (waitSecs.value >= 300) return 'urgent' // 5分钟
|
||||
if (waitSecs.value >= 120) return 'long' // 2分钟
|
||||
return 'normal'
|
||||
})
|
||||
const waitLevel = computed<WaitLevel>(() => waitLevelOf(waitSecs.value))
|
||||
|
||||
// ── 审批到期倒计时(BUG-260624-02)──
|
||||
// 剩余 = aiShared 审批超时计时器真实起点推算(自动拒绝时刻精确对齐,非本地近似)。
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
//! - AiToolCallInfo(@/api/types)
|
||||
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { formatDuration } from '@/utils/time'
|
||||
import type { AiToolCallInfo } from '@/api/types'
|
||||
|
||||
/** 工具结果 JSON 已知字段(全可选;list_* 运行时返回数组,靠 Array.isArray 区分) */
|
||||
@@ -326,8 +327,9 @@ export function combineAndTruncateLines(stdout: string | undefined, stderr: stri
|
||||
export function formatCommandResult(r: ToolResult): string {
|
||||
const code = typeof r.exit_code === 'number' ? r.exit_code : -1
|
||||
const ms = typeof r.duration_ms === 'number' ? r.duration_ms : 0
|
||||
// 耗时预格式化为人话时长(与卡片右侧耗时同源 formatDuration),不再双显两种口径
|
||||
const head = code === 0
|
||||
? t('aiTool.commandOk', { ms })
|
||||
? t('aiTool.commandOk', { time: formatDuration(ms) })
|
||||
: t('aiTool.commandFailed', { code })
|
||||
// 合并 stdout + stderr(实际 stderr 通常含报错),各自取前若干行
|
||||
const MAX_LINES = 20
|
||||
@@ -554,8 +556,9 @@ export function formatHttpResult(r: ToolResult): string {
|
||||
const url = typeof r.url === 'string' ? r.url : ''
|
||||
const ms = typeof r.elapsed_ms === 'number' ? r.elapsed_ms : 0
|
||||
const ok = status >= 200 && status < 300
|
||||
// 耗时预格式化为人话时长(与卡片右侧耗时同源 formatDuration),不再双显两种口径
|
||||
const head = ok
|
||||
? t('aiTool.httpOk', { method, status, ms })
|
||||
? t('aiTool.httpOk', { method, status, time: formatDuration(ms) })
|
||||
: t('aiTool.httpFailed', { method, status })
|
||||
const parts: string[] = [head, url]
|
||||
const body = typeof r.body === 'string' ? r.body : ''
|
||||
@@ -671,14 +674,15 @@ function summaryListDir(r: ToolResult): string {
|
||||
function summaryRunCommand(r: ToolResult): string {
|
||||
const code = typeof r.exit_code === 'number' ? r.exit_code : -1
|
||||
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 {
|
||||
return r.exists === false
|
||||
? t('aiTool.fileInfoMissing', { path: r.path || '' })
|
||||
: t('aiTool.fileInfoSize', { path: r.path || '', size: formatBytes(typeof r.size === 'number' ? r.size : 0) })
|
||||
? t('aiTool.fileInfoMissing', { path: shortPath(r.path || '') })
|
||||
: t('aiTool.fileInfoSize', { path: shortPath(r.path || ''), size: formatBytes(typeof r.size === 'number' ? r.size : 0) })
|
||||
}
|
||||
|
||||
/** 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 ms = typeof r.elapsed_ms === 'number' ? r.elapsed_ms : 0
|
||||
const ok = status >= 200 && status < 300
|
||||
// 耗时预格式化为人话时长(与卡片右侧耗时同源 formatDuration),不再双显两种口径
|
||||
return ok
|
||||
? t('aiTool.httpOk', { method, status, ms })
|
||||
? t('aiTool.httpOk', { method, status, time: formatDuration(ms) })
|
||||
: t('aiTool.httpFailed', { method, status })
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,13 @@
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { isToolFailure } from '@/composables/ai/useToolCard'
|
||||
import { toolDisplayName } from '@/composables/ai/useToolCardHeader'
|
||||
import { formatDuration } from '@/utils/time'
|
||||
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 {
|
||||
/** 调用总数(含 running/pending/failed/rejected) */
|
||||
@@ -96,7 +101,7 @@ export function statsTail(s: TurnCallStats): string {
|
||||
* 头部 total=0 显「过程记录」;零值段省略;durationMs 可选(轨迹卡传起止差,分组行不传)。
|
||||
* 供 MessageList 轨迹卡与 ToolCardList 聚合分组行共用,拼装语义单一出口。
|
||||
*/
|
||||
export function aggText(s: TurnCallStats, durationMs?: number): string {
|
||||
export function turnSummaryText(s: TurnCallStats, durationMs?: number): string {
|
||||
const parts: string[] = []
|
||||
parts.push(s.total > 0 ? t('ai.trace.calls', { n: s.total }) : t('ai.trace.processOnly'))
|
||||
const tail = statsTail(s)
|
||||
@@ -106,29 +111,29 @@ export function aggText(s: TurnCallStats, durationMs?: number): string {
|
||||
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 差;历史消息无时间戳返回空串隐藏) */
|
||||
export function callDurationText(startedAt?: number, completedAt?: number): string {
|
||||
if (startedAt == null || completedAt == null) return ''
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export function useTimerOwnership() {
|
||||
const timeouts = new Set<ReturnType<typeof setTimeout>>()
|
||||
const intervals = new Set<ReturnType<typeof setInterval>>()
|
||||
|
||||
/** 注册一次性 timeout;触发后自动从注册表移除。返回可交给 clearOwn 的 id。 */
|
||||
/** 注册一次性 timeout;触发后自动从注册表移除。返回可交给 clearOwnTimeout 的 id。 */
|
||||
function setOwnTimeout(fn: () => void, ms: number) {
|
||||
const id = setTimeout(() => {
|
||||
timeouts.delete(id)
|
||||
@@ -34,7 +34,7 @@ export function useTimerOwnership() {
|
||||
}
|
||||
|
||||
/** 清理指定 timeout(id 为 null/undefined 时 no-op)。 */
|
||||
function clearOwn(id?: ReturnType<typeof setTimeout> | null) {
|
||||
function clearOwnTimeout(id?: ReturnType<typeof setTimeout> | null) {
|
||||
if (id != null) {
|
||||
clearTimeout(id)
|
||||
timeouts.delete(id)
|
||||
@@ -56,5 +56,5 @@ export function useTimerOwnership() {
|
||||
intervals.clear()
|
||||
})
|
||||
|
||||
return { setOwnTimeout, setOwnInterval, clearOwn, clearOwnInterval }
|
||||
return { setOwnTimeout, setOwnInterval, clearOwnTimeout, clearOwnInterval }
|
||||
}
|
||||
|
||||
@@ -34,14 +34,14 @@ export function useToast(defaultDurationMs = 3000) {
|
||||
// 会清掉组件 A 的 timer,致 A 的 toast 永不隐藏(跨实例 bug)。
|
||||
// 已下沉为「每实例独立 timer」:useTimerOwnership 每实例注册表 + onUnmounted 只清自己。
|
||||
// 不再保留模块级兜底 —— 模块级共享正是串扰根源,每实例自清理即正确语义。
|
||||
const { setOwnTimeout, clearOwn } = useTimerOwnership()
|
||||
const { setOwnTimeout, clearOwnTimeout } = useTimerOwnership()
|
||||
let _timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function showToast(msg: string, type: ToastType = 'info', durationMs?: number) {
|
||||
toast.msg = msg
|
||||
toast.type = type
|
||||
toast.visible = true
|
||||
if (_timer) clearOwn(_timer)
|
||||
if (_timer) clearOwnTimeout(_timer)
|
||||
_timer = setOwnTimeout(() => {
|
||||
_timer = null
|
||||
toast.visible = false
|
||||
@@ -50,7 +50,7 @@ export function useToast(defaultDurationMs = 3000) {
|
||||
|
||||
function hideToast() {
|
||||
if (_timer) {
|
||||
clearOwn(_timer)
|
||||
clearOwnTimeout(_timer)
|
||||
_timer = null
|
||||
}
|
||||
toast.visible = false
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//! 根治此前三套状态映射互相矛盾、任务文案两处不一致的问题。
|
||||
//! 项目进度不在此处(按 stage 拍百分比已废弃),由任务完成率计算,见 store.projectTaskProgress。
|
||||
|
||||
import type { IdeaStatus } from '@/api/types'
|
||||
|
||||
// ── 项目状态 ──
|
||||
|
||||
// 值为 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 {
|
||||
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
@@ -22,9 +22,9 @@ export default {
|
||||
errorAuth: 'Request failed: the API key is invalid or lacks permission.',
|
||||
errorTimeout: 'The response timed out. Please try again.',
|
||||
errorNetwork: 'Network connection failed. Please check your network.',
|
||||
// CR-30-2 / F-260616-07(d) / decision a1: stream pre-failure retry bubble copy
|
||||
// clarify this is an AI response (network/rate-limit) retry, not a tool failure (shown in card)
|
||||
aiStreamRetry: '⚠ AI response failed, retrying ({attempt}/{max})…',
|
||||
// CR-30-2 / F-260616-07(d) / decision a1: copy for the auto-retry transition line
|
||||
// (neutral wording: a retry is transient, not a terminal failure; exhausted retries still surface the AiError card)
|
||||
aiStreamRetry: 'Connection interrupted, auto-retrying ({attempt}/{max})…',
|
||||
// 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.',
|
||||
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}',
|
||||
expand: 'Expand trace',
|
||||
collapse: 'Collapse trace',
|
||||
roundN: 'Round {n}',
|
||||
processOnly: 'Process log',
|
||||
stepDone: '✓ {n} calls done',
|
||||
stepRunning: 'running {name}',
|
||||
|
||||
@@ -250,6 +250,10 @@ export default {
|
||||
// Auto-approval toast (same-session already approved same-kind op: same tool + same 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) ──
|
||||
ciTitle: 'CI Checks',
|
||||
ciNFailed: '{n} failed',
|
||||
|
||||
@@ -15,14 +15,18 @@ export default {
|
||||
// tool execution failure marker (AR-6 keeps status=completed, red to distinguish from success)
|
||||
executionFailed: 'Failed',
|
||||
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',
|
||||
collapse: 'Collapse',
|
||||
expandAll: 'Expand all',
|
||||
collapseAll: 'Collapse all',
|
||||
written: 'written',
|
||||
items: 'items',
|
||||
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)
|
||||
linesTruncated: '{shown}/{total} lines, truncated',
|
||||
dirTruncated: 'Truncated, first {n} items only',
|
||||
@@ -74,7 +78,7 @@ export default {
|
||||
advancedTask: 'Task「{title}」 advanced to {status}',
|
||||
// advance_task return-round accumulation (shown only when >0); {title} = task name (TaskRecord), fallback id
|
||||
advancedTaskWithRounds: 'Task「{title}」 advanced to {status} (returned {n} times)',
|
||||
commandOk: 'Command succeeded ({ms} ms)',
|
||||
commandOk: 'Command succeeded ({time})',
|
||||
commandFailed: 'Command failed (exit code {code})',
|
||||
commandOutputLines: '{n} lines of output',
|
||||
patchedFile: 'Patched {path} ({diff} bytes)',
|
||||
@@ -90,7 +94,7 @@ export default {
|
||||
writeOverwrite: 'overwrote {old} → {neww}',
|
||||
encodingLabel: 'encoding: {enc}',
|
||||
// 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',
|
||||
httpTruncated: 'Response truncated ({bytes}), see details',
|
||||
httpRequestFallback: 'HTTP Request',
|
||||
|
||||
@@ -203,6 +203,11 @@ export default {
|
||||
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?',
|
||||
|
||||
// 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 =====
|
||||
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.',
|
||||
|
||||
@@ -22,9 +22,9 @@ export default {
|
||||
errorAuth: '调用失败:API Key 无效或无权限',
|
||||
errorTimeout: '响应超时,请重试',
|
||||
errorNetwork: '网络连接失败,请检查网络',
|
||||
// CR-30-2 / F-260616-07(d) / 决策 a1: 流前失败重试中错误气泡内更新文案
|
||||
// 明确这是 AI 响应(网络/限流)重试,非工具执行失败(工具失败在卡片内显示)
|
||||
aiStreamRetry: '⚠ AI 响应失败,正在重试({attempt}/{max})…',
|
||||
// CR-30-2 / F-260616-07(d) / 决策 a1: 流中断自动重试的过渡细条文案(中性描述:
|
||||
// 重试是过渡态非终态失败,不用错误警示语气;重试耗尽的终态失败仍走 AiError 错误卡)
|
||||
aiStreamRetry: '与上游连接中断,自动重试中({attempt}/{max})…',
|
||||
// Fatal 类错误(4xx/鉴权)不可重试,提示用户去设置而非重试
|
||||
notRetryable: '此错误无法通过重试解决,请检查配置',
|
||||
streamInterruptedAfterTool: '⚠ 工具已执行完成,后续回复中断(长时间无数据流)。可点继续重试。',
|
||||
@@ -67,6 +67,7 @@ export default {
|
||||
duration: '总耗时 {time}',
|
||||
expand: '展开轨迹',
|
||||
collapse: '收起轨迹',
|
||||
roundN: '第 {n} 轮',
|
||||
processOnly: '过程记录',
|
||||
stepDone: '✓ {n} 次调用完成',
|
||||
stepRunning: '正在 {name}',
|
||||
|
||||
@@ -251,6 +251,10 @@ export default {
|
||||
// 自动放行 toast(同会话已批准过同类操作:同工具+同目录,轻量 info 非审批气泡)
|
||||
autoApprovedToast: '已自动放行: {tool}({dir})',
|
||||
|
||||
// ── 技能注入反馈闭环(/ 技能 resolve 成功/失败轻 toast) ──
|
||||
skillInjectedToast: '已注入技能《{name}》',
|
||||
skillNotFoundToast: '技能 {name} 未找到',
|
||||
|
||||
// ── CIStatus.vue(代码提交 CI 检查状态卡)──
|
||||
ciTitle: 'CI 检查',
|
||||
ciNFailed: '{n} 项失败',
|
||||
|
||||
@@ -16,12 +16,16 @@ export default {
|
||||
executionFailed: '执行失败',
|
||||
executionFailedHint: '工具执行失败,详情见下方',
|
||||
collapse: '收起',
|
||||
expandAll: '展开全部',
|
||||
expand: '展开',
|
||||
collapseAll: '全部收起',
|
||||
written: '已写入',
|
||||
items: '项',
|
||||
lines: '行',
|
||||
// 状态点 tooltip(ToolCard 绿/红/蓝点 hover,中英成对)
|
||||
statusOk: '成功',
|
||||
statusFailed: '失败',
|
||||
statusRunning: '进行中',
|
||||
statusPending: '待审批',
|
||||
statusRejected: '已拒绝',
|
||||
// UX-260618-08/11:截断提示(read_file 分页截断 / list_directory 1000 上限截断)
|
||||
linesTruncated: '{shown}/{total} 行,已截断',
|
||||
dirTruncated: '已截断,仅前 {n} 项',
|
||||
@@ -75,7 +79,7 @@ export default {
|
||||
advancedTask: '任务「{title}」已推进至 {status}',
|
||||
// advance_task 退回累加轮数(>0 才显);{title} 显任务名(后端返 TaskRecord),回退 id
|
||||
advancedTaskWithRounds: '任务「{title}」已推进至 {status}(已退回 {n} 次)',
|
||||
commandOk: '命令成功({ms} 毫秒)',
|
||||
commandOk: '命令成功({time})',
|
||||
commandFailed: '命令失败(退出码 {code})',
|
||||
commandOutputLines: '{n} 行输出',
|
||||
patchedFile: '已修改 {path}(增减 {diff} 字节)',
|
||||
@@ -91,7 +95,7 @@ export default {
|
||||
writeOverwrite: '覆盖 {old} → {neww}',
|
||||
encodingLabel: '编码:{enc}',
|
||||
// http_request 结果摘要(method + 状态码 + 耗时;body 在展开态显示)
|
||||
httpOk: '{method} {status} 成功({ms} 毫秒)',
|
||||
httpOk: '{method} {status} 成功({time})',
|
||||
httpFailed: '{method} {status} 失败',
|
||||
httpTruncated: '响应已截断({bytes}),详见下方',
|
||||
httpRequestFallback: 'HTTP 请求',
|
||||
|
||||
@@ -202,6 +202,11 @@ export default {
|
||||
pathAuthBypassConfirmTitle: '开启「信任所有目录」?',
|
||||
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 命令执行安全 =====
|
||||
panelScriptSafety: '🛡️ 命令执行安全',
|
||||
descScriptSafety: '配置 ScriptNode 命令白/黑名单。命令名取首词(如 `rm -rf /` 取 `rm`),按 trim + 小写比较。黑名单优先于白名单。',
|
||||
|
||||
@@ -253,7 +253,7 @@ const SCALAR_COMMANDS: Record<string, () => unknown> = {
|
||||
settings_get_all: () => ({}),
|
||||
settings_set: () => true,
|
||||
settings_delete: () => true,
|
||||
get_plan_execution: () => false,
|
||||
is_plan_execution_enabled: () => false,
|
||||
get_data_dir: () => 'C:\\devflow-browser-mock-data',
|
||||
ai_is_generating: () => false,
|
||||
list_tool_executions: () => ({ items: [], total: 0, has_more: false }),
|
||||
|
||||
+74
-2
@@ -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' })
|
||||
}
|
||||
|
||||
/** 相对时间(走 i18n:justNow/minutesAgo/hoursAgo/dayAgo;en/zh 双语);无效返回 '—' */
|
||||
export function formatRelative(v: string | number | null | undefined): string {
|
||||
/** 相对时间分档差异参数(formatRelative 选项;不传 = 原默认阶梯) */
|
||||
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)
|
||||
if (ms == null) return '—'
|
||||
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)
|
||||
if (diffH < 24) return t('common.hoursAgo', { n: diffH })
|
||||
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 })
|
||||
}
|
||||
|
||||
/** 消息间隔分隔线用绝对时间:当天只 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'
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<!-- H1 修复:审批挂起时长提示(对齐 ToolCard: >2min 橙色 / >5min 红色脉冲)。
|
||||
后端 APPROVAL_TIMEOUT_MS 5min 静默拒绝,浮窗原本无计时,用户误以为还有时间。
|
||||
基于审批到达时间(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)) }) }}
|
||||
</div>
|
||||
<!-- path 类:目录提示 -->
|
||||
@@ -153,6 +153,7 @@ import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
|
||||
import { toolDisplayName } from '@/composables/ai/useToolCardHeader'
|
||||
import { dispatchApprovalIPC } from '@/composables/ai/useToolApproval'
|
||||
import { ApprovalPopupEvents } from '@/composables/ai/useApprovalPopup'
|
||||
import { formatWaitTime, waitLevelOf } from '@/utils/time'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { AiToolCallInfo } from '@/api/types'
|
||||
@@ -189,30 +190,18 @@ function answerConfirm(ok: boolean) {
|
||||
// ── H1:审批挂起时长计时(对齐 ToolCard useToolApproval 的 waitSecs/waitLevel)──
|
||||
// 浮窗独立于主窗口,不直接复用 useToolApproval(那个绑定单 tc + 完整状态机 emit/防抖,
|
||||
// 浮窗已用 processingId/runApproval 自管)。这里基于「到达时间」(popup 首次见到 tc.id 的时刻)
|
||||
// 每秒刷新。语义/样式/阈值与 ToolCard 同款:120s long / 300s urgent。
|
||||
// 每秒刷新。格式化(formatWaitTime)与分级阈值(waitLevelOf,120s long / 300s urgent)
|
||||
// 收敛至 @/utils/time,与 ToolCard 同源。
|
||||
/** tc.id → 到达时刻(ms)。审批首次出现在 approvals 即记时;移除即清。 */
|
||||
const arrivalTimes = reactive(new Map<string, number>())
|
||||
/** tc.id → 已等待秒数(每秒 tick 更新)。 */
|
||||
const waitSecsMap = reactive(new Map<string, number>())
|
||||
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) */
|
||||
function waitSecsFor(id: string): number {
|
||||
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) */
|
||||
function _tickWaitSecs() {
|
||||
const now = Date.now()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user