Compare commits

..
9 Commits
Author SHA1 Message Date
lxy 0b57126ac5 文档: 三路走查报告与编排经验蒸馏 2026-10-03 15:35:37 +08:00
lxy 2d43f90e50 优化: 对话交互修复与界面完善
- 修输入法组词回车直发/分离窗误关/出错后按钮死锁
- 新建任务失败弹窗内联提示,会话切换乐观高亮
- 状态多选联动刷新不再冲空可见行
- 测试连接保留已配开关与权重,防抖按提供商分键
- 代码块复制/长回复折叠带量/队列回填/排队时钟
- 子代理完成卡渲染,死链卡片与引用清理
- 截图渲染换 pro 版依赖,样式 token 化收尾
2026-10-03 15:35:18 +08:00
lxy b4fafa0e9a 优化: 会话状态 per-conv 化与交互守卫
- 待审批跨会话 per-conv 合并,审批类事件全局放行
- 看门狗/重试计数/流式残段按会话键控迁移
- 输入草稿与子代理完成卡数据按会话隔离
- 切换兜底 activeId,收敛守卫防幽灵气泡
- 发送错误按限流/额度/网络分类文案
2026-10-03 15:35:15 +08:00
lxy d406a31722 优化: agentic防护与删除审批子代理域
- 循环主体 catch_unwind 防护,panic 走正典终态不再卡死
- 会话删除停 in-flight 循环+删除侧单点补发终态
- 审批超时到点主动收敛,执行等待循环加删除判死
- 子代理出口整轨迹落库,拒绝工具结构化+报告协议
- 压缩会话 id 修正,begin_turn 喂用户原文
- 远程桥会话列表按 origin 过滤子会话,新增中断续跑命令
2026-10-03 15:35:08 +08:00
lxy c1e5b96d75 修复: 上下文流式与工具签名域清债
- tool_signature 归一化补参,合法换参不再误触探索熔断
- SSE 超限截头改对齐事件边界保尾,流不再永久破损
- content_filter 纳入终态判定,无 DONE 流也收敛
- sanitize 占位补头前缀短路只吞 orphan 的盲区消除
- intent 关键词加词边界,history 不再误判
- 恢复重放后重置话题锚点,URL 图 token 地板不被稀释
2026-10-03 15:35:05 +08:00
lxy 499aa49e49 优化: 存储层清债与子轨迹来源迁移
- V44 加 ai_conversations.origin 列(子代理轨迹来源标记)
- V21 断点续迁改造(逐对话进度判定,崩溃重跑补漏)
- 手写事务改 guard.transaction,panic 不残留打开事务
- 软删 CAS 补 deleted_at IS NULL(任务/项目/灵感三处)
- 父任务校验拒回收站挂子,删模块先清依赖边
- 列缺失与未知 status 降级补 warn 留痕,limit=0 返空集
2026-10-03 15:34:55 +08:00
lxy 3f4c914dbf 测试: 工具卡分组活组豁免回归用例(live-group.spec) 2026-10-02 01:21:33 +08:00
lxy db8c240d95 功能: 启用 Tauri asset 协议支持 webview 本地图片预览
- Cargo.toml 开启 protocol-asset feature
- tauri.conf.json 配置 assetProtocol scope=**
- Cargo.lock 同步 http-range 依赖
2026-10-02 01:21:27 +08:00
lxy 79ca55620b 清理: 移除代码注释中的走查编号前缀(UX/B/SW/CR/ID/P0-P2-26xxxx,共102处) 2026-10-02 01:00:37 +08:00
105 changed files with 4195 additions and 926 deletions
Generated
+7
View File
@@ -1973,6 +1973,12 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "http-range"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]] [[package]]
name = "httparse" name = "httparse"
version = "1.10.1" version = "1.10.1"
@@ -4524,6 +4530,7 @@ dependencies = [
"gtk", "gtk",
"heck 0.5.0", "heck 0.5.0",
"http", "http",
"http-range",
"jni", "jni",
"libc", "libc",
"log", "log",
+236 -19
View File
@@ -66,7 +66,8 @@ pub struct AiTool {
// - approval_hint:审批卡文案模板 + 关键参数键 // - approval_hint:审批卡文案模板 + 关键参数键
// - display_verb:轻量中文动作标签 // - display_verb:轻量中文动作标签
// - goal:从工具调用推理目标描述(动词 + 参数键 + 取值风格 + 缺参兜底文案) // - goal:从工具调用推理目标描述(动词 + 参数键 + 取值风格 + 缺参兜底文案)
// - sig:漂移检测签名取哪些参数组(组内多键为取值回退序,数值键按 JSON 形态序列化) // - sig:漂移检测签名取哪些参数组(组内多键为取值回退序,数值键按 JSON 形态序列化;
// 注册点未覆盖但换值即换操作的键由 SIG_EXTRA_GROUPS 补参,见 tool_signature)
// ============================================================ // ============================================================
/// 取值风格(goal 维度):从参数值到展示文本的取法 /// 取值风格(goal 维度):从参数值到展示文本的取法
@@ -98,14 +99,16 @@ pub struct GoalMeta {
pub enum SigValKind { pub enum SigValKind {
/// 字符串取值(as_str,缺失为空串) /// 字符串取值(as_str,缺失为空串)
Str, Str,
/// Value::to_string 形态(数字 50 -> "50",缺失为空串) /// Value::to_string 形态(数字 50 -> "50",布尔 true -> "true");组内全键参与归一化
/// (排序拼接,缺失键空占位)
Json, Json,
} }
/// 漂移检测签名元数据:tool_call_signature 消费 /// 漂移检测签名元数据:tool_call_signature 消费
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct SigMeta { pub struct SigMeta {
/// 有序参数组;每组内多键表示取值回退序(如 pattern 缺失落 query) /// 有序参数组;组内多键:Str 组为取值回退序(如 pattern 缺失落 query),Json 组为
/// 全键参与(排序拼接,防同组其他键换值被压成同签名)
pub groups: &'static [(&'static [&'static str], SigValKind)], pub groups: &'static [(&'static [&'static str], SigValKind)],
} }
@@ -227,36 +230,135 @@ pub fn goal_text(goal: GoalMeta, args: Option<&Value>) -> Option<String> {
rendered.or_else(|| goal.fallback.map(|f| f.to_string())) rendered.or_else(|| goal.fallback.map(|f| f.to_string()))
} }
/// 签名体:每组渲染 k=v,组间逗号连接。标签恒为首键(与旧实现 pair("pattern", ...) 一致, /// 组渲染口径:Always = 基础组逐组渲染,缺键空值占位(消费方按固定串断言,形态不可漂移);
/// 取值来自回退键时标签不变)。Str 组取组内首个字符串值键的值,全非字符串落末键; /// Present = 补参口径,只渲染 args 中实际出现的键,键未出现视同未换参,不渲染空占位。
/// Json 组按 Value::to_string(缺失为空串)。 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub fn sig_body(sig: SigMeta, args: &Value) -> String { enum GroupPresence {
let arg_str = |key: &str| -> String { Always,
args.get(key).and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_default() Present,
}; }
sig.groups
/// 组渲染:每组产出 k=v 片段,组间逗号连接。Str 组标签恒为首键(与旧实现
/// pair("pattern", ...) 一致,取值来自回退键时标签不变),取组内首个字符串值键的值,
/// 全非字符串落末键。Json 组全键参与归一化:数值/布尔键换值即换操作,只取 keys[0] 会把
/// 同组其他键换值的不同调用压成同一签名(G2 熔断以签名为重复判据,压缩即误判重复),
/// 排序后拼接保证与键序无关的确定性。
fn render_groups(
groups: &'static [(&'static [&'static str], SigValKind)],
args: &Value,
presence: GroupPresence,
) -> String {
groups
.iter() .iter()
.map(|(keys, kind)| { .filter_map(|(keys, kind)| match kind {
let value = match kind { SigValKind::Json => {
SigValKind::Json => args.get(keys[0]).map(|v| v.to_string()).unwrap_or_default(), let mut parts: Vec<String> = keys
.iter()
.copied()
.filter(|k| presence == GroupPresence::Always || args.get(*k).is_some())
.map(|k| match args.get(k) {
Some(v) => format!("{}={}", k, v.to_string()),
None => format!("{}=", k),
})
.collect();
parts.sort_unstable();
if parts.is_empty() {
None
} else {
Some(parts.join("|"))
}
}
SigValKind::Str => { SigValKind::Str => {
if presence == GroupPresence::Present {
// 补参组恒单键:键未出现整组跳过,值非字符串视同缺失
return args
.get(keys[0])
.and_then(|v| v.as_str())
.map(|s| format!("{}={}", keys[0], s));
}
let key = keys let key = keys
.iter() .iter()
.find(|k| args.get(**k).and_then(|v| v.as_str()).is_some()) .find(|k| args.get(**k).and_then(|v| v.as_str()).is_some())
.unwrap_or(&keys[keys.len() - 1]); .unwrap_or(&keys[keys.len() - 1]);
arg_str(key) Some(format!(
"{}={}",
keys[0],
args.get(*key).and_then(|v| v.as_str()).unwrap_or("")
))
} }
};
format!("{}={}", keys[0], value)
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(",") .join(",")
} }
/// 工具调用签名:有 SigMeta 元数据按组渲染,未注册回退「工具名 + 全参 JSON」保守兜底。 /// 签名体:基础组按 Always 口径渲染。
pub fn sig_body(sig: SigMeta, args: &Value) -> String {
render_groups(sig.groups, args, GroupPresence::Always)
}
/// 签名补参表:注册点 SigMeta 组未覆盖、但「换值即换操作」的键,按工具名追加进签名。
///
/// 为什么在 df-ai 签名层:G2 熔断以签名为重复判据,签名过粗会把合法换参误判成
/// 「同一操作反复」(同工具换参是不同操作)。注册点 SigMeta 声明主参数组,换参敏感
/// 但未覆盖的键由本表统一补参;若日后注册点把这些键并入 groups,须同步从本表移除
/// (双源会重复渲染同键,不改变同异判定但产生冗余片段)。
///
/// 渲染走 Present 口径(键出现在 args 才进签名):补参键缺省 = 未换参,既有签名逐字
/// 不变。纯格式/开关类键(-n/-C/context_chars/timeout_secs)不补,变体不构成意图变化,
/// 补进签名只会稀释重复判据。
static SIG_EXTRA_GROUPS: &[(&str, &'static [(&'static [&'static str], SigValKind)])] = &[
// read_file:同 path 换 search 是换词定位,非重复读
("read_file", &[(&["search"], SigValKind::Str)]),
// search_files:换 recursive 换搜索范围,换 offset/limit 是翻页续搜
// (schema 无 -i/output_mode,大小写不敏感是内建语义非参数)
(
"search_files",
&[
(&["recursive"], SigValKind::Json),
(&["offset"], SigValKind::Json),
(&["limit"], SigValKind::Json),
],
),
// grep:换 -i 是敏感/不敏感两种匹配,换 output_mode 是行内容/文件名/计数三种形态,
// 换 max_results 是扩容重取(schema 无 offset/limit,数量参数实为 max_results)
(
"grep",
&[
(&["-i"], SigValKind::Json),
(&["output_mode"], SigValKind::Str),
(&["max_results"], SigValKind::Json),
],
),
// run_command:同命令不同 working_dir 是在不同目录执行
("run_command", &[(&["working_dir"], SigValKind::Str)]),
];
/// 按工具名取补参组(未列名返回 None)
fn sig_extra_groups(name: &str) -> Option<&'static [(&'static [&'static str], SigValKind)]> {
SIG_EXTRA_GROUPS
.iter()
.find(|(n, _)| *n == name)
.map(|(_, g)| *g)
}
/// 工具调用签名:有 SigMeta 元数据按组渲染并追加补参组,未注册回退「工具名 + 全参 JSON」
/// 保守兜底(全参已含补参键,无需追加)。
///
/// G2 判定语义:同工具同参连续 = 同签名,计重复;同工具换参 = 补参键进签名,签名不同
/// 不累计(正常探索)。
pub fn tool_signature(name: &str, args: &Value) -> String { pub fn tool_signature(name: &str, args: &Value) -> String {
match sig_of(name) { match sig_of(name) {
Some(sig) => format!("{}:{}", name, sig_body(sig, args)), Some(sig) => {
let mut body = render_groups(sig.groups, args, GroupPresence::Always);
let extra = sig_extra_groups(name)
.map(|g| render_groups(g, args, GroupPresence::Present))
.unwrap_or_default();
if !extra.is_empty() {
body.push(',');
body.push_str(&extra);
}
format!("{}:{}", name, body)
}
None => format!("{}:name={},args={}", name, name, args.to_string()), None => format!("{}:name={},args={}", name, name, args.to_string()),
} }
} }
@@ -545,4 +647,119 @@ mod tests {
let sig = tool_signature("custom_tool", &json!({"x": 1})); let sig = tool_signature("custom_tool", &json!({"x": 1}));
assert!(sig.starts_with("custom_tool:name=custom_tool,args=")); assert!(sig.starts_with("custom_tool:name=custom_tool,args="));
} }
/// Json 组全键参与:同组多键排序拼接(与 args 键序无关),任一键换值即换片段。
#[test]
fn sig_body_json_group_includes_all_keys() {
use serde_json::json;
let page = SigMeta { groups: &[(&["offset", "limit"], SigValKind::Json)] };
// 排序拼接:limit < offset 字典序,与 args 内键书写顺序无关
assert_eq!(sig_body(page, &json!({"offset": 50, "limit": 100})), "limit=100|offset=50");
assert_eq!(sig_body(page, &json!({"limit": 100, "offset": 50})), "limit=100|offset=50");
// 任一键换值 → 不同片段
assert_ne!(
sig_body(page, &json!({"offset": 50, "limit": 100})),
sig_body(page, &json!({"offset": 60, "limit": 100}))
);
// 全缺失逐键空占位(既有缺省口径);布尔键按 JSON 形态
assert_eq!(sig_body(page, &json!({})), "limit=|offset=");
let flag = SigMeta { groups: &[(&["-i"], SigValKind::Json)] };
assert_eq!(sig_body(flag, &json!({"-i": true})), "-i=true");
}
/// 注册与生产同形态的探针工具。df-ai 单测进程 GLOBAL_TOOL_META 无生产注册,
/// 不种入则 tool_signature 走未注册兜底,测不到「注册点组 + 补参」合流分支。
/// 进程级表跨测试共享,各处注册值恒相同,幂等无顺序依赖。
fn seed_signature_probe_tools() {
let read_sig = SigMeta {
groups: &[
(&["path"], SigValKind::Str),
(&["offset"], SigValKind::Json),
(&["limit"], SigValKind::Json),
],
};
let search_sig = SigMeta {
groups: &[
(&["pattern", "query"], SigValKind::Str),
(&["path", "glob"], SigValKind::Str),
],
};
let cmd_sig = SigMeta { groups: &[(&["command"], SigValKind::Str)] };
let mut reg = AiToolRegistry::new();
reg.register_meta("read_file", "读取", object_schema(vec![]), RiskLevel::Low,
ToolMeta { sig: Some(read_sig), ..Default::default() }, dummy_handler());
reg.register_meta("search_files", "搜索文件", object_schema(vec![]), RiskLevel::Low,
ToolMeta { sig: Some(search_sig), ..Default::default() }, dummy_handler());
reg.register_meta("grep", "跨文件内容搜索", object_schema(vec![]), RiskLevel::Low,
ToolMeta { sig: Some(search_sig), ..Default::default() }, dummy_handler());
reg.register_meta("run_command", "执行命令", object_schema(vec![]), RiskLevel::High,
ToolMeta { sig: Some(cmd_sig), ..Default::default() }, dummy_handler());
}
/// 补参:合法换参 → 签名不同(G2 目标语义:换参不共享签名,不再误判重复);
/// 同参 → 签名相同(重复判据正例仍成立)。
#[test]
fn tool_signature_extra_params_distinguish_calls() {
use serde_json::json;
seed_signature_probe_tools();
// read_file 同 path 换 search:不同签名
let s1 = tool_signature("read_file", &json!({"path": "a.rs", "search": "fn sig"}));
let s2 = tool_signature("read_file", &json!({"path": "a.rs", "search": "struct cfg"}));
assert_ne!(s1, s2, "read_file 换 search 应不同签名: {} vs {}", s1, s2);
assert!(s1.contains("search=fn sig"), "read_file 签名应含 search: {}", s1);
// search_files 只差 offset(翻页续搜):不同签名
let s1 = tool_signature("search_files", &json!({"pattern": "todo", "path": "src", "offset": 0}));
let s2 = tool_signature("search_files", &json!({"pattern": "todo", "path": "src", "offset": 50}));
assert_ne!(s1, s2, "search_files 换 offset 应不同签名: {} vs {}", s1, s2);
// grep 只差 -i / output_mode / max_results(schema 无 offset/limit,数量参数实为
// max_results):换匹配方式/输出形态/扩容重取都是不同操作
let base = tool_signature("grep", &json!({"pattern": "err", "path": "src"}));
let ci = tool_signature("grep", &json!({"pattern": "err", "path": "src", "-i": true}));
let mode = tool_signature("grep", &json!({"pattern": "err", "path": "src", "output_mode": "files_with_matches"}));
let capped = tool_signature("grep", &json!({"pattern": "err", "path": "src", "max_results": 200}));
assert_ne!(base, ci, "grep 换 -i 应不同签名: {} vs {}", base, ci);
assert_ne!(base, mode, "grep 换 output_mode 应不同签名: {} vs {}", base, mode);
assert_ne!(base, capped, "grep 换 max_results 应不同签名: {} vs {}", base, capped);
// run_command 同命令换 working_dir:不同签名
let s1 = tool_signature("run_command", &json!({"command": "cargo test"}));
let s2 = tool_signature("run_command", &json!({"command": "cargo test", "working_dir": "crates/df-ai"}));
assert_ne!(s1, s2, "run_command 换 working_dir 应不同签名: {} vs {}", s1, s2);
// 同工具同参:签名相同(同签名连续才算 G2 重复)
assert_eq!(
tool_signature("grep", &json!({"pattern": "err", "path": "src"})),
base
);
}
/// 补参键缺席时签名与既有格式逐字一致(向后兼容:消费方按固定串断言,空补参不占位)。
#[test]
fn tool_signature_without_extra_keys_unchanged() {
use serde_json::json;
seed_signature_probe_tools();
assert_eq!(
tool_signature("read_file", &json!({"path": "big.log", "offset": 50, "limit": 100})),
"read_file:path=big.log,offset=50,limit=100"
);
assert_eq!(
tool_signature("read_file", &json!({})),
"read_file:path=,offset=,limit="
);
assert_eq!(
tool_signature("grep", &json!({"pattern": "MAX", "path": "src", "case_sensitive": true})),
"grep:pattern=MAX,path=src"
);
assert_eq!(
tool_signature("search_files", &json!({"query": "TODO", "glob": "**/*.rs"})),
"search_files:pattern=TODO,path=**/*.rs"
);
assert_eq!(
tool_signature("run_command", &json!({"command": "ls -la", "timeout": 5000})),
"run_command:command=ls -la"
);
}
} }
+13 -6
View File
@@ -626,16 +626,23 @@ impl LlmProvider for AnthropicCompatProvider {
error!(%status, %text, "Anthropic API 调用失败(不可重试)"); error!(%status, %text, "Anthropic API 调用失败(不可重试)");
return AttemptOutcome::Fatal(msg); return AttemptOutcome::Fatal(msg);
} }
let resp: AnthropicResponse = match resp.json().await { // body 解析:text 先取再 serde 分层判定:读取中断(text Err,reqwest 归
Ok(r) => r, // is_decode 类,complete 重发幂等安全)可重试;JSON 结构错属 Fatal(响应已
// serde 结构错属 Fatal(重试同败);body 读取中途断连同归 is_decode,可重试。 // 完整送达,重试同败)。Response::json 把 serde 错同包成 is_decode,先 text
// 后 from_str 才能区分二者,避免确定性结构错被重试白烧预算。
let text = match resp.text().await {
Ok(t) => t,
Err(e) => { Err(e) => {
if e.is_decode() { if is_reqwest_error_retryable(&e) || e.is_decode() {
return AttemptOutcome::Retryable(format!("响应解码失败(可重试): {}", e)); return AttemptOutcome::Retryable(format!("响应读取失败(可重试): {}", e));
} }
return AttemptOutcome::Fatal(format!("响应解析失败: {}", e)); return AttemptOutcome::Fatal(format!("响应读取失败(不可重试): {}", e));
} }
}; };
let resp: AnthropicResponse = match serde_json::from_str(&text) {
Ok(r) => r,
Err(e) => return AttemptOutcome::Fatal(format!("响应解析失败: {}", e)),
};
// content 块中拼接 text,收集 tool_use,累积 thinking // content 块中拼接 text,收集 tool_use,累积 thinking
let mut text = String::new(); let mut text = String::new();
let mut reasoning = String::new(); let mut reasoning = String::new();
+29 -1
View File
@@ -51,7 +51,7 @@ fn estimate_message_counts_parts_tokens() {
mm_tokens mm_tokens
); );
// url 模式(无字节)也按 URL 长度估算,不爆 // url 模式(无字节)按每图 ~1600 token 地板估(生效于 ×0.35 折扣之后,防预算漏裁)
let url_msg = ChatMessage::user_parts( let url_msg = ChatMessage::user_parts(
"t", "t",
vec![crate::provider::ContentPart::image_url("https://example.com/x.png")], vec![crate::provider::ContentPart::image_url("https://example.com/x.png")],
@@ -777,6 +777,34 @@ fn topic_marker_take_is_idempotent_and_clears() {
assert!(mgr.take_topic_marker().is_none(), "marker 一次性消费, 二次 take 必空"); assert!(mgr.take_topic_marker().is_none(), "marker 一次性消费, 二次 take 必空");
} }
#[test]
fn topic_marker_reset_after_restore_from_messages() {
// restore_from_messages(DB 加载)重放历史时 push 会照常跑主题切换检测,
// 历史里"当时已发生"的切换不得残留成 pending marker——否则加载后首轮 take
// 拿到假话题锚点,后续话题归属被误判为切换、多插一次系统标记。
let history = vec![
ChatMessage::user("帮我重构这段代码"), // topic=code(高置信)
ChatMessage::assistant("好的"),
ChatMessage::user("创建项目并绑定目录"), // topic=project(高置信)→ 重放中会置位 marker
];
let mut mgr = ContextManager::new(cfg(100_000));
mgr.restore_from_messages(history);
assert!(
mgr.take_topic_marker().is_none(),
"重放历史不得残留 pending_topic_marker(假话题锚点)"
);
// restore 之后的实时 push 检测不受影响:末条 user topic=project,新 push code → 触发
mgr.push(ChatMessage::user("帮我重构这段代码"));
let marker = mgr
.take_topic_marker()
.expect("restore 后的实时切换仍应触发");
assert!(
marker.contains("project") && marker.contains("code"),
"marker: {}",
marker
);
}
// ============================================================ // ============================================================
// msg-split-phase1:增量写路径基线(persisted_msg_count)+ 全量重写标志(needs_full_rewrite) // msg-split-phase1:增量写路径基线(persisted_msg_count)+ 全量重写标志(needs_full_rewrite)
// ============================================================ // ============================================================
+4
View File
@@ -316,6 +316,10 @@ impl ContextManager {
} }
self.persisted_msg_count = len; self.persisted_msg_count = len;
self.needs_full_rewrite = false; self.needs_full_rewrite = false;
// 重放历史时 push 会照常跑主题切换检测,历史里"当时已发生"的切换会残留进 marker;
// 留着它,加载后首轮 take_topic_marker 就拿到假话题锚点、误插一次切换标记。
// 恢复即重置:切换检测只对 restore 之后的实时 push 生效。
self.pending_topic_marker = None;
} }
/// 就地替换某条 tool_result 的内容(兼容审批 replace_tool_result) /// 就地替换某条 tool_result 的内容(兼容审批 replace_tool_result)
+67 -18
View File
@@ -196,8 +196,10 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
/// 占位与非占位统一改"补头自愈":任何工具结果都不丢(除非无 tool_call_id 的异常数据,无法 /// 占位与非占位统一改"补头自愈":任何工具结果都不丢(除非无 tool_call_id 的异常数据,无法
/// 补头配对,只能保留——由协议层兜底)。 /// 补头配对,只能保留——由协议层兜底)。
/// ///
/// **未闭合 vs TOOL_MISSING_PREFIX 头**:已带 TOOL_MISSING_PREFIX 的 id 视为已配对(出口断言 /// **未闭合 vs TOOL_MISSING_PREFIX 头**:判定只看 head_ids.contains——真配对(占位头在)由
/// 补的占位头),不重复补头(否则同 id 双头致 400)。 /// contains 覆盖,不会重复补头;头不在的 prefix id result(前轮占位头被裁剪丢)即 orphan,
/// 同样补头自愈。补出的头 id 为前缀叠加(tool_missing_tool_missing_X,双层前缀),头与
/// result 改写后 id 一致,唯一配对闭合(双层前缀为已知无害副作用)。
pub fn drop_reverse_orphans(messages: Vec<ChatMessage>) -> Vec<ChatMessage> { pub fn drop_reverse_orphans(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
use std::collections::HashSet; use std::collections::HashSet;
// 收集所有保留 assistant 头的 tool_call.id(正向 orphan 处理后残留的头里的 id)。 // 收集所有保留 assistant 头的 tool_call.id(正向 orphan 处理后残留的头里的 id)。
@@ -227,16 +229,14 @@ pub fn drop_reverse_orphans(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
out.push(m); out.push(m);
continue; continue;
}; };
// 已配对(头在)→ 原样保留。 // 已配对(头在)→ 原样保留。TOOL_MISSING_PREFIX 前缀 id 无需特判:真配对(占位头在)
// 已被 contains 覆盖;头不在(前轮占位头被裁剪丢掉)即是 orphan,同样走补头自愈。
// 补出的头 id 为前缀叠加(tool_missing_tool_missing_X,双层前缀),头+result 改写后
// id 一致,唯一配对闭合,功能正确(双层前缀为已知无害副作用)。
if head_ids.contains(id) { if head_ids.contains(id) {
out.push(m); out.push(m);
continue; continue;
} }
// TOOL_MISSING_PREFIX 占位头 id:已被出口断言/上一轮补过,视为配对,不重复补头。
if id.starts_with(TOOL_MISSING_PREFIX) {
out.push(m);
continue;
}
// orphan tool_result(无头)→ **补占位头自愈**(不丢 result)。 // orphan tool_result(无头)→ **补占位头自愈**(不丢 result)。
// 头 id 复用 result 的 tool_call_id 精确配对(同 assert_placeholder_pairing 自愈模式)。 // 头 id 复用 result 的 tool_call_id 精确配对(同 assert_placeholder_pairing 自愈模式)。
let head_id = format!("{}{}", TOOL_MISSING_PREFIX, id); let head_id = format!("{}{}", TOOL_MISSING_PREFIX, id);
@@ -306,13 +306,16 @@ pub fn assert_placeholder_pairing(
.collect(); .collect();
// 找 orphan tool_result(id 不在 head_ids)。占位/非占位都需补头(否则 400)。 // 找 orphan tool_result(id 不在 head_ids)。占位/非占位都需补头(否则 400)。
// 注:TOOL_MISSING_PREFIX 前缀 id 不豁免——真配对(占位头在)已被 head_ids.contains
// 覆盖;头不在的 prefix id result(前轮占位头被裁剪丢)正是 orphan,必须补头闭合。
// 补出的头 id 为前缀叠加(tool_missing_tool_missing_X,双层前缀),唯一配对,功能正确。
// 单次遍历产出新 Vec:orphan result 前插一个占位头(同 id),其余原样保留保序。 // 单次遍历产出新 Vec:orphan result 前插一个占位头(同 id),其余原样保留保序。
let mut healed = 0u32; let mut healed = 0u32;
let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len()); let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len());
for m in messages.into_iter() { for m in messages.into_iter() {
if matches!(m.role, MessageRole::Tool) { if matches!(m.role, MessageRole::Tool) {
if let Some(id) = m.tool_call_id.as_deref() { if let Some(id) = m.tool_call_id.as_deref() {
if !head_ids.contains(id) && !id.starts_with(TOOL_MISSING_PREFIX) { if !head_ids.contains(id) {
// orphan result → 前补占位头(TOOL_MISSING_PREFIX + 原 id,确保唯一且与 result 配对) // orphan result → 前补占位头(TOOL_MISSING_PREFIX + 原 id,确保唯一且与 result 配对)
let head_id = format!("{}{}", TOOL_MISSING_PREFIX, id); let head_id = format!("{}{}", TOOL_MISSING_PREFIX, id);
let head = ChatMessage::assistant_with_tools( let head = ChatMessage::assistant_with_tools(
@@ -419,6 +422,11 @@ pub fn ensure_sequence_legal(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
last.tool_calls.get_or_insert_with(Vec::new).extend(calls); last.tool_calls.get_or_insert_with(Vec::new).extend(calls);
} }
} }
// 多模态 parts 随合并迁移:空 content 纯图消息的 content 拼接为空操作,
// parts 不迁移则图片片随整条消息丢弃,图对 LLM 隐身
if let Some(parts) = m.parts {
last.parts.get_or_insert_with(Vec::new).extend(parts);
}
merged += 1; merged += 1;
continue; continue;
} }
@@ -921,9 +929,11 @@ mod tests {
} }
#[test] #[test]
fn drop_reverse_orphans_no_redundant_head_for_tool_missing_prefix_id() { fn drop_reverse_orphans_heals_prefix_id_orphan_with_nested_prefix_head() {
// 边界:orphan result 的 id 已是 TOOL_MISSING_PREFIX(出口断言/上轮补过)→ 视为已配对, // 边界(盲区修复回归):orphan result 的 id 已是 TOOL_MISSING_PREFIX 但占位头不在
// 不重复补头(否则同 id 双头致 400)。 // (前轮占位头被裁剪丢)→ 即 orphan,同样补头自愈(旧"前缀即已配对"豁免会吞掉此场景,
// 三元组不闭合直送 provider 400)。补出的头 id 为双层前缀叠加
// (tool_missing_tool_missing_X),头与 result 改写后 id 一致,唯一配对闭合。
let msgs = vec![ let msgs = vec![
ChatMessage::user("问题"), ChatMessage::user("问题"),
ChatMessage::tool_result("tool_missing_already", "已补过头的结果"), ChatMessage::tool_result("tool_missing_already", "已补过头的结果"),
@@ -936,17 +946,39 @@ mod tests {
.filter(|m| matches!(m.role, MessageRole::Tool)) .filter(|m| matches!(m.role, MessageRole::Tool))
.count(), .count(),
1, 1,
"TOOL_MISSING_PREFIX id 的 result 应保留" "TOOL_MISSING_PREFIX id 的 orphan result 应保留(补头自愈)"
); );
// 不补新头(原样保留,无新 TOOL_MISSING_PREFIX 占位头插入) // 补双层前缀占位头(头 id 含原 id 且以前缀开头)
let new_heads = sanitized let healed_heads: Vec<&str> = sanitized
.iter() .iter()
.filter(|m| matches!(m.role, MessageRole::Assistant)) .filter(|m| matches!(m.role, MessageRole::Assistant))
.filter_map(|m| m.tool_calls.as_ref()) .filter_map(|m| m.tool_calls.as_ref())
.flatten() .flatten()
.filter(|c| c.id.starts_with(TOOL_MISSING_PREFIX)) .map(|c| c.id.as_str())
.count(); .collect();
assert_eq!(new_heads, 0, "TOOL_MISSING_PREFIX id result 不应重复补头"); assert_eq!(
healed_heads.len(),
1,
"prefix id orphan 应补 1 个占位头, 实际 {:?}",
healed_heads
);
assert!(
healed_heads[0].starts_with(TOOL_MISSING_PREFIX)
&& healed_heads[0][TOOL_MISSING_PREFIX.len()..].starts_with(TOOL_MISSING_PREFIX),
"补的头 id 应为双层前缀叠加(tool_missing_tool_missing_X), 实际 {}",
healed_heads[0]
);
// result 的 tool_call_id 改写为占位头 id(闭合配对)
let result_id = sanitized
.iter()
.find(|m| matches!(m.role, MessageRole::Tool))
.and_then(|m| m.tool_call_id.clone())
.expect("应有 tool_result");
assert_eq!(
result_id,
healed_heads[0],
"result id 应与补的头 id 一致(闭合配对)"
);
} }
#[test] #[test]
@@ -1205,4 +1237,21 @@ mod tests {
sanitized.first().map(|m| format!("{:?}", m.role)) sanitized.first().map(|m| format!("{:?}", m.role))
); );
} }
/// 连续同 role 合并须迁移多模态 parts:空 content 纯图消息与相邻同 role 消息
/// 合并时,图片片并入前一条(此前只拼 content,纯图消息整条丢弃,图对 LLM 隐身)
#[test]
fn sequence_merge_moves_multimodal_parts() {
let pure_image = ChatMessage::user_parts(
"",
vec![crate::provider::ContentPart::image_url("https://x/a.png")],
);
let fixed = ensure_sequence_legal(vec![pure_image, ChatMessage::user("继续")]);
assert_eq!(fixed.len(), 1, "连续 user 应合并为一条");
let parts = fixed[0].parts.as_ref().expect("图片片应迁移到合并结果");
assert_eq!(parts.len(), 1);
assert!(parts[0].is_image(), "迁移的应是图片片");
assert_eq!(fixed[0].content, "继续");
}
} }
+61 -5
View File
@@ -48,6 +48,8 @@ impl Default for TokenEstimator {
// 2026-08-05(F-260801 Phase4):url 模式无字节可估,仅按 URL 长度估(几十字符≈几 token)会致含图 // 2026-08-05(F-260801 Phase4):url 模式无字节可估,仅按 URL 长度估(几十字符≈几 token)会致含图
// 消息 history_tokens 严重低估 → 预算裁剪不触发 → 多图/长会话超 provider 上限 400。地板按每图 // 消息 history_tokens 严重低估 → 预算裁剪不触发 → 多图/长会话超 provider 上限 400。地板按每图
// ~1600 token 保守估(对齐 Anthropic 图片 token 成本量级),宁可高估触发裁剪也不低估漏裁。 // ~1600 token 保守估(对齐 Anthropic 图片 token 成本量级),宁可高估触发裁剪也不低估漏裁。
// 注意单位是 token 不是字符:地板必须在 ×chars_ratio 折扣之后直接加 token,若混进 char_count
// 会被 0.35 稀释成 560,保底名存实亡。
const URL_IMAGE_TOKEN_FLOOR: usize = 1600; const URL_IMAGE_TOKEN_FLOOR: usize = 1600;
impl TokenEstimator { impl TokenEstimator {
@@ -59,6 +61,10 @@ impl TokenEstimator {
/// 这里把 parts 的文本/base64 按同一 chars_ratio 粗估累加(base64 视为密集字符,0.35 偏保守)。 /// 这里把 parts 的文本/base64 按同一 chars_ratio 粗估累加(base64 视为密集字符,0.35 偏保守)。
pub fn estimate_message(&self, msg: &ChatMessage) -> u32 { pub fn estimate_message(&self, msg: &ChatMessage) -> u32 {
let mut char_count = msg.content.chars().count(); let mut char_count = msg.content.chars().count();
// url 图地板单独按 token 累加:地板语义是"每图至少 ~1600 token",必须生效于
// ×chars_ratio 折扣之后——若折进 char_count 会被 0.35 稀释成 560,1600 保底失效,
// 含 url 图消息的 history_tokens 低估 → 预算裁剪漏触发 → provider 超限 400。
let mut url_image_floor_tokens: u32 = 0;
if let Some(parts) = &msg.parts { if let Some(parts) = &msg.parts {
for p in parts { for p in parts {
match p { match p {
@@ -66,19 +72,20 @@ impl TokenEstimator {
crate::provider::ContentPart::Image { base64, url, .. } => { crate::provider::ContentPart::Image { base64, url, .. } => {
// 0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。 // 0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。
// 偏保守致含图消息 token 高估、过度裁剪;降值(如 0.10~0.15)需独立评估裁剪边界,本次不改值仅标注。 // 偏保守致含图消息 token 高估、过度裁剪;降值(如 0.10~0.15)需独立评估裁剪边界,本次不改值仅标注。
// base64 优先(多模态主载荷),url 次之;url 模式无字节,仅按 URL 长度估 // base64 优先(多模态主载荷),url 次之;url 模式无字节,按地板直接计 token
if let Some(b) = base64 { if let Some(b) = base64 {
char_count += b.chars().count(); char_count += b.chars().count();
} else if url.is_some() { } else if url.is_some() {
// F-260801 Phase4:url 模式按地板估(URL 长度估严重低估,见 URL_IMAGE_TOKEN_FLOOR 注)。 // F-260801 Phase4:url 模式按地板估(URL 长度估严重低估,见 URL_IMAGE_TOKEN_FLOOR 注)。
char_count += URL_IMAGE_TOKEN_FLOOR; url_image_floor_tokens = url_image_floor_tokens
.saturating_add(URL_IMAGE_TOKEN_FLOOR as u32);
} }
} }
} }
} }
} }
let content_tokens = (char_count as f32 * self.chars_ratio).ceil() as u32; let content_tokens = (char_count as f32 * self.chars_ratio).ceil() as u32;
let mut total = content_tokens + self.per_message_overhead; let mut total = content_tokens + url_image_floor_tokens + self.per_message_overhead;
// tool_calls 的 JSON 结构开销(role=Assistant 时可能有) // tool_calls 的 JSON 结构开销(role=Assistant 时可能有)
if let Some(ref calls) = msg.tool_calls { if let Some(ref calls) = msg.tool_calls {
@@ -833,8 +840,14 @@ impl WorkingContext {
parts.push(format!("当前步骤: {} [{:?}]", step.step_name, step.status)); parts.push(format!("当前步骤: {} [{:?}]", step.step_name, step.status));
} }
// L1b: 过去 N 轮内有变更才注入 // L1b: 过去 N 轮内有变更才注入。窗口语义 = 最近 N 轮 [current_turn-N+1, current_turn]:
let threshold = self.version.current_turn.saturating_sub(recent_turns); // 变更落在第 current_turn-N 轮(即 N+1 轮前)已出窗。原实现下界取 current_turn-N 且
// 比较 >=,把 N+1 轮前的变更也划进窗口(off-by-one 多注一轮,脏段晚一轮才过期)。
let threshold = self
.version
.current_turn
.saturating_sub(recent_turns)
.saturating_add(1);
let has_active = self.goals.iter().any(|g| g.status == GoalStatus::Active); let has_active = self.goals.iter().any(|g| g.status == GoalStatus::Active);
if has_active && self.version.goals_changed_at >= threshold { if has_active && self.version.goals_changed_at >= threshold {
let active: Vec<&str> = self.goals.iter() let active: Vec<&str> = self.goals.iter()
@@ -899,6 +912,12 @@ impl WorkingContext {
if !items.is_empty() { if !items.is_empty() {
self.decisions = items; self.decisions = items;
} }
} else {
// 解析失败不吞:压缩后决策回填静默丢失会让"决策段凭空消失"无从排查,warn 留痕
tracing::warn!(
"sync_after_compress: 摘要 JSON 卡片解析失败,decisions 未恢复(卡片 {} 字节)",
json_card.len()
);
} }
self.version.reset_all(json_card, self.version.current_turn); self.version.reset_all(json_card, self.version.current_turn);
} }
@@ -1674,4 +1693,41 @@ mod tests {
let out = ctx.build_injection(3); let out = ctx.build_injection(3);
assert!(out.is_empty(), "过期后所有段均不注入, 实际: {}", out); assert!(out.is_empty(), "过期后所有段均不注入, 实际: {}", out);
} }
#[test]
fn working_context_injection_window_is_exactly_n_turns() {
// 边界:脏窗口 = 最近 N 轮。turn=1 的变更、窗口 3:
// current_turn=3 → 第 1 轮是第 3 轮前,恰在窗内(1,2,3 共 3 轮),注入;
// current_turn=4 → 第 1 轮是第 4 轮前,出窗不注入(off-by-one bug 下会误注)。
let mut ctx = WorkingContext::new();
ctx.begin_turn("窗口边界目标", 100); // current_turn=1, goals_changed_at=1
ctx.version.current_turn = 3;
assert!(
ctx.build_injection(3).contains("窗口边界目标"),
"第 N 轮前的变更恰在窗内,应注入"
);
ctx.version.current_turn = 4;
assert!(
ctx.build_injection(3).is_empty(),
"第 N+1 轮前的变更应出窗,不注入"
);
}
#[test]
fn url_image_token_floor_survives_ratio_discount() {
// url 图地板语义是"每图至少 ~1600 token",必须生效于 ×chars_ratio 折扣之后:
// 若地板折进 char_count 再乘 0.35,1600 被稀释成 ~560,保底失效 → 预算裁剪漏触发。
let est = TokenEstimator::default();
let msg = ChatMessage::user_parts(
"t",
vec![crate::provider::ContentPart::image_url("https://example.com/x.png")],
);
let tokens = est.estimate_message(&msg);
assert!(
tokens >= URL_IMAGE_TOKEN_FLOOR as u32,
"url 图消息 token({}) 应 >= 地板 {},稀释 bug 下仅 ~565",
tokens,
URL_IMAGE_TOKEN_FLOOR
);
}
} }
+53 -1
View File
@@ -420,7 +420,7 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)>
for rule in cand.rules.iter() { for rule in cand.rules.iter() {
// 英文关键词用小写匹配;中文不受 to_lowercase 影响(no-op)。 // 英文关键词用小写匹配;中文不受 to_lowercase 影响(no-op)。
let needle = rule.kw.to_lowercase(); let needle = rule.kw.to_lowercase();
if lower.contains(&needle) { if kw_matches(&lower, &needle) {
score += rule.weight; score += rule.weight;
matched = true; matched = true;
} }
@@ -437,6 +437,26 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)>
best best
} }
/// 关键词命中判定:ASCII 关键词要求两侧词边界(词字符 = ASCII 字母数字 + 下划线,与正则
/// `\b` 语义一致),防子串误判——"history" 不命中 "hi"(误判 Chat)、"rapid" 不命中
/// "api"(误判 Http)。非 ASCII(中文)关键词保持子串语义:中文无词边界概念,直接 contains。
fn kw_matches(haystack: &str, needle: &str) -> bool {
if !needle.is_ascii() {
return haystack.contains(needle);
}
let is_word_char = |c: char| c.is_ascii_alphanumeric() || c == '_';
haystack.match_indices(needle).any(|(pos, m)| {
haystack[..pos]
.chars()
.next_back()
.map_or(true, |c| !is_word_char(c))
&& haystack[pos + m.len()..]
.chars()
.next()
.map_or(true, |c| !is_word_char(c))
})
}
// ---- Intent → 工具子集映射 -------------------------------------------------- // ---- Intent → 工具子集映射 --------------------------------------------------
/// 按 Intent 返回工具名子集。 /// 按 Intent 返回工具名子集。
@@ -854,6 +874,38 @@ mod tests {
assert_eq!(i, Intent::Http); assert_eq!(i, Intent::Http);
} }
// --- ASCII 关键词词边界(防子串误判) ---
#[test]
fn word_boundary_history_not_chat() {
// "history" 内嵌 "hi" 不再判 Chat(无其他关键词 → Unknown)
let (i, c) = IntentRecognizer::recognize("show me the history");
assert_eq!(i, Intent::Unknown, "history 不应命中 hi, 实际 conf={}", c);
}
#[test]
fn word_boundary_rapid_not_http() {
// "rapid" 内嵌 "api" 不再判 Http
let (i, c) = IntentRecognizer::recognize("rapid growth");
assert_eq!(i, Intent::Unknown, "rapid 不应命中 api, 实际 conf={}", c);
}
#[test]
fn standalone_english_keywords_still_hit() {
// 词边界不误伤独立词命中
let (i, _) = IntentRecognizer::recognize("call the api");
assert_eq!(i, Intent::Http);
let (i, _) = IntentRecognizer::recognize("hi");
assert_eq!(i, Intent::Chat);
}
#[test]
fn cjk_adjacent_ascii_keyword_still_hits() {
// 中文紧邻 ASCII 关键词:非 ASCII 字符算边界,不破坏中文场景命中
let (i, _) = IntentRecognizer::recognize("调用这个api");
assert_eq!(i, Intent::Http);
}
// --- tool_subset_for --- // --- tool_subset_for ---
#[test] #[test]
+46 -7
View File
@@ -522,17 +522,23 @@ impl LlmProvider for OpenAICompatProvider {
error!(%status, %body, "LLM API 调用失败(不可重试)"); error!(%status, %body, "LLM API 调用失败(不可重试)");
return AttemptOutcome::Fatal(msg); return AttemptOutcome::Fatal(msg);
} }
// body 解析: serde 结构错属 Fatal(响应已成功送达,重试同样失败); // body 解析:text 先取再 serde 分层判定:读取中断(text Err,reqwest 归
// body 读取中途断连在 reqwest 同归 is_decode,重发幂等安全 → 可重试。 // is_decode 类,重发幂等安全)可重试;JSON 结构错属 Fatal(响应已完整送达,
let body: OpenAiResponse = match resp.json().await { // 重试同败)。Response::json 把 serde 错同包成 is_decode,先 text 后
Ok(b) => b, // from_str 才能区分二者,避免确定性结构错被重试白烧预算。
let text = match resp.text().await {
Ok(t) => t,
Err(e) => { Err(e) => {
if e.is_decode() { if is_reqwest_error_retryable(&e) || e.is_decode() {
return AttemptOutcome::Retryable(format!("响应解码失败(可重试): {}", e)); return AttemptOutcome::Retryable(format!("响应读取失败(可重试): {}", e));
} }
return AttemptOutcome::Fatal(format!("响应解析失败: {}", e)); return AttemptOutcome::Fatal(format!("响应读取失败(不可重试): {}", e));
} }
}; };
let body: OpenAiResponse = match serde_json::from_str(&text) {
Ok(b) => b,
Err(e) => return AttemptOutcome::Fatal(format!("响应解析失败: {}", e)),
};
let choice = match body.choices.into_iter().next() { let choice = match body.choices.into_iter().next() {
Some(c) => c, Some(c) => c,
None => return AttemptOutcome::Fatal("LLM 响应无 choices".to_string()), None => return AttemptOutcome::Fatal("LLM 响应无 choices".to_string()),
@@ -858,6 +864,39 @@ mod tests {
assert_eq!(c.error.as_deref(), Some("stream error"), "无 message 字段应兜底"); assert_eq!(c.error.as_deref(), Some("stream error"), "无 message 字段应兜底");
} }
/// 标准 error 帧不带 choices 键 → 仍须解析出 error(此前 choices 必填,
/// 整帧 serde 失败走空 chunk 分支,error 被吞、流呈无声截断)
#[test]
fn openai_sse_error_frame_without_choices_key_parses() {
let mut acc: Option<TokenUsage> = None;
let data = r#"{"error":{"message":"insufficient quota","type":"insufficient_quota"}}"#;
let c = apply_openai_sse(data, &mut acc);
assert!(!c.finished, "error 帧不应走 finished 完成路径");
assert_eq!(c.delta, "");
assert_eq!(c.error.as_deref(), Some("insufficient quota"));
assert!(c.usage.is_none());
}
/// usage 尾帧不带 choices 键(OpenAI include_usage 标准形态)→ 累加进 usage,
/// [DONE] 终态带出(此前 choices 必填,该帧被整帧丢弃,usage 永远拿不到)
#[test]
fn openai_sse_usage_tail_frame_without_choices_key_accumulates() {
let mut acc: Option<TokenUsage> = None;
let data = r#"{"usage":{"prompt_tokens":7,"completion_tokens":3,"total_tokens":10}}"#;
let c = apply_openai_sse(data, &mut acc);
assert_eq!(c.delta, "");
assert!(!c.finished);
let acc_after_tail = acc.as_ref().expect("usage 尾帧应写入累加器");
assert_eq!(acc_after_tail.prompt_tokens, 7);
assert_eq!(acc_after_tail.completion_tokens, 3);
let done = apply_openai_sse("[DONE]", &mut acc);
assert!(done.finished);
let usage = done.usage.expect("[DONE] 应带出累计 usage");
assert_eq!(usage.prompt_tokens, 7);
assert_eq!(usage.total_tokens, 10);
}
// ---------- 多模态 convert_request ---------- // ---------- 多模态 convert_request ----------
/// 含图消息 → content 数组(text + image_url data URI);纯文本 → 字符串简写 /// 含图消息 → content 数组(text + image_url data URI);纯文本 → 字符串简写
+33 -5
View File
@@ -191,6 +191,10 @@ impl From<OpenAiUsageRaw> for OpenAiUsage {
/// SSE 流式响应 chunk /// SSE 流式响应 chunk
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct OpenAiStreamChunk { pub(crate) struct OpenAiStreamChunk {
/// 流式 choice 数组。标准 error 帧(`{"error":{..}}`)与 usage 尾帧均不带 choices 键,
/// 缺省为空数组才能整帧解析成功;必填会让这类合法帧 serde 失败走空 chunk 分支,
/// 流呈无声截断。
#[serde(default)]
pub choices: Vec<OpenAiStreamChoice>, pub choices: Vec<OpenAiStreamChoice>,
/// 末 chunk(choices 为空)携带的累计 usage /// 末 chunk(choices 为空)携带的累计 usage
#[serde(default)] #[serde(default)]
@@ -204,11 +208,13 @@ pub(crate) struct OpenAiStreamChunk {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct OpenAiStreamChoice { pub(crate) struct OpenAiStreamChoice {
/// 部分 usage 尾帧的 choice 仅带 finish_reason 无 delta 键,缺省按空 delta 处理
#[serde(default)]
pub delta: OpenAiStreamDelta, pub delta: OpenAiStreamDelta,
pub finish_reason: Option<String>, pub finish_reason: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Default, Deserialize)]
pub(crate) struct OpenAiStreamDelta { pub(crate) struct OpenAiStreamDelta {
pub content: Option<String>, pub content: Option<String>,
pub tool_calls: Option<Vec<OpenAiStreamToolCall>>, pub tool_calls: Option<Vec<OpenAiStreamToolCall>>,
@@ -219,6 +225,8 @@ pub(crate) struct OpenAiStreamDelta {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct OpenAiStreamToolCall { pub(crate) struct OpenAiStreamToolCall {
/// 部分端点 tool_call 增量省略 index,缺省 0(单工具调用场景语义等价)
#[serde(default)]
pub index: u32, pub index: u32,
pub id: Option<String>, pub id: Option<String>,
pub function: Option<OpenAiStreamFunction>, pub function: Option<OpenAiStreamFunction>,
@@ -299,10 +307,13 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
} }
if let Some(choice) = chunk.choices.into_iter().next() { if let Some(choice) = chunk.choices.into_iter().next() {
let delta_text = choice.delta.content.unwrap_or_default(); let delta_text = choice.delta.content.unwrap_or_default();
// "length" = max_tokens 截断,属正常终止(非断连),纳入 finished // 终态 finish_reason:stop(正常结束)/ tool_calls(转交工具执行)/
let finished = choice.finish_reason.as_deref() == Some("stop") // length(max_tokens 截断,属正常终止非断连)/ content_filter(内容审查拦截)。
|| choice.finish_reason.as_deref() == Some("tool_calls") // content_filter 的流部分 provider 不发 [DONE] 帧,不纳入终态会一直悬挂
|| choice.finish_reason.as_deref() == Some("length"); let finished = matches!(
choice.finish_reason.as_deref(),
Some("stop") | Some("tool_calls") | Some("length") | Some("content_filter")
);
let tool_calls = choice.delta.tool_calls.map(|tcs| { let tool_calls = choice.delta.tool_calls.map(|tcs| {
tcs.into_iter() tcs.into_iter()
@@ -411,4 +422,21 @@ mod tests {
assert_eq!(usage.prompt_cache_hit_tokens, 64); assert_eq!(usage.prompt_cache_hit_tokens, 64);
assert_eq!(usage.prompt_cache_miss_tokens, 36); assert_eq!(usage.prompt_cache_miss_tokens, 36);
} }
/// content_filter 属终态:敏感内容拦截的流部分 provider 不发 [DONE] 帧,
/// 必须靠 finish_reason 收敛,否则悬挂;未列入终态的 finish_reason 保持非终态
#[test]
fn finish_reason_content_filter_is_terminal() {
let data = r#"{"choices":[{"delta":{"content":""},"finish_reason":"content_filter"}]}"#;
let mut accum = None;
let chunk = apply_openai_sse(data, &mut accum);
assert!(chunk.finished, "content_filter 应收敛为终态,不再悬挂");
let mut accum2 = None;
let chunk2 = apply_openai_sse(
r#"{"choices":[{"delta":{"content":"x"},"finish_reason":null}]}"#,
&mut accum2,
);
assert!(!chunk2.finished, "无终态 finish_reason 的普通 delta 不应终结");
}
} }
+91 -6
View File
@@ -17,7 +17,8 @@
//! data: 前缀提取) //! data: 前缀提取)
//! - 多字节续接:事件体只在定位到完整分隔符后才解码,跨 chunk 的多字节字符在 //! - 多字节续接:事件体只在定位到完整分隔符后才解码,跨 chunk 的多字节字符在
//! buffer 中天然拼接复原(分隔符为 ASCII,不落在多字节序列中间) //! buffer 中天然拼接复原(分隔符为 ASCII,不落在多字节序列中间)
//! - 容错:解析失败时跳过该事件继续,不中断流;buffer 设 `BUF_MAX` 上限防 OOM //! - 容错:解析失败时跳过该事件继续,不中断流;buffer 设 `BUF_MAX` 上限防 OOM,
//! 超限时按事件边界对齐保尾裁剪(丢最旧的超限事件整体),不产出半截破损事件
//! - 返回 Vec<String>(每个元素是一个事件 data 字段拼接内容) //! - 返回 Vec<String>(每个元素是一个事件 data 字段拼接内容)
use futures::Stream; use futures::Stream;
@@ -35,6 +36,9 @@ const BUF_MAX: usize = 1 << 20; // 1 MiB
pub struct SseStream<S> { pub struct SseStream<S> {
inner: S, inner: S,
buffer: Vec<u8>, buffer: Vec<u8>,
/// 超限裁剪置位:无边界的超限事件已整体丢弃,但其尾部字节仍在后续 chunk 中,
/// 到达收尾 boundary 前的数据须静默吞掉,避免残缺事件体被当完整事件产出
skip_partial: bool,
} }
impl<S> SseStream<S> impl<S> SseStream<S>
@@ -45,6 +49,7 @@ where
Self { Self {
inner, inner,
buffer: Vec::with_capacity(8192), buffer: Vec::with_capacity(8192),
skip_partial: false,
} }
} }
@@ -62,6 +67,17 @@ where
/// 字符在 buffer 中天然拼接复原(分隔符为 ASCII,不会落在多字节序列中间)。 /// 字符在 buffer 中天然拼接复原(分隔符为 ASCII,不会落在多字节序列中间)。
/// 解码用 `String::from_utf8`(失败再降级 lossy),避免把合法多字节误判为残缺。 /// 解码用 `String::from_utf8`(失败再降级 lossy),避免把合法多字节误判为残缺。
fn parse_events(&mut self) -> Vec<SseEvent> { fn parse_events(&mut self) -> Vec<SseEvent> {
// 超限裁剪收尾:被整体丢弃的残缺事件,其后续字节以首个 boundary 收尾,
// 此处静默吞掉不产出,之后的正常事件恢复正常解析
if self.skip_partial {
match Self::find_event_boundary(&self.buffer) {
Some((pos, len)) => {
self.buffer.drain(..pos + len);
self.skip_partial = false;
}
None => return Vec::new(),
}
}
let mut events = Vec::new(); let mut events = Vec::new();
loop { loop {
// 同时识别三种分隔符,取最早出现的位置与该分隔符的字节长度 // 同时识别三种分隔符,取最早出现的位置与该分隔符的字节长度
@@ -149,10 +165,23 @@ where
Poll::Ready(Some(Ok(chunk))) => { Poll::Ready(Some(Ok(chunk))) => {
self.buffer.extend_from_slice(&chunk); self.buffer.extend_from_slice(&chunk);
// 防御:缓冲区不应无限增长。正常情况下 parse_events 会持续消费, // 防御:缓冲区不应无限增长。正常情况下 parse_events 会持续消费,
// 仅当上游持续不发分隔符(畸形流)时才触发,此处裁掉头部旧数据避免 OOM。 // 超限只发生在单个事件体超过 BUF_MAX 的场景(畸形上游不发分隔符,
// 或超大单事件)。按字节盲截头部会把半截事件体留给后续解析,产出
// 破损 JSON 且无法复原;改为对齐事件边界的保尾裁剪:
// - 已有完整分隔符 → 最旧的一个事件(超限源头)整体丢弃,保留的
// 数据从干净的事件起点开始,后续事件解析不受损;
// - 全程无分隔符 → 整体丢弃并置 skip_partial,该事件的收尾字节
// 到达时静默吞掉。
if self.buffer.len() > BUF_MAX { if self.buffer.len() > BUF_MAX {
let drop_n = self.buffer.len() - BUF_MAX; match Self::find_event_boundary(&self.buffer) {
self.buffer.drain(..drop_n); Some((pos, len)) => {
self.buffer.drain(..pos + len);
}
None => {
self.buffer.clear();
self.skip_partial = true;
}
}
} }
continue; continue;
} }
@@ -160,8 +189,9 @@ where
return Poll::Ready(Some(Err(format!("SSE 流读取错误: {}", e)))); return Poll::Ready(Some(Err(format!("SSE 流读取错误: {}", e))));
} }
Poll::Ready(None) => { Poll::Ready(None) => {
// 流结束,处理 buffer 中的剩余数据(可能没有 \n\n 结束的最后一段) // 流结束,处理 buffer 中的剩余数据(可能没有 \n\n 结束的最后一段);
if !self.buffer.is_empty() { // skip_partial 置位时剩余数据是被丢弃残缺事件的尾段,不得溢出为事件
if !self.buffer.is_empty() && !self.skip_partial {
// 解码:优先严格 UTF-8(EOF 无下一 chunk 可拼接, // 解码:优先严格 UTF-8(EOF 无下一 chunk 可拼接,
// 残缺多字节尾部只能尽力而为,降级 lossy 不崩) // 残缺多字节尾部只能尽力而为,降级 lossy 不崩)
let mut remaining = match String::from_utf8(self.buffer.clone()) { let mut remaining = match String::from_utf8(self.buffer.clone()) {
@@ -185,3 +215,58 @@ where
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use futures::stream;
/// 构造只含 Ok chunk 的内存流,跑完整个 SseStream,返回拍平的事件序列
fn run_chunks(chunks: Vec<String>) -> Vec<SseEvent> {
use futures::StreamExt;
let items: Vec<Result<bytes::Bytes, reqwest::Error>> = chunks
.into_iter()
.map(|c| Ok(bytes::Bytes::from(c)))
.collect();
let mut s = SseStream::new(stream::iter(items));
futures::executor::block_on(async {
let mut out = Vec::new();
while let Some(item) = s.next().await {
out.extend(item.expect("测试流不应产生错误"));
}
out
})
}
/// 回归:正常多事件流照常解析
#[test]
fn normal_events_parse() {
let events = run_chunks(vec![
"data: {\"a\":1}\n\n".into(),
"data: {\"b\":2}\n\n".into(),
]);
assert_eq!(events, vec!["{\"a\":1}".to_string(), "{\"b\":2}".to_string()]);
}
/// 超限单事件(末尾带完整分隔符)整体丢弃:不产出半截破损事件,后续正常事件照常解析。
/// 旧按字节截头实现会把残留尾部 "data: TAIL" 行当伪事件产出
#[test]
fn oversize_event_with_boundary_dropped_whole() {
let giant = format!("data: {}\ndata: TAIL\n\n", "x".repeat(BUF_MAX));
let events = run_chunks(vec![giant, "data: {\"ok\":1}\n\n".into()]);
assert_eq!(events, vec!["{\"ok\":1}".to_string()]);
}
/// 验收:超限注入(无边界大块)丢弃后,残缺事件收尾静默吞掉,后续正常事件仍可解析。
/// 旧按字节截头实现会把 STALE-TAIL 残段当伪事件产出
#[test]
fn oversize_boundaryless_blob_then_normal_events_still_parse() {
let events = run_chunks(vec![
"x".repeat(BUF_MAX + 100),
"data: STALE-TAIL\n\n".into(),
"data: {\"ok\":1}\n\n".into(),
"data: [DONE]\n\n".into(),
]);
assert_eq!(events, vec!["{\"ok\":1}".to_string(), "[DONE]".to_string()]);
}
}
+88 -35
View File
@@ -40,8 +40,31 @@ fn ai_provider_from_row(row: &Row<'_>) -> std::result::Result<AiProviderRecord,
.unwrap_or_default(), .unwrap_or_default(),
} }
}; };
// id 先读:① 结构体字段必填列(缺失直接 ? 报错);② enabled/weight 降级时日志带行标识,
// 坏数据可定位到具体行而非只剩全局报警。
let id: String = row.get("id")?;
// enabled/weight 列老库经 v19 迁移补建,DEFAULT 1 / DEFAULT 50。get 失败(列缺失
// 或 NULL 坏数据)降级默认值保功能(防单行坏数据拖垮 list_all),但 warn 留痕:
// 正常新库列必然存在,此分支触发即迁移未跑/表结构被外力改动,坏数据可观测。
// from_row 按 i32 取列值兼容(SQLite 无真 BOOLEAN),0→false/非0→true。
let enabled = match row.get::<_, i32>("enabled") {
Ok(v) => v != 0,
Err(e) => {
tracing::warn!("ai_providers[{id}] enabled 列读取失败({e}),降级默认 enabled=true");
true
}
};
// weight 读侧 clamp [0,100]:与 insert/update_full 落库的 `.min(100)` 对齐,
// 防老库(clamp 落地前写入的)或外部直改 DB 产生的越界值污染路由权重语义。
let weight = match row.get::<_, i32>("weight") {
Ok(v) => v.clamp(0, 100) as u32,
Err(e) => {
tracing::warn!("ai_providers[{id}] weight 列读取失败({e}),降级默认 weight=50");
50
}
};
Ok(AiProviderRecord { Ok(AiProviderRecord {
id: row.get("id")?, id,
name: row.get("name")?, name: row.get("name")?,
provider_type: row.get("provider_type")?, provider_type: row.get("provider_type")?,
api_key: row.get("api_key")?, api_key: row.get("api_key")?,
@@ -53,14 +76,8 @@ fn ai_provider_from_row(row: &Row<'_>) -> std::result::Result<AiProviderRecord,
config: row.get("config")?, config: row.get("config")?,
created_at: row.get("created_at")?, created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?, updated_at: row.get("updated_at")?,
// enabled/weight 列老库经 v19 迁移补建,DEFAULT 1 / DEFAULT 50。 enabled,
// from_row 按 i32 取列值兼容(SQLite 无真 BOOLEAN),0→false/非0→true。 weight,
enabled: row.get::<_, i32>("enabled").unwrap_or(1) != 0,
// weight 读侧 clamp [0,100]:与 insert/update_full 落库的 `.min(100)` 对齐,
// 防老库(clamp 落地前写入的)或外部直改 DB 产生的越界值污染路由权重语义。
weight: row.get::<_, i32>("weight")
.unwrap_or(50)
.clamp(0, 100) as u32,
}) })
} }
@@ -88,6 +105,9 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
pending_approvals: row.get("pending_approvals")?, pending_approvals: row.get("pending_approvals")?,
created_at: row.get("created_at")?, created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?, updated_at: row.get("updated_at")?,
// origin 列老库经 v44 迁移补建。unwrap_or(None) 兜底(同 message_id v21 模式):
// 迁移未跑/极端情况防御,NULL=主会话。
origin: row.get("origin").unwrap_or(None),
}) })
} }
@@ -163,13 +183,13 @@ impl_repo!(
from_row => |row| ai_conversation_from_row(row), from_row => |row| ai_conversation_from_row(row),
insert => |conn, rec| { insert => |conn, rec| {
conn.execute( conn.execute(
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at) "INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at, origin)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
params![ params![
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
if rec.pinned { 1i32 } else { 0i32 }, if rec.pinned { 1i32 } else { 0i32 },
rec.prompt_tokens, rec.completion_tokens, rec.prompt_tokens, rec.completion_tokens,
rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at, rec.origin
], ],
) )
}, },
@@ -261,33 +281,28 @@ impl AiToolExecutionRepo {
return Ok(()); return Ok(());
} }
let conn = self.conn.clone(); let conn = self.conn.clone();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || -> Result<()> {
let guard = conn.blocking_lock(); let mut guard = conn.blocking_lock();
guard.execute_batch("BEGIN").map_err(storage_err)?; // transaction() guard 范式(同 clear_conversation_atomic / message_repo::insert_batch):
let result = (|| -> std::result::Result<(), rusqlite::Error> { // rusqlite Transaction 在 Drop 未 commit 时自动回滚,中途 panic/早退不残留打开事务;
let mut stmt = guard.prepare( // 原手写 BEGIN/COMMIT 无此保证,panic 后连接带开事务,后续写全部静默失败。
let tx = guard.transaction().map_err(storage_err)?;
{
let mut stmt = tx.prepare(
"INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by) "INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
)?; )
.map_err(storage_err)?;
for rec in &records { for rec in &records {
stmt.execute(params![ stmt.execute(params![
rec.id, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name, rec.id, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name,
rec.arguments, rec.result, rec.status, rec.risk_level, rec.arguments, rec.result, rec.status, rec.risk_level,
rec.requested_at, rec.executed_at, rec.decided_by rec.requested_at, rec.executed_at, rec.decided_by
])?; ])
} .map_err(storage_err)?;
Ok(())
})();
match result {
Ok(()) => {
guard.execute_batch("COMMIT").map_err(storage_err)?;
}
Err(e) => {
// 回滚失败静默(尽量保一致性;rollback 失败通常是连接已坏,交给上层)
let _ = guard.execute_batch("ROLLBACK");
return Err(storage_err(e));
} }
} }
tx.commit().map_err(storage_err)?;
Ok(()) Ok(())
}) })
.await .await
@@ -606,6 +621,12 @@ impl AiConversationRepo {
/// 参数化绑定(替代原调用方的 format! 拼 SQL + execute_batch),防 snapshot 含引号/ /// 参数化绑定(替代原调用方的 format! 拼 SQL + execute_batch),防 snapshot 含引号/
/// 特殊字符致注入或损坏。列对齐 conversation_checkpoints(id, conv_id, snapshot, /// 特殊字符致注入或损坏。列对齐 conversation_checkpoints(id, conv_id, snapshot,
/// token_total, created_at)。 /// token_total, created_at)。
///
/// 冲突语义:调用方 ck_id 由 (conv, 消息数) 确定性生成,同轮重复 save 撞同 id 是
/// 预期幂等跳过;但静默会掩盖另一情形——会话回退/重生成回同消息数且内容已变,
/// 新 snapshot 被旧 checkpoint 挡住未落库。affected=0 时 warn 留痕,两种情形都
/// 可观测(是否异常由调用方日志上下文判别)。签名保 Result<()> 不外泄跳过信息:
/// 跳过非错误,调用方对 Err 已按 warn 处理,无需改调用点。
pub async fn insert_checkpoint( pub async fn insert_checkpoint(
&self, &self,
id: &str, id: &str,
@@ -619,20 +640,25 @@ impl AiConversationRepo {
let conv_id = conv_id.to_string(); let conv_id = conv_id.to_string();
let snapshot = snapshot.to_string(); let snapshot = snapshot.to_string();
let created_at = created_at.to_string(); let created_at = created_at.to_string();
let _ = tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || -> Result<()> {
let guard = conn.blocking_lock(); let guard = conn.blocking_lock();
guard let affected = guard
.execute( .execute(
"INSERT OR IGNORE INTO conversation_checkpoints \ "INSERT OR IGNORE INTO conversation_checkpoints \
(id, conv_id, snapshot, token_total, created_at) \ (id, conv_id, snapshot, token_total, created_at) \
VALUES (?1, ?2, ?3, ?4, ?5)", VALUES (?1, ?2, ?3, ?4, ?5)",
params![id, conv_id, snapshot, token_total, created_at], params![id, conv_id, snapshot, token_total, created_at],
) )
.map_err(storage_err) .map_err(storage_err)?;
if affected == 0 {
tracing::warn!(
"checkpoint 幂等跳过(同 id 已存在,新 snapshot 未落库): id={id} conv={conv_id}"
);
}
Ok(())
}) })
.await .await
.map_err(storage_err)??; .map_err(storage_err)?
Ok(())
} }
/// 清空对话消息内容(保留 conversation 记录本身,只清 messages JSON + 清零 token 计数) /// 清空对话消息内容(保留 conversation 记录本身,只清 messages JSON + 清零 token 计数)
@@ -943,4 +969,31 @@ mod tests {
let got2 = repo.find_by_conv_tool_args("conv1", "write_file", "{\"path\":\"a.go\"}").await.expect("query"); let got2 = repo.find_by_conv_tool_args("conv1", "write_file", "{\"path\":\"a.go\"}").await.expect("query");
assert_eq!(got2.unwrap().id, "a2", "同三元组应返回最新落定记录"); assert_eq!(got2.unwrap().id, "a2", "同三元组应返回最新落定记录");
} }
/// insert_checkpoint 冲突语义:同 id 二次写入 Ok 不报错(幂等跳过,affected=0 走 warn),
/// 保留首次 snapshot。锁死 OR IGNORE 行为,防未来误改成 REPLACE 静默覆盖快照。
#[tokio::test]
async fn insert_checkpoint_conflict_skips_and_keeps_first() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiConversationRepo::new(&db);
repo.insert_checkpoint("ck_1", "conv1", r#"[{"seq":0}]"#, 1, "100")
.await
.expect("首次写入");
// 同 id 不同内容:IGNORE 生效应 Ok,首次 snapshot 保留
repo.insert_checkpoint("ck_1", "conv1", r#"[{"seq":0},{"seq":1}]"#, 2, "200")
.await
.expect("冲突写入应幂等跳过不报错");
let conn = db.conn();
let g = conn.lock().await;
let count: i64 = g
.query_row("SELECT COUNT(*) FROM conversation_checkpoints WHERE id = 'ck_1'", [], |r| r.get(0))
.expect("count");
assert_eq!(count, 1, "同 id 只应有一行");
let snapshot: String = g
.query_row("SELECT snapshot FROM conversation_checkpoints WHERE id = 'ck_1'", [], |r| r.get(0))
.expect("snapshot");
assert_eq!(snapshot, r#"[{"seq":0}]"#, "冲突时应保留首次快照");
}
} }
+34 -3
View File
@@ -104,7 +104,16 @@ fn idea_from_row(row: &Row<'_>) -> std::result::Result<IdeaRecord, rusqlite::Err
description: row.get("description")?, description: row.get("description")?,
status: { status: {
let s: String = row.get("status")?; let s: String = row.get("status")?;
IdeaStatus::from_db_str(&s).unwrap_or_default() match IdeaStatus::from_db_str(&s) {
Some(v) => v,
None => {
// 未知 status 静默降级默认值后,后续 update_full 回写会把库里的未知值
// 覆盖成默认值(读→改→写放大静默),warn 留痕让脏数据可发现。
let id: String = row.get("id")?;
tracing::warn!("ideas.id={id} 含未知 status {s:?},按默认值降级(回写会覆盖原值,请核查)");
Default::default()
}
}
}, },
priority: row.get("priority")?, priority: row.get("priority")?,
score: row.get("score")?, score: row.get("score")?,
@@ -613,7 +622,7 @@ impl IdeaRepo {
/// ///
/// 不动 GUI 的无条件 [`update_full`](无 expected 语义,保持现状);本方法仅服务 /// 不动 GUI 的无条件 [`update_full`](无 expected 语义,保持现状);本方法仅服务
/// 需要乐观锁版本的调用方(df-mcp update_idea / score_idea)。 /// 需要乐观锁版本的调用方(df-mcp update_idea / score_idea)。
/// `Ok(false)` = id 不存在或版本冲突。 /// `Ok(false)` = id 不存在、已进回收站(deleted_at IS NULL 守卫,V28 灵感软删)或版本冲突。
pub async fn update_full_cas( pub async fn update_full_cas(
&self, &self,
record: &IdeaRecord, record: &IdeaRecord,
@@ -626,7 +635,8 @@ impl IdeaRepo {
let guard = conn.blocking_lock(); let guard = conn.blocking_lock();
let affected = guard let affected = guard
.execute( .execute(
"UPDATE ideas SET title = ?1, description = ?2, status = ?3, priority = ?4, score = ?5, tags = ?6, source = ?7, promoted_to = ?8, ai_analysis = ?9, scores = ?10, related_ids = ?11, updated_at = ?12 WHERE id = ?13 AND updated_at = ?14", // deleted_at IS NULL:回收站灵感不可被 CAS 覆盖(同 tasks/projects 同族守卫)
"UPDATE ideas SET title = ?1, description = ?2, status = ?3, priority = ?4, score = ?5, tags = ?6, source = ?7, promoted_to = ?8, ai_analysis = ?9, scores = ?10, related_ids = ?11, updated_at = ?12 WHERE id = ?13 AND updated_at = ?14 AND deleted_at IS NULL",
params![ params![
rec.title, rec.description, rec.status.as_str(), rec.priority, rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis, rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
@@ -1992,6 +2002,27 @@ mod tests {
assert_eq!(after.updated_at, "1800000000000"); assert_eq!(after.updated_at, "1800000000000");
} }
#[tokio::test]
async fn idea_update_full_cas_skips_soft_deleted() {
// 回收站灵感不可被 CAS 命中(deleted_at IS NULL 守卫,V28 软删)
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "原标题")).await.unwrap();
repo.soft_delete("i1").await.unwrap();
// soft_delete 刷新了 updated_at,取当前值作 expected(排除版本不匹配干扰)
let current = repo.get_by_id("i1").await.unwrap().unwrap();
let mut rec = current.clone();
rec.title = "回收站里改标题".to_string();
rec.updated_at = "1900000000000".to_string();
let ok = repo
.update_full_cas(&rec, &current.updated_at)
.await
.unwrap();
assert!(!ok, "软删灵感 CAS 应拒绝(deleted_at 守卫)");
let after = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(after.title, "原标题", "回收站灵感内容不应被改写");
assert_eq!(after.updated_at, current.updated_at, "updated_at 不应抖动");
}
#[tokio::test] #[tokio::test]
async fn idea_update_full_cas_conflict_when_expected_stale() { async fn idea_update_full_cas_conflict_when_expected_stale() {
let repo = setup_idea_repo().await; let repo = setup_idea_repo().await;
+32 -2
View File
@@ -147,6 +147,10 @@ impl AiMessageRepo {
/// list_by_conversation 一致的 seq 升序)。`before_seq` 可选:指定后只取 seq < before_seq /// list_by_conversation 一致的 seq 升序)。`before_seq` 可选:指定后只取 seq < before_seq
/// 的消息(滚顶加载更多时的游标,取下一页更早的历史)。 /// 的消息(滚顶加载更多时的游标,取下一页更早的历史)。
/// ///
/// `limit = 0` 契约:返空集(SQLite LIMIT 0 合法且恒 0 行),不做旧版的 `.max(1)`
/// 静默钳制——请求 0 条却回 1 条会让调用方误判页内有数据。当前唯一调用方传
/// 常量 PAGE_SIZE=50 恒 >0,0 只可能来自未来调用方失误,空集是最不易误导的语义。
///
/// 典型用法: /// 典型用法:
/// - 首次切入对话:list_recent(conv_id, 50, None) → 最近 50 条 /// - 首次切入对话:list_recent(conv_id, 50, None) → 最近 50 条
/// - 滚顶加载更多:list_recent(conv_id, 50, Some(最早已加载消息的 seq)) → 再加载 50 条更早的 /// - 滚顶加载更多:list_recent(conv_id, 50, Some(最早已加载消息的 seq)) → 再加载 50 条更早的
@@ -160,8 +164,9 @@ impl AiMessageRepo {
let conv_id = conversation_id.to_owned(); let conv_id = conversation_id.to_owned();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock(); let guard = conn.blocking_lock();
// before_seq 有无分两个 SQL(参数化 LIMIT 必须用固定占位,Rust 侧 clamp 防 0) // before_seq 有无分两个 SQL(参数化 LIMIT 必须用固定占位);limit=0 直传
let limit = limit.max(1) as i64; // LIMIT 0 返空集(契约见方法文档),不再钳成 1
let limit = limit as i64;
let sql = if before_seq.is_some() { let sql = if before_seq.is_some() {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id, "SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
@@ -681,4 +686,29 @@ mod tests {
let got = repo.list_by_conversation("c").await.expect("list"); let got = repo.list_by_conversation("c").await.expect("list");
assert_eq!(got.len(), 1, "重复 replace 不应叠加"); assert_eq!(got.len(), 1, "重复 replace 不应叠加");
} }
/// limit=0 契约:返空集(直传 SQLite LIMIT 0),不再旧版 max(1) 钳成 1 条。
/// 锁死语义防回归:请求 0 条却回 1 条会让调用方误判页内有数据。
#[tokio::test]
async fn list_recent_limit_zero_returns_empty() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiMessageRepo::new(&db);
repo.insert_batch(vec![
mk_msg("m0", "c", 0, "user", "0"),
mk_msg("m1", "c", 1, "user", "1"),
])
.await
.expect("insert");
let got = repo.list_recent("c", 0, None).await.expect("list limit=0");
assert!(got.is_empty(), "limit=0 无游标应返空集");
let got2 = repo.list_recent("c", 0, Some(5)).await.expect("list limit=0 带游标");
assert!(got2.is_empty(), "limit=0 带游标也应返空集");
// 邻位 sanity:limit=1 行为不受影响,仍返最近 1 条
let got3 = repo.list_recent("c", 1, None).await.expect("list limit=1");
assert_eq!(got3.len(), 1);
assert_eq!(got3[0].seq, 1, "limit=1 应取 seq 最大(最新)一条");
}
} }
@@ -169,17 +169,28 @@ impl ProjectModuleRepo {
} }
/// 按 id 硬删工程(工程不需要软删审计追溯)。 /// 按 id 硬删工程(工程不需要软删审计追溯)。
///
/// 单事务先清 module_dependencies 依赖边(from/to 任一端引用本工程)再删工程:
/// module_dependencies 的 FK 无 ON DELETE 级联且 PRAGMA foreign_keys=ON,带着依赖边
/// 直接删工程必 FK 违例回滚(调用侧 module.rs/task_graph 各自清边的兜底收口到本层)。
pub async fn delete(&self, id: &str) -> Result<bool, df_types::error::Error> { pub async fn delete(&self, id: &str) -> Result<bool, df_types::error::Error> {
let conn = self.conn.clone(); let conn = self.conn.clone();
let id = id.to_owned(); let id = id.to_owned();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock(); let mut guard = conn.blocking_lock();
let affected = guard let tx = guard.transaction().map_err(storage_err)?;
tx.execute(
"DELETE FROM module_dependencies WHERE from_module_id = ?1 OR to_module_id = ?1",
params![id],
)
.map_err(storage_err)?;
let affected = tx
.execute( .execute(
"DELETE FROM project_modules WHERE id = ?1", "DELETE FROM project_modules WHERE id = ?1",
params![id], params![id],
) )
.map_err(storage_err)?; .map_err(storage_err)?;
tx.commit().map_err(storage_err)?;
Ok(affected > 0) Ok(affected > 0)
}) })
.await .await
@@ -187,17 +198,26 @@ impl ProjectModuleRepo {
} }
/// 按项目删除全部工程(删项目时级联清理,硬删)。 /// 按项目删除全部工程(删项目时级联清理,硬删)。
///
/// 单事务先清本项目全部依赖边再删工程(顺序同 [`delete`],防 FK 违例)。
pub async fn delete_by_project(&self, project_id: &str) -> Result<bool, df_types::error::Error> { pub async fn delete_by_project(&self, project_id: &str) -> Result<bool, df_types::error::Error> {
let conn = self.conn.clone(); let conn = self.conn.clone();
let project_id = project_id.to_owned(); let project_id = project_id.to_owned();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock(); let mut guard = conn.blocking_lock();
let affected = guard let tx = guard.transaction().map_err(storage_err)?;
tx.execute(
"DELETE FROM module_dependencies WHERE project_id = ?1",
params![project_id],
)
.map_err(storage_err)?;
let affected = tx
.execute( .execute(
"DELETE FROM project_modules WHERE project_id = ?1", "DELETE FROM project_modules WHERE project_id = ?1",
params![project_id], params![project_id],
) )
.map_err(storage_err)?; .map_err(storage_err)?;
tx.commit().map_err(storage_err)?;
Ok(affected > 0) Ok(affected > 0)
}) })
.await .await
@@ -338,4 +358,62 @@ mod tests {
assert!(hit); assert!(hit);
assert!(repo.list_by_project(&pid).await.unwrap().is_empty()); assert!(repo.list_by_project(&pid).await.unwrap().is_empty());
} }
// ============================================================
// 删工程先清 module_dependencies 依赖边(⑦走查项:FK 无级联,带边删必违例)
// ============================================================
/// 建一条 a→b 依赖边(模块 id 规则见 mrec:mod-a / mod-b)。
async fn seed_edge(db: &Database, pid: &str) -> crate::crud::ModuleDependencyRepo {
let dep_repo = crate::crud::ModuleDependencyRepo::new(db);
dep_repo
.insert(crate::models::ModuleDependencyRecord {
id: "dep-1".to_string(),
project_id: pid.to_string(),
from_module_id: "mod-a".to_string(),
to_module_id: "mod-b".to_string(),
dep_type: "library".to_string(),
label: None,
created_at: "0".to_string(),
})
.await
.expect("insert dependency edge");
dep_repo
}
#[tokio::test]
async fn delete_clears_dependency_edges_first() {
let (_db, repo, pid) = setup().await;
repo.insert(mrec(&pid, "a", "/p/a")).await.unwrap();
repo.insert(mrec(&pid, "b", "/p/b")).await.unwrap();
let dep_repo = seed_edge(&_db, &pid).await;
// 修前行为对照:带边直接删工程会 FK 违例报错;修后先清边再删,单事务原子
let hit = repo.delete("mod-a").await.expect("delete 应成功不报 FK");
assert!(hit);
assert!(
dep_repo.list_by_field("from_module_id", "mod-a").await.unwrap().is_empty()
&& dep_repo.list_by_field("to_module_id", "mod-a").await.unwrap().is_empty(),
"引用被删工程的依赖边应一并清除"
);
assert!(repo.get_by_id("mod-a").await.unwrap().is_none());
// 另一端工程不受影响
assert!(repo.get_by_id("mod-b").await.unwrap().is_some());
}
#[tokio::test]
async fn delete_by_project_clears_dependency_edges() {
let (_db, repo, pid) = setup().await;
repo.insert(mrec(&pid, "a", "/p/a")).await.unwrap();
repo.insert(mrec(&pid, "b", "/p/b")).await.unwrap();
let dep_repo = seed_edge(&_db, &pid).await;
let hit = repo.delete_by_project(&pid).await.expect("delete_by_project 应成功不报 FK");
assert!(hit);
assert!(
dep_repo.list_by_field("project_id", &pid).await.unwrap().is_empty(),
"项目级删除应先清全部依赖边"
);
assert!(repo.list_by_project(&pid).await.unwrap().is_empty());
}
} }
+37 -3
View File
@@ -111,7 +111,16 @@ fn project_from_row(row: &Row<'_>) -> std::result::Result<ProjectRecord, rusqlit
description: row.get("description")?, description: row.get("description")?,
status: { status: {
let s: String = row.get("status")?; let s: String = row.get("status")?;
ProjectStatus::from_db_str(&s).unwrap_or_default() match ProjectStatus::from_db_str(&s) {
Some(v) => v,
None => {
// 未知 status 静默降级默认值后,后续 update_full 回写会把库里的未知值
// 覆盖成默认值(读→改→写放大静默),warn 留痕让脏数据可发现。
let id: String = row.get("id")?;
tracing::warn!("projects.id={id} 含未知 status {s:?},按默认值降级(回写会覆盖原值,请核查)");
Default::default()
}
}
}, },
idea_id: row.get("idea_id")?, idea_id: row.get("idea_id")?,
path: row.get("path")?, path: row.get("path")?,
@@ -436,7 +445,8 @@ impl ProjectRepo {
/// 而非静默覆盖(旧 `update_full` 无条件覆盖,存在跨进程互相覆盖竞态)。 /// 而非静默覆盖(旧 `update_full` 无条件覆盖,存在跨进程互相覆盖竞态)。
/// ///
/// 不动 GUI 的无条件 [`update_full`](无 expected 语义,保持现状);本方法仅服务 /// 不动 GUI 的无条件 [`update_full`](无 expected 语义,保持现状);本方法仅服务
/// 需要乐观锁版本的调用方(df-mcp update_project)。`Ok(false)` = id 不存在或版本冲突。 /// 需要乐观锁版本的调用方(df-mcp update_project)。`Ok(false)` = id 不存在、已进回收站
/// (deleted_at IS NULL 守卫)或版本冲突。
pub async fn update_full_cas( pub async fn update_full_cas(
&self, &self,
record: &ProjectRecord, record: &ProjectRecord,
@@ -449,7 +459,9 @@ impl ProjectRepo {
let guard = conn.blocking_lock(); let guard = conn.blocking_lock();
let affected = guard let affected = guard
.execute( .execute(
"UPDATE projects SET name = ?1, description = ?2, status = ?3, idea_id = ?4, path = ?5, stack = ?6, updated_at = ?7 WHERE id = ?8 AND updated_at = ?9", // deleted_at IS NULL:回收站项目不可被 CAS 覆盖(MCP 持旧 expected 也能
// 命中已软删行,造成回收站内容被静默改写),与 advance 类守卫同族。
"UPDATE projects SET name = ?1, description = ?2, status = ?3, idea_id = ?4, path = ?5, stack = ?6, updated_at = ?7 WHERE id = ?8 AND updated_at = ?9 AND deleted_at IS NULL",
params![ params![
rec.name, rec.description, rec.status.as_str(), rec.idea_id, rec.name, rec.description, rec.status.as_str(), rec.idea_id,
rec.path, rec.stack, rec.updated_at, rec.id, expected rec.path, rec.stack, rec.updated_at, rec.id, expected
@@ -755,4 +767,26 @@ mod tests {
assert_eq!(after.name, "他人已改", "CAS 冲突不得覆盖他人修改"); assert_eq!(after.name, "他人已改", "CAS 冲突不得覆盖他人修改");
assert_eq!(after.updated_at, "1800000000000"); assert_eq!(after.updated_at, "1800000000000");
} }
#[tokio::test]
async fn update_full_cas_skips_soft_deleted() {
// 回收站项目不可被 CAS 命中(deleted_at IS NULL 守卫):即使 expected 与 DB
// updated_at 一致,已软删项目也拒绝写入,防 MCP 对回收站项目静默改写。
let repo = setup().await;
repo.insert(prec("p1", "原项目")).await.unwrap();
repo.soft_delete("p1").await.unwrap();
// soft_delete 刷新了 updated_at,取当前值作 expected(排除版本不匹配干扰)
let current = repo.get_by_id("p1").await.unwrap().unwrap();
let mut rec = current.clone();
rec.name = "回收站里改名".to_string();
rec.updated_at = "1900000000000".to_string();
let ok = repo
.update_full_cas(&rec, &current.updated_at)
.await
.unwrap();
assert!(!ok, "软删项目 CAS 应拒绝(deleted_at 守卫)");
let after = repo.get_by_id("p1").await.unwrap().unwrap();
assert_eq!(after.name, "原项目", "回收站项目内容不应被改写");
assert_eq!(after.updated_at, current.updated_at, "updated_at 不应抖动");
}
} }
@@ -136,6 +136,10 @@ impl_repo!(
"project_services", "project_services",
from_row => |row| project_service_from_row(row), from_row => |row| project_service_from_row(row),
insert => |conn, rec| { insert => |conn, rec| {
// 宏生成的 insert/update_full 是公开写入路径,SQL 体由本调用点整体传入——校验就挂
// 在体首(等价 insert_validated/update_full_validated 的 validate_record 收口),
// 堵住旁路宏方法把非法 service_type / 含凭证字段写进库的 D10 缺口。
validate_record(rec)?;
conn.execute( conn.execute(
"INSERT INTO project_services (id, project_id, name, service_type, endpoint, config_json, environment, remark, created_at, updated_at) "INSERT INTO project_services (id, project_id, name, service_type, endpoint, config_json, environment, remark, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
@@ -147,6 +151,8 @@ impl_repo!(
) )
}, },
update => |conn, rec| { update => |conn, rec| {
// 同 insert:宏 update_full 的校验收口在体首(update_full_validated 同源校验)。
validate_record(rec)?;
conn.execute( conn.execute(
"UPDATE project_services SET project_id = ?1, name = ?2, service_type = ?3, endpoint = ?4, config_json = ?5, environment = ?6, remark = ?7, updated_at = ?8 WHERE id = ?9", "UPDATE project_services SET project_id = ?1, name = ?2, service_type = ?3, endpoint = ?4, config_json = ?5, environment = ?6, remark = ?7, updated_at = ?8 WHERE id = ?9",
params![ params![
@@ -436,6 +442,41 @@ mod tests {
assert_eq!(got.config_json.as_deref(), Some(r#"{"pool_size":10}"#)); assert_eq!(got.config_json.as_deref(), Some(r#"{"pool_size":10}"#));
} }
// ============================================================
// 宏生成的 insert/update_full 校验收口(⑥走查项:宏写入路径同过 validate_record)
// ============================================================
#[tokio::test]
async fn macro_insert_rejects_invalid_service_type() {
// 旁路 insert_validated 直接走宏 insert,非法 service_type 同样被拒
let (_db, repo) = setup().await;
let err = repo
.insert(srec("s1", "elasticsearch", "development"))
.await;
assert!(err.is_err(), "宏 insert 应过 validate_record 拒非法类型");
assert!(repo.get_by_id("s1").await.unwrap().is_none(), "拒后不应入库");
}
#[tokio::test]
async fn macro_update_full_rejects_sensitive_config() {
// 旁路 update_full_validated 直接走宏 update_full,凭证审查同样生效
let (_db, repo) = setup().await;
repo.insert_validated(srec("s1", "mysql", "development"))
.await
.unwrap();
let mut bad = srec("s1", "mysql", "development");
bad.endpoint = Some("https://api.example.com?token=abc".to_string());
let err = repo.update_full(&bad).await;
assert!(err.is_err(), "宏 update_full 应过 D10 凭证审查");
let got = repo.get_by_id("s1").await.unwrap().unwrap();
assert_eq!(
got.endpoint.as_deref(),
Some("localhost:3306"),
"被拒后原记录不应被改写"
);
}
#[tokio::test] #[tokio::test]
async fn update_full_validated_changes_fields() { async fn update_full_validated_changes_fields() {
let (_db, repo) = setup().await; let (_db, repo) = setup().await;
+61 -3
View File
@@ -27,7 +27,16 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
description: row.get("description")?, description: row.get("description")?,
status: { status: {
let s: String = row.get("status")?; let s: String = row.get("status")?;
TaskStatus::from_db_str(&s).unwrap_or_default() match TaskStatus::from_db_str(&s) {
Some(v) => v,
None => {
// 未知 status 静默降级默认值后,后续 update_full 回写会把库里的未知值
// 覆盖成默认值(读→改→写放大静默),warn 留痕让脏数据可发现。
let id: String = row.get("id")?;
tracing::warn!("tasks.id={id} 含未知 status {s:?},按默认值降级(回写会覆盖原值,请核查)");
Default::default()
}
}
}, },
priority: row.get("priority")?, priority: row.get("priority")?,
branch_name: row.get("branch_name")?, branch_name: row.get("branch_name")?,
@@ -219,6 +228,30 @@ impl TaskRepo {
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 按 id 查未删除任务(`WHERE id AND deleted_at IS NULL`)。
///
/// 专供 [`validate_parent_id`](super::task_validation) 的父任务存在性检查:
/// 回收站父任务(soft_delete 已标 deleted_at)对"挂子任务"视角等同不存在,
/// 通用 get_by_id 不带软删过滤会让子任务挂到回收站父下(⑤走查项)。
/// 列清单与 get_children 一致(TaskRecord 不带 deleted_at,不 SELECT 该列)。
pub async fn get_by_id_active(&self, id: &str) -> Result<Option<TaskRecord>> {
let conn = self.conn.clone();
let id = id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, module_id, created_at, updated_at FROM tasks WHERE id = ?1 AND deleted_at IS NULL")
.map_err(storage_err)?;
let row = stmt
.query_row(params![id], |row| task_from_row(row))
.optional()
.map_err(storage_err)?;
Ok(row)
})
.await
.map_err(storage_err)?
}
/// 原子推进任务状态(任务推进链唯一 status 写入路径) /// 原子推进任务状态(任务推进链唯一 status 写入路径)
/// ///
/// 下沉 SQL `WHERE id=? AND status=?expected` 做 CAS(Compare-And-Swap)防 TOCTOU: /// 下沉 SQL `WHERE id=? AND status=?expected` 做 CAS(Compare-And-Swap)防 TOCTOU:
@@ -784,7 +817,8 @@ impl TaskRepo {
/// 而非静默覆盖。对齐 [`advance_status_atomic`](同款 `WHERE ... = ?expected` 原子条件写)。 /// 而非静默覆盖。对齐 [`advance_status_atomic`](同款 `WHERE ... = ?expected` 原子条件写)。
/// ///
/// 不动 GUI 的无条件 [`update_full`](无 expected 语义,保持现状);本方法仅服务 /// 不动 GUI 的无条件 [`update_full`](无 expected 语义,保持现状);本方法仅服务
/// 需要乐观锁版本的调用方(df-mcp update_task)。`Ok(false)` = id 不存在或版本冲突。 /// 需要乐观锁版本的调用方(df-mcp update_task)。`Ok(false)` = id 不存在、已进回收站
/// (deleted_at IS NULL 守卫)或版本冲突。
pub async fn update_full_cas( pub async fn update_full_cas(
&self, &self,
record: &TaskRecord, record: &TaskRecord,
@@ -797,7 +831,9 @@ impl TaskRepo {
let guard = conn.blocking_lock(); let guard = conn.blocking_lock();
let affected = guard let affected = guard
.execute( .execute(
"UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, queue = ?13, parent_id = ?14, content_json = ?15, module_id = ?16, updated_at = ?17 WHERE id = ?18 AND updated_at = ?19", // deleted_at IS NULL:回收站任务不可被 CAS 覆盖——MCP 拿旧 expected 也能
// 命中已软删行,造成回收站内容被改且 updated_at 抖动,调用方无从感知。
"UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, queue = ?13, parent_id = ?14, content_json = ?15, module_id = ?16, updated_at = ?17 WHERE id = ?18 AND updated_at = ?19 AND deleted_at IS NULL",
params![ params![
rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority, rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch, rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
@@ -1299,6 +1335,28 @@ mod tests {
assert_eq!(after.updated_at, "1800000000000"); assert_eq!(after.updated_at, "1800000000000");
} }
#[tokio::test]
async fn update_full_cas_skips_soft_deleted() {
// 回收站任务不可被 CAS 命中(deleted_at IS NULL 守卫):即使 expected 与 DB
// updated_at 一致,已软删行也拒绝写入,防 MCP 对回收站任务静默改写。
let repo = setup().await;
repo.insert(trec("t1", "todo", None)).await.unwrap();
repo.soft_delete("t1").await.unwrap();
// soft_delete 刷新了 updated_at,取当前值作 expected(排除版本不匹配干扰)
let current = repo.get_by_id("t1").await.unwrap().unwrap();
let mut rec = current.clone();
rec.title = "回收站里改标题".to_string();
rec.updated_at = "1900000000000".to_string();
let ok = repo
.update_full_cas(&rec, &current.updated_at)
.await
.unwrap();
assert!(!ok, "软删任务 CAS 应拒绝(deleted_at 守卫)");
let after = repo.get_by_id("t1").await.unwrap().unwrap();
assert_eq!(after.title, "task-t1", "回收站任务内容不应被改写");
assert_eq!(after.updated_at, current.updated_at, "updated_at 不应抖动");
}
// ============================================================ // ============================================================
// LW-5 count_by_query 维度对齐 list_by_query(防 count/list 漂移翻页) // LW-5 count_by_query 维度对齐 list_by_query(防 count/list 漂移翻页)
// ============================================================ // ============================================================
+92 -3
View File
@@ -45,7 +45,7 @@ pub fn validate_content_json(content: Option<&str>) -> Result<Option<String>, St
} }
} }
/// 校验 parent_id 1 级嵌套铁律:父任务存在 + 父任务自身不是子任务(防孙任务)。 /// 校验 parent_id 1 级嵌套铁律:父任务存在(未软删) + 父任务自身不是子任务(防孙任务)。
/// 返回归一化 parent_id(空串/缺省 → None)。 /// 返回归一化 parent_id(空串/缺省 → None)。
pub async fn validate_parent_id( pub async fn validate_parent_id(
repo: &TaskRepo, repo: &TaskRepo,
@@ -55,11 +55,13 @@ pub async fn validate_parent_id(
.map(|s| s.trim().to_string()) .map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()); .filter(|s| !s.is_empty());
if let Some(pid) = &pid { if let Some(pid) = &pid {
// get_by_id_active 带 deleted_at IS NULL:回收站父任务拒挂子,否则子任务
// 挂在已删父下,父恢复前子任务在树/看板视图两不可见(get_by_id 无软删过滤)。
let parent = repo let parent = repo
.get_by_id(pid) .get_by_id_active(pid)
.await .await
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.ok_or_else(|| format!("父任务 {pid} 不存在,无法创建子任务"))?; .ok_or_else(|| format!("父任务 {pid} 不存在(或已在回收站),无法创建子任务"))?;
if parent.parent_id.is_some() { if parent.parent_id.is_some() {
return Err(format!( return Err(format!(
"违反 1 级嵌套铁律:父任务 {pid} 自身是子任务(parent_id={:?}),不允许在其下创建孙任务", "违反 1 级嵌套铁律:父任务 {pid} 自身是子任务(parent_id={:?}),不允许在其下创建孙任务",
@@ -80,3 +82,90 @@ pub fn normalize_priority(priority: Option<i64>) -> Result<i32, String> {
)), )),
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::crud::ProjectRepo;
use crate::db::Database;
use crate::models::{ProjectRecord, TaskRecord};
use df_types::types::ProjectStatus;
/// 内存 DB + 占位 project(tasks.project_id FK 要求)+ TaskRepo。
async fn setup() -> TaskRepo {
let db = Database::open_in_memory().await.expect("open_in_memory");
ProjectRepo::new(&db)
.insert(ProjectRecord {
id: "proj-1".to_string(),
name: "proj-1".to_string(),
description: String::new(),
status: ProjectStatus::Planning,
idea_id: None,
path: None,
stack: None,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
})
.await
.unwrap();
TaskRepo::new(&db)
}
fn trec(id: &str, parent_id: Option<&str>) -> TaskRecord {
TaskRecord {
id: id.to_string(),
project_id: "proj-1".to_string(),
title: format!("task-{id}"),
description: String::new(),
status: df_types::types::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,
queue: "todo".to_string(),
parent_id: parent_id.map(|s| s.to_string()),
content_json: None,
module_id: None,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
}
}
#[tokio::test]
async fn parent_in_trash_rejected() {
// 回收站父任务拒挂子(deleted_at IS NULL 存在性检查,⑤走查项)
let repo = setup().await;
repo.insert(trec("parent", None)).await.unwrap();
repo.soft_delete("parent").await.unwrap();
let err = validate_parent_id(&repo, Some("parent")).await.unwrap_err();
assert!(err.contains("回收站"), "软删父应报「不存在(或已在回收站)」: {err}");
}
#[tokio::test]
async fn active_parent_accepted_and_grandchild_still_rejected() {
let repo = setup().await;
repo.insert(trec("parent", None)).await.unwrap();
// 活跃父:校验通过,归一化保留 pid
let pid = validate_parent_id(&repo, Some(" parent ")).await.unwrap();
assert_eq!(pid.as_deref(), Some("parent"), "trim 后应保留 pid");
// 1 级嵌套铁律不回归:父是子任务时仍拒孙
repo.insert(trec("child", Some("parent"))).await.unwrap();
let err = validate_parent_id(&repo, Some("child")).await.unwrap_err();
assert!(err.contains("嵌套"), "孙任务应被拒: {err}");
}
#[tokio::test]
async fn missing_and_empty_parent() {
let repo = setup().await;
// 不存在的父:报不存在
let err = validate_parent_id(&repo, Some("ghost")).await.unwrap_err();
assert!(err.contains("不存在"));
// 空串/缺省:归一化为 None 直接放行
assert_eq!(validate_parent_id(&repo, None).await.unwrap(), None);
assert_eq!(validate_parent_id(&repo, Some(" ")).await.unwrap(), None);
}
}
+326 -31
View File
@@ -47,7 +47,11 @@ pub fn run(conn: &Connection) -> Result<()> {
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。 // 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
// V41 = 任务关联工程模块:tasks.module_id 列(工程系统打底,项目多工程下任务落到具体 module)。 // V41 = 任务关联工程模块:tasks.module_id 列(工程系统打底,项目多工程下任务落到具体 module)。
// V42 = ai_messages 加 is_estimated 列(消息级估算标记,reload 逐条回显「估算」标注)。 // V42 = ai_messages 加 is_estimated 列(消息级估算标记,reload 逐条回显「估算」标注)。
let steps: [(i32, fn(&Connection) -> Result<()>); 42] = [ // V43 = 存量知识一次性晋升(决策单 B4①:candidate + confidence='high' → published,
// 纯数据 UPDATE 不经过 promote 函数,不触发嵌入生成)。
// V44 = 子代理轨迹来源标记(ai_conversations.origin TEXT,spawn_agent 出口整轨迹落库,
// 桌面侧栏/miniapp 列表按 origin 过滤子会话)。
let steps: [(i32, fn(&Connection) -> Result<()>); 44] = [
(1, migrate_v1), (1, migrate_v1),
(2, migrate_v2), (2, migrate_v2),
(3, migrate_v3), (3, migrate_v3),
@@ -90,6 +94,8 @@ pub fn run(conn: &Connection) -> Result<()> {
(40, migrate_v40), (40, migrate_v40),
(41, migrate_v41), (41, migrate_v41),
(42, migrate_v42), (42, migrate_v42),
(43, migrate_v43),
(44, migrate_v44),
]; ];
for (version, migrate_fn) in steps { for (version, migrate_fn) in steps {
@@ -433,11 +439,13 @@ fn migrate_v20(conn: &Connection) -> Result<()> {
/// **一次原子迁移**(决策 V21 合并,不拆 V21a/V21b): /// **一次原子迁移**(决策 V21 合并,不拆 V21a/V21b):
/// 1. 建表 ai_messages(IF NOT EXISTS 幂等,新库空表/老库均安全) /// 1. 建表 ai_messages(IF NOT EXISTS 幂等,新库空表/老库均安全)
/// 2. 幂等补 ai_tool_executions.message_id 列(消息级溯源 audit) /// 2. 幂等补 ai_tool_executions.message_id 列(消息级溯源 audit)
/// 3. COUNT 探测 ai_messages 已有数据 → 跳过数据迁移(仅写版本号,防重复迁移) /// 3. 遍历 ai_conversations.messages JSON → 逐条提取到 ai_messages(分批 commit)
/// 4. 遍历 ai_conversations.messages JSON → 逐条提取到 ai_messages(分批 commit) /// 4. 逐对话进度判定:已落库数 == JSON 消息数 → 跳过该对话(防重复迁移)
/// ///
/// 设计要点(详见消息拆分存储设计 §4.2): /// 设计要点(详见消息拆分存储设计 §4.2):
/// - **幂等安全**:COUNT 探测 + INSERT OR IGNORE,中途崩溃重跑跳过已迁移数据 /// - **幂等安全**:逐对话进度判定 + INSERT OR IGNORE,中途崩溃重跑只补未迁对话。
/// 不用全局 COUNT 判完成——批量迁移每批一事务,批次中途崩溃后 ai_messages 已有
/// 数据,COUNT>0 会整段跳过,剩余对话永不再迁而版本号照样补齐,残缺被永久掩盖
/// - **分批 commit**:每 50 对话一批,避免长事务持有 SQLite 写锁 /// - **分批 commit**:每 50 对话一批,避免长事务持有 SQLite 写锁
/// - **迁移期 ID**:`msg_migrated_{conv_id}_{seq}` —— 天然唯一(UNIQUE 是 conv_id+seq)、零依赖 /// - **迁移期 ID**:`msg_migrated_{conv_id}_{seq}` —— 天然唯一(UNIQUE 是 conv_id+seq)、零依赖
/// - **裸 JSON 提取**:用 `serde_json::Value` 而非 ChatMessage(df-storage 不依赖 df-ai-core) /// - **裸 JSON 提取**:用 `serde_json::Value` 而非 ChatMessage(df-storage 不依赖 df-ai-core)
@@ -471,20 +479,7 @@ fn migrate_v21(conn: &Connection) -> Result<()> {
tracing::info!("v21: 补建 ai_tool_executions.message_id 列(消息级溯源 audit)"); tracing::info!("v21: 补建 ai_tool_executions.message_id 列(消息级溯源 audit)");
} }
// 3. COUNT 探测:ai_messages 已有数据 → 跳过迁移只写版本号(幂等) // 3. 遍历 ai_conversations,反序列化 messages JSON → 逐条写入 ai_messages
// INSERT OR IGNORE 防崩溃重跑(schema_version PK 冲突):run() 正常流程只调
// 一次 migrate_v21(current_version<21),但崩溃重跑/手动重调时 version=21
// 可能已存在,IGNORE 保证幂等不报错。
let existing: i64 = conn.query_row(
"SELECT COUNT(*) FROM ai_messages", [], |row| row.get(0),
)?;
if existing > 0 {
tracing::info!("v21: ai_messages 已有 {} 条,跳过数据迁移", existing);
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [21])?;
return Ok(());
}
// 4. 遍历 ai_conversations,反序列化 messages JSON → 逐条写入 ai_messages
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, messages, created_at FROM ai_conversations", "SELECT id, messages, created_at FROM ai_conversations",
)?; )?;
@@ -497,13 +492,13 @@ fn migrate_v21(conn: &Connection) -> Result<()> {
})?; })?;
let all_rows: Vec<(String, String, String)> = rows.collect::<std::result::Result<Vec<_>, _>>()?; let all_rows: Vec<(String, String, String)> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
// 5. 分批 commit(每 50 个对话一批,避免长事务持有写锁) // 4. 分批 commit(每 50 个对话一批,避免长事务持有写锁)
const BATCH_SIZE: usize = 50; const BATCH_SIZE: usize = 50;
let mut migrated_count: usize = 0; let mut migrated_count: usize = 0;
for (batch_idx, batch) in all_rows.chunks(BATCH_SIZE).enumerate() { for (batch_idx, batch) in all_rows.chunks(BATCH_SIZE).enumerate() {
let tx = conn.unchecked_transaction()?; let tx = conn.unchecked_transaction()?;
for (conv_id, messages_json, conv_created_at) in batch { for (conv_id, messages_json, conv_created_at) in batch {
// 6. 逐对话反序列化 messages JSON → Vec<serde_json::Value> // 5. 逐对话反序列化 messages JSON → Vec<serde_json::Value>
// (用裸 JSON 而非 ChatMessage,因 df-storage 不依赖 df-ai-core) // (用裸 JSON 而非 ChatMessage,因 df-storage 不依赖 df-ai-core)
let messages: Vec<serde_json::Value> = match serde_json::from_str(messages_json) { let messages: Vec<serde_json::Value> = match serde_json::from_str(messages_json) {
Ok(v) => v, Ok(v) => v,
@@ -513,8 +508,23 @@ fn migrate_v21(conn: &Connection) -> Result<()> {
} }
}; };
// 逐对话进度判定(幂等核心):批事务保证对话级原子——一个对话的全部消息
// 在同一事务内写入,要么全落库要么全无,不存在半迁持久态。已落库数等于
// JSON 消息数即该对话已完整迁移,跳过;否则(未迁/断点残缺)整对话重跑,
// 行级 INSERT OR IGNORE 兜底去重。中途崩溃重跑只补未迁对话,不再被
// 全局 COUNT>0 整段短路。
let json_count = messages.len() as i64;
let db_count: i64 = tx.query_row(
"SELECT COUNT(*) FROM ai_messages WHERE conversation_id = ?1",
rusqlite::params![conv_id],
|row| row.get(0),
)?;
if json_count > 0 && db_count == json_count {
continue;
}
for (seq, msg) in messages.iter().enumerate() { for (seq, msg) in messages.iter().enumerate() {
// 7. 逐条消息提取字段 → INSERT INTO ai_messages // 6. 逐条消息提取字段 → INSERT INTO ai_messages
// 字段名硬编码("role"/"content" 等)——ChatMessage 改名会漏数据! // 字段名硬编码("role"/"content" 等)——ChatMessage 改名会漏数据!
// 迁移期 ID 天然唯一(UNIQUE 是 conv_id+seq),人类可读,零依赖 // 迁移期 ID 天然唯一(UNIQUE 是 conv_id+seq),人类可读,零依赖
let id = format!("msg_migrated_{}_{}", conv_id, seq); let id = format!("msg_migrated_{}_{}", conv_id, seq);
@@ -1275,6 +1285,55 @@ fn migrate_v42(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
/// V43: 存量知识一次性晋升 — candidate 且 confidence='high' 批量转 published
///
/// 决策单 B4①(docs/02-架构设计/专项设计/aichat上下文管理-派工与决策单-2026-09-29.md ①,
/// 2026-09-29)。DB 实测:knowledges 表 candidate 967 条(其中 confidence='high' 854 条)
/// 长期滞留,而检索侧(search/vector/top_reused)只认 published(53 条)——AI 提炼的知识
/// 从未进入检索面,知识库沉淀近乎零生效。本迁移把存量高置信 candidate 一次性晋升,
/// 与代码侧「复用即信任」(KnowledgeRepo::auto_publish_if_reused)互补:前者清历史存量,
/// 后者管增量沉淀。
///
/// 设计要点:
/// - **纯数据 UPDATE,不经过 promote 函数**:不触发嵌入生成(854 条不会在启动时批量调
/// 嵌入 API);embedding NULL 的条目检索走 LIKE 降级,向量面由后续复用/晋升路径逐步补
/// ——这是有意为之,非遗漏。也不写 updated_at(非内容变更,不伪造新近度)。
/// - **幂等双保险**:schema_version 版本号保证只跑一次(run() 按 current_version<43 跳过);
/// 即便崩溃重跑/版本号回退重调本函数,WHERE 条件天然幂等(已晋升行不再命中,UPDATE
/// 影响 0 行),版本号 INSERT OR IGNORE 防 PK 冲突(同 V21/V32 崩溃重跑防御模式)。
/// - **可逆方式**(备用,当前不执行):本迁移不落版本特征列,knowledges.updated_at 由业务
/// 路径维护、无法精确圈定本次影响集,可逆按晋升前后 confidence 不变这一事实圈定:
/// `UPDATE knowledges SET status='candidate' WHERE status='published' AND confidence='high'`
/// ——注意此语句会连带回退「非本迁移产生的 published+high」条目(人工放行/复用达标晋升的
/// 高置信条目),执行前需以 knowledge_events 审计表核对时间窗。
fn migrate_v43(conn: &Connection) -> Result<()> {
let affected = conn.execute(
"UPDATE knowledges SET status = 'published' \
WHERE status = 'candidate' AND confidence = 'high'",
[],
)?;
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [43])?;
tracing::info!("迁移 v43 完成(存量知识 candidate+confidence=high 晋升 published,共 {} 条)", affected);
Ok(())
}
/// V44: 子代理轨迹来源标记 — ai_conversations 加 origin TEXT 列。
///
/// 子代理会话(spawn_agent)从「纯内存零落库」改为出口整轨迹落库(二期池①),落库时
/// origin='sub_agent' 随首插写入,桌面侧栏与 miniapp 会话列表按此过滤——对话本体可追溯
/// 而不污染主会话列表。主会话 origin 恒为 NULL(业务层 None),无其他写入点。
/// - **幂等**:column_exists 守卫(同 V42 模式),崩溃重跑/全链两遍不炸;
/// - **可逆**:`ALTER TABLE ai_conversations DROP COLUMN origin`(SQLite 3.35+)。
fn migrate_v44(conn: &Connection) -> Result<()> {
if !column_exists(conn, "ai_conversations", "origin") {
conn.execute("ALTER TABLE ai_conversations ADD COLUMN origin TEXT", [])?;
tracing::info!("v44: ai_conversations 加 origin 列(子代理轨迹来源标记)");
}
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [44])?;
tracing::info!("迁移 v44 完成: ai_conversations 加 origin 列");
Ok(())
}
/// V21 建表 SQL — 消息拆分存储 ai_messages 表 /// V21 建表 SQL — 消息拆分存储 ai_messages 表
/// ///
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。 /// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
@@ -1742,7 +1801,7 @@ mod tests {
assert_eq!(bad_count, 0); assert_eq!(bad_count, 0);
} }
/// 幂等重跑:第二次 migrate_v21 不重复迁移(COUNT 探测跳过) /// 幂等重跑:第二次 migrate_v21 按逐对话进度判定跳过已迁对话,不重复迁移
#[test] #[test]
fn v21_idempotent_rerun() { fn v21_idempotent_rerun() {
let conn = setup_legacy_db(); let conn = setup_legacy_db();
@@ -1759,7 +1818,7 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(count_after_first, 1); assert_eq!(count_after_first, 1);
// 第二次跑:COUNT 探测 > 0 → 跳过数据迁移,不重复 // 第二次跑:逐对话进度判定命中 → 跳过,不重复
migrate_v21(&conn).expect("二次迁移应幂等成功"); migrate_v21(&conn).expect("二次迁移应幂等成功");
let count_after_second: i64 = conn let count_after_second: i64 = conn
.query_row("SELECT COUNT(*) FROM ai_messages", [], |r| r.get(0)) .query_row("SELECT COUNT(*) FROM ai_messages", [], |r| r.get(0))
@@ -1778,6 +1837,141 @@ mod tests {
assert_eq!(v_count, 1, "版本号 21 应只写一次"); assert_eq!(v_count, 1, "版本号 21 应只写一次");
} }
/// 断点续迁:批次中途崩溃后 ai_messages 已有部分对话数据、版本号未写,
/// 重跑必须补齐未迁对话,不得因「表已有数据」整段跳过。
/// 旧实现全局 COUNT>0 即跳过全部数据迁移,此场景下 conv_b 永不再迁而版本号照样补齐。
#[test]
fn v21_crash_midway_resumes_missing_convs() {
let conn = setup_legacy_db();
let msgs_a = serde_json::json!([
{"role": "user", "content": "a1"},
{"role": "assistant", "content": "a2"}
])
.to_string();
let msgs_b = serde_json::json!([{"role": "user", "content": "b1"}]).to_string();
conn.execute(
"INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES ('conv_a', ?1, '0', '0')",
rusqlite::params![msgs_a],
)
.unwrap();
conn.execute(
"INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES ('conv_b', ?1, '0', '0')",
rusqlite::params![msgs_b],
)
.unwrap();
// 模拟崩溃断点:表已建(V21_SQL,首次跑 migrate_v21 的产物)、conv_a 已按迁移格式
// 落库(其批次已 commit),conv_b 未迁,版本号未写
conn.execute_batch(V21_SQL).unwrap();
for seq in 0..2i64 {
conn.execute(
"INSERT INTO ai_messages (id, conversation_id, seq, role, content, status, created_at)
VALUES (?1, 'conv_a', ?2, 'user', ?3, 'active', '0')",
rusqlite::params![
format!("msg_migrated_conv_a_{}", seq),
seq,
format!("a{}", seq)
],
)
.unwrap();
}
let version_before: i64 = conn
.query_row(
"SELECT COUNT(*) FROM schema_version WHERE version = 21",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(version_before, 0, "崩溃断点:版本号尚未写入");
migrate_v21(&conn).expect("重跑应补齐未迁对话");
// conv_b 被补迁(旧实现此处为 0 —— 被 COUNT>0 整段跳过)
let b_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM ai_messages WHERE conversation_id = 'conv_b'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(b_count, 1, "未迁对话应被补齐");
// conv_a 不重复迁移
let a_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM ai_messages WHERE conversation_id = 'conv_a'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(a_count, 2, "已迁对话不应重复插入");
let v: i64 = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, 21, "重跑后版本号应补齐");
}
/// 批次边界断点续迁:55 个对话分两批(每批 50),首批已 commit 后崩溃,
/// 重跑应只补剩余批次,全部对话迁移完整且无重复。
#[test]
fn v21_batch_boundary_crash_resumes_remaining_batch() {
let conn = setup_legacy_db();
let total = 55usize;
let batch_done = 50usize;
for i in 0..total {
let msgs = serde_json::json!([
{"role": "user", "content": format!("q{}", i)},
{"role": "assistant", "content": format!("r{}", i)}
])
.to_string();
conn.execute(
"INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES (?1, ?2, '0', '0')",
rusqlite::params![format!("conv_{:03}", i), msgs],
)
.unwrap();
}
// 模拟崩溃断点:表已建(V21_SQL)、首批 50 对话已按迁移格式落库(批次已 commit),其余未迁
conn.execute_batch(V21_SQL).unwrap();
for i in 0..batch_done {
for seq in 0..2i64 {
conn.execute(
"INSERT INTO ai_messages (id, conversation_id, seq, role, content, status, created_at)
VALUES (?1, ?2, ?3, 'user', '', 'active', '0')",
rusqlite::params![
format!("msg_migrated_conv_{:03}_{}", i, seq),
format!("conv_{:03}", i),
seq
],
)
.unwrap();
}
}
migrate_v21(&conn).expect("重跑应补齐剩余批次");
let total_count: i64 = conn
.query_row("SELECT COUNT(*) FROM ai_messages", [], |r| r.get(0))
.unwrap();
assert_eq!(
total_count as usize,
total * 2,
"55 对话 × 2 条,无重复无缺失"
);
// 逐对话完整性:不存在条数不足的残缺对话
let incomplete: i64 = conn
.query_row(
"SELECT COUNT(*) FROM (
SELECT conversation_id FROM ai_messages GROUP BY conversation_id HAVING COUNT(*) < 2
)",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(incomplete, 0, "不应存在残缺对话");
}
/// ai_tool_executions.message_id 列补建(老库已有表无该列) /// ai_tool_executions.message_id 列补建(老库已有表无该列)
#[test] #[test]
fn v21_adds_message_id_column_to_tool_executions() { fn v21_adds_message_id_column_to_tool_executions() {
@@ -1962,7 +2156,108 @@ mod tests {
} }
// ============================================================ // ============================================================
// 全量迁移测试 — 新库从零跑完整 V1-V41 路径 // V43 迁移(存量知识一次性晋升)
// ============================================================
/// 构造最小 knowledges 表(schema_version 同建),只含 V43 触及的列。
fn setup_knowledges_db() -> Connection {
let conn = Connection::open_in_memory().expect("open in-memory db");
conn.execute_batch(
"CREATE TABLE schema_version (version INTEGER PRIMARY KEY);
CREATE TABLE knowledges (
id TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'candidate',
confidence TEXT
);",
)
.expect("create knowledges table");
conn
}
/// V43:仅 candidate + confidence='high' 晋升,其余状态/置信度不动
#[test]
fn v43_promotes_only_candidate_high_confidence() {
let conn = setup_knowledges_db();
conn.execute_batch(
"INSERT INTO knowledges (id, status, confidence) VALUES
('k1', 'candidate', 'high'),
('k2', 'candidate', 'high'),
('k3', 'candidate', 'medium'),
('k4', 'candidate', 'low'),
('k5', 'candidate', NULL),
('k6', 'pending_review', 'high'),
('k7', 'published', 'high'),
('k8', 'archived', 'high');",
)
.unwrap();
migrate_v43(&conn).expect("v43 应成功晋升");
// k1/k2 转 published
let published_high: i64 = conn
.query_row(
"SELECT COUNT(*) FROM knowledges WHERE status = 'published' AND confidence = 'high'",
[], |r| r.get(0),
)
.unwrap();
assert_eq!(published_high, 3, "2 条存量晋升 + 原 k7 = 3 条 published+high");
// 非 candidate+high 一律不动
let untouched: i64 = conn
.query_row(
"SELECT COUNT(*) FROM knowledges WHERE id IN ('k3','k4','k5','k6','k8') \
AND status NOT IN ('candidate','pending_review','archived')",
[], |r| r.get(0),
)
.unwrap();
assert_eq!(untouched, 0, "medium/low/NULL 置信与 pending_review/archived 不应被晋升");
// candidate 剩余:k3/k4/k5(2 条 high 已走)
let candidates_left: i64 = conn
.query_row(
"SELECT COUNT(*) FROM knowledges WHERE status = 'candidate'",
[], |r| r.get(0),
)
.unwrap();
assert_eq!(candidates_left, 3, "仅剩 medium/low/NULL 置信的 candidate");
let v: i64 = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, 43, "应写入版本号 43");
}
/// V43 幂等:重跑 UPDATE 影响 0 行(WHERE 天然幂等,版本号防重入)
#[test]
fn v43_idempotent_rerun_zero_affected() {
let conn = setup_knowledges_db();
conn.execute(
"INSERT INTO knowledges (id, status, confidence) VALUES ('k1', 'candidate', 'high')",
[],
)
.unwrap();
migrate_v43(&conn).expect("首次晋升");
migrate_v43(&conn).expect("重跑应幂等成功(UPDATE 0 行)");
let published: i64 = conn
.query_row(
"SELECT COUNT(*) FROM knowledges WHERE status = 'published'",
[], |r| r.get(0),
)
.unwrap();
assert_eq!(published, 1, "重跑不应产生重复变更");
let v_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM schema_version WHERE version = 43",
[], |r| r.get(0),
)
.unwrap();
assert_eq!(v_count, 1, "版本号 43 应只写一次");
}
// ============================================================
// 全量迁移测试 — 新库从零跑完整 V1-V43 路径
// ------------------------------------------------------------ // ------------------------------------------------------------
// 目的:某 migrate_vN 的 SQL 手滑写错(列名/类型/缺索引/缺表)只能等运行时暴露, // 目的:某 migrate_vN 的 SQL 手滑写错(列名/类型/缺索引/缺表)只能等运行时暴露,
// 此测试一次性覆盖全部迁移路径。任何一条迁移 SQL 写错、列名拼错、缺建表 // 此测试一次性覆盖全部迁移路径。任何一条迁移 SQL 写错、列名拼错、缺建表
@@ -1986,7 +2281,7 @@ mod tests {
); );
} }
/// 全量迁移:新库从零跑完 V1-V41,验证关键表齐全 + 列数 > 0 + 关键列存在。 /// 全量迁移:新库从零跑完 V1-V43,验证关键表齐全 + 列数 > 0 + 关键列存在。
/// ///
/// 覆盖至少:task / ai_conversations / ai_messages / ai_tool_executions / /// 覆盖至少:task / ai_conversations / ai_messages / ai_tool_executions /
/// conversation_checkpoints / ai_providers / projects / ideas。 /// conversation_checkpoints / ai_providers / projects / ideas。
@@ -2043,7 +2338,7 @@ mod tests {
"tasks.module_id 列缺失(V41 加)" "tasks.module_id 列缺失(V41 加)"
); );
// 3. schema_version 应推进到 42(全量迁移成功落版本号) // 3. schema_version 应推进到 43(全量迁移成功落版本号)
let max_version: i64 = conn let max_version: i64 = conn
.query_row( .query_row(
"SELECT COALESCE(MAX(version), 0) FROM schema_version", "SELECT COALESCE(MAX(version), 0) FROM schema_version",
@@ -2052,8 +2347,8 @@ mod tests {
) )
.expect("查 schema_version 应成功"); .expect("查 schema_version 应成功");
assert_eq!( assert_eq!(
max_version, 42, max_version, 44,
"全量迁移后 schema_version 应为 42(实际 {}),说明某条 migrate_vN 链路断在中间", "全量迁移后 schema_version 应为 44(实际 {}),说明某条 migrate_vN 链路断在中间",
max_version max_version
); );
@@ -2114,7 +2409,7 @@ mod tests {
assert_eq!(v_count, 1, "版本号 32 应只写一次"); assert_eq!(v_count, 1, "版本号 32 应只写一次");
} }
/// G5.5: 全链幂等不变量——V1-V41 每步执行两遍不抛错。 /// G5.5: 全链幂等不变量——V1-V43 每步执行两遍不抛错。
/// ///
/// 首轮 run() 建全 schema;清空 schema_version 强制下一轮从 V1 重跑每步 /// 首轮 run() 建全 schema;清空 schema_version 强制下一轮从 V1 重跑每步
/// (模拟存量库 + 崩溃重跑/版本号回退)。任何 migrate_vN 的裸 ALTER(无 column_exists /// (模拟存量库 + 崩溃重跑/版本号回退)。任何 migrate_vN 的裸 ALTER(无 column_exists
@@ -2130,7 +2425,7 @@ mod tests {
|r| r.get(0), |r| r.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(max_v, 42, "首轮应推进到 42"); assert_eq!(max_v, 44, "首轮应推进到 44");
// 清空版本表强制全链第二遍(每步 execute 第二次) // 清空版本表强制全链第二遍(每步 execute 第二次)
conn.execute("DELETE FROM schema_version", []).unwrap(); conn.execute("DELETE FROM schema_version", []).unwrap();
@@ -2142,7 +2437,7 @@ mod tests {
|r| r.get(0), |r| r.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(max_v2, 42, "重跑后应重新推进到 42"); assert_eq!(max_v2, 44, "重跑后应重新推进到 44");
} }
// ============================================================ // ============================================================
+3
View File
@@ -371,6 +371,9 @@ pub struct AiConversationRecord {
pub pending_approvals: Option<String>, // 挂起审批快照(JSON 对象,以 tool_call_id 为键,默认'{}') pub pending_approvals: Option<String>, // 挂起审批快照(JSON 对象,以 tool_call_id 为键,默认'{}')
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
/// 会话来源标记(V44):None=主会话;Some("sub_agent")=子代理轨迹(spawn_agent 出口落库),
/// 桌面侧栏/miniapp 列表按此过滤。不可变列(UPDATE 不写)。
pub origin: Option<String>,
} }
/// AI 工具执行审计记录 /// AI 工具执行审计记录
@@ -0,0 +1,75 @@
# 多代理流水线实战经验蒸馏(2026-10-03)
> 来源:上下文管理任务两天接力(10-02 至 10-03),13+ 代理批、多次会话重启、跨会话协调实战。
> 四个角度:编排 / 技术 / UX / 流程。每条都是实战检验过的,非理论推演。
## 一、编排角度
### 1.1 派工颗粒 = 一轮自证
单代理任务必须一轮内带验收证据(命令+结果)回来;超一轮=颗粒过大拆批。实测 13+ 批无一超轮,关键是任务书里给足锚点(文件:行 或 符号名)+ 明确禁改清单 + 验收命令。
### 1.2 文件零交集矩阵
派工前用 grep 把每个子项的**真实落点文件**定位出来(不是按想象的功能域),按文件分组派路:
- 同文件子项 → 合并给同一路(串行在同代理内)
- 跨路文件 → 各自禁改清单写死
- 实测:「结构不符需请示」类中断仅 2 次,均为锚点与实际结构偏差(不是互踩)
### 1.3 双碰文件错峰授权
两路先后都要碰同一文件(如 AiChat.vue):前路**收口确认后**,主代理向候路代理 SendMessage 显式解锁。不预共享、不口头约定。候路代理遇未授权文件主动请示而非越界。
### 1.4 主代理独立核查三件套
子代理自验 ≠ 完成。主代理必做:
1. **全量测试**:`cargo test --workspace`,与基线计数对比(防「新测试绿了旧测试红了」)
2. **锚点实锤**:报告声称的每个 文件:行号 grep 验证存在且语义吻合
3. **语义抽查**:关键设计裁决读一眼(如「删除侧单点 emit 覆盖 9 出口」——判断是否真比逐处 patch 优)
### 1.5 勘察反转:零命中 ≠ 空白
S2 勘察反转案例:sub_agent.rs grep "approval" 零命中,看起来是「审批语义空白=安全债」;实际是审批以 `deny` 命名的 fail-closed 双层防线(Medium/High 一律就地拒绝)。**「缺失」类结论必须勘察先行再定性**,否则会为伪需求开三批工。
### 1.6 残留进程判活
会话重启后接手:45s 双 stat 观察 mtime 是否推进 + 特征锚点 grep(目标改动是否在增长),确认无活跃写入者再动手。重启通知 ≠ 代理死亡(前代教训)。
## 二、技术角度(代码库层面的可复用裁决)
### 2.1 单点覆盖优于逐处 patch
删除族终态事件:与其在 9 处 loop 早退路径逐个补 emit(每处还要区分「已删」与「接管」),不如在删除侧单点补发——epoch 递增使所有早退路径自然被守卫压制。**先找不变量(owner 守卫),让分支自然消失**。
### 2.2 兼容性设计的「缺席不占位」
tool_signature 补参:补参键不在 args 时签名串与旧格式**逐字一致**——8 处既有逐字断言零破坏。改共享格式前先 grep 逐字断言消费方。
### 2.3 字面任务书会杀人
execute_with_heartbeat「不在 pending 即退出」按字面实现会腰斩**全部已批准执行**(现网先 remove 后 execute)。入口快照(was_pending_at_entry)守卫救场。派工书描述机制时,代理必须先读现场调用方再动手。
### 2.4 测试锁死旧行为的连带改写
修 bug 时若既有测试断言的正是 bug 行为,改写测试是**必要偏离**——但要如实报告(sanitize 盲区修复改写 1 测试)。反例警示:测试与 helper 失配的半成品测试(v21 断点续迁)会带病潜伏。
### 2.5 fail-closed 是底线
子代理 Medium/High 工具一律就地拒绝(零挂起零放行)被证明是正确架构——「拒绝」可以后补结构化与报告协议,但「放行」收不回来。
## 三、UX 走查角度
### 3.1 系统性缺陷按模式全仓 grep
「输入法组词回车直发」不是一个个页面看出来的——grep `isComposing` 全仓零命中 = 系统性缺失,一次修 3 文件。走查先找「某模式全仓缺失」类系统债,收益大于逐页面挑刺。
### 3.2 三域拆分 + 防重复清单
对话/管理/设置三域并行走查,各自带「刚修过清单」(防重复登记)与「已登记清单」(防池子膨胀)。52 项中 P1×8 集中在「事件仲裁(Esc/Enter)、状态反馈缺失、配置静默丢失」三类母题。
### 3.3 UX P1 三母题
- **输入事件仲裁缺失**:IME 组词、Esc 冒泡、快捷键无焦点守卫——同一类
- **操作无反馈**:静默失败、切换无过渡、点击像失灵——同一类
- **配置/状态静默丢失**:探测重拉覆盖用户配置、错误态顶掉可用按钮——同一类
修母题 > 修表象。
## 四、流程角度
### 4.1 唯一等令点
全流程只在 T0(提交+换装)一处等用户令,其余自主流水。攒批规模随每批汇报透出,用户随时可下令。避免频繁请示打断流水。
### 4.2 门禁基线演进
624 → 629 → 631 → 1871(全 workspace)。每批销账记录基线,验收=「不低于基线+新增」。基线漂移即红灯。
### 4.3 攒批分组提交
多批混工作区时(本批+前批+六路),提交须按批分组——主代理维护各批文件清单(进度文件),提交时可机械分组。
### 4.4 进度文件是多会话接力的唯一事实源
两次会话压缩、多次重启,全靠 `temp/*-进度.md` 分段追加接续。写进度=对下代会话交钥匙。
@@ -0,0 +1,121 @@
# aichat 三路走查报告(2026-10-02)
> 走查代理:rev-backend(src-tauri/src/commands/ai)/ rev-frontend(src/composables/ai + src/components/ai)/ rev-crates(crates/df-ai + df-storage)。
> 背景:上下文管理信息组织批收口期增量走查,为批次2(后端高危)/批次3(前端体验)提供输入。硬性排除清单(已修 B1-B5/D1-D7/已登记 todo/范围外决策项)均已遵守,结论以当前源码为准。
> 主代理核验说明:后端 P0-1 已亲核实锤(chat.rs:1697-1740 conv_id 分裂确认);crates P0-1 与其余 P0/P1 为代理独立源码复核(报告内标注),未逐项亲验——批次开工时以现场源码为准再确认。
---
## 一、后端走查(rev-backend)
范围:src-tauri/src/commands/ai/ 约 24 个核心文件全文精读(主循环/流式中继/审计工具执行/审批门控/路径授权/会话持久化/事件总线/子代理/上下文生命周期/通知),其余约 55 文件(tools/*/augmentation/* 等)按四类模式扫描(裸 emit/裸 conv()/锁内 unwrap/状态复位配对)。
### P0-1 ai_chat_compress_context 的 conv_id 分裂:跨会话压缩污染 + compressing 标志永久卡死【已亲核实锤】
- 位置:src-tauri/src/commands/ai/commands/chat.rs:1697-1736(读取段)vs :1743/:1762/:1800-1805/:1827-1830(写入段)
- 根因:读取段全部落在 IPC 参数 conversation_id 上(:1701 conv()/:1710 set_compressing(true)/:1712 protect_start/:1727 克隆消息);但 :1734 `let cid = session.active_conversation_id.clone().unwrap_or_else(...)` 之后所有突变(复位 compressing/compress_old_messages/insert_at(0, 摘要)/save)全落 cid(active 会话)。F-260616-09 批4 决策 e 使「压缩后台会话」成为受支持路径,非边角。
- 影响(压缩任一非当前会话 100% 触发):①目标 conv is_compressing 置 true 后无出口复位→手动/自动压缩从此永久跳过;②active 会话前 compress_end 条消息被误标 compressed 并插入由后台会话内容生成的摘要——跨会话数据污染,经 save 持久化进 DB;③:1716 protect_start==0 早退分支 cid 无 fallback 与主路径不一致。
- 修复:统一单一标识,:1734 改 `let cid = conversation_id.clone()`(AiCompressing/AiManualCompressed 事件已按 conversation_id 路由);约 4 行,收益/风险比极高,批次2首位。
### P1(3 项)
- **P1-1 remote_bridge 6 处 AiError 单路 emit**:remote_bridge.rs:812/:871/:951/:1000/:1034/:1066,miniapp 专属错误回执用裸 app.emit(桌面前端通道),miniapp 只能经 ai_event_bus 收——手机端发送失败完全无感知(「发了没反应」),桌面反收无关错误气泡。同模块 AiUserMessage 已按 BUG-260805-02 修为 emit_ai_chat_event 双写,此 6 处系同族漏网。修复:替换为 emit_ai_chat_event(同文件已有 import),机械改动可批量。
- **P1-2 只读工具结果缓存无写失效**:audit/cache.rs find_cached_readonly_result 按「工具名+参数」命中即回放,写类操作后同参数读命中旧缓存→LLM 写后读回验证拿到写前内容,可能据此二次「修复」或误报失败。与 G2 写间隔豁免的设计哲学矛盾(写后验证恰是最需要真实读回的场景)。修复:per_conv 维护「自上次写类成功后的脏标记」,写后缓存整体失效(不建议按参数精细失效)。
- **P1-3 ai_conversation_delete 不停 in-flight loop**:conversation.rs:608-640 无 stop_flag/无 epoch bump(对比 ai_chat_clear 先置停)。删除后当前轮(流式+工具执行+审批等待)完整跑完:继续烧 token/继续执行已授权高危工具;审批挂起中被删要等满 15 分钟外层超时;audit/mod.rs 9 处 `session.conv(conv_id).messages.push` 收尾回填惰性重建已删 conv 条目→zombie 复活(deleted_convs 只拦 DB 写拦不住内存重建)。修复:对齐 ai_chat_clear 删除时锁内置停+notify+bump epoch;execute_with_heartbeat 等待循环加「conv 已删即提前退出」。
### P2(5 项)
- **P2-1** agentic/mod.rs 4 处裸 emit 绕过正典(:1863 heartbeat/:1939 熔断求助/:2072 写删回环熔断/:2102 探索熔断)→miniapp 收不到心跳与熔断卡;AiCompleted :2331 双路分叉是有意设计勿动。修复:替换 emit_ai_chat_event。
- **P2-2** maybe_auto_compress protect_start 跨锁过期可致切片 panic:context_lifecycle.rs:102 锁内算 :118 锁外用,窗口内 clear_context 清到剩 6 条→`[..94]` index out of bounds,loop task 溃亡(仅 F2 看门狗兜底,而 F2 前移正是已登记待办)。修复:锁内 `let cut = protect_start.min(mgr.len())` 一行钳制。
- **P2-3** try_continue_agent_loop 裸 emit 零 token AiCompleted 绕过 emit_ai_completed_once(helpers.rs:100/:171)→挂起审批时被 continue 接管会残留占位 tool_result 与 pending 条目(__PENDING__: 标记风险)。修复:改调正典出口或补 finalize 两行。
- **P2-4** save_conversation 幽灵复活防线 Ok(Some) 分支缺二段复检(conversation.rs save 路径):get_by_id 返回 Some→删除事务提交→update_full/insert_batch 把已删会话消息写回(孤儿行)。修复:写库前再查 deleted_convs。
- **P2-5** stream_relay Fatal 路径 emit 先于 save(:454-458)与「先持久化后 emit」约定相反,emit 后进程退→前端有错误 DB 丢最后一轮;同文件全候选 Exhausted 文案把「provider 池饱和跳过」报成「重试耗尽」误导排障。修复:调换顺序;文案区分饱和/重试。
### P3(合并)
command_lock.rs 全仓零调用(spawn_agent 上线后建议接线或标注预留);update_pinned_goals 部分去重判定偏差;ai_approve None 分支持锁跨 DB await;clear_once_allowed_dirs 全清误伤多会话 once 授权;save_checkpoint 用 persist_msgs.len() 冒充 token 阈值+捷径下永不触发(疑似死代码);prev_compressing 双锁窗口 auto+manual 并发双压缩;保护区间 insert_at(0) 与后台压缩并发致压缩边界偏移一条;GOAL_VERIFY 续轮跨轮 Tool 段重复计数;HashMap.values() 乱序仅影响展示。
---
## 二、前端走查(rev-frontend)
范围:src/composables/ai/ 24 文件 + src/components/ai/ 16 文件全量,i18n ai/aiChat/aiTool 命名空间全扫。已对照排除清单,与 todo 第一批/9-28 走查无重复。
### P1(2 项)
- **P1-1 后台会话审批/路径授权事件被 isCurrent 守卫整体丢弃→挂起审批不可见、会话无限期挂死**:useAiEvents.ts:94-123 `!isCurrent` 分支只处理 Completed/Error/HelpRequired,其余事件一律 return——AiApprovalRequired/AiDirAuthRequired 落缝。后台会话 B 审批挂起时:不建审批卡/不进 pendingApprovals/不启超时计时器/不弹浮窗,后端 loop 阻塞等审批 IPC 而前端零信号→无限期挂死(用户切回经 pendingToolCalls 恢复才可见,但无「该切回」提示)。与已登记 #8 徽标计数不同源(#8 是已有项的计数,本条是上游路由丢失)。修复:!isCurrent 分支对 ApprovalRequired/DirAuthRequired 放行给 handleToolEvent(两 handler 已写 conversationId,findToolCall 找不到卡片自然跳过,无视图污染)。
- **P1-2 switchConversation「对话不存在」兜底分支漏置 activeConversationId→视图/高亮/发送目标三重错配**:useAiConversations.ts:294-309 该分支新建会话并持久化+清空视图,但没置 state.activeConversationId 即 return(对比 newConversation:101 与正常路径:354 都有)。触发=点击后端已不存在的会话(重启后未落库虚拟会话/跨窗口删除竞态):侧栏高亮指旧会话,sendMessage 把消息发进用户刚离开的会话,持久化与内存分叉。修复:补一行置 activeId+对齐 per-conv 游标重算,或直接复用 switchConversation 统一路径。
### P2(6 项)
- **P2-1** _lastDeltas 去重 Map 双路径漏清(aiShared.ts:400-420;漏清点 useAiEvents.ts:96-115 后台终止/:154-161 跨窗口删除)→无界残留+切回后首条 delta 被去重误丢(回复开头缺字符)。修复:两处补 clearLastDelta 或收敛进 clearConvStreamState 成对清理。
- **P2-2** startListener 部分失败→listener 泄漏+双注册(useAiEvents.ts:142-175):onEvent 成功后续 listen() 抛错不回滚,_startPromise 清零后下次全量重注册→流式文字双倍。修复:catch 内补偿 unlisten 或单数组统一回滚。
- **P2-3** 看门狗 legacy/per-conv 双轨迁移半途三错配(useAiSend.ts:101 vs :169/:241/:512):stopChat 清不到 per-conv timer(停止成功 90s 后补莫名「已收敛」气泡或误报中断);regenerate/editMessage 走 legacy forceConverge→convStates.clear() 抹掉其他并发会话生成态;_resumeAttempts 会话完成后不清,下次续跑额度缩水。修复:统一迁移传 convId(债务本就要还)+clear 时顺带删 _resumeAttempts。
- **P2-4** removeGoal 乐观更新不回写 store/失败不回滚→已删目标「复活」(TopBar.vue:530-539;loadGoals 读 conv.pinned_goals 重灌)。修复:成功同步写 pinned_goals 或触发 refresh,失败回滚+toast。
- **P2-5** 切换/新建会话整体覆写 state.pendingApprovals(useAiConversations.ts:419/:462 同族 newConversation:110/clearChat useAiPanel.ts:191)→聚合层 per-conv 共存被旁路,切走期间后台会话审批入口全盲(与 P1-1 叠加成「后台审批双盲」)。修复:恢复路径按 conversationId 合并(保留其他 conv 段)。
- **P2-6** i18n 地雷:searchFilesBlocked 值含裸 @(zh-CN/aiChat.ts:198 en/aiChat.ts:197,`@[项目]` 未按 {'@'} 转义)——当前死键无消费方不炸,接线即踩。修复:转义或删死键。
### P3(8 项合并)
clearAllApprovalTimers 全清误伤并发会话审批倒计时;整轮收尾 clearAllToolSlowTimers 全清丢后台慢执行 toast;删除会话双调 switchConversation(冗余非 bug);activeId 空窗被后台首事件抢占;切会话未重置 expandedLongIds/JsonIds/SegmentIds/ThinkIds(跨会话展开态残留+fallback id 跨会话撞 key);分离窗口 X 关不清 df-ai-gen-*/df-ai-text-*;clearChat 清审批不清计时器(到点自动拒+气泡进已清空视图);@任务联想缓存组件生命周期不失效。
### 排查干净面(免重查)
事件监听器清理全成对;per-conv Map 主干写收敛正确;useToolCard/纯函数层无状态问题;AiUserMessage 自回灌疑点排除;组件 i18n 除 P2-6 无特殊字符地雷。
---
## 三、crates 走查(rev-crates)
范围:df-ai 30 文件+df-storage 24 文件全量,主代理亲读迁移链/message_repo/conversation_repo/idea_repo/settings 白名单/ai_tools 注册/context/retry/lib,4 并行子审查覆盖 compat/context/storage/编排网络层。未覆盖 secret.rs(权限拒绝)。
### P0-1 V21 迁移 COUNT 幂等窗口:中途崩溃→ai_messages 永久残缺且被版本号掩盖【代理已核验】
- 位置:crates/df-storage/src/migrations.rs:481-488, :570
- 根因:批量迁移(每 50 对话一事务)先 SELECT COUNT(*) FROM ai_messages,>0 即跳过全部数据迁移并写版本号;版本号 21 在所有批次跑完后才写。批次中途崩溃/断电,重跑时 COUNT>0→剩余对话永不再迁,但版本号照样补齐,迁移链视为完成。
- 影响:该批对话 ai_messages 永久缺行(list_recent 在用读路径翻历史缺消息;原始 JSON 还在 conversations.messages 列,非真丢)。
- 修复:按对话进度标记(临时表记已完成 conv_id)替代全局 COUNT;或每批成功后写进度,重跑从断点续。
### P1(12 项,逐条)
1. **OpenAI 流式 error 帧/usage 尾帧因 choices 必填被整帧静默丢弃**(openai_helpers.rs:193-203 choices 无 default,:260,:349 Err→debug! 吞):标准 error 帧 `{"error":{...}}` 无 choices 键→serde 失败被吞,:265-280 error 处理对最常见形态是死代码,中转站流中途报错呈「无声截断」。修复:choices/delta/index 加 default+补无-choices 单测。
2. **同步响应 .json() 的 serde 结构错被判 Retryable**(anthropic_compat.rs:629-638/openai_compat.rs:527-535):reqwest 0.12 把 serde 错误包成 is_decode()==true 先命中 Retryable——确定性结构错重试 3 次白烧 30s。修复:先 resp.text() 再 from_str(读中断→Retryable,结构错→Fatal)。
3. **Anthropic usage/id 必填非 Option**(anthropic_helpers.rs:91-105/:66):网关 200+缺 usage→整响应失败,有效文本回复整体丢弃(叠加#2 还重试 3 次)。修复:usage 改 Option+缺失 warn。
4. **stop_reason 全链路不可见→max_tokens 4096 截断静默零痕迹**(anthropic_helpers.rs:71/:176-184;OpenAI 侧有 FinishReason 告警,不对称)。修复:stop_reason=="max_tokens" warn。
5. **TokenUsage 口径两栈漂移**(openai_compat.rs:542-549 缺失全零静默入库 vs Anthropic 缺失硬失败;anthropic cache 段少记 prompt 不含 cache_read/creation)。修复:None warn 留痕;cache 场景 prompt=input+creation+read。
6. **embed() 无超时+返回条数不校验**(openai_compat.rs:449-474):body 可无限 hang;data.len()<texts.len() 不校验→检索向量错位。修复:tokio timeout+len 断言。
7. **model_fetch 空 id 列表返回 Ok([])→上层写回清空用户模型池**(model_fetch.rs:169-186/:147-163;src-tauri 写回点待核):一次畸形/空响应静默清空 model_configs(含 UserSet 标注)。修复:空结果返回 Err/标记;写回点空列表不落库。
8. **ai_messages INSERT OR IGNORE 静默丢写**(message_repo.rs:86-102/:289-307):UNIQUE(conv,seq) 冲突整条 IGNORE 仍 Ok(()),调用方只判 Err;增量 insert_batch 路径 seq 重排/双写即静默丢消息。修复:核对 affected 与 records.len()。
9. **整行/CAS 更新缺软删防护**(task_repo.rs:800/project_repo.rs:452/宏 crud/mod.rs:183-196):fbd8fae 给 update_field_active 加了 deleted_at IS NULL,整行路径没跟上——回收站任务可被 df-mcp CAS 改写。修复:CAS/整行 UPDATE 补 deleted_at IS NULL。
10. **module_dependency_repo::list_by_field 列名直拼无白名单**(module_dependency_repo.rs:72-81,全 crate 唯一例外;当前调用方均字面量暂不可达注入,API 约定破口)。修复:白名单限定 5 列。
11. **truncate_after_user_message 标 truncated 不回收 history_tokens**(context/mod.rs:385-406):唯一不同步扣还的路径,编辑重生成后 history_tokens 永久虚高→过早裁剪+under-trim 超预算发送。修复:标记时逐条 saturating_sub。
12. **sanitize 合并连续同 role 丢多模态 parts**(context/sanitize.rs:406-425):合并只拼 content+tool_calls,parts 不迁移,空 content 纯图消息整个消失;触发路径现实(裁剪概要摘要与首个保留 user 相邻必合并,该 user 带图则图对 LLM 隐身)。修复:合并分支 extend parts。
### P2(34 项,一行一条)
df-storage:①from_row 吞列错误为默认值(conversation_repo.rs:25,58-63,100);②手写 BEGIN/COMMIT panic 残留打开事务(:259-296 改 guard.transaction());③insert_checkpoint OR IGNORE 静默跳过(:609-636);④limit=0 语义分裂(message_repo.rs:164);⑤父任务软删校验缺失致回收站父下隐形子任务(task_validation.rs:50-71);⑥project_service 宏绕过 validate_record/D10(project_service_repo.rs:128-159);⑦模块删除未清依赖边 FK 报错(project_module_repo.rs:172-205);⑧CAS 以 TEXT 等值比较 updated_at(task_repo.rs:800);⑨未知 status 静默变默认值+update_full 回写放大(task_repo.rs:30/idea_repo.rs:107/project_repo.rs:114);⑩V43 confidence='high' 精确小写匹配 vs 提炼侧无归一化(migrations.rs:1304,非小写条目滞留靠 include_candidates 兜底)。
df-ai:⑪intent "hi"/"api" 子串无词边界,history 等全判 Chat 0.9(intent.rs:186,194,420-424);⑫restore_from_messages 重放造假 pending_topic_marker(mod.rs:308-319,加载后首轮假话题锚点);⑬URL 图 token 地板被 ×0.35 稀释 1600→560(context_helpers.rs:73-80);⑭sync_after_compress 吞 JSON 解析失败(:881-902);⑮build_injection 脏窗口 off-by-one 多注一轮(:837);⑯TOOL_MISSING_PREFIX 前缀=已配对盲区(sanitize.rs:236/315);⑰manager_tests 恒零断言+同义反复(:401-405/:484-489);⑱begin_turn 喂变换后文本(src-tauri chat.rs:810/2112 调用方漂移);⑲tool_signature 漂移:read_file 缺 search、search_files/grep 缺 offset/limit/-i/output_mode、run_command 缺 working_dir(合法换参重调共享签名,与 dd0afcf 同类);⑳ai_tools.rs:241 SigValKind::Json 组只用 keys[0](latent)。
编排/网络(多 latent):㉑git_worktree merge-tree 传参预检失效(git_worktree.rs:174-188);㉒coordinator JoinSet 子任务 panic 结果静默缺项(coordinator.rs:511-520);㉓worktree/branch 清理吞错恒 Ok(:224-295);㉔router 全禁用静默回落 default_model(router.rs:96-101);㉕plan_executor 串行冒充并行+重复 id 假环(plan_executor.rs:65-91);㉖namespace_store 上限可永久突破(:144-157);㉗retry 位移溢出+预算最坏 121s≠文档 30s(retry.rs:77/:126-135);㉘未知 provider_type 静默回落 OpenAI(lib.rs:143-150);㉙sse_parser BUF_MAX 溢出截头致 JSON 破损(sse_parser.rs:150-157);㉚message_start 缺失 prompt_tokens 兜底 0;㉛同步 tool_use input 缺失 args="";㉜content_filter 不算 finished,无 [DONE] 流无终态(openai_helpers.rs:303-305);㉝model_probe "-v"/"vl" 误判 Vision(:118)。
### P3(合并)
u64→u32 as 截断(不可达);complete 内联 headers 未复用;同步 reasoning_tokens 恒 0;BOM 首事件;begin_turn 首行注释与实现矛盾(context_helpers.rs:857-862);retry 单测界宽于实现。
### 已核对无漂移(免重查)
V42 is_estimated/V33 pending_approvals/V41 module_id/V29 三列/V24 related_ids 迁移链全对齐;V43 迁移本身幂等可重入测试完备;KNOWLEDGE_COLS 与 KnowledgeRecord 15 字段一致;.optional() 用法全对;anthropic 双栈 SSE 多行拼接/跨 chunk UTF-8/thinking/tool_call 空 id fallback 自洽。
### 待下批核对
save_conversation 是否检查 needs_full_rewrite 再走增量路径(advance_persisted_count 无条件清 dirty,漏查即静默漏更新旧行);secret.rs(本次权限拒绝未读)。
---
## 四、批次归并建议(主代理整理)
- **批次2(后端高危)并入**:后端 P0-1(首位,4 行)+P1-3(删除不停 loop,与已登记 conv 删除族同域合并做)+P2-2(切片钳制一行,与 F2 前移同域);crates P0-1(V21 迁移进度标记)+P1-1/P1-2(流式 error 帧与 Retryable 误判,同文件同批)+P1-12(sanitize 丢图)。
- **批次3(前端体验)并入**:前端 P1-1(后台审批路由,与本批 incomplete/守卫同文件)+P1-2(兜底漏置 activeId 一行)+P2-5(审批数组 per-conv 合并,与 P1-1 同域)+P2-1/P2-3(看门狗双轨迁移,本就要还的债)。
- **机械批(可独立小批)**:remote_bridge 6 处+agentic 4 处裸 emit 换 emit_ai_chat_event;P2-6 死键转义。
- **登记观察**:crates P1-7(model_fetch 清空模型池,src-tauri 写回点待核后定级);其余 P2 按域分批消化。
+73 -6
View File
@@ -15,7 +15,7 @@
> 背景:用户反馈交互刷屏已修(工具卡默认折叠)后,按「使用体验/使用成本」深入走查三动线;截图实证「写完就删」循环跑到轮次耗尽——G2 熔断签名机制对写删交替结构性漏网(唯一率 0.83≫0.4,单签名最高 2 次<3,全程连警示都不触发);与 9-28 走查 17 项合并排序。 > 背景:用户反馈交互刷屏已修(工具卡默认折叠)后,按「使用体验/使用成本」深入走查三动线;截图实证「写完就删」循环跑到轮次耗尽——G2 熔断签名机制对写删交替结构性漏网(唯一率 0.83≫0.4,单签名最高 2 次<3,全程连警示都不触发);与 9-28 走查 17 项合并排序。
> 详情:[05-代码审查/aichat可用性与交互成本研究-2026-09-29.md](./05-代码审查/aichat可用性与交互成本研究-2026-09-29.md) > 详情:[05-代码审查/aichat可用性与交互成本研究-2026-09-29.md](./05-代码审查/aichat可用性与交互成本研究-2026-09-29.md)
- [ ] 第一批 P1:I1 停止能力窄窗(工具执行期按钮消失,失控不可干预)+I2 编辑入口死链(=9-28#1)+G2 破坏性回环熔断(写后 N 步内删同路径计数,误杀率极低)+V2 审批黑洞(非活跃会话审批事件 isCurrent 守卫整体丢弃)+V1/V3 后台信号缺失(等审批伪装 generating+完成零通知) - [x] 第一批 P1 ✅2026-10-02 走查核验:I1 停止窄窗已修(ChatInput.vue:291 stopBtnState 读 conv_state)+I2 编辑死链已修(04acc42 canEdit+start-edit)+G2 破坏性回环熔断已实现(helpers.rs detect_destructive_loop)+V3 后台通知已修(e1dd0bc notify.rs 接线);V2 聚合层已修(useAiToolEvents 无条件入列,V5/V6 未逐项核)与 V1 审批徽标未修→留前端体验批
- [ ] 第二批 P2:V8 草稿 per-conv 防串台/R3 长回复带量提示+R4 代码块复制/I5 错误分类兜底(429 占 55%)/V4 浮窗高风险二次确认/V7 排队时钟冻结/R5+V9 消息内容检索 等 16 项,排序详报告第五章 - [ ] 第二批 P2:V8 草稿 per-conv 防串台/R3 长回复带量提示+R4 代码块复制/I5 错误分类兜底(429 占 55%)/V4 浮窗高风险二次确认/V7 排队时钟冻结/R5+V9 消息内容检索 等 16 项,排序详报告第五章
- [ ] 第三批 P3 攒批:I8-I13+R8-R13+V10-V12+9-28 #9-#17;其中 9-28#17 图片默认上传外部域名涉开源边界,单独决策 - [ ] 第三批 P3 攒批:I8-I13+R8-R13+V10-V12+9-28 #9-#17;其中 9-28#17 图片默认上传外部域名涉开源边界,单独决策
- [x] ✅ 同日已修:工具卡默认折叠刷屏+read_file/list_directory 单行摘要补齐+分组折叠幂等补齐(工作区待提交) - [x] ✅ 同日已修:工具卡默认折叠刷屏+read_file/list_directory 单行摘要补齐+分组折叠幂等补齐(工作区待提交)
@@ -26,12 +26,12 @@
> 详情:[05-代码审查/aichat知识库与记忆走查-2026-09-29.md](./05-代码审查/aichat知识库与记忆走查-2026-09-29.md) > 详情:[05-代码审查/aichat知识库与记忆走查-2026-09-29.md](./05-代码审查/aichat知识库与记忆走查-2026-09-29.md)
> ✅ 同日已修:上下文管理演进 T4/T5 生产侧空壳补齐(WorkingContext begin_turn/sync_after_compress 写入链 + 工作流 DAG 摘要注入链),编译门禁过,详见诊断文档第四章。 > ✅ 同日已修:上下文管理演进 T4/T5 生产侧空壳补齐(WorkingContext begin_turn/sync_after_compress 写入链 + 工作流 DAG 摘要注入链),编译门禁过,详见诊断文档第四章。
- [ ] P0 知识检索去整句 LIKE(分词/关键词多条件 OR 或默认开语义检索,idea_repo.rs:640-661 + knowledge_inject.rs:364-384) - [x] P0 知识检索去整句 LIKE ✅已修(2026-10-02 核验:7c62d8d 接 search_keywords 分词,knowledge_inject 与 knowledge_search 工具两调用点实证,idea_repo.rs:699 纯函数+单测齐)
- [ ] P0 candidate→published 断链打通(自动提炼产物按置信/复用自动晋升,人工审核改可选) - [x] P0 candidate→published 断链打通 ✅已修(机制先行 7c62d8d:复用自动晋升+knowledge_promote 工具+晋升补嵌入;存量晋升迁移 V43 2026-10-02 落地:candidate+confidence=high→published,库副本实证 854 条晋升 967/53→113/907,重跑幂等 0 行,df-storage 180 测试全绿;增量 confidence 分级与 auto_publish 默认值等 C3 拍板)
- [ ] P0 视图裁剪补滚动摘要(超预算淘汰单元无声丢弃,无补偿,context/mod.rs:206-253) - [x] P0 视图裁剪补滚动摘要 ✅已修(9b89610 淘汰补偿摘要插视图首部,user 角色保缓存指纹,纯视图不落库,eviction 11 测试全绿,2026-10-02 核验)
- [ ] P1 LLM 知识工具注册(tool_registry 复用 df-mcp search/insert_knowledge,agent 可按需查/存) - [x] P1 LLM 知识工具注册 ✅已修(7c62d8d 注册 knowledge_search/insert/promote 三工具,tool_registry.rs:798,检索与注入同源分词)
- [ ] P1 跨会话召回最小闭环(项目级记忆+会话结束提炼进知识库,source_project 已有字段未用) - [ ] P1 跨会话召回最小闭环(项目级记忆+会话结束提炼进知识库,source_project 已有字段未用)
- [ ] P1 存量测试修复 4 个(anthropic_compat usage 断言/model_fetch_helpers 过滤,疑似 6bafdcd 后未同步) - [x] P1 存量测试修复 4 个 ✅已修(df3ed55 断言同步+e7cd035 笔误修正,2026-10-02 全量 cargo test --workspace 1827 passed/0 failed 复核)
- [ ] P2 DB 落库截断分 role 定阈值(user 原话均 41 字被 3072 截断无必要)/namespace 重启懒重建/兜底压缩路径 sync_after_compress 复议 - [ ] P2 DB 落库截断分 role 定阈值(user 原话均 41 字被 3072 截断无必要)/namespace 重启懒重建/兜底压缩路径 sync_after_compress 复议
### 💡 2026-09-28 devflow 集成 u-claw 能力(可行性已定,待实施) ### 💡 2026-09-28 devflow 集成 u-claw 能力(可行性已定,待实施)
@@ -827,3 +827,70 @@ graph TD
**关联**:本走查与「2026-08-09 TaskDetail 操作按钮缺陷走查」同属 TaskDetail 交互审查线,但本段聚焦 AI 工作流推进链路(后端 + 审批 UI),按钮缺陷聚焦 TaskDetail 自身交互。修复时建议合并处理(都在 TaskDetail + workflow 链路)。 **关联**:本走查与「2026-08-09 TaskDetail 操作按钮缺陷走查」同属 TaskDetail 交互审查线,但本段聚焦 AI 工作流推进链路(后端 + 审批 UI),按钮缺陷聚焦 TaskDetail 自身交互。修复时建议合并处理(都在 TaskDetail + workflow 链路)。
---
# aichat agent loop 静默死亡走查待办(2026-10-02)
> 来源:中断根治批次全量走查(正常设计路径无漏 emit,风险集中四类)。P1-2/P0-1/P2-1 本批已修,以下为登记项。
### P0 run_agentic_loop 包 catch_unwind
- **位置**:`agentic/mod.rs` run_agentic_loop 主体(无 catch_unwind)
- **现状**:任一轮 prepare/stream/finalize/tools 段 panic → task 死亡 → GeneratingGuard::drop 只迁 Idle+emit AiConvStateChanged,**不 emit AiError/AiCompleted**,heartbeat 随作用域死亡——「工具完成后流断+无终止事件」的精确根因形状(前端探测式续跑已兜住后果,此为后端根治)。
- **修复**:整体包 `std::panic::catch_unwind(AssertUnwindSafe(...))`,unwind 时 emit AiError{error_type:Internal} 再收敛;guard Drop 兜底保留。核心 loop 改动,需谨慎设计+场景回放测试。
### P1 conv 删除类静默退出族 6 处补终态事件
- **位置**:`agentic/mod.rs:417/:1316/:1379/:1437/:1593/:1423`、`stream_relay.rs:520-525`(MidStream 保文时 conv-deleted 跳过 teardown emit,最刺眼)
- **现状**:生成期间会话被删,裸 return 仅 guard Drop 迁 Idle,无终止事件。
- **修复**:收敛到 conv-deleted 也 emit 带 conv_id 的 AiCompleted(incomplete) 或等价终态语义事件;至少 stream_relay.rs:525 处补 teardown。
### P2 审批超时惰性收敛改主动任务
- **位置**:`agentic/approval_timeout.rs:5-6`(cleanup_expired_approvals 仅 try_continue_agent_loop 入口调用)
- **现状**:用户不点任何按钮,超时审批永久占位、会话永远停审批态;注释语义(自动转拒绝)与实现(下次交互才转)不符。
- **修复**:审批挂起时 spawn timeout 定时任务到点主动 cleanup+emit(现有函数可复用)。
### 附:F2 兜底前移(可选)
- `ai_chat_stop` 的心跳 60s stale 判死+emit AiCompleted 兜底只在用户点停止时运行;可在 loop spawn 时自带一份,panic 死 loop 无需用户点停止也能收敛。
# aichat 三路走查增量待办(2026-10-02 深夜)
> 来源:上下文管理批收口期 rev-backend/rev-frontend/rev-crates 三路走查(历三次会话重启后续跑完成)。**全文与批次归并建议见 `docs/05-代码审查/aichat三路走查报告-2026-10-02.md`**。以下为可执行登记,开工以现场源码为准。
### 批次2(后端高危)并入项
- [ ] P0 压缩 conv_id 分裂【已亲核实锤】:`commands/chat.rs:1734` 起 cid 切 active——压缩后台会话时目标 conv compressing 永久卡死 + active 会话被插入他人摘要入库。修::1734 改 `cid = conversation_id.clone()`(约 4 行,首位)
- [ ] P0 V21 迁移 COUNT 幂等窗口:`df-storage/migrations.rs:481-488,570` 批次中途崩溃→剩余对话永不迁移但版本号补齐。修:按对话进度标记替代全局 COUNT
- [ ] P1 conv delete 不停 in-flight loop:`commands/conversation.rs:608-640` 无 stop_flag/epoch bump——zombie 复活+审批吊死。**与上方「conv 删除族 6 处」同域合并做**
- [ ] P1 只读结果缓存无写失效:`audit/cache.rs` 写后读回过期内容。修:per_conv 脏标记,写后整体失效
- [ ] P1 OpenAI 流式 error 帧被 choices 必填整帧丢弃:`df-ai/openai_helpers.rs:193-203`(无声截断)+ 同批 serde 结构错误判 Retryable(`anthropic_compat.rs:629`/`openai_compat.rs:527`)
- [ ] P1 sanitize 合并连续同 role 丢多模态 parts:`df-ai/context/sanitize.rs:406-425` 带图消息图对 LLM 隐身。修:合并分支 extend parts
- [ ] P2-2 maybe_auto_compress protect_start 跨锁过期切片 panic:`context_lifecycle.rs:118` 加 `min(len)` 钳制(一行,与 F2 前移同域)
- [ ] P2-3 try_continue_agent_loop 裸 emit 绕过 emit_ai_completed_once:`agentic/helpers.rs:100/:171` 占位 tool_result 与 pending 残留
### 批次3(前端体验)并入项
- [ ] P1 后台审批/授权事件被 isCurrent 丢弃→会话挂死:`useAiEvents.ts:94-123` !isCurrent 分支对 ApprovalRequired/DirAuthRequired 放行(与 P2-5 审批数组 per-conv 合并同域)
- [ ] P1 switchConversation「对话不存在」兜底漏置 activeId:`useAiConversations.ts:294-309` 补一行(视图/高亮/发送目标三重错配)
- [ ] P2 看门狗 legacy/per-conv 双轨三错配:useAiSend.ts:169/:241/:512 统一迁移传 convId + `_resumeAttempts` 收尾清(债务本就要还)
- [ ] P2 _lastDeltas 双路径漏清:useAiEvents.ts:96-115/:154-161 补 clearLastDelta
- [ ] P2 removeGoal 不回写 store/不回滚:TopBar.vue:530-539 对齐 withConvOp 模式
- [ ] P2 forceConverge 收敛/错误气泡缺 activeId 守卫(useAiStream.ts:115-121,与本批 onStreamTimeout 修复同家族)
### 机械批(可独立小批)
- [ ] remote_bridge 6 处 + agentic/mod.rs 4 处裸 emit 换 emit_ai_chat_event(miniapp 收不到错误/心跳/熔断卡)
- [ ] i18n searchFilesBlocked 死键裸 @(zh-CN/en aiChat.ts:198/197)转义或删
### 登记观察(定级前先核)
- [ ] model_fetch 空列表 Ok([]) 上层写回清空模型池(df-ai 侧已核验,src-tauri provider.rs 写回点待核后定级)
- [ ] save_conversation 需核 needs_full_rewrite 检查(advance_persisted_count 无条件清 dirty);secret.rs 未走查(权限拒绝)
# 后续规划方向(2026-10-02 用户口述,不分先后)
> 用户指令:完成 ai-chat 核心后,以下 9 项纳入后续规划,先记录后逐渐开展。此处为方向索引,开展时各自立项细化。
1. 打造核心 ai-chat 及上下文管理能力(当前上下文管理批即此项主线,持续演进)
2. 知识库、技能、记忆的管控管理(知识链路 B 系延伸;candidate 晋升/跨会话召回 C1/记忆空壳定案后续)
3. miniapp(关联 df-miniapp 跨端三层,Phase3 后端闭环已有,剩真机联调+UI+token 生产级)
4. 接入飞书 bot(启动定位=调度中枢的飞书侧动线,P0 打包+u-switch 接入已在本批)
5. devflow 云端化部署(分布式 ai 办公协同)
6. 接入企微
7. UX 体验交互体验(批次3 及后续 UX 批持续线)
8. 内置数据库、远程服务器连接能力(与代理工具生态 mysql-proxy/ssh-proxy 等整合)
9. 远程内网设备文件程序管控(中继+代理 client,内置于 u-desk/devflow 或全新设计,关联 u-relay)
+6 -6
View File
@@ -18,7 +18,7 @@
"dagre": "^0.8.5", "dagre": "^0.8.5",
"dompurify": "^3.4.10", "dompurify": "^3.4.10",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"html2canvas": "^1.4.1", "html2canvas-pro": "^2.5.0",
"marked": "^18.0.5", "marked": "^18.0.5",
"mermaid": "^11.16.0", "mermaid": "^11.16.0",
"sortablejs": "^1.15.7", "sortablejs": "^1.15.7",
@@ -2695,17 +2695,17 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/html2canvas": { "node_modules/html2canvas-pro": {
"version": "1.4.1", "version": "2.5.0",
"resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz", "resolved": "https://registry.npmmirror.com/html2canvas-pro/-/html2canvas-pro-2.5.0.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", "integrity": "sha512-+xuLQWcYMGvbXtlIzVOLvNJEXqL/dY7Dbn2T4qYtJvqfPVtJ8BcNr1CrrUp/uCxne9DE/F+eZHsf1khb1dbMyg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"css-line-break": "^2.1.0", "css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3" "text-segmentation": "^1.0.3"
}, },
"engines": { "engines": {
"node": ">=8.0.0" "node": ">=16.0.0"
} }
}, },
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
+1 -1
View File
@@ -24,7 +24,7 @@
"dagre": "^0.8.5", "dagre": "^0.8.5",
"dompurify": "^3.4.10", "dompurify": "^3.4.10",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"html2canvas": "^1.4.1", "html2canvas-pro": "^2.5.0",
"marked": "^18.0.5", "marked": "^18.0.5",
"mermaid": "^11.16.0", "mermaid": "^11.16.0",
"sortablejs": "^1.15.7", "sortablejs": "^1.15.7",
+1 -1
View File
@@ -14,7 +14,7 @@ tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
# Phase3 桌面内嵌 HTTP MCP server + 托盘常驻:tray-icon feature 非默认,必须显式启用 # Phase3 桌面内嵌 HTTP MCP server + 托盘常驻:tray-icon feature 非默认,必须显式启用
tauri = { version = "2", features = ["tray-icon"] } tauri = { version = "2", features = ["protocol-asset", "tray-icon"] }
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-window-state = "2" tauri-plugin-window-state = "2"
@@ -119,8 +119,12 @@ pub(super) async fn maybe_auto_compress(
let conv = session.conv(&conv_id); let conv = session.conv(&conv_id);
conv.messages.set_compressing(true); conv.messages.set_compressing(true);
// 读 active 克隆(不改 status):filter is_active,LLM 失败则消息状态完全不变。 // 读 active 克隆(不改 status):filter is_active,LLM 失败则消息状态完全不变。
let active_msgs: Vec<ChatMessage> = conv.messages.messages_mut() // protect_start 是上一段锁内算的快照,两段锁之间消息可能被清空/裁剪变短,
[..protect_start] // 切片前按当时实际长度钳制,防 index out of bounds panic
// (后续 compress_old_messages 内部自带 min(len) 钳制,口径一致)。
let msgs = conv.messages.messages_mut();
let cut = protect_start.min(msgs.len());
let active_msgs: Vec<ChatMessage> = msgs[..cut]
.iter() .iter()
.filter(|t| t.message.is_active()) .filter(|t| t.message.is_active())
.map(|t| t.message.clone()) .map(|t| t.message.clone())
+180 -31
View File
@@ -96,15 +96,17 @@ pub async fn try_continue_agent_loop(
Some(cid) => cid, Some(cid) => cid,
None => conv_id.to_string(), None => conv_id.to_string(),
}; };
// 零 token 收敛信号,非估算(无真实 LLM 调用)。 // 走正典 emit_ai_completed_once(零 token 收敛信号,非估算,事件字段与裸 emit 等价):
emit_ai_chat_event(app, AiChatEvent::AiCompleted { // 随正典出口一并终态化占位 tool_result + 清该 conv 的 pending_approvals,
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, // 防挂起审批残留的 __PENDING__: 占位下次发送喂给 LLM。
prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0, // notify=false:本站点是 generating 已复位的收尾信号,非真实完成,不发系统通知
is_estimated: false, // (对齐原裸 emit 无通知行为)。
incomplete: None, let zero_usage = df_ai::provider::TokenUsage::default();
conversation_id: Some(emit_conv_id), super::emit_ai_completed_once(
pinned_goals: snap.pinned_goals_snapshot.clone(), app, &emit_conv_id, &zero_usage, false,
}); None, None, None, true,
&snap.pinned_goals_snapshot, false,
).await;
} }
return; return;
} }
@@ -167,15 +169,17 @@ pub async fn try_continue_agent_loop(
// B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争,无需 session lock)。 // B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争,无需 session lock)。
let still_generating = state.conv_states.is_active(conv_id); let still_generating = state.conv_states.is_active(conv_id);
if !still_generating { if !still_generating {
// 零 token 收敛信号,非估算(无真实 LLM 调用)。 // 走正典 emit_ai_completed_once(零 token 收敛信号,非估算,事件字段与裸 emit 等价):
emit_ai_chat_event(app, AiChatEvent::AiCompleted { // 随正典出口一并终态化占位 tool_result + 清该 conv 的 pending_approvals,
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, // 防挂起审批残留的 __PENDING__: 占位下次发送喂给 LLM。
prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0, // notify=false:本站点是 generating 已复位的收尾信号,非真实完成,不发系统通知
is_estimated: false, // (对齐原裸 emit 无通知行为)。
incomplete: None, let zero_usage = df_ai::provider::TokenUsage::default();
conversation_id: Some(conv_id_owned.clone()), super::emit_ai_completed_once(
pinned_goals: snap.pinned_goals_snapshot.clone(), app, &conv_id_owned, &zero_usage, false,
}); None, None, None, true,
&snap.pinned_goals_snapshot, false,
).await;
return; return;
} }
@@ -266,34 +270,49 @@ pub(crate) fn tool_call_signature(name: &str, args: &serde_json::Value) -> Strin
/// ///
/// 样本 = (签名, 是否写类调用)。策略(后两条任一命中即 true): /// 样本 = (签名, 是否写类调用)。策略(后两条任一命中即 true):
/// - 样本不足(len < `REPETITION_MIN_SAMPLE`=6)→ false(不判,小样本误杀风险高)。 /// - 样本不足(len < `REPETITION_MIN_SAMPLE`=6)→ false(不判,小样本误杀风险高)。
/// - **唯一签名数 / 总数 < 0.4**(超 60% 重复)→ true。 /// - **有效单元数 / 总样本数 < 0.4**(折损超 60%)→ true。
/// 覆盖「多个签名轮换但整体高度重复」(如 a/b/c/a/b/c/d/a/b),唯一率低 = 没有新探索方向。 /// 覆盖「多个签名轮换但整体高度重复」(如 a/b/c/a/b/c/d/a/b),有效单元占比低 = 没有新探索方向。
/// - **或:某签名「无写间隔连续段」>= 3** → true。覆盖「单点反复」。 /// - **或:某签名「无写间隔连续段」>= 3** → true。覆盖「单点反复」。
/// ///
/// 写间隔豁免(脚本失败致执行中断的根治):验证命令(测试/构建/lint)反复跑但两次之间 /// 有效单元数(唯一率分子):每签名首现 +1;再现时满足任一豁免也 +1——
/// 「与上次出现间夹有写类样本(区间不含自身:世界已变,重查是新鲜验证活动)」或
/// 「自身是写类」(写类对称豁免,对齐单点分支「写不作死磕计数对象」语义);
/// 未豁免的再现 +0(无写证据的重复折损)。豁免的再现计为新单元而非去重删除:
/// 写后重验是世界变化后的新活动,不与历史失败累计,分母保持全样本数不变。
///
/// 写间隔豁免(两分支对称):验证命令(测试/构建/lint/grep 验证残留)反复跑但两次之间
/// 夹有写类调用(patch/write 改了世界)是红绿迭代——重跑是验证行为,不与历史失败累计; /// 夹有写类调用(patch/write 改了世界)是红绿迭代——重跑是验证行为,不与历史失败累计;
/// 中间只有读类调用不算证据(世界未变,重跑仍是无进展重复)。原「总出现次数 >= 3」口径 /// 中间只有读类调用不算证据(世界未变,重跑仍是无进展重复)。单点分支按「无写间隔连续段」
/// 会把红绿迭代误判漂移(12 样本窗口内同一测试命令必然出现 >= 3 次),阈值不变、纯死磕 /// 计数;唯一率分支按上述有效单元数口径——否则清理任务「write→grep 验证→write→grep 验证」
/// 语义不变,只把"死磕"修准为"中间无写证据的重复"。 /// 窗口内同 grep 签名重复超 60% 会误判游荡。
/// 阈值不变、纯死磕语义不变,只把"重复折损"修准为"无写证据的重复"。
pub(crate) fn is_repetitive_exploration(samples: &[(String, bool)]) -> bool { pub(crate) fn is_repetitive_exploration(samples: &[(String, bool)]) -> bool {
/// 最小样本量:不足此数不判定(避免早期误杀,如刚启动 2-3 个 grep 全不同不应熔断)。 /// 最小样本量:不足此数不判定(避免早期误杀,如刚启动 2-3 个 grep 全不同不应熔断)。
const REPETITION_MIN_SAMPLE: usize = 6; const REPETITION_MIN_SAMPLE: usize = 6;
/// 单签名无写间隔连续段阈值:达此即判单点死磕漂移。 /// 单签名无写间隔连续段阈值:达此即判单点死磕漂移。
const REPETITION_SINGLE_MAX: usize = 3; const REPETITION_SINGLE_MAX: usize = 3;
/// 唯一签名占比阈值:低于此(重复超 60%)判整体游荡不前进。 /// 有效单元占比阈值:低于此(折损超 60%)判整体游荡不前进。
const REPETITION_UNIQUE_RATIO: f64 = 0.4; const REPETITION_UNIQUE_RATIO: f64 = 0.4;
if samples.len() < REPETITION_MIN_SAMPLE { if samples.len() < REPETITION_MIN_SAMPLE {
return false; return false;
} }
let total = samples.len(); let total = samples.len();
let unique = { // 有效单元数:首现 +1;再现时「与上次出现间夹写(samples[j+1..i],不含自身)」或
let mut s: Vec<&String> = samples.iter().map(|(sig, _)| sig).collect(); // 「自身是写」则豁免 +1,否则 +0 折损。HashMap 按签名记上次下标,一趟扫 O(n)。
s.sort(); let mut last_idx: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
s.dedup(); let mut units = 0usize;
s.len() for (i, (sig, is_write)) in samples.iter().enumerate() {
let is_new_unit = match last_idx.get(sig.as_str()) {
None => true, // 首现
Some(&j) => *is_write || samples[j + 1..i].iter().any(|(_, w)| *w),
}; };
let unique_ratio = unique as f64 / total as f64; if is_new_unit {
units += 1;
}
last_idx.insert(sig.as_str(), i);
}
let unique_ratio = units as f64 / total as f64;
if unique_ratio < REPETITION_UNIQUE_RATIO { if unique_ratio < REPETITION_UNIQUE_RATIO {
return true; return true;
} }
@@ -778,6 +797,136 @@ mod tests {
assert!(!is_repetitive_exploration(&reps(sigs))); assert!(!is_repetitive_exploration(&reps(sigs)));
} }
// ── 唯一率分支写间隔豁免测试 ──
/// 场景清理任务红绿验证:grep 同签名反复验证残留,但每次再现前都夹有 write(世界已变,
/// 重查是验证行为)——唯一率分支豁免后不应判游荡。旧口径 12 样本窗口内同签名重复 6 次
/// 必误熔断。
#[test]
fn scenario_cleanup_verify_with_writes_not_repetitive() {
let g = tool_call_signature("grep", &serde_json::json!({"pattern": "TODO", "path": "src"}));
let run = tool_call_signature("run_command", &serde_json::json!({"command": "cargo check"}));
let read = tool_call_signature("read_file", &serde_json::json!({"path": "a.rs", "offset": 0, "limit": 50}));
let w = |n: &str| {
tool_call_signature("patch_file", &serde_json::json!({"path": format!("{}.rs", n), "old": "x", "new": "y"}))
};
// grep 位于 1/3/5/8/10,每次再现与上次之间都夹 patch_file 写。
let samples: Vec<(String, bool)> = vec![
(run.clone(), false),
(g.clone(), false),
(w("a"), true),
(g.clone(), false),
(w("b"), true),
(g.clone(), false),
(read.clone(), false),
(w("c"), true),
(g.clone(), false),
(w("d"), true),
(g.clone(), false),
(w("e"), true),
];
assert_eq!(samples.len(), 12);
assert!(!is_repetitive_exploration(&samples), "清理任务 grep 验证+每次夹写不应判游荡");
}
/// 场景纯游荡仍熔断:全读无写,豁免无写证据可用,唯一率 3/9≈0.33 < 0.4 → true(语义不变)。
#[test]
fn scenario_pure_wandering_still_repetitive() {
let seq = ["a", "b", "a", "c", "a", "b", "a", "c", "a"];
let samples: Vec<(String, bool)> = seq.iter()
.map(|p| (tool_call_signature("grep", &serde_json::json!({"pattern": p, "path": "x"})), false))
.collect();
assert!(is_repetitive_exploration(&samples), "无写间隔的轮换游荡应判重复");
}
/// 场景紧邻同签名死磕:紧邻重复(j+1==i 空区间)不豁免,计入唯一率折损,且单点
/// 无写间隔 streak 达 3,双分支兜底 → true。
#[test]
fn scenario_adjacent_grinding_still_repetitive() {
let seq = ["a", "a", "b", "a", "a", "w", "a", "a", "b"];
let samples: Vec<(String, bool)> = seq.iter()
.enumerate()
.map(|(i, s)| {
let sig = tool_call_signature("read_file",
&serde_json::json!({"path": format!("{}.txt", s), "offset": 0, "limit": 50}));
(sig, i == 5) // 仅 "w" 位是写类调用
})
.collect();
assert!(is_repetitive_exploration(&samples), "紧邻重复(无写间隔)死磕应仍判重复");
}
/// 场景写间隔豁免翻转判定:同签名序列 [g,w1,g,w2,g,w3],无写版 g 三连无写间隔
/// streak 达 3 → true;有写版每次再现夹写全部豁免 → false。
#[test]
fn scenario_write_interval_exemption_flips_verdict() {
let g = tool_call_signature("grep", &serde_json::json!({"pattern": "v", "path": "x"}));
let w = |p: &str| {
tool_call_signature("patch_file", &serde_json::json!({"path": p, "old": "a", "new": "b"}))
};
let no_write: Vec<(String, bool)> = vec![
(g.clone(), false), (w("p1"), false), (g.clone(), false),
(w("p2"), false), (g.clone(), false), (w("p3"), false),
];
let with_write: Vec<(String, bool)> = vec![
(g.clone(), false), (w("p1"), true), (g.clone(), false),
(w("p2"), true), (g.clone(), false), (w("p3"), true),
];
assert!(is_repetitive_exploration(&no_write), "同序列无写版应判死磕");
assert!(!is_repetitive_exploration(&with_write), "有写版每次再现夹写应豁免不判");
}
/// 读/写同签名交替 [g,w]×6(g 同签名读,w 同签名写)。推演:g 首现 +1,再现均
/// 夹 w 豁免 +1(5 次);w 首现 +1,再现自身是写豁免 +1(5 次)→ units=12,ratio=12/12=1.0;
/// 单点分支 g 段间恒夹写 max_streak=1,w 不计数 → false。
/// 旧算法豁免再现不入集合(首现已入,dedup 零影响)unique=2,ratio=2/12 误熔断 true(红绿回归锚点)。
#[test]
fn scenario_read_write_alternation_not_repetitive() {
let g = tool_call_signature("grep", &serde_json::json!({"pattern": "v", "path": "x"}));
let w = tool_call_signature("patch_file", &serde_json::json!({"path": "p.rs", "old": "a", "new": "b"}));
let samples: Vec<(String, bool)> = (0..6)
.flat_map(|_| [(g.clone(), false), (w.clone(), true)])
.collect();
assert_eq!(samples.len(), 12);
assert!(!is_repetitive_exploration(&samples), "读/写同签名交替(红绿迭代)不应判漂移");
}
/// 纯同签名读死磕 [g]×6。推演:首现 1 单元,再现区间空且非写全折损 →
/// units=1,ratio=1/6≈0.17 < 0.4 → true。
#[test]
fn scenario_pure_read_grinding_repetitive() {
let g = tool_call_signature("grep", &serde_json::json!({"pattern": "v", "path": "x"}));
let samples: Vec<(String, bool)> = vec![(g, false); 6];
assert!(is_repetitive_exploration(&samples), "纯同签名读死磕应判漂移");
}
/// 全读轮换游荡 [a,b,c,a,b,c,d,a,b]。推演:再现间无写全折损,units=4
/// (a/b/c/d 首现),ratio=4/9≈0.44 未低于 0.4(唯一率分支不触发);a 无写间隔
/// streak=3(位 0/3/7)达单点阈值 → true(经单点分支兜底命中)。
#[test]
fn scenario_all_read_wandering_repetitive() {
let sig = |p: &str| tool_call_signature("grep", &serde_json::json!({"pattern": p, "path": "x"}));
let seq = ["a", "b", "c", "a", "b", "c", "d", "a", "b"];
let samples: Vec<(String, bool)> = seq.iter().map(|p| (sig(p), false)).collect();
assert!(is_repetitive_exploration(&samples), "全读轮换游荡应判漂移");
}
/// write→grep 交替但 write 各异签名(清理任务红绿迭代)。推演:6 个 write
/// 签名各异全首现 +6,g 首现 +1、再现前均夹各异 write 豁免 +5 → units=12,ratio=1.0;
/// g 段间恒夹写 max_streak=1 → false。
#[test]
fn scenario_write_grep_alternate_distinct_writes_not_repetitive() {
let g = tool_call_signature("grep", &serde_json::json!({"pattern": "TODO", "path": "src"}));
let samples: Vec<(String, bool)> = (0..6)
.flat_map(|i| {
let w = tool_call_signature("patch_file",
&serde_json::json!({"path": format!("f{}.rs", i), "old": "x", "new": "y"}));
[(w, true), (g.clone(), false)]
})
.collect();
assert_eq!(samples.len(), 12);
assert!(!is_repetitive_exploration(&samples), "write 各异+同 grep 验证交替不应判游荡");
}
// ── is_pure_greeting 纯问候判定测试 ── // ── is_pure_greeting 纯问候判定测试 ──
#[test] #[test]
+186 -22
View File
@@ -984,7 +984,7 @@ pub(crate) async fn run_agentic_loop(
&coord_usage, &coord_usage,
// Coordinator 路径无 round(未 stream),无估算语义 → false。 // Coordinator 路径无 round(未 stream),无估算语义 → false。
false, false,
None, None, true, None, None, None, true,
&pinned_goals_snapshot, &pinned_goals_snapshot,
&app_handle, &app_handle,
&loop_epoch_arc, my_epoch, &loop_epoch_arc, my_epoch,
@@ -1004,7 +1004,34 @@ pub(crate) async fn run_agentic_loop(
// permits=2 防单对话内并发 LLM 调用失控。 // permits=2 防单对话内并发 LLM 调用失控。
// - permit 绑 guard(函数返回)Drop 自动释放——各 return 点退出即释放槽位。 // - permit 绑 guard(函数返回)Drop 自动释放——各 return 点退出即释放槽位。
// retry 同 loop 内,持 per_conv 合理。 // retry 同 loop 内,持 per_conv 合理。
let _conv_per_conv_permit = llm_concurrency.acquire_per_conv(&conv_id).await; //
// acquire 包 30s 超时:阻塞式 acquire 在同 conv 配额被占满时永久 parked(如竞态双 spawn 的
// 第三个 loop),而 heartbeat 任务已在此前启动持续喂前端看门狗 → UI 永远转圈无终止事件。
// 超时走 AiError 收敛,范式对齐上方 provider 解析 30s 超时:guard.reset → epoch 检查 →
// 落库 user 消息 → emit → return。
let _conv_per_conv_permit = match tokio::time::timeout(
std::time::Duration::from_secs(30),
llm_concurrency.acquire_per_conv(&conv_id),
).await {
Ok(permit) => permit,
Err(_elapsed) => {
guard.reset().await;
// 旧 loop 不 emit(被新 loop 接管时,错误由 owner loop 负责)。
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
return;
}
// 入口超时早退也落库 user 消息(调用方已 push 内存,防重启/切会话丢末条 user)。
save_conversation(&session_arc, &db, &conv_id, None, None, true).await;
tracing::error!(conv_id = %conv_id, "[ai] 等待 per_conv 并发配额超时(30s),同对话 loop 疑似积压");
let err_msg = "等待对话并发配额超时(30s),请稍后重试".to_string();
emit_ai_chat_event(&app_handle, AiChatEvent::AiError {
error: err_msg,
error_type: Some(ErrorType::Unknown),
conversation_id: Some(conv_id.clone()),
});
return;
}
};
// 防瞬时抖动(G4.1):per-provider 连续饱和计数(跨迭代累计)。 // 防瞬时抖动(G4.1):per-provider 连续饱和计数(跨迭代累计)。
// 候选循环内 Exhausted +1 / Acquired 清零;链首(主 provider)连续 // 候选循环内 Exhausted +1 / Acquired 清零;链首(主 provider)连续
@@ -1014,6 +1041,17 @@ pub(crate) async fn run_agentic_loop(
// 0 = 不限:effective_max=usize::MAX,for 到不了上界,靠 stop_flag/收敛/审批退出(下方达上限暂停分支不触发) // 0 = 不限:effective_max=usize::MAX,for 到不了上界,靠 stop_flag/收敛/审批退出(下方达上限暂停分支不触发)
let effective_max = if max_iterations == 0 { usize::MAX } else { max_iterations }; let effective_max = if max_iterations == 0 { usize::MAX } else { max_iterations };
// panic 防护:loop 主体(for + 达上限收尾 + 正常完成收尾)整体包 catch_unwind。
// 背景:本函数由 IPC 层 tauri::async_runtime::spawn 托管,主体 panic 时任务静默死亡,
// conv 卡 Generating(无终态事件,前端无法恢复)。捕获后经 unwind_to_result 把 panic 转
// 受控 Err,走既有 teardown_round 正典出口补发终态;ConvState 复位由 guard 的 Drop 兜底
// 在 unwind 时完成,此处补齐 save 落库与 AiCompleted emit。
// 借用结构:async 块非 move,guard/tokens/converged 等仍由外层函数持有,unwind 销毁的是
// 块帧,外层变量完好,panic 分支才能用同一份 guard/tokens 走 teardown_round。
// 范围:仅 loop 主体;provider 解析/配额获取等已有独立错误分支的段落不包,避免语义重叠。
// 依赖 panic=unwind(tokio 默认),panic=abort 构建下 catch_unwind 不生效(尽力兜底)。
let outcome = unwind_to_result(async {
for iteration in start_iteration..effective_max { for iteration in start_iteration..effective_max {
// ── 轮次准备段:入口 stop/stale 收尾闸门 → 主题标记 → conv 一致性(iteration_used/epoch/心跳) // ── 轮次准备段:入口 stop/stale 收尾闸门 → 主题标记 → conv 一致性(iteration_used/epoch/心跳)
// → 轮通知 → 自动压缩 → 请求消息构建(build_for_request + namespace 展开 + 易变尾段 // → 轮通知 → 自动压缩 → 请求消息构建(build_for_request + namespace 展开 + 易变尾段
@@ -1101,13 +1139,14 @@ pub(crate) async fn run_agentic_loop(
// 全部自动执行完成 → 继续下一轮 // 全部自动执行完成 → 继续下一轮
} }
// 达 MAX 未收敛(LLM 末轮仍想调工具被截断,末轮 tool_result 不再回传 LLM):转入暂停态询问用户 // 达 MAX 未收敛(LLM 末轮仍想调工具被截断,末轮 tool_result 不再回传 LLM):自动完成收尾。
// emit AiMaxRoundsReached + 保持 generating=true(仿审批等待), // 历史为暂停态(emit AiMaxRoundsReached + 保持 generating=true 仿审批等待,等用户点继续/
// 等用户点继续(ai_continue_loop → try_continue_agent_loop 再跑 max_iterations 轮) // 停止),因操作卡不可达时 generating 永真无声卡死,已改为 guard 复位 + teardown_round 发
// 或点停止(ai_stop_loop → 走完成流程)。try_continue 续跑 iteration 由调用方传 start_iteration 决定: // AiCompleted(incomplete=true) 自动终止;前端续/停操作卡(pendingMaxRounds)随之成死链。
// 审批续跑累计(防多次审批反复跑满 max 致 token 失控,传 session.iteration_used), // ai_continue_loop/ai_stop_loop IPC 仍保留,try_continue 续跑 iteration 由调用方传
// start_iteration 决定:审批续跑累计(防多次审批反复跑满 max 致 token 失控,传 session.iteration_used),
// 达 max 续跑重计(用户点继续=授权重来,传 0 + 重置 iteration_used)。 // 达 max 续跑重计(用户点继续=授权重来,传 0 + 重置 iteration_used)。
// 注:max_iterations=0(不限)时 effective_max=usize::MAX,for 不会正常结束至此,故不触发暂停(靠 stop/收敛/审批退出) // 注:max_iterations=0(不限)时 effective_max=usize::MAX,for 不会正常结束至此,不触发本分支(靠 stop/收敛/审批退出)
if !converged { if !converged {
tracing::warn!( tracing::warn!(
conv_id = %conv_id, conv_id = %conv_id,
@@ -1124,7 +1163,7 @@ pub(crate) async fn run_agentic_loop(
crate::commands::ai::notify::NotifyKind::NeedAction, crate::commands::ai::notify::NotifyKind::NeedAction,
&conv_id, &conv_id,
"已达最大轮次", "已达最大轮次",
"AI 循环未收敛已暂停,等待继续或停止指令", "AI 循环未收敛,已达最大轮次,已自动收尾",
); );
// 统一走 teardown_round:save(Some usage, Some model) + 不 spawn_title(对齐原无 title) + // 统一走 teardown_round:save(Some usage, Some model) + 不 spawn_title(对齐原无 title) +
// emit(Some(true), publish=false)。**do_publish=false 保留原 max_iterations 不 publish 行为** // emit(Some(true), publish=false)。**do_publish=false 保留原 max_iterations 不 publish 行为**
@@ -1137,7 +1176,7 @@ pub(crate) async fn run_agentic_loop(
&mut guard, &mut guard,
&emit_usage, &emit_usage,
last_round_estimated, last_round_estimated,
Some(true), None, false, Some(true), None, Some("max_rounds"), false,
&pinned_goals_snapshot, &pinned_goals_snapshot,
&app_handle, &app_handle,
&loop_epoch_arc, my_epoch, &loop_epoch_arc, my_epoch,
@@ -1193,10 +1232,58 @@ pub(crate) async fn run_agentic_loop(
emit_ai_completed_once( emit_ai_completed_once(
&app_handle, &conv_id, &normal_usage, &app_handle, &conv_id, &normal_usage,
last_round_estimated, last_round_estimated,
None, None, true, None, None, None, true,
&pinned_goals_snapshot, &pinned_goals_snapshot,
true, true,
).await; ).await;
}).await;
// panic 分支:记 error(conv_id + panic 消息,不取全 backtrace),再走与既有异常分支
// (达 MAX 自动收尾)同构的 teardown_round。emit 字段对齐 try_continue 正典:AiCompleted
// 收敛信号(incomplete 三元 None + do_publish=true + notify=false,非裸 emit),职责是
// 解卡前端并清占位 tool_result 与审批残留,不代表内容真实完成。旧 loop 被 force_send
// 接管的场景由 teardown_round 内部 epoch 守卫兜住(只 disarm 不 save/emit)。
if let Err(panic_msg) = outcome {
tracing::error!(conv_id = %conv_id, "[ai] agentic loop panic,走受控收尾: {}", panic_msg);
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
let emit_usage = tokens_snapshot(&tokens);
teardown_round(
&session_arc, &db, &conv_id,
Some(&save_usage), Some(&resolved_model),
false,
&provider_config, &llm_concurrency,
&mut guard,
&emit_usage,
last_round_estimated,
None, None, None, true,
&pinned_goals_snapshot,
&app_handle,
&loop_epoch_arc, my_epoch,
false,
).await;
}
}
// panic 载荷转受控错误消息:只认 String 与 &'static str 两种常见载荷,其余类型走兜底文案
// (不放全 backtrace,定位靠 conv_id + 消息;载荷不可字符串化时保语义不保细节)。
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
payload
.downcast_ref::<String>()
.cloned()
.or_else(|| payload.downcast_ref::<&'static str>().map(|s| s.to_string()))
.unwrap_or_else(|| "agentic loop panic(非字符串载荷)".to_string())
}
// loop 主体 panic 防护薄包装:Future 正常完成走 Ok,panic 转受控 Err(消息)。
// 纯函数层无 Tauri/session 依赖,场景回放见 panic_guard_tests;生产侧 run_agentic_loop 把
// loop 主体 async 块传入,Err 分支走 teardown_round 正典出口。未经过 tokio spawn,不触碰
// JoinError 语义,外层 spawn 边界行为不变。
async fn unwind_to_result<F: std::future::Future>(fut: F) -> Result<F::Output, String> {
use std::panic::AssertUnwindSafe;
match futures::FutureExt::catch_unwind(AssertUnwindSafe(fut)).await {
Ok(v) => Ok(v),
Err(p) => Err(panic_message(&*p)),
}
} }
@@ -1285,7 +1372,7 @@ async fn prepare_round_inputs(
&emit_usage, &emit_usage,
// 入口 stop:本轮尚未 stream,last_round_estimated 仍为初始 false(无估算)。 // 入口 stop:本轮尚未 stream,last_round_estimated 仍为初始 false(无估算)。
last_round_estimated, last_round_estimated,
None, None, true, None, None, None, true,
pinned_goals_snapshot, pinned_goals_snapshot,
app_handle, app_handle,
loop_epoch_arc, my_epoch, loop_epoch_arc, my_epoch,
@@ -1649,7 +1736,7 @@ async fn finalize_round_output(
guard, guard,
&emit_usage, &emit_usage,
last_round_estimated, last_round_estimated,
None, None, true, None, None, None, true,
pinned_goals_snapshot, pinned_goals_snapshot,
app_handle, app_handle,
loop_epoch_arc, my_epoch, loop_epoch_arc, my_epoch,
@@ -2183,12 +2270,18 @@ async fn fetch_goal_summary(session_arc: &Arc<Mutex<AiSession>>, conv_id: &str)
if goals.is_empty() { if goals.is_empty() {
return String::new(); return String::new();
} }
let goal_summary = goals.iter() let mut items: Vec<String> = goals.iter()
.map(|g| g.text.trim()) .map(|g| g.text.trim().to_string())
.filter(|g| !g.is_empty()) .filter(|g| !g.is_empty())
.collect::<Vec<_>>() .collect();
.join("; "); // 超过 3 条截断:全量目标已在 system_prompt「## 当前目标」段锚定,警示里只做提醒,
format!("(当前目标: {})", goal_summary) // 全列会让文案膨胀(实测 5 条目标拼接占满整块警示气泡)。
if items.len() > 3 {
let total = items.len();
items.truncate(3);
items.push(format!("…等共 {} 条", total));
}
format!("(当前目标: {})", items.join("; "))
} }
// ── insert_tool_call_over_limit_warning: AC-2 ① 单轮工具调用数超限软提示 ── // ── insert_tool_call_over_limit_warning: AC-2 ① 单轮工具调用数超限软提示 ──
@@ -2263,13 +2356,16 @@ pub(crate) async fn emit_fatal_error(app_handle: &AppHandle, conv_id: &str, erro
// emit 单点(teardown_round 各出口 + 正常完成直调),通知收敛此处避免双写; // emit 单点(teardown_round 各出口 + 正常完成直调),通知收敛此处避免双写;
// 用户主动 stop 出口(teardown_round 转发)传 false 关闭。通知内部条件发送 // 用户主动 stop 出口(teardown_round 转发)传 false 关闭。通知内部条件发送
// (notify.rs:主窗可见聚焦不发 + 同类 5 秒节流 + 失败静默)。 // (notify.rs:主窗可见聚焦不发 + 同类 5 秒节流 + 失败静默)。
async fn emit_ai_completed_once( // pub(crate):conv 删除(ai_conversation_delete)在 epoch 递增后单点补发删除终态,
// loop 侧各退出路径因非 owner 静默,删除侧是唯一发出者(调用方无 epoch 守卫,见其注释)。
pub(crate) async fn emit_ai_completed_once(
app_handle: &AppHandle, app_handle: &AppHandle,
conv_id: &str, conv_id: &str,
usage: &df_ai::provider::TokenUsage, usage: &df_ai::provider::TokenUsage,
is_estimated: bool, is_estimated: bool,
emit_incomplete: Option<bool>, emit_incomplete: Option<bool>,
publish_incomplete: Option<bool>, publish_incomplete: Option<bool>,
incomplete_reason: Option<&str>,
do_publish: bool, do_publish: bool,
pinned_goals: &[super::GoalEntry], pinned_goals: &[super::GoalEntry],
notify: bool, notify: bool,
@@ -2288,7 +2384,7 @@ async fn emit_ai_completed_once(
// publish 端:do_publish=false(max_iterations)跳过 publish(保留原无 publish 行为); // publish 端:do_publish=false(max_iterations)跳过 publish(保留原无 publish 行为);
// MidStream incomplete 分叉(emit=Some(true)/publish=None)事件值不同,不能并成单次 // MidStream incomplete 分叉(emit=Some(true)/publish=None)事件值不同,不能并成单次
// emit_ai_chat_event 调用,分路构造(其余 5 路径两端同值,走单次双写)。 // emit_ai_chat_event 调用,分路构造(其余 5 路径两端同值,走单次双写)。
let ev = completed_event(usage, is_estimated, emit_incomplete, conv_id, pinned_goals); let ev = completed_event(usage, is_estimated, emit_incomplete, incomplete_reason, conv_id, pinned_goals);
if do_publish && emit_incomplete == publish_incomplete { if do_publish && emit_incomplete == publish_incomplete {
emit_ai_chat_event(app_handle, ev); emit_ai_chat_event(app_handle, ev);
return; return;
@@ -2296,7 +2392,7 @@ async fn emit_ai_completed_once(
let _ = app_handle.emit("ai-chat-event", ev); let _ = app_handle.emit("ai-chat-event", ev);
if do_publish { if do_publish {
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event( let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(
completed_event(usage, is_estimated, publish_incomplete, conv_id, pinned_goals), completed_event(usage, is_estimated, publish_incomplete, None, conv_id, pinned_goals),
); );
} }
// P1 bg 化:完成出口系统通知(条件发送,失败静默;stop 出口经调用方传 false 关闭) // P1 bg 化:完成出口系统通知(条件发送,失败静默;stop 出口经调用方传 false 关闭)
@@ -2349,10 +2445,13 @@ async fn last_active_assistant_text(
} }
/// 构造 AiCompleted 事件(emit_ai_completed_once 内部去重用,字段口径见其文档)。 /// 构造 AiCompleted 事件(emit_ai_completed_once 内部去重用,字段口径见其文档)。
/// incomplete_reason 与 incomplete 配对:emit 侧事件随 emit_incomplete 取值,publish 侧
/// 分叉事件(与 emit 不一致时)incomplete 为 None,reason 同步 None。
fn completed_event( fn completed_event(
usage: &df_ai::provider::TokenUsage, usage: &df_ai::provider::TokenUsage,
is_estimated: bool, is_estimated: bool,
incomplete: Option<bool>, incomplete: Option<bool>,
incomplete_reason: Option<&str>,
conv_id: &str, conv_id: &str,
pinned_goals: &[super::GoalEntry], pinned_goals: &[super::GoalEntry],
) -> AiChatEvent { ) -> AiChatEvent {
@@ -2365,6 +2464,7 @@ fn completed_event(
reasoning_tokens: usage.reasoning_tokens, reasoning_tokens: usage.reasoning_tokens,
is_estimated, is_estimated,
incomplete, incomplete,
incomplete_reason: incomplete_reason.map(str::to_string),
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
pinned_goals: pinned_goals.to_vec(), pinned_goals: pinned_goals.to_vec(),
} }
@@ -2599,6 +2699,9 @@ pub(crate) async fn teardown_round(
emit_estimated: bool, emit_estimated: bool,
emit_incomplete: Option<bool>, emit_incomplete: Option<bool>,
publish_incomplete: Option<bool>, publish_incomplete: Option<bool>,
// 不完整原因(emit 侧,与 emit_incomplete 配对):Some("max_rounds")=达最大轮次自动收尾,
// Some("network")=流中途网络中断保文,None=完整/未分类。口径见 AiCompleted 字段文档。
incomplete_reason: Option<&str>,
do_publish: bool, do_publish: bool,
pinned_goals: &[super::GoalEntry], pinned_goals: &[super::GoalEntry],
app_handle: &AppHandle, app_handle: &AppHandle,
@@ -2629,7 +2732,7 @@ pub(crate) async fn teardown_round(
// generating 复位后再 emit Completed(对齐原各路径顺序) // generating 复位后再 emit Completed(对齐原各路径顺序)
emit_ai_completed_once( emit_ai_completed_once(
app_handle, conv_id, emit_usage, emit_estimated, app_handle, conv_id, emit_usage, emit_estimated,
emit_incomplete, publish_incomplete, do_publish, emit_incomplete, publish_incomplete, incomplete_reason, do_publish,
pinned_goals, pinned_goals,
notify, notify,
).await; ).await;
@@ -2810,3 +2913,64 @@ fn build_plan_layers_payload(
}) })
.collect() .collect()
} }
// panic 防护场景回放:构造 loop 体 panic 的最小等价场景,验证 unwind_to_result 把 panic
// 转为受控 Err、外层状态不随 unwind 丢失、panic 前构造的 RAII 在 unwind 时执行 Drop
// (对应生产侧 GeneratingGuard Drop 复位 ConvState 的兜底)。先于实现落盘,红转绿。
#[cfg(test)]
mod panic_guard_tests {
use super::*;
// 正常完成:Ok 直通,包装层不得吞输出或改变返回值
#[tokio::test]
async fn catch_unwind_ok_path_unchanged() {
let r = unwind_to_result(async { 41 + 1 }).await;
assert_eq!(r, Ok(42));
}
// String 载荷 panic 转受控 Err,消息原样保留(生产侧凭此记 tracing::error)
#[tokio::test]
async fn catch_unwind_string_panic_to_controlled_err() {
let r = unwind_to_result(async { panic!("boom {}", 7); }).await;
assert_eq!(r, Err("boom 7".to_string()));
}
// &'static str 载荷同样收敛
#[tokio::test]
async fn catch_unwind_str_panic_to_controlled_err() {
let r = unwind_to_result(async { panic!("static boom"); }).await;
assert_eq!(r, Err("static boom".to_string()));
}
// 非字符串载荷(panic_any)收敛为兜底文案,终态不因载荷类型丢失
#[tokio::test]
async fn catch_unwind_nonstring_panic_to_fallback_err() {
let r = unwind_to_result(async { std::panic::panic_any(42u32); }).await;
assert!(matches!(r, Err(ref m) if m.contains("panic")), "应走兜底文案: {:?}", r);
}
// 无泄漏断言:panic 前 mutate 的共享状态对包装层外可见(生产侧对应 panic 分支仍能用
// 外层 guard/tokens 走 teardown);panic 前构造的 RAII 在 unwind 时执行 Drop
// (对应 GeneratingGuard 在 unwind 路径复位 ConvState 的既有兜底)
#[tokio::test]
async fn catch_unwind_outer_state_survives_and_drop_runs_on_panic() {
struct DropFlag(std::sync::Arc<std::sync::atomic::AtomicBool>);
impl Drop for DropFlag {
fn drop(&mut self) {
self.0.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
let outer = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let dropped = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let outer_in_fut = outer.clone();
let dropped_in_fut = dropped.clone();
let r = unwind_to_result(async move {
let _raii = DropFlag(dropped_in_fut);
outer_in_fut.store(true, std::sync::atomic::Ordering::SeqCst);
panic!("mid teardown");
}).await;
assert!(r.is_err());
assert!(outer.load(std::sync::atomic::Ordering::SeqCst), "panic 前的外层 mutate 应保留");
assert!(dropped.load(std::sync::atomic::Ordering::SeqCst), "unwind 时 RAII Drop 应执行");
}
}
@@ -565,7 +565,7 @@ pub(crate) async fn stream_round(
guard, guard,
&emit_usage, &emit_usage,
*last_round_estimated, *last_round_estimated,
Some(true), None, true, Some(true), None, Some("network"), true,
pinned_goals_snapshot, pinned_goals_snapshot,
app_handle, app_handle,
loop_epoch_arc, my_epoch, loop_epoch_arc, my_epoch,
+177 -15
View File
@@ -1,12 +1,23 @@
//! 子代理循环(spawn_agent v1) — 纯内存子会话 + 拼装既有积木的独立 agentic 循环。 //! 子代理循环(spawn_agent v1) — 隔离子会话 + 拼装既有积木的独立 agentic 循环。
//! //!
//! 架构定稿:子代理 = 纯内存子会话,不落库、不进侧栏列表、无标题/知识提炼、无终态事件, //! 架构定稿:子代理 = 隔离子会话,运行期零落库、不进侧栏列表、无标题/知识提炼;统一出口
//! 最终轮文本即报告经返回值交付(Claude Code Task 语义同构:隔离上下文、一轮回传报告)。 //! 整轨迹落库(origin='sub_agent' 标记,列表侧过滤),最终轮文本即报告经返回值交付
//! (Claude Code Task 语义同构:隔离上下文、一轮回传报告)。
//! //!
//! ## 零落库机制(单点复用) //! ## 报告协议(S2):拒绝可见性
//! 子会话 conv_id 启动即写入 `AiSession.deleted_convs`,`save_conversation_inner` 入口检测 //! 工具拒绝维持 fail-closed 就地拒绝(audit/mod.rs deny 单点),但 envelope 携带
//! 到该集合命中即跳过全部 DB 写入。既有路径(含 stream_round 异常分支内的兜底 save)对该 //! tool_name/risk_level/denial_layer 结构化字段;子循环按轮收集被拒清单(解析 envelope),
//! conv 一律不产生落库,无需给共享链路加子代理分支。 //! 出口追加「被拒工具」段落到报告尾部 + AiAgentFinished.rejected_tools 透出——
//! 父会话与用户能看见子代理被拒了什么、哪些子任务因此未完成,而非拿到一份哑报告。
//!
//! ## 运行期零落库 + 出口整轨迹落库(二期池①,单点复用)
//! 子会话 conv_id 启动即写入 `AiSession.deleted_convs` + `AiSession.conv_origins`,
//! `save_conversation_inner` 入口检测到前者的命中即跳过全部 DB 写入——运行期既有路径
//! (含 stream_round 异常分支兜底 save)一律不落库,无需给共享链路加子代理分支。
//! 统一出口(`run_sub_agent_loop`)摘除拦截后调一次 `save_conversation`,整条子轨迹经
//! 既有 save 链路持久化,origin='sub_agent' 随首插落 `ai_conversations.origin`(V44),
//! 桌面侧栏/miniapp 列表按此过滤。Truncated/Failed 轨迹同样落库(调试与轨迹树数据源);
//! 父删除级联命中(deleted_convs 被级联重打/per_conv 已清)时放弃落库,防空壳复活。
//! //!
//! ## 循环拼装(全部复用既有积木,不另起炉灶) //! ## 循环拼装(全部复用既有积木,不另起炉灶)
//! - provider 解析:AiProviderRepo.list_all + ProviderPool::select(override 亲和)+ 候选链; //! - provider 解析:AiProviderRepo.list_all + ProviderPool::select(override 亲和)+ 候选链;
@@ -26,7 +37,7 @@
//! 会向子 conv emit AiError、MidStream 分支会触发一次子 conv 的后台标题任务(写库被上者拦, //! 会向子 conv emit AiError、MidStream 分支会触发一次子 conv 的后台标题任务(写库被上者拦,
//! 仅一次无效 LLM 调用),均为网络异常罕见路径,接受并记录于此。 //! 仅一次无效 LLM 调用),均为网络异常罕见路径,接受并记录于此。
use std::collections::{HashMap, VecDeque}; use std::collections::{BTreeMap, HashMap, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
@@ -47,7 +58,7 @@ use crate::commands::ai::agentic::{
STALL_BREAKER_ENABLED, STALL_BREAKER_ENABLED,
}; };
use crate::commands::ai::audit::{process_tool_calls, ToolExecPolicy}; use crate::commands::ai::audit::{process_tool_calls, ToolExecPolicy};
use crate::commands::ai::conversation::{truncate_for_persist, TokenAccumulator}; use crate::commands::ai::conversation::{save_conversation, truncate_for_persist, TokenAccumulator};
use crate::commands::ai::event_bus::emit_ai_chat_event; use crate::commands::ai::event_bus::emit_ai_chat_event;
use crate::commands::ai::AiChatEvent; use crate::commands::ai::AiChatEvent;
use crate::state::LlmConcurrency; use crate::state::LlmConcurrency;
@@ -60,6 +71,10 @@ use super::{
summarize_tool_results, tokens_snapshot, RoundOutcome, summarize_tool_results, tokens_snapshot, RoundOutcome,
}; };
/// 子代理会话的 origin 来源标记(落 ai_conversations.origin,V44)。
/// 桌面侧栏/miniapp 列表按此值过滤子会话;主会话 origin 恒为 NULL 无写入点。
const SUB_AGENT_ORIGIN: &str = "sub_agent";
// ============================================================ // ============================================================
// 预算常量(三重熔断) // 预算常量(三重熔断)
// ============================================================ // ============================================================
@@ -111,16 +126,20 @@ pub(crate) struct SubAgentOutcome {
pub(crate) summary: String, pub(crate) summary: String,
pub(crate) rounds: u32, pub(crate) rounds: u32,
pub(crate) tokens: u64, pub(crate) tokens: u64,
/// 运行期被拒工具汇总(工具名→次数,BTreeMap 序确定性)。S2 报告协议:父会话须能
/// 看见子代理被拒了什么,而非只拿到一份不知缺口的报告;空 Vec=本次零拒绝。
pub(crate) rejected_tools: Vec<(String, u32)>,
} }
impl SubAgentOutcome { impl SubAgentOutcome {
/// 启动期失败快捷构造(rounds/tokens 为 0)。 /// 启动期失败快捷构造(rounds/tokens 为 0,未进循环故无被拒记录)。
pub(crate) fn failed(msg: impl Into<String>) -> Self { pub(crate) fn failed(msg: impl Into<String>) -> Self {
Self { Self {
status: SubAgentStatus::Failed, status: SubAgentStatus::Failed,
summary: msg.into(), summary: msg.into(),
rounds: 0, rounds: 0,
tokens: 0, tokens: 0,
rejected_tools: Vec::new(),
} }
} }
} }
@@ -262,6 +281,38 @@ pub(crate) fn round_budget_stop(
None None
} }
/// 判定 tool_result 内容是否为子代理拒绝 envelope(S2 报告协议解析核心,纯函数可单测)。
/// 仅识别结构化 JSON 的 status=rejected + reason=sub_agent_denied;非 JSON/正常结果/
/// 其他拒绝(如主会话 path_blacklist)一律不算,防被拒清单误计。
pub(crate) fn is_sub_agent_deny_envelope(content: &str) -> bool {
match serde_json::from_str::<serde_json::Value>(content) {
Ok(v) => {
v.get("status").and_then(|s| s.as_str()) == Some("rejected")
&& v.get("reason").and_then(|r| r.as_str()) == Some("sub_agent_denied")
}
Err(_) => false,
}
}
/// 被拒工具清单格式化("工具名×次数")。outcome/事件/报告段三处共用,单一出口。
pub(crate) fn format_rejected_tools(rejected: &[(String, u32)]) -> Vec<String> {
rejected.iter().map(|(n, c)| format!("{}×{}", n, c)).collect()
}
/// 报告尾部追加「被拒工具」段落。机制侧兜底:不依赖子 LLM 报告自觉申报
/// (对齐「机制优先 prompt 说教」),无拒绝原文返回零打扰。纯函数可单测。
pub(crate) fn append_rejected_section(summary: String, rejected: &[(String, u32)]) -> String {
if rejected.is_empty() {
return summary;
}
let total: u32 = rejected.iter().map(|(_, c)| c).sum();
let list = format_rejected_tools(rejected).join(", ");
format!(
"{}\n\n---\n被拒工具(共 {} 次): {}\n以上工具在子代理中无执行权限,相关子任务可能未完成,请父会话评估是否接手。",
summary, total, list
)
}
/// 子代理专用 system prompt(要点:面向父任务执行、最终回复即结论报告、无审批通道)。 /// 子代理专用 system prompt(要点:面向父任务执行、最终回复即结论报告、无审批通道)。
pub(crate) fn sub_agent_system_prompt() -> String { pub(crate) fn sub_agent_system_prompt() -> String {
"你是 DevFlow 主会话派生的子代理,在隔离上下文中独立执行父任务交办的任务书。\n\ "你是 DevFlow 主会话派生的子代理,在隔离上下文中独立执行父任务交办的任务书。\n\
@@ -273,7 +324,8 @@ pub(crate) fn sub_agent_system_prompt() -> String {
- 控制轮次:默认最多 6 轮,收敛即停,不做任务书之外的无关探索\n\ - 控制轮次:默认最多 6 轮,收敛即停,不做任务书之外的无关探索\n\
## 输出要求\n\ ## 输出要求\n\
- 最终回复就是交付给父任务的结论报告,不要寒暄、不要向用户提问\n\ - 最终回复就是交付给父任务的结论报告,不要寒暄、不要向用户提问\n\
- 报告结构:结论先行,然后列关键发现/证据/数据,最后列未尽事项与建议" - 报告结构:结论先行,然后列关键发现/证据/数据,最后列未尽事项与建议\n\
- 若有工具调用被拒绝:在未尽事项中说明哪些子任务因工具权限受限未完成,父会话据此决定接手方式"
.to_string() .to_string()
} }
@@ -301,7 +353,7 @@ pub(crate) async fn run_sub_agent_loop(
llm_concurrency: LlmConcurrency, llm_concurrency: LlmConcurrency,
) -> SubAgentOutcome { ) -> SubAgentOutcome {
let outcome = run_sub_agent_loop_inner( let outcome = run_sub_agent_loop_inner(
session_arc.clone(), tools_arc, db, app_handle.clone(), session_arc.clone(), tools_arc, db.clone(), app_handle.clone(),
parent_conv_id.clone(), child_conv_id.clone(), parent_conv_id.clone(), child_conv_id.clone(),
system_prompt, task_prompt, tool_defs, system_prompt, task_prompt, tool_defs,
max_rounds, model_override, llm_concurrency.clone(), max_rounds, model_override, llm_concurrency.clone(),
@@ -315,13 +367,36 @@ pub(crate) async fn run_sub_agent_loop(
status: outcome.status.as_str().to_string(), status: outcome.status.as_str().to_string(),
rounds: outcome.rounds, rounds: outcome.rounds,
tokens: outcome.tokens, tokens: outcome.tokens,
// S2 报告协议:被拒清单透出(空则 None,事件面最小噪音);前端不消费也不破坏
rejected_tools: if outcome.rejected_tools.is_empty() {
None
} else {
Some(format_rejected_tools(&outcome.rejected_tools))
},
}); });
// 清理子会话内存条目(报告已提取,纯内存子会话不留残迹;审计表记录保留可追溯) // 子轨迹落库(二期池①):摘除零落库拦截,整条轨迹经既有 save 链路持久化。
// 级联取消/父删除已打 deleted_convs 标记或清 per_conv 时放弃落库(save 的惰性
// 重建会 INSERT 空壳复活);存活则 origin='sub_agent' 随首插落库,列表侧按此过滤。
let child_persistable = {
let mut session = session_arc.lock().await;
let alive = !session.deleted_convs.contains(&child_conv_id)
&& session.per_conv.contains_key(&child_conv_id);
if alive {
session.deleted_convs.remove(&child_conv_id);
}
alive
};
if child_persistable {
save_conversation(&session_arc, &db, &child_conv_id, None, None, false).await;
}
// 清理子会话内存条目(轨迹已持久化或已随级联丢弃;origin 标记随 per_conv 一并回收)
{ {
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
session.per_conv.remove(&child_conv_id); session.per_conv.remove(&child_conv_id);
session.save_locks.remove(&child_conv_id); session.save_locks.remove(&child_conv_id);
session.conv_origins.remove(&child_conv_id);
} }
llm_concurrency.release_conv(&child_conv_id).await; llm_concurrency.release_conv(&child_conv_id).await;
@@ -369,10 +444,12 @@ async fn run_sub_agent_loop_inner(
if !session.per_conv.contains_key(&parent_conv_id) { if !session.per_conv.contains_key(&parent_conv_id) {
return SubAgentOutcome::failed("子代理启动失败:父会话已不存在(可能已被删除)"); return SubAgentOutcome::failed("子代理启动失败:父会话已不存在(可能已被删除)");
} }
// 零落库(关键):deleted_convs 使 save_conversation 入口对该 conv 一律跳过, // 运行期零落库(关键):deleted_convs 使 save_conversation 入口对该 conv 一律跳过,
// 覆盖 stream_round 异常分支兜底 save 等所有既有写库路径(见模块头文档)。 // 覆盖 stream_round 异常分支兜底 save 等所有既有写库路径(见模块头文档);
// conv_origins 记来源,统一出口摘除拦截落库时随首插写 ai_conversations.origin。
// 先于 conv() 调用(insert 需可变借 session,避免与 conv 借用冲突) // 先于 conv() 调用(insert 需可变借 session,避免与 conv 借用冲突)
session.deleted_convs.insert(child_conv_id.clone()); session.deleted_convs.insert(child_conv_id.clone());
session.conv_origins.insert(child_conv_id.clone(), SUB_AGENT_ORIGIN.to_string());
let conv = session.conv(&child_conv_id); let conv = session.conv(&child_conv_id);
conv.messages.push(ChatMessage::user(&task_prompt)); conv.messages.push(ChatMessage::user(&task_prompt));
conv.stop_flag.store(false, Ordering::SeqCst); conv.stop_flag.store(false, Ordering::SeqCst);
@@ -442,6 +519,8 @@ async fn run_sub_agent_loop_inner(
let mut status = SubAgentStatus::Truncated; let mut status = SubAgentStatus::Truncated;
let mut stop_reason = String::new(); let mut stop_reason = String::new();
let mut report = String::new(); let mut report = String::new();
// S2 报告协议:被拒工具累计(工具名→次数,BTreeMap 序确定性),出口汇总进报告尾+outcome
let mut rejected_tools: BTreeMap<String, u32> = BTreeMap::new();
for round in 0..max_rounds { for round in 0..max_rounds {
// ── 停止检查点 + 预算检查(均在 prepare 段前)── // ── 停止检查点 + 预算检查(均在 prepare 段前)──
@@ -547,10 +626,17 @@ async fn run_sub_agent_loop_inner(
break; break;
} }
// ── 工具执行(SubAgent 策略:NeedsAuth/中高风险自动 deny,零待审批挂起)── // ── 工具执行(SubAgent 策略:NeedsAuth/中高风险自动 deny,零待审批挂起)──
// 先快照本轮 (tool_call_id, 工具名):process 按值消费 tool_calls,之后取不到
let round_calls: Vec<(String, String)> = tool_calls
.values()
.map(|d| (d.id.clone(), d.name.clone()))
.collect();
let pending = process_tool_calls( let pending = process_tool_calls(
&session_arc, tool_calls, &tools_arc, &db, &app_handle, &child_conv_id, &session_arc, tool_calls, &tools_arc, &db, &app_handle, &child_conv_id,
ToolExecPolicy::SubAgent, ToolExecPolicy::SubAgent,
).await; ).await;
// S2 报告协议:收集本轮被拒清单(deny 单点已写入结构化 envelope,此处只读解析)
collect_rejected_tools(&session_arc, &child_conv_id, &round_calls, &mut rejected_tools).await;
if pending > 0 { if pending > 0 {
// 防御:SubAgent 策略下不应产生挂起;出现即终止(子代理无审批恢复通道) // 防御:SubAgent 策略下不应产生挂起;出现即终止(子代理无审批恢复通道)
status = SubAgentStatus::Failed; status = SubAgentStatus::Failed;
@@ -626,6 +712,11 @@ async fn run_sub_agent_loop_inner(
} }
}; };
// S2 报告协议:报告尾部追加「被拒工具」段落(机制兜底,不依赖子 LLM 自觉申报);
// 清单同时随 outcome 交付父 LLM,并经 AiAgentFinished.rejected_tools 透出前端。
let rejected: Vec<(String, u32)> = rejected_tools.into_iter().collect();
let summary = append_rejected_section(summary, &rejected);
tracing::info!( tracing::info!(
parent = %parent_conv_id, parent = %parent_conv_id,
agent = %child_conv_id, agent = %child_conv_id,
@@ -642,6 +733,7 @@ async fn run_sub_agent_loop_inner(
summary, summary,
rounds: rounds_done, rounds: rounds_done,
tokens: total_tokens, tokens: total_tokens,
rejected_tools: rejected,
} }
} }
@@ -678,6 +770,37 @@ async fn last_assistant_text(
.filter(|c| !c.trim().is_empty()) .filter(|c| !c.trim().is_empty())
} }
/// 收集本轮被拒工具调用(S2 报告协议数据源):按本轮 tool_call id 匹配消息流中的
/// tool_result,解析结构化 envelope 判定子代理拒绝,按工具名累计次数。deny 单点
/// 已把 envelope 写入消息流,此处只读解析,不反向侵入 audit 层接口。短锁段纯读。
async fn collect_rejected_tools(
session_arc: &Arc<Mutex<crate::commands::ai::AiSession>>,
child_id: &str,
round_calls: &[(String, String)], // (tool_call_id, tool_name),本轮快照
rejected: &mut BTreeMap<String, u32>,
) {
if round_calls.is_empty() {
return;
}
let session = session_arc.lock().await;
let Some(conv) = session.conv_read(child_id) else {
return;
};
for m in conv.messages.all_messages_clone() {
if !matches!(m.role, MessageRole::Tool) {
continue;
}
let Some(tc_id) = m.tool_call_id.as_deref() else {
continue;
};
if let Some((_, name)) = round_calls.iter().find(|(id, _)| id == tc_id) {
if is_sub_agent_deny_envelope(&m.content) {
*rejected.entry(name.clone()).or_insert(0) += 1;
}
}
}
}
// ============================================================ // ============================================================
// 单元测试:白名单钳制 / max_rounds 熔断 / registry 级联注册移除 // 单元测试:白名单钳制 / max_rounds 熔断 / registry 级联注册移除
// ============================================================ // ============================================================
@@ -797,5 +920,44 @@ mod tests {
assert!(p.contains("子代理")); assert!(p.contains("子代理"));
assert!(p.contains("最终回复")); assert!(p.contains("最终回复"));
assert!(p.contains("审批通道"), "应说明无审批通道(防挂死设计的前提告知)"); assert!(p.contains("审批通道"), "应说明无审批通道(防挂死设计的前提告知)");
assert!(p.contains("工具权限受限"), "S2 报告协议:应要求申报因工具受限未完成的子任务");
}
/// S2 报告协议:deny envelope 识别——结构化 JSON 判定,非 JSON/正常结果/其他拒绝不误计。
#[test]
fn deny_envelope_recognition() {
let env = serde_json::json!({
"status": "rejected",
"reason": "sub_agent_denied",
"tool_name": "write_file",
"risk_level": "medium",
"denial_layer": "medium-high-policy",
"message": "子代理无权执行该工具"
}).to_string();
assert!(is_sub_agent_deny_envelope(&env));
// 工具正常成功结果(JSON 但非拒绝 envelope)不误计
assert!(!is_sub_agent_deny_envelope(r#"{"status":"ok","data":1}"#));
// 裸文本输出(老格式/普通内容)不误计
assert!(!is_sub_agent_deny_envelope("普通工具输出文本"));
// 其他 reason 的拒绝(如路径黑名单)不算子代理策略拒绝
let other = serde_json::json!({"status":"rejected","reason":"path_blacklist"}).to_string();
assert!(!is_sub_agent_deny_envelope(&other));
}
/// S2 报告协议:被拒段落——有拒绝才追加(总次数/逐项计数/接手提示),无拒绝零打扰。
#[test]
fn rejected_section_appended_only_when_nonempty() {
let base = "结论:已完成检索".to_string();
assert_eq!(append_rejected_section(base.clone(), &[]), base, "无拒绝应原文返回");
let rejected = vec![("write_file".to_string(), 3u32), ("run_command".to_string(), 1u32)];
let out = append_rejected_section(base, &rejected);
assert!(out.starts_with("结论:已完成检索"), "报告本体须在前: {}", out);
assert!(out.contains("被拒工具(共 4 次)"), "总次数统计: {}", out);
assert!(out.contains("write_file×3") && out.contains("run_command×1"), "逐项计数: {}", out);
assert!(out.contains("父会话"), "接手提示: {}", out);
assert_eq!(
format_rejected_tools(&rejected),
vec!["write_file×3".to_string(), "run_command×1".to_string()]
);
} }
} }
+1 -1
View File
@@ -365,7 +365,7 @@ pub(super) async fn handle_approval_tool(
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
}; };
emit_ai_chat_event(&app_handle, ev); emit_ai_chat_event(&app_handle, ev);
// 审计:去重命中记一条(status 透传缓存来源 completed/rejected/failed,SW-260618-16;decided_by=auto_dedup),不进 pending // 审计:去重命中记一条(status 透传缓存来源 completed/rejected/failed,;decided_by=auto_dedup),不进 pending
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(warned_content), Some("auto_dedup"), current_message_id).await; audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(warned_content), Some("auto_dedup"), current_message_id).await;
return; return;
} }
+252 -14
View File
@@ -114,20 +114,40 @@ pub(crate) enum ToolExecPolicy {
/// SubAgent 策略的就地拒绝单点:失败 tool_result(结构化 envelope,断路器可判)+ /// SubAgent 策略的就地拒绝单点:失败 tool_result(结构化 envelope,断路器可判)+
/// emit AiToolCallCompleted + 审计留痕(decided_by=sub_agent_denied)。 /// emit AiToolCallCompleted + 审计留痕(decided_by=sub_agent_denied)。
/// 不 insert pending_approvals(子代理无审批通道,挂起即永久停摆)。 /// 不 insert pending_approvals(子代理无审批通道,挂起即永久停摆)。
///
/// S2 拒绝可见性:envelope 增 tool_name/risk_level/denial_layer 结构化字段——拒绝不只
/// 「说不」,子 LLM 须能从结构化信息推断可改尝试方向(换 Low 工具/换路径/转交主会话)。
/// denial_layer 区分四层拒绝源,各层行动提示不同:
/// - whitelist-clamp:工具不在白名单(spawn 入口裁剪或幻觉调用)
/// - needs-auth:路径待授权(主会话可授权放行)
/// - path-blacklist:系统黑名单(主会话也放不了行,从 needs-auth 拆出防误导授权尝试)
/// - medium-high-policy:中高风险策略(拆低风险步骤或转交主会话审批)
async fn deny_sub_agent_tool( async fn deny_sub_agent_tool(
session_arc: &Arc<Mutex<AiSession>>, session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str, conv_id: &str,
draft: &ToolCallDraft, draft: &ToolCallDraft,
tools_arc: &Arc<AiToolRegistry>, tools_arc: &Arc<AiToolRegistry>,
audit_repo: &AiToolExecutionRepo, audit_repo: &AiToolExecutionRepo,
layer: &str,
reason: &str, reason: &str,
current_message_id: Option<&str>, current_message_id: Option<&str>,
app_handle: &AppHandle, app_handle: &AppHandle,
) { ) {
// 各拒绝层的可改尝试方向(行动提示,让子 LLM 下一步有路可走而非反复重试同一拒绝)
let hint = match layer {
"whitelist-clamp" => "该工具不在你的白名单(spawn 时被裁剪或不存在),改用白名单内的低风险工具完成等价操作",
"needs-auth" => "该路径主会话可经授权放行,在报告未尽事项中说明,由主会话授权后执行",
"path-blacklist" => "该路径系统级禁用,主会话授权也无法放行,须改用其他路径或放弃该操作",
_ => "中高风险工具子代理不可执行,可拆解为低风险步骤,或在报告未尽事项中说明由主会话审批执行",
};
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
let msg = serde_json::json!({ let msg = serde_json::json!({
"status": "rejected", "status": "rejected",
"reason": "sub_agent_denied", "reason": "sub_agent_denied",
"message": format!("子代理无权执行该工具({}):仅低风险只读且路径已授权的工具可在子代理中自动执行,如确需该操作请写入报告交由主会话完成", reason) "tool_name": draft.name,
"risk_level": risk_level,
"denial_layer": layer,
"message": format!("子代理无权执行该工具({}):仅低风险只读且路径已授权的工具可在子代理中自动执行。{}", reason, hint)
}).to_string(); }).to_string();
// 短 lock 段:push tool_result(纯写,无 await) // 短 lock 段:push tool_result(纯写,无 await)
{ {
@@ -142,7 +162,6 @@ async fn deny_sub_agent_tool(
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
}; };
emit_ai_chat_event(app_handle, 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, 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; "rejected", risk_level, Some(msg), Some("sub_agent_denied"), current_message_id).await;
} }
@@ -157,7 +176,12 @@ async fn deny_sub_agent_tool(
/// 全盘 search 等开发长命令——前端 watchdog 误判断流,抛"工具已执行完成后续中断"误报 /// 全盘 search 等开发长命令——前端 watchdog 误判断流,抛"工具已执行完成后续中断"误报
/// (实测:用户报"一边流一边抛",前一轮 delta 文本在屏 + 当前轮工具执行静默 > 130s)。 /// (实测:用户报"一边流一边抛",前一轮 delta 文本在屏 + 当前轮工具执行静默 > 130s)。
/// 补工具执行阶段的心跳缺口,前端 watchdog 在静默期也能收到 reset,不再误杀长工具。 /// 补工具执行阶段的心跳缺口,前端 watchdog 在静默期也能收到 reset,不再误杀长工具。
async fn execute_with_heartbeat( ///
/// 审批执行窗口(ai_approve / ai_authorize_dir)复用本函数:审批暂停时主 loop 已 return、
/// 其 heartbeat_loop 随作用域退出,慢工具(大文件 patch / spawn_agent / run_command 长构建)
/// 整窗无心跳 → 前端看门狗误杀。复用同时获得 run_command 实时流式 sink、spawn_agent 父会话
/// task-local 注入与 outer_secs 分档超时(spawn_agent=挂钟预算+60 / run_command=timeout_secs clamp)。
pub(crate) async fn execute_with_heartbeat(
tools: &AiToolRegistry, tools: &AiToolRegistry,
name: &str, name: &str,
args: serde_json::Value, args: serde_json::Value,
@@ -242,9 +266,44 @@ async fn execute_with_heartbeat(
conv_id.to_string(), conv_id.to_string(),
exec_fut, exec_fut,
); );
match tokio::time::timeout(Duration::from_secs(outer_secs), exec_fut).await {
Ok(result) => result, // ── 提前退出监视(防空转):会话已删 / 审批窗口失效时中止工具执行 ──
Err(_elapsed) => { // 进入快照:该 tool_call_id 是否仍在 pending_approvals。现网 4 个调用点(chat.rs ai_approve /
// ai_authorize_dir 两个审批执行窗口均「先 remove 后 execute」,本文件 low/trust 两条自动执行
// 路径从未 insert),进入时 pending 必无此 id——若按字面「不在 pending 即退出」实现,会把
// 所有已批准执行立即腰斩。故审批移除监视仅在「进入时仍在 pending」的调用形态下武装
// (现网不触发,为未来调用形态兜底);会话已删监视对所有调用点生效:执行中删除会话,
// 长工具最多再跑 2s 即中止,不再空转到 outer_secs 上限。
let tc_watch = tool_call_id.to_string();
let was_pending_at_entry = {
let st = app.state::<crate::state::AppState>();
let session = st.ai_session.lock().await;
session.pending_approvals.contains_key(&tc_watch)
};
let watch_app = app.clone();
let watch_conv = conv_id.to_string();
let abort_watch = async move {
loop {
// 2s 轮询:短锁纯读(锁不跨 await 持有);中止延迟 ≤2s 与锁竞争频率的权衡
tokio::time::sleep(Duration::from_secs(2)).await;
let st = watch_app.state::<crate::state::AppState>();
let session = st.ai_session.lock().await;
if session.deleted_convs.contains(&watch_conv) {
return format!("会话 {} 已删除", watch_conv);
}
if was_pending_at_entry && !session.pending_approvals.contains_key(&tc_watch) {
return format!("审批 {} 已不在挂起表", tc_watch);
}
}
};
tokio::pin!(exec_fut);
// 三路 select:exec 正常完成 / outer_secs 兜底超时(既有语义,drop exec 腰斩) /
// 监视判死提前退出(同样 drop exec,防审批被删或会话已删后空转到超时上限)。
// exec 先完成时 select 丢弃 abort_watch,监视零泄漏(非 spawn,随 select 作用域消亡)。
tokio::select! {
result = &mut exec_fut => result,
_ = tokio::time::sleep(Duration::from_secs(outer_secs)) => {
tracing::error!( tracing::error!(
conv_id = %conv_id, conv_id = %conv_id,
tool = %name, tool = %name,
@@ -252,11 +311,178 @@ async fn execute_with_heartbeat(
"[ai] 工具执行超时({}s),返回错误 tool_result(防 process_tool_calls 持 session lock 永久卡死)", "[ai] 工具执行超时({}s),返回错误 tool_result(防 process_tool_calls 持 session lock 永久卡死)",
outer_secs outer_secs
); );
Err(anyhow::anyhow!("工具执行超时({}s),已中止(防死锁)", outer_secs)) Err(anyhow::anyhow!("工具 {} 执行超时({}s),已中止(防死锁)", name, outer_secs))
}
reason = abort_watch => {
tracing::warn!(
conv_id = %conv_id,
tool = %name,
tool_call_id = %tool_call_id,
"[ai] execute_with_heartbeat 提前退出: {reason}(防空转)"
);
Err(anyhow::anyhow!("工具 {} 提前中止: {}", name, reason))
} }
} }
} }
// ============================================================
// 审批超时主动收敛(定时器) — 治「审批过期后用户不操作则永挂」
// ============================================================
//
// 原状:cleanup_expired_approvals(私有于 agentic 模块)仅在 try_continue_agent_loop 入口惰性
// 触发,用户不操作则审批永挂(前端无终态、审批弹窗吊死)。此处补登记侧定时器:审批插入后
// (process_tool_calls 收尾统一扫描武装)spawn tokio 定时任务,到点主动执行与
// cleanup_expired_approvals 语义一致的取消动作 + emit AiApprovalResult 终态。
//
// 模块可见性说明:approval_timeout 模块私有于 agentic(纯 `mod` 声明),audit 不可达;
// 文件约束(不动 agentic/mod.rs,他代理在改)下定时器与到点收敛落调用侧,
// cancel_expired_approval 语义镜像 approval_timeout::cleanup_expired_approvals,
// 两端需同步维护(改超时取消语义时两处同改)。
/// 已武装超时定时器的审批 tool_call_id 集合(进程级,防重复 spawn)。
/// 挂起审批在 pending 期间会被每轮 process_tool_calls 的收尾扫描反复见到(启动恢复的积压审批
/// 尤其跨多轮存留),靠此集合保证同 id 只 spawn 一次。只增不清:tool_call_id 一轮唯一,
/// 审批移除后残留条目仅少量内存无行为影响,进程退出即释放。
static ARMED_TIMEOUT_IDS: std::sync::OnceLock<std::sync::Mutex<HashSet<String>>> =
std::sync::OnceLock::new();
/// 为当前所有待审批武装「到点主动收敛」定时任务。
///
/// 幂等:ARMED_TIMEOUT_IDS 去重,同一 tool_call_id 只 spawn 一次。
/// 全量扫描(不按 conv 过滤):启动恢复的积压审批也能在任意一轮 process_tool_calls 被补武装。
/// 跳过:conversation_id=None(无主审批,cleanup 按 conv 路由无法定位)与 created_at=None
/// (异常数据,超时判定恒 false,定时任务纯浪费)。
pub(crate) async fn arm_approval_timeout_timers(app: &AppHandle) {
let timeout_minutes = app
.state::<crate::state::AppState>()
.approval_timeout_minutes
.load(std::sync::atomic::Ordering::SeqCst);
if timeout_minutes == 0 {
return; // 0=禁用超时:不武装,与 cleanup_expired_approvals 禁用语义一致
}
let timeout_dur = std::time::Duration::from_secs(timeout_minutes * 60);
// 短锁快照 + 标武装(锁内纯读写,无 await 持锁跨 Await)
let to_arm: Vec<(String, String, std::time::SystemTime)> = {
let state = app.state::<crate::state::AppState>();
let session = state.ai_session.lock().await;
let mut armed = ARMED_TIMEOUT_IDS.get_or_init(|| std::sync::Mutex::new(HashSet::new()))
.lock()
.unwrap_or_else(|e| e.into_inner());
session
.pending_approvals
.values()
.filter_map(|a| {
let cid = a.conversation_id.as_deref()?;
let created = a.created_at?;
// insert 返 false = 已武装过,跳过(不重复 spawn)
if !armed.insert(a.tool_call_id.clone()) {
return None;
}
Some((cid.to_string(), a.tool_call_id.clone(), created))
})
.collect()
};
for (conv_id, tc_id, created) in to_arm {
let app_task = app.clone();
tokio::spawn(async move {
// 首睡 = 登记时点距过期的差额 + 2s 缓冲(created_at 读取早于 spawn,
// 补缓冲保到点时 elapsed 判定必过线,免得到点差几毫秒被判未过期而空转)。
let mut sleep_for = timeout_dur
.saturating_sub(created.elapsed().unwrap_or_default())
+ std::time::Duration::from_secs(2);
loop {
tokio::time::sleep(sleep_for).await;
let state = app_task.state::<crate::state::AppState>();
// 幂等/防泄漏闸门(短锁纯读):
// 会话已删或 per_conv 已不在 → 静默退场,不复活会话、不 emit 幽灵终态;
// 审批已不在 pending(用户已处理/他路已清理)→ 终态已发,静默返回防重复 emit。
{
let session = state.ai_session.lock().await;
if session.deleted_convs.contains(&conv_id)
|| !session.per_conv.contains_key(&conv_id)
{
return;
}
if !session.pending_approvals.contains_key(&tc_id) {
return;
}
}
// 到点精判读最新配置:兼容运行中调大超时(未过期则补睡差额重查;调小/不变必已过期)
let cur_minutes = state
.approval_timeout_minutes
.load(std::sync::atomic::Ordering::SeqCst);
if cur_minutes == 0 {
return; // 中途禁用超时:放弃主动收敛(禁用语义=永不超时)
}
let created_at = {
let session = state.ai_session.lock().await;
session.pending_approvals.get(&tc_id).and_then(|a| a.created_at)
};
let Some(created_at) = created_at else {
return; // 异常:条目存在却无 created_at,超时判定恒 false,退场交回惰性路径
};
let elapsed = std::time::SystemTime::now()
.duration_since(created_at)
.unwrap_or_default();
let cur_dur = std::time::Duration::from_secs(cur_minutes * 60);
if elapsed >= cur_dur {
cancel_expired_approval(&app_task, state.inner(), &conv_id, &tc_id, cur_minutes).await;
return;
}
sleep_for = cur_dur - elapsed + std::time::Duration::from_secs(2);
}
});
}
}
/// 到点取消单个超时审批:语义镜像 approval_timeout::cleanup_expired_approvals 的单条处理路径
/// (移除 pending → 替换占位 tool_result → emit AiApprovalResult 终态 → 审计 rejected → 落库)。
/// 入参均为已判定过期(调用方闸门后),失败不抛错仅随各步内部降级,避免影响其他审批。
async fn cancel_expired_approval(
app: &AppHandle,
state: &crate::state::AppState,
conv_id: &str,
tc_id: &str,
timeout_minutes: u64,
) {
tracing::info!(
conv_id = %conv_id,
tool_call_id = %tc_id,
timeout_minutes,
"[APPROVAL-TIMEOUT] 定时器到点,主动取消挂起审批"
);
// 1+2. 短锁:从 pending 移除 + 替换占位 tool_result(让 LLM 看到回执不再盲猜)
{
let mut session = state.ai_session.lock().await;
session.pending_approvals.remove(tc_id);
session
.conv(conv_id)
.messages
.replace_tool_result_content(tc_id, &format!("审批超时({}分钟)已自动取消,请改换其他方案", timeout_minutes));
}
// 3. emit AiApprovalResult(前端清除 pending UI,emit+publish 双写经正典 helper)
emit_ai_chat_event(
app,
AiChatEvent::AiApprovalResult {
id: tc_id.to_string(),
approved: false,
conversation_id: Some(conv_id.to_string()),
},
);
// 4. 审计落 rejected(审计表有终态,ai_approve 幂等分支可据此收敛前端残留)
audit_finalize(
state,
tc_id,
"rejected",
Some(format!("审批超时({}分钟)自动取消", timeout_minutes)),
)
.await;
// 5. 落库会话状态变更(占位消息替换后需持久化,防重启后丢失)
super::conversation::save_conversation(&state.ai_session, &state.db, conv_id, None, None, true).await;
}
pub(crate) async fn process_tool_calls( pub(crate) async fn process_tool_calls(
session_arc: &Arc<Mutex<AiSession>>, session_arc: &Arc<Mutex<AiSession>>,
tool_calls_acc: HashMap<u32, ToolCallDraft>, tool_calls_acc: HashMap<u32, ToolCallDraft>,
@@ -400,8 +626,10 @@ pub(crate) async fn process_tool_calls(
// 收集 NeedsAuth/Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环) // 收集 NeedsAuth/Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环)
let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new(); let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new();
let mut path_denied: Vec<(ToolCallDraft, String)> = Vec::new(); let mut path_denied: Vec<(ToolCallDraft, String)> = Vec::new();
// SubAgent 策略:须授权/黑名单拒绝统一收集,后续就地拒绝(绝不挂起) // SubAgent 策略:须授权/黑名单拒绝统一收集,后续就地拒绝(绝不挂起)。
let mut sub_agent_path_denied: Vec<(ToolCallDraft, String)> = Vec::new(); // 第三元=denial_layer:needs-auth(待授权)与 path-blacklist(系统黑名单)分开标注——
// 黑名单主会话也放不了行,混标会误导子 LLM 走授权通道。
let mut sub_agent_path_denied: Vec<(ToolCallDraft, String, &'static str)> = Vec::new();
let mut authorized_drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = Vec::new(); let mut authorized_drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = Vec::new();
for (idx, draft, args) in drafts { for (idx, draft, args) in drafts {
// 会话级临时授权目录在 allowed_guard.session 内(进程级,随 active 会话切换清空), // 会话级临时授权目录在 allowed_guard.session 内(进程级,随 active 会话切换清空),
@@ -411,7 +639,7 @@ pub(crate) async fn process_tool_calls(
FileToolAuthOutcome::NeedsAuth(req) => { FileToolAuthOutcome::NeedsAuth(req) => {
// SubAgent 策略:子代理无授权弹窗通道,挂起即永久停摆 → 就地拒绝。 // SubAgent 策略:子代理无授权弹窗通道,挂起即永久停摆 → 就地拒绝。
if is_sub_agent { if is_sub_agent {
sub_agent_path_denied.push((draft, "路径未授权且子代理无授权弹窗通道".to_string())); sub_agent_path_denied.push((draft, "路径未授权且子代理无授权弹窗通道".to_string(), "needs-auth"));
continue; continue;
} }
// all 档全自动(用户拍板):路径授权弹窗也不打断,与 RiskLevel 门同语义。 // all 档全自动(用户拍板):路径授权弹窗也不打断,与 RiskLevel 门同语义。
@@ -425,7 +653,7 @@ pub(crate) async fn process_tool_calls(
} }
FileToolAuthOutcome::Denied(reason) => { FileToolAuthOutcome::Denied(reason) => {
if is_sub_agent { if is_sub_agent {
sub_agent_path_denied.push((draft, reason)); sub_agent_path_denied.push((draft, reason, "path-blacklist"));
} else { } else {
path_denied.push((draft, reason)); path_denied.push((draft, reason));
} }
@@ -467,10 +695,10 @@ pub(crate) async fn process_tool_calls(
} }
// SubAgent 策略:须授权/黑名单拒绝就地产出失败 tool_result(零挂起,防子循环永久停摆)。 // SubAgent 策略:须授权/黑名单拒绝就地产出失败 tool_result(零挂起,防子循环永久停摆)。
for (draft, reason) in sub_agent_path_denied { for (draft, reason, layer) in sub_agent_path_denied {
deny_sub_agent_tool( deny_sub_agent_tool(
session_arc, conv_id, &draft, tools_arc, &audit_repo, session_arc, conv_id, &draft, tools_arc, &audit_repo,
&reason, current_message_id, app_handle, layer, &reason, current_message_id, app_handle,
).await; ).await;
} }
@@ -588,9 +816,12 @@ pub(crate) async fn process_tool_calls(
// SubAgent 策略:Medium/High 就地拒绝(无审批通道,挂起即子循环永久停摆)。 // SubAgent 策略:Medium/High 就地拒绝(无审批通道,挂起即子循环永久停摆)。
// 白名单钳制已使子代理 LLM 只见 Low 工具定义,此处拦的是幻觉调用非白名单工具的残面。 // 白名单钳制已使子代理 LLM 只见 Low 工具定义,此处拦的是幻觉调用非白名单工具的残面。
if is_sub_agent && risk_level != RiskLevel::Low { if is_sub_agent && risk_level != RiskLevel::Low {
// 已注册工具=中高风险策略拒绝;未注册名(risk 兜底 High)=白名单外幻觉调用,
// 标 whitelist-clamp 让子 LLM 知道该工具不在其可见定义里,而非风险不让用。
let layer = if tools_arc.get(&draft.name).is_some() { "medium-high-policy" } else { "whitelist-clamp" };
deny_sub_agent_tool( deny_sub_agent_tool(
session_arc, conv_id, &draft, tools_arc, &audit_repo, session_arc, conv_id, &draft, tools_arc, &audit_repo,
"工具风险等级超出子代理权限", current_message_id, app_handle, layer, "工具风险等级超出子代理权限", current_message_id, app_handle,
).await; ).await;
continue; continue;
} }
@@ -611,6 +842,13 @@ pub(crate) async fn process_tool_calls(
).await; ).await;
} }
// ── 审批超时主动收敛:为本轮登记(含历史遗留)的挂起武装到点定时任务 ──
// 本函数是审批插入的唯一漏斗(路径授权挂起 + Med/High 风险审批都在上方循环落 pending),
// 收尾统一扫描武装,覆盖两类登记。惰性清理只在用户下次交互时生效,用户不操作则审批
// 永挂(前端无终态、弹窗吊死);到点由定时任务主动 emit AiApprovalResult 终态收敛前端。
// 内部幂等(armed 集合去重 + 会话已删/审批已处理静默跳过),重复调用无副作用。
arm_approval_timeout_timers(app_handle).await;
// trust-hit 并行执行:execute + 即时 emit 在闭包内(闭包不访问 session, // trust-hit 并行执行:execute + 即时 emit 在闭包内(闭包不访问 session,
// 调用方不再持锁,本段所有 await 完全在锁外), // 调用方不再持锁,本段所有 await 完全在锁外),
// push tool_result / audit 在 join_all 后串行回填(短 lock push + 锁外 audit)。对齐 Low risk 并行模式。 // push tool_result / audit 在 join_all 后串行回填(短 lock push + 锁外 audit)。对齐 Low risk 并行模式。
+118 -59
View File
@@ -34,7 +34,7 @@ use super::super::agentic::GOAL_MAX_CHARS;
// ConvState 枚举变体引用(Idle 等),用于 B-Phase2 写侧 conv_states.transition 调用。 // ConvState 枚举变体引用(Idle 等),用于 B-Phase2 写侧 conv_states.transition 调用。
// 读侧拦截(can_accept_request)走 state.conv_states 方法,不直接引用 ConvState 类型。 // 读侧拦截(can_accept_request)走 state.conv_states 方法,不直接引用 ConvState 类型。
use super::super::agentic::conv_state::ConvState; use super::super::agentic::conv_state::ConvState;
use super::super::audit::{audit_finalize, emit_data_changed}; use super::super::audit::{audit_finalize, emit_data_changed, execute_with_heartbeat};
use super::super::augmentation::build_augmentation_segment; use super::super::augmentation::build_augmentation_segment;
use super::super::augmentation::sanitize; use super::super::augmentation::sanitize;
use super::super::conversation::save_conversation; use super::super::conversation::save_conversation;
@@ -806,8 +806,11 @@ pub(crate) async fn spawn_agentic_session(
} else { } else {
conv.messages.push(ChatMessage::user(&user_content)); conv.messages.push(ChatMessage::user(&user_content));
} }
// 新用户轮把本请求钉扎为 WorkingContext 常驻目标(截断 200 字符,与 agentic G1 目标钉扎 GOAL_MAX_CHARS 同量级) // 新用户轮把本请求钉扎为 WorkingContext 常驻目标(截断 200 字符,与 agentic G1 目标钉扎 GOAL_MAX_CHARS 同量级)。
conv.working_context.begin_turn(&user_content, 200); // 喂原文 message 而非落库变换文本 user_content:begin_turn 契约是"本条用户请求",
// 降级提示尾缀与 /{skill} 空文本标记是落库与标题素材专用,当目标会污染钉扎文本;
// 纯技能调用原文为空,靠 begin_turn 自身的空文本跳过语义不记目标。
conv.working_context.begin_turn(&message, 200);
// F-260619-04 P1:push 后取末条 user 消息 id(本轮 user,原供知识注入 referenced 溯源)。 // F-260619-04 P1:push 后取末条 user 消息 id(本轮 user,原供知识注入 referenced 溯源)。
// DRY(B):知识注入已收敛至 inject_knowledge_into_prompt(helper 内部同消息取 text+id), // DRY(B):知识注入已收敛至 inject_knowledge_into_prompt(helper 内部同消息取 text+id),
// 此处 user_msg_id 不再透传到注入逻辑,保留下划线占用(锁内 push 已发生,语义不变)。 // 此处 user_msg_id 不再透传到注入逻辑,保留下划线占用(锁内 push 已发生,语义不变)。
@@ -1078,7 +1081,7 @@ pub async fn ai_approve(
log_slow_lock(__lock_t534, "ai_approve:534"); log_slow_lock(__lock_t534, "ai_approve:534");
drop(session); // 释放锁后再执行 drop(session); // 释放锁后再执行
// B-260617-01 决策 a(方案 a2 后端分支):run_workflow 特殊处理。 // 决策 a(方案 a2 后端分支):run_workflow 特殊处理。
// run_workflow handler(tool_registry.rs:544)仅持 db:Arc<Database>,无 AppHandle/State // run_workflow handler(tool_registry.rs:544)仅持 db:Arc<Database>,无 AppHandle/State
// (registry/event_bus/workflows Repo),经 ai_tools.execute 调用必然 Err。原行为:审批通过 → // (registry/event_bus/workflows Repo),经 ai_tools.execute 调用必然 Err。原行为:审批通过 →
// handler Err → tool_result=错误提示 → LLM 重试 → 又进审批 → 死循环(工具未标 no_retry)。 // handler Err → tool_result=错误提示 → LLM 重试 → 又进审批 → 死循环(工具未标 no_retry)。
@@ -1093,43 +1096,16 @@ pub async fn ai_approve(
let exec_result: anyhow::Result<serde_json::Value> = if approval.tool_name == "run_workflow" { let exec_result: anyhow::Result<serde_json::Value> = if approval.tool_name == "run_workflow" {
super::super::execute_run_workflow_for_tool(&app, &state, &args, conv_id.as_deref()).await super::super::execute_run_workflow_for_tool(&app, &state, &args, conv_id.as_deref()).await
} else { } else {
// F-260620 对齐 ai_authorize_dir(:658):ai_approve 执行同样包 60s 超时。 // 审批执行窗口复用 execute_with_heartbeat:审批暂停时主 loop 已 return、其 heartbeat_loop
// 防 list_directory 大目录/run_command 慢命令卡死 → 到不了 audit_finalize/emit/try_continue // 随作用域退出,慢工具(大文件 patch / spawn_agent / run_command 长构建)整窗无心跳
// → IPC 永挂 → per_conv.generating 永真 → 前端 130s 看门狗静默吞消息(F-260620 同型故障, // → 前端 130s 看门狗误判断流。复用同时获得:30s AiHeartbeat 喂狗 + run_command 实时
// 之前只给 ai_authorize_dir 加超时,ai_approve 漏)。 // 流式 sink + spawn_agent 父会话 task-local 注入 + outer_secs 分档(spawn_agent=挂钟
// run_command 实时流式:经 task-local sink 注入(AppHandle + tool_call_id + conv_id), // 预算+60 / run_command=args.timeout_secs clamp [60,600],此前恒 60s 会腰斩长构建,
// handler 内 execute_streaming 每行 emit AiCommandOutput(治执行中黑盒)。非 run_command 不注入。 // 现 run_command 内部超时先触发返带语义的 tool_result)。
let sink = if approval.tool_name == "run_command" { execute_with_heartbeat(
Some(super::super::command_stream::CommandSink::new( &state.ai_tools, &approval.tool_name, args.clone(),
app.clone(), id.clone(), conv_id.clone(), &app, conv_id.as_deref().unwrap_or_default(), &id,
)) ).await
} 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(approve_outer_secs),
exec_fut,
).await {
Ok(r) => r,
// 任务2: ai_approve 路径同步 run_command 失败提示(超时为最常见失败场景)。
// 仅对 run_command 追加(其余工具无 shell 适配问题),避免污染其他工具错误语义。
Err(_) => Err(anyhow::anyhow!("工具执行超时({}s): {}{}", approve_outer_secs, approval.tool_name,
if approval.tool_name == "run_command" && cfg!(windows) {
"\n提示: PowerShell 下路径用正斜杠或双引号包裹,命令间用 `;` 而非 `&&`(PS5)"
} else { "" })),
}
}; };
// 工具失败不 return Err:把错误包成 tool_result,落库 + emit completed + 续循环全走通。 // 工具失败不 return Err:把错误包成 tool_result,落库 + emit completed + 续循环全走通。
// 否则前端 approveToolCall 的 catch 会回滚 pending_approval,审批按钮卡死无法消除。 // 否则前端 approveToolCall 的 catch 会回滚 pending_approval,审批按钮卡死无法消除。
@@ -1144,7 +1120,7 @@ pub async fn ai_approve(
// 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。 // 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。
if exec_result.is_ok() { if exec_result.is_ok() {
emit_data_changed(&app, &approval.tool_name); emit_data_changed(&app, &approval.tool_name);
// MED-1(CR-260620-01 审查): path 绑定工具(Medium)审批执行后 reload 白名单。 // MED-1(审查): path 绑定工具(Medium)审批执行后 reload 白名单。
// bind_dir_to_project / repo.update_field 直写 DB 绕过 project.rs IPC reload, // bind_dir_to_project / repo.update_field 直写 DB 绕过 project.rs IPC reload,
// 此处单点补全 → AI 绑定目录即时进白名单,防后续文件工具误弹窗。 // 此处单点补全 → AI 绑定目录即时进白名单,防后续文件工具误弹窗。
if matches!(approval.tool_name.as_str(), "bind_directory" | "create_project" | "update_project") { if matches!(approval.tool_name.as_str(), "bind_directory" | "create_project" | "update_project") {
@@ -1451,20 +1427,18 @@ pub async fn ai_authorize_dir(
// ── 执行工具(路径现已授权,handler 内 resolve_workspace_path_with_allowed 放行) ── // ── 执行工具(路径现已授权,handler 内 resolve_workspace_path_with_allowed 放行) ──
// 复用 ai_approve 执行分支:run_workflow 特殊处理 / 其余走 ai_tools.execute // 复用 ai_approve 执行分支:run_workflow 特殊处理 / 其余走 ai_tools.execute
tracing::debug!(tool = %approval.tool_name, "[AI-AUTHZ] execute 前"); tracing::debug!(tool = %approval.tool_name, "[AI-AUTHZ] execute 前");
// F-260620 防工具卡死 ai_authorize_dir(致 per_conv.generating 永真 + 前端看门狗 130s 超时后 // 防工具卡死:超时兜底已收敛进 execute_with_heartbeat 的 outer_secs 分档,
// 静默吞消息):list_directory 等大目录工具可能 read_dir+metadata 海量文件卡住,execute 不返回 // execute 不返回时超时返 Err → 走 failed 分支 finalize+emit+try_continue,IPC 必返回不卡。
// → 到不了 audit_finalize/emit/try_continue → IPC 永挂 → generating 永真。包 60s 超时(对齐
// run_command),超时返 Err → 走 failed 分支 finalize+emit+try_continue,IPC 必返回不卡。
let exec_result: anyhow::Result<serde_json::Value> = if approval.tool_name == "run_workflow" { let exec_result: anyhow::Result<serde_json::Value> = if approval.tool_name == "run_workflow" {
super::super::execute_run_workflow_for_tool(&app, &state, &args, conv_id.as_deref()).await super::super::execute_run_workflow_for_tool(&app, &state, &args, conv_id.as_deref()).await
} else { } else {
match tokio::time::timeout( // 授权执行窗口复用 execute_with_heartbeat(对齐 ai_approve,详见其执行段注释):
std::time::Duration::from_secs(60), // 授权挂起期主 loop 心跳已停,慢文件工具(如跨盘 copy+remove 降级)整窗静默
state.ai_tools.execute(&approval.tool_name, args.clone()), // → 前端看门狗误杀;附带补齐 run_command 实时流式与超时 clamp(原恒 60s)。
).await { execute_with_heartbeat(
Ok(r) => r, &state.ai_tools, &approval.tool_name, args.clone(),
Err(_) => Err(anyhow::anyhow!("工具执行超时(60s): {}", approval.tool_name)), &app, conv_id.as_deref().unwrap_or_default(), &tool_call_id,
} ).await
}; };
tracing::debug!( tracing::debug!(
ok = exec_result.is_ok(), ok = exec_result.is_ok(),
@@ -1486,7 +1460,7 @@ pub async fn ai_authorize_dir(
audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string())).await; audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string())).await;
if exec_result.is_ok() { if exec_result.is_ok() {
emit_data_changed(&app, &approval.tool_name); emit_data_changed(&app, &approval.tool_name);
// MED-1(CR-260620-01 审查): path 绑定工具(Medium)审批执行后 reload 白名单。 // MED-1(审查): path 绑定工具(Medium)审批执行后 reload 白名单。
// bind_dir_to_project / repo.update_field 直写 DB 绕过 project.rs IPC reload, // bind_dir_to_project / repo.update_field 直写 DB 绕过 project.rs IPC reload,
// 此处单点补全 → AI 绑定目录即时进白名单,防后续文件工具误弹窗。 // 此处单点补全 → AI 绑定目录即时进白名单,防后续文件工具误弹窗。
if matches!(approval.tool_name.as_str(), "bind_directory" | "create_project" | "update_project") { if matches!(approval.tool_name.as_str(), "bind_directory" | "create_project" | "update_project") {
@@ -1742,7 +1716,9 @@ pub async fn ai_chat_compress_context(
if protect_start == 0 { if protect_start == 0 {
// 空会话/全在保护区 → 无可压缩消息,set_compressing(false) 后返回 Ok // 空会话/全在保护区 → 无可压缩消息,set_compressing(false) 后返回 Ok
conv.messages.set_compressing(false); conv.messages.set_compressing(false);
let cid = session.active_conversation_id.clone(); // noop 事件归属 IPC 参数 conversation_id(与上方 set_compressing(false) 同一 conv),
// 不读 active_conversation_id:后台压缩非 active 会话时事件须路由回发起压缩的会话。
let cid = Some(conversation_id.clone());
drop(session); drop(session);
// 治 Task#1:手动 IPC 路径用 AiManualCompressed(前端弹 toast 告知"无可压缩/已完成")。 // 治 Task#1:手动 IPC 路径用 AiManualCompressed(前端弹 toast 告知"无可压缩/已完成")。
let ev = AiChatEvent::AiManualCompressed { let ev = AiChatEvent::AiManualCompressed {
@@ -1760,7 +1736,11 @@ pub async fn ai_chat_compress_context(
.filter(|t| t.message.is_active()) .filter(|t| t.message.is_active())
.map(|t| t.message.clone()) .map(|t| t.message.clone())
.collect(); .collect();
let cid = session.active_conversation_id.clone().unwrap_or_else(|| conversation_id.clone()); // 压缩目标一律取 IPC 参数 conversation_id:上方 conv()/set_compressing(true)/active 克隆
// 全部落在此 conv,后续突变(复位 compressing/标 compressed/插摘要/落库)必须同 conv,
// 否则后台压缩非 active 会话时目标 conv 的 compressing 标志永久卡死,active 会话反被
// 插入他人摘要。AiCompressing/AiManualCompressed 事件本就按 conversation_id 路由,归属一致。
let cid = conversation_id.clone();
log_slow_lock(__lock_t1205, "ai_chat_compress_context:1205"); log_slow_lock(__lock_t1205, "ai_chat_compress_context:1205");
(cid, active_msgs, protect_start) (cid, active_msgs, protect_start)
}; };
@@ -2137,8 +2117,11 @@ pub async fn ai_chat_force_send(
} else { } else {
conv.messages.push(ChatMessage::user(&user_content)); conv.messages.push(ChatMessage::user(&user_content));
} }
// 新用户轮把本请求钉扎为 WorkingContext 常驻目标(截断 200 字符,与 agentic G1 目标钉扎 GOAL_MAX_CHARS 同量级) // 新用户轮把本请求钉扎为 WorkingContext 常驻目标(截断 200 字符,与 agentic G1 目标钉扎 GOAL_MAX_CHARS 同量级)。
conv.working_context.begin_turn(&user_content, 200); // 喂原文 message 而非落库变换文本 user_content:begin_turn 契约是"本条用户请求",
// 降级提示尾缀与 /{skill} 空文本标记是落库与标题素材专用,当目标会污染钉扎文本;
// 纯技能调用原文为空,靠 begin_turn 自身的空文本跳过语义不记目标。
conv.working_context.begin_turn(&message, 200);
// F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。 // F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。
let user_msg_id = conv.messages.last_user_message_id(); let user_msg_id = conv.messages.last_user_message_id();
// G1 目标钉扎已迁移至 run_agentic_loop 工具调用推理(提取方式同 ai_chat_send) // G1 目标钉扎已迁移至 run_agentic_loop 工具调用推理(提取方式同 ai_chat_send)
@@ -2169,6 +2152,7 @@ pub async fn ai_chat_force_send(
// 零 token 收敛信号,非估算(无真实 LLM 调用)。 // 零 token 收敛信号,非估算(无真实 LLM 调用)。
is_estimated: false, is_estimated: false,
incomplete: None, incomplete: None,
incomplete_reason: None,
conversation_id: Some(cid.clone()), conversation_id: Some(cid.clone()),
pinned_goals: old_pinned_goals.clone(), pinned_goals: old_pinned_goals.clone(),
}; };
@@ -2294,6 +2278,7 @@ pub async fn ai_chat_stop(
// 零 token 收敛信号,非估算(无真实 LLM 调用)。 // 零 token 收敛信号,非估算(无真实 LLM 调用)。
is_estimated: false, is_estimated: false,
incomplete: None, incomplete: None,
incomplete_reason: None,
conversation_id: Some(target), conversation_id: Some(target),
pinned_goals, pinned_goals,
}; };
@@ -2320,7 +2305,7 @@ pub async fn ai_chat_stop(
log_slow_lock(__lock_t1611, "ai_chat_stop:1611"); log_slow_lock(__lock_t1611, "ai_chat_stop:1611");
drop(session); drop(session);
// B-260615-13 兜底任务(F2 加固,2026-08-05):loop 若 panic/异常退出漏发收尾,stop_flag 无人读, // 兜底任务(F2 加固,2026-08-05):loop 若 panic/异常退出漏发收尾,stop_flag 无人读,
// 用户点 stop 无反应、generating 卡 true。 // 用户点 stop 无反应、generating 卡 true。
// 原实现 sleep 3s 后仍 active 就强制复位 + emit AiCompleted——但用户点停时 loop 可能在长工具执行 // 原实现 sleep 3s 后仍 active 就强制复位 + emit AiCompleted——但用户点停时 loop 可能在长工具执行
// (>3s),原兜底误触发 → conv 假完成但旧 loop 仍活 → 双完成 + 用户可再发消息(竞态 F2)。 // (>3s),原兜底误触发 → conv 假完成但旧 loop 仍活 → 双完成 + 用户可再发消息(竞态 F2)。
@@ -2405,6 +2390,7 @@ pub async fn ai_chat_stop(
// 零 token 收敛信号,非估算(无真实 LLM 调用)。 // 零 token 收敛信号,非估算(无真实 LLM 调用)。
is_estimated: false, is_estimated: false,
incomplete: None, incomplete: None,
incomplete_reason: None,
conversation_id: conv_id.clone(), conversation_id: conv_id.clone(),
pinned_goals, pinned_goals,
}; };
@@ -2458,6 +2444,78 @@ pub async fn ai_continue_loop(
Ok("ok".to_string()) Ok("ok".to_string())
} }
/// 流中断自动续跑探测:前端看门狗超时后调用,按后端真实状态三分支返回。
/// 返回 "generating":loop 仍在跑(前端误判),前端重挂看门狗即可;
/// 返回 "resumed":检测到中断态(尾部无 assistant 终答),已复位生成态并重新 spawn loop;
/// 返回 "finished":会话已正常收敛(尾部是 assistant 文本终答),前端兜底收敛即可。
#[tauri::command]
pub async fn ai_resume_interrupted(
app: AppHandle,
state: State<'_, AppState>,
conversation_id: String,
) -> Result<String, String> {
{
let __lock_t = std::time::Instant::now();
let mut session = state.ai_session.lock().await;
// loop 仍在跑 → 前端误判超时,不 spawn(防双跑),前端重挂看门狗即可。
// 检查必须在 session lock 内:主窗口+分离窗口双探针并发探测时,锁外检查与
// 后续 spawn 之间存在 TOCTOU 窗口(两探针都通过检查后各自续跑双 spawn);
// 锁内「查 active→transition Generating」对其他探针原子,只放行一方。
if state.conv_states.is_active(&conversation_id) {
log_slow_lock(__lock_t, "ai_resume_interrupted");
return Ok("generating".to_string());
}
// conv_read 只读(存在性 + 尾部判定);突变走下面 session.conv(&mut)。
let interrupted = match session.conv_read(&conversation_id) {
Some(conv) => {
// 尾部特征判定:最后一条是 Tool 结果或 assistant 带工具调用 → loop 死在
// 工具轮中途(未 emit 终止事件);尾部是 assistant 纯文本 → 已正常收敛。
match conv.messages.iter().last() {
Some(m) => match m.role {
df_ai::provider::MessageRole::Tool => true,
df_ai::provider::MessageRole::Assistant => {
m.tool_calls.as_ref().map_or(false, |t| !t.is_empty())
}
// 尾部是 user(loop 死在首 token 前,LLM 未产出任何内容)也属中断,
// 走 resumed 续跑;归 finished 会误判收敛,前端补假「已收敛」信号。
df_ai::provider::MessageRole::User => true,
_ => false,
},
None => false,
}
}
None => {
log_slow_lock(__lock_t, "ai_resume_interrupted");
return Err("会话不存在".to_string());
}
};
if !interrupted {
log_slow_lock(__lock_t, "ai_resume_interrupted");
return Ok("finished".to_string());
}
// 中断态复位:stop_flag 清残留(防续跑入口即退出),iteration 重计
// (对齐 ai_continue_loop 用户点继续=授权重计语义)。
let conv = session.conv(&conversation_id);
conv.stop_flag.store(false, Ordering::SeqCst);
conv.iteration_used = 0;
log_slow_lock(__lock_t, "ai_resume_interrupted");
// conv_state 写侧 transition 失败 = 状态机不在可续跑态(如已 Idle=另有方在收尾),
// warn 且不续跑:续跑前提是本探针独占置起 Generating,叠加 spawn 会双跑。
if let Err(e) = state.conv_states.transition(&conversation_id, ConvState::Generating) {
tracing::warn!(
conv_id = %conversation_id,
error = %e,
"[ai] conv_states ai_resume_interrupted→Generating 非法(不续跑)"
);
log_slow_lock(__lock_t, "ai_resume_interrupted");
return Ok("generating".to_string());
}
}
// 锁已释放(独立块作用域)再续跑,复用审批恢复入口,内部 spawn run_agentic_loop。
try_continue_agent_loop(&app, &state, &conversation_id, 0).await;
Ok("resumed".to_string())
}
/// 停止 agentic 循环并走完成流程达 max_iterations 暂停态用户点「停止」) /// 停止 agentic 循环并走完成流程达 max_iterations 暂停态用户点「停止」)
/// ///
/// 场景:run_agentic_loop 达 max_iterations 未收敛 → emit AiMaxRoundsReached + 保持 /// 场景:run_agentic_loop 达 max_iterations 未收敛 → emit AiMaxRoundsReached + 保持
@@ -2508,6 +2566,7 @@ pub async fn ai_stop_loop(
// 零 token 收敛信号,非估算(无真实 LLM 调用)。 // 零 token 收敛信号,非估算(无真实 LLM 调用)。
is_estimated: false, is_estimated: false,
incomplete: None, incomplete: None,
incomplete_reason: None,
conversation_id: Some(conversation_id), conversation_id: Some(conversation_id),
pinned_goals, pinned_goals,
}; };
@@ -180,7 +180,7 @@ pub async fn ai_conversation_create(
// 后台跑着的 conv。决策 e:旧 conv 的 per_conv 完整保留(后台 loop 继续跑自己的 conv), // 后台跑着的 conv。决策 e:旧 conv 的 per_conv 完整保留(后台 loop 继续跑自己的 conv),
// 仅切换 active_conversation_id 到新会话 + 为新会话建独立 per_conv。 // 仅切换 active_conversation_id 到新会话 + 为新会话建独立 per_conv。
// //
// B-260618-06 保留:切换 active 前先持久化旧 active conv(防其内存 messages 因后续操作丢失)。 // 保留:切换 active 前先持久化旧 active conv(防其内存 messages 因后续操作丢失)。
// 注意:旧 conv 若有后台 loop 在跑,loop 自身的 save_conversation 也会持久化,此处 save 幂等。 // 注意:旧 conv 若有后台 loop 在跑,loop 自身的 save_conversation 也会持久化,此处 save 幂等。
let old_conv = { let old_conv = {
let session = state.ai_session.lock().await; let session = state.ai_session.lock().await;
@@ -227,9 +227,10 @@ pub async fn ai_conversation_list(
let limit = limit.unwrap_or(50); let limit = limit.unwrap_or(50);
let include_archived = include_archived.unwrap_or(false); let include_archived = include_archived.unwrap_or(false);
let records = state.ai_conversations.list_all().await.map_err(err_str)?; let records = state.ai_conversations.list_all().await.map_err(err_str)?;
// list_all 已按 created_at DESC(最新在前);默认排除归档 + 截断 limit // list_all 已按 created_at DESC(最新在前);默认排除归档 + 截断 limit。
// origin 非空(子代理轨迹,V44)不进侧栏列表——轨迹在库可追溯,展示面只留主会话。
let summaries: Vec<serde_json::Value> = records.iter() let summaries: Vec<serde_json::Value> = records.iter()
.filter(|r| include_archived || !r.archived) .filter(|r| (include_archived || !r.archived) && r.origin.is_none())
.take(limit) .take(limit)
.map(|r| { .map(|r| {
// 修复 models 字段类型 bug:r.models 是 JSON 字符串,前端期望数组 // 修复 models 字段类型 bug:r.models 是 JSON 字符串,前端期望数组
@@ -317,7 +318,7 @@ pub async fn ai_conversation_switch(
None None
}; };
let title = record.title.clone(); let title = record.title.clone();
// B-260617-17 续:历史会话 title 空(显"新对话")→ 切入后触发重新生成(用户诉求)。 // 续:历史会话 title 空(显"新对话")→ 切入后触发重新生成(用户诉求)。
// 含 "新对话" 占位(Some 但未生成):title.rs ensure :40 同步排除"新对话"占位不跳过, // 含 "新对话" 占位(Some 但未生成):title.rs ensure :40 同步排除"新对话"占位不跳过,
// 否则 title 落"新对话"(LLM 返空兜底/默认)后 is_some()=true 永远跳过 → 标题卡死。 // 否则 title 落"新对话"(LLM 返空兜底/默认)后 is_some()=true 永远跳过 → 标题卡死。
let need_title_regen = record.title.is_none() || record.title.as_deref() == Some("新对话"); let need_title_regen = record.title.is_none() || record.title.as_deref() == Some("新对话");
@@ -606,16 +607,33 @@ pub async fn ai_conversation_load_more(
/// 删除对话 /// 删除对话
#[tauri::command] #[tauri::command]
pub async fn ai_conversation_delete( pub async fn ai_conversation_delete(
app: AppHandle,
state: State<'_, AppState>, state: State<'_, AppState>,
conversation_id: String, conversation_id: String,
) -> Result<(), String> { ) -> Result<(), String> {
// 记录已删除 conv_id,必须先于 DB 删除写入:save_conversation 入口检测到即跳过, // 记录已删除 conv_id,必须先于 DB 删除写入:save_conversation 入口检测到即跳过,
// 封死「loop 在途 save 复活」窗口(不惰性重建 per_conv、不 Ok(None) INSERT 空壳)。 // 封死「loop 在途 save 复活」窗口(不惰性重建 per_conv、不 Ok(None) INSERT 空壳)。
// 删除语义:删了就删了,后续 save 不应复活。conv_id 是 ULID 不重用,标记长期保留。 // 删除语义:删了就删了,后续 save 不应复活。conv_id 是 ULID 不重用,标记长期保留。
{ //
// 同锁内停 in-flight agentic loop(范式对齐 ai_chat_force_send / ai_chat_clear 的锁内写法):
// stop_flag 置位让 loop 在下一轮闸门退出;notify_one 唤醒阻塞在 stream select! 的 loop
// 不必等 chunk;epoch 递增使旧 loop 立即失去 owner 身份,其全部退出路径的
// guard.reset/emit/save 因 epoch 不匹配静默跳过,不会在已删 conv 上复活生成态。
// 用 conv_read 而非 conv():conv() 对缺失条目惰性重建空壳,恰是删除场景要避免的;
// 条目已不存在(重复删除/本就无 loop)则无事可停。
let loop_was_alive = {
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
session.deleted_convs.insert(conversation_id.clone()); session.deleted_convs.insert(conversation_id.clone());
match session.conv_read(&conversation_id) {
Some(conv) => {
conv.stop_flag.store(true, Ordering::SeqCst);
conv.notify.notify_one();
conv.loop_epoch.fetch_add(1, Ordering::SeqCst);
true
} }
None => false,
}
};
// 单事务删 ai_messages 子行 + ai_conversations 主行(防孤儿消息)。 // 单事务删 ai_messages 子行 + ai_conversations 主行(防孤儿消息)。
// 顺序:先删数据再摘内存(下方 per_conv.remove)。 // 顺序:先删数据再摘内存(下方 per_conv.remove)。
@@ -655,6 +673,24 @@ pub async fn ai_conversation_delete(
if was_active { if was_active {
state.clear_session_allowed_dirs_if_idle().await; state.clear_session_allowed_dirs_if_idle().await;
} }
// 终态事件单点补发:上方 epoch 递增后,被停 loop 的全部退出路径(loop 入口已删 / stop 闸门
// teardown / 流后 stale / MidStream 保文跳过 teardown / push 后删除检查)都因 epoch 不匹配
// 静默退出,不再发任何 AiCompleted,前端会永久卡 Generating。故由删除侧单点补发,一次覆盖
// 所有退出路径,天然防重复(loop 侧 emit 全部带 epoch owner 守卫,删除后必不触发)。
// 走正典 emit_ai_completed_once(零 token 先例见 agentic/helpers.rs),incomplete 语义对齐
// D 线契约:emit 端 Some(true) + reason "deleted"(前端 types.ts 仅特判 max_rounds,其余
// 值走通用不完整提示),publish 端 None 对齐 MidStream 分叉(总线消费方无需删除标记)。
// notify=false:会话已删除,不发"任务完成"系统通知。仅 loop_was_alive 时补发,重复删除
// 或无 loop 的 conv 不产生多余事件。
if loop_was_alive {
let zero_usage = df_ai::provider::TokenUsage::default();
crate::commands::ai::agentic::emit_ai_completed_once(
&app, &conversation_id, &zero_usage, false,
Some(true), None, Some("deleted"), true,
&[], false,
).await;
}
Ok(()) Ok(())
} }
+5 -1
View File
@@ -253,7 +253,7 @@ async fn save_conversation_inner(
// 避免同时持有 session 的 mut 和 immut 借用(E0502)。 // 避免同时持有 session 的 mut 和 immut 借用(E0502)。
// BE1无变化捷径提前到克隆之前——同一 lock 段内先判「消息数未增且非 dirty」, // BE1无变化捷径提前到克隆之前——同一 lock 段内先判「消息数未增且非 dirty」,
// 无变化则不 clone(每轮无变化 save 省一次全量 clone + 后续 truncate/records 构建)。 // 无变化则不 clone(每轮无变化 save 省一次全量 clone + 后续 truncate/records 构建)。
let (provider_id, created_at, pinned_goals, msg_len, persisted_count, needs_full_rewrite, mut persist_msgs) = { let (provider_id, created_at, pinned_goals, msg_len, persisted_count, needs_full_rewrite, mut persist_msgs, conv_origin) = {
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
// 已删除对话直接跳过写入(loop 在「push 后、save 前」被删除的幽灵复活根治): // 已删除对话直接跳过写入(loop 在「push 后、save 前」被删除的幽灵复活根治):
// 此处不惰性重建 per_conv、不触发 INSERT 空壳 + insert_batch 复活。 // 此处不惰性重建 per_conv、不触发 INSERT 空壳 + insert_batch 复活。
@@ -270,6 +270,8 @@ async fn save_conversation_inner(
// 避免同时持有 session 的 mut 和 immut 借用(E0502)。 // 避免同时持有 session 的 mut 和 immut 借用(E0502)。
let provider_id = session.active_provider_id.clone(); let provider_id = session.active_provider_id.clone();
let created_at = session.active_conv_created_at.clone(); let created_at = session.active_conv_created_at.clone();
// 会话来源标记(V44 子轨迹落库):子代理 conv 首插时随 record 落 origin 列,主会话 None
let conv_origin = session.conv_origins.get(conv_id).cloned();
let conv = session.conv(conv_id); let conv = session.conv(conv_id);
let msg_len = conv.messages.len(); let msg_len = conv.messages.len();
let persisted_count = conv.messages.persisted_msg_count(); let persisted_count = conv.messages.persisted_msg_count();
@@ -291,6 +293,7 @@ async fn save_conversation_inner(
persisted_count, persisted_count,
needs_full_rewrite, needs_full_rewrite,
cloned, cloned,
conv_origin,
) )
}; };
let __lock_total = __lock_start.elapsed(); let __lock_total = __lock_start.elapsed();
@@ -469,6 +472,7 @@ async fn save_conversation_inner(
pending_approvals: Some(pending_approvals_json.clone()), pending_approvals: Some(pending_approvals_json.clone()),
created_at: conv_created.clone(), created_at: conv_created.clone(),
updated_at: now, updated_at: now,
origin: conv_origin,
}; };
if let Err(e) = conv_repo.insert(rec).await { if let Err(e) = conv_repo.insert(rec).await {
tracing::warn!("落库对话失败 {conv_id}: {e}"); tracing::warn!("落库对话失败 {conv_id}: {e}");
+16 -3
View File
@@ -181,6 +181,10 @@ pub enum AiChatEvent {
is_estimated: bool, is_estimated: bool,
/// 不完整标记(可选):Some(true)=网络中断保文,None 或 Some(false)=完整回复 /// 不完整标记(可选):Some(true)=网络中断保文,None 或 Some(false)=完整回复
incomplete: Option<bool>, incomplete: Option<bool>,
/// 不完整原因(与 incomplete 配合):Some("max_rounds")=达最大轮次自动收尾,
/// Some("network")=流中途网络中断保文,None=完整回复/未分类。前端据此选文案:
/// "max_rounds" → 「已达最大轮次自动收尾」,其他/缺省 → 网络中断文案。
incomplete_reason: Option<String>,
conversation_id: Option<String>, conversation_id: Option<String>,
/// 当前会话 pinned_goals(G1 目标钉扎持久化,前端直接读取刷新) /// 当前会话 pinned_goals(G1 目标钉扎持久化,前端直接读取刷新)
pinned_goals: Vec<GoalEntry>, pinned_goals: Vec<GoalEntry>,
@@ -231,6 +235,7 @@ pub enum AiChatEvent {
/// - `conversation_id`:父会话 id(路由键,挂父不挂子,子会话不在侧栏) /// - `conversation_id`:父会话 id(路由键,挂父不挂子,子会话不在侧栏)
/// - `status`:"completed" | "truncated" | "failed" /// - `status`:"completed" | "truncated" | "failed"
/// - `rounds`/`tokens`:实际执行轮数与累计 token(启动失败为 0) /// - `rounds`/`tokens`:实际执行轮数与累计 token(启动失败为 0)
/// - `rejected_tools`:被拒工具清单("工具名×次数",如 "write_file×3"),None=本次零拒绝
AiAgentFinished { AiAgentFinished {
agent_id: String, agent_id: String,
conversation_id: Option<String>, conversation_id: Option<String>,
@@ -238,6 +243,9 @@ pub enum AiChatEvent {
status: String, status: String,
rounds: u32, rounds: u32,
tokens: u64, tokens: u64,
/// S2 报告协议:子循环运行期被拒工具汇总,让父会话与用户看见子代理被拒了什么。
/// 可选字段:前端 S1 完成卡不消费该字段也不破坏(事件多字段向后兼容),消费留二期。
rejected_tools: Option<Vec<String>>,
}, },
/// 流式调用自动重试提示流前失败重试中) /// 流式调用自动重试提示流前失败重试中)
/// ///
@@ -497,7 +505,7 @@ pub struct ConvSummary {
/// ///
/// 优先级:`pending_approvals` 非空(有挂起审批优先报 AwaitingApproval, /// 优先级:`pending_approvals` 非空(有挂起审批优先报 AwaitingApproval,
/// 即使同时在流式)> `generating`(Streaming)> 都不满足(Idle)。 /// 即使同时在流式)> `generating`(Streaming)> 都不满足(Idle)。
// SW-260618-21 b:原预留读视图(0 消费者)。path_auth 审批链阶段1 接入后有消费者 // b:原预留读视图(0 消费者)。path_auth 审批链阶段1 接入后有消费者
// (try_continue_agent_loop / ai_chat_stop 经 session_state 替代手写 has_pending),不再 dead_code。 // (try_continue_agent_loop / ai_chat_stop 经 session_state 替代手写 has_pending),不再 dead_code。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionState { pub enum SessionState {
@@ -643,6 +651,10 @@ pub struct AiSession {
/// 删除语义:删了就删了,loop 后续 save 不应复活。conv_id 是 ULID 不重用,条目长期保留 /// 删除语义:删了就删了,loop 后续 save 不应复活。conv_id 是 ULID 不重用,条目长期保留
/// (单条 ~40B,删除量级下可忽略)。 /// (单条 ~40B,删除量级下可忽略)。
pub deleted_convs: HashSet<String>, pub deleted_convs: HashSet<String>,
/// 会话来源标记(V44 子轨迹落库):conv_id → origin(现仅 "sub_agent")。
/// save 首插时随 record 落 origin 列,桌面侧栏/miniapp 列表按此过滤子会话。
/// 子代理启动时写入,出口清理时随 per_conv 一并移除;主会话无条目(None)。
pub conv_origins: HashMap<String, String>,
/// 每会话 save 串行锁:同会话并发 save(loop 与 IPC)串行化,防全量重写互踩吞新行 /// 每会话 save 串行锁:同会话并发 save(loop 与 IPC)串行化,防全量重写互踩吞新行
pub save_locks: HashMap<String, Arc<tokio::sync::Mutex<()>>>, pub save_locks: HashMap<String, Arc<tokio::sync::Mutex<()>>>,
/// T2 工具命名空间存储(运行时缓存,大工具结果不进主队列) /// T2 工具命名空间存储(运行时缓存,大工具结果不进主队列)
@@ -658,6 +670,7 @@ impl AiSession {
pending_approvals: HashMap::new(), pending_approvals: HashMap::new(),
per_conv: HashMap::new(), per_conv: HashMap::new(),
deleted_convs: HashSet::new(), deleted_convs: HashSet::new(),
conv_origins: HashMap::new(),
save_locks: HashMap::new(), save_locks: HashMap::new(),
namespace_store: df_ai::namespace_store::NamespaceStore::default(), namespace_store: df_ai::namespace_store::NamespaceStore::default(),
} }
@@ -1142,7 +1155,7 @@ pub enum ApprovalKind {
Path(PathAuthRequest), Path(PathAuthRequest),
/// 普通 RiskLevel 审批(Med/High 风险工具)。 /// 普通 RiskLevel 审批(Med/High 风险工具)。
/// diff 仅 write_file 挂起审批前预读注入;其余工具 / recovered 审批为 None。 /// diff 仅 write_file 挂起审批前预读注入;其余工具 / recovered 审批为 None。
#[allow(dead_code)] // IPC 活跃(useAiEvents:252+ToolCard·UX-260618-06)·cargo Rust never-read 误报 #[allow(dead_code)] // IPC 活跃(useAiEvents:252+ToolCard·)·cargo Rust never-read 误报
Risk { diff: Option<String> }, Risk { diff: Option<String> },
} }
@@ -1166,7 +1179,7 @@ pub(crate) struct ToolCallDraft {
} }
// ============================================================ // ============================================================
// B-260617-01 决策 a(方案 a2):run_workflow 工具经审批后的真实执行入口 // 决策 a(方案 a2):run_workflow 工具经审批后的真实执行入口
// //
// 背景:run_workflow handler(tool_registry.rs)仅持 db:Arc<Database>,无 AppHandle/State // 背景:run_workflow handler(tool_registry.rs)仅持 db:Arc<Database>,无 AppHandle/State
// (registry/event_bus/workflows Repo),经 ai_tools.execute 必然 Err。ai_approve 识别 run_workflow // (registry/event_bus/workflows Repo),经 ai_tools.execute 必然 Err。ai_approve 识别 run_workflow
+14 -12
View File
@@ -422,8 +422,10 @@ async fn route_list_conversations(state: &State<'_, AppState>) {
}; };
// 映射摘要 + parse 时间戳。updated_at 缺失/脏 → None(排序兜底 0)。 // 映射摘要 + parse 时间戳。updated_at 缺失/脏 → None(排序兜底 0)。
// origin 非空(子代理轨迹,V44)不推 miniapp——与桌面侧栏同一过滤口径。
let mut summaries: Vec<ConvSummary> = records let mut summaries: Vec<ConvSummary> = records
.into_iter() .into_iter()
.filter(|r| r.origin.is_none())
.map(|r| ConvSummary { .map(|r| ConvSummary {
id: r.id, id: r.id,
title: r.title.unwrap_or_default(), title: r.title.unwrap_or_default(),
@@ -809,8 +811,8 @@ async fn route_send_message(app: &AppHandle, state: &State<'_, AppState>, args:
conv_id = ?reject_conv_id, conv_id = ?reject_conv_id,
"[remote_bridge][R1] 同 conv 正在生成中,拒绝远程 send_message(emit AiError 回 miniapp)" "[remote_bridge][R1] 同 conv 正在生成中,拒绝远程 send_message(emit AiError 回 miniapp)"
); );
let _ = app.emit( emit_ai_chat_event(
"ai-chat-event", app,
AiChatEvent::AiError { AiChatEvent::AiError {
error: "正在生成中,请等待完成".to_string(), error: "正在生成中,请等待完成".to_string(),
// 并发拒绝非错误源分类(auth/network/timeout/provider_config),用 Unknown 兜底。 // 并发拒绝非错误源分类(auth/network/timeout/provider_config),用 Unknown 兜底。
@@ -868,8 +870,8 @@ async fn route_send_message(app: &AppHandle, state: &State<'_, AppState>, args:
conv_id = ?conversation_id, conv_id = ?conversation_id,
"[remote_bridge] ai_chat_send 返回 Err,转 AiError emit 回 miniapp" "[remote_bridge] ai_chat_send 返回 Err,转 AiError emit 回 miniapp"
); );
let _ = app.emit( emit_ai_chat_event(
"ai-chat-event", app,
AiChatEvent::AiError { AiChatEvent::AiError {
error: err_msg, error: err_msg,
// ai_chat_send 的 Err 多为 generating 拦截 / Provider 配置缺失, // ai_chat_send 的 Err 多为 generating 拦截 / Provider 配置缺失,
@@ -948,8 +950,8 @@ async fn route_force_send(app: &AppHandle, state: &State<'_, AppState>, args: Va
conv_id = ?conversation_id, conv_id = ?conversation_id,
"[remote_bridge] ai_chat_force_send 返回 Err,转 AiError emit 回 miniapp" "[remote_bridge] ai_chat_force_send 返回 Err,转 AiError emit 回 miniapp"
); );
let _ = app.emit( emit_ai_chat_event(
"ai-chat-event", app,
AiChatEvent::AiError { AiChatEvent::AiError {
error: err_msg, error: err_msg,
error_type: Some(crate::commands::ai::ErrorType::Unknown), error_type: Some(crate::commands::ai::ErrorType::Unknown),
@@ -997,8 +999,8 @@ async fn route_edit_message(app: &AppHandle, state: &State<'_, AppState>, args:
conv_id = %conversation_id, conv_id = %conversation_id,
"[remote_bridge] ai_chat_edit 返回 Err,转 AiError emit 回 miniapp" "[remote_bridge] ai_chat_edit 返回 Err,转 AiError emit 回 miniapp"
); );
let _ = app.emit( emit_ai_chat_event(
"ai-chat-event", app,
AiChatEvent::AiError { AiChatEvent::AiError {
error: err_msg, error: err_msg,
error_type: Some(crate::commands::ai::ErrorType::Unknown), error_type: Some(crate::commands::ai::ErrorType::Unknown),
@@ -1031,8 +1033,8 @@ async fn route_compress_context(app: &AppHandle, state: &State<'_, AppState>, ar
conv_id = %conversation_id, conv_id = %conversation_id,
"[remote_bridge] ai_chat_compress_context 返回 Err,转 AiError emit 回 miniapp" "[remote_bridge] ai_chat_compress_context 返回 Err,转 AiError emit 回 miniapp"
); );
let _ = app.emit( emit_ai_chat_event(
"ai-chat-event", app,
AiChatEvent::AiError { AiChatEvent::AiError {
error: err_msg, error: err_msg,
error_type: Some(crate::commands::ai::ErrorType::Unknown), error_type: Some(crate::commands::ai::ErrorType::Unknown),
@@ -1063,8 +1065,8 @@ async fn route_clear_context(app: &AppHandle, state: &State<'_, AppState>, args:
conv_id = %conversation_id, conv_id = %conversation_id,
"[remote_bridge] ai_chat_clear_context 返回 Err,转 AiError emit 回 miniapp" "[remote_bridge] ai_chat_clear_context 返回 Err,转 AiError emit 回 miniapp"
); );
let _ = app.emit( emit_ai_chat_event(
"ai-chat-event", app,
AiChatEvent::AiError { AiChatEvent::AiError {
error: err_msg, error: err_msg,
error_type: Some(crate::commands::ai::ErrorType::Unknown), error_type: Some(crate::commands::ai::ErrorType::Unknown),
+2 -2
View File
@@ -167,7 +167,7 @@ fn flush_delta(app_handle: &AppHandle, conv_id: &str, pending_delta: &mut String
/// 流式接收 LLM 响应。 /// 流式接收 LLM 响应。
/// ///
/// 三类异常处理(返回 StreamResult 显式区分出口): /// 三类异常处理(返回 StreamResult 显式区分出口):
/// - Init 失败(provider.stream() Err):UX-260618-15 **不 emit AiError**,返回 /// - Init 失败(provider.stream() Err):**不 emit AiError**,返回
/// InitFailed{retryable, error}。retryable 据状态码分类(retry::is_status_retryable 镜像): /// InitFailed{retryable, error}。retryable 据状态码分类(retry::is_status_retryable 镜像):
/// 5xx/429/timeout/connect=true,4xx(非429)/鉴权/参数错=false Fatal。调用方走流前重试(仅 /// 5xx/429/timeout/connect=true,4xx(非429)/鉴权/参数错=false Fatal。调用方走流前重试(仅
/// retryable=true,≤max_retries 次, 复用 retry::backoff_delay 退避 + 30s 总预算),重试耗尽或 /// retryable=true,≤max_retries 次, 复用 retry::backoff_delay 退避 + 30s 总预算),重试耗尽或
@@ -177,7 +177,7 @@ fn flush_delta(app_handle: &AppHandle, conv_id: &str, pending_delta: &mut String
/// + 系统提示网络中断。空文本仍返回 InitFailed{retryable=true}(无文可保,交重试,emit 权归调用方)。 /// + 系统提示网络中断。空文本仍返回 InitFailed{retryable=true}(无文可保,交重试,emit 权归调用方)。
/// - 用户停止(stop_flag):返回 Complete(incomplete 语义非异常中断)。 /// - 用户停止(stop_flag):返回 Complete(incomplete 语义非异常中断)。
/// ///
/// 心跳与停止响应(B-260615-02 / B-260615-04):单 `tokio::select!` 三分支 /// 心跳与停止响应(/ ):单 `tokio::select!` 三分支
/// - `stream.next()`:正常 chunk 处理 /// - `stream.next()`:正常 chunk 处理
/// - `heartbeat.tick()`(30s):静默期(如工具执行后等下一轮首 chunk)emit `AiHeartbeat`, /// - `heartbeat.tick()`(30s):静默期(如工具执行后等下一轮首 chunk)emit `AiHeartbeat`,
/// 前端 watchdog 据此 reset,区分「LLM 在跑」与「真断」(避免空气泡误报中断) /// 前端 watchdog 据此 reset,区分「LLM 在跑」与「真断」(避免空气泡误报中断)
+1 -1
View File
@@ -88,7 +88,7 @@ pub(crate) async fn ensure_conversation_title(
// 在传给 LLM 前合并连续同 role(拼接 content),不影响其他逻辑(本函数 summary 派生数据)。 // 在传给 LLM 前合并连续同 role(拼接 content),不影响其他逻辑(本函数 summary 派生数据)。
let summary_msgs = merge_consecutive_roles(summary_msgs); let summary_msgs = merge_consecutive_roles(summary_msgs);
// B-260617-17 修复:先用 extract 兜底 emit 让侧栏即时更新(不落库), // 修复:先用 extract 兜底 emit 让侧栏即时更新(不落库),
// 下方 LLM 生成成功后覆盖。这样 LLM 失败时有重试机会, // 下方 LLM 生成成功后覆盖。这样 LLM 失败时有重试机会,
// 不会永久锁定一个机械截取的差标题。 // 不会永久锁定一个机械截取的差标题。
let _fallback_title = extract_title(&all_msgs).unwrap_or_else(|| "新对话".to_string()); let _fallback_title = extract_title(&all_msgs).unwrap_or_else(|| "新对话".to_string());
+1 -1
View File
@@ -73,7 +73,7 @@ pub async fn create_idea(
state: State<'_, AppState>, state: State<'_, AppState>,
input: CreateIdeaInput, input: CreateIdeaInput,
) -> Result<IdeaRecord, String> { ) -> Result<IdeaRecord, String> {
// priority 值域校验 ∈ 0..=3 (对标 tasks B-260615-15)。 // priority 值域校验 ∈ 0..=3 (对标 tasks )。
// priority_from_i32 对越界值兜底归并(>=3→Critical),但 IPC 入口应显式拒非法值, // priority_from_i32 对越界值兜底归并(>=3→Critical),但 IPC 入口应显式拒非法值,
// 防 LLM/前端传 99 等被静默吞为 Critical。 // 防 LLM/前端传 99 等被静默吞为 Critical。
if !(0..=3).contains(&input.priority) { if !(0..=3).contains(&input.priority) {
+1 -1
View File
@@ -338,7 +338,7 @@ pub async fn get_project(
/// 更新项目单个字段(字段名走 df-storage 白名单校验) /// 更新项目单个字段(字段名走 df-storage 白名单校验)
/// ///
/// B-260801-010 行(id 不存在 / 已软删)→ 返 Err 而非假成功。原实现无视 /// 行(id 不存在 / 已软删)→ 返 Err 而非假成功。原实现无视
/// update_field 返回值恒返 Ok(true),前端误以为成功但库无变更(UI/DB 不一致)。 /// update_field 返回值恒返 Ok(true),前端误以为成功但库无变更(UI/DB 不一致)。
/// ///
/// 返回值保持 bool(成功恒 true):前端 api 包装 `Promise<boolean>` 契约不变零破坏, /// 返回值保持 bool(成功恒 true):前端 api 包装 `Promise<boolean>` 契约不变零破坏,
+2 -2
View File
@@ -365,7 +365,7 @@ pub async fn create_task(
/// 更新任务单个字段(字段名走 df-storage 白名单校验;status 值走枚举校验)。 /// 更新任务单个字段(字段名走 df-storage 白名单校验;status 值走枚举校验)。
/// ///
/// B-260801-010 行(id 不存在 / 已软删)→ 返 Err 而非 false,杜绝「假成功」 /// 行(id 不存在 / 已软删)→ 返 Err 而非 false,杜绝「假成功」
/// (前端 updateTask 仅 await 不读 bool,但 store.runWithCatch 把 Err 转 toast,用户能感知; /// (前端 updateTask 仅 await 不读 bool,但 store.runWithCatch 把 Err 转 toast,用户能感知;
/// 若返 false 则 store 静默忽略,本地状态前端手动改但库无变更,造成 UI 与 DB 不一致)。 /// 若返 false 则 store 静默忽略,本地状态前端手动改但库无变更,造成 UI 与 DB 不一致)。
/// ///
@@ -406,7 +406,7 @@ pub async fn update_task(
} }
} }
} }
// project_id 跨表存在性校验(B-260616-16 收尾)。 // project_id 跨表存在性校验(收尾)。
// crud.rs tasks 白名单保留 project_id(支持跨项目移动),跨表约束由本命令层兜底。 // crud.rs tasks 白名单保留 project_id(支持跨项目移动),跨表约束由本命令层兜底。
// 拦截移动到不存在的项目(手输脏 id / 已物理删除的项目),避免 tasks.project_id 悬空。 // 拦截移动到不存在的项目(手输脏 id / 已物理删除的项目),避免 tasks.project_id 悬空。
if field == "project_id" { if field == "project_id" {
+2 -2
View File
@@ -109,7 +109,7 @@ pub async fn run_workflow(
run_workflow_inner(&app, &state, name, dag, config, task_id, target_status, "manual", None).await run_workflow_inner(&app, &state, name, dag, config, task_id, target_status, "manual", None).await
} }
/// 工作流执行核心逻辑(B-260617-01 抽取,命令层瘦转发)。 /// 工作流执行核心逻辑(抽取,命令层瘦转发)。
/// ///
/// 原 `run_workflow` 命令体拆出,使 ai_approve 经 execute_run_workflow_for_tool 直接调用 /// 原 `run_workflow` 命令体拆出,使 ai_approve 经 execute_run_workflow_for_tool 直接调用
/// (持 &AppState 而非 tauri::State,绕开 tauri::State 在非命令上下文不可构造的限制)。 /// (持 &AppState 而非 tauri::State,绕开 tauri::State 在非命令上下文不可构造的限制)。
@@ -249,7 +249,7 @@ pub async fn run_workflow_inner(
// broadcast 不暴露被丢事件的具体类型,无法在此精确判断是否为关键终态事件。 // broadcast 不暴露被丢事件的具体类型,无法在此精确判断是否为关键终态事件。
// 风险:若关键终态事件(WorkflowCompleted/WorkflowFailed)被丢,finished 判定不触发, // 风险:若关键终态事件(WorkflowCompleted/WorkflowFailed)被丢,finished 判定不触发,
// 循环不会 break,前端永久收不到工作流结束信号。 // 循环不会 break,前端永久收不到工作流结束信号。
// B-260615-35 兜底:收到 Lagged 时,累计次数或单次 n 达阈值则查 DB 终态, // 兜底:收到 Lagged 时,累计次数或单次 n 达阈值则查 DB 终态,
// 若工作流已终态则补发对应 workflow-event 并 break 退出 forward。 // 若工作流已终态则补发对应 workflow-event 并 break 退出 forward。
// 避免依赖实时性差的 DB 轮询兜底导致前端审批/完成弹窗卡死。 // 避免依赖实时性差的 DB 轮询兜底导致前端审批/完成弹窗卡死。
Err(RecvError::Lagged(n)) => { Err(RecvError::Lagged(n)) => {
+2
View File
@@ -428,6 +428,8 @@ pub fn run() {
// 达 max_iterations 暂停态续/停(消费 AiMaxRoundsReached,前端操作卡留 batch45) // 达 max_iterations 暂停态续/停(消费 AiMaxRoundsReached,前端操作卡留 batch45)
commands::ai::ai_continue_loop, commands::ai::ai_continue_loop,
commands::ai::ai_stop_loop, commands::ai::ai_stop_loop,
// 流中断自动续跑探测(前端看门狗超时后调,按后端真实状态续跑/兜底收敛)
commands::ai::ai_resume_interrupted,
commands::ai::ai_pending_tool_calls, commands::ai::ai_pending_tool_calls,
commands::ai::ai_chat_clear, commands::ai::ai_chat_clear,
commands::ai::ai_is_generating, commands::ai::ai_is_generating,
+2 -2
View File
@@ -156,12 +156,12 @@ pub struct AppState {
pub project_modules: ProjectModuleRepo, pub project_modules: ProjectModuleRepo,
/// 工程依赖关系 Repo(V35 module_dependencies,依赖图边数据)。 /// 工程依赖关系 Repo(V35 module_dependencies,依赖图边数据)。
pub module_dependencies: ModuleDependencyRepo, pub module_dependencies: ModuleDependencyRepo,
/// 发布表 Repo(预留:ReleaseRepo 持久化已就位·IPC/逻辑未接入·SW-260618-21 b 保留) /// 发布表 Repo(预留:ReleaseRepo 持久化已就位·IPC/逻辑未接入·b 保留)
#[allow(dead_code)] #[allow(dead_code)]
pub releases: ReleaseRepo, pub releases: ReleaseRepo,
/// 工作流执行表 Repo /// 工作流执行表 Repo
pub workflows: WorkflowRepo, pub workflows: WorkflowRepo,
/// 节点执行表 Repo(预留:NodeExecutionRepo 持久化已就位·IPC/逻辑未接入·SW-260618-21 b 保留) /// 节点执行表 Repo(预留:NodeExecutionRepo 持久化已就位·IPC/逻辑未接入·b 保留)
#[allow(dead_code)] #[allow(dead_code)]
pub node_executions: NodeExecutionRepo, pub node_executions: NodeExecutionRepo,
/// 工作流事件总线(tokio broadcast) /// 工作流事件总线(tokio broadcast)
+5 -1
View File
@@ -20,7 +20,11 @@
} }
], ],
"security": { "security": {
"csp": null "csp": null,
"assetProtocol": {
"enable": true,
"scope": ["**"]
}
} }
}, },
"bundle": { "bundle": {
+5
View File
@@ -93,6 +93,11 @@ export const aiApi = {
return invoke('ai_continue_loop', { conversationId }) return invoke('ai_continue_loop', { conversationId })
}, },
/** 流中断探测:后端按真实状态返回 generating/resumed/finished,供看门狗超时自动续跑 */
resumeInterrupted(conversationId: string): Promise<'generating' | 'resumed' | 'finished'> {
return invoke('ai_resume_interrupted', { conversationId })
},
/** /**
* 停止 agentic 循环并走完成流程(达 max_iterations 暂停态点停止)。 * 停止 agentic 循环并走完成流程(达 max_iterations 暂停态点停止)。
* 后端 ai_stop_loop:复位 generating + emit AiCompleted(暂停前已 save)。 * 后端 ai_stop_loop:复位 generating + emit AiCompleted(暂停前已 save)。
+7 -2
View File
@@ -350,10 +350,10 @@ export type Modality = 'text' | 'vision' | 'audio' | 'video'
/** 维度 2 — 能力(模型能做什么)。serde snake_case 字面量。 */ /** 维度 2 — 能力(模型能做什么)。serde snake_case 字面量。 */
export type Capability = 'tool_use' | 'embedding' | 'code_gen' export type Capability = 'tool_use' | 'embedding' | 'code_gen'
/** 维度 3 — 价格分级(成本)。serde snake_case 字面量。'free' 已删(B-260618-05 死档,预设/启发式从不赋值)。 */ /** 维度 3 — 价格分级(成本)。serde snake_case 字面量。'free' 已删(死档,预设/启发式从不赋值)。 */
export type CostTier = 'low' | 'medium' | 'high' export type CostTier = 'low' | 'medium' | 'high'
/** 维度 4 — 智力分级。serde snake_case 字面量。CR-260618-11#3:与 CostTier 同,cost/intel 已从路由硬过滤及前端标签去除,字段保留仅 DB 兼容,不再参与路由选模型。 */ /** 维度 4 — 智力分级。serde snake_case 字面量。与 CostTier 同,cost/intel 已从路由硬过滤及前端标签去除,字段保留仅 DB 兼容,不再参与路由选模型。 */
export type IntelligenceTier = 'lite' | 'standard' | 'plus' | 'ultra' export type IntelligenceTier = 'lite' | 'standard' | 'plus' | 'ultra'
/** 探测来源(只读,由后端探测器填充)。serde snake_case 字面量。 */ /** 探测来源(只读,由后端探测器填充)。serde snake_case 字面量。 */
@@ -449,6 +449,11 @@ export type AiChatEvent = ({
* undefined/false=真实值。前端 MessageList 据此标注『估算』区分 estimated vs real。 * undefined/false=真实值。前端 MessageList 据此标注『估算』区分 estimated vs real。
*/ */
is_estimated?: boolean is_estimated?: boolean
/**
* incomplete 场景细分(达上限自动收尾 vs 网络中断,前端据此区分提示文案):
* "max_rounds"=达最大轮次后端自动收尾;其他/缺省=网络中断等异常。
*/
incomplete_reason?: string
incomplete?: boolean; pinned_goals?: GoalEntry[] incomplete?: boolean; pinned_goals?: GoalEntry[]
} | { } | {
type: 'AiError'; error: string; error_type?: AiErrorType type: 'AiError'; error: string; error_type?: AiErrorType
+79 -22
View File
@@ -57,8 +57,6 @@
<span class="ai-agentic-progress-dot"></span> <span class="ai-agentic-progress-dot"></span>
<span class="ai-agentic-progress-text">{{ agenticProgressText }}</span> <span class="ai-agentic-progress-text">{{ agenticProgressText }}</span>
</div> </div>
<!-- 第五批抽离至 ai/MaxRoundsCard.vue(F-260616-03 达 max_iterations 暂停态操作卡,零行为变更,store 单例 + pendingMaxRounds 模块级 ref 共享,toast 经 emit 转父) -->
<MaxRoundsCard @toast="(p) => showToast(p.msg, p.type)" />
<!-- L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21): <!-- L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
断路器熔断 emit AiHelpRequired → pendingHelp(模块级 ref)→ 本卡显 reason + options 按钮供用户选。 断路器熔断 emit AiHelpRequired → pendingHelp(模块级 ref)→ 本卡显 reason + options 按钮供用户选。
机制优先 prompt 说教:代码强制熔断 + 结构化求助卡(非教 AI 主动问用户)。 --> 机制优先 prompt 说教:代码强制熔断 + 结构化求助卡(非教 AI 主动问用户)。 -->
@@ -71,7 +69,7 @@
<!-- 待发送队列(生成中排队的消息,完成后自动续发;仅显示当前活跃会话的条目) --> <!-- 待发送队列(生成中排队的消息,完成后自动续发;仅显示当前活跃会话的条目) -->
<div v-if="activeQueue.length > 0" class="ai-queue" :class="{ 'ai-queue--timeout': queueTimedOut }"> <div v-if="activeQueue.length > 0" class="ai-queue" :class="{ 'ai-queue--timeout': queueTimedOut }">
<div class="ai-queue-head"> <div class="ai-queue-head">
<span class="ai-queue-title">{{ $t('aiChat.queueTitle', { n: activeQueue.length }) }}<span v-if="queueWaitSeconds >= 0" class="ai-queue-timer">({{ queueWaitSeconds }}s)</span></span> <span class="ai-queue-title">{{ $t('aiChat.queueTitle', { n: activeQueue.length }) }}</span>
<div class="ai-queue-actions"> <div class="ai-queue-actions">
<button v-if="queueTimedOut" class="ai-queue-force" @click="handleForceSend">{{ $t('ai.forceSendBtn') }}</button> <button v-if="queueTimedOut" class="ai-queue-force" @click="handleForceSend">{{ $t('ai.forceSendBtn') }}</button>
<button class="ai-queue-clear" @click="store.clearQueue(store.state.activeConversationId)">{{ $t('aiChat.clearQueue') }}</button> <button class="ai-queue-clear" @click="store.clearQueue(store.state.activeConversationId)">{{ $t('aiChat.clearQueue') }}</button>
@@ -86,8 +84,7 @@
v-model="editingQueueText" v-model="editingQueueText"
class="ai-queue-edit-input" class="ai-queue-edit-input"
type="text" type="text"
@keydown.enter.prevent="saveEditQueued" @keydown="onQueueEditKeydown"
@keydown.esc.prevent="cancelEditQueued"
@blur="saveEditQueued" @blur="saveEditQueued"
/> />
<button class="ai-queue-btn" @mousedown.prevent="saveEditQueued" :title="$t('aiChat.queueSave')">✓</button> <button class="ai-queue-btn" @mousedown.prevent="saveEditQueued" :title="$t('aiChat.queueSave')">✓</button>
@@ -96,6 +93,10 @@
<template v-else> <template v-else>
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span> <span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
<span class="ai-queue-text">{{ item.text }}</span> <span class="ai-queue-text">{{ item.text }}</span>
<!-- V7 排队时钟:该项已等待时长 m:ss(nowTick 每秒推进,入发送随队列清空即停) -->
<span class="ai-queue-wait">{{ queueItemWait(item) }}</span>
<!-- I4 队列回填:取出该项填回输入框重新编辑(从队列移除) -->
<button class="ai-queue-btn" @click="backfillQueued(idx)" :title="$t('aiChat.queueBackfill')">{{ $t('aiChat.queueBackfill') }}</button>
<!-- 编辑按钮 --> <!-- 编辑按钮 -->
<button class="ai-queue-btn" @click="startEditQueued(idx)" :title="$t('aiChat.queueEdit')">{{ $t('aiChat.queueEdit') }}</button> <button class="ai-queue-btn" @click="startEditQueued(idx)" :title="$t('aiChat.queueEdit')">{{ $t('aiChat.queueEdit') }}</button>
<!-- 立即发送(打断当前+发本条) --> <!-- 立即发送(打断当前+发本条) -->
@@ -141,7 +142,6 @@ import ConversationSidebar from './ai/ConversationSidebar.vue'
import ChatInput from './ai/ChatInput.vue' import ChatInput from './ai/ChatInput.vue'
import TopBar from './ai/TopBar.vue' import TopBar from './ai/TopBar.vue'
import MessageList from './ai/MessageList.vue' import MessageList from './ai/MessageList.vue'
import MaxRoundsCard from './ai/MaxRoundsCard.vue'
import HelpRequiredCard from './ai/HelpRequiredCard.vue' import HelpRequiredCard from './ai/HelpRequiredCard.vue'
import DirAuthDialog from './ai/DirAuthDialog.vue' import DirAuthDialog from './ai/DirAuthDialog.vue'
import { useConfirm } from '../composables/useConfirm' import { useConfirm } from '../composables/useConfirm'
@@ -265,7 +265,7 @@ async function confirmClearChat() {
* 同属真并发语义——不弹「中断并新建」确认(与并行矛盾)。对齐 switchConversation:89-91 * 同属真并发语义——不弹「中断并新建」确认(与并行矛盾)。对齐 switchConversation:89-91
* 「允许生成中切换:后台继续生成,事件按 conversation_id 路由」。 * 「允许生成中切换:后台继续生成,事件按 conversation_id 路由」。
* *
* 历史:B-260615-10 单 loop 软复位弹中断确认,F-09 决策e 上线后已废弃(A 路线遗留)。 * 历史:单 loop 软复位弹中断确认,F-09 决策e 上线后已废弃(A 路线遗留)。
*/ */
async function confirmNewConversation() { async function confirmNewConversation() {
try { try {
@@ -364,7 +364,14 @@ function onGlobalKeydown(e: KeyboardEvent) {
answerConfirm(false) // 视为取消,resolve Promise 打断 confirmClearChat/confirmDelete answerConfirm(false) // 视为取消,resolve Promise 打断 confirmClearChat/confirmDelete
return return
} }
if (props.detached) { // P1-2 Esc 仲裁:事件源落在内层 Esc 消费者(输入框/侧栏重命名框/队列编辑框等输入控件,
// 或技能/@联想浮层与技能 chip)时,Esc 语义归内层(关浮层/取消编辑/取消输入法组词),
// 不关分离窗——否则输入框按 Esc 关技能浮层的同时窗口也进入关闭流程,一次按键双重效果。
// 浮层打开时焦点通常仍在 textarea(已被输入控件分支覆盖),补浮层容器类名兜
// 「鼠标点过浮层项后焦点落在浮层内」的旁路(SkillMention/MentionPopover 共用 .ai-skill-popover 根类)。
const el = e.target as HTMLElement | null
const inInnerEscLayer = !!el?.closest?.('input, textarea, [contenteditable], .ai-skill-popover, .ai-skill-chip')
if (props.detached && !inInnerEscLayer) {
e.preventDefault() e.preventDefault()
store.closeDetachedWindow() store.closeDetachedWindow()
return return
@@ -409,12 +416,36 @@ const activeQueue = computed(() =>
/** 队首排队是否已超 30s(仅看当前活跃会话队列) */ /** 队首排队是否已超 30s(仅看当前活跃会话队列) */
const queueTimedOut = computed(() => store.isQueueTimedOut(store.state.activeConversationId)) const queueTimedOut = computed(() => store.isQueueTimedOut(store.state.activeConversationId))
/** 队首已排队秒数(用于 UI 展示倒计时;无队列时 -1) */ // ── V7 排队时钟:响应式 tick 驱动排队项已等待时长 ──
const queueWaitSeconds = computed(() => { // 原 queueWaitSeconds 在 computed 内直接取 Date.now(),非响应式依赖:纯等待期(无 store 变更)
const first = activeQueue.value[0] // 永不重算,秒数冻结;且只覆盖队首。改为有排队时起 1s interval 推进 nowTick,展示函数读
if (!first) return -1 // nowTick 建立依赖实现每秒刷新;队列清空(含队首进入发送被移除)即关表,卸载时显式清理防泄漏。
return Math.floor((Date.now() - first.enqueuedAt) / 1000) const nowTick = ref(Date.now())
}) let queueTickTimer: ReturnType<typeof setInterval> | null = null
watch(
() => activeQueue.value.length > 0,
(waiting) => {
if (waiting && queueTickTimer === null) {
nowTick.value = Date.now()
queueTickTimer = setInterval(() => { nowTick.value = Date.now() }, 1000)
} else if (!waiting && queueTickTimer !== null) {
clearInterval(queueTickTimer)
queueTickTimer = null
}
},
{ immediate: true },
)
/** 秒数转 m:ss(分钟不封顶,长等待继续累计) */
function formatWaitTime(totalSeconds: number): string {
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`
}
/** 排队项已等待时长(m:ss);读 nowTick 建立响应式依赖,每秒随 tick 刷新 */
function queueItemWait(item: { enqueuedAt: number }): string {
return formatWaitTime(Math.max(0, Math.floor((nowTick.value - item.enqueuedAt) / 1000)))
}
/** 强制发送:委托 tryForceSend 唯一负责弹 confirm + 移除队列 + force_send(作用于当前活跃会话) */ /** 强制发送:委托 tryForceSend 唯一负责弹 confirm + 移除队列 + force_send(作用于当前活跃会话) */
async function handleForceSend() { async function handleForceSend() {
@@ -442,6 +473,20 @@ function cancelEditQueued() {
editingQueueText.value = '' editingQueueText.value = ''
} }
/** P1-1 队列编辑框键盘处理:输入法组词中(isComposing/229)的 Enter/Esc 属 IME 选词/取消组词,
* 不当提交/取消处理,防半截拼音被写回队列项。原 @keydown.enter/.esc 内联修饰符挂不了守卫,
* 收敛到本函数统一判。 */
function onQueueEditKeydown(e: KeyboardEvent) {
if (e.isComposing || e.keyCode === 229) return
if (e.key === 'Enter') {
e.preventDefault()
saveEditQueued()
} else if (e.key === 'Escape') {
e.preventDefault()
cancelEditQueued()
}
}
/** 保存编辑:回车/失焦触发,委托 store.editQueued 写回 text */ /** 保存编辑:回车/失焦触发,委托 store.editQueued 写回 text */
function saveEditQueued() { function saveEditQueued() {
if (editingQueueIdx.value === null) return if (editingQueueIdx.value === null) return
@@ -455,6 +500,20 @@ async function handleSendQueuedNow(idx: number) {
await store.sendQueuedNow(idx) await store.sendQueuedNow(idx)
} }
/** I4 队列回填:取出该条填回输入框重新编辑(从队列移除,可改完再发)。
* cancelQueued 内部经 activeQueueRealIndex 做过滤视图下标映射,越界 no-op;
* 项携带技能时按名找回 SkillInfo 回挂 chip,找不到(技能已卸载)只回填文本,
* 与队列原地编辑只保 text 同一取舍。 */
function backfillQueued(idx: number) {
const item = activeQueue.value[idx]
if (!item) return
const skill = item.skill
? store.state.skills.find(s => s.name === item.skill) ?? null
: null
store.cancelQueued(idx)
chatInputRef.value?.fillDraft(item.text, skill)
}
// 第四批抽离: 滚动跟随/回底部/流式 Markdown 分块渲染/工具卡自动收起/选区保持 全部随 // 第四批抽离: 滚动跟随/回底部/流式 Markdown 分块渲染/工具卡自动收起/选区保持 全部随
// MessageList 子组件迁移(零行为变更,store/useMarkdown 单例共享)。 // MessageList 子组件迁移(零行为变更,store/useMarkdown 单例共享)。
// 父仅保留 scrollToBottom 转发桩(ChatInput @sent / TopBar @scroll-to-pending 经 expose 调)。 // 父仅保留 scrollToBottom 转发桩(ChatInput @sent / TopBar @scroll-to-pending 经 expose 调)。
@@ -462,6 +521,8 @@ function scrollToBottom(smooth = false) {
messageListRef.value?.scrollToBottom(smooth) messageListRef.value?.scrollToBottom(smooth)
} }
onBeforeUnmount(() => { onBeforeUnmount(() => {
// V7 排队时钟 interval 随组件卸载清理(watch 随卸载停但 interval 本体不自停,须显式清)
if (queueTickTimer !== null) { clearInterval(queueTickTimer); queueTickTimer = null }
// 全局快捷键卸载时移除 window listener,防分离窗口/切组件后泄漏 // 全局快捷键卸载时移除 window listener,防分离窗口/切组件后泄漏
window.removeEventListener('keydown', onGlobalKeydown) window.removeEventListener('keydown', onGlobalKeydown)
// 摘除未发送输入保护(分离窗口关闭/页面刷新触发 beforeunload) // 摘除未发送输入保护(分离窗口关闭/页面刷新触发 beforeunload)
@@ -476,7 +537,7 @@ onBeforeUnmount(() => {
store.stopListener() store.stopListener()
// F-15 阶段2: 释放上下文管理事件监听器 // F-15 阶段2: 释放上下文管理事件监听器
store.stopContextListener() store.stopContextListener()
if (_unlistenToolSlow) { _unlistenToolSlow(); _unlistenToolSlow = null } // B-260616-12 if (_unlistenToolSlow) { _unlistenToolSlow(); _unlistenToolSlow = null } //
if (_unlistenAutoApproved) { _unlistenAutoApproved(); _unlistenAutoApproved = null } // AE-2025-04 if (_unlistenAutoApproved) { _unlistenAutoApproved(); _unlistenAutoApproved = null } // AE-2025-04
if (_unlistenAiToast) { _unlistenAiToast(); _unlistenAiToast = null } // P1-e-d if (_unlistenAiToast) { _unlistenAiToast(); _unlistenAiToast = null } // P1-e-d
if (_unlistenSkillInjected) { _unlistenSkillInjected(); _unlistenSkillInjected = null } // 技能注入反馈 toast if (_unlistenSkillInjected) { _unlistenSkillInjected(); _unlistenSkillInjected = null } // 技能注入反馈 toast
@@ -580,10 +641,6 @@ async function toggleAlwaysOnTop() {
appSettingsStore.set('df-ai-always-on-top', alwaysOnTop.value) appSettingsStore.set('df-ai-always-on-top', alwaysOnTop.value)
} }
// 第五批抽离: F-260616-03 max_iterations 暂停态操作卡(maxRoundsActing/showMaxRoundsCard/
// handleContinueLoop/handleStopLoop/pendingMaxRounds watch)已迁移至 ai/MaxRoundsCard.vue
// (零行为变更,store 单例 + pendingMaxRounds 模块级 ref 共享,toast 经 emit 转父)。
// ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ── // ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ──
// ChatMessage.status 渲染语义(types.ts AiMessage.status 字面量联合定义): // ChatMessage.status 渲染语义(types.ts AiMessage.status 字面量联合定义):
// null | undefined | 'active' → 正常消息(现状) // null | undefined | 'active' → 正常消息(现状)
@@ -721,7 +778,6 @@ async function initContextEventListeners(): Promise<void> {
border-top: 0.5px solid var(--df-border); border-top: 0.5px solid var(--df-border);
background: var(--df-bg); background: var(--df-bg);
} }
/* 第五批抽离: F-260616-03 max_iterations 操作卡样式已迁移至 ai/MaxRoundsCard.vue */
/* ── Agentic 循环进度条 ── */ /* ── Agentic 循环进度条 ── */
.ai-agentic-progress { .ai-agentic-progress {
@@ -772,7 +828,6 @@ async function initContextEventListeners(): Promise<void> {
margin-bottom: 4px; margin-bottom: 4px;
} }
.ai-queue-title { font-size: 11px; color: var(--df-text-dim); font-weight: 600; } .ai-queue-title { font-size: 11px; color: var(--df-text-dim); font-weight: 600; }
.ai-queue-timer { font-weight: 400; opacity: 0.7; }
.ai-queue--timeout .ai-queue-title { color: var(--df-warning); } .ai-queue--timeout .ai-queue-title { color: var(--df-warning); }
.ai-queue-actions { display: flex; align-items: center; gap: 8px; } .ai-queue-actions { display: flex; align-items: center; gap: 8px; }
.ai-queue-force { .ai-queue-force {
@@ -793,6 +848,8 @@ async function initContextEventListeners(): Promise<void> {
font-size: 12px; font-size: 12px;
} }
.ai-queue-skill { color: var(--df-accent); font-weight: 600; flex-shrink: 0; } .ai-queue-skill { color: var(--df-accent); font-weight: 600; flex-shrink: 0; }
/* V7 排队时钟:每项已等待时长 */
.ai-queue-wait { color: var(--df-text-dim); font-size: 10px; flex-shrink: 0; opacity: 0.7; }
.ai-queue-text { .ai-queue-text {
color: var(--df-text); color: var(--df-text);
flex: 1; flex: 1;
@@ -802,7 +859,7 @@ async function initContextEventListeners(): Promise<void> {
} }
.ai-queue-x { background: none; border: none; color: var(--df-text-dim); cursor: pointer; font-size: 14px; line-height: 1; padding: 0 2px; flex-shrink: 0; } .ai-queue-x { background: none; border: none; color: var(--df-text-dim); cursor: pointer; font-size: 14px; line-height: 1; padding: 0 2px; flex-shrink: 0; }
.ai-queue-x:hover { color: var(--df-danger); } .ai-queue-x:hover { color: var(--df-danger); }
/* UX-260616-06 队列项编辑/立即发送按钮 + 编辑态 input */ /* 队列项编辑/立即发送按钮 + 编辑态 input */
.ai-queue-btn { .ai-queue-btn {
background: none; border: none; color: var(--df-text-dim); cursor: pointer; background: none; border: none; color: var(--df-text-dim); cursor: pointer;
font-size: 11px; line-height: 1; padding: 0 4px; flex-shrink: 0; font-size: 11px; line-height: 1; padding: 0 4px; flex-shrink: 0;
+1 -1
View File
@@ -136,7 +136,7 @@
@result="answerConfirm" @result="answerConfirm"
/> />
<!-- UX-260617-14:审批 loading 超时兜底 toast(零依赖 reactive,复用 AiChat 同款样式) --> <!-- 审批 loading 超时兜底 toast(零依赖 reactive,复用 AiChat 同款样式) -->
<Transition name="ai-tool-toast"> <Transition name="ai-tool-toast">
<div v-if="approveToast.visible" class="ai-tool-toast ai-tool-toast--warning">{{ approveToast.msg }}</div> <div v-if="approveToast.visible" class="ai-tool-toast ai-tool-toast--warning">{{ approveToast.msg }}</div>
</Transition> </Transition>
+35 -7
View File
@@ -31,7 +31,7 @@
@expand-content="toggleExpand" @expand-content="toggleExpand"
@approve="(e) => emit('approve', e)" @approve="(e) => emit('approve', e)"
@install-obscura="() => emit('install-obscura')" @install-obscura="() => emit('install-obscura')"
:class="{ 'ai-tool-card--group-hidden': group.calls.length >= 2 && isGroupCollapsed(gi) }" :class="{ 'ai-tool-card--group-hidden': isGroupHidden(tc, group, gi) }"
/> />
</template> </template>
</div> </div>
@@ -123,19 +123,36 @@ const pendingCount = computed(() =>
* 响应式而非一次性初始化——流式场景第一张卡到达即触发旧一次性逻辑, * 响应式而非一次性初始化——流式场景第一张卡到达即触发旧一次性逻辑,
* 此后组从 1 卡长到 N 卡不再折叠,批量读取的 N 张单行卡全展开刷屏; * 此后组从 1 卡长到 N 卡不再折叠,批量读取的 N 张单行卡全展开刷屏;
* 签名(组名:卡数)变化即幂等补齐,新达到阈值的组收起,已 touched 的组不动。 * 签名(组名:卡数)变化即幂等补齐,新达到阈值的组收起,已 touched 的组不动。
*
* 活组豁免:含 running/pending_approval 卡或用户展开过卡的组(groupHasLiveCard)
* 不自动收;已被收的活组自动复活展开。治"执行轮整组(读取文件/搜索内容)被收只剩组头,
* running 卡强制展开也被组 hidden 压住,用户想看进度要点两层"。
*/ */
watch( watch(
() => groupedToolCalls.value.map(g => `${g.name}:${g.calls.length}`).join('|'), () => groupedToolCalls.value.map(g => `${g.name}:${g.calls.length}`).join('|'),
() => { () => {
const next = new Set(collapsedGroups.value) const next = new Set(collapsedGroups.value)
groupedToolCalls.value.forEach((g, gi) => { groupedToolCalls.value.forEach((g, gi) => {
if (g.calls.length >= 2 && !userTouchedGroups.value.has(g.name)) next.add(gi) if (g.calls.length < 2 || userTouchedGroups.value.has(g.name)) return
if (groupHasLiveCard(g)) next.delete(gi)
else next.add(gi)
}) })
collapsedGroups.value = next collapsedGroups.value = next
}, },
{ immediate: true } { immediate: true }
) )
/** 组内是否存在"活着"的卡:running/pending_approval 进行中 或 用户主动展开过(userExpandedCards)。
* 自动收起补齐对这类组跳过(不误收用户正在看的/正在执行的),
* 且补齐时自动展开复活——治"组从 1 卡长到 2 卡瞬间整组被收、用户点开也被压回去"。 */
function groupHasLiveCard(g: ToolCallGroup): boolean {
return g.calls.some(tc =>
tc.status === 'running'
|| tc.status === 'pending_approval'
|| userExpandedCards.value.has(tc.id)
)
}
function isGroupCollapsed(gi: number): boolean { function isGroupCollapsed(gi: number): boolean {
return collapsedGroups.value.has(gi) return collapsedGroups.value.has(gi)
} }
@@ -180,7 +197,9 @@ function groupDisplayName(group: ToolCallGroup): string {
/** /**
* 单卡是否展开: * 单卡是否展开:
* - 分组已折叠 → false(整组隐藏) * - 分组已折叠 → false(整组隐藏)。**例外**: 用户主动展开过的卡(userExpandedCards)
* 不被组折叠压制()——组收起只隐藏"无用户记忆"的卡,
* 用户点开过的卡保持展开,否则"点开又被组补齐自动压回去"死循环。
* - 未手动操作过 → 默认折叠(U-260629):completed 卡一律单行 header(工具名+目标+结果摘要), * - 未手动操作过 → 默认折叠(U-260629):completed 卡一律单行 header(工具名+目标+结果摘要),
* 杜绝"AI 一轮多工具各铺开一整卡"刷屏;点 header 才展开。 * 杜绝"AI 一轮多工具各铺开一整卡"刷屏;点 header 才展开。
* running/pending/rejected/write_file 不受此值影响——ToolCard 内部 * running/pending/rejected/write_file 不受此值影响——ToolCard 内部
@@ -189,17 +208,26 @@ function groupDisplayName(group: ToolCallGroup): string {
* 清了 expandedCards,记忆态仍保持展开(用户读过的卡不强制折叠)。 * 清了 expandedCards,记忆态仍保持展开(用户读过的卡不强制折叠)。
*/ */
function isCardExpanded(tc: AiToolCallInfo, gi: number): boolean { function isCardExpanded(tc: AiToolCallInfo, gi: number): boolean {
if (isGroupCollapsed(gi)) return false // 用户主动折叠最优先(覆盖一切,含组折叠与展开记忆)
// 用户主动折叠最优先(覆盖用户展开记忆)
if (userCollapsedCards.value.has(tc.id)) return false if (userCollapsedCards.value.has(tc.id)) return false
// 用户主动展开的卡片记忆态优先(不被自动收起影响) // 用户主动展开记忆次优先:压过组折叠(),点开过的卡组收起时仍展开
if (userExpandedCards.value.has(tc.id)) return true if (userExpandedCards.value.has(tc.id)) return true
// 组折叠(只影响无用户记忆的卡)
if (isGroupCollapsed(gi)) return false
// 用户手动切换过该卡(当前态) // 用户手动切换过该卡(当前态)
if (expandedCards.value.has(tc.id)) return true if (expandedCards.value.has(tc.id)) return true
// 兜底:默认折叠(单行 header 已有 toolDisplayName 路径/pattern/host + toolResultSummary 摘要) // 兜底:默认折叠(单行 header 已有 toolDisplayName 路径/pattern/host + toolResultSummary 摘要)
return false return false
} }
/** 组收起时卡片是否整卡隐藏(display:none)。
* 用户主动展开过的卡豁免——组收起只隐藏无用户记忆的卡,
* 否则模板 class 的 display:none 会把 isCardExpanded 已放行的展开卡重新藏掉。 */
function isGroupHidden(tc: AiToolCallInfo, group: ToolCallGroup, gi: number): boolean {
if (group.calls.length < 2 || !isGroupCollapsed(gi)) return false
return !userExpandedCards.value.has(tc.id)
}
function toggleCardExpand(tc: AiToolCallInfo, gi: number) { function toggleCardExpand(tc: AiToolCallInfo, gi: number) {
const id = tc.id const id = tc.id
// 用 isCardExpanded 判当前真实态(含用户操作记忆),而非 expandedCards(记忆态不在其中, // 用 isCardExpanded 判当前真实态(含用户操作记忆),而非 expandedCards(记忆态不在其中,
@@ -399,7 +427,7 @@ function groupIcon(name: string): string {
} }
/* -- 分组隐藏态的卡片。 /* -- 分组隐藏态的卡片。
UX-260616-03: 保持 display:none(原行为)。 保持 display:none(原行为)。
不改 max-height/opacity 动画的原因:flex 容器 gap:8px 对零高 item 仍贡献 gap, 不改 max-height/opacity 动画的原因:flex 容器 gap:8px 对零高 item 仍贡献 gap,
会留双倍间距致布局回归;display:none 移出流才无 gap。 会留双倍间距致布局回归;display:none 移出流才无 gap。
真正的高度动画需 <Transition> JS 钩子(高度 auto→0 不可纯 CSS transition), 真正的高度动画需 <Transition> JS 钩子(高度 auto→0 不可纯 CSS transition),
+2 -2
View File
@@ -121,7 +121,7 @@
</div> </div>
<!-- patch_file 结果:文件路径/字节增减 + unified diff 红删绿增行级预览(复用 ai-tool-diff-pre/--add/--del/--ctx 既有样式) <!-- patch_file 结果:文件路径/字节增减 + unified diff 红删绿增行级预览(复用 ai-tool-diff-pre/--add/--del/--ctx 既有样式)
UX-260618-06:后端 generate_diff 产完整 diff,result.diff 字段此前被丢弃,用户无法验证改了什么 → 此分支渲染 diff --> 后端 generate_diff 产完整 diff,result.diff 字段此前被丢弃,用户无法验证改了什么 → 此分支渲染 diff -->
<div v-else-if="tc.name === 'patch_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-patch-result"> <div v-else-if="tc.name === 'patch_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-patch-result">
<div class="ai-tool-file-bar"> <div class="ai-tool-file-bar">
<span class="ai-tool-file-icon" v-html="fileIcon"></span> <span class="ai-tool-file-icon" v-html="fileIcon"></span>
@@ -153,7 +153,7 @@
<pre v-if="cmdOutputExpanded && cmdOutput" class="ai-tool-cmd-output"><code>{{ cmdOutput }}</code></pre> <pre v-if="cmdOutputExpanded && cmdOutput" class="ai-tool-cmd-output"><code>{{ cmdOutput }}</code></pre>
</div> </div>
<!-- http_request 结果:method + url + 状态码徽标 + 可折叠响应 body(对齐 run_command 模式 UX-260618-08) --> <!-- http_request 结果:method + url + 状态码徽标 + 可折叠响应 body(对齐 run_command 模式 ) -->
<div <div
v-else-if="tc.name === 'http_request' && tc.result && tc.status === 'completed' && parsed" v-else-if="tc.name === 'http_request' && tc.result && tc.status === 'completed' && parsed"
class="ai-tool-http-result" class="ai-tool-http-result"
+75 -3
View File
@@ -116,6 +116,7 @@ import { useAiStore } from '../../stores/ai'
import { useProjectStore } from '../../stores/project' import { useProjectStore } from '../../stores/project'
import { getConvState } from '../../composables/ai/useAiEvents' import { getConvState } from '../../composables/ai/useAiEvents'
import { textIdle } from '../../composables/ai/useAiPendingState' import { textIdle } from '../../composables/ai/useAiPendingState'
import { getConvDraft, setConvDraft, classifySendError, type SendErrorKind } from '../../composables/ai/aiShared'
import { extractImageUrlParts } from '../../composables/ai/utils' import { extractImageUrlParts } from '../../composables/ai/utils'
import { uploadFile } from '../../composables/ai/fileUpload' import { uploadFile } from '../../composables/ai/fileUpload'
import { open } from '@tauri-apps/plugin-dialog' import { open } from '@tauri-apps/plugin-dialog'
@@ -292,7 +293,12 @@ const stopBtnState = computed<'idle' | 'stop' | 'stopping' | 'retry'>(() => {
const convId = store.state.activeConversationId const convId = store.state.activeConversationId
const cs = getConvState(convId) const cs = getConvState(convId)
if (cs === 'stopping') return 'stopping' if (cs === 'stopping') return 'stopping'
if (cs === 'error') return 'retry' // P1-3: conv_state='error' 是 AiError 收尾落的终态(useAiLifecycleEvents cleanup 先
// setStreaming(false) 再置 error),此时本会话 loop 已死——「重试停止」按钮点 stopChat 对死
// loop 无意义,却顶掉发送位,出错后鼠标用户点不了发送(Enter 不看按钮态仍可发,语义错位)。
// 仅流式仍在跑(停止失败等 loop 未断的 error)时保留 retry 语义,否则回发送按钮,
// 与 Enter/handleSend 对齐。
if (cs === 'error') return store.state.streaming ? 'retry' : 'idle'
const textActive = store.state.streaming && (cs === 'generating' || !textIdle.value) const textActive = store.state.streaming && (cs === 'generating' || !textIdle.value)
return textActive ? 'stop' : 'idle' return textActive ? 'stop' : 'idle'
}) })
@@ -676,21 +682,29 @@ function selectMention(item: MentionItem) {
} }
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
// P1-1 输入法组词守卫:拼音/双拼组词未上屏时 Enter=选词、Esc=取消组词,均非用户发送/取消意图,
// 直接放行给 IME。isComposing 覆盖标准浏览器;keyCode===229 兜部分环境 keydown 阶段不上报 isComposing。
if (e.isComposing || e.keyCode === 229) return
// 各 Esc 消费分支 stopPropagation:P1-2 双层防,分离窗口 AiChat 的 window 级 Esc 会关整个窗口,
// 内层已消费的 Esc 不再外抛(主防线是 AiChat 侧的 e.target 输入控件守卫,此处为辅)
// 编辑态 Escape 取消编辑(最高优先级) // 编辑态 Escape 取消编辑(最高优先级)
if (props.editingMsgId && e.key === 'Escape') { if (props.editingMsgId && e.key === 'Escape') {
e.preventDefault() e.preventDefault()
e.stopPropagation()
emit('cancel-edit') emit('cancel-edit')
return return
} }
// Escape 关闭技能联想浮层(无论是否有匹配项) // Escape 关闭技能联想浮层(无论是否有匹配项)
if (skillOpen.value && e.key === 'Escape') { if (skillOpen.value && e.key === 'Escape') {
e.preventDefault() e.preventDefault()
e.stopPropagation()
skillOpen.value = false skillOpen.value = false
return return
} }
// 已选技能 chip 态下 Escape 取消选中(浮层已关闭,这里兜底 chip 可取消路径) // 已选技能 chip 态下 Escape 取消选中(浮层已关闭,这里兜底 chip 可取消路径)
if (pendingSkill.value && e.key === 'Escape') { if (pendingSkill.value && e.key === 'Escape') {
e.preventDefault() e.preventDefault()
e.stopPropagation()
pendingSkill.value = null pendingSkill.value = null
return return
} }
@@ -713,6 +727,7 @@ function handleKeydown(e: KeyboardEvent) {
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault() e.preventDefault()
e.stopPropagation()
skillOpen.value = false skillOpen.value = false
return return
} }
@@ -736,6 +751,7 @@ function handleKeydown(e: KeyboardEvent) {
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault() e.preventDefault()
e.stopPropagation()
mentionOpen.value = false mentionOpen.value = false
mentionStart.value = -1 mentionStart.value = -1
return return
@@ -859,9 +875,10 @@ async function handleSend() {
if (snapshotSpans.length) { if (snapshotSpans.length) {
pendingMentionSpans.value = snapshotSpans pendingMentionSpans.value = snapshotSpans
} }
// 用户可见提示(原仅 console.error,用户无感);Tauri IPC 错误是字符串非 Error 对象 // 用户可见提示(原仅 console.error,用户无感);Tauri IPC 错误是字符串非 Error 对象。
// I5:按错误内容分类映射友好文案,原始错误串留在 {msg} 细节里不丢排查线索
const msg = e instanceof Error ? e.message : String(e) const msg = e instanceof Error ? e.message : String(e)
emit('error', { msg: t('aiChat.toastSendFail', { msg }), type: 'error' }) emit('error', { msg: t(SEND_ERR_KEY[classifySendError(msg)], { msg }), type: 'error' })
} }
await nextTick() await nextTick()
emit('sent') emit('sent')
@@ -902,11 +919,65 @@ function clearInput(): void {
if (inputEl.value) inputEl.value.style.height = 'auto' if (inputEl.value) inputEl.value.style.height = 'auto'
} }
/** I4 队列回填:父把取出的队列项文本填回输入框重新编辑。
* 可选 skill:队列项携带技能名时由父按名找回 SkillInfo 回挂 chip;找回失败传 null 仅丢
* chip(与队列原地编辑只保 text 同一取舍)。清 mention 区间/浮层态,光标置尾并聚焦。 */
function fillDraft(text: string, skill: SkillInfo | null = null): void {
pendingSkill.value = skill
inputText.value = text
pendingMentionSpans.value = []
mentionOpen.value = false
mentionStart.value = -1
nextTick(() => {
inputEl.value?.focus()
const el = inputEl.value
if (el) {
const len = el.value.length
el.setSelectionRange(len, len)
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
}
})
}
/** 聚焦输入框 */ /** 聚焦输入框 */
function focus(): void { function focus(): void {
inputEl.value?.focus() inputEl.value?.focus()
} }
// ── V8 per-conv 草稿:切会话保存/恢复,防 A 会话未发送草稿串到 B 输入框 ──
// 草稿真相源在 aiShared.convDrafts(内存 Map,不落 localStorage)。父组件 AiChat 的
// activeConversationId watcher 注册更早先执行:编辑态切换时父先 cancelEdit 清输入,
// 本 watcher 后执行,旧会话保存到的是清空后的空串,与既有「切会话丢弃编辑缓冲」语义一致。
watch(() => store.state.activeConversationId, (nv, ov) => {
if (ov) setConvDraft(ov, inputText.value)
inputText.value = getConvDraft(nv)
// 随输入框的临时态不跨会话携带:mention 区间偏移只对旧文本有效,照搬到恢复的文本会渲染错位
// chip;技能 chip 与待发图片同理,残留到新会话会静默混入下一条消息
pendingMentionSpans.value = []
mentionOpen.value = false
mentionStart.value = -1
pendingSkill.value = null
pendingImages.value = []
skillOpen.value = false
// 恢复文本会触发输入框自身 watch 重算联想(文本以 @ 结尾时按旧光标位误拉起浮层),
// nextTick 在该 watch 之后统一收口,并按恢复文本重算输入框高度
nextTick(() => {
mentionOpen.value = false
mentionStart.value = -1
autoResize()
})
})
// ── I5 发送失败错误分类文案:类别到 i18n 键的映射(分类逻辑在 aiShared.classifySendError)。
// other 复用既有 toastSendFail 文案,行为与改造前一致 ──
const SEND_ERR_KEY: Record<SendErrorKind, string> = {
rate_limit: 'aiChat.sendErrRateLimit',
quota: 'aiChat.sendErrQuota',
network: 'aiChat.sendErrNetwork',
other: 'aiChat.toastSendFail',
}
/** /**
* 未发送输入保护。检测 inputText/pendingImages/pendingSkill 任一非空, * 未发送输入保护。检测 inputText/pendingImages/pendingSkill 任一非空,
* 用于 beforeunload 拦截(分离窗口关闭/刷新)与未来 onBeforeUnmount 拦截(面板切换)。 * 用于 beforeunload 拦截(分离窗口关闭/刷新)与未来 onBeforeUnmount 拦截(面板切换)。
@@ -923,6 +994,7 @@ defineExpose({
focus, focus,
startEditFocus, startEditFocus,
clearInput, clearInput,
fillDraft,
hasUnsentInput, hasUnsentInput,
sendExamplePrompt, sendExamplePrompt,
}) })
+96 -7
View File
@@ -40,6 +40,7 @@
class="ai-conv-item" class="ai-conv-item"
:class="{ :class="{
'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--active': conv.id === store.state.activeConversationId,
'ai-conv-item--switching': conv.id === pendingSwitchId,
'ai-conv-item--archived': conv.archived, 'ai-conv-item--archived': conv.archived,
'ai-conv-item--pinned': conv.pinned, 'ai-conv-item--pinned': conv.pinned,
}" }"
@@ -49,6 +50,8 @@
<span class="ai-conv-item-title" :title="conv.title || ''"> <span class="ai-conv-item-title" :title="conv.title || ''">
{{ conv.title || t('aiChat.newConversation') }} {{ conv.title || t('aiChat.newConversation') }}
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span> <span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
</span> </span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span> <span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
</div> </div>
@@ -102,8 +105,8 @@
:key="conv.id" :key="conv.id"
:ref="(el) => setItemEl(conv.id, el)" :ref="(el) => setItemEl(conv.id, el)"
class="ai-conv-item" class="ai-conv-item"
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--pinned': conv.pinned }" :class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }"
@click="store.switchConversation(conv.id)" @click="beginSwitch(conv.id)"
> >
<div class="ai-conv-item-main"> <div class="ai-conv-item-main">
<input <input
@@ -111,8 +114,7 @@
:ref="setEditEl" :ref="setEditEl"
v-model="editText" v-model="editText"
class="ai-conv-item-edit" class="ai-conv-item-edit"
@keyup.enter="commitRename(conv.id)" @keydown="onRenameKeydown($event, conv.id)"
@keyup.esc="cancelRename"
@blur="commitRename(conv.id)" @blur="commitRename(conv.id)"
@click.stop @click.stop
/> />
@@ -132,6 +134,8 @@
><polyline points="20 6 9 17 4 12"/></svg> ><polyline points="20 6 9 17 4 12"/></svg>
<!-- F-09: 多会话并发生成指示器(后台/当前会话生成中均显脉冲点,多会话可同时显示) --> <!-- F-09: 多会话并发生成指示器(后台/当前会话生成中均显脉冲点,多会话可同时显示) -->
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span> <span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
</span> </span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span> <span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
</div> </div>
@@ -171,13 +175,15 @@
:key="'a-' + conv.id" :key="'a-' + conv.id"
:ref="(el) => setItemEl(conv.id, el)" :ref="(el) => setItemEl(conv.id, el)"
class="ai-conv-item ai-conv-item--archived" class="ai-conv-item ai-conv-item--archived"
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--pinned': conv.pinned }" :class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }"
@click="store.switchConversation(conv.id)" @click="beginSwitch(conv.id)"
> >
<div class="ai-conv-item-main"> <div class="ai-conv-item-main">
<span class="ai-conv-item-title" :title="conv.title || ''"> <span class="ai-conv-item-title" :title="conv.title || ''">
{{ conv.title || t('aiChat.newConversation') }} {{ conv.title || t('aiChat.newConversation') }}
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span> <span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
</span> </span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span> <span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
</div> </div>
@@ -235,6 +241,8 @@
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount, type ComponentPublicInstance } from 'vue' import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount, type ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai' import { useAiStore } from '../../stores/ai'
// V1 只读消费:跨会话待审批真相源(state.pendingApprovals 仅是活跃会话投影,后台会话挂起不进投影)
import { convPendingApprovals } from '../../composables/ai/aiShared'
import { formatRelative } from '../../utils/time' import { formatRelative } from '../../utils/time'
import type { AiConversationSummary } from '../../api/types' import type { AiConversationSummary } from '../../api/types'
@@ -302,6 +310,20 @@ function cancelRename() {
editingConvId.value = null editingConvId.value = null
} }
/** P1-1 重命名框键盘处理:输入法组词中(isComposing/229)的 Enter/Esc 属 IME 选词/取消组词,
* 不当重命名提交/取消处理,防标题被半截拼音覆盖。原 keyup.enter/.esc 改 keydown 统一挂守卫:
* 组词上屏的回车到 keyup 阶段 isComposing 已复位守不住,keydown 才拦得到。 */
function onRenameKeydown(e: KeyboardEvent, convId: string) {
if (e.isComposing || e.keyCode === 229) return
if (e.key === 'Enter') {
e.preventDefault()
commitRename(convId)
} else if (e.key === 'Escape') {
e.preventDefault()
cancelRename()
}
}
// ── 会话项「更多」操作菜单(置顶/归档/删除)── // ── 会话项「更多」操作菜单(置顶/归档/删除)──
const actionsOpenId = ref<string | null>(null) const actionsOpenId = ref<string | null>(null)
@@ -321,13 +343,31 @@ function onActionsOutsideClick(e: MouseEvent) {
actionsOpenId.value = null actionsOpenId.value = null
} }
// ── 会话切换过渡态(UX走查 2026-10-03 P1):点击即预高亮目标项(乐观),IPC 返回后随
// activeConversationId 落定接管高亮;失败/过期时 finally 清 pending,高亮自动回滚到原活跃项。
// 治:大历史会话 IPC 数百 ms 期间点击项不高亮、原高亮不动、无任何进行时反馈,快速连点像失灵。
const pendingSwitchId = ref<string | null>(null)
function beginSwitch(id: string) {
// 已活跃会话:switchConversation 内部同判短路,无过渡期,不挂 pending
if (id === store.state.activeConversationId) {
void store.switchConversation(id)
return
}
pendingSwitchId.value = id
void store.switchConversation(id).finally(() => {
// 仅当 pending 仍是本次点击的 id 才清:快速连点 B 覆盖 A 后,A 的迟到返回不得清掉 B 的过渡态
if (pendingSwitchId.value === id) pendingSwitchId.value = null
})
}
/** /**
* 搜索结果点击切对话后清空 searchQuery,退出搜索模式回原时间分组视图。 * 搜索结果点击切对话后清空 searchQuery,退出搜索模式回原时间分组视图。
* M3:命中项可能在折叠的时间分组或折叠的归档分组内,选中态不可见。切回分组视图后展开所在分组 * M3:命中项可能在折叠的时间分组或折叠的归档分组内,选中态不可见。切回分组视图后展开所在分组
* 并 scrollIntoView,确保选中项立即可见(原仅切 searchQuery,选中项落在折叠组里看不到高亮)。 * 并 scrollIntoView,确保选中项立即可见(原仅切 searchQuery,选中项落在折叠组里看不到高亮)。
*/ */
function onSelectSearchResult(id: string) { function onSelectSearchResult(id: string) {
void store.switchConversation(id) // 走 beginSwitch 挂切换过渡态(乐观预高亮),与分组视图点击口径一致
beginSwitch(id)
store.state.searchQuery = '' store.state.searchQuery = ''
// nextTick 后视图已切回分组态;ensureVisible 展开所在组 + 滚动定位 // nextTick 后视图已切回分组态;ensureVisible 展开所在组 + 滚动定位
void nextTick(() => ensureActiveVisible()) void nextTick(() => ensureActiveVisible())
@@ -370,6 +410,30 @@ function ensureActiveVisible(): void {
}) })
} }
// ── V1: 跨会话待审批徽标 ──
// convPendingApprovals Map 本体非响应式(aiShared 注明渲染只依赖投影数组),故 computed 读 Map 的
// 同时锚定三类响应式源触发重算:
// - state.pendingApprovals(活跃会话投影,审批增删/切换恢复整体替换时必变)→ 活跃会话即时刷新;
// - conversations 列表(新建/删除/排序刷新);
// - pendingTick 2.5s 轮询兜底:后台会话新增/解除挂起不触碰任何响应式源(aiShared 只写裸 Map),
// 轻量周期重算补偿(徽标属弱实时信息,秒级延迟可接受)。
const pendingTick = ref(0)
let pendingPollTimer: ReturnType<typeof setInterval> | null = null
const pendingCountByConv = computed(() => {
void store.state.pendingApprovals.length
void store.state.conversations.length
void pendingTick.value
const out = new Map<string, number>()
for (const [convId, arr] of convPendingApprovals) {
if (arr.length > 0) out.set(convId, arr.length)
}
return out
})
function pendingCountOf(convId: string): number {
return pendingCountByConv.value.get(convId) ?? 0
}
// ── 对话分组:今天 / 昨天 / 更早 ── // ── 对话分组:今天 / 昨天 / 更早 ──
const bucketLabels = computed<Record<string, string>>(() => ({ const bucketLabels = computed<Record<string, string>>(() => ({
today: t('aiChat.today'), today: t('aiChat.today'),
@@ -447,10 +511,14 @@ function onResizeStart(e: MouseEvent) {
onMounted(() => { onMounted(() => {
// 点击外部关闭更多操作菜单 // 点击外部关闭更多操作菜单
document.addEventListener('click', onActionsOutsideClick) document.addEventListener('click', onActionsOutsideClick)
// V1:后台会话待审批变化兜底轮询(见 pendingCountByConv 注)
pendingPollTimer = setInterval(() => { pendingTick.value++ }, 2500)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('click', onActionsOutsideClick) document.removeEventListener('click', onActionsOutsideClick)
// 释放待审批徽标轮询计时器
if (pendingPollTimer) { clearInterval(pendingPollTimer); pendingPollTimer = null }
// 拖拽中途卸载兜底摘临时 document listener(原 AiChat 同段迁移) // 拖拽中途卸载兜底摘临时 document listener(原 AiChat 同段迁移)
document.removeEventListener('mousemove', onResizeMove) document.removeEventListener('mousemove', onResizeMove)
document.removeEventListener('mouseup', onResizeEnd) document.removeEventListener('mouseup', onResizeEnd)
@@ -612,6 +680,12 @@ body.ai-sidebar-resizing * {
.ai-conv-item--active { .ai-conv-item--active {
background: var(--df-sidebar-active); background: var(--df-sidebar-active);
} }
/* 切换进行时(UX走查 P1):与活跃项同底色示"已选中"(乐观预高亮),降不透明度示"加载中";
IPC 返回后 activeConversationId 落定、本类移除,视觉无缝衔接;失败回滚高亮回原活跃项 */
.ai-conv-item--switching {
background: var(--df-sidebar-active);
opacity: 0.7;
}
.ai-conv-item--archived .ai-conv-item-title { .ai-conv-item--archived .ai-conv-item-title {
opacity: 0.6; opacity: 0.6;
} }
@@ -676,6 +750,21 @@ body.ai-sidebar-resizing * {
0%, 100% { opacity: 0.35; transform: scale(0.85); } 0%, 100% { opacity: 0.35; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1.15); } 50% { opacity: 1; transform: scale(1.15); }
} }
/* V1: 跨会话待审批徽标(橙点计数,warning 色系与待审批语义一致;固定尺寸不挤标题换行) */
.ai-conv-approve-badge {
flex-shrink: 0;
min-width: 12px;
height: 12px;
padding: 0 3px;
border-radius: 6px;
background: var(--df-warning, #f0c75e);
color: #000;
font-size: 9px;
font-weight: 600;
line-height: 12px;
text-align: center;
cursor: help;
}
.ai-conv-item-actions { .ai-conv-item-actions {
display: none; display: none;
align-items: center; align-items: center;
-180
View File
@@ -1,180 +0,0 @@
<template>
<!-- 第五批抽离至 ai/MaxRoundsCard.vue(F-260616-03 达 max_iterations 暂停态操作卡,零行为变更)。
条件:pendingMaxRounds(达 max 事件置)+ 当前视图正在生成(防切走后误显)。
点继续 → ai_continue_loop(后端续 max_iterations 轮)→ 卡片隐藏(等后端事件)。
点停止 → ai_stop_loop(后端走 AiCompleted 收尾)→ 卡片隐藏。
store 单例共享(state/aiApi),pendingMaxRounds 模块级 ref(useAiEvents)直接导入。
toast 经 emit 转父(保持单一 toast 源)。 -->
<div v-if="showMaxRoundsCard" ref="maxRoundsRef" class="ai-max-rounds">
<span class="ai-max-rounds-text">{{ $t('aiChat.maxRoundsReached') }}</span>
<span class="ai-max-rounds-hint">{{ $t('aiChat.maxRoundsHint') }}</span>
<div class="ai-max-rounds-actions">
<button
class="ai-max-rounds-btn ai-max-rounds-btn--continue"
:disabled="maxRoundsActing"
@click="handleContinueLoop"
>{{ $t('aiChat.continueLoop') }}</button>
<button
class="ai-max-rounds-btn ai-max-rounds-btn--stop"
:disabled="maxRoundsActing"
@click="handleStopLoop"
>{{ $t('aiChat.stopLoop') }}</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { getConvState } from '../../composables/ai/useAiEvents'
import { pendingMaxRounds } from '../../composables/ai/useAiPendingState'
import { aiApi } from '../../api'
const emit = defineEmits<{
(e: 'toast', payload: { msg: string; type: 'error' | 'warning' | 'info' }): void
}>()
const store = useAiStore()
const { t } = useI18n()
// F-260620 根治(达 max 挂起无声卡死):同 DirAuthDialog,去 streaming 依赖。
// 达 max(agentic:1186 guard.disarm + AiMaxRoundsReached,generating 保持 true)发生在多轮迭代后,
// 耗时长,130s 流式看门狗极易在迭代间隙超时清 streaming → 卡片因 streaming=false 不弹 → 用户看不到
// "继续/停止" → generating 永真卡死。达 max 比 DirAuthAuth 更易触发(必经多轮)。去 streaming,
// isGenerating(conv) 单条件覆盖挂起态(达 max 时 generatingConvs 含 conv,useAiEvents handleEvent 对非完成/错误事件 add)。
// 多会话并发 — isGenerating(active) 替代单值比对
const isViewingGenerating = computed(() =>
store.isGenerating(store.state.activeConversationId),
)
// L2 状态机(批2 1c):达 max 可靠弹判断改读 conv_state(generating),诊断痛②看门狗误清 streaming
// 致卡片不弹的根解。conv_state 作新增派生源,旧 isGenerating 双轨过渡兜底——conv_state 未收到
// 时(CONV_STATE_ENABLED=off 或老后端)回退 isGenerating 判断,不强制全替防回归。
// 注:达 max 时后端仍 generating(conv_state=generating,审批挂起期亦是 generating),故读
// conv_state===generating 覆盖挂起态;compressed(压缩派生)也视为活跃可弹(达 max 与压缩互斥,
// 理论不并存,此处宽放以防边界)。
const isViewingGeneratingState = computed(() => {
const cs = getConvState(store.state.activeConversationId)
if (cs === 'generating' || cs === 'compressed') return true
if (cs === 'idle' || cs === 'error' || cs === 'stopping') return false
// conv_state 未追踪过(null)→ 回退旧 isGenerating bool 判断
return isViewingGenerating.value
})
// pendingMaxRounds(达 max 事件置的挂起 convId)+ 当前视图正在生成(防切走后误显)双重守卫。
// TD-260621-02 per-conv:pendingMaxRounds 存挂起 convId(string|null),精确比对
// activeConversationId —— 仅当挂起会话 === 当前展示会话时显操作卡,F-09 并发下不会错显于其他会话。
// maxRoundsActing 本地 ref 持按钮 loading:点继续/停止后禁双按钮,等后端事件
// (continueLoop→新一轮 AiAgentRound;stopLoop→AiCompleted)自然清卡片。
const maxRoundsActing = ref(false)
const showMaxRoundsCard = computed(() =>
!!pendingMaxRounds.value
&& pendingMaxRounds.value === store.state.activeConversationId
&& isViewingGeneratingState.value,
)
// M8:卡片根 ref。达 max 挂起卡位于输入区上方,多轮迭代后消息流已很长,卡片常被挤出视口 → 用户看不到
// "继续/停止" → generating 永真卡死。卡片显示时 scrollIntoView 让操作入口立即可见。
const maxRoundsRef = ref<HTMLElement | null>(null)
/** 点继续:调 ai_continue_loop。后端续 max_iterations 轮(iteration 从 0 重计)。
* 不主动清 pendingMaxRounds——由后端新一轮事件(AiAgentRound)与最终 AiCompleted/AiError
* 在 useAiEvents 内清。IPC 失败回滚 acting 让用户可重试。 */
async function handleContinueLoop(): Promise<void> {
const convId = store.state.activeConversationId
if (!convId) return
maxRoundsActing.value = true
try {
await aiApi.continueLoop(convId)
} catch (e) {
maxRoundsActing.value = false
const msg = e instanceof Error ? e.message : String(e)
emit('toast', { msg: t('aiChat.continueLoopFailed', { msg }), type: 'error' })
}
}
/** 点停止:调 ai_stop_loop。后端复位 generating + emit AiCompleted,useAiEvents 据此
* 清 pendingMaxRounds。IPC 失败回滚 acting。 */
async function handleStopLoop(): Promise<void> {
const convId = store.state.activeConversationId
if (!convId) return
maxRoundsActing.value = true
try {
await aiApi.stopLoop(convId)
} catch (e) {
maxRoundsActing.value = false
const msg = e instanceof Error ? e.message : String(e)
emit('toast', { msg: t('aiChat.stopLoopFailed', { msg }), type: 'error' })
}
}
/** pendingMaxRounds 离开暂停态(后端事件已清 → null)或切到非本会话挂起时复位 acting,允许下次再操作 */
watch(() => pendingMaxRounds.value, (v) => {
if (!v) maxRoundsActing.value = false
})
/** M8:卡片显示(从无→有)时 scrollIntoView 让"继续/停止"按钮组立即进入视口。
* 仅监 showMaxRoundsCard 真值翻转,不监 acting 等内部态避免已可见时反复跳。 */
watch(showMaxRoundsCard, async (show) => {
if (!show) return
await nextTick()
maxRoundsRef.value?.scrollIntoView({ block: 'center', behavior: 'smooth' })
})
</script>
<style scoped>
.ai-max-rounds {
display: flex;
flex-direction: column;
gap: 3px;
margin-bottom: 6px;
padding: 6px 8px;
background: color-mix(in srgb, var(--df-warning) 10%, transparent);
border: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent);
border-radius: var(--df-radius);
}
.ai-max-rounds-text {
font-size: 11.5px;
font-weight: 600;
color: var(--df-warning);
}
.ai-max-rounds-hint {
font-size: 10.5px;
color: var(--df-text-dim);
opacity: 0.9;
}
.ai-max-rounds-actions {
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
}
.ai-max-rounds-btn {
border: none;
cursor: pointer;
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: calc(var(--df-radius) - 2px);
transition: filter 0.15s var(--df-ease);
}
.ai-max-rounds-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ai-max-rounds-btn--continue {
background: var(--df-warning);
color: #000;
}
.ai-max-rounds-btn--continue:hover:not(:disabled) { filter: brightness(1.1); }
.ai-max-rounds-btn--stop {
background: transparent;
color: var(--df-text-dim);
border: 0.5px solid var(--df-border);
}
.ai-max-rounds-btn--stop:hover:not(:disabled) {
color: var(--df-text);
border-color: var(--df-text-dim);
}
</style>
+239 -20
View File
@@ -22,6 +22,7 @@ import { useMessageScroll } from '../../composables/ai/useMessageScroll'
import { useStreamRenderer } from '../../composables/ai/useStreamRenderer' import { useStreamRenderer } from '../../composables/ai/useStreamRenderer'
import { loadMoreHistory, getLoadMoreState } from '../../composables/ai/useAiConversations' import { loadMoreHistory, getLoadMoreState } from '../../composables/ai/useAiConversations'
import { collectTurnStats, turnSummaryText, sumTurnTokens, formatTokensK, type TurnCallStats } from '../../composables/ai/useTurnTrace' import { collectTurnStats, turnSummaryText, sumTurnTokens, formatTokensK, type TurnCallStats } from '../../composables/ai/useTurnTrace'
import { getConvAgentFinished, type AgentFinishedCard } from '../../composables/ai/aiShared'
import ToolCardList from '../ToolCardList.vue' import ToolCardList from '../ToolCardList.vue'
import MessageItem from './MessageItem.vue' import MessageItem from './MessageItem.vue'
import EmptyState from './EmptyState.vue' import EmptyState from './EmptyState.vue'
@@ -279,7 +280,7 @@ watch(() => store.state.streaming, (s) => {
// 收起旧的已完成/已拒绝卡片,保留 running/pending/write_file 活跃卡片。 // 收起旧的已完成/已拒绝卡片,保留 running/pending/write_file 活跃卡片。
// 折叠态由 ToolCardList 自治,父级仅经 expose 调 collapseInactive ── // 折叠态由 ToolCardList 自治,父级仅经 expose 调 collapseInactive ──
// //
// B-260616-13 性能:快照仅含 toolCalls 的 (id,status)+消息条数,剔除 content.length。 // 性能:快照仅含 toolCalls 的 (id,status)+消息条数,剔除 content.length。
// 收起判定只依赖 active Set(由 toolCalls 状态推导),与文本长度无关。 // 收起判定只依赖 active Set(由 toolCalls 状态推导),与文本长度无关。
// 原快照含 len → 流式每 token 增长末条消息 content → snap 变 → body 每帧重算 // 原快照含 len → 流式每 token 增长末条消息 content → snap 变 → body 每帧重算
// (deep watch 检测仍每帧跑,但 body 跳过)。剔除 len 后流式 delta 不再触发收起重算, // (deep watch 检测仍每帧跑,但 body 跳过)。剔除 len 后流式 delta 不再触发收起重算,
@@ -540,16 +541,18 @@ async function copyMsgContent(msg: AiMessage): Promise<void> {
// ── 消息气泡复制为图片(html2canvas 渲染 PNG 写入剪贴板) ── // ── 消息气泡复制为图片(html2canvas 渲染 PNG 写入剪贴板) ──
// 原生 ClipboardItem 即可写图片到剪贴板,故不加 Tauri 剪贴板插件(少装依赖) // 原生 ClipboardItem 即可写图片到剪贴板,故不加 Tauri 剪贴板插件(少装依赖)
// 用 html2canvas-pro 替代 html2canvas:后者不支持 color-mix/oklch 现代色彩函数,渲染气泡样式会抛错
async function copyMsgAsImage(msg: AiMessage): Promise<void> { async function copyMsgAsImage(msg: AiMessage): Promise<void> {
// msg.id 经 CSS.escape 转义:含特殊字符的 id 在属性选择器里会匹配失败(引号内转义合法)
const bubble = messagesContainer.value?.querySelector<HTMLElement>( const bubble = messagesContainer.value?.querySelector<HTMLElement>(
`.ai-msg[data-msg-id="${msg.id}"] .ai-msg-bubble`, `.ai-msg[data-msg-id="${CSS.escape(msg.id)}"] .ai-msg-bubble`,
) )
if (!bubble) { if (!bubble) {
emit('toast', { msg: t('aiChat.copyAsImageFailed'), type: 'error' }) emit('toast', { msg: t('aiChat.copyAsImageFailed'), type: 'error' })
return return
} }
try { try {
const { default: html2canvas } = await import('html2canvas') const { default: html2canvas } = await import('html2canvas-pro')
const canvas = await html2canvas(bubble, { backgroundColor: null, useCORS: true, scale: 2 }) const canvas = await html2canvas(bubble, { backgroundColor: null, useCORS: true, scale: 2 })
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png')) const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'))
if (!blob) throw new Error('toBlob failed') if (!blob) throw new Error('toBlob failed')
@@ -574,6 +577,72 @@ async function copyMsgAsImage(msg: AiMessage): Promise<void> {
} }
} }
// ── R4 代码块复制:渲染层 html 装饰 + 容器级事件委托 ──
// 代码块经 marked 渲染后以 v-html 注入,模板层无法逐块插按钮;改为在 html 字符串层把
// <pre><code> 包进 .md-code-block 并注入复制按钮(发生在 renderMd 的 DOMPurify 消毒之后,
// 注入节点不经消毒通道,仅静态结构无用户内容,安全)。点击走容器级事件委托(单监听器,
// 不逐块绑定)。结果 memo 缓存:v-html 按字符串相等决定是否重写 DOM,不缓存会导致组件每次
// 重渲染都重设 innerHTML,打破 AC-EFF-S2-1「已完成块 DOM 稳定」优化。
const _decoCache = new Map<string, string>()
const DECO_CACHE_LIMIT = 800
function decorateCodeHtml(raw: string): string {
if (!raw || !raw.includes('<pre><code')) return raw
const hit = _decoCache.get(raw)
if (hit !== undefined) return hit
// marked 的 code renderer 统一输出 <pre><code class="hljs ...">…</code></pre>
//(含缩进码块),代码内容已 HTML 转义,正则非贪婪到首个闭合标签不会越界
const out = raw.replace(
/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g,
'<div class="md-code-block"><button type="button" class="md-code-copy">复制</button><pre><code$1>$2</code></pre></div>',
)
if (_decoCache.size > DECO_CACHE_LIMIT) _decoCache.clear()
_decoCache.set(raw, out)
return out
}
/** renderMd 包装:渲染 + 代码块装饰(双层 memo:renderMd 的 _mdCache + 本缓存,未变即零成本) */
function renderMdDecorated(raw: string): string {
return decorateCodeHtml(renderMd(raw))
}
// 流式分块同走装饰(computed 缓存:块 html 未变时返回同串,v-html 不触发 DOM 重写)
const decoratedStreamingBlocks = computed(() =>
streamingBlocks.value.map(b => ({ key: b.key, html: decorateCodeHtml(b.html) })),
)
/** 容器级事件委托:点击 .md-code-copy 复制所属代码块原文(pre.textContent 自动还原 HTML 转义) */
async function onMdCodeCopyClick(e: MouseEvent): Promise<void> {
const btn = (e.target as HTMLElement | null)?.closest('button.md-code-copy') as HTMLButtonElement | null
if (!btn) return
const code = btn.parentElement?.querySelector('pre')?.textContent ?? ''
// 必须在 click 同步链内发起 clipboard 调用(浏览器安全限制);非 secure context 走
// execCommand 兜底(对齐 copyMsgContent 降级链),两者均失败如实反馈
let ok = false
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(code)
ok = true
}
} catch { /* 权限拒绝/中断,落入 execCommand 兜底 */ }
if (!ok) {
const ta = document.createElement('textarea')
ta.value = code
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
try { ok = document.execCommand('copy') } catch { ok = false }
document.body.removeChild(ta)
}
// 成功态短暂反馈(直接改按钮 DOM;下次 v-html 重渲染自然还原,无需状态机)
const prev = btn.textContent
btn.textContent = ok ? '已复制' : '复制失败'
btn.classList.add(ok ? 'is-ok' : 'is-err')
setTimeout(() => {
btn.textContent = prev
btn.classList.remove('is-ok', 'is-err')
}, 1200)
}
// ── 错误气泡操作入口 ── // ── 错误气泡操作入口 ──
// errorType 经 useAiEvents push 错误消息时附带(types.ts AiMessage 未含此字段, // errorType 经 useAiEvents push 错误消息时附带(types.ts AiMessage 未含此字段,
// 经 cast 读写闭环在 AiChat.vue / useAiEvents 两端,不污染 types.ts 定义)。 // 经 cast 读写闭环在 AiChat.vue / useAiEvents 两端,不污染 types.ts 定义)。
@@ -710,6 +779,18 @@ function shouldCollapseLong(msg: AiMessage): boolean {
return true return true
} }
// R3:折叠条带量 —— 折叠态按钮显示「已折叠 N 字符 / 约 M tokens,点击展开」。
// 折叠量按 M1 的标定估算(400px 视口 ≈ 阈值字符量,见 LONG_MSG_THRESHOLD 注),
// 即 content.length - 阈值;token 为粗估(中英混合 1 token ≈ 2.5 字符),只供量级感知。
// 文案硬编码中文:i18n locale 文件本批锁定(他代理并行追加,不新增键),
// 展开态仍复用既有 aiChat.collapse 键。
const CHAR_PER_TOKEN_EST = 2.5
function collapsedLongLabel(msg: AiMessage): string {
const folded = Math.max(0, (msg.content?.length ?? 0) - LONG_MSG_THRESHOLD)
const tokens = Math.max(1, Math.round(folded / CHAR_PER_TOKEN_EST))
return `已折叠 ${folded} 字符 / 约 ${tokens} tokens,点击展开`
}
/** /**
* JSON 工具结果折叠防御(对齐 miniapp apps/df-miniapp/src/pages/chat/index.vue:344-354 * JSON 工具结果折叠防御(对齐 miniapp apps/df-miniapp/src/pages/chat/index.vue:344-354
* isToolResultJson):assistant content 为纯 JSON 工具结果(trim 后以 {/[ 开头且 JSON.parse * isToolResultJson):assistant content 为纯 JSON 工具结果(trim 后以 {/[ 开头且 JSON.parse
@@ -828,6 +909,8 @@ type RenderItem =
| { kind: 'step'; key: string; round: number; stats: TurnCallStats } | { kind: 'step'; key: string; round: number; stats: TurnCallStats }
// UX 密度改造:相邻消息间隔 ≥30 分钟或跨天的居中时间分隔线(替代 per-block 相对时间) // UX 密度改造:相邻消息间隔 ≥30 分钟或跨天的居中时间分隔线(替代 per-block 相对时间)
| { kind: 'timesep'; key: string; ts: number } | { kind: 'timesep'; key: string; ts: number }
// S1 子代理完成卡(AiAgentFinished 事件):非消息数据(per-conv 存 aiShared),逐卡一项
| { kind: 'agentFin'; key: string; card: AgentFinishedCard }
/** /**
* 渲染项(A 过程压缩):在归档分段之上叠加轮次折叠。 * 渲染项(A 过程压缩):在归档分段之上叠加轮次折叠。
@@ -964,9 +1047,36 @@ const renderItems = computed<RenderItem[]>(() => {
} }
} }
flushBucket() flushBucket()
// S1 子代理完成卡追加在消息流末尾:子代理在父轮内运行,完成语义上晚于当轮内容;
// 逐卡一项(key 按 agentId 稳定),同会话多子代理多卡共存,Vue diff 复用不闪。
// 真相源是 aiShared.convAgentFinished(per-conv),切会话重进仍可见,非组件局部状态。
for (const card of getConvAgentFinished(store.state.activeConversationId)) {
items.push({ kind: 'agentFin', key: 'agentfin-' + card.agentId, card })
}
return items return items
}) })
// ── S1 子代理完成卡(AiAgentFinished) ──
// 状态→色调三态:completed 中性(常态结果不抢视线)/ truncated 警示(结果不完整)/
// failed 错误(需关注);未知的 status 字面量兜底中性,不误报成功也不误报失败。
function agentFinTone(status: AgentFinishedCard['status']): 'ok' | 'warn' | 'err' {
if (status === 'truncated') return 'warn'
if (status === 'failed') return 'err'
return 'ok'
}
// 状态词独立于颜色透出(色弱可用);键与后端 SubAgentStatus::as_str 字面量一一对应
function agentFinStatusText(status: AgentFinishedCard['status']): string {
if (status === 'truncated') return t('ai.agentFinished.truncated')
if (status === 'failed') return t('ai.agentFinished.failed')
return t('ai.agentFinished.completed')
}
// 摘要超长截断:单行细条承载,压平空白后按 160 字截断(对齐轨迹卡 500 字口径的轻量版),
// 全文经 title hover 兜底不丢信息。
function agentFinSummary(summary: string): string {
const s = (summary || '').replace(/\s+/g, ' ').trim()
return s.length > 160 ? s.slice(0, 160) + '…' : s
}
// UX 密度改造:轮次导航条(仅多轮长会话 ≥3 张轨迹卡出现,sticky 置顶,只做跳转不做统计) // UX 密度改造:轮次导航条(仅多轮长会话 ≥3 张轨迹卡出现,sticky 置顶,只做跳转不做统计)
const traceNav = computed(() => const traceNav = computed(() =>
renderItems.value.filter((it): it is Extract<RenderItem, { kind: 'trace' }> => it.kind === 'trace') renderItems.value.filter((it): it is Extract<RenderItem, { kind: 'trace' }> => it.kind === 'trace')
@@ -1025,7 +1135,7 @@ defineExpose({
<template> <template>
<!-- Messages --> <!-- Messages -->
<div class="ai-messages" ref="messagesContainer" @scroll="handleMessagesScroll"> <div class="ai-messages" ref="messagesContainer" @scroll="handleMessagesScroll" @click="onMdCodeCopyClick">
<!-- 空状态 (UX-2025-20: 示例问题卡片 + 无 provider 引导跳 Settings) — 提取至 EmptyState.vue --> <!-- 空状态 (UX-2025-20: 示例问题卡片 + 无 provider 引导跳 Settings) — 提取至 EmptyState.vue -->
<EmptyState <EmptyState
v-if="store.state.messages.length === 0 && !store.state.streaming" v-if="store.state.messages.length === 0 && !store.state.streaming"
@@ -1049,7 +1159,7 @@ defineExpose({
正常消息 + 展开的折叠段内消息 emit 'msg' 项。连续 archived_segment/compressed 正常消息 + 展开的折叠段内消息 emit 'msg' 项。连续 archived_segment/compressed
合并一条分隔条,点击展开/收起看原文) 合并一条分隔条,点击展开/收起看原文)
A 过程压缩:trace 项=完成轮次执行轨迹卡,step 项=执行中实时步骤摘要,均先于 msg 分支拦截。 A 过程压缩:trace 项=完成轮次执行轨迹卡,step 项=执行中实时步骤摘要,均先于 msg 分支拦截。
UX-2025-19: 每项外层包 .ai-msg-slot(B-260618-01 虚拟滚动已移除,现恒渲染; 每项外层包 .ai-msg-slot(虚拟滚动已移除,现恒渲染;
slot 仅作 flex 子占位 + 消息分组的语义容器,gap 由 .ai-messages 承担)。 slot 仅作 flex 子占位 + 消息分组的语义容器,gap 由 .ai-messages 承担)。
UX 密度改造:timesep 项=相邻消息间隔 ≥30 分钟或跨天的居中时间分隔线。 --> UX 密度改造:timesep 项=相邻消息间隔 ≥30 分钟或跨天的居中时间分隔线。 -->
<template v-for="item in renderItems" :key="item.key"> <template v-for="item in renderItems" :key="item.key">
@@ -1085,7 +1195,7 @@ defineExpose({
<div <div
v-if="expandedThinkIds.has(m.id) && m.content?.trim()" v-if="expandedThinkIds.has(m.id) && m.content?.trim()"
class="ai-think-full ai-md" class="ai-think-full ai-md"
v-html="renderMd(m.content)" v-html="renderMdDecorated(m.content)"
></div> ></div>
</template> </template>
<ToolCardList <ToolCardList
@@ -1107,6 +1217,18 @@ defineExpose({
<span class="ai-step-text">{{ stepText(item.stats, item.round) }}</span> <span class="ai-step-text">{{ stepText(item.stats, item.round) }}</span>
</div> </div>
</div> </div>
<!-- S1 子代理完成卡(AiAgentFinished):状态点/状态词三态区分(completed 中性/truncated
警示/failed 错误),摘要单行截断 + 轮数 + tokens;骨架对齐 ai-step-line/ai-retry-line
轻量细条范式,非消息数据不进气泡 -->
<div v-else-if="item.kind === 'agentFin'" class="ai-msg-slot">
<div class="ai-agent-fin" :class="'ai-agent-fin--' + agentFinTone(item.card.status)">
<span class="ai-agent-fin-dot"></span>
<span class="ai-agent-fin-label">{{ $t('ai.agentFinished.title') }}</span>
<span class="ai-agent-fin-status">{{ agentFinStatusText(item.card.status) }}</span>
<span class="ai-agent-fin-summary" :title="item.card.summary">{{ agentFinSummary(item.card.summary) }}</span>
<span class="ai-agent-fin-meta">{{ $t('ai.agentFinished.meta', { rounds: item.card.rounds, tokens: formatTokensK(item.card.tokens) }) }}</span>
</div>
</div>
<!-- 居中时间分隔线(UX 密度改造:相邻消息间隔 ≥30 分钟或跨天;当天只 HH:mm,跨天 MM-DD HH:mm) --> <!-- 居中时间分隔线(UX 密度改造:相邻消息间隔 ≥30 分钟或跨天;当天只 HH:mm,跨天 MM-DD HH:mm) -->
<div v-else-if="item.kind === 'timesep'" class="ai-time-sep"> <div v-else-if="item.kind === 'timesep'" class="ai-time-sep">
<span>{{ formatAbsolute(item.ts) }}</span> <span>{{ formatAbsolute(item.ts) }}</span>
@@ -1119,7 +1241,7 @@ defineExpose({
<span class="ai-retry-text">{{ item.msg.content }}</span> <span class="ai-retry-text">{{ item.msg.content }}</span>
</div> </div>
</div> </div>
<!-- B-260618-19: 纯空 assistant(无 content/error/toolCalls 且非末条流式)整 slot 不渲染, <!-- 纯空 assistant(无 content/error/toolCalls 且非末条流式)整 slot 不渲染,
消除空 div + flex gap 堆积的大片空白;sep 始终渲染;有 toolCalls 的空 assistant 消除空 div + flex gap 堆积的大片空白;sep 始终渲染;有 toolCalls 的空 assistant
仍渲染(工具卡可见——修复 agentic 工具轮 LLM 无文本时工具卡消失)。 仍渲染(工具卡可见——修复 agentic 工具轮 LLM 无文本时工具卡消失)。
UX 密度改造:per-block 相对时间已移除,精确时刻上移到消息容器 title(hover 可查)。 --> UX 密度改造:per-block 相对时间已移除,精确时刻上移到消息容器 title(hover 可查)。 -->
@@ -1152,8 +1274,8 @@ defineExpose({
</span> </span>
</div> </div>
<!-- 消息(正常段 + 展开的折叠段内消息)— 虚拟滚动已移除(UX-260617-01:IO/RO 时序致重叠),消息恒渲染 --> <!-- 消息(正常段 + 展开的折叠段内消息)— 虚拟滚动已移除(IO/RO 时序致重叠),消息恒渲染 -->
<!-- B-260618-19: 纯空 assistant 过滤已上提到外层 .ai-msg-slot 的 v-if(shouldRenderMsg), <!-- 纯空 assistant 过滤已上提到外层 .ai-msg-slot 的 v-if(shouldRenderMsg),
此处 v-else 恒渲染 .ai-msg(sep 已在上方 v-if 分支处理,此处必为 msg)。 --> 此处 v-else 恒渲染 .ai-msg(sep 已在上方 v-if 分支处理,此处必为 msg)。 -->
<div <div
v-else v-else
@@ -1164,7 +1286,7 @@ defineExpose({
<!-- F-15:压缩摘要 system 消息置顶显示(compressed 原文照常展开不折叠,见 messageSegments) --> <!-- F-15:压缩摘要 system 消息置顶显示(compressed 原文照常展开不折叠,见 messageSegments) -->
<div v-if="item.msg.role === 'system'" class="ai-msg-system"> <div v-if="item.msg.role === 'system'" class="ai-msg-system">
<span class="ai-msg-system-label">{{ $t('aiChat.compressedSummaryLabel') }}</span> <span class="ai-msg-system-label">{{ $t('aiChat.compressedSummaryLabel') }}</span>
<div class="ai-msg-system-summary ai-md" v-html="renderMd(item.msg.content)"></div> <div class="ai-msg-system-summary ai-md" v-html="renderMdDecorated(item.msg.content)"></div>
</div> </div>
<!-- 用户消息(提取至 MessageItem.vue,仅 user 角色;assistant/system 由本组件内联渲染) --> <!-- 用户消息(提取至 MessageItem.vue,仅 user 角色;assistant/system 由本组件内联渲染) -->
<MessageItem <MessageItem
@@ -1194,7 +1316,7 @@ defineExpose({
@click="toggleJson(item.msg.id)" @click="toggleJson(item.msg.id)"
><pre>{{ item.msg.content }}</pre></div> ><pre>{{ item.msg.content }}</pre></div>
<div <div
v-else-if="(item.msg.content && item.msg.content.trim()) || (isLastAi(item.msg) && store.state.streaming && streamingBlocks.length > 0)" v-else-if="(item.msg.content && item.msg.content.trim()) || (isLastAi(item.msg) && store.state.streaming && decoratedStreamingBlocks.length > 0)"
class="ai-msg-bubble ai-msg-bubble--ai ai-md" class="ai-msg-bubble ai-msg-bubble--ai ai-md"
:class="{ 'ai-msg-bubble--error': item.msg.isError, 'ai-msg-bubble--collapsed': shouldCollapseLong(item.msg), 'ai-msg-bubble--answer': item.final === true && !item.msg.isError }" :class="{ 'ai-msg-bubble--error': item.msg.isError, 'ai-msg-bubble--collapsed': shouldCollapseLong(item.msg), 'ai-msg-bubble--answer': item.final === true && !item.msg.isError }"
:key="'md-' + item.msg.id + '-' + (isLastAi(item.msg) ? _mdRenderKey : 0)" :key="'md-' + item.msg.id + '-' + (isLastAi(item.msg) ? _mdRenderKey : 0)"
@@ -1203,18 +1325,18 @@ defineExpose({
AC-EFF-S2-1(2026-08-09):条件改用 streamingBlocks.length 而非 store.state.currentText, AC-EFF-S2-1(2026-08-09):条件改用 streamingBlocks.length 而非 store.state.currentText,
消除「currentText 变化触发模板重渲染 + rAF 更新 streamingBlocks 触发重渲染」的双渲染 消除「currentText 变化触发模板重渲染 + rAF 更新 streamingBlocks 触发重渲染」的双渲染
(每次 delta 只经 rAF 更新 streamingBlocks 触发一次渲染)。 --> (每次 delta 只经 rAF 更新 streamingBlocks 触发一次渲染)。 -->
<template v-if="isLastAi(item.msg) && store.state.streaming && streamingBlocks.length > 0"> <template v-if="isLastAi(item.msg) && store.state.streaming && decoratedStreamingBlocks.length > 0">
<div v-for="block in streamingBlocks" :key="block.key" v-html="block.html"></div> <div v-for="block in decoratedStreamingBlocks" :key="block.key" v-html="block.html"></div>
<span v-if="isViewingGenerating" class="ai-cursor"></span> <span v-if="isViewingGenerating" class="ai-cursor"></span>
</template> </template>
<!-- 完成/历史:整段v-html(不变) --> <!-- 完成/历史:整段v-html(不变,经装饰层注入代码块复制按钮) -->
<div v-else v-html="renderMd(item.msg.content)"></div> <div v-else v-html="renderMdDecorated(item.msg.content)"></div>
<!-- M1:超长 assistant 文本折叠后的底部展开按钮(仅非流式 + 长消息显示) --> <!-- M1/R3:超长 assistant 文本折叠条(折叠态带量:已折叠 N 字符/约 M tokens) -->
<button <button
v-if="isLongMsg(item.msg) && !(isLastAi(item.msg) && store.state.streaming)" v-if="isLongMsg(item.msg) && !(isLastAi(item.msg) && store.state.streaming)"
class="ai-msg-bubble-toggle" class="ai-msg-bubble-toggle"
@click.stop="toggleLong(item.msg.id)" @click.stop="toggleLong(item.msg.id)"
>{{ expandedLongIds.has(item.msg.id) ? $t('aiChat.collapse') : $t('aiChat.expand') }}</button> >{{ shouldCollapseLong(item.msg) ? collapsedLongLabel(item.msg) : $t('aiChat.collapse') }}</button>
<button <button
v-if="item.msg.content" v-if="item.msg.content"
class="ai-copy-btn ai-copy-btn--ai" class="ai-copy-btn ai-copy-btn--ai"
@@ -1237,7 +1359,7 @@ defineExpose({
<span class="ai-cursor"></span> <span class="ai-cursor"></span>
</div> </div>
<!-- UX-03 / UX-260616-01:错误气泡操作入口(重试 / 去设置) --> <!-- UX-03 / 错误气泡操作入口(重试 / 去设置) -->
<div v-if="item.msg.isError" class="ai-msg-actions ai-msg-actions--error"> <div v-if="item.msg.isError" class="ai-msg-actions ai-msg-actions--error">
<span v-if="!canRetry(item.msg)" class="ai-msg-not-retryable">{{ $t('ai.notRetryable') }}</span> <span v-if="!canRetry(item.msg)" class="ai-msg-not-retryable">{{ $t('ai.notRetryable') }}</span>
<button <button
@@ -1341,7 +1463,7 @@ defineExpose({
</div> </div>
</div> </div>
</div> </div>
</div><!-- /.ai-msg-slot(B-260618-01:虚拟滚动已移除,恒渲染容器) --> </div><!-- /.ai-msg-slot(虚拟滚动已移除,恒渲染容器) -->
</template> </template>
</div> </div>
@@ -1468,7 +1590,7 @@ defineExpose({
color: var(--df-danger); color: var(--df-danger);
border-color: rgba(240,101,101,0.3); border-color: rgba(240,101,101,0.3);
} }
/* 修正 UX-260617-21 的 display:block 方案——display:block 让 table 失去表格 /* 修正 的 display:block 方案——display:block 让 table 失去表格
布局上下文,tr/td 脱离 table 上下文塌成块级堆叠丢列对齐(用户截图反馈测试报告四列表格错乱)。 布局上下文,tr/td 脱离 table 上下文塌成块级堆叠丢列对齐(用户截图反馈测试报告四列表格错乱)。
正解:table 保持默认 display:table(列对齐恢复)+ max-width:100%;宽表格横向滚动交由气泡 正解:table 保持默认 display:table(列对齐恢复)+ max-width:100%;宽表格横向滚动交由气泡
容器 .ai-msg-bubble--ai.ai-md 的 overflow-x:auto 承载(CSS spec 中 table-display 下 容器 .ai-msg-bubble--ai.ai-md 的 overflow-x:auto 承载(CSS spec 中 table-display 下
@@ -1664,6 +1786,45 @@ defineExpose({
right: 30px; right: 30px;
} }
/* ── R4 代码块复制按钮:节点经 v-html 注入,非 scoped 受控,经模板锚点 .ai-md :deep 命中。
悬浮显示(代码块 hover 才现,右上角);is-ok/is-err 为复制反馈态(常显 1.2s 后由 JS 还原)。 ── */
.ai-md :deep(.md-code-block) {
position: relative;
}
.ai-md :deep(.md-code-copy) {
position: absolute;
top: 6px;
right: 6px;
z-index: 1;
padding: 2px 8px;
font-size: 10px;
line-height: 1.4;
color: var(--df-text-dim);
background: color-mix(in srgb, var(--df-bg) 82%, transparent);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s var(--df-ease), color 0.15s var(--df-ease);
}
.ai-md :deep(.md-code-block:hover .md-code-copy) {
opacity: 1;
}
.ai-md :deep(.md-code-copy:hover) {
color: var(--df-accent);
border-color: var(--df-accent);
}
.ai-md :deep(.md-code-copy.is-ok) {
opacity: 1;
color: var(--df-success, #4caf50);
border-color: var(--df-success, #4caf50);
}
.ai-md :deep(.md-code-copy.is-err) {
opacity: 1;
color: var(--df-danger);
border-color: var(--df-danger);
}
/* ── UX-02/消息操作栏(hover 显示 / 错误气泡常显) ── */ /* ── UX-02/消息操作栏(hover 显示 / 错误气泡常显) ── */
.ai-msg-actions { .ai-msg-actions {
display: flex; display: flex;
@@ -2092,4 +2253,62 @@ defineExpose({
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
/* S1 子代理完成卡(AiAgentFinished):三态色分层——completed 中性灰(常态结果不抢视线)
/ truncated 琥珀(结果不完整,与 retry 细条同警示层)/ failed 红(需关注,对齐错误卡层)。
骨架对齐 .ai-retry-line 轻量细条;摘要单行截断,全文经 title hover 查看 */
.ai-agent-fin {
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-text-secondary) 6%, transparent);
font-size: 11px;
color: var(--df-text-secondary);
user-select: none;
}
.ai-agent-fin-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--df-text-secondary);
flex-shrink: 0;
}
.ai-agent-fin--warn {
background: color-mix(in srgb, var(--df-warning, #f0c75e) 8%, transparent);
}
.ai-agent-fin--warn .ai-agent-fin-dot {
background: var(--df-warning, #f0c75e);
}
.ai-agent-fin--err {
background: color-mix(in srgb, var(--df-danger) 10%, transparent);
}
.ai-agent-fin--err .ai-agent-fin-dot {
background: var(--df-danger);
}
.ai-agent-fin-label {
flex-shrink: 0;
font-weight: 600;
}
.ai-agent-fin-status {
flex-shrink: 0;
}
.ai-agent-fin--warn .ai-agent-fin-status {
color: var(--df-warning, #f0c75e);
}
.ai-agent-fin--err .ai-agent-fin-status {
color: var(--df-danger);
}
.ai-agent-fin-summary {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-agent-fin-meta {
flex-shrink: 0;
font-family: var(--df-font-mono);
font-size: 10.5px;
}
</style> </style>
+2 -2
View File
@@ -22,7 +22,7 @@
<span class="status-tag" :class="'status-' + idea.status">{{ $t(ideaStatusLabelKey(idea.status)) }}</span> <span class="status-tag" :class="'status-' + idea.status">{{ $t(ideaStatusLabelKey(idea.status)) }}</span>
</div> </div>
</div> </div>
<!-- B-260615-25:灵感描述 Markdown 渲染,复用 useMarkdown composable(同 B-24 TaskDetail),空值回退 — --> <!-- 灵感描述 Markdown 渲染,复用 useMarkdown composable(同 B-24 TaskDetail),空值回退 — -->
<template v-if="editing"> <template v-if="editing">
<textarea v-model="editDesc" class="detail-desc-edit" rows="4"></textarea> <textarea v-model="editDesc" class="detail-desc-edit" rows="4"></textarea>
<div class="desc-edit-actions"> <div class="desc-edit-actions">
@@ -725,7 +725,7 @@ watch(() => props.idea.id, loadHistory)
margin-bottom: var(--df-gap-page); margin-bottom: var(--df-gap-page);
} }
/* ===== 灵感描述 Markdown 渲染(B-260615-25,基础样式收敛至全局 ai-md.css) ===== */ /* ===== 灵感描述 Markdown 渲染(,基础样式收敛至全局 ai-md.css) ===== */
.detail-desc.ai-md { white-space: normal; color: var(--df-text-secondary); } .detail-desc.ai-md { white-space: normal; color: var(--df-text-secondary); }
.detail-section { .detail-section {
+15 -7
View File
@@ -30,7 +30,7 @@
<div class="detail-field"> <div class="detail-field">
<label>{{ t('knowledge.contentLabel') }}</label> <label>{{ t('knowledge.contentLabel') }}</label>
<!-- B-260615-25:知识内容 Markdown 渲染(展示态),复用 useMarkdown composable(同 B-24 TaskDetail);编辑态保持 textarea --> <!-- 知识内容 Markdown 渲染(展示态),复用 useMarkdown composable(同 B-24 TaskDetail);编辑态保持 textarea -->
<div <div
v-if="!editing && detail.knowledge.content" v-if="!editing && detail.knowledge.content"
ref="contentEl" ref="contentEl"
@@ -150,7 +150,7 @@ import { useRendered } from '@/composables/useMarkdown'
import { formatRelative } from '@/utils/time' import { formatRelative } from '@/utils/time'
import type { KnowledgeDetailPayload, KnowledgeEventRecord } from '@/api/types' import type { KnowledgeDetailPayload, KnowledgeEventRecord } from '@/api/types'
// 详情面板子组件(Knowledge.vue 拆分,SW-260618-20): // 详情面板子组件(Knowledge.vue 拆分,):
// 接收 detail 渲染只读四段(基本信息/溯源/引用/时间线),内部自管 editing/editForm/refLimit; // 接收 detail 渲染只读四段(基本信息/溯源/引用/时间线),内部自管 editing/editForm/refLimit;
// 编辑保存与审核操作通过 emit 回传父级执行 IPC(publish/reject/archive/save)。 // 编辑保存与审核操作通过 emit 回传父级执行 IPC(publish/reject/archive/save)。
const props = defineProps<{ const props = defineProps<{
@@ -202,7 +202,7 @@ function saveEdit() {
editing.value = false editing.value = false
} }
// ===== Markdown 渲染(B-260615-25,复用模块单例渲染器)===== // ===== Markdown 渲染(,复用模块单例渲染器)=====
const { rendered: renderedContent, ensureLoaded } = useRendered( const { rendered: renderedContent, ensureLoaded } = useRendered(
() => props.detail?.knowledge.content ?? '', () => props.detail?.knowledge.content ?? '',
) )
@@ -342,11 +342,11 @@ watch(renderedContent, () => {
</script> </script>
<style scoped> <style scoped>
/* 详情面板四段(SW-260618-20 从 Knowledge.vue 迁移,样式与原一致) */ /* 详情面板四段(从 Knowledge.vue 迁移,样式与原一致) */
.detail-section { padding-bottom: 18px; margin-bottom: 18px; border-bottom: 0.5px solid var(--df-border); } .detail-section { padding-bottom: 18px; margin-bottom: 18px; border-bottom: 0.5px solid var(--df-border); }
.detail-section:last-child { border-bottom: none; margin-bottom: 0; } .detail-section:last-child { border-bottom: none; margin-bottom: 0; }
.detail-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 12px; min-width: 0; } .detail-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 12px; min-width: 0; flex-wrap: wrap; }
.detail-title-row { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; overflow: hidden; } .detail-title-row { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; overflow: hidden; }
.detail-kind-icon { font-size: 20px; flex-shrink: 0; } .detail-kind-icon { font-size: 20px; flex-shrink: 0; }
.detail-title { font-size: 18px; font-weight: 500; color: var(--df-text); word-break: break-word; overflow-wrap: break-word; min-width: 0; } .detail-title { font-size: 18px; font-weight: 500; color: var(--df-text); word-break: break-word; overflow-wrap: break-word; min-width: 0; }
@@ -358,7 +358,7 @@ watch(renderedContent, () => {
.detail-field label { display: block; font-size: 12px; color: var(--df-text-secondary); margin-bottom: 4px; } .detail-field label { display: block; font-size: 12px; color: var(--df-text-secondary); margin-bottom: 4px; }
.detail-content { font-size: 13px; color: var(--df-text); line-height: 1.6; white-space: pre-wrap; } .detail-content { font-size: 13px; color: var(--df-text); line-height: 1.6; white-space: pre-wrap; }
/* ===== 知识内容 Markdown 渲染(B-260615-25,基础样式收敛至全局 ai-md.css)===== */ /* ===== 知识内容 Markdown 渲染(,基础样式收敛至全局 ai-md.css)===== */
.detail-content.ai-md { white-space: normal; } .detail-content.ai-md { white-space: normal; }
.detail-tags { display: flex; gap: 6px; flex-wrap: wrap; } .detail-tags { display: flex; gap: 6px; flex-wrap: wrap; }
.tag { font-size: 11px; padding: 2px 8px; border-radius: var(--df-radius-xs); background: var(--df-accent-bg); color: var(--df-accent); } .tag { font-size: 11px; padding: 2px 8px; border-radius: var(--df-radius-xs); background: var(--df-accent-bg); color: var(--df-accent); }
@@ -448,10 +448,18 @@ watch(renderedContent, () => {
/* 按钮基础样式 (.btn / .btn-sm / .btn-primary / .btn-ghost) 已收敛至 global.css */ /* 按钮基础样式 (.btn / .btn-sm / .btn-primary / .btn-ghost) 已收敛至 global.css */
/* ===== 响应式:窄屏详情标题防竖线化(B-260619)===== */ /* ===== 响应式:窄屏详情标题防竖线化()===== */
/* 双闸:@media 管窗口窄;@container 管「视口宽但面板被 AI 平分挤压」场景。
注意:@container 需要 .knowledge(父链上有 container-type 的祖先)声明容器,
已在 Knowledge.vue 根节点加 container-type: inline-size。 */
@media (max-width: 760px) { @media (max-width: 760px) {
.detail-head { flex-direction: column; align-items: flex-start; } .detail-head { flex-direction: column; align-items: flex-start; }
.detail-actions { width: 100%; } .detail-actions { width: 100%; }
.detail-title { font-size: 15px; } .detail-title { font-size: 15px; }
} }
@container (max-width: 420px) {
.detail-head { flex-direction: column; align-items: flex-start; }
.detail-actions { width: 100%; }
.detail-title { font-size: 15px; }
}
</style> </style>
+3 -3
View File
@@ -1,5 +1,5 @@
<template> <template>
<!-- 人工审批对话框(从 ProjectDetail.vue 抽离 UX-260618-17 submitting 保留)--> <!-- 人工审批对话框(从 ProjectDetail.vue 抽离 submitting 保留)-->
<div v-if="visible" class="modal-overlay"> <div v-if="visible" class="modal-overlay">
<div class="modal-box" style="width: 500px"> <div class="modal-box" style="width: 500px">
<h3 class="modal-title">{{ $t('projectDetail.approvalTitle') }}</h3> <h3 class="modal-title">{{ $t('projectDetail.approvalTitle') }}</h3>
@@ -76,7 +76,7 @@ import { useToast } from '@/composables/useToast'
// 抽离自 ProjectDetail.vue(L175-225 模板 + L416-471 审批逻辑/watch) // 抽离自 ProjectDetail.vue(L175-225 模板 + L416-471 审批逻辑/watch)
// store 共享:父级 useProjectStore() 实例传入(同源 store,响应式共享 pendingApproval) // store 共享:父级 useProjectStore() 实例传入(同源 store,响应式共享 pendingApproval)
// submitting 自治:本组件持有禁用态(UX-260618-17 双击防抖) // submitting 自治:本组件持有禁用态(双击防抖)
// ProjectStore 类型未 export(store.ts:135 内部 type),用 ReturnType 自推导同源类型 // ProjectStore 类型未 export(store.ts:135 内部 type),用 ReturnType 自推导同源类型
type ProjectStore = ReturnType<typeof useProjectStore> type ProjectStore = ReturnType<typeof useProjectStore>
@@ -135,7 +135,7 @@ async function handleApproval(decision: string) {
} }
} }
// 多选审批(UX-260618-17 submitting 防双击保留) // 多选审批(submitting 防双击保留)
async function handleApprovalMulti() { async function handleApprovalMulti() {
if (multiDecisions.value.length === 0) return if (multiDecisions.value.length === 0) return
submitting.value = true submitting.value = true
+16 -4
View File
@@ -71,10 +71,19 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 图片(外部 diff 存在时让位;加载失败显示错误占位,不静默空白) -->
<!-- 图片(外部 diff 存在时让位) -->
<div v-else-if="isImage && !isExternalDiff" class="preview-image"> <div v-else-if="isImage && !isExternalDiff" class="preview-image">
<img :src="imageUrl ?? ''" :alt="filePath ?? ''" /> <img
v-if="imageUrl && !imageLoadError"
:src="imageUrl"
:alt="filePath ?? ''"
@error="imageLoadError = true"
/>
<div v-else class="preview-image-error">
<div class="preview-placeholder-icon">🖼️</div>
<div class="preview-placeholder-text">图片加载失败(路径: {{ filePath }})</div>
</div>
</div> </div>
<!-- Diff 视图(外部注入的 diff 优先展示;置于 Markdown 之前,使 .md 文件也支持 Diff) --> <!-- Diff 视图(外部注入的 diff 优先展示;置于 Markdown 之前,使 .md 文件也支持 Diff) -->
@@ -171,6 +180,8 @@ const isBinary = ref(false)
const fileSize = ref<number | null>(null) const fileSize = ref<number | null>(null)
const truncated = ref(false) const truncated = ref(false)
const imageUrl = ref<string | null>(null) const imageUrl = ref<string | null>(null)
/** 图片加载失败标记(asset 协议不可用/文件被删等);@error 置位,文件切换时复位 */
const imageLoadError = ref(false)
/** 符号概览(fileOutline 结果;仅支持语言 + 非 diff 视图时展示)。 */ /** 符号概览(fileOutline 结果;仅支持语言 + 非 diff 视图时展示)。 */
const outlineSupported = ref(false) const outlineSupported = ref(false)
@@ -312,7 +323,7 @@ const hljsClass = computed(() => {
}) })
/** 图片后缀白名单。 */ /** 图片后缀白名单。 */
const IMAGE_EXT = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.ico'] const IMAGE_EXT = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.ico', '.avif']
/** Markdown 后缀判定。 */ /** Markdown 后缀判定。 */
const isMarkdown = computed(() => { const isMarkdown = computed(() => {
@@ -486,6 +497,7 @@ async function loadFile() {
URL.revokeObjectURL(imageUrl.value) URL.revokeObjectURL(imageUrl.value)
imageUrl.value = null imageUrl.value = null
} }
imageLoadError.value = false
// 重置上一文件的符号概览(新文件结果到达后再填充) // 重置上一文件的符号概览(新文件结果到达后再填充)
outlineSupported.value = false outlineSupported.value = false
outlineSymbols.value = [] outlineSymbols.value = []
+7 -3
View File
@@ -187,7 +187,7 @@ const showHistory = ref(false)
const selectedFile = ref('') const selectedFile = ref('')
const diffContent = ref('') const diffContent = ref('')
const diffLoading = ref(false) const diffLoading = ref(false)
// 行内错误提示:loadError 主区展示(loadStatus 失败),actionError 分支条下方展示(loadMore/selectFile 失败) // 行内错误提示:loadError 主区展示(loadStatus 失败),actionError 分支条下方展示(loadMore/selectFile/切分支/提交详情 失败)
const loadError = ref('') const loadError = ref('')
const actionError = ref('') const actionError = ref('')
// 请求序号守卫:moduleId 切换与 refreshKey 刷新双触发源并发时,旧响应晚到不覆盖新模块数据 // 请求序号守卫:moduleId 切换与 refreshKey 刷新双触发源并发时,旧响应晚到不覆盖新模块数据
@@ -402,12 +402,14 @@ async function onSwitchBranch() {
} }
showBranchPicker.value = true showBranchPicker.value = true
branchLoading.value = true branchLoading.value = true
actionError.value = ''
try { try {
const res = await moduleApi.listBranches(props.moduleId) const res = await moduleApi.listBranches(props.moduleId)
branchList.value = res.branches branchList.value = res.branches
currentBranch.value = res.current currentBranch.value = res.current
} catch { } catch (e) {
branchList.value = [] branchList.value = []
actionError.value = String(e)
} finally { } finally {
branchLoading.value = false branchLoading.value = false
} }
@@ -479,11 +481,13 @@ async function selectCommit(c: CommitItem) {
// 切换提交 → 清空右侧宽预览,避免残留上一个提交文件的 diff 与当前提交错位 // 切换提交 → 清空右侧宽预览,避免残留上一个提交文件的 diff 与当前提交错位
emit('select-file', '', '') emit('select-file', '', '')
commitDetailLoading.value = true commitDetailLoading.value = true
actionError.value = ''
try { try {
const res = await moduleApi.getCommitDetail(props.moduleId, c.hash) const res = await moduleApi.getCommitDetail(props.moduleId, c.hash)
commitDetail.value = res commitDetail.value = res
} catch { } catch (e) {
commitDetail.value = null commitDetail.value = null
actionError.value = String(e)
} finally { } finally {
commitDetailLoading.value = false commitDetailLoading.value = false
} }
+4 -3
View File
@@ -132,10 +132,11 @@ watch(() => props.view, () => nextTick(measureDesc))
padding: 3px 10px; padding: 3px 10px;
border-radius: var(--df-radius-sm); border-radius: var(--df-radius-sm);
} }
.stage-design { background: rgba(100,181,246,0.15); color: var(--df-info); } /* 阶段色对齐 ProjectDetail 语义 token:design=info / testing=warning / release=success */
.stage-design { background: var(--df-info-bg); color: var(--df-info); }
.stage-coding { background: var(--df-accent-soft); color: var(--df-accent); } .stage-coding { background: var(--df-accent-soft); color: var(--df-accent); }
.stage-testing { background: rgba(255,217,61,0.15); color: var(--df-warning); } .stage-testing { background: var(--df-warning-bg); color: var(--df-warning); }
.stage-release { background: rgba(100,255,218,0.15); color: var(--df-success); } .stage-release { background: var(--df-success-bg); color: var(--df-success); }
/* WK-1:暂停/取消置灰(PROJECT_STATUS_BADGE_CLASS 映射) */ /* WK-1:暂停/取消置灰(PROJECT_STATUS_BADGE_CLASS 映射) */
.stage-dim { background: color-mix(in srgb, var(--df-text-dim) 15%, transparent); color: var(--df-text-dim); } .stage-dim { background: color-mix(in srgb, var(--df-text-dim) 15%, transparent); color: var(--df-text-dim); }
+12 -3
View File
@@ -61,8 +61,9 @@
<span class="setting-desc">{{ $t('settings.descApprovalTimeout') }}</span> <span class="setting-desc">{{ $t('settings.descApprovalTimeout') }}</span>
</div> </div>
<!-- 文件上传服务端点:配置后聊天图片上传到用户自己的文件服务转公开 URL;留空则图片本地 base64 内嵌。 <!-- 文件上传服务端点:配置后聊天图片上传到用户自己的文件服务转公开 URL;留空则图片本地 base64 内嵌。
与 fileUpload.ts 的读取约定同 key(df-file-upload-base),blur/enter 提交即落库生效 --> 与 fileUpload.ts 的读取约定同 key(df-file-upload-base),blur/enter 提交即落库生效。
<SettingRow ref="rowUploadBase" :label="$t('settings.labelFileUploadBase')" :desc="$t('settings.descFileUploadBase')"> upload-row 整行堆叠(对齐 prompt-row 先例):原横排 280px 最小宽在窄列强挤 label 致逐字竖排 -->
<SettingRow ref="rowUploadBase" class="upload-row" :label="$t('settings.labelFileUploadBase')" :desc="$t('settings.descFileUploadBase')">
<input <input
v-model="fileUploadBase" v-model="fileUploadBase"
class="setting-input upload-base-input" class="setting-input upload-base-input"
@@ -264,9 +265,17 @@ function onApprovalTimeoutChange() {
word-break: break-all; word-break: break-all;
line-height: 1.4; line-height: 1.4;
} }
/* 文件上传端点行:整行堆叠——label/desc 在上,输入独立一行拉满(对齐 prompt-row 先例)。
窄列不再 280px 强制最小宽挤压 label 逐字竖排 */
.upload-row {
flex-direction: column;
align-items: stretch;
}
/* 端点输入走等宽字体(URL 逐字符核对更易读),对齐 AllowedDirsPanel 路径输入风格 */ /* 端点输入走等宽字体(URL 逐字符核对更易读),对齐 AllowedDirsPanel 路径输入风格 */
.upload-base-input { .upload-base-input {
min-width: 280px; width: 100%;
min-width: 0;
box-sizing: border-box;
font-family: var(--df-font-mono, monospace); font-family: var(--df-font-mono, monospace);
} }
/* 自定义提示词行:整行堆叠,textarea 独立一行从左到右拉满。 /* 自定义提示词行:整行堆叠,textarea 独立一行从左到右拉满。
+14 -5
View File
@@ -481,6 +481,8 @@ async function fetchModels() {
}) })
// 手动优先:探测结果合并已存在的 UserSet 标注——用户手动改过的能力不被「测试连接」覆盖 // 手动优先:探测结果合并已存在的 UserSet 标注——用户手动改过的能力不被「测试连接」覆盖
// (对齐后端 ai_fetch_models 合并逻辑:probe_source==UserSet 保留手动能力维度)。 // (对齐后端 ai_fetch_models 合并逻辑:probe_source==UserSet 保留手动能力维度)。
// enabled/weight 同理保留旧值:重拉只是刷新能力维度,用户已关掉的模型/调过的权重
// 属用户配置意图,被探测默认值复位等于静默丢配置(走查 P1-2)。
providerForm.models = models.map(m => { providerForm.models = models.map(m => {
const old = providerForm.models.find(o => o.model_id === m.model_id) const old = providerForm.models.find(o => o.model_id === m.model_id)
const isManual = old?.probe_source === 'user_set' const isManual = old?.probe_source === 'user_set'
@@ -489,6 +491,8 @@ async function fetchModels() {
modalities: isManual ? [...old.modalities] : [...m.modalities], modalities: isManual ? [...old.modalities] : [...m.modalities],
capabilities: isManual ? [...old.capabilities] : [...m.capabilities], capabilities: isManual ? [...old.capabilities] : [...m.capabilities],
probe_source: isManual ? 'user_set' : m.probe_source, probe_source: isManual ? 'user_set' : m.probe_source,
enabled: old ? old.enabled : m.enabled,
weight: old ? old.weight : m.weight,
} }
}) })
emit('toast', t('settings.toastFetchOk', { count: models.length }), 'info') emit('toast', t('settings.toastFetchOk', { count: models.length }), 'info')
@@ -600,7 +604,9 @@ async function setDefaultProvider(id: string) {
// 重建 per_provider caps,下条消息即按新配置 acquire。 // 重建 per_provider caps,下条消息即按新配置 acquire。
// 不走 saveProvider(全量 INSERT OR REPLACE 会触发 R-PD-1 密钥迁移分支,与 c 轻量 UPDATE 路径冲突)。 // 不走 saveProvider(全量 INSERT OR REPLACE 会触发 R-PD-1 密钥迁移分支,与 c 轻量 UPDATE 路径冲突)。
// ============================================================ // ============================================================
let _poolTimer: ReturnType<typeof setTimeout> | null = null // 计时器按 provider id 分键:单例计时器下 300ms 内先后改两家权重,B 的变更会清掉
// A 尚未落库的保存,先改的一方静默丢失(走查 P2-8)。
const _poolTimers = new Map<string, ReturnType<typeof setTimeout>>()
/** /**
* toggle 即时生效(开关语义=立即入/出池,无 debounce 必要;但失败需回滚视图)。 * toggle 即时生效(开关语义=立即入/出池,无 debounce 必要;但失败需回滚视图)。
@@ -628,8 +634,11 @@ function onPoolWeightChange(p: AiProviderConfig, raw: number) {
const clamped = Math.min(100, Math.max(0, Number.isFinite(raw) ? Math.trunc(raw) : 50)) const clamped = Math.min(100, Math.max(0, Number.isFinite(raw) ? Math.trunc(raw) : 50))
const prev = p.weight const prev = p.weight
p.weight = clamped p.weight = clamped
if (_poolTimer) clearTimeout(_poolTimer) // 仅重置同一 provider 的待发计时;跨 provider 互不干扰
_poolTimer = setTimeout(async () => { const prevTimer = _poolTimers.get(p.id)
if (prevTimer) clearTimeout(prevTimer)
_poolTimers.set(p.id, setTimeout(async () => {
_poolTimers.delete(p.id)
try { try {
await aiApi.updateProviderPool(p.id, p.enabled !== false, clamped) await aiApi.updateProviderPool(p.id, p.enabled !== false, clamped)
emit('toast', t('settings.toastPoolUpdateOk'), 'info') emit('toast', t('settings.toastPoolUpdateOk'), 'info')
@@ -638,7 +647,7 @@ function onPoolWeightChange(p: AiProviderConfig, raw: number) {
console.error(t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), e) console.error(t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), e)
emit('toast', t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), 'error') emit('toast', t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), 'error')
} }
}, 300) }, 300))
} }
function maskKey(key: string): string { function maskKey(key: string): string {
@@ -654,7 +663,7 @@ defineExpose({
loadProviders, loadProviders,
isFormDirty, isFormDirty,
closeProviderForm, closeProviderForm,
clearPoolTimer() { if (_poolTimer) clearTimeout(_poolTimer) }, clearPoolTimer() { for (const t of _poolTimers.values()) clearTimeout(t); _poolTimers.clear() },
}) })
// 阶段3 UX 重构:本面板改为 ai-model 类懒挂载,不再由 Settings.vue onMounted 调用, // 阶段3 UX 重构:本面板改为 ai-model 类懒挂载,不再由 Settings.vue onMounted 调用,
+5 -2
View File
@@ -20,18 +20,21 @@
/* ===== 设置行容器(表单型 Panel 共用) ===== */ /* ===== 设置行容器(表单型 Panel 共用) ===== */
.general-settings { display: flex; flex-direction: column; } .general-settings { display: flex; flex-direction: column; }
/* 窄列(设置抽屉/面板窄态)三防线:gap 隔开 info 与 control;info min-width:0 允许收缩、
label/desc 文本按常规断行(多词换行,不逐字竖排);control 不被压缩挤扁 */
.setting-row { .setting-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 12px;
padding: 14px 0; padding: 14px 0;
border-bottom: 0.5px solid var(--df-border); border-bottom: 0.5px solid var(--df-border);
} }
.setting-row:last-child { border-bottom: none; } .setting-row:last-child { border-bottom: none; }
.setting-info { display: flex; flex-direction: column; gap: 2px; } .setting-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.setting-label { font-size: 14px; font-weight: 500; color: var(--df-text); } .setting-label { font-size: 14px; font-weight: 500; color: var(--df-text); }
.setting-desc { font-size: 12px; color: var(--df-text-dim); } .setting-desc { font-size: 12px; color: var(--df-text-dim); }
.setting-control { display: flex; align-items: center; } .setting-control { display: flex; align-items: center; flex-shrink: 0; }
/* ===== 控件(5/5 Panel 重复) ===== */ /* ===== 控件(5/5 Panel 重复) ===== */
/* 控件统一基准(padding/border/radius/font 对齐),各 type 仅差异化必要属性 */ /* 控件统一基准(padding/border/radius/font 对齐),各 type 仅差异化必要属性 */
+1 -1
View File
@@ -271,7 +271,7 @@ function confirmDelete() {
} }
.tbb-toast--success { border-color: color-mix(in srgb, var(--df-success) 50%, transparent); color: var(--df-success); } .tbb-toast--success { border-color: color-mix(in srgb, var(--df-success) 50%, transparent); color: var(--df-success); }
.tbb-toast--warning { border-color: color-mix(in srgb, var(--df-warning) 50%, transparent); color: var(--df-warning); } .tbb-toast--warning { border-color: color-mix(in srgb, var(--df-warning) 50%, transparent); color: var(--df-warning); }
.tbb-toast--error { border-color: rgba(240, 101, 101, 0.5); color: var(--df-danger); } .tbb-toast--error { border-color: color-mix(in srgb, var(--df-danger) 50%, transparent); color: var(--df-danger); }
.toast-enter-active, .toast-leave-active { transition: opacity 0.2s; } .toast-enter-active, .toast-leave-active { transition: opacity 0.2s; }
.toast-enter-from, .toast-leave-to { opacity: 0; } .toast-enter-from, .toast-leave-to { opacity: 0; }
</style> </style>
+1 -1
View File
@@ -87,7 +87,7 @@ const reviewSuggestions = computed<string[]>(() => {
<style scoped> <style scoped>
/* ===== F-AiNodeSelfReview: output_json 区块(产出 + 自审结论卡) ===== */ /* ===== F-AiNodeSelfReview: output_json 区块(产出 + 自审结论卡) ===== */
/* 基础布局 .info-item / .info-block / .label / .value 已收敛至全局 components.css /* 基础布局 .info-item / .info-block / .label / .value 已收敛至全局 components.css
(DRY 收口 B-260619)。全局类对子组件内部 DOM 生效(全局非 scoped 无渗透限制), (DRY 收口)。全局类对子组件内部 DOM 生效(全局非 scoped 无渗透限制),
故本子组件无需复制等价基础布局,仅保留本组件特有的 output/review 样式。 */ 故本子组件无需复制等价基础布局,仅保留本组件特有的 output/review 样式。 */
.output-block .output-body { .output-block .output-body {
+171
View File
@@ -361,6 +361,78 @@ export function clearConvStreamState(convId: string | null | undefined): void {
convStreamStates.delete(convId) convStreamStates.delete(convId)
} }
// ── per-conv 待审批集合(审批挂起按 conversation_id 归会话,跨会话互不覆盖) ──
//
// 背景:state.pendingApprovals 是全局单数组,switchConversation 恢复挂起时对它整体替换
// (= restored / = []),后台会话经事件到达的挂起会被切换动作整组覆盖丢失(全局单值覆盖语义)。
// 改为双层结构:convPendingApprovals Map 是跨会话真相源(各会话独立数组,互不覆盖);
// state.pendingApprovals 退化为「当前活跃会话」的视图投影,组件消费方(徽标/浮层/审批浮窗)
// 仍只读该数组,零改动保持视图按当前会话展示。
//
// 注:Map 本体不建响应式(仅切换/收尾时由本组函数读写,渲染只依赖 state.pendingApprovals)。
export const convPendingApprovals = new Map<string, AiToolCallInfo[]>()
function pendingActiveKey(): string {
return getState()?.activeConversationId ?? ''
}
/**
* 追加一条待审批:按 conversationId 归入所属会话集合(缺省归当前活跃会话);
* 属当前活跃会话时同步投影进 state.pendingApprovals。同 id 幂等,返回是否真正新增
* (调用方据此决定是否做工具卡改写/计时器启动/滚动通知等一次性副作用)。
*/
export function addPendingApproval(info: AiToolCallInfo): boolean {
const key = info.conversationId || pendingActiveKey()
let arr = convPendingApprovals.get(key)
if (!arr) {
arr = []
convPendingApprovals.set(key, arr)
}
let added = false
if (!arr.some(p => p.id === info.id)) {
arr.push(info)
added = true
}
if (key === pendingActiveKey()) {
const view = getState()?.pendingApprovals
if (view && !view.some(p => p.id === info.id)) view.push(info)
}
return added
}
/** 按 id 移除一条待审批(全会话集合扫描;审批结果不依赖当前视图是哪个会话) */
export function removePendingApprovalById(id: string): void {
for (const [k, arr] of convPendingApprovals) {
if (arr.some(p => p.id === id)) {
convPendingApprovals.set(k, arr.filter(p => p.id !== id))
}
}
const view = getState()?.pendingApprovals
if (view) {
const idx = view.findIndex(p => p.id === id)
if (idx >= 0) view.splice(idx, 1)
}
}
/**
* 整体替换某会话的待审批集合(切换恢复后端审计挂起 / 新建清空用);
* 目标为当前活跃会话时同步投影进视图,否则只写集合不动视图(跨会话互不覆盖的关键)。
*/
export function setConvPendingApprovals(convId: string | null | undefined, items: AiToolCallInfo[]): void {
if (!convId) return
convPendingApprovals.set(convId, items)
if (convId === pendingActiveKey()) {
const view = getState()?.pendingApprovals
if (view) view.splice(0, view.length, ...items)
}
}
/** 仅删除某会话的集合项,不动视图数组(会话终止/删除时用;视图侧无法归属的旧项由调用方自定保留策略) */
export function discardConvPendingApprovals(convId: string | null | undefined): void {
if (!convId) return
convPendingApprovals.delete(convId)
}
// ── 切换中缓冲 ── // ── 切换中缓冲 ──
// //
// 背景:后台会话(A)生成中,用户点击切换到 A(switchConversation await 往返期间),A 的 // 背景:后台会话(A)生成中,用户点击切换到 A(switchConversation await 往返期间),A 的
@@ -385,6 +457,7 @@ export interface AiStateShape {
streaming: boolean streaming: boolean
messages: AiMessage[] messages: AiMessage[]
activeConversationId: string | null activeConversationId: string | null
pendingApprovals: AiToolCallInfo[]
} }
let _stateGetter: (() => AiStateShape) | null = null let _stateGetter: (() => AiStateShape) | null = null
@@ -494,3 +567,101 @@ export function clearAllToolSlowTimers(): void {
_toolTimers.clear() _toolTimers.clear()
_toolSlowNotified.clear() _toolSlowNotified.clear()
} }
// ── V8 per-conv 输入草稿 ──
//
// 背景:输入框草稿原先只在 ChatInput 组件内持单份 ref,切会话后 A 会话未发送的草稿直接
// 显示在 B 会话输入框(串稿)。本 Map 是跨会话草稿的内存真相源,ChatInput 在
// activeConversationId 变化时写旧会话文本、读新会话文本。内存态,不落 localStorage。
//
// 放本模块的原因:沿用 convStreamStates 同款 per-conv reactive Map 模式集中管理,
// 消费方(ChatInput)与潜在的其他输入域消费方都可读,且不新增依赖环。
export const convDrafts = reactive(new Map<string, string>())
/** 读某会话草稿;无记录返回空串(调用方直接回填输入框即可) */
export function getConvDraft(convId: string | null | undefined): string {
if (!convId) return ''
return convDrafts.get(convId) ?? ''
}
/** 写某会话草稿;空串收敛删 Map 项(对齐 convStates idle 收敛语义,Map 不持陈旧空项) */
export function setConvDraft(convId: string | null | undefined, text: string): void {
if (!convId) return
if (text) {
convDrafts.set(convId, text)
} else {
convDrafts.delete(convId)
}
}
// ── I5 发送失败错误分类 ──
//
// 背景:发送失败错误串原样透出到 toast,后端/上游错误原文(429 Too Many Requests、
// insufficient_quota、ECONNREFUSED 等)对用户不友好。按内容归类四类,消费方
// (ChatInput 发送失败 toast)据类别映射 i18n 文案,原始错误串作为 {msg} 细节保留不丢。
export type SendErrorKind = 'rate_limit' | 'quota' | 'network' | 'other'
/**
* 按错误串内容分类。顺序敏感:429/限流先判(部分上游限流文案同时含网络相关词),
* 其次额度/余额,再网络/超时,兜底 other。
*/
export function classifySendError(raw: string): SendErrorKind {
const s = String(raw ?? '')
if (/\b429\b|rate[_\s-]?limit|too\s*many\s*requests|限流|请求过于频繁/i.test(s)) return 'rate_limit'
if (/\b402\b|quota|insufficient|balance|billing|credit|额度|余额|欠费|配额/i.test(s)) return 'quota'
if (/timeout|timed?[_\s-]?out|network|connection|ECONN|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|\bDNS\b|超时|网络|连接/i.test(s)) return 'network'
return 'other'
}
// ── S1 子代理完成卡(per-conv 归集,消费后端 AiAgentFinished 事件) ──
//
// 背景:spawn_agent 每个子循环结束经统一出口挂父会话 emit 一条 AiAgentFinished
// (status/summary/rounds/tokens,后端 sub_agent.rs;status 字面量对齐后端 SubAgentStatus::as_str)。
// 本 Map 是跨会话完成卡的内存真相源,沿用 convStreamStates 同款 per-conv reactive Map 模式:
// 后台会话的完成事件也归集,切会话重进仍可见;MessageList 只读当前活跃会话的数组,不串视图。
// 内存态不落库(与待审批/流式态同口径),会话删除即清。
export type AgentFinishedStatus = 'completed' | 'truncated' | 'failed'
export interface AgentFinishedCard {
/** 子会话 id(形态 {parent}::agent-{n}),同会话多子代理各占一卡按此键控 */
agentId: string
summary: string
status: AgentFinishedStatus
rounds: number
tokens: number
finishedAt: number
}
export const convAgentFinished = reactive(new Map<string, AgentFinishedCard[]>())
/** 读某会话的完成卡数组;未出现过子代理返回空数组(渲染方零特判) */
export function getConvAgentFinished(convId: string | null | undefined): AgentFinishedCard[] {
if (!convId) return []
return convAgentFinished.get(convId) ?? []
}
/**
* 追加一条完成卡(按 agentId 幂等覆盖)。后端单一出口每子循环只 emit 一次,覆盖仅为防
* 监听重放双卡,不改变「同一会话多子代理多卡共存」语义;追加序即完成序。
*/
export function addConvAgentFinished(
convId: string | null | undefined,
card: Omit<AgentFinishedCard, 'finishedAt'>,
): void {
if (!convId || !card.agentId) return
let arr = convAgentFinished.get(convId)
if (!arr) {
arr = []
convAgentFinished.set(convId, arr)
}
const withTs: AgentFinishedCard = { ...card, finishedAt: Date.now() }
const idx = arr.findIndex(c => c.agentId === card.agentId)
if (idx >= 0) arr.splice(idx, 1, withTs)
else arr.push(withTs)
}
/** 清某会话完成卡(会话删除时调用,防 Map 持已删会话条目无限增长) */
export function clearConvAgentFinished(convId: string | null | undefined): void {
if (!convId) return
convAgentFinished.delete(convId)
}
+3 -3
View File
@@ -18,7 +18,7 @@ import { aiApi } from '@/api'
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers' import { t } from '@/i18n/i18n-helpers'
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream' import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
import { findToolCall } from './aiShared' import { findToolCall, removePendingApprovalById } from './aiShared'
import { dispatchApprovalIPC } from './useToolApproval' import { dispatchApprovalIPC } from './useToolApproval'
/** /**
@@ -90,8 +90,8 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
tc.result = t('ai.aiTool.rejectedHint') tc.result = t('ai.aiTool.rejectedHint')
} }
} }
// 同步从 pendingApprovals 移除(不等后端 AiApprovalResult 事件来移,防 loading 卡死) // 同步从待审批集合移除(含 per-conv 集合;不等后端 AiApprovalResult 事件来移,防 loading 卡死)
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== toolCallId) removePendingApprovalById(toolCallId)
} catch (e) { } catch (e) {
// IPC 未送达:用户已点过按钮,清看门狗(避免 130s 后误触发假错误)再回滚到失败态 // IPC 未送达:用户已点过按钮,清看门狗(避免 130s 后误触发假错误)再回滚到失败态
// TD-260621-03 per-conv:清审批 tc 所属会话的 timer(与上方 reset 同 conv,对称成对)。 // TD-260621-03 per-conv:清审批 tc 所属会话的 timer(与上方 reset 同 conv,对称成对)。
+17 -6
View File
@@ -9,7 +9,7 @@ import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
import { persistUiState } from './useAiPanel' import { persistUiState } from './useAiPanel'
import { setStreaming } from './streamingGuard' import { setStreaming } from './streamingGuard'
import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText, notifyConversationChanged } from './aiShared' import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText, notifyConversationChanged, setConvPendingApprovals, discardConvPendingApprovals } from './aiShared'
import { t } from '@/i18n/i18n-helpers' import { t } from '@/i18n/i18n-helpers'
import type { AiConversationDetail, AiMessage, AiToolCallInfo, ConvId, MessageId } from '@/api/types' import type { AiConversationDetail, AiMessage, AiToolCallInfo, ConvId, MessageId } from '@/api/types'
@@ -107,7 +107,8 @@ async function newConversation() {
loadMoreCursor.loading = false loadMoreCursor.loading = false
state.messages = [] state.messages = []
state.currentText = '' state.currentText = ''
state.pendingApprovals = [] // 新会话挂起为空:写其 per-conv 集合并投影视图(旧会话挂起留在集合里,切回时恢复,不被此清空覆盖)
setConvPendingApprovals(result.id, [])
setStreaming(false, { reason: 'newConversation' }) setStreaming(false, { reason: 'newConversation' })
// F-09 决策e(真并发):newConversation 对齐 switchConversation 并行语义——旧 conv 后台 loop 继续, // F-09 决策e(真并发):newConversation 对齐 switchConversation 并行语义——旧 conv 后台 loop 继续,
// 不清 generatingConvs(保留旧 conv 生成态跟踪,侧栏显双会话生成,AiCompleted/AiError 按 // 不清 generatingConvs(保留旧 conv 生成态跟踪,侧栏显双会话生成,AiCompleted/AiError 按
@@ -296,6 +297,9 @@ export async function switchConversation(id: string, force = false) {
const created = await aiApi.createConversation() const created = await aiApi.createConversation()
if (mySwitchId !== _latestSwitchId) return if (mySwitchId !== _latestSwitchId) return
void appSettings.set('df-ai-active-conv', created.id) void appSettings.set('df-ai-active-conv', created.id)
// 兜底置 activeId:此路径原只持久化不写 state.activeConversationId,旧(不存在)id 残留
// 会让后续所有事件被 isCurrent 判为非当前而丢弃或误路由
state.activeConversationId = created.id
// 用新对话 id 重走后续逻辑 // 用新对话 id 重走后续逻辑
detail = { id: created.id as ConvId, title: null, messages: '[]' } detail = { id: created.id as ConvId, title: null, messages: '[]' }
// 新建对话无历史,load_more 游标复位 // 新建对话无历史,load_more 游标复位
@@ -304,6 +308,8 @@ export async function switchConversation(id: string, force = false) {
loadMoreCursor.convId = created.id loadMoreCursor.convId = created.id
loadMoreCursor.loading = false loadMoreCursor.loading = false
state.messages = [] state.messages = []
// 新会话挂起为空(同 newConversation:清视图投影并登记空集合,旧会话挂起保留在集合里)
setConvPendingApprovals(created.id, [])
notifyConversationChanged() notifyConversationChanged()
scheduleConversationsRefresh() scheduleConversationsRefresh()
return return
@@ -416,7 +422,8 @@ export async function switchConversation(id: string, force = false) {
// 含工具卡,或目标会话生成中(可能在途产生待审批)才需要恢复挂起。纯文本/无工具历史直接置空。 // 含工具卡,或目标会话生成中(可能在途产生待审批)才需要恢复挂起。纯文本/无工具历史直接置空。
const hasToolCalls = state.messages.some(m => (m.toolCalls ?? []).length > 0) const hasToolCalls = state.messages.some(m => (m.toolCalls ?? []).length > 0)
if (!targetGen && !hasToolCalls) { if (!targetGen && !hasToolCalls) {
state.pendingApprovals = [] // 纯文本会话挂起为空:写集合并投影视图(其他会话的挂起集合不受影响)
setConvPendingApprovals(id, [])
} else { } else {
try { try {
const pending = await aiApi.pendingToolCalls(id) const pending = await aiApi.pendingToolCalls(id)
@@ -459,11 +466,14 @@ export async function switchConversation(id: string, force = false) {
} }
} }
} }
state.pendingApprovals = restored // per-conv 合并语义:整体替换目标会话自己的挂起集合;其他会话的集合不动,
// 后台会话经事件到达的挂起不被切换动作覆盖丢失
setConvPendingApprovals(id, restored)
} else { } else {
state.pendingApprovals = [] setConvPendingApprovals(id, [])
} }
} catch { } catch {
// 挂起查询 IPC 失败:后端真值未知,仅清视图投影(集合保留,切走再切回时重新查询校正)
state.pendingApprovals = [] state.pendingApprovals = []
} }
} }
@@ -508,9 +518,10 @@ async function deleteConversation(id: string) {
) )
// 注意:op 返回 void,成功=undefined、失败=null(不能 `!ok`——undefined 也 falsy 会误判失败) // 注意:op 返回 void,成功=undefined、失败=null(不能 `!ok`——undefined 也 falsy 会误判失败)
if (ok === null) return // 失败已回滚 + 推气泡,保持原视图 if (ok === null) return // 失败已回滚 + 推气泡,保持原视图
// 清该会话的 stream state 与 delta 去重记录,防 per-conv Map 无限增长 // 清该会话的 stream state 与 delta 去重记录,防 per-conv Map 无限增长;挂起集合同步作废
clearConvStreamState(id) clearConvStreamState(id)
clearLastDelta(id) clearLastDelta(id)
discardConvPendingApprovals(id)
if (wasActive) { if (wasActive) {
void appSettings.remove('df-ai-active-conv') void appSettings.remove('df-ai-active-conv')
// 删除活跃会话 → load_more 游标复位(无 active 视图) // 删除活跃会话 → load_more 游标复位(无 active 视图)
+52 -4
View File
@@ -15,8 +15,8 @@ import { listen, emit } from '@tauri-apps/api/event'
import { aiApi } from '@/api' import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings' import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
import { nextMsgId, setConvState, convStates, switchingConvs, getConvStreamState, setConvCurrentText, setConvStreaming, clearConvStreamState, clearAllApprovalTimers, clearAllToolSlowTimers, flushCurrentText, friendlyError, notifyConversationChanged } from './aiShared' import { nextMsgId, setConvState, convStates, switchingConvs, getConvStreamState, setConvCurrentText, setConvStreaming, clearConvStreamState, clearAllApprovalTimers, clearAllToolSlowTimers, flushCurrentText, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, addConvAgentFinished, clearConvAgentFinished, type AgentFinishedStatus } from './aiShared'
import { resetStreamWatchdog, clearAllStreamWatchdogs } from './useAiStream' import { resetStreamWatchdog, clearAllStreamWatchdogs, clearResumeAttempts } from './useAiStream'
import { setStreaming } from './streamingGuard' import { setStreaming } from './streamingGuard'
import { scheduleConversationsRefresh } from './useAiConversations' import { scheduleConversationsRefresh } from './useAiConversations'
import { handleStreamingEvent } from './useAiStreamingEvents' import { handleStreamingEvent } from './useAiStreamingEvents'
@@ -44,6 +44,13 @@ const appSettings = useAppSettingsStore()
// 模块级 Set 复用,避免每事件(delta/token 高频)新建数组字面量做 includes。 // 模块级 Set 复用,避免每事件(delta/token 高频)新建数组字面量做 includes。
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiDirAuthRequired', 'AiHelpRequired']) const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiDirAuthRequired', 'AiHelpRequired'])
/** 全局审批类事件:跨会话放行,不因非当前会话被过滤。审批挂起是等用户决策的跨会话资产,
* 丢弃后用户在切回该会话前没有任何感知与处理入口;归口仍按 conversation_id 写 per-conv
* 待审批集合(aiShared.addPendingApproval),不污染当前视图。 */
function isGlobalApprovalEvent(event: AiChatEvent): boolean {
return event.type === 'AiApprovalRequired' || event.type === 'AiDirAuthRequired'
}
/** L2 状态机域:消费 AiConvStateChanged,写 convStates(per-conv conv_state 真相源)。 /** L2 状态机域:消费 AiConvStateChanged,写 convStates(per-conv conv_state 真相源)。
* 返回 true=已处理;无 convId 无法路由时忽略(防误写全局态)。 */ * 返回 true=已处理;无 convId 无法路由时忽略(防误写全局态)。 */
function handleConvStateEvent(event: AiChatEvent): boolean { function handleConvStateEvent(event: AiChatEvent): boolean {
@@ -54,6 +61,34 @@ function handleConvStateEvent(event: AiChatEvent): boolean {
return true return true
} }
/** S1 子代理完成域:消费 AiAgentFinished,写 per-conv 完成卡集合(aiShared.convAgentFinished)。
* AiChatEvent 联合类型(types.ts 归口)暂未收编该事件,经局部 shape 窄化取字段,不改 types。
* 归集挂在 isCurrent 守卫之前(同 AiConvStateChanged 范式):后台会话的子代理结束也要落卡,
* 切回仍可见;「不串当前视图」由消费侧只读当前会话数组天然保证,无需在此按活跃会话过滤。 */
function handleAgentFinishedEvent(event: AiChatEvent): boolean {
if ((event as { type?: string }).type !== 'AiAgentFinished') return false
const ev = event as unknown as {
agent_id: string
summary: string
status: AgentFinishedStatus
rounds: number
tokens: number
}
const convId = event.conversation_id
if (!convId || !ev.agent_id) return true
addConvAgentFinished(convId, {
agentId: ev.agent_id,
summary: ev.summary,
status: ev.status,
rounds: ev.rounds,
tokens: ev.tokens,
})
// 子代理执行是该会话的真实活跃信号(父 loop 在 spawn_agent 内等待):此前该事件穿透到
// 活跃事件分支会重置看门狗,现提前归集后显式补上,保持既有不误判停滞的语义。
resetStreamWatchdog(convId)
return true
}
/** 跨端用户消息域:miniapp→桌面 user 气泡同步(仅处理 AiUserMessage)。 /** 跨端用户消息域:miniapp→桌面 user 气泡同步(仅处理 AiUserMessage)。
* 去重:末条已是同 content 的 user 则跳过(防本地乐观 push + 事件回灌双气泡)。 */ * 去重:末条已是同 content 的 user 则跳过(防本地乐观 push + 事件回灌双气泡)。 */
function handleUserMessageEvent(event: AiChatEvent): boolean { function handleUserMessageEvent(event: AiChatEvent): boolean {
@@ -91,9 +126,12 @@ export function handleEvent(event: AiChatEvent) {
if (handleConvStateEvent(event)) return if (handleConvStateEvent(event)) return
// 跨端用户消息同步:在 isCurrent 守卫之前处理(补 user 气泡信号,非流式活跃事件) // 跨端用户消息同步:在 isCurrent 守卫之前处理(补 user 气泡信号,非流式活跃事件)
if (handleUserMessageEvent(event)) return if (handleUserMessageEvent(event)) return
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏 // S1 子代理完成卡归集:在 isCurrent 守卫之前处理(per-conv 落卡,后台会话不丢卡)
if (handleAgentFinishedEvent(event)) return
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏;
// 全局审批类事件例外(isGlobalApprovalEvent),放行到下方按域分派
const isCurrent = !convId || convId === state.activeConversationId const isCurrent = !convId || convId === state.activeConversationId
if (!isCurrent) { if (!isCurrent && !isGlobalApprovalEvent(event)) {
if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') { if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') {
// 非当前会话终止:收敛会话状态(删 Map 项回不在生成) // 非当前会话终止:收敛会话状态(删 Map 项回不在生成)
convStates.delete(convId || '') convStates.delete(convId || '')
@@ -101,6 +139,12 @@ export function handleEvent(event: AiChatEvent) {
// switchConversation 恢复进 currentText,下轮发送的防御 flush 又把它写进上一轮气泡, // switchConversation 恢复进 currentText,下轮发送的防御 flush 又把它写进上一轮气泡,
// 把已完成回复覆盖为残片;回复正文以切换时 DB 加载为准,流式态无需保留。 // 把已完成回复覆盖为残片;回复正文以切换时 DB 加载为准,流式态无需保留。
clearConvStreamState(convId || '') clearConvStreamState(convId || '')
// 对齐前台 cleanupTerminatedConversation 的 per-conv 收敛:清 delta 去重记录(残留记录
// 会在切回后把下一轮首个与上轮末个相同内容的 delta 误判重复丢弃)、待审批集合(终止即作废,
// 切回时由 pendingToolCalls 从后端审计表按需重建)、自动续跑计数(终止即清,不带入新轮)
clearLastDelta(convId || '')
discardConvPendingApprovals(convId || '')
clearResumeAttempts(convId || undefined)
// FE1:后台会话终止只刷新列表(防抖调度,避免与 AiCompleted 收尾的 notify 双拉)。 // FE1:后台会话终止只刷新列表(防抖调度,避免与 AiCompleted 收尾的 notify 双拉)。
scheduleConversationsRefresh() scheduleConversationsRefresh()
// 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列) // 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列)
@@ -159,6 +203,10 @@ export async function startListener() {
setStreaming(false, { convId: deleted, reason: 'conv-deleted' }) setStreaming(false, { convId: deleted, reason: 'conv-deleted' })
convStates.delete(deleted) convStates.delete(deleted)
} }
// S1:会话删除连带清其完成卡(Map 不持已删会话条目;存在即清,不限当前视图)
if (deleted) {
clearConvAgentFinished(deleted)
}
}) })
_unlistenApprovalClear = await listen('ai-approval-clear-timers', () => { clearAllApprovalTimers() }) _unlistenApprovalClear = await listen('ai-approval-clear-timers', () => { clearAllApprovalTimers() })
emit('ai-client-ready', { lastConvId: state.activeConversationId }) emit('ai-client-ready', { lastConvId: state.activeConversationId })
+15 -4
View File
@@ -9,9 +9,9 @@ import { emit } from '@tauri-apps/api/event'
import { useAppSettingsStore } from '@/stores/appSettings' import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers' import { t } from '@/i18n/i18n-helpers'
import { nextMsgId, convStates, clearApprovalTimer, flushCurrentText, clearAllToolSlowTimers, friendlyError, notifyConversationChanged } from './aiShared' import { nextMsgId, convStates, clearApprovalTimer, flushCurrentText, clearAllToolSlowTimers, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals } from './aiShared'
import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingDirAuths } from './useAiPendingState' import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingDirAuths } from './useAiPendingState'
import { clearStreamWatchdog } from './useAiStream' import { clearStreamWatchdog, clearResumeAttempts } from './useAiStream'
import { setStreaming } from './streamingGuard' import { setStreaming } from './streamingGuard'
import type { AiChatEvent, AiMessage, MessageId } from '@/api/types' import type { AiChatEvent, AiMessage, MessageId } from '@/api/types'
@@ -52,6 +52,12 @@ function isShowTokenUsage(): boolean {
*/ */
function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | 'AiError' | 'AiHelpRequired') { function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | 'AiError' | 'AiHelpRequired') {
clearStreamWatchdog(convId || undefined) clearStreamWatchdog(convId || undefined)
// 正常收尾路径清该会话的 delta 去重记录:flushCurrentText 只清活跃会话的,这里按 convId
// 显式清一遍兜住 convId 与 active 不一致或事件缺 convId 的时序,防残留记录误丢下轮同内容 delta
clearLastDelta(convId || undefined)
// 正常收尾清自动续跑计数:计数只服务于单轮内连续超时的风暴防护,终态后必须归零,
// 否则陈旧计数会让下一轮的正常超时提前触发强制收敛(清零只放收尾路径,不在发消息时清)
clearResumeAttempts(convId || undefined)
clearAllToolSlowTimers() // 整轮/错误/求助结束清全部工具慢执行计时器与已提示集合 clearAllToolSlowTimers() // 整轮/错误/求助结束清全部工具慢执行计时器与已提示集合
clearTextIdleTimer() // 清文本空闲定时器 + 置 textIdle=true(整轮结束不该有活跃信号残留) clearTextIdleTimer() // 清文本空闲定时器 + 置 textIdle=true(整轮结束不该有活跃信号残留)
flushCurrentText() flushCurrentText()
@@ -86,6 +92,9 @@ function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | '
const removedApprovals = state.pendingApprovals.filter(p => p.conversationId === convId) const removedApprovals = state.pendingApprovals.filter(p => p.conversationId === convId)
state.pendingApprovals = state.pendingApprovals.filter(p => !p.conversationId || p.conversationId !== convId) state.pendingApprovals = state.pendingApprovals.filter(p => !p.conversationId || p.conversationId !== convId)
for (const p of removedApprovals) clearApprovalTimer(p.id) for (const p of removedApprovals) clearApprovalTimer(p.id)
// 本会话已终止,其 per-conv 挂起集合作废(仅删集合项,视图保留上面无法归属的旧项;
// 切回该会话时由 pendingToolCalls 从后端审计表按需重建)
discardConvPendingApprovals(convId || undefined)
// 清分离窗口生成态快照(per-conv key,清本会话;兼容旧单 key) // 清分离窗口生成态快照(per-conv key,清本会话;兼容旧单 key)
if (convId) { if (convId) {
localStorage.removeItem(`df-ai-gen-${convId}`) localStorage.removeItem(`df-ai-gen-${convId}`)
@@ -102,12 +111,14 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean {
console.warn('[FE-AICOMPLETED] 收到 conv=', event.conversation_id, 'streaming=', state.streaming) console.warn('[FE-AICOMPLETED] 收到 conv=', event.conversation_id, 'streaming=', state.streaming)
cleanupTerminatedConversation(event.conversation_id || '', 'AiCompleted') cleanupTerminatedConversation(event.conversation_id || '', 'AiCompleted')
console.warn('[FE-AICOMPLETED] cleanup 后 streaming=', state.streaming) console.warn('[FE-AICOMPLETED] cleanup 后 streaming=', state.streaming)
// 流中途失败保文:后端 emit AiCompleted(incomplete=true),前端追加系统提示气泡 // 流中途失败保文:后端 emit AiCompleted(incomplete=true),前端追加系统提示气泡。
// incomplete_reason 细分文案:"max_rounds"=达上限自动收尾(非网络中断,语义不同),
// 其他/缺省=网络中断等异常走原提示。
if (event.incomplete) { if (event.incomplete) {
state.messages.push({ state.messages.push({
id: `incomplete-${nextMsgId()}` as MessageId, id: `incomplete-${nextMsgId()}` as MessageId,
role: 'assistant', role: 'assistant',
content: t('ai.responseIncomplete'), content: event.incomplete_reason === 'max_rounds' ? t('ai.responseIncompleteMaxRounds') : t('ai.responseIncomplete'),
timestamp: Date.now(), timestamp: Date.now(),
}) })
} }
+6 -1
View File
@@ -14,7 +14,7 @@ import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
import { setStreaming } from './streamingGuard' import { setStreaming } from './streamingGuard'
import { syncHighRiskTools } from './useToolApproval' import { syncHighRiskTools } from './useToolApproval'
import { nextMsgId } from './aiShared' import { nextMsgId, setConvPendingApprovals } from './aiShared'
import { t } from '@/i18n/i18n-helpers' import { t } from '@/i18n/i18n-helpers'
import type { AiMessage, AiProviderConfig } from '@/api/types' import type { AiMessage, AiProviderConfig } from '@/api/types'
@@ -188,7 +188,12 @@ async function clearChat() {
await aiApi.clearChat() await aiApi.clearChat()
state.messages = [] state.messages = []
state.currentText = '' state.currentText = ''
// 清空的是当前会话:其 per-conv 挂起集合同步清(其他会话集合不动)
if (state.activeConversationId) {
setConvPendingApprovals(state.activeConversationId, [])
} else {
state.pendingApprovals = [] state.pendingApprovals = []
}
setStreaming(false, { reason: 'clearChat' }) setStreaming(false, { reason: 'clearChat' })
} catch (e) { } catch (e) {
// A2-B9(G3.2):清空失败需反馈,原裸 await 失败静默(异常进 unhandledrejection,用户无感)。 // A2-B9(G3.2):清空失败需反馈,原裸 await 失败静默(异常进 unhandledrejection,用户无感)。
+20 -15
View File
@@ -2,7 +2,7 @@
//! //!
//! 审批链(approveToolCall/batchApprove)已拆分到 useAiApproval.ts(fe-arch P0-1)。 //! 审批链(approveToolCall/batchApprove)已拆分到 useAiApproval.ts(fe-arch P0-1)。
//! //!
//! B-260616-02 L2 发送韧性:三级降级 //! L2 发送韧性:三级降级
//! L0 normal:后端 idle → 直接发送 //! L0 normal:后端 idle → 直接发送
//! L1 queued: 后端 busy → 入队等待(≤30s 正常排队) //! L1 queued: 后端 busy → 入队等待(≤30s 正常排队)
//! L2 force: 排队>30s → 弹 confirm → 用户确认后调 ai_chat_force_send 复位残留并强制发出 //! L2 force: 排队>30s → 弹 confirm → 用户确认后调 ai_chat_force_send 复位残留并强制发出
@@ -21,7 +21,7 @@ import { aiApi } from '@/api'
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
import type { ContentPart, AiMessage, MentionSpan, MessageId } from '@/api/types' import type { ContentPart, AiMessage, MentionSpan, MessageId } from '@/api/types'
import { t } from '@/i18n/i18n-helpers' import { t } from '@/i18n/i18n-helpers'
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream' import { resetStreamWatchdog, clearStreamWatchdog, clearResumeAttempts } from './useAiStream'
import { setStreaming } from './streamingGuard' import { setStreaming } from './streamingGuard'
import { startListener } from './useAiEvents' import { startListener } from './useAiEvents'
import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang, convStates, setConvCurrentText, setConvStreaming, flushCurrentText } from './aiShared' import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang, convStates, setConvCurrentText, setConvStreaming, flushCurrentText } from './aiShared'
@@ -166,7 +166,8 @@ async function regenerate() {
// 只清当前会话的排队消息,不误伤其他会话 // 只清当前会话的排队消息,不误伤其他会话
const regenConvId = state.activeConversationId const regenConvId = state.activeConversationId
state.queue = state.queue.filter(q => q.conversationId !== regenConvId) state.queue = state.queue.filter(q => q.conversationId !== regenConvId)
resetStreamWatchdog() // 会话态走 per-conv 键(与 doSend 一致),不再落 legacy 单 timer fallback
resetStreamWatchdog(regenConvId || undefined)
await startListener() await startListener()
const lang = resolveAiLang() const lang = resolveAiLang()
@@ -174,7 +175,7 @@ async function regenerate() {
if (!convId) { if (!convId) {
// 无活跃对话:回滚占位,报错 // 无活跃对话:回滚占位,报错
setStreaming(false, { reason: 'regenerate-no-conv' }) setStreaming(false, { reason: 'regenerate-no-conv' })
clearStreamWatchdog() clearStreamWatchdog(regenConvId || undefined)
state.messages = state.messages.filter(m => m.id !== aiMsgId) state.messages = state.messages.filter(m => m.id !== aiMsgId)
throw new Error(t('aiChat.regenerateUnavailable')) throw new Error(t('aiChat.regenerateUnavailable'))
} }
@@ -183,7 +184,7 @@ async function regenerate() {
} catch (e) { } catch (e) {
// IPC 失败:回滚 streaming + 移除占位气泡(旧回复后端已弹但前端先弹了,保持弹后态) // IPC 失败:回滚 streaming + 移除占位气泡(旧回复后端已弹但前端先弹了,保持弹后态)
setStreaming(false, { convId, reason: 'regenerate-ipc-fail' }) setStreaming(false, { convId, reason: 'regenerate-ipc-fail' })
clearStreamWatchdog() clearStreamWatchdog(convId || undefined)
state.messages = state.messages.filter(m => m.id !== aiMsgId) state.messages = state.messages.filter(m => m.id !== aiMsgId)
throw e throw e
} }
@@ -238,14 +239,15 @@ async function editMessage(newMessage: string) {
// 只清当前会话的排队消息,不误伤其他会话 // 只清当前会话的排队消息,不误伤其他会话
const editConvId = state.activeConversationId const editConvId = state.activeConversationId
state.queue = state.queue.filter(q => q.conversationId !== editConvId) state.queue = state.queue.filter(q => q.conversationId !== editConvId)
resetStreamWatchdog() // 会话态走 per-conv 键(与 doSend 一致),不再落 legacy 单 timer fallback
resetStreamWatchdog(editConvId || undefined)
await startListener() await startListener()
const lang = resolveAiLang() const lang = resolveAiLang()
const convId = state.activeConversationId const convId = state.activeConversationId
if (!convId) { if (!convId) {
setStreaming(false, { reason: 'editMessage-no-conv' }) setStreaming(false, { reason: 'editMessage-no-conv' })
clearStreamWatchdog() clearStreamWatchdog(editConvId || undefined)
state.messages = state.messages.filter(m => m.id !== aiMsgId) state.messages = state.messages.filter(m => m.id !== aiMsgId)
throw new Error(t('aiChat.editNotLastUser')) throw new Error(t('aiChat.editNotLastUser'))
} }
@@ -254,7 +256,7 @@ async function editMessage(newMessage: string) {
} catch (e) { } catch (e) {
// IPC 失败:回滚 streaming + 移除占位气泡(被截断的旧回复后端已标 truncated,前端已移除) // IPC 失败:回滚 streaming + 移除占位气泡(被截断的旧回复后端已标 truncated,前端已移除)
setStreaming(false, { convId, reason: 'editMessage-ipc-fail' }) setStreaming(false, { convId, reason: 'editMessage-ipc-fail' })
clearStreamWatchdog() clearStreamWatchdog(convId || undefined)
state.messages = state.messages.filter(m => m.id !== aiMsgId) state.messages = state.messages.filter(m => m.id !== aiMsgId)
throw e throw e
} }
@@ -395,7 +397,7 @@ export async function tryForceSend(confirmFn: (msg: string) => Promise<boolean>,
state.queue.splice(idx, 1) state.queue.splice(idx, 1)
// 清看门狗:doSend 内 force_send 路径会重设 streaming=true 并重启看门狗, // 清看门狗:doSend 内 force_send 路径会重设 streaming=true 并重启看门狗,
// 此处无需前置 false(前置 false 会触发消息列表 watch 清流式块再重建,产生瞬态抖动) // 此处无需前置 false(前置 false 会触发消息列表 watch 清流式块再重建,产生瞬态抖动)
clearStreamWatchdog() clearStreamWatchdog(targetConv || undefined)
try { try {
await sendMessage(first.text, first.skill, true, first.parts, first.spans, first.conversationId) await sendMessage(first.text, first.skill, true, first.parts, first.spans, first.conversationId)
return true return true
@@ -411,7 +413,7 @@ export async function tryForceSend(confirmFn: (msg: string) => Promise<boolean>,
conversationId: first.conversationId, conversationId: first.conversationId,
}) })
setStreaming(false, { convId: targetConv ?? state.activeConversationId, reason: 'tryForceSend-fail' }) setStreaming(false, { convId: targetConv ?? state.activeConversationId, reason: 'tryForceSend-fail' })
clearStreamWatchdog() clearStreamWatchdog(targetConv || undefined)
console.error('[AI] tryForceSend force_send 失败,消息已回填队首:', e) console.error('[AI] tryForceSend force_send 失败,消息已回填队首:', e)
return false return false
} }
@@ -509,8 +511,11 @@ async function stopChat() {
// 又会因 flushCurrentText 的非流式守卫跳过回填,不在此处 flush 则部分回复随停止丢失。 // 又会因 flushCurrentText 的非流式守卫跳过回填,不在此处 flush 则部分回复随停止丢失。
flushCurrentText() flushCurrentText()
setStreaming(false, { convId: state.activeConversationId, reason: 'stopChat' }) // 本地先复位,不依赖后端 AiCompleted(审批态看门狗已 clear,竞态丢失则卡死) setStreaming(false, { convId: state.activeConversationId, reason: 'stopChat' }) // 本地先复位,不依赖后端 AiCompleted(审批态看门狗已 clear,竞态丢失则卡死)
clearStreamWatchdog() // 清残留看门狗 // 停止属正常收尾:按目标会话清 per-conv 看门狗与自动续跑计数(计数只放收尾路径清,
// UX-260616-05 决策 a(逐条续发):不再清队列,保留供 AiCompleted 链式触发 drainQueue 续发。 // 不在发消息时清,否则会掩盖单轮内的真实重试次数)
clearStreamWatchdog(state.activeConversationId || undefined)
clearResumeAttempts(state.activeConversationId || undefined)
// 决策 a(逐条续发):不再清队列,保留供 AiCompleted 链式触发 drainQueue 续发。
// 后端 agentic/mod.rs emit AiCompleted 注释明确「保证前端收事件时后端已可接下一条,队列续发不被拒」; // 后端 agentic/mod.rs emit AiCompleted 注释明确「保证前端收事件时后端已可接下一条,队列续发不被拒」;
// 当前未完成回复已入库(agentic/mod.rs save_conversation)保留为截断回复。 // 当前未完成回复已入库(agentic/mod.rs save_conversation)保留为截断回复。
try { try {
@@ -519,7 +524,7 @@ async function stopChat() {
} catch (e) { } catch (e) {
// stop 信号未送达后端:重启看门狗(后端继续生成时活跃事件会重置它,真 hang 则 130s 后兜底收尾), // stop 信号未送达后端:重启看门狗(后端继续生成时活跃事件会重置它,真 hang 则 130s 后兜底收尾),
// 避免后端仍在跑却无任何恢复路径(原 clearStreamWatchdog 已把兜底关掉)。 // 避免后端仍在跑却无任何恢复路径(原 clearStreamWatchdog 已把兜底关掉)。
resetStreamWatchdog() resetStreamWatchdog(state.activeConversationId || undefined)
const errMsg = e instanceof Error ? e.message : String(e) const errMsg = e instanceof Error ? e.message : String(e)
state.messages.push({ state.messages.push({
id: `stop-err-${nextMsgId()}`, id: `stop-err-${nextMsgId()}`,
@@ -540,8 +545,8 @@ export function useAiSend() {
drainQueue, drainQueue,
cancelQueued, cancelQueued,
clearQueue, clearQueue,
editQueued, // UX-260616-06 editQueued, //
sendQueuedNow, // UX-260616-07 sendQueuedNow, //
stopChat, stopChat,
isQueueTimedOut, isQueueTimedOut,
tryForceSend, tryForceSend,
+117 -16
View File
@@ -6,14 +6,16 @@
//! 耦合说明: //! 耦合说明:
//! - sendMessage 启动生成时 resetStreamWatchdog() 启动计时 //! - sendMessage 启动生成时 resetStreamWatchdog() 启动计时
//! - handleEvent 每个活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误 clear //! - handleEvent 每个活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误 clear
//! - 超时回调 onStreamTimeout 直接改 state 并补一条错误消息 //! - 超时回调 onStreamTimeout 先探测后端真实状态:loop 活着/已续跑则静默重挂看门狗,
//! 仅后端已收敛/探测失败/连续超时达上限才强制收敛+补气泡
//! //!
//! 依赖:nextMsgId 取自 aiShared(原从 useAiEvents 取,构成 useAiEvents ↔ useAiStream //! 依赖:nextMsgId 取自 aiShared(原从 useAiEvents 取,构成 useAiEvents ↔ useAiStream
//! 循环依赖;下沉到 aiShared 后本模块不再 import useAiEvents,环消除) //! 循环依赖;下沉到 aiShared 后本模块不再 import useAiEvents,环消除)
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
import { aiApi } from '@/api/ai'
import { t } from '@/i18n/i18n-helpers' import { t } from '@/i18n/i18n-helpers'
import { nextMsgId, convStates, flushCurrentText } from './aiShared' import { nextMsgId, convStates, flushCurrentText, clearLastDelta, getConvState, getConvStreamState, clearConvStreamState } from './aiShared'
import type { MessageId } from '@/api/types' import type { MessageId } from '@/api/types'
import { forceResetStreaming } from './streamingGuard' import { forceResetStreaming } from './streamingGuard'
import { emit } from '@tauri-apps/api/event' import { emit } from '@tauri-apps/api/event'
@@ -32,28 +34,45 @@ export const STREAM_TIMEOUT_MS = 90000
// 背景(F-09 多会话并发):原单 timer 下,A 会话 delta 重置计时器会顶掉 B 会话已挂起的 timer, // 背景(F-09 多会话并发):原单 timer 下,A 会话 delta 重置计时器会顶掉 B 会话已挂起的 timer,
// 或 A 超时连累 B 的 generatingConvs(全清)。per-conv Map 后,各会话独立计时,互不顶替/连累。 // 或 A 超时连累 B 的 generatingConvs(全清)。per-conv Map 后,各会话独立计时,互不顶替/连累。
// //
// 向后兼容(避撞 F-09 禁动域):convId 可选。禁动域 useAiSend.ts/useAiApproval.ts 的 14 处调用 // 向后兼容(双轨迁移收尾):convId 可选。useAiSend/useAiEvents/useAiApproval 的调用点均已带
// 不传 convId,走 _legacyWatchdog fallback(模块级单 timer,行为对齐旧版);本批域 useAiEvents.ts // 会话上下文传 convId 走 per-conv Map(无活跃会话时传 undefined 才落 legacy 单 timer fallback)。
// 的调用点(已知 convId)传 convId 走 per-conv Map。两路并存,等 F-09 统一迁移调用点后可删 fallback。
// //
// per-conv 路径与 legacy 路径互不干扰:convId 路径只动 Map,legacy 路径只动 _legacyWatchdog。 // per-conv 路径与 legacy 路径互不干扰:convId 路径只动 Map,legacy 路径只动 _legacyWatchdog。
// 实际并发场景:F-09 已让 useAiEvents 按 conversation_id 路由事件(handleEvent:546 isCurrent 判定), // 实际并发场景:F-09 已让 useAiEvents 按 conversation_id 路由事件(handleEvent isCurrent 判定),
// 非 current conv 的事件不触发 reset/clear(reset/clear 在 isCurrent 分支内调用),故 per-conv // 非 current conv 的事件不触发 reset/clear(reset/clear 在 isCurrent 分支内调用),故 per-conv
// 路径天然只跟踪当前展示会话的活跃 timer,跨会话切换时旧 conv 的 timer 由其 AiCompleted/AiError 精确清。 // 路径天然只跟踪当前展示会话的活跃 timer,跨会话切换时旧 conv 的 timer 由其 AiCompleted/AiError 精确清。
const _watchdogTimers = new Map<string, ReturnType<typeof setTimeout>>() const _watchdogTimers = new Map<string, ReturnType<typeof setTimeout>>()
// 禁动域 fallback 单 timer(useAiSend/useAiApproval 无 convId 调用走此,行为对齐旧版)。 // 禁动域 fallback 单 timer(调用点无活跃会话上下文传 undefined 时走此,行为对齐旧版)。
let _legacyWatchdog: ReturnType<typeof setTimeout> | null = null let _legacyWatchdog: ReturnType<typeof setTimeout> | null = null
// 看门狗超时自动续跑计数:同一会话连续超时且探测到后端活着/已续跑的次数(达上限降级强制收敛)。
// 真实活跃事件到达 resetStreamWatchdog 即清零(链路已活,计数失去意义)。
const _resumeAttempts = new Map<string, number>()
// 自动续跑上限:超限说明前端事件链路大概率已死(后端活着也收不到事件),静默等无意义
const MAX_AUTO_RESUME = 3
/** /**
* 看门狗超时回调:收尾 streaming 态并补错误消息(走 forceResetStreaming 兜底复位, * 清某会话的自动续跑计数(正常收尾 completed/error/stop 路径调用)。
* 对齐 [[devflow-generating-statemachine]])。 * 计数只服务于「单轮内连续超时」的风暴防护,终态后必须归零,防陈旧计数让下一轮的首次超时
* 被误判为连续超时而提前强制收敛;刻意不提供发消息时清零的入口(发消息清会掩盖真实重试次数)。
*/
export function clearResumeAttempts(convId?: string | null): void {
if (convId) _resumeAttempts.delete(convId)
}
/**
* 强制收敛:清 streaming/convStates/watchdog + 补提示气泡(原 onStreamTimeout 主体,
* 走 forceResetStreaming 兜底复位,对齐 [[devflow-generating-statemachine]])。
* *
* TD-260621-01 per-conv:携带 convId 时,forceResetStreaming(reason, convId) 经 setStreaming * TD-260621-01 per-conv:携带 convId 时,forceResetStreaming(reason, convId) 经 setStreaming
* 仅复位该会话 streaming 态。批4 双轨收口后 generatingConvs 已退役,convStates 项由各会话 * 仅复位该会话 streaming 态。批4 双轨收口后 generatingConvs 已退役,convStates 项由各会话
* 收尾事件精确管理(on/off 后端均 emit 收尾态),本回调不再触碰生成态集合。convId 为空 * 收尾事件精确管理(on/off 后端均 emit 收尾态),本回调不再触碰生成态集合。convId 为空
* (legacy fallback 路径)时全局兜底,行为对齐旧版。 * (legacy fallback 路径)时全局兜底,行为对齐旧版。
* 补气泡仅限当前视图会话:后台会话静默收敛(状态照常清理,提示气泡落进无关视图属错位),
* 见下方实现处守卫注释。
* contentOverride 覆盖补气泡文案(后端已收敛的轻收敛提示用),缺省按 hasCompletedTool 区分中断文案。
*/ */
export function onStreamTimeout(convId?: string) { function forceConverge(convId?: string, contentOverride?: string) {
// BUG-2026-07-17 根治性重构:状态机必须能自我收敛。 // BUG-2026-07-17 根治性重构:状态机必须能自我收敛。
// //
// 原问题:streaming=true 写在多个入口(doSend/regenerate/edit/L1入队), // 原问题:streaming=true 写在多个入口(doSend/regenerate/edit/L1入队),
@@ -67,6 +86,24 @@ export function onStreamTimeout(convId?: string) {
// 不管 stillGenerating 检测(即使已经 false 也再清理一次,完备包底)。 // 不管 stillGenerating 检测(即使已经 false 也再清理一次,完备包底)。
console.warn(`[AI-Watchdog] 超时触发,强制收敛状态${convId ? `(convId=${convId})` : '(全局)'}`) console.warn(`[AI-Watchdog] 超时触发,强制收敛状态${convId ? `(convId=${convId})` : '(全局)'}`)
// per-conv 触发守卫(仅 convId 路径,legacy 全局兜底不设防):会话已不存在(已从列表删除
// 且非当前视图)或已收敛(conv_state 无非终态且 per-conv streaming 已 false)时,真实收尾/
// 删除路径早已清理过,迟到或重复触发的 timer 只清残留计时,直接返回。不设防的后果:
// 对已删会话收敛会在当前视图推出幽灵错误气泡;对已收敛会话会连环补重复的错误气泡。
if (convId) {
const convExists = convId === state.activeConversationId
|| state.conversations.some(c => c.id === convId)
const alreadyConverged = !getConvState(convId) && !(getConvStreamState(convId)?.streaming ?? false)
if (!convExists || alreadyConverged) {
clearStreamWatchdog(convId)
_resumeAttempts.delete(convId)
clearLastDelta(convId)
clearConvStreamState(convId)
convStates.delete(convId)
return
}
}
// 先把已流出的部分回复回填气泡再清态:forceResetStreaming 会清 currentText,streaming // 先把已流出的部分回复回填气泡再清态:forceResetStreaming 会清 currentText,streaming
// 翻 false 又清流式分块,不 flush 则屏幕上已显示的部分正文一并蒸发(内容空白消失)。 // 翻 false 又清流式分块,不 flush 则屏幕上已显示的部分正文一并蒸发(内容空白消失)。
// flushCurrentText 只认当前活跃会话的流式态,仅本会话是当前视图时才 flush。 // flushCurrentText 只认当前活跃会话的流式态,仅本会话是当前视图时才 flush。
@@ -82,6 +119,9 @@ export function onStreamTimeout(convId?: string) {
} }
} }
clearStreamWatchdog(convId) clearStreamWatchdog(convId)
// 异常中断路径同步清该会话的 delta 去重记录:后台会话超时不走 flushCurrentText(仅当前视图
// 会 flush),不显式清则残留记录会在该会话下一轮把首个与上轮末个相同内容的 delta 误判重复丢弃
if (convId) clearLastDelta(convId)
// 广播清审批计时器(流式已中断,审批计时器无意义) // 广播清审批计时器(流式已中断,审批计时器无意义)
void emit('ai-approval-clear-timers', {}) void emit('ai-approval-clear-timers', {})
@@ -100,11 +140,10 @@ export function onStreamTimeout(convId?: string) {
if (hasCompletedTool) break if (hasCompletedTool) break
} }
// 补一条错误气泡(仅在实际还在生成态时补,避免重复补) // 补一条错误气泡,仅限当前视图会话:后台会话静默收敛(状态照常清理,提示气泡落进
// 这里直接补——forceResetStreaming 已重置状态,不会重复触发 // 无关视图属错位)。上方 per-conv 守卫已拦掉已删除/已收敛会话,此处再按视图过滤一次。
const content = hasCompletedTool if (!convId || convId === state.activeConversationId) {
? t('ai.streamInterruptedAfterTool') const content = contentOverride ?? (hasCompletedTool ? t('ai.streamInterruptedAfterTool') : t('ai.streamInterrupted'))
: t('ai.streamInterrupted')
state.messages.push({ state.messages.push({
id: `timeout-${nextMsgId()}` as MessageId, id: `timeout-${nextMsgId()}` as MessageId,
role: 'assistant', role: 'assistant',
@@ -113,16 +152,78 @@ export function onStreamTimeout(convId?: string) {
timestamp: Date.now(), timestamp: Date.now(),
}) })
} }
}
/**
* 看门狗超时回调:先探测后端真实状态(ai_resume_interrupted),能续跑则静默重挂看门狗,
* 不能才强制收敛——治"后端 loop 静默死亡/前端误判一超时就任务失败"。
*
* - 无 convId(legacy fallback):无法探测,直接 forceConverge(行为对齐旧版)。
* - 连续自动续跑达 MAX_AUTO_RESUME 次:不再探测,清计数降级 forceConverge(防后端活但
* 前端事件链路已死时无限静默等)。
* - 探测 generating/resumed:后端 loop 活着/已自动续跑,前端生成态不动(不 flush 不复位,
* 后端续跑后心跳/事件会自然喂狗),仅重挂看门狗;计数 +1。先重挂再写计数——
* resetStreamWatchdog 入口按"真实活跃事件到达"语义清计数,探测重挂不算活跃事件。
* - 探测 finished:后端已正常收敛仅收尾事件丢失,清计数 forceConverge 补轻收敛文案。
* - 探测抛错(会话不存在/后端异常):清计数降级 forceConverge(原中断行为含原错误气泡)。
*
* 防重入:超时 timer 一次性且已触发,探测 await 期间该 conv 无 armed timer,
* 本回调不会被重复触发,无需加锁。
*/
export async function onStreamTimeout(convId?: string) {
if (!convId) {
forceConverge()
return
}
const attempts = _resumeAttempts.get(convId) ?? 0
if (attempts >= MAX_AUTO_RESUME) {
_resumeAttempts.delete(convId)
forceConverge(convId)
return
}
try {
const probe = await aiApi.resumeInterrupted(convId)
if (probe === 'finished') {
_resumeAttempts.delete(convId)
forceConverge(convId, t('ai.streamFinishedConverged'))
return
}
// generating/resumed:后端活着或已续跑,静默重挂看门狗继续等
// 首次续跑补一条非错误提示气泡(自动恢复对用户可见,防 90s+ 无反馈期间误手动点继续;
// 后续重挂静默,防刷屏)。手动点继续有 is_active 前置校验兜底,无双跑风险。
if (attempts === 0 && convId === state.activeConversationId) {
// 仅当前活跃会话补「自动恢复」气泡:后台会话的气泡落进无关视图属错位,静默重挂即可
state.messages.push({
id: `auto-resume-${nextMsgId()}` as MessageId,
role: 'assistant',
content: t('ai.streamAutoResuming'),
timestamp: Date.now(),
})
}
resetStreamWatchdog(convId)
_resumeAttempts.set(convId, attempts + 1)
console.warn(`[AI-Watchdog] 超时探测后端=${probe},重挂看门狗继续等(convId=${convId},自动续跑第${attempts + 1}次)`)
} catch (err) {
_resumeAttempts.delete(convId)
console.warn(`[AI-Watchdog] 超时探测失败,降级强制收敛(convId=${convId})`, err)
forceConverge(convId)
}
}
/** /**
* 启动/重置看门狗(活跃事件或 sendMessage 时调用)。 * 启动/重置看门狗(活跃事件或 sendMessage 时调用)。
* *
* TD-260621-01 per-conv:传 convId → 走 per-conv Map(arm 前 clear 该 conv 旧 timer,防重入堆积); * TD-260621-01 per-conv:传 convId → 走 per-conv Map(arm 前 clear 该 conv 旧 timer,防重入堆积);
* 省略 convId(禁动域 useAiSend/useAiApproval 调用)→ 走 _legacyWatchdog fallback(单 timer,行为对齐旧版)。 * 省略 convId(调用点无活跃会话上下文)→ 走 _legacyWatchdog fallback(单 timer,行为对齐旧版)。
* setTimeout 回调闭包捕获 convId,到期 onStreamTimeout(convId) 精确清该 conv 态。 * setTimeout 回调闭包捕获 convId,到期 onStreamTimeout(convId) 精确清该 conv 态。
*/ */
export function resetStreamWatchdog(convId?: string) { export function resetStreamWatchdog(convId?: string) {
if (convId) { if (convId) {
// 真实活跃事件到达即证明前后端链路活了,自动续跑计数清零(本入口语义=链路活跃;
// 超时探测路径的静默重挂在 onStreamTimeout 里先调本函数再写计数,不受此清零影响)。
_resumeAttempts.delete(convId)
const old = _watchdogTimers.get(convId) const old = _watchdogTimers.get(convId)
if (old) clearTimeout(old) if (old) clearTimeout(old)
const timer = setTimeout(() => onStreamTimeout(convId), STREAM_TIMEOUT_MS) const timer = setTimeout(() => onStreamTimeout(convId), STREAM_TIMEOUT_MS)
+10 -16
View File
@@ -1,6 +1,8 @@
//! 流式事件域 — handleStreamingEvent //! 流式事件域 — handleStreamingEvent
//! //!
//! 处理:AiTextDelta / AiAgentRound / AiHeartbeat / AiStreamRetry / AiMaxRoundsReached / AiDirAuthRequired。 //! 处理:AiTextDelta / AiAgentRound / AiHeartbeat / AiStreamRetry / AiDirAuthRequired。
//! AiMaxRoundsReached 不处理:后端达 max 已改 AiCompleted(incomplete=true) 自动完成(暂停态
//! 语义已废),该事件后端零 emit;pendingMaxRounds/MaxRoundsCard 续停操作卡为遗留死链待清理。
//! 依赖方向:本模块 → aiShared / useAiPendingState(叶子)/ useAiStream,不导入任何同级子 composable。 //! 依赖方向:本模块 → aiShared / useAiPendingState(叶子)/ useAiStream,不导入任何同级子 composable。
//! _lastDelta 去重 Map 与 flushCurrentText 因跨域共用已下沉 aiShared,这里仅经 accessor 读写。 //! _lastDelta 去重 Map 与 flushCurrentText 因跨域共用已下沉 aiShared,这里仅经 accessor 读写。
@@ -8,8 +10,8 @@ import { emit } from '@tauri-apps/api/event'
import { useAppSettingsStore } from '@/stores/appSettings' import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers' import { t } from '@/i18n/i18n-helpers'
import { nextMsgId, findToolCall, startApprovalTimer, clearToolSlowTimer, getLastDelta, setLastDelta, flushCurrentText } from './aiShared' import { nextMsgId, findToolCall, startApprovalTimer, clearToolSlowTimer, getLastDelta, setLastDelta, flushCurrentText, addPendingApproval } from './aiShared'
import { resetTextIdleTimer, clearTextIdleTimer, pendingMaxRounds, pendingDirAuths, pushPendingDirAuth } from './useAiPendingState' import { resetTextIdleTimer, clearTextIdleTimer, pendingDirAuths, pushPendingDirAuth } from './useAiPendingState'
import { clearStreamWatchdog } from './useAiStream' import { clearStreamWatchdog } from './useAiStream'
import type { AiChatEvent, AiMessage, MessageId } from '@/api/types' import type { AiChatEvent, AiMessage, MessageId } from '@/api/types'
@@ -21,7 +23,7 @@ function isUnifiedApproval(): boolean {
return appSettings.get<boolean>('df-ai-unified-approval', true) return appSettings.get<boolean>('df-ai-unified-approval', true)
} }
/** streaming 域:流式文本累积/新轮/心跳/重试/max 轮暂停。返回 true=已处理。 */ /** streaming 域:流式文本累积/新轮/心跳/重试。返回 true=已处理。 */
export function handleStreamingEvent(event: AiChatEvent): boolean { export function handleStreamingEvent(event: AiChatEvent): boolean {
switch (event.type) { switch (event.type) {
case 'AiTextDelta': { case 'AiTextDelta': {
@@ -84,23 +86,14 @@ export function handleStreamingEvent(event: AiChatEvent): boolean {
return true return true
} }
case 'AiMaxRoundsReached': {
// 达 max 暂停态(后端仍 generating=true):看门狗不计整流超时(clear),挂起 convId 驱动操作卡。
// 无 convId 时保留原值兜底(达 max 事件理论上必带 convId,缺失默认当前活跃会话)。
clearStreamWatchdog(event.conversation_id || undefined)
pendingMaxRounds.value = event.conversation_id || state.activeConversationId || pendingMaxRounds.value
return true
}
case 'AiDirAuthRequired': { case 'AiDirAuthRequired': {
// 路径授权挂起(后端 generating 保持 true,等用户决策):看门狗跳过 reset,由本 case clear。 // 路径授权挂起(后端 generating 保持 true,等用户决策):看门狗跳过 reset,由本 case clear。
clearStreamWatchdog(event.conversation_id || undefined) clearStreamWatchdog(event.conversation_id || undefined)
clearToolSlowTimer(event.id) clearToolSlowTimer(event.id)
// 统一审批模型开:path 挂起归一进 pendingApprovals(kind='path'),ToolCard 内联审批。 // 统一审批模型开:path 挂起归一进待审批集合(kind='path'),ToolCard 内联审批。
// 同 id 幂等(provider id 不稳或重发时不重复 push 防残留卡)。 // per-conv 归会话追加(同 id 幂等):跨会话事件互不覆盖;首次到达才做工具卡改写等一次性副作用。
if (isUnifiedApproval()) { if (isUnifiedApproval()) {
if (!state.pendingApprovals.some(p => p.id === event.id)) { const added = addPendingApproval({
state.pendingApprovals.push({
id: event.id, id: event.id,
name: event.tool, name: event.tool,
args: { path: event.path }, args: { path: event.path },
@@ -111,6 +104,7 @@ export function handleStreamingEvent(event: AiChatEvent): boolean {
conversationId: event.conversation_id ?? state.activeConversationId ?? undefined, conversationId: event.conversation_id ?? state.activeConversationId ?? undefined,
reason: t('aiChat.dirAuthHint', { tool: event.tool, path: event.path }), reason: t('aiChat.dirAuthHint', { tool: event.tool, path: event.path }),
}) })
if (added) {
// 同步改对应工具卡的 tc,让 ToolCard 显 path 类审批按钮(once/always/deny) // 同步改对应工具卡的 tc,让 ToolCard 显 path 类审批按钮(once/always/deny)
const tc = findToolCall(event.id) const tc = findToolCall(event.id)
if (tc) { if (tc) {
+8 -4
View File
@@ -6,7 +6,7 @@
import { emit } from '@tauri-apps/api/event' import { emit } from '@tauri-apps/api/event'
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers' import { t } from '@/i18n/i18n-helpers'
import { findToolCall, clearApprovalTimer, startApprovalTimer, startToolSlowTimer, clearToolSlowTimer } from './aiShared' import { findToolCall, clearApprovalTimer, startApprovalTimer, startToolSlowTimer, clearToolSlowTimer, addPendingApproval, removePendingApprovalById } from './aiShared'
import { removePendingDirAuth } from './useAiPendingState' import { removePendingDirAuth } from './useAiPendingState'
import { clearStreamWatchdog } from './useAiStream' import { clearStreamWatchdog } from './useAiStream'
import type { AiChatEvent, AiToolCallInfo } from '@/api/types' import type { AiChatEvent, AiToolCallInfo } from '@/api/types'
@@ -43,7 +43,8 @@ export function handleToolEvent(event: AiChatEvent): boolean {
tc.result = event.result tc.result = event.result
tc.completedAt = Date.now() tc.completedAt = Date.now()
} }
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id) // 移除待审批(含 per-conv 集合,后台会话集合里的同 id 挂起一并清)
removePendingApprovalById(event.id)
clearToolSlowTimer(event.id) clearToolSlowTimer(event.id)
// 工具结束(无论审批通过后执行还是被拒),清审批超时计时器 // 工具结束(无论审批通过后执行还是被拒),清审批超时计时器
clearApprovalTimer(event.id) clearApprovalTimer(event.id)
@@ -68,7 +69,8 @@ export function handleToolEvent(event: AiChatEvent): boolean {
reason: event.reason, reason: event.reason,
conversationId: event.conversation_id ?? state.activeConversationId ?? undefined, conversationId: event.conversation_id ?? state.activeConversationId ?? undefined,
} }
state.pendingApprovals.push(info) // per-conv 归会话追加(同 id 幂等):跨会话事件不再互相覆盖;首次到达才做后续副作用
if (addPendingApproval(info)) {
const tc = findToolCall(event.id) const tc = findToolCall(event.id)
if (tc) { if (tc) {
tc.status = 'pending_approval' tc.status = 'pending_approval'
@@ -81,6 +83,7 @@ export function handleToolEvent(event: AiChatEvent): boolean {
startApprovalTimer(event.id, event.name, 'risk') startApprovalTimer(event.id, event.name, 'risk')
// 通知 ToolCardList 展开折叠组 + scroll(pending 卡落折叠分组不可见时) // 通知 ToolCardList 展开折叠组 + scroll(pending 卡落折叠分组不可见时)
void emit('ai-pending-arrived', { toolCallId: event.id }) void emit('ai-pending-arrived', { toolCallId: event.id })
}
return true return true
} }
@@ -91,7 +94,8 @@ export function handleToolEvent(event: AiChatEvent): boolean {
tc.status = 'rejected' tc.status = 'rejected'
tc.result = t('ai.aiTool.rejectedHint') tc.result = t('ai.aiTool.rejectedHint')
} }
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id) // 移除待审批(含 per-conv 集合)
removePendingApprovalById(event.id)
clearToolSlowTimer(event.id) clearToolSlowTimer(event.id)
clearApprovalTimer(event.id) clearApprovalTimer(event.id)
} else { } else {
+1 -1
View File
@@ -188,7 +188,7 @@ export function useToolApproval(tc: Ref<AiToolCallInfo>, emit: ToolApprovalEmit)
/** /**
* 拒绝按钮处理(risk 类专用)。 * 拒绝按钮处理(risk 类专用)。
* loading + 超时兜底复用 B-260616-08 机制。拒绝不做 High 风险二次确认(无害,加确认反增摩擦)。 * loading + 超时兜底复用 机制。拒绝不做 High 风险二次确认(无害,加确认反增摩擦)。
*/ */
async function onReject() { async function onReject() {
if (approving.value) return // 防重入 if (approving.value) return // 防重入
+3 -3
View File
@@ -70,7 +70,7 @@ export interface ToolResult {
has_more?: boolean has_more?: boolean
warning?: string warning?: string
review_rounds?: number review_rounds?: number
// UX-260618-05~12:补全后端返回前端消费的字段(review_rounds/truncated/bytes_moved/has_more/total/entries 已存在) // ~12:补全后端返回前端消费的字段(review_rounds/truncated/bytes_moved/has_more/total/entries 已存在)
task_id?: string task_id?: string
execution_id?: string execution_id?: string
target_status?: string target_status?: string
@@ -146,7 +146,7 @@ export function shouldKeepOpen(tc: AiToolCallInfo): boolean {
* - 通用工具 raw 文本:AR-6 路径后端塞 serde_json::Value::String("工具 X 执行失败: ..."), * - 通用工具 raw 文本:AR-6 路径后端塞 serde_json::Value::String("工具 X 执行失败: ..."),
* 前端 parseResult 返回 null(非 JSON 对象)→ 走 raw 文本兜底,无结构信号 * 前端 parseResult 返回 null(非 JSON 对象)→ 走 raw 文本兜底,无结构信号
* 此时按行首前缀 "执行失败"/"Error:"/"Failed:" 启发式判定(对齐后端 err_msg 格式, * 此时按行首前缀 "执行失败"/"Error:"/"Failed:" 启发式判定(对齐后端 err_msg 格式,
* 行首锚定避免误伤含这些子串的正常产出,见 UX-260617-18)。 * 行首锚定避免误伤含这些子串的正常产出,见 )。
* *
* 注意:status==='rejected'(用户拒绝)走独立分支,不算工具失败,不进此函数。 * 注意:status==='rejected'(用户拒绝)走独立分支,不算工具失败,不进此函数。
* running/pending_approval 自然 false(无 result 或 result 未稳定)。 * running/pending_approval 自然 false(无 result 或 result 未稳定)。
@@ -631,7 +631,7 @@ export function formatFetchUrl(r: ToolResult): string {
/** list_* summary 版:n=0 返空串(折叠态无噪音),按 name 选 i18n key */ /** list_* summary 版:n=0 返空串(折叠态无噪音),按 name 选 i18n key */
function summaryList(r: ToolResult, name: string): string { function summaryList(r: ToolResult, name: string): string {
// UX-260618-10/12: 后端返 {items,total,has_more} 对象(非数组),原 Array.isArray(r) 恒 false→折叠态无计数 // 后端返 {items,total,has_more} 对象(非数组),原 Array.isArray(r) 恒 false→折叠态无计数
const n = Array.isArray(r) ? r.length const n = Array.isArray(r) ? r.length
: (typeof r.total === 'number' ? r.total : (typeof r.total === 'number' ? r.total
: (Array.isArray(r.items) ? r.items.length : 0)) : (Array.isArray(r.items) ? r.items.length : 0))
+1 -1
View File
@@ -21,7 +21,7 @@ export interface DiffLine {
/** /**
* 解析 unified diff 文本 → DiffLine[]。 * 解析 unified diff 文本 → DiffLine[]。
* *
* 来源(AE-2025-03 / UX-260618-06):后端 generate_diff 输出 '+新行/-旧行/(空格)上下文', * 来源(AE-2025-03 / ):后端 generate_diff 输出 '+新行/-旧行/(空格)上下文',
* 逐行拆 → {kind, text} 供模板按 kind 着色(add 绿/del 红/ctx 灰)。 * 逐行拆 → {kind, text} 供模板按 kind 着色(add 绿/del 红/ctx 灰)。
* *
* - 空行过滤(generate_diff 末尾保留单尾换行 → split 末尾有空串元素) * - 空行过滤(generate_diff 末尾保留单尾换行 → split 末尾有空串元素)
+3 -3
View File
@@ -37,7 +37,7 @@ import shell from 'highlight.js/lib/languages/shell'
import java from 'highlight.js/lib/languages/java' import java from 'highlight.js/lib/languages/java'
import csharp from 'highlight.js/lib/languages/csharp' import csharp from 'highlight.js/lib/languages/csharp'
import cpp from 'highlight.js/lib/languages/cpp' import cpp from 'highlight.js/lib/languages/cpp'
// B-260616-11(②子项): diff 语言注册,```diff 代码块 +/- 行获 hljs-addition/deletion 红绿(github-dark.css 自带) // (②子项): diff 语言注册,```diff 代码块 +/- 行获 hljs-addition/deletion 红绿(github-dark.css 自带)
import diff from 'highlight.js/lib/languages/diff' import diff from 'highlight.js/lib/languages/diff'
// 主题:github-dark 与现有深色 UI(#0c0e1a)贴合;亮色主题下 code 块仍可读(深底浅字)。 // 主题:github-dark 与现有深色 UI(#0c0e1a)贴合;亮色主题下 code 块仍可读(深底浅字)。
// 注意:该 CSS 在 ai-md.css 之前/之后导入均可,hljs 用 .hljs/.hljs-* token class 着色, // 注意:该 CSS 在 ai-md.css 之前/之后导入均可,hljs 用 .hljs/.hljs-* token class 着色,
@@ -185,7 +185,7 @@ function renderMd(text: string): string {
if (!mdReady.value || !_marked || !_purify) { if (!mdReady.value || !_marked || !_purify) {
return escapeFallback(text) return escapeFallback(text)
} }
// B-260616-11(①子项): 裸 diff 文本预处理。渲染前将无围栏的连续 +/- 行包成 ```diff // (①子项): 裸 diff 文本预处理。渲染前将无围栏的连续 +/- 行包成 ```diff
// 围栏代码块,复用②子项已注册的 diff 语言 hljs 红绿样式,零额外 CSS。 // 围栏代码块,复用②子项已注册的 diff 语言 hljs 红绿样式,零额外 CSS。
// 防御性:即使现网 AI 多带围栏,裸行也正确处理(围栏内整段不动)。 // 防御性:即使现网 AI 多带围栏,裸行也正确处理(围栏内整段不动)。
const wrapped = wrapNakedDiff(text) const wrapped = wrapNakedDiff(text)
@@ -198,7 +198,7 @@ function renderMd(text: string): string {
} }
/** /**
* B-260616-11(①子项,方案A wrapNakedDiff): 裸 +/- diff 文本预处理。 * (①子项,方案A wrapNakedDiff): 裸 +/- diff 文本预处理。
* *
* 现网 AI(尤其 deepseek/glm/qwen 等)在描述「改动/对比」时常直接吐裸 -/+ 行而忘记 * 现网 AI(尤其 deepseek/glm/qwen 等)在描述「改动/对比」时常直接吐裸 -/+ 行而忘记
* 围栏(或仅用普通文本),导致 marked 当成普通段落/list 渲染,丢失 diff 视觉语义。 * 围栏(或仅用普通文本),导致 marked 当成普通段落/list 渲染,丢失 diff 视觉语义。
+15
View File
@@ -29,8 +29,14 @@ export default {
notRetryable: 'This error cannot be resolved by retrying. Please check the configuration.', notRetryable: 'This error cannot be resolved by retrying. Please check the configuration.',
streamInterruptedAfterTool: '⚠ The tool finished, but the following reply was interrupted (no data stream for a long time). You can click Continue to retry.', streamInterruptedAfterTool: '⚠ The tool finished, but the following reply was interrupted (no data stream for a long time). You can click Continue to retry.',
streamInterrupted: '⚠ The response was interrupted (no data stream for a long time). This may be due to a network disconnection or a backend error. Please try again.', streamInterrupted: '⚠ The response was interrupted (no data stream for a long time). This may be due to a network disconnection or a backend error. Please try again.',
// Light-convergence notice when the watchdog probe reports the backend already finished (only the closing event was lost)
streamFinishedConverged: 'The response was interrupted during finalization and has been converged.',
// First-time notice when the watchdog probe finds the backend loop alive/auto-resumed (only the first probe posts; subsequent re-arms stay silent)
streamAutoResuming: 'The response stalled and is auto-resuming (no action needed)...',
// UX-2025-04 / CR-30-2 / decision a1: system notice bubble after mid-stream failure preserve (frontend mirrors backend session.messages) // UX-2025-04 / CR-30-2 / decision a1: system notice bubble after mid-stream failure preserve (frontend mirrors backend session.messages)
responseIncomplete: '⚠ The response is incomplete due to a network interruption. The above is the partial content received. You can resend to get a full reply.', responseIncomplete: '⚠ The response is incomplete due to a network interruption. The above is the partial content received. You can resend to get a full reply.',
// Backend auto-finalized after reaching the max rounds (not a network interruption; reuses the incomplete bubble with distinct semantics)
responseIncompleteMaxRounds: 'Maximum rounds reached. The AI has finalized this round with the results so far; continue the conversation to have it refine and complete the answer.',
queueFull: 'Send queue is full (max {limit} items)', queueFull: 'Send queue is full (max {limit} items)',
forceSendConfirm: 'AI generation appears stuck. Force-stop and send anyway? (This may interrupt the current response)', forceSendConfirm: 'AI generation appears stuck. Force-stop and send anyway? (This may interrupt the current response)',
forceSendBtn: 'Force Send', forceSendBtn: 'Force Send',
@@ -73,5 +79,14 @@ export default {
stepRunning: 'running {name}', stepRunning: 'running {name}',
stepPending: '⏳ {n} pending approval', stepPending: '⏳ {n} pending approval',
}, },
// S1 sub-agent finished card (AiAgentFinished): lightweight terminal-state card at the
// end of the parent conversation message flow
agentFinished: {
title: 'Sub-agent',
completed: 'completed',
truncated: 'truncated',
failed: 'failed',
meta: '{rounds} rounds · {tokens} tokens',
},
}, },
} }
+11 -3
View File
@@ -94,11 +94,13 @@ export default {
// ── Pending queue ── // ── Pending queue ──
queueTitle: 'Queued {n}/10', queueTitle: 'Queued {n}/10',
clearQueue: 'Clear', clearQueue: 'Clear',
// UX-260616-06/07: queue item edit + send now // queue item edit + send now
queueEdit: 'Edit', queueEdit: 'Edit',
queueSave: 'Save', queueSave: 'Save',
queueCancel: 'Cancel', queueCancel: 'Cancel',
queueSendNow: 'Send now', queueSendNow: 'Send now',
// 3c UX batch addition (I4 queue backfill: pull a queued item back into the input box)
queueBackfill: 'Backfill',
// ── Skill ── // ── Skill ──
clearSkill: 'Clear skill', clearSkill: 'Clear skill',
@@ -164,7 +166,7 @@ export default {
editNotLastUser: 'Only the last user message can be edited', editNotLastUser: 'Only the last user message can be edited',
placeholderEditing: 'Edit message then Enter to regenerate (Esc to cancel)', placeholderEditing: 'Edit message then Enter to regenerate (Esc to cancel)',
// ── Error bubble actions (UX-03 / UX-260616-01) ── // ── Error bubble actions (UX-03 / ) ──
retry: 'Retry', retry: 'Retry',
goToSettingsAction: 'Open settings', goToSettingsAction: 'Open settings',
@@ -194,7 +196,7 @@ export default {
// ── Input Augmentation layer: mention/skill injection feedback ── // ── Input Augmentation layer: mention/skill injection feedback ──
// search_files blind search denied by whitelist fallback (no auth dialog; prompt user to use @mention or absolute path) // search_files blind search denied by whitelist fallback (no auth dialog; prompt user to use @mention or absolute path)
searchFilesBlocked: 'search_files only works inside authorized dirs. Use @[project] to reference or provide an absolute path.', searchFilesBlocked: "search_files only works inside authorized dirs. Use {'@'}[project] to reference or provide an absolute path.",
// /skill hot reload (ai_reload_skills IPC) success toast // /skill hot reload (ai_reload_skills IPC) success toast
skillReloaded: 'Skills reloaded', skillReloaded: 'Skills reloaded',
// @mention entity resolve failed (id points to a project/task/idea/skill that was deleted or unavailable) // @mention entity resolve failed (id points to a project/task/idea/skill that was deleted or unavailable)
@@ -276,5 +278,11 @@ export default {
goalsCount: 'Goals ({n})', goalsCount: 'Goals ({n})',
none: 'None', none: 'None',
projectLabel: "{'@'}Projects", projectLabel: "{'@'}Projects",
// ── 3c UX batch addition (I5 classified send-failure messages): friendly text by
// error kind; {msg} keeps the raw error string for troubleshooting ──
sendErrRateLimit: 'Rate limit reached, please retry later (error: {msg})',
sendErrQuota: 'AI quota or balance insufficient, check your provider account (error: {msg})',
sendErrNetwork: 'Network error or timeout, check your connection and retry (error: {msg})',
}, },
} }
+2 -2
View File
@@ -27,7 +27,7 @@ export default {
statusRunning: 'Running', statusRunning: 'Running',
statusPending: 'Pending approval', statusPending: 'Pending approval',
statusRejected: 'Rejected', statusRejected: 'Rejected',
// UX-260618-08/11: truncation hints (read_file paged truncation / list_directory 1000-entry cap) // truncation hints (read_file paged truncation / list_directory 1000-entry cap)
linesTruncated: '{shown}/{total} lines, truncated', linesTruncated: '{shown}/{total} lines, truncated',
dirTruncated: 'Truncated, first {n} items only', dirTruncated: 'Truncated, first {n} items only',
@@ -55,7 +55,7 @@ export default {
taskCount: '{n} tasks', taskCount: '{n} tasks',
projectCount: '{n} projects', projectCount: '{n} projects',
ideaCount: '{n} ideas', ideaCount: '{n} ideas',
// UX-260618-10/12: list_trash collapsed header count + paged has_more hint (backend returns {items,total,has_more} object) // list_trash collapsed header count + paged has_more hint (backend returns {items,total,has_more} object)
trashCount: '{n} in trash', trashCount: '{n} in trash',
listHasMore: '({n} shown, more available)', listHasMore: '({n} shown, more available)',
createdWithName: 'Created: {name}', createdWithName: 'Created: {name}',
+1 -1
View File
@@ -82,7 +82,7 @@ export default {
fetchFailedNotFound: 'Endpoint not found, this vendor may not support model listing: {msg}', fetchFailedNotFound: 'Endpoint not found, this vendor may not support model listing: {msg}',
toastModelEditLocalOnly: 'Enabled / weight are local edits, overwritten on next fetch', toastModelEditLocalOnly: 'Enabled / weight are local edits, overwritten on next fetch',
// Model trait tags (modality + capability + probe source; cost/intel 去除见 UX-260618-04) // Model trait tags (modality + capability + probe source; cost/intel 去除见 )
tagModality: { text: 'Text', vision: 'Vision', audio: 'Audio', video: 'Video' }, tagModality: { text: 'Text', vision: 'Vision', audio: 'Audio', video: 'Video' },
tagCapability: { tool_use: 'Tool use', embedding: 'Embedding', code_gen: 'Code' }, tagCapability: { tool_use: 'Tool use', embedding: 'Embedding', code_gen: 'Code' },
tagSource: { user_set: 'Manual', preset_table: 'Preset', heuristic: 'Heuristic', default: 'Default' }, tagSource: { user_set: 'Manual', preset_table: 'Preset', heuristic: 'Heuristic', default: 'Default' },
+14
View File
@@ -29,8 +29,14 @@ export default {
notRetryable: '此错误无法通过重试解决,请检查配置', notRetryable: '此错误无法通过重试解决,请检查配置',
streamInterruptedAfterTool: '⚠ 工具已执行完成,后续回复中断(长时间无数据流)。可点继续重试。', streamInterruptedAfterTool: '⚠ 工具已执行完成,后续回复中断(长时间无数据流)。可点继续重试。',
streamInterrupted: '⚠ 响应中断(长时间无数据流)。可能是网络断连或后端异常,请重试。', streamInterrupted: '⚠ 响应中断(长时间无数据流)。可能是网络断连或后端异常,请重试。',
// 看门狗探测到后端已正常收敛(仅收尾事件丢失)时补的轻收敛提示,非错误语气
streamFinishedConverged: '响应在收尾阶段中断,已自动完成收敛。',
// 看门狗超时探测到后端 loop 活着/已自动续跑时的首次提示(仅首次补,后续静默重挂不刷屏)
streamAutoResuming: '响应出现停滞,正在自动恢复(无需手动操作)…',
// UX-2025-04 / CR-30-2 / 决策 a1: 流中途失败保文后的系统提示气泡(前端镜像后端 session.messages) // UX-2025-04 / CR-30-2 / 决策 a1: 流中途失败保文后的系统提示气泡(前端镜像后端 session.messages)
responseIncomplete: '⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。', responseIncomplete: '⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。',
// 达最大轮次后端自动收尾(非网络中断,复用 incomplete 气泡但语义独立)
responseIncompleteMaxRounds: '已达最大轮次,AI 已自动收尾本轮结果;可继续对话让 AI 补充完善',
queueFull: '待发送队列已满(最多 {limit} 条)', queueFull: '待发送队列已满(最多 {limit} 条)',
forceSendConfirm: 'AI 生成似乎卡住了。是否强制结束当前生成并发送消息?(可能中断正在生成的回复)', forceSendConfirm: 'AI 生成似乎卡住了。是否强制结束当前生成并发送消息?(可能中断正在生成的回复)',
forceSendBtn: '强制发送', forceSendBtn: '强制发送',
@@ -73,5 +79,13 @@ export default {
stepRunning: '正在 {name}', stepRunning: '正在 {name}',
stepPending: '⏳ {n} 项待审批', stepPending: '⏳ {n} 项待审批',
}, },
// S1 子代理完成卡(AiAgentFinished):父会话消息流末尾的子代理终态轻量卡
agentFinished: {
title: '子代理',
completed: '已完成',
truncated: '已截断',
failed: '失败',
meta: '{rounds} 轮 · {tokens} tokens',
},
}, },
} }
+11 -3
View File
@@ -94,11 +94,13 @@ export default {
// ── 待发送队列 ── // ── 待发送队列 ──
queueTitle: '待发送 {n}/10', queueTitle: '待发送 {n}/10',
clearQueue: '清空', clearQueue: '清空',
// UX-260616-06/07:队列项编辑 + 立即发送 // 队列项编辑 + 立即发送
queueEdit: '编辑', queueEdit: '编辑',
queueSave: '保存', queueSave: '保存',
queueCancel: '取消', queueCancel: '取消',
queueSendNow: '立即发送', queueSendNow: '立即发送',
// 3c UX 批追加(I4 队列回填:取出排队项填回输入框重新编辑)
queueBackfill: '回填',
// ── 技能 ── // ── 技能 ──
clearSkill: '取消技能', clearSkill: '取消技能',
@@ -165,7 +167,7 @@ export default {
editNotLastUser: '只能编辑最后一条用户消息', editNotLastUser: '只能编辑最后一条用户消息',
placeholderEditing: '编辑消息后回车重新生成(Esc 取消)', placeholderEditing: '编辑消息后回车重新生成(Esc 取消)',
// ── 错误气泡操作入口(UX-03 / UX-260616-01) ── // ── 错误气泡操作入口(UX-03 / ) ──
retry: '重试', retry: '重试',
goToSettingsAction: '去设置', goToSettingsAction: '去设置',
@@ -195,7 +197,7 @@ export default {
// ── Input Augmentation 层: mention/skill 注入反馈 ── // ── Input Augmentation 层: mention/skill 注入反馈 ──
// search_files 盲搜被白名单兜底拒绝(不应弹授权窗,直接友好提示用户改用 @引用或绝对路径) // search_files 盲搜被白名单兜底拒绝(不应弹授权窗,直接友好提示用户改用 @引用或绝对路径)
searchFilesBlocked: 'search_files 仅在已授权目录可用,请用 @[项目] 引用或提供绝对路径', searchFilesBlocked: "search_files 仅在已授权目录可用,请用 {'@'}[项目] 引用或提供绝对路径",
// /技能 热重载(ai_reload_skills IPC)成功后提示 // /技能 热重载(ai_reload_skills IPC)成功后提示
skillReloaded: '技能已重新加载', skillReloaded: '技能已重新加载',
// @引用 实体 resolve 失败(id 指向的项目/任务/灵感/技能已被删除或不可用) // @引用 实体 resolve 失败(id 指向的项目/任务/灵感/技能已被删除或不可用)
@@ -277,5 +279,11 @@ export default {
goalsCount: '目标({n})', goalsCount: '目标({n})',
none: '无', none: '无',
projectLabel: "{'@'}项目", projectLabel: "{'@'}项目",
// ── 3c UX 批追加(I5 发送失败错误分类文案):按错误内容映射友好提示,
// {msg} 保留原始错误串供排查,不丢线索 ──
sendErrRateLimit: '发送频率超限,请稍后重试(原始错误:{msg})',
sendErrQuota: 'AI 服务额度或余额不足,请检查 Provider 账户(原始错误:{msg})',
sendErrNetwork: '网络异常或请求超时,请检查网络后重试(原始错误:{msg})',
}, },
} }
+2 -2
View File
@@ -26,7 +26,7 @@ export default {
statusRunning: '进行中', statusRunning: '进行中',
statusPending: '待审批', statusPending: '待审批',
statusRejected: '已拒绝', statusRejected: '已拒绝',
// UX-260618-08/11:截断提示(read_file 分页截断 / list_directory 1000 上限截断) // 截断提示(read_file 分页截断 / list_directory 1000 上限截断)
linesTruncated: '{shown}/{total} 行,已截断', linesTruncated: '{shown}/{total} 行,已截断',
dirTruncated: '已截断,仅前 {n} 项', dirTruncated: '已截断,仅前 {n} 项',
@@ -56,7 +56,7 @@ export default {
taskCount: '{n} 项任务', taskCount: '{n} 项任务',
projectCount: '{n} 个项目', projectCount: '{n} 个项目',
ideaCount: '{n} 条灵感', ideaCount: '{n} 条灵感',
// UX-260618-10/12:list_trash 折叠态计数 + 分页 has_more 提示(后端返 {items,total,has_more} 对象) // list_trash 折叠态计数 + 分页 has_more 提示(后端返 {items,total,has_more} 对象)
trashCount: '回收站 {n} 项', trashCount: '回收站 {n} 项',
listHasMore: '(已显示 {n} 项,还有更多)', listHasMore: '(已显示 {n} 项,还有更多)',
createdWithName: '已创建:{name}', createdWithName: '已创建:{name}',
+1 -1
View File
@@ -81,7 +81,7 @@ export default {
fetchFailedNotFound: '端点不存在,该厂商可能不支持模型列表拉取:{msg}', fetchFailedNotFound: '端点不存在,该厂商可能不支持模型列表拉取:{msg}',
toastModelEditLocalOnly: '已启用 / 权重为本地编辑,下次拉取时覆盖', toastModelEditLocalOnly: '已启用 / 权重为本地编辑,下次拉取时覆盖',
// 模型特征标签(模态 + 能力 + 探测来源;cost/intel 去除见 UX-260618-04) // 模型特征标签(模态 + 能力 + 探测来源;cost/intel 去除见 )
tagModality: { text: '文本', vision: '视觉', audio: '音频', video: '视频' }, tagModality: { text: '文本', vision: '视觉', audio: '音频', video: '视频' },
tagCapability: { tool_use: '工具调用', embedding: '向量', code_gen: '代码' }, tagCapability: { tool_use: '工具调用', embedding: '向量', code_gen: '代码' },
tagSource: { user_set: '手动', preset_table: '预设', heuristic: '推断', default: '默认' }, tagSource: { user_set: '手动', preset_table: '预设', heuristic: '推断', default: '默认' },
+1 -1
View File
@@ -231,7 +231,7 @@ watch(
) )
/** /**
* 估算单条消息 parts 的字符体积(base64/url 字符串长度之和,UX-260617-20)。 * 估算单条消息 parts 的字符体积(base64/url 字符串长度之和,)。
* *
* 只计 image 片的 base64 与 url(大体量载荷);text 片(正文/工具结果文本)不在此预算口径内, * 只计 image 片的 base64 与 url(大体量载荷);text 片(正文/工具结果文本)不在此预算口径内,
* 避免对常规长文本消息误伤。无 parts 或非 image 片返回 0。 * 避免对常规长文本消息误伤。无 parts 或非 image 片返回 0。
+1 -1
View File
@@ -52,7 +52,7 @@ export function parseTags(tags: string | null | undefined): string[] {
} }
// ── 知识 label 文案辅助(列表 Knowledge.vue 与详情 KnowledgeDetail.vue 共用) ── // ── 知识 label 文案辅助(列表 Knowledge.vue 与详情 KnowledgeDetail.vue 共用) ──
// 原两文件各内联一份重复实现(SW-260618-20 详情迁移时复制),提取至此单一来源。 // 原两文件各内联一份重复实现(详情迁移时复制),提取至此单一来源。
// 全部依赖 i18n,故用模块级 t(同文件 parseTags 之上的全局 t)。 // 全部依赖 i18n,故用模块级 t(同文件 parseTags 之上的全局 t)。
/** 知识类型文案(无 icon):${icon} ${text} 组合的文本部分,亦供下拉选项 */ /** 知识类型文案(无 icon):${icon} ${text} 组合的文本部分,亦供下拉选项 */
+7 -3
View File
@@ -29,7 +29,7 @@ function createStore() {
// 取代工具执行后需手动刷新。 // 取代工具执行后需手动刷新。
// //
// task 分支不再无参 loadTasks(会把 store.tasks 冲成全量,破坏 // task 分支不再无参 loadTasks(会把 store.tasks 冲成全量,破坏
// B-260615-29「store.tasks 反映当前筛选」契约)。改为读 _activeTaskProject: // 「store.tasks 反映当前筛选」契约)。改为读 _activeTaskProject:
// - undefined: Tasks 视图未挂载/从未设过筛选 → 不触发 task load(避免破坏筛选, // - undefined: Tasks 视图未挂载/从未设过筛选 → 不触发 task load(避免破坏筛选,
// 让 B-29 watch/handle 主导;此时改动由用户下次进 Tasks 自然刷新) // 让 B-29 watch/handle 主导;此时改动由用户下次进 Tasks 自然刷新)
// - 'all' : 全量拉取 // - 'all' : 全量拉取
@@ -38,7 +38,7 @@ function createStore() {
// //
// F-260621-02(P1 status 下沉):新增 _activeTaskStatus 追踪当前 status 筛选,数据变更 // F-260621-02(P1 status 下沉):新增 _activeTaskStatus 追踪当前 status 筛选,数据变更
// 联动刷新时一并下沉到后端 query(保 B-29「store.tasks 反映当前筛选」契约, // 联动刷新时一并下沉到后端 query(保 B-29「store.tasks 反映当前筛选」契约,
// 避免刷新把已选 status 冲掉)。 // 避免刷新把已选 status 冲掉)。仅单选下沉;多选拼接串见 buildActiveTaskQuery 内注释。
let _activeTaskProject: string | undefined = undefined let _activeTaskProject: string | undefined = undefined
let _activeTaskStatus: string | undefined = undefined let _activeTaskStatus: string | undefined = undefined
// Tasks 视图搜索关键字登记(数据变更联动刷新时一并下沉 keyword,避免冲掉搜索结果) // Tasks 视图搜索关键字登记(数据变更联动刷新时一并下沉 keyword,避免冲掉搜索结果)
@@ -57,7 +57,11 @@ function createStore() {
if (_activeTaskProject === undefined) return undefined // 视图未挂载,不触发 if (_activeTaskProject === undefined) return undefined // 视图未挂载,不触发
const query: TaskQuery = {} const query: TaskQuery = {}
if (_activeTaskProject !== 'all') query.project_id = _activeTaskProject if (_activeTaskProject !== 'all') query.project_id = _activeTaskProject
if (_activeTaskStatus && _activeTaskStatus !== 'all') query.status = _activeTaskStatus // UX走查 2026-10-03(P1):后端 status 单值语义,多选拼接串("todo,done")按单值精确匹配
// 必返空集,外部变更联动刷新会把可见行冲空。多选时不传 status,退化为按项目全量拉取 +
// 前端过滤(对齐 Tasks.vue buildTaskQuery 口径,status 筛选纯前端);单选仍下沉后端。
const isMultiStatus = _activeTaskStatus != null && _activeTaskStatus.includes(',')
if (_activeTaskStatus && _activeTaskStatus !== 'all' && !isMultiStatus) query.status = _activeTaskStatus
if (_activeTaskKeyword) query.keyword = _activeTaskKeyword if (_activeTaskKeyword) query.keyword = _activeTaskKeyword
return query return query
} }
+3 -3
View File
@@ -1,5 +1,5 @@
/* ═══ Markdown 渲染(全局) ═══ */ /* ═══ Markdown 渲染(全局) ═══ */
/* CR-260615-09: 从 AiChat.vue / ProjectDetail.vue / Ideas.vue / Knowledge.vue / TaskDetail.vue /* 从 AiChat.vue / ProjectDetail.vue / Ideas.vue / Knowledge.vue / TaskDetail.vue
的重复 .ai-md 样式块抽取为全局 CSS,消除 ~350 行重复。 的重复 .ai-md 样式块抽取为全局 CSS,消除 ~350 行重复。
各文件独有覆盖保留在原文件 scoped style 中。 */ 各文件独有覆盖保留在原文件 scoped style 中。 */
@@ -56,11 +56,11 @@
border-top: 0.5px solid var(--df-border); border-top: 0.5px solid var(--df-border);
margin: 8px 0; margin: 8px 0;
} }
/* B-260618-06:移除 display:block(破坏 table 布局上下文致 tr/td 塌堆叠丢列对齐,用户截图 /* 移除 display:block(破坏 table 布局上下文致 tr/td 塌堆叠丢列对齐,用户截图
反馈测试报告表格错乱根因)+ 移除 overflow-x:auto(display:table 下浏览器忽略无效)。 反馈测试报告表格错乱根因)+ 移除 overflow-x:auto(display:table 下浏览器忽略无效)。
table 恢复默认 display:table 列对齐恢复;width:100%→max-width:100% 允许窄表格按内容 table 恢复默认 display:table 列对齐恢复;width:100%→max-width:100% 允许窄表格按内容
自适应不强制拉满。宽表格横向滚动交由各页面容器承载(AiChat 气泡 .ai-msg-bubble--ai.ai-md 自适应不强制拉满。宽表格横向滚动交由各页面容器承载(AiChat 气泡 .ai-msg-bubble--ai.ai-md
加 overflow-x:auto,见 AiChat.vue)。原 UX-260617-21 display:block 方案治标破坏本体。 */ 加 overflow-x:auto,见 AiChat.vue)。display:block 方案治标破坏本体。 */
.ai-md table { .ai-md table {
max-width: 100%; max-width: 100%;
border-collapse: collapse; border-collapse: collapse;
+1 -1
View File
@@ -13,7 +13,7 @@
- global.css 已有的 .btn / .modal 系列 / .page-header / .empty-hint / .empty-state - global.css 已有的 .btn / .modal 系列 / .page-header / .empty-hint / .empty-state
不在此重复,仅补充尚未全局化的类 不在此重复,仅补充尚未全局化的类
落地批:DRY 收口(B-260619) 落地批:DRY 收口
- .info-item / .info-item.info-block / .info-item .label / .info-item .value - .info-item / .info-item.info-block / .info-item .label / .info-item .value
← ProjectDetail.vue / TaskDetail.vue / TaskOutputCard.vue 三处复制 ← ProjectDetail.vue / TaskDetail.vue / TaskOutputCard.vue 三处复制
- .panel / .panel-header ← ProjectDetail.vue / TaskDetail.vue 两处复制 - .panel / .panel-header ← ProjectDetail.vue / TaskDetail.vue 两处复制
+1 -1
View File
@@ -13,7 +13,7 @@
import { t, currentLocale } from '@/i18n/i18n-helpers' import { t, currentLocale } from '@/i18n/i18n-helpers'
// CR-260615-08相对时间走 i18n(原硬编码中文,en locale 时间恒中文)。 // 相对时间走 i18n(原硬编码中文,en locale 时间恒中文)。
// 类型安全访问经 i18n-helpers 收口(规避 vue-i18n TS2589)。 // 类型安全访问经 i18n-helpers 收口(规避 vue-i18n TS2589)。
/** 解析任意时间值为毫秒时间戳;无效或空返回 null(仅本模块内部使用) */ /** 解析任意时间值为毫秒时间戳;无效或空返回 null(仅本模块内部使用) */
+3 -1
View File
@@ -520,7 +520,8 @@ onMounted(() => {
.col-tool { min-width: 130px; } .col-tool { min-width: 130px; }
.col-risk { width: 56px; } .col-risk { width: 56px; }
.col-status { width: 80px; } .col-status { width: 80px; }
.col-decided { width: 64px; } /* min-width 而非定宽:容纳最长标签「自动(信任)」不逐字竖排,超出由 .audit-table-wrap overflow:auto 兜底 */
.col-decided { min-width: 88px; }
.col-args { min-width: 200px; max-width: 320px; } .col-args { min-width: 200px; max-width: 320px; }
.col-result { min-width: 220px; max-width: 360px; } .col-result { min-width: 220px; max-width: 360px; }
/* 表格内 args/result 单元格限高 + 截断(长内容不撑爆行),完整内容点行展开查 detail-pre */ /* 表格内 args/result 单元格限高 + 截断(长内容不撑爆行),完整内容点行展开查 detail-pre */
@@ -613,6 +614,7 @@ onMounted(() => {
font-weight: 500; font-weight: 500;
padding: 2px 8px; padding: 2px 8px;
border-radius: var(--df-radius-xs); border-radius: var(--df-radius-xs);
white-space: nowrap; /* 窄列防 CJK 逐字竖排(对齐 .status-tag) */
} }
.decided-human { background: var(--df-accent-bg); color: var(--df-accent); } .decided-human { background: var(--df-accent-bg); color: var(--df-accent); }
.decided-auto { background: rgba(90,99,128,0.2); color: var(--df-text-dim); } .decided-auto { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
+1 -1
View File
@@ -483,7 +483,7 @@ async function onUpdateTags(tags: string): Promise<boolean> {
onMounted(async () => { onMounted(async () => {
await loadCurrentView() await loadCurrentView()
// 从路由参数恢复选中灵感(修复 /ideas/:id 直接访问空白页 B-260615-36) // 从路由参数恢复选中灵感(修复 /ideas/:id 直接访问空白页 )
if (route.params.id) { if (route.params.id) {
const id = route.params.id as string const id = route.params.id as string
const exists = store.ideas.some(i => i.id === id) const exists = store.ideas.some(i => i.id === id)
+9 -3
View File
@@ -88,7 +88,7 @@
</div> </div>
</div> </div>
<!-- ===== 右侧:详情(KnowledgeDetail 子组件,SW-260618-20 拆分)===== --> <!-- ===== 右侧:详情(KnowledgeDetail 子组件,拆分)===== -->
<div class="kn-detail-panel"> <div class="kn-detail-panel">
<KnowledgeDetail <KnowledgeDetail
v-if="detail" v-if="detail"
@@ -417,7 +417,7 @@ onMounted(() => {
</script> </script>
<style scoped> <style scoped>
.knowledge { padding: 16px 20px 20px; height: 100%; display: flex; flex-direction: column; } .knowledge { padding: 16px 20px 20px; height: 100%; display: flex; flex-direction: column; container-type: inline-size; }
/* ===== 顶部 Toast 提示(⑤ 操作反馈,对齐 Settings.vue)===== */ /* ===== 顶部 Toast 提示(⑤ 操作反馈,对齐 Settings.vue)===== */
.toast { .toast {
@@ -452,7 +452,7 @@ onMounted(() => {
/* ===== 左右分栏 ===== */ /* ===== 左右分栏 ===== */
.kn-layout { .kn-layout {
display: grid; grid-template-columns: minmax(240px, 360px) 1fr; gap: var(--df-gap-page); display: grid; grid-template-columns: minmax(240px, 360px) minmax(280px, 1fr); gap: var(--df-gap-page);
flex: 1; min-height: 0; flex: 1; min-height: 0;
} }
.kn-list-panel, .kn-detail-panel { .kn-list-panel, .kn-detail-panel {
@@ -526,7 +526,13 @@ onMounted(() => {
.form-textarea { min-height: 80px; resize: vertical; font-family: inherit; } .form-textarea { min-height: 80px; resize: vertical; font-family: inherit; }
/* ===== 响应式:窄屏单列布局(详情窄屏样式随 KnowledgeDetail 子组件迁移) ===== */ /* ===== 响应式:窄屏单列布局(详情窄屏样式随 KnowledgeDetail 子组件迁移) ===== */
/* 双闸:@media 管窗口本身窄;@container 管「视口不窄但主区被 AI 面板挤压」场景
(AI 面板平分内容时 .knowledge 实际宽度 < 620px,media query 感知不到)。 */
@media (max-width: 760px) { @media (max-width: 760px) {
.kn-layout { grid-template-columns: 1fr; } .kn-layout { grid-template-columns: 1fr; }
} }
@container (max-width: 620px) {
.kn-layout { grid-template-columns: 1fr; }
.kn-detail-panel { overflow-y: visible; }
}
</style> </style>

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