Compare commits

29 Commits
Author SHA1 Message Date
lxy 0317873a5b 文档: 上下文管理设计/对标u-claw差距分析与看板登记
- 专项设计:M1-M4四批修复蓝图+对标u-claw六能力画像与P0-P2补差
- 看板:M1销账(验收证据)/收尾令/对标P0-P2条目登记
2026-10-19 00:25:52 +08:00
lxy eec5978fa7 修复: 上下文管理第二批(E5注入产物/E13超时收敛/E2降级透出)
- E5:[补充指令]与[系统恢复]同治三处——载入滤出/恢复回放剔除/
  首条任务消息排除(434c8c40占位实证),is_guide_artifact统一判据
- E13:同工具超时连败≥3跨签名聚合,执行前软收敛警告(治换档位调参
  逃逸,434c8c40 run_command 8连败实证)
- E2:压缩失败走关键词摘要兜底时新发AiContextDegraded,前端toast
  「上下文已降级」不再静默失忆(76730ce8 seq166实证)
- vite watch忽略target/(cargo编译写.o触发EBUSY杀dev进程)
- provider-list模板根div缺闭合补齐(拆分半成品断链)

验收:tool_failure 5/keyword_fallback 3/recovery 6 passed;vue-tsc EXIT 0
2026-10-19 00:25:51 +08:00
lxy 25ec9cf69a 优化: 前端u-agent令牌对齐与组件拆分续批
- 全局样式类(df-sec-h/df-toast族)替换散装实现,主题token去硬编码兜底
- 拆分子组件:ai-chat/queue-panel(队列时钟与编辑随迁)、
  conversation-sidebar/ConversationList、settings/provider-panel
- 父组件死副本清理(队列编辑/时钟/键位表,TS6133清零)并补
  fill-draft父侧转发(重构半成品断链修复)
- 验收:vue-tsc EXIT 0
2026-10-18 23:15:57 +08:00
lxy 302c4bcf52 修复: 飞书审批决议终态闭环与决议词容错
- F5-1:决议词归一(trim/剥尾标点/同义:批准|同意|拒绝|不同意)
- F5-2:审批终态卡原地更新(桌面决议/超时拒后不再恒挂可点);
  迟到决议30min窗内提示已处理,不再注入运行中任务
- APPROVAL_CARDS/RESOLVED_CHATS内存句柄,重启后走幂等回执无损失
2026-10-18 23:15:51 +08:00
lxy 05d0f89c16 修复: aichat上下文管理与信息组织P1批
逐件(证据:深查-aichat质量/信息UX-2026-10-17):
- 恢复链套娃:RECOVERY_ARTIFACT_PREFIX双侧过滤+单点拦截(E1)
- message_repo全量重写保留原created_at(E11,治1750条时间线污染)
- search_files/grep 0命中附阴性存疑提示(E6,共享negative_hit_hint)
- 项目清单三源活跃度排序+system prompt指纹对齐(E4跑题)
- 会话列表活跃/归档offset分页+侧栏加载更多(F4-1,治50硬上限)
- inject消息source标记(轮次分桶不误计发起,V52同源)
- 提示词回复风格条款中英双语(反口水话,含测试断言)
- i18n补键:复制/折叠量/发送title/历史定位提示(F1-1/F2-1/F6-1)

验收:cargo test --workspace 2281 passed 0 failed;vue-tsc EXIT 0
2026-10-18 23:15:45 +08:00
lxy a273a86a7d 优化: 参考 u-agent 管理台范式升级 UI 设计令牌与公共组件 2026-10-18 15:35:15 +08:00
lxy 45f0560f85 修复: 飞书长连接假活自愈——探活持续断流后主动断开重建
看门狗连续 3 个巡检周期(断流约13分钟)判定假活后 Notify 唤醒
run_loop 主动退出,复用外层 backoff 重连循环重建全新 WS 握手
只告警不自愈的盲区已补齐,比 SDK 内部重连更彻底(全新握手)
2026-10-17 22:19:57 +08:00
lxy f74d51b905 优化: 会话侧栏字体层级收敛+v0.1.3发布收尾脚本入库,归档飞书WS假活排查文档 2026-10-17 21:54:25 +08:00
lxy e08b0f4311 优化: 升版 0.1.2 -> 0.1.3,启动 B2/B3 端到端升级验证 2026-10-17 21:18:03 +08:00
lxy 3bb662f092 修复: 工具外置集成测试夹具失效
- 夹具误用错误行信号(.rs:)做普通大结果,错误行全量保留致压缩断言先天不成立,改中性行
- 生产行为缺陷(错误行洪泛击穿压缩,grep/rustc 类输出为主要受害者)登记看板待修
2026-10-15 14:27:33 +08:00
lxy a644e93604 修复: 项目详情页三态分离与反馈一致性
- notFound/已归档/加载失败三态分离:单查兜底+归档恢复入口+加载失败重试钮
- 反馈一致性六项:同步守卫与完成提示/首屏并行加载/筛选空态文案/计数改过滤口径/路径勾选 seq 守卫/删除确认危险态
- 文件列表滚动条对齐全局范式(scrollbar-width:thin 致 Chromium 忽略定制样式)
- 删除失败不再跳转(store 返布尔)/幽灵勾选清理/折叠阈值与 CSS 对齐
2026-10-15 14:27:32 +08:00
lxy 795935bf6a 新增: 飞书文件消息入站与补充注入链完善
- 文件消息入站:20MB 上限落盘引用,模型经 read_file 按需读盘;下载带 3 次退避+120s 超时防线,复用媒体域 hash 去重/OSS 双写
- 群聊门控对齐 u-claw 模式:前移至类型分流前,@自己/@所有人响应,未艾特静默旁观(原门控后置致未支持类型误回提示卡)
- 修复补充注入丢失:pending_injects 未消化计数+收敛出口续轮+桌面注入 IPC+前端本地气泡
- df-ai-core 测试构造器补 source 字段(溯源字段遗漏,全量门禁暴露)
2026-10-15 14:27:22 +08:00
lxy 7548896f6d 文档: 登记目标污染与停滞判定实证待办 2026-10-13 13:29:17 +08:00
lxy 5faaa88302 新增: 知识路标投影进系统提示词
- published 且验证或高复用条目按项目优先进投影稳定段
- 段尾固定引导工具故障先检索路标,治有经验不想查
- 与规则文件投影共用会话级缓存,空库零回归
2026-10-13 13:29:16 +08:00
lxy 8c4aa5dc5b 新增: 工具失败连败升级与熔断停靠
- 同工具连败三级提示:再败软提示,三败强制换路并引导检索经验
- 连拦五次强制安全停靠,不给无视熔断的余地
- 连击按会话计数防参数抖动绕过,成功即清零
2026-10-13 13:29:15 +08:00
lxy 9142e30714 新增: 项目规则文件投影进系统提示词
- 会话启动读 devflow.md 纪律与领单两分区,CLAUDE.md 同目录自动并入
- system prompt 稳定尾纯追加,主会话与子代理双路径同源
- 分区字符上限裁剪,会话级缓存同会话只读一次盘
- 文件缺失零回归,缓存前缀零击穿
2026-10-12 18:12:36 +08:00
lxy 9e26d6b19c 优化: 脉络卡归属置顶与源会话跳转
- 当前会话归属项目组置顶,focused 标记高亮
- source_ref 解析出可跳转会话 id,消息级引用反查补全
- 条目相对时间/空态引导/标题点击跳源,失败静默
2026-10-12 18:12:26 +08:00
lxy cb23061088 新增: 跨会话召回四层架构与脉络卡
- 会话终态提炼: 删除前+停靠双挂点,快照与清水位同临界区
- 目标快照: 恢复引导按最终有效意图生成,旧目标不复活
- 知识检索项目维度软排序,WorkingContext 持久化迁移
- 脉络卡: published 知识按项目聚合纯读侧,带入上下文经输入框显式注入
- 压缩与缓存能力在主循环/子代理/提示词三面接线
- 两份专项设计文档与看板销账
2026-10-11 22:27:20 +08:00
lxy 6f5c6b7532 优化: 上下文压缩缓存友好化与召回句柄
- 块状裁剪: 水位目标+轮边界吸附+病态回退,贪心路径保留为开关回退
- 存储态 System 锚点出请求时视图转 user,治前缀缓存整体折叠失配
- BP3 断点机制+缓存命中率观测日志,调参有据
- 压缩摘要可召回: JSONL 档案落盘+句柄行注入,take 语义一次性消费
- 字符估算修正: CJK 分档计权+改图地板,估算对象对齐实际消耗
- namespace 引用跨重启内容哈希懒回填
2026-10-11 22:27:08 +08:00
lxy c7d0ed939c 新增: 连发消息防抖与长命令后台化
- 飞书连发消息 0.5s 窗(拍板值)合并为一次任务:文本空行拼接+图片到达序合并
- 命令/审批/注入即时路径不进窗;追加消息并入即静默返回;窗满统一受理,守卫拒绝落待发箱
- run_command 超 grace 窗(默认 90s,可配)转后台:LLM 收非终态结果,不再整轮阻塞
- 后台任务自持心跳+删除自中止+失败记忆+完成通知+回填续跑;占用闸单点保会话态自洽
- 开关默认关=同步路径逐字节不变;新增 13 测
2026-10-11 12:37:23 +08:00
lxy fda991cbe1 优化: 上下文压缩域补偿与召回设计
- 裁剪淘汰补偿摘要上限 300→1000 字:原值对几十轮历史形同虚设,放大后相对裁剪收益仍是零头
- 测试断言改「不超上限」(结构性产出远低于天花板,精确等于过脆)
- 压缩可召回(压缩不丢/可回读)七条实施要点入档,待小批实施
2026-10-11 12:37:22 +08:00
lxy f9d3b4c76b 修复: namespace 引用按真实体量入账估算
- ref 尾段 ?len= 携带原文字节数, 估算器解析入账(原 ~15 token 低估致预算/裁剪/压缩三门失效)
- 走查五线复核通过: 入口唯一/展开在发送链/估算无旁路/压缩扣减闭环/子代理同效
- 归一方案否决留档: 近度豁免失效等四破坏, 重consider 条件三条
- 已知取舍: 字节直入字符口径中文 3x 高估, 方向安全
2026-10-11 11:14:23 +08:00
lxy 3a1b8ccda9 清理: 撤销 namespace 引用体量入账估算
方案重审: ref 尾段携带长度牵动协议格式/解析/测试多处,
收益与复杂度不成比例; 退回估算器原口径, 另寻不改协议的入账路径
2026-10-11 10:52:27 +08:00
lxy 10cd9d2465 修复: namespace引用按真实体量入账估算
ref 尾段携带原文字节数, 估算器解析入账(原 ~15 token 低估致
预算/裁剪/压缩三门失效); 旧格式向后兼容, 淘汰内容不虚占预算
2026-10-11 10:08:20 +08:00
lxy 96bab8cd49 修复: 子代理上下文预算恒128K不随模型窗口
子代理预算改继承父会话(同模型同窗口单一事实来源), 939-③ 收口;
939-⑪⑫ 核实已由前批落地, 看板销账更新
2026-10-11 09:35:49 +08:00
lxy 423c1df398 优化: 执行中折叠卡头补生成间隙即时摘要
工具间隙(LLM 写正文)显「生成回复中」, 折叠卡头任意时刻可见当前动作
2026-10-11 08:55:37 +08:00
lxy c2809bd927 新增: 压缩摘要折叠进所属轮次轨迹卡
- 压缩摘要进轮次桶随轨迹卡收起(旧口径原样展开作废)
- 轨迹卡内渲染为摘要 pill 单行, 点击展开全文(复用思考展开态)
- 压缩摘要不竞选最终答复气泡, 过程记录语义
2026-10-11 08:53:36 +08:00
lxy b4b18e351b 新增: 轮次按发起边界分桶与稳定编号
- 轮号=用户发起消息计数(单一真值来源), 单消息桶不吞号, 编号不再漂移
- 补充注入标 source=inject, 归属当前轮不开新轮(961 轮次语义)
- 压缩摘要/错误气泡归属当前轮, 压缩后不再另起新轮次
- reload 消息透传 source 字段, 轮次导航条自动同口径
2026-10-11 08:50:35 +08:00
lxy 461354d7f4 新增: 执行中轨迹卡展开与占用期消息必达
- 执行中 step 卡支持点击展开(复用轨迹展开态, 实时过程工具卡回看)
- 占用期消息改落待发箱自动续投, 不再拒绝让用户重发(959)
- 待发项派发后完成结果回推来源飞书会话(35min 弃守)
- 961 轮次语义/960 聚合防抖设计要点登记
2026-10-11 07:49:34 +08:00
134 changed files with 11288 additions and 2747 deletions
Generated
+1 -1
View File
@@ -1039,7 +1039,7 @@ dependencies = [
[[package]] [[package]]
name = "devflow" name = "devflow"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
+3
View File
@@ -310,6 +310,7 @@ mod tests {
role: MessageRole::User, role: MessageRole::User,
content: "字面量构造".into(), content: "字面量构造".into(),
parts: None, parts: None,
source: None,
tool_call_id: None, tool_call_id: None,
tool_calls: None, tool_calls: None,
model: None, model: None,
@@ -368,6 +369,7 @@ mod tests {
role: MessageRole::Assistant, role: MessageRole::Assistant,
content: "answer".to_string(), content: "answer".to_string(),
parts: None, parts: None,
source: None,
tool_call_id: None, tool_call_id: None,
tool_calls: None, tool_calls: None,
model: None, model: None,
@@ -429,6 +431,7 @@ mod tests {
role: MessageRole::Assistant, role: MessageRole::Assistant,
content: "answer".to_string(), content: "answer".to_string(),
parts: None, parts: None,
source: None,
tool_call_id: None, tool_call_id: None,
tool_calls: None, tool_calls: None,
model: None, model: None,
+253 -18
View File
@@ -62,6 +62,46 @@ pub fn anthropic_cache_enabled() -> bool {
ANTHROPIC_CACHE_ENABLED.load(std::sync::atomic::Ordering::SeqCst) ANTHROPIC_CACHE_ENABLED.load(std::sync::atomic::Ordering::SeqCst)
} }
/// BP3 摘要链尾断点开关(缓存友好上下文总开关在本模块的投影,默认开)。
///
/// 本模块不读 src-tauri KV(跨 crate),上层读 settings `df-ai-cache-friendly-context`
/// 后经 [`set_bp3_enabled`] 同步(同 [`set_anthropic_cache_enabled`] 先例)。
/// 关时消息级只打 BP2,断点布局与历史逐字节一致。BP3 仍内嵌于 anthropic 缓存总开关
/// 之下(它发的也是 cache_control),两层正交。
static BP3_ENABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
/// 设置 BP3 摘要链尾断点开关(上层读 settings 后同步)。
pub fn set_bp3_enabled(enabled: bool) {
BP3_ENABLED.store(enabled, std::sync::atomic::Ordering::SeqCst);
}
/// 读取 BP3 开关(`convert_request` 组装消息级断点用)。
pub fn bp3_enabled() -> bool {
BP3_ENABLED.load(std::sync::atomic::Ordering::SeqCst)
}
/// 缓存命中率观测日志(usage 就绪点每轮一条,供后续水位调参取数)。
///
/// anthropic 语义:input_tokens 为未缓存输入,cache_read/creation 独立计数,
/// 故命中率分母 = read + creation + input;三者全零(端点未回 usage)跳过不报。
fn log_cache_usage(usage: &TokenUsage) {
let total_input = usage.prompt_cache_hit_tokens as u64
+ usage.prompt_cache_miss_tokens as u64
+ usage.prompt_tokens as u64;
if total_input == 0 {
return;
}
let hit_rate = usage.prompt_cache_hit_tokens as f64 / total_input as f64;
let hit_pct = format!("{:.1}%", hit_rate * 100.0);
tracing::info!(
input_tokens = usage.prompt_tokens,
cache_read_input_tokens = usage.prompt_cache_hit_tokens,
cache_creation_input_tokens = usage.prompt_cache_miss_tokens,
hit_rate = %hit_pct,
"Anthropic prompt cache 命中率观测"
);
}
// ============================================================ // ============================================================
// Provider 实现 // Provider 实现
// ============================================================ // ============================================================
@@ -147,10 +187,21 @@ impl AnthropicCompatProvider {
// 构建非 system 消息(保留顺序,合并连续 tool_result) // 构建非 system 消息(保留顺序,合并连续 tool_result)
let mut messages: Vec<serde_json::Value> = Vec::new(); let mut messages: Vec<serde_json::Value> = Vec::new();
let mut pending_tool_results: Vec<serde_json::Value> = Vec::new(); let mut pending_tool_results: Vec<serde_json::Value> = Vec::new();
// 摘要锚点 = index>0 的 System(存储态:压缩摘要/话题标记/催办;首条是组装层
// system_prompt,归 BP1 顶层 system 非锚点)。记录「末锚点之后的消息条数」而非
// 下标:取用时按 len 反推目标,此前的 merge/补首条等数组增删天然自校正。
// 锚点以存储态 System 识别——view 层 System→user 转换落地后锚点以 user 形态
// 到达,此处识别不到,BP3 自然旁路(不加断点),无副作用。
let mut msgs_after_anchor: Option<usize> = None;
for m in req.messages.iter() { for (src_idx, m) in req.messages.iter().enumerate() {
match m.role { match m.role {
MessageRole::System => continue, MessageRole::System => {
if src_idx > 0 {
msgs_after_anchor = Some(0);
}
continue;
}
MessageRole::Tool => { MessageRole::Tool => {
// 累积 tool_result 块,遇到非 Tool 消息时 flush。 // 累积 tool_result 块,遇到非 Tool 消息时 flush。
// tool_use_id 为 None 时绝不能发 null(GLM anthropic 端点会 500 // tool_use_id 为 None 时绝不能发 null(GLM anthropic 端点会 500
@@ -173,7 +224,7 @@ impl AnthropicCompatProvider {
} }
} }
MessageRole::User => { MessageRole::User => {
Self::flush_tool_results(&mut messages, &mut pending_tool_results); Self::flush_tool_results(&mut messages, &mut pending_tool_results, &mut msgs_after_anchor);
// 多模态 user 消息 → content blocks 数组(text/image)。 // 多模态 user 消息 → content blocks 数组(text/image)。
// 含图时把 content + parts 拍平成 blocks:Text 片 → {type:text}, // 含图时把 content + parts 拍平成 blocks:Text 片 → {type:text},
// Image 片 → {type:image, source:{type:base64, media_type, data}}。 // Image 片 → {type:image, source:{type:base64, media_type, data}}。
@@ -192,9 +243,12 @@ impl AnthropicCompatProvider {
} else { } else {
messages.push(serde_json::json!({ "role": "user", "content": m.content })); messages.push(serde_json::json!({ "role": "user", "content": m.content }));
} }
if let Some(k) = &mut msgs_after_anchor {
*k += 1;
}
} }
MessageRole::Assistant => { MessageRole::Assistant => {
Self::flush_tool_results(&mut messages, &mut pending_tool_results); Self::flush_tool_results(&mut messages, &mut pending_tool_results, &mut msgs_after_anchor);
let mut content: Vec<serde_json::Value> = Vec::new(); let mut content: Vec<serde_json::Value> = Vec::new();
if !m.content.is_empty() { if !m.content.is_empty() {
content.push(serde_json::json!({ "type": "text", "text": m.content })); content.push(serde_json::json!({ "type": "text", "text": m.content }));
@@ -218,10 +272,13 @@ impl AnthropicCompatProvider {
content.push(serde_json::json!({ "type": "text", "text": "" })); content.push(serde_json::json!({ "type": "text", "text": "" }));
} }
messages.push(serde_json::json!({ "role": "assistant", "content": content })); messages.push(serde_json::json!({ "role": "assistant", "content": content }));
if let Some(k) = &mut msgs_after_anchor {
*k += 1;
} }
} }
} }
Self::flush_tool_results(&mut messages, &mut pending_tool_results); }
Self::flush_tool_results(&mut messages, &mut pending_tool_results, &mut msgs_after_anchor);
// 合并相邻 user 块。Anthropic 协议要求 user/assistant 严格交替,连续 user // 合并相邻 user 块。Anthropic 协议要求 user/assistant 严格交替,连续 user
// 触发 GLM 1214。场景:drainQueue 续发(前一轮以 tool_result 结尾 + 新 user)→ flush 把 // 触发 GLM 1214。场景:drainQueue 续发(前一轮以 tool_result 结尾 + 新 user)→ flush 把
@@ -240,10 +297,12 @@ impl AnthropicCompatProvider {
// (正常会话首条本就是 user),远优于丢弃上下文。 // (正常会话首条本就是 user),远优于丢弃上下文。
Self::ensure_leading_user(&mut messages); Self::ensure_leading_user(&mut messages);
// 消息级缓存断点(开关开):除 system 断点外在历史段尾部再打一个 cache_control, // 消息级缓存断点(开关开):BP2 打在历史段稳定边界,使 system 之后的对话历史
// 使 system 之后的对话历史也可命中(否则只缓存 system,长会话历史段全价重算)。 // 也可命中(否则只缓存 system,长会话历史段全价重算);BP3(缓存友好开关,默认开)
// 加打摘要锚点链之后第一条,BP2 因裁剪边界/压缩失效时 [system+摘要链] 小前缀仍命中。
if anthropic_cache_enabled() { if anthropic_cache_enabled() {
Self::apply_message_cache_breakpoint(&mut messages); let after_anchor = if bp3_enabled() { msgs_after_anchor } else { None };
Self::apply_message_cache_breakpoint(&mut messages, after_anchor);
} }
let tools = req.tools.map(|defs| { let tools = req.tools.map(|defs| {
@@ -328,34 +387,61 @@ impl AnthropicCompatProvider {
fn flush_tool_results( fn flush_tool_results(
messages: &mut Vec<serde_json::Value>, messages: &mut Vec<serde_json::Value>,
pending: &mut Vec<serde_json::Value>, pending: &mut Vec<serde_json::Value>,
msgs_after_anchor: &mut Option<usize>,
) { ) {
if pending.is_empty() { if pending.is_empty() {
return; return;
} }
let blocks: Vec<serde_json::Value> = pending.drain(..).collect(); let blocks: Vec<serde_json::Value> = pending.drain(..).collect();
messages.push(serde_json::json!({ "role": "user", "content": blocks })); messages.push(serde_json::json!({ "role": "user", "content": blocks }));
if let Some(k) = msgs_after_anchor {
*k += 1;
}
} }
/// 消息级缓存断点:在「历史段稳定边界」消息的末个 content block 上打 cache_control。 /// 消息级缓存断点:BP2 打在「历史段稳定边界」的末个 content block 上,BP3(可选)
/// 加打「摘要锚点链之后第一条」——`after_anchor` 为锚点链后的消息条数
/// (Some(0)=锚点收尾无后继,不打;None=无锚点,只打 BP2)。
/// ///
/// 断点落点取**末条 user 消息之前的相邻前一条**(末条非 user 收尾的异常序列则取末条): /// BP2 落点取**末条 user 消息之前的相邻前一条**(末条非 user 收尾的异常序列则取末条):
/// 末条 user 是每轮请求的漂移边界——缓存拆分模式下它是请求末尾追加的易变尾段 /// 末条 user 是每轮请求的漂移边界——缓存拆分模式下它是请求末尾追加的易变尾段
/// (日期/清单/augmentation,每次重组),非拆分模式下是本轮新问题,二者都不属于 /// (日期/清单/augmentation,每次重组),非拆分模式下是本轮新问题,二者都不属于
/// 只增历史;打在它身上断点位置随请求漂移,永远无法命中。标其前一条(assistant / /// 只增历史;打在它身上断点位置随请求漂移,永远无法命中。标其前一条(assistant /
/// tool_result 归并的 user)则断点始终落在只增历史的末尾,历史只增不改位: /// tool_result 归并的 user)则断点始终落在只增历史的末尾,历史只增不改位:
/// 上一轮写入的缓存前缀是本轮请求前缀的逐字节前缀 → 命中后增量续写,滚动前进。 /// 上一轮写入的缓存前缀是本轮请求前缀的逐字节前缀 → 命中后增量续写,滚动前进。
/// ///
/// BP3 服务于 BP2 频繁失效的场景:BP2 随裁剪边界移动/压缩事件/新近度豁免改写而
/// 迁位,前缀反复全价重算;BP3 前缀只含 [system+摘要链+1 条],失效源仅压缩事件,
/// 为该小前缀保底命中。与 BP2 同条(短序列)时跳过,BP2 优先。
///
/// content 形态:string(简写)→ 升级为单 text block 数组再标;array(blocks)→ 标末块。 /// content 形态:string(简写)→ 升级为单 text block 数组再标;array(blocks)→ 标末块。
/// 断点预算:Anthropic 单请求最多 4 个 cache_control,system 1 个 + 消息级 1 个,余量充足。 /// 断点预算:Anthropic 单请求最多 4 个 cache_control——system 1 + BP2 1 + BP3 1 = 3,
fn apply_message_cache_breakpoint(messages: &mut [serde_json::Value]) { /// 余 1 备用。
fn apply_message_cache_breakpoint(
messages: &mut [serde_json::Value],
after_anchor: Option<usize>,
) {
let n = messages.len(); let n = messages.len();
if n == 0 { if n == 0 {
return; return;
} }
let last_is_user = let last_is_user =
messages[n - 1].get("role").and_then(|r| r.as_str()) == Some("user"); messages[n - 1].get("role").and_then(|r| r.as_str()) == Some("user");
let idx = if last_is_user && n >= 2 { n - 2 } else { n - 1 }; let bp2_idx = if last_is_user && n >= 2 { n - 2 } else { n - 1 };
let msg = &mut messages[idx]; if let Some(k) = after_anchor {
if k > 0 && k <= n {
let bp3_idx = n - k;
if bp3_idx != bp2_idx {
Self::mark_cache_control(&mut messages[bp3_idx]);
}
}
}
Self::mark_cache_control(&mut messages[bp2_idx]);
}
/// 单条消息打 cache_control:string content 升级为单 text block 数组再标;
/// array(blocks) 标末块;其余形态(异常)不动。
fn mark_cache_control(msg: &mut serde_json::Value) {
let cache_control = serde_json::json!({ "type": "ephemeral" }); let cache_control = serde_json::json!({ "type": "ephemeral" });
match msg.get("content").cloned() { match msg.get("content").cloned() {
Some(serde_json::Value::Array(mut blocks)) => { Some(serde_json::Value::Array(mut blocks)) => {
@@ -729,6 +815,7 @@ impl LlmProvider for AnthropicCompatProvider {
prompt_cache_miss_tokens: resp.usage.cache_creation_input_tokens, prompt_cache_miss_tokens: resp.usage.cache_creation_input_tokens,
reasoning_tokens: 0, reasoning_tokens: 0,
}; };
log_cache_usage(&usage);
AttemptOutcome::Ok(CompletionResponse { AttemptOutcome::Ok(CompletionResponse {
text, text,
model: resp.model, model: resp.model,
@@ -874,6 +961,12 @@ impl LlmProvider for AnthropicCompatProvider {
if let Some(err) = chunk.error.as_mut() { if let Some(err) = chunk.error.as_mut() {
*err = format!("{} | messages 摘要: {}", err, messages_summary); *err = format!("{} | messages 摘要: {}", err, messages_summary);
} }
// 流式收尾(message_stop 终态帧)即 usage 就绪点,输出命中率观测日志
if chunk.finished {
if let Some(u) = &chunk.usage {
log_cache_usage(u);
}
}
chunks.push(Ok(chunk)); chunks.push(Ok(chunk));
} }
} }
@@ -1258,7 +1351,7 @@ mod tests {
serde_json::json!({ "role": "assistant", "content": [{"type": "text", "text": "a1"}] }), serde_json::json!({ "role": "assistant", "content": [{"type": "text", "text": "a1"}] }),
serde_json::json!({ "role": "user", "content": "q2" }), serde_json::json!({ "role": "user", "content": "q2" }),
]; ];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages); AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, None);
// 前一条 assistant 的末块带 cache_control // 前一条 assistant 的末块带 cache_control
let a1 = &messages[1]; let a1 = &messages[1];
let blocks = a1["content"].as_array().unwrap(); let blocks = a1["content"].as_array().unwrap();
@@ -1275,7 +1368,7 @@ mod tests {
serde_json::json!({ "role": "assistant", "content": "a1" }), serde_json::json!({ "role": "assistant", "content": "a1" }),
serde_json::json!({ "role": "user", "content": "q2" }), serde_json::json!({ "role": "user", "content": "q2" }),
]; ];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages); AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, None);
let blocks = messages[1]["content"].as_array().expect("string 应升级为 blocks 数组"); let blocks = messages[1]["content"].as_array().expect("string 应升级为 blocks 数组");
assert_eq!(blocks.len(), 1); assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0]["type"], "text"); assert_eq!(blocks[0]["type"], "text");
@@ -1289,7 +1382,7 @@ mod tests {
let mut messages = vec![ let mut messages = vec![
serde_json::json!({ "role": "user", "content": "only" }), serde_json::json!({ "role": "user", "content": "only" }),
]; ];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages); AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, None);
let blocks = messages[0]["content"].as_array().expect("单条应升级为 blocks"); let blocks = messages[0]["content"].as_array().expect("单条应升级为 blocks");
assert_eq!(blocks[0]["cache_control"]["type"], "ephemeral"); assert_eq!(blocks[0]["cache_control"]["type"], "ephemeral");
} }
@@ -1298,10 +1391,152 @@ mod tests {
#[test] #[test]
fn cache_breakpoint_empty_messages_noop() { fn cache_breakpoint_empty_messages_noop() {
let mut messages: Vec<serde_json::Value> = vec![]; let mut messages: Vec<serde_json::Value> = vec![];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages); AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, None);
assert!(messages.is_empty()); assert!(messages.is_empty());
} }
/// BP3 落「锚点链后首条」(按锚点后条数反推下标),与 BP2 各占一条互不覆盖。
#[test]
fn cache_breakpoint_bp3_marks_first_message_after_anchor_chain() {
// 模拟 [锚点链][q0][a0][q1][a1][q2] 转换后的数组:锚点后 5 条
let mut messages = vec![
serde_json::json!({ "role": "user", "content": "q0" }),
serde_json::json!({ "role": "assistant", "content": "a0" }),
serde_json::json!({ "role": "user", "content": "q1" }),
serde_json::json!({ "role": "assistant", "content": "a1" }),
serde_json::json!({ "role": "user", "content": "q2" }),
];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, Some(5));
// BP3 → idx0(q0);BP2(末条 user,取 n-2)→ idx3(a1)
for idx in [0usize, 3] {
let blocks = messages[idx]["content"].as_array().unwrap();
assert_eq!(
blocks.last().unwrap()["cache_control"]["type"],
"ephemeral",
"idx{} 应带断点",
idx
);
}
assert_eq!(messages[4]["content"].as_str(), Some("q2"), "末条 user 不打点");
}
/// BP3 与 BP2 同条(短序列)→ 跳过 BP3,BP2 优先,断点预算不重复占。
#[test]
fn cache_breakpoint_bp3_skipped_when_colliding_with_bp2() {
let mut messages = vec![
serde_json::json!({ "role": "user", "content": "q0" }),
serde_json::json!({ "role": "assistant", "content": "a0" }),
serde_json::json!({ "role": "user", "content": "q1" }),
];
// 锚点后 2 条 → BP3 目标 idx1,与 BP2(n-2=1)同条 → 跳过
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, Some(2));
let blocks = messages[1]["content"].as_array().unwrap();
assert_eq!(blocks.last().unwrap()["cache_control"]["type"], "ephemeral");
assert!(messages[0]["content"].as_str().is_some(), "q0 应保持字符串简写");
}
/// 锚点收尾无后继(after_anchor=0)→ 不打 BP3,BP2 照常落点。
#[test]
fn cache_breakpoint_bp3_zero_after_anchor_noop() {
let mut messages = vec![
serde_json::json!({ "role": "user", "content": "q0" }),
serde_json::json!({ "role": "user", "content": "q1" }),
];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, Some(0));
// BP2(末条 user → n-2=0)照常标 q0
let blocks = messages[0]["content"].as_array().expect("BP2 应照常落点");
assert_eq!(blocks.last().unwrap()["cache_control"]["type"], "ephemeral");
}
// ---------- BP3 开关门(convert 级,进程级开关用例须持 convert_lock) ----------
/// 构造 BP3 开关用例的请求:首条组装层 system_prompt + (可选)锚点链 + 历史段。
/// 转换后数组恒为 [q0, a0, q1](锚点折叠进顶层 system)。
fn bp3_req(with_anchor: bool) -> CompletionRequest {
let mut messages = vec![
ChatMessage::system("sys-prompt"),
ChatMessage::user("q0"),
ChatMessage::assistant("a0"),
ChatMessage::user("q1"),
];
if with_anchor {
messages.insert(1, ChatMessage::system("## 压缩摘要"));
messages.insert(2, ChatMessage::system("## 话题标记"));
}
CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages,
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
}
}
/// BP3 开 + 有锚点 → 断点落摘要链后第一条(q0),BP2 照常落历史尾(a0)。
#[test]
fn bp3_enabled_with_anchor_marks_first_message_after_chain() {
let _convert = convert_lock();
set_anthropic_cache_enabled(true);
set_bp3_enabled(true);
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let body = provider.convert_request(bp3_req(true));
assert_eq!(body.messages.len(), 3);
// BP3:锚点链后首条 q0(原字符串升级为 blocks + cache_control)
let q0_blocks = body.messages[0]["content"].as_array().expect("BP3 应标在锚点链后首条 q0");
assert_eq!(q0_blocks[0]["text"], "q0");
assert_eq!(q0_blocks[0]["cache_control"]["type"], "ephemeral");
// BP2 照常:末条 user 前相邻一条 a0
let a0_blocks = body.messages[1]["content"].as_array().unwrap();
assert_eq!(a0_blocks.last().unwrap()["cache_control"]["type"], "ephemeral");
// 末条 user 保持字符串简写
assert_eq!(body.messages[2]["content"].as_str(), Some("q1"));
set_anthropic_cache_enabled(false);
set_bp3_enabled(true); // 复位到进程默认(默认开),防污染其他并行用例
}
/// BP3 关 → 断点布局与历史一致(仅 BP2,锚点链后首条不打点)。
#[test]
fn bp3_disabled_keeps_legacy_breakpoint_layout() {
let _convert = convert_lock();
set_anthropic_cache_enabled(true);
set_bp3_enabled(false);
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let body = provider.convert_request(bp3_req(true));
assert_eq!(body.messages.len(), 3);
assert_eq!(
body.messages[0]["content"].as_str(),
Some("q0"),
"BP3 关,q0 保持字符串简写"
);
let a0_blocks = body.messages[1]["content"].as_array().unwrap();
assert_eq!(a0_blocks.last().unwrap()["cache_control"]["type"], "ephemeral", "BP2 照常");
set_anthropic_cache_enabled(false);
set_bp3_enabled(true);
}
/// 无锚点(仅组装层首条 system)→ 不加 BP3,仅 BP2。
#[test]
fn bp3_no_anchor_no_extra_breakpoint() {
let _convert = convert_lock();
set_anthropic_cache_enabled(true);
set_bp3_enabled(true);
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let body = provider.convert_request(bp3_req(false));
assert_eq!(body.messages.len(), 3);
assert_eq!(
body.messages[0]["content"].as_str(),
Some("q0"),
"无锚点应不加 BP3"
);
let a0_blocks = body.messages[1]["content"].as_array().unwrap();
assert_eq!(a0_blocks.last().unwrap()["cache_control"]["type"], "ephemeral", "BP2 照常");
set_anthropic_cache_enabled(false);
set_bp3_enabled(true);
}
// ---------- ensure_leading_user(首条 assistant → 补 user 占位,保留上下文)---------- // ---------- ensure_leading_user(首条 assistant → 补 user 占位,保留上下文)----------
/// 辅助:构造 assistant(tool_use) 消息 /// 辅助:构造 assistant(tool_use) 消息
+180
View File
@@ -0,0 +1,180 @@
//! 压缩可召回存档:被压缩原文 JSONL 落盘 + 召回句柄拼装。
//!
//! 压缩把原文从 LLM 上下文隔离(is_active 白名单过滤),摘要必有损。本模块在压缩标记时
//! 把本次被压原文追加写入 `<data_dir>/context-archive/<conv_id>-archive.jsonl`
//! (JSON Lines,一行一条 {role, content}),调用方在压缩摘要尾部拼召回句柄行,
//! LLM 需要原文时经 read_file 回读。会话一文件、追加写,目录不存在则创建;
//! 会话删除级联清理留待后续挂点。存档只落盘不进上下文,token 估算零影响;
//! 不与 tool_offload 复用(那是 src-tauri 侧机制,跨 crate 不混用)。
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::provider::ChatMessage;
/// 存档目录名(data_dir 下)
const ARCHIVE_DIR: &str = "context-archive";
/// 一次压缩事件的存档目标(会话维度)。调用方读到开关开(`df-ai-compress-recall`)后经
/// [`super::ContextManager::set_compress_recall`] 注入,压缩标记时一次性消费。
pub(super) struct RecallTarget {
data_dir: PathBuf,
conv_id: String,
}
impl RecallTarget {
pub(super) fn new(data_dir: &Path, conv_id: &str) -> Self {
Self {
data_dir: data_dir.to_path_buf(),
conv_id: conv_id.to_string(),
}
}
/// 存档文件路径:`<data_dir>/context-archive/<conv_id>-archive.jsonl`
fn file(&self) -> PathBuf {
self.data_dir
.join(ARCHIVE_DIR)
.join(format!("{}-archive.jsonl", self.conv_id))
}
}
/// 把本次被压原文追加写入会话存档(JSONL 追加语义,不覆盖既有行)。
/// 返回存档路径供句柄拼装;空消息/IO 失败返 None(调用方降级为无句柄,压缩本身不受影响)。
pub(super) fn append_archive(target: &RecallTarget, msgs: &[ChatMessage]) -> Option<PathBuf> {
if msgs.is_empty() {
return None;
}
let path = target.file();
let dir = path.parent()?;
if let Err(e) = std::fs::create_dir_all(dir) {
tracing::warn!(error = %e, dir = %dir.display(), "[archive] 存档目录创建失败,本轮无召回句柄");
return None;
}
let mut file = match OpenOptions::new().create(true).append(true).open(&path) {
Ok(f) => f,
Err(e) => {
tracing::warn!(error = %e, path = %path.display(), "[archive] 存档文件打开失败,本轮无召回句柄");
return None;
}
};
for m in msgs {
let line = serde_json::json!({ "role": &m.role, "content": m.content });
if let Err(e) = writeln!(file, "{line}") {
tracing::warn!(error = %e, path = %path.display(), "[archive] 存档行写入失败,本轮无召回句柄");
return None;
}
}
Some(path)
}
/// 召回句柄行:LLM 按此路径 read_file 回读被压原文。
pub fn recall_line(path: &Path) -> String {
format!("早期历史全文已存档: `{}`(read_file 可召回)", path.display())
}
/// 摘要尾部拼召回句柄行(开关开且存档落盘成功时用;开关关调用方直接用原摘要)。
pub fn with_recall_line(summary: &str, path: &Path) -> String {
format!("{}\n{}", summary, recall_line(path))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::ContextManager;
use crate::context_helpers::ContextConfig;
use tempfile::TempDir;
fn mgr() -> ContextManager {
ContextManager::new(ContextConfig {
max_tokens: 100_000,
..Default::default()
})
}
/// 灌 2 条可压 + 1 条保护区消息,注入召回目标后压缩 [0..2),返回存档路径。
fn compress_with_recall(tmp: &TempDir, conv_id: &str) -> PathBuf {
let mut m = mgr();
m.push(ChatMessage::user("早期问题"));
m.push(ChatMessage::assistant("早期回复"));
m.push(ChatMessage::user("近期问题"));
m.set_compress_recall(tmp.path(), conv_id);
let compressed = m.compress_old_messages(2);
assert_eq!(compressed.len(), 2, "前置:应压缩 2 条");
m.take_archive_path().expect("存档应落盘")
}
/// 压缩时存档文件创建,且每行含被压原文的 role+content。
#[test]
fn compress_with_recall_archives_original_text() {
let tmp = TempDir::new().unwrap();
let path = compress_with_recall(&tmp, "conv-a");
assert_eq!(
path,
tmp.path().join("context-archive").join("conv-a-archive.jsonl")
);
let text = std::fs::read_to_string(&path).unwrap();
let mut lines = text.lines();
let first: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
assert_eq!(first["role"], "user");
assert_eq!(first["content"], "早期问题");
let second: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
assert_eq!(second["role"], "assistant");
assert_eq!(second["content"], "早期回复");
assert!(lines.next().is_none(), "不应有多余行");
}
/// 开关开:摘要尾部含召回句柄行(含存档路径与 read_file 指引);路径一次性消费。
#[test]
fn recall_enabled_summary_gets_handle_line() {
let tmp = TempDir::new().unwrap();
let path = compress_with_recall(&tmp, "conv-b");
let summary = "## 意图\n- 测试主题";
let with_handle = with_recall_line(summary, &path);
assert!(with_handle.starts_with(summary), "句柄拼在摘要尾部,原摘要在前");
let handle = recall_line(&path);
assert!(with_handle.ends_with(&handle));
assert!(handle.contains(&path.display().to_string()));
assert!(handle.contains("read_file"));
}
/// 开关关(不注入召回目标):不落盘、无句柄路径可拼。
#[test]
fn recall_off_no_archive_no_handle() {
let tmp = TempDir::new().unwrap();
let mut m = mgr();
m.push(ChatMessage::user("问题"));
m.push(ChatMessage::assistant("回复"));
m.push(ChatMessage::user("近期"));
let compressed = m.compress_old_messages(2);
assert_eq!(compressed.len(), 2);
assert!(m.take_archive_path().is_none(), "开关关不应有存档路径");
assert!(
!tmp.path().join("context-archive").exists(),
"开关关不应创建存档目录"
);
}
/// 二次压缩追加不覆盖(JSONL 追加语义):两批原文都在,行数累计。
#[test]
fn second_compress_appends_not_overwrites() {
let tmp = TempDir::new().unwrap();
let mut m = mgr();
m.push(ChatMessage::user("第一批"));
m.push(ChatMessage::assistant("第一批回复"));
m.push(ChatMessage::user("第二批"));
m.push(ChatMessage::assistant("第二批回复"));
m.push(ChatMessage::user("近期"));
m.set_compress_recall(tmp.path(), "conv-c");
let _ = m.compress_old_messages(2);
// 第二次压缩事件:调用方重新注入(开关可能已变),幂等跳过已压缩,只压新批
m.set_compress_recall(tmp.path(), "conv-c");
let second = m.compress_old_messages(4);
assert_eq!(second.len(), 2, "第二次只压新激活的 2 条");
let path = m.take_archive_path().unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert_eq!(text.lines().count(), 4, "两次压缩累计 4 行,不覆盖");
assert!(text.contains("第一批回复"), "第一批原文仍在");
assert!(text.contains("第二批回复"), "第二批原文已追加");
}
}
+248 -5
View File
@@ -44,10 +44,10 @@ fn estimate_message_counts_parts_tokens() {
mm_tokens, mm_tokens,
text_tokens text_tokens
); );
// base64 字符按 0.35 粗估,约 1.7k * 0.35 ≈ 595 tokens 量级 // base64 改按图地板计(~1600,与 provider 端按图计费同口径),不随字符串长度膨胀
assert!( assert!(
mm_tokens > 500, mm_tokens >= 1600,
"大 base64 应贡献可观 token,实际 {}", "大 base64 应按图地板计 token,实际 {}",
mm_tokens mm_tokens
); );
@@ -72,8 +72,10 @@ fn short_history_no_trim() {
#[test] #[test]
fn over_budget_trims_old() { fn over_budget_trims_old() {
// 小预算强制裁剪:20 条超预算,触发裁剪且保留保护区 // 小预算强制裁剪:20 条超预算,触发裁剪且保留保护区。
let mut mgr = ContextManager::new(cfg(200)); // 预算取值须容纳「补偿摘要+保护区 6 条」(中文按 1.2 token/字计约 230)再留裁剪空间,
// 否则保护区被挤压、最新消息也被压,测的就不再是「裁旧保新」语义。
let mut mgr = ContextManager::new(cfg(400));
// user/assistant 交替(真实对话序列;连续 user 会被 ensure_sequence_legal 合并,无法测条数裁剪) // user/assistant 交替(真实对话序列;连续 user 会被 ensure_sequence_legal 合并,无法测条数裁剪)
for i in 0..20 { for i in 0..20 {
if i % 2 == 0 { if i % 2 == 0 {
@@ -1234,3 +1236,244 @@ fn push_system_anchor_avoids_dangling_tool_call_head() {
"悬空头应保持末条,其 tool 响应可紧随其后回填(协议不变式成立)" "悬空头应保持末条,其 tool 响应可紧随其后回填(协议不变式成立)"
); );
} }
// ============================================================
// 缓存友好上下文(W1 块状裁剪 + W2 system 锚点 view 层转 user)
// ============================================================
/// 推入一个完整工具轮 [user 提问, assistant 带 tool_call, tool_result, assistant 小结]。
/// 淘汰单元随之分为「user 独立单元 + 三元组单元」,轮起点(user)是轮吸附锚点。
fn push_tool_round(mgr: &mut ContextManager, tag: &str, filler: &str) {
mgr.push(ChatMessage::user(&format!("问题{}{}", tag, filler)));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new(format!("tc{}", tag), "read_file", "{}")],
));
mgr.push(ChatMessage::tool_result(format!("tc{}", tag), "结果"));
mgr.push(ChatMessage::assistant(&format!("小结{}", tag)));
}
/// 视图中真实历史的 token 量:排除尾部追加的淘汰补偿摘要(它不计入裁剪水位)。
fn view_history_tokens(msgs: &[ChatMessage]) -> u64 {
let est = TokenEstimator::default();
msgs.iter()
.filter(|m| !m.content.starts_with("[早期对话概要]"))
.map(|m| est.estimate_message(m) as u64)
.sum()
}
#[test]
fn block_trim_lands_below_target_water_level() {
// W1:开关开后超预算裁剪一次裁到 T×available 水位以下(贪心只裁到刚好塞下 available)。
let mut mgr = ContextManager::new(cfg(1000));
for i in 0..40 {
let text = format!("第{}条这条消息足够长让总历史稳定超过一千token预算线", i);
if i % 2 == 0 {
mgr.push(ChatMessage::user(&text));
} else {
mgr.push(ChatMessage::assistant(&text));
}
}
assert!(mgr.history_tokens() > 1000, "前置:历史须超预算");
mgr.set_cache_friendly(true);
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(trimmed, "超预算应触发裁剪");
let target = (1000u64 as f32 * crate::context_helpers::TRIM_TARGET_RATIO) as u64;
let remaining = view_history_tokens(&msgs);
assert!(
remaining <= target,
"裁后水位应落在目标 {} 以下, 实际 {}",
target,
remaining
);
// 保护区最新消息不被动(摘要追加在尾部,末条真实消息是其前一条)
let last_real = msgs
.iter()
.rev()
.find(|m| !m.content.starts_with("[早期对话概要]"))
.expect("视图非空");
assert!(
last_real.content.contains("第39条"),
"保护区最新消息被误裁, 实际末条: {}",
last_real.content
);
}
#[test]
fn block_trim_snaps_boundary_to_turn_start() {
// W1 轮边界吸附:水位在某单元边界达标但下一条不是 user 时继续扫,直到边界吸附轮起点
// (整问整答同生同死)。3 个工具轮 + 2 条过渡 + 6 条保护区,len=20 且 protect_start=14
// 恰为保护区起点;按纯水位会在过渡轮中间收手(首条为 assistant),吸附后首条必是 user。
let mut mgr = ContextManager::new(cfg(300));
let filler = "这一轮的体量用来把历史顶过预算线撑出裁剪空间";
for r in 0..3 {
push_tool_round(&mut mgr, &r.to_string(), filler);
}
mgr.push(ChatMessage::user(&format!("过渡问题A{}", filler)));
mgr.push(ChatMessage::assistant(&format!("过渡回复B{}", filler)));
for i in 0..6 {
let text = format!("保护区{}需要足够长的文本来撑量", i);
if i % 2 == 0 {
mgr.push(ChatMessage::user(&text));
} else {
mgr.push(ChatMessage::assistant(&text));
}
}
assert!(mgr.history_tokens() > 300, "前置:历史须超预算");
mgr.set_cache_friendly(true);
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(trimmed, "超预算应触发裁剪");
let first = msgs.first().expect("视图非空");
assert!(
matches!(first.role, MessageRole::User),
"裁剪边界应吸附轮起点(下一条未淘汰消息是 user), 实际首条 role={:?}",
first.role
);
let target = (300u64 as f32 * crate::context_helpers::TRIM_TARGET_RATIO) as u64;
assert!(
view_history_tokens(&msgs) <= target,
"吸附同时仍应满足目标水位"
);
assert_eq!(
has_head(&msgs),
has_tail(&msgs),
"轮吸附不得劈开工具三元组"
);
}
#[test]
fn block_trim_pathological_falls_back_to_hard_budget() {
// W1 病态回退:单轮体量 > 块,扫到保护区仍无「水位+轮边界」齐备的边界(保护区首条
// 是 system 锚点,堵死末轮三元组边界的轮吸附)→ 回退取满足硬预算的最后单元边界,
// 硬预算安全底线不破;且回退比贪心裁得更深(贪心在首个硬预算边界即收手)。
let mut mgr = ContextManager::new(cfg(400));
let filler = "这一轮的提问体量远超块大小用来制造病态回退场景".repeat(10);
for r in 0..3 {
push_tool_round(&mut mgr, &r.to_string(), &filler);
}
// 保护区首条 system 锚点:末轮三元组边界 next 非 user,轮吸附不可达
mgr.push(ChatMessage::system("[早期锚点]"));
for i in 0..5 {
let text = format!("保护区{}需要足够长的文本来撑量", i);
if i % 2 == 0 {
mgr.push(ChatMessage::user(&text));
} else {
mgr.push(ChatMessage::assistant(&text));
}
}
assert!(mgr.history_tokens() > 400, "前置:历史须超预算");
mgr.set_cache_friendly(true);
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(trimmed, "病态回退仍应触发裁剪");
let remaining = view_history_tokens(&msgs);
assert!(
remaining <= 400,
"硬预算安全底线:裁后不得超过 available, 实际 {}",
remaining
);
let first = msgs.first().expect("视图非空");
assert!(
first.content.contains("[早期锚点]"),
"回退应取满足硬预算的最后单元边界(裁到保护区起点), 实际首条: {}",
first.content
);
}
#[test]
fn cache_friendly_off_keeps_greedy_min_trim() {
// W1 开关关:走原贪心最小裁剪,裁到刚好塞下硬预算即停(高于目标水位),逐字节等价旧路径。
let mut mgr = ContextManager::new(cfg(1000));
for i in 0..40 {
let text = format!("第{}条这条消息足够长让总历史稳定超过一千token预算线", i);
if i % 2 == 0 {
mgr.push(ChatMessage::user(&text));
} else {
mgr.push(ChatMessage::assistant(&text));
}
}
// 不注入(字段默认 false)= 开关关
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(trimmed, "超预算应触发裁剪");
let remaining = view_history_tokens(&msgs);
let target = (1000u64 as f32 * crate::context_helpers::TRIM_TARGET_RATIO) as u64;
assert!(
remaining <= 1000,
"贪心裁后应塞下硬预算, 实际 {}",
remaining
);
assert!(
remaining > target,
"贪心只裁到刚好塞下(高于目标水位 {}), 实际 {}",
target,
remaining
);
}
#[test]
fn system_anchor_view_becomes_user_when_cache_friendly_on() {
// W2:开关开,存储态 system 锚点在发送视图转 user;存储不动;最终视图(组装层
// system_prompt 在下标 0,不经 build_for_request)中 system 独占首条。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问"));
mgr.push(ChatMessage::assistant("答"));
mgr.push_system_anchor(ChatMessage::system("[压缩摘要锚点]"));
mgr.set_cache_friendly(true);
let (msgs, _t) = mgr.build_for_request(0);
assert!(!msgs.is_empty());
assert!(
msgs.iter().all(|m| !matches!(m.role, MessageRole::System)),
"开时视图不得残留 system: {:?}",
msgs.iter().map(role_tag).collect::<Vec<_>>()
);
assert!(
mgr.all_messages_clone()
.iter()
.any(|m| matches!(m.role, MessageRole::System)),
"存储态 system 锚点不应被改写(纯视图)"
);
let mut final_view = vec![ChatMessage::system("SYS_PROMPT")];
final_view.extend(msgs);
assert!(
matches!(final_view[0].role, MessageRole::System),
"组装层首条 system_prompt 不转"
);
assert_eq!(
final_view
.iter()
.filter(|m| matches!(m.role, MessageRole::System))
.count(),
1,
"最终视图应只有组装层首条 system"
);
}
#[test]
fn system_anchor_head_insert_still_converted() {
// W2:IPC 压缩路径 insert_at(0) 的存储首位 system 锚点,最终视图在下标 1(>0),同样转 user。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问"));
mgr.insert_at(0, ChatMessage::system("[摘要]"));
mgr.set_cache_friendly(true);
let (msgs, _t) = mgr.build_for_request(0);
assert!(
msgs.iter().all(|m| !matches!(m.role, MessageRole::System)),
"存储首位的 system 锚点最终视图下标 > 0,应转 user"
);
}
#[test]
fn system_anchor_stays_system_when_cache_friendly_off() {
// W2 开关关:不转换,视图与存储角色一致(旧行为逐字节等价)。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问"));
mgr.push(ChatMessage::assistant("答"));
mgr.push_system_anchor(ChatMessage::system("[压缩摘要锚点]"));
let (msgs, _t) = mgr.build_for_request(0);
assert!(
msgs.iter().any(|m| matches!(m.role, MessageRole::System)),
"关时 system 锚点应原样进视图"
);
}
+139 -15
View File
@@ -20,9 +20,11 @@
//! 保持 `ContextManager::sanitize_messages(...)` 调用路径不变(零调用方变更)。 //! 保持 `ContextManager::sanitize_messages(...)` 调用路径不变(零调用方变更)。
mod sanitize; mod sanitize;
pub mod archive;
use crate::context_helpers::{ use crate::context_helpers::{
build_eviction_summary_text, classify_group, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT, build_eviction_summary_text, classify_group, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT,
TRIM_TARGET_RATIO,
}; };
// 重导出:保持 `df_ai::context::TokenEstimator` / `df_ai::context::ContextConfig` 等 // 重导出:保持 `df_ai::context::TokenEstimator` / `df_ai::context::ContextConfig` 等
// 历史路径对外可见(agentic.rs / commands/ai/mod.rs 等调用方零变更)。 // 历史路径对外可见(agentic.rs / commands/ai/mod.rs 等调用方零变更)。
@@ -78,6 +80,16 @@ pub struct ContextManager {
/// insert_at / compress_old_messages / messages_mut(可变借用兜底,调用方可能改 status/content)。 /// insert_at / compress_old_messages / messages_mut(可变借用兜底,调用方可能改 status/content)。
/// 误置位零代价(只是下次 save 多走一次全量重写,正确性不变);漏置位才会丢更新——故保守置位。 /// 误置位零代价(只是下次 save 多走一次全量重写,正确性不变);漏置位才会丢更新——故保守置位。
needs_full_rewrite: bool, needs_full_rewrite: bool,
/// 压缩可召回:本次压缩事件的存档目标(调用方读到 KV 开后注入,compress_old_messages
/// 一次性消费)。None = 本事件不存档(开关关/未注入),零行为变更。
compress_recall: Option<archive::RecallTarget>,
/// 最近一次压缩成功落盘的存档路径(take 语义,供调用方在压缩摘要尾部拼召回句柄;
/// 每次 compress_old_messages 按事件重置,防跨事件残留误报)。
archive_path: Option<std::path::PathBuf>,
/// 缓存友好上下文总开关(跨 crate 注入:settings 在 src-tauri 侧,df-ai 不可直读,
/// 调用方读 KV `df-ai-cache-friendly-context` 后经 [`Self::set_cache_friendly`] 注入)。
/// 默认 false = 旧行为(贪心最小裁剪 + system 锚点原样发送),未注入路径零行为变更。
cache_friendly: bool,
} }
impl ContextManager { impl ContextManager {
@@ -91,6 +103,9 @@ impl ContextManager {
pending_topic_marker: None, pending_topic_marker: None,
persisted_msg_count: 0, persisted_msg_count: 0,
needs_full_rewrite: false, needs_full_rewrite: false,
compress_recall: None,
archive_path: None,
cache_friendly: false,
} }
} }
@@ -153,6 +168,8 @@ impl ContextManager {
// 全量重写空列表也会清 DB)。 // 全量重写空列表也会清 DB)。
self.persisted_msg_count = 0; self.persisted_msg_count = 0;
self.needs_full_rewrite = true; self.needs_full_rewrite = true;
self.compress_recall = None;
self.archive_path = None;
} }
/// 消息数量 /// 消息数量
@@ -196,10 +213,10 @@ impl ContextManager {
// 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环) // 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环)
if self.history_tokens <= available { if self.history_tokens <= available {
let sanitized = Self::sanitize_messages(self.all_messages_clone()); let view = self.anchors_to_user(Self::sanitize_messages(self.all_messages_clone()));
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan) // 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
return ( return (
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED), Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
false, false,
); );
} }
@@ -208,15 +225,12 @@ impl ContextManager {
let protect_start = self.messages.len().saturating_sub(PROTECT_COUNT); let protect_start = self.messages.len().saturating_sub(PROTECT_COUNT);
let units = self.build_eviction_units(protect_start); let units = self.build_eviction_units(protect_start);
let mut removed: u64 = 0; // 缓存友好开:块状裁剪(目标水位+轮边界吸附);关:贪心最小裁剪(逐字节等价旧路径)
let mut trim_end = 0; let (trim_end, removed) = if self.cache_friendly {
for unit in &units { self.block_trim_target(&units, available)
if self.history_tokens.saturating_sub(removed as u32) <= available { } else {
break; self.greedy_trim_target(&units, available)
} };
removed += unit.token_sum as u64;
trim_end = unit.end;
}
if trim_end == 0 { if trim_end == 0 {
tracing::warn!( tracing::warn!(
@@ -229,9 +243,9 @@ impl ContextManager {
// 致超预算且保护区满时命中)。异常会话(开头连续 assistant/tool 无 user)经 // 致超预算且保护区满时命中)。异常会话(开头连续 assistant/tool 无 user)经
// ensure_sequence_legal 清空后,由协议层 ensure_leading_user 补 user 占位降级,不阻塞。 // ensure_sequence_legal 清空后,由协议层 ensure_leading_user 补 user 占位降级,不阻塞。
// view-only:不改 self.messages 持久化(与分支 1/3 一致)。 // view-only:不改 self.messages 持久化(与分支 1/3 一致)。
let sanitized = Self::sanitize_messages(self.all_messages_clone()); let view = self.anchors_to_user(Self::sanitize_messages(self.all_messages_clone()));
return ( return (
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED), Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
false, false,
); );
} }
@@ -264,14 +278,93 @@ impl ContextManager {
"context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)", "context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
trim_end, removed trim_end, removed
); );
let sanitized = Self::sanitize_messages(msgs); let view = self.anchors_to_user(Self::sanitize_messages(msgs));
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan) // 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
( (
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED), Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
true, true,
) )
} }
/// 贪心最小裁剪:累计淘汰单元直到塞下硬预算即停(历史行为,开关关路径)。
///
/// 缺陷:对话每增长一条消息就重新越过预算一点,裁剪边界随之前移一个单元,
/// 发送视图首条每轮都变,前缀缓存从第 0 字节起永远 miss。
fn greedy_trim_target(&self, units: &[EvictionUnit], available: u32) -> (usize, u64) {
let mut removed: u64 = 0;
let mut trim_end = 0;
for unit in units {
if self.history_tokens.saturating_sub(removed as u32) <= available {
break;
}
removed += unit.token_sum as u64;
trim_end = unit.end;
}
(trim_end, removed)
}
/// 块状裁剪:一次裁到目标水位 `TRIM_TARGET_RATIO × available`,且边界吸附轮边界。
///
/// 为什么块状:两次裁剪之间裁剪边界不动 → 被淘汰集不变 → 视图前缀逐字节稳定,
/// 前缀缓存每轮命中;裁剪频度由水位差(块大小)决定而非逐条爬行。
/// 轮边界:单元 end 处下一条未淘汰消息 role=User 即轮起点,整问整答(含工具链)
/// 同生同死,淘汰摘要按轮覆盖语义完整,且轮起点是稳定地标。
/// 病态回退(单轮体量 > 块,扫到保护区仍无两条件齐备的边界):取满足硬预算
/// available 的最后单元边界(安全底线);连硬预算都不可得(保护区自身超限)则
/// 尽力裁到末单元,对齐贪心路径。
fn block_trim_target(&self, units: &[EvictionUnit], available: u32) -> (usize, u64) {
let target = (available as f32 * TRIM_TARGET_RATIO) as u32;
let mut removed: u64 = 0;
let mut hard = (0usize, 0u64);
let mut last = (0usize, 0u64);
for unit in units {
removed += unit.token_sum as u64;
last = (unit.end, removed);
let remaining = self.history_tokens.saturating_sub(removed as u32);
if remaining <= available {
hard = (unit.end, removed);
}
// 目标水位隐含硬预算已满足(target < available),轮吸附与水位同时判定
if remaining <= target
&& self
.messages
.get(unit.end)
.is_some_and(|next| matches!(next.message.role, MessageRole::User))
{
return (unit.end, removed);
}
}
if hard.0 > 0 {
hard
} else {
last
}
}
/// 发送视图出口整形(sanitize 之后):总开关开时把存储态 role=System 消息
/// (压缩摘要/话题标记/警示等锚点)映射为 role=User 发送。
///
/// 为什么:anthropic_compat 把存储态全部 role=System 折叠进顶层 system(带
/// cache_control),每次锚点事件都改写顶层缓存指纹,system 断点与消息断点同时失配。
/// 转 user 后顶层 system 回到纯 system_prompt 稳定段;连续 user 由
/// anthropic_compat::merge_consecutive_users 合并(协议要求严格交替),user 不触发
/// 三元组/首条规则。纯视图:存储态仍 System,压缩定位(has_compressible_messages
/// 的 System 排除)与前端展示语义不变。调用方组装层加入的 system_prompt 不经本
/// 函数(不在 storage),最终视图中它独占 role=System 首条。
fn anchors_to_user(&self, view: Vec<ChatMessage>) -> Vec<ChatMessage> {
if !self.cache_friendly {
return view;
}
view.into_iter()
.map(|mut m| {
if matches!(m.role, MessageRole::System) {
m.role = MessageRole::User;
}
m
})
.collect()
}
// ── 畸形配对自愈(转发至 [`sanitize`] 子模块,保持 ContextManager::xxx 调用路径)── // ── 畸形配对自愈(转发至 [`sanitize`] 子模块,保持 ContextManager::xxx 调用路径)──
/// 畸形配对自愈 — 转发到 [`sanitize::sanitize_messages`]。 /// 畸形配对自愈 — 转发到 [`sanitize::sanitize_messages`]。
@@ -668,6 +761,11 @@ impl ContextManager {
/// ///
/// 返回空 Vec 表示本批次无可压缩消息(全部已 compressed 或范围空),调用方据此跳过 LLM 调用。 /// 返回空 Vec 表示本批次无可压缩消息(全部已 compressed 或范围空),调用方据此跳过 LLM 调用。
pub fn compress_old_messages(&mut self, compress_end: usize) -> Vec<ChatMessage> { pub fn compress_old_messages(&mut self, compress_end: usize) -> Vec<ChatMessage> {
// 压缩可召回:目标一次性消费(防跨事件残留),路径按本事件重置。压缩标记后原文
// 从 LLM 上下文隔离,此刻原文在手,追加写入会话存档供摘要句柄召回(read_file);
// 失败仅置空路径(调用方降级为无句柄),不影响压缩本身。
let recall = self.compress_recall.take();
self.archive_path = None;
// 压缩边界按对话单元(三元组)对齐:调用方传入的 end(如 len - PROTECT_COUNT)周期性 // 压缩边界按对话单元(三元组)对齐:调用方传入的 end(如 len - PROTECT_COUNT)周期性
// 落在单元中间(assistant 头与其 tool 结果之间),只压半个三元组会让保护区以孤立的 // 落在单元中间(assistant 头与其 tool 结果之间),只压半个三元组会让保护区以孤立的
// 头或尾开头,成为 sanitize 每请求告警的确定性供给。按全长分组取「完整单元边界中 // 头或尾开头,成为 sanitize 每请求告警的确定性供给。按全长分组取「完整单元边界中
@@ -694,10 +792,36 @@ impl ContextManager {
// newly_compressed 为空,状态未变,无需全量重写)。 // newly_compressed 为空,状态未变,无需全量重写)。
if !newly_compressed.is_empty() { if !newly_compressed.is_empty() {
self.needs_full_rewrite = true; self.needs_full_rewrite = true;
if let Some(target) = recall {
match archive::append_archive(&target, &newly_compressed) {
Some(p) => self.archive_path = Some(p),
None => tracing::warn!("[ai] 压缩存档写入失败,本轮摘要无召回句柄(压缩不受影响)"),
}
}
} }
newly_compressed newly_compressed
} }
/// 注入本次压缩事件的存档目标(压缩可召回,`df-ai-compress-recall` 开时由调用方在
/// `compress_old_messages` 之前调用;开关关不调用)。一次性消费:压缩标记时落盘,
/// 路径经 [`Self::take_archive_path`] 取走。
pub fn set_compress_recall(&mut self, data_dir: &std::path::Path, conv_id: &str) {
self.compress_recall = Some(archive::RecallTarget::new(data_dir, conv_id));
}
/// 取走最近一次压缩成功落盘的存档路径(Some = 本次压缩可召回,调用方据此在摘要尾部
/// 拼句柄行)。take 语义:取后即空,防跨压缩事件残留误报。
pub fn take_archive_path(&mut self) -> Option<std::path::PathBuf> {
self.archive_path.take()
}
/// 注入缓存友好上下文总开关(每请求由 src-tauri 调用方读 KV 后在 `build_for_request`
/// 前调用,持续语义:注入值保持到下次注入)。true = 块状裁剪 + system 锚点 view 层
/// 转 user;false = 两者旁路,行为与旧路径逐字节等价。
pub fn set_cache_friendly(&mut self, enabled: bool) {
self.cache_friendly = enabled;
}
/// 压缩重入标志(读)。true 表示一次 LLM 压缩正在进行中,触发方应跳过本轮压缩。 /// 压缩重入标志(读)。true 表示一次 LLM 压缩正在进行中,触发方应跳过本轮压缩。
pub fn is_compressing(&self) -> bool { pub fn is_compressing(&self) -> bool {
self.is_compressing self.is_compressing
+116 -40
View File
@@ -26,7 +26,7 @@ use crate::provider::{ChatMessage, MessageRole};
/// 用于发送前预算控制,误差 ±15% 完全可接受(保守估计,宁可多算)。 /// 用于发送前预算控制,误差 ±15% 完全可接受(保守估计,宁可多算)。
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct TokenEstimator { pub struct TokenEstimator {
/// 字符 → token 转换系数(默认 0.35,即 ~2.8 字符/token,中英混合偏保守) /// 非 CJK 字符(拉丁/数字/符号)→ token 转换系数(默认 0.35,约 3 字符/token)
pub chars_ratio: f32, pub chars_ratio: f32,
/// 每条消息固定开销(role 标记 + 格式) /// 每条消息固定开销(role 标记 + 格式)
pub per_message_overhead: u32, pub per_message_overhead: u32,
@@ -44,48 +44,83 @@ impl Default for TokenEstimator {
} }
} }
// url 模式图片 token 地板(Anthropic 保守 ~1600/图,OpenAI 按 tile;URL 长度估严重低估会致预算裁剪不触发)。 /// CJK(中日韩)字符 token 比率:1 字约 1~1.5 token,取 1.2 略保守。
/// 此前统一按 0.35 计,中文低估 2~3 倍,长中文会话预算门形同虚设
///(估 3 万实 9 万,裁剪不触发直顶 provider 上限)。
const CJK_TOKEN_RATIO: f32 = 1.2;
// 图片 token 地板(base64 与 url 两模式同用,Anthropic 保守 ~1600/图,OpenAI 按 tile)。
// 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 // 注意单位是 token 不是字符:地板必须在字符折扣之后直接加 token,若混进字符计数
// 会被 0.35 稀释成 560,保底名存实亡。 // 会被 0.35 稀释成 560,保底名存实亡。
const URL_IMAGE_TOKEN_FLOOR: usize = 1600; // base64 同用地板(此前按字符 0.35 计):provider 端图片按分辨率/张数计费,与 base64
// 字符串长度无关,25 万字符按 0.35 计 8.7 万 token 是纯高估,致含图消息过度裁剪;
// 两模式语义等价(都是一张图),成本口径单一(DRY)。
const IMAGE_TOKEN_FLOOR: usize = 1600;
/// CJK 字符判定:中日韩文字/假名/谚文/CJK 标点/全角形式。
fn is_cjk(ch: char) -> bool {
matches!(ch as u32,
0x3000..=0x303F // CJK 标点
| 0x3040..=0x30FF // 日文假名
| 0x3400..=0x4DBF // 表意文字扩展 A
| 0x4E00..=0x9FFF // CJK 基本区
| 0xAC00..=0xD7AF // 韩文谚文
| 0xF900..=0xFAFF // 兼容表意文字
| 0xFF00..=0xFFEF // 全角形式
)
}
/// 分档字符 token 计数:CJK 按 [`CJK_TOKEN_RATIO`],其余按调用方给定比率。
fn weighted_char_tokens(s: &str, plain_ratio: f32) -> u32 {
let (mut cjk, mut plain) = (0usize, 0usize);
for ch in s.chars() {
if is_cjk(ch) { cjk += 1 } else { plain += 1 }
}
(cjk as f32 * CJK_TOKEN_RATIO + plain as f32 * plain_ratio).ceil() as u32
}
impl TokenEstimator { impl TokenEstimator {
/// 估算单条消息的 token 数(保守估计) /// 估算单条消息的 token 数(保守估计)
/// ///
/// 多模态回归修正:`msg.parts` 中的 Image.base64 与 Text.text 同样计入预算。 /// 文本按 CJK/其余分档计数;图片(base64 与 url)统一按 [`IMAGE_TOKEN_FLOOR`] 计,
/// 此前只算 `content`,含图消息的大段 base64(可达 25 万 tokens)被完全忽略,致 /// 不随 base64 字符串长度膨胀。
/// `history_tokens` 严重低估 → build_for_request 误判未超预算 → provider 超限 400/500。
/// 这里把 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(); // namespace 引用(Tool 消息)特殊入账:按 ref 尾段携带的原文字节数保守视作字符
// url 图地板单独按 token 累加:地板语义是"每图至少 ~1600 token",必须生效于 // 并按 plain 比率计(多为代码/JSON 输出);中文引用会低估,方向可接受:ref 体量
// ×chars_ratio 折扣之后——若折进 char_count 会被 0.35 稀释成 560,1600 保底失效, // 本就由 namespace 上限闸门控,此处入账只为预算可见性。
// 含 url 图消息的 history_tokens 低估 → 预算裁剪漏触发 → provider 超限 400。 let mut content_tokens = if matches!(msg.role, crate::provider::MessageRole::Tool) {
let mut url_image_floor_tokens: u32 = 0; match crate::namespace_store::parse_ref_content_len(&msg.content) {
Some(n) => (n as f32 * self.chars_ratio).ceil() as u32,
None => weighted_char_tokens(&msg.content, self.chars_ratio),
}
} else {
weighted_char_tokens(&msg.content, self.chars_ratio)
};
// 图片地板按 token 直接累加,生效于字符折扣之外(折进字符计数会被 0.35 稀释,
// 1600 保底失效,预算裁剪漏触发 → provider 超限 400)。
let mut 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 {
crate::provider::ContentPart::Text { text } => char_count += text.chars().count(), crate::provider::ContentPart::Text { text } => {
content_tokens = content_tokens
.saturating_add(weighted_char_tokens(text, self.chars_ratio));
}
crate::provider::ContentPart::Image { base64, url, .. } => { crate::provider::ContentPart::Image { base64, url, .. } => {
// 0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。 // base64 与 url 语义等价(都是一张图),同用地板;有 base64 时
// 偏保守致含图消息 token 高估、过度裁剪;降值(如 0.10~0.15)需独立评估裁剪边界,本次不改值仅标注。 // url 为空(构造端二选一),不重复计
// base64 优先(多模态主载荷),url 次之;url 模式无字节,按地板直接计 token if base64.is_some() || url.is_some() {
if let Some(b) = base64 { image_floor_tokens =
char_count += b.chars().count(); image_floor_tokens.saturating_add(IMAGE_TOKEN_FLOOR as u32);
} else if url.is_some() {
// F-260801 Phase4:url 模式按地板估(URL 长度估严重低估,见 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 mut total = content_tokens + image_floor_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 {
@@ -104,9 +139,9 @@ impl TokenEstimator {
total total
} }
/// 估算纯文本字符串的 token 数(用于 system prompt) /// 估算纯文本字符串的 token 数(用于 system prompt),CJK 分档同 [`Self::estimate_message`]
pub fn estimate_text(&self, text: &str) -> u32 { pub fn estimate_text(&self, text: &str) -> u32 {
(text.chars().count() as f32 * self.chars_ratio).ceil() as u32 weighted_char_tokens(text, self.chars_ratio)
} }
} }
@@ -647,12 +682,22 @@ pub struct EvictionUnit {
/// 保护区大小:最后 N 条消息永不淘汰(≈ 最近 2 个完整用户轮次) /// 保护区大小:最后 N 条消息永不淘汰(≈ 最近 2 个完整用户轮次)
pub const PROTECT_COUNT: usize = 6; pub const PROTECT_COUNT: usize = 6;
/// 块状裁剪目标水位(× available):超预算时一次裁到该水位以下,而非贪心的刚好塞下。
///
/// 块大小 = (1 − T) × available,随模型窗口自适应缩放。两次裁剪之间裁剪边界不动 →
/// 被淘汰集不变 → 淘汰摘要内容不变(关键词提炼确定性)→ 发送视图前缀逐字节稳定,
/// 前缀缓存每轮命中。默认 0.8:块 ≈ 19K tokens,稳定窗口 2–9 轮,估算误差 ±15%
/// 被水位差吸收,不会刚裁完就再触。
pub const TRIM_TARGET_RATIO: f32 = 0.8;
/// 淘汰补偿摘要的硬上限(字符数)。 /// 淘汰补偿摘要的硬上限(字符数)。
/// ///
/// build_for_request 超预算裁剪时,把被淘汰文本提炼成一短段 system 摘要插回发送视图 /// build_for_request 超预算裁剪时,把被淘汰文本提炼成一短段 system 摘要插回发送视图
/// 头部(补偿无声丢弃的早期主题,对齐压缩路径的"裁剪有补偿"语义)。摘要过长会吃掉 /// 头部(补偿无声丢弃的早期主题,对齐压缩路径的"裁剪有补偿"语义)。摘要过长会吃掉
/// 本次裁剪腾出的预算,故硬截断到此上限(≈百 token 量级,相对淘汰收益可忽略)。 /// 本次裁剪腾出的预算,故硬截断到此上限。原 300 字(≈百 token)对几十轮历史的
pub const EVICTION_SUMMARY_MAX_CHARS: usize = 300; /// 摘要形同虚设,放大到 1000(≈300 token)——相对被裁掉的数千~数万 token 仍是零头,
/// 不侵蚀裁剪收益;PROTECT_COUNT 保持 6 不动(无实证症状,避免无据行为变更)。
pub const EVICTION_SUMMARY_MAX_CHARS: usize = 1000;
/// Anthropic 流式 tool_use 缺 id 时的占位 id 前缀(见 anthropic_compat.rs 170-176)。 /// Anthropic 流式 tool_use 缺 id 时的占位 id 前缀(见 anthropic_compat.rs 170-176)。
/// 此类 id 必然无匹配 tool_result,是历史中毒的标志,sanitize 时据此剔除畸形三元组。 /// 此类 id 必然无匹配 tool_result,是历史中毒的标志,sanitize 时据此剔除畸形三元组。
@@ -1059,18 +1104,18 @@ mod tests {
#[test] #[test]
fn eviction_summary_hard_capped_at_max_chars() { fn eviction_summary_hard_capped_at_max_chars() {
// 12 条纯标识符消息(每个标识符 39 字符,freq 均等且无中文干扰)→ top-10 全为长词, // 40 条纯标识符消息(每个标识符 39 字符,freq 均等且无中文干扰)→ top-10 全为长词,
// 总长超 300 → 硬截断到 EVICTION_SUMMARY_MAX_CHARS。 // 总长超上限(上限放大到 1000 后需更多数据才触发截断) → 硬截断。
let msgs: Vec<ChatMessage> = (0..12) let msgs: Vec<ChatMessage> = (0..40)
.map(|i| { .map(|i| {
ChatMessage::user(&format!("long_identifier_{:02}_for_truncation_probe", i)) ChatMessage::user(&format!("long_identifier_{:02}_for_truncation_probe", i))
}) })
.collect(); .collect();
let text = build_eviction_summary_text(&msgs).expect("应产出摘要"); let text = build_eviction_summary_text(&msgs).expect("应产出摘要");
assert_eq!( // 上限是防御性天花板(结构性产出 top-10 词表通常远低于它),断言「不超」而非「精确等于」
text.chars().count(), assert!(
EVICTION_SUMMARY_MAX_CHARS, text.chars().count() <= EVICTION_SUMMARY_MAX_CHARS,
"超长时应精确截断到硬上限, 实际 {}", "摘要不得超硬上限, 实际 {}",
text.chars().count() text.chars().count()
); );
} }
@@ -1744,8 +1789,8 @@ mod tests {
#[test] #[test]
fn url_image_token_floor_survives_ratio_discount() { fn url_image_token_floor_survives_ratio_discount() {
// url 图地板语义是"每图至少 ~1600 token",必须生效于 ×chars_ratio 折扣之后: // 图地板语义是"每图至少 ~1600 token",必须生效于字符折扣之外:
// 若地板折进 char_count 再乘 0.35,1600 被稀释成 ~560,保底失效 → 预算裁剪漏触发。 // 若地板折进字符计数再乘比率,1600 被稀释成 ~560,保底失效 → 预算裁剪漏触发。
let est = TokenEstimator::default(); let est = TokenEstimator::default();
let msg = ChatMessage::user_parts( let msg = ChatMessage::user_parts(
"t", "t",
@@ -1753,10 +1798,41 @@ mod tests {
); );
let tokens = est.estimate_message(&msg); let tokens = est.estimate_message(&msg);
assert!( assert!(
tokens >= URL_IMAGE_TOKEN_FLOOR as u32, tokens >= IMAGE_TOKEN_FLOOR as u32,
"url 图消息 token({}) 应 >= 地板 {},稀释 bug 下仅 ~565", "url 图消息 token({}) 应 >= 地板 {},稀释 bug 下仅 ~565",
tokens, tokens,
URL_IMAGE_TOKEN_FLOOR IMAGE_TOKEN_FLOOR
); );
} }
#[test]
fn base64_image_counts_as_floor_not_chars() {
// base64 不按字符计:provider 端图片按张数/分辨率计费,25 万字符按 0.35
// 计 8.7 万 token 是纯高估致过度裁剪。改地板后与图的真实成本同量级。
let est = TokenEstimator::default();
let big_base64 = "iVBORw0KGgoAAAANS".repeat(15000); // ~25.5 万字符
let msg = ChatMessage::user_parts(
"t",
vec![crate::provider::ContentPart::image_base64("image/png", big_base64)],
);
let tokens = est.estimate_message(&msg);
// 期望构成:地板 1600 + "t"(1 plain 字符 ceil 后 1) + 每消息开销 4
assert_eq!(
tokens,
IMAGE_TOKEN_FLOOR as u32 + 1 + est.per_message_overhead,
"base64 图消息应恰为地板+文本+开销,实际 {}",
tokens
);
}
#[test]
fn cjk_chars_cost_more_than_latin() {
// 中文按 1.2 token/字计:同字符数下中文估值须显著高于拉丁,预算门不再低估 2~3 倍
let est = TokenEstimator::default();
let zh = est.estimate_text("预算控制测试"); // 6 CJK 字符
let en = est.estimate_text("abcdef"); // 6 拉丁字符
assert_eq!(zh, 8, "6 CJK 字应计 8 token(6×1.2=7.2 ceil)");
assert_eq!(en, 3, "6 拉丁字符应计 3 token(6×0.35=2.1 ceil)");
assert!(zh > en * 2, "CJK 估值({zh})须显著高于同长度拉丁({en})");
}
} }
+115 -2
View File
@@ -60,9 +60,25 @@ pub fn is_namespace_ref(content: &str) -> bool {
/// 从引用路径中提取内部 key /// 从引用路径中提取内部 key
pub fn parse_namespace_key(full_path: &str) -> Option<&str> { pub fn parse_namespace_key(full_path: &str) -> Option<&str> {
full_path.strip_prefix(NAMESPACE_REF_PREFIX) full_path.strip_prefix(NAMESPACE_REF_PREFIX)
// 939①: 先剥 ?len= 长度尾段(估算入账用),否则尾段并入 key 致全线查空
.and_then(|s| s.split("?len=").next())
.and_then(|s| s.split('/').nth(1)) .and_then(|s| s.split('/').nth(1))
} }
/// 从引用路径提取原文字节数(939①)。带 `?len=N` 尾段的引用返回 Some(N);
/// 旧格式/放弃入库的无尾段引用返回 None(调用方按字面字符保守估算)。
pub fn parse_ref_content_len(full_path: &str) -> Option<usize> {
full_path.rsplit("?len=")
.next()
.and_then(|s| s.parse::<usize>().ok())
.filter(|_| full_path.contains("?len="))
}
/// 从引用路径提取工具名段(懒回填重建条目元数据用)。
pub fn parse_ref_tool_name(full_path: &str) -> Option<&str> {
full_path.strip_prefix(NAMESPACE_REF_PREFIX)?.split('/').next()
}
/// namespace 条目 /// namespace 条目
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct NamespaceEntry { pub struct NamespaceEntry {
@@ -138,6 +154,12 @@ impl NamespaceStore {
fn store_inner(&mut self, tool_name: &str, content: &str, owner: Option<&str>, pinned: bool) -> String { fn store_inner(&mut self, tool_name: &str, content: &str, owner: Option<&str>, pinned: bool) -> String {
let key = simple_hash(content); let key = simple_hash(content);
// 939① 估算入账:ref 尾段携带原文字节数,TokenEstimator 解析即按真实体量入账
// (旧口径 ref 仅 ~30 字符被记 ~15 token,预算/裁剪/压缩三重门对其失效)。
// parse_namespace_key 取 nth(1) 不受尾段影响,展开/淘汰提示零改动。
// 放弃入库(超容量/淘汰后仍放不下)返回**不带长度**的 path:原文不在池,展开点
// 落占位提示,预算按轻量字符计——已淘汰内容不应继续虚占预算。
let path_with_len = format!("{}{}/{}?len={}", NAMESPACE_REF_PREFIX, tool_name, key, content.len());
let path = format!("{}{}/{}", NAMESPACE_REF_PREFIX, tool_name, key); let path = format!("{}{}/{}", NAMESPACE_REF_PREFIX, tool_name, key);
// 已存在 → 刷新 access_count,不重复占用空间;若本次要求 pin,升级既有条目 // 已存在 → 刷新 access_count,不重复占用空间;若本次要求 pin,升级既有条目
@@ -148,7 +170,7 @@ impl NamespaceStore {
entry.pinned = true; entry.pinned = true;
} }
} }
return path; return path_with_len;
} }
let new_bytes = content.len(); let new_bytes = content.len();
@@ -190,7 +212,7 @@ impl NamespaceStore {
self.current_bytes += new_bytes; self.current_bytes += new_bytes;
self.entries.insert(key, entry); self.entries.insert(key, entry);
path path_with_len
} }
/// pin/取消 pin(事后)。pin 总量超 50% 容量时本调用拒绝并返回 false(防失控,拍板 C1)。 /// pin/取消 pin(事后)。pin 总量超 50% 容量时本调用拒绝并返回 false(防失控,拍板 C1)。
@@ -263,6 +285,40 @@ impl NamespaceStore {
)) ))
} }
/// 跨重启懒回填:store 清空后旧引用首次展开未命中时,从会话消息原文找回并重建条目。
/// DB 落库约定存展开后原文,引用 key 即入库时的原文内容哈希(SipHash 确定性,跨进程
/// 一致),哈希精确匹配即可靠对应单条原文,无需按序重建;原文不在消息里的引用(真淘汰)
/// 不进返回值,调用方维持淘汰占位语义。
/// 入库走 store 既有容量语义(超容量拒绝只影响后续轮次命中,本轮视图仍用返回的原文)。
/// 返回 引用路径 → 原文。
pub fn backfill_from_messages(
&mut self,
missed_refs: &[String],
message_contents: &[String],
) -> HashMap<String, String> {
let mut recovered: HashMap<String, String> = HashMap::new();
if missed_refs.is_empty() {
return recovered;
}
let wanted: HashMap<&str, &String> = missed_refs.iter()
.filter_map(|p| parse_namespace_key(p).map(|k| (k, p)))
.collect();
for content in message_contents {
let key = simple_hash(content);
if let Some(path) = wanted.get(key.as_str()) {
// 同一引用可能在多条历史消息出现,找回一次即可
if recovered.contains_key(path.as_str()) {
continue;
}
if let Some(tool) = parse_ref_tool_name(path) {
self.store(tool, content);
}
recovered.insert((*path).clone(), content.clone());
}
}
recovered
}
/// 检查 key 是否存在 /// 检查 key 是否存在
pub fn contains(&self, full_path: &str) -> bool { pub fn contains(&self, full_path: &str) -> bool {
parse_namespace_key(full_path) parse_namespace_key(full_path)
@@ -532,4 +588,61 @@ mod tests {
assert!(ns.contains(&p_old) == false || ns.pinned_bytes() <= 50, assert!(ns.contains(&p_old) == false || ns.pinned_bytes() <= 50,
"降级机制应使 pin 总量可被压缩"); "降级机制应使 pin 总量可被压缩");
} }
#[test]
fn ref_carries_content_len_for_budget() {
// 939①: 成功入库的 ref 带 ?len= 原文字节数,parse 往返一致;
// parse_namespace_key 不受尾段影响(展开点零改动)
let mut ns = NamespaceStore::new(100_000);
let content = "x".repeat(5000);
let path = ns.store("read_file", &content);
assert!(path.contains(&format!("?len={}", content.len())), "ref 应带 len: {path}");
assert_eq!(parse_ref_content_len(&path), Some(content.len()));
assert_eq!(parse_namespace_key(&path), parse_namespace_key(&format!("namespace://read_file/{}", crate::namespace_store::simple_hash(&content))));
// 旧格式/无尾段 → None(调用方保守按字面计)
assert_eq!(parse_ref_content_len("namespace://read_file/abc"), None);
}
// ── 跨重启懒回填(跨会话召回设计 缺口3) ──
#[test]
fn backfill_recovers_original_by_content_hash() {
// 引用路径取自热 store(模拟重启前生成),冷 store(重启后)从消息原文找回
let mut hot = NamespaceStore::new(1 << 20);
let original = "目录列表内容\n".repeat(100);
let path = hot.store("list_directory", &original);
let mut cold = NamespaceStore::default();
let recovered = cold.backfill_from_messages(
&[path.clone()],
&[original.clone(), "无关消息".to_string()],
);
assert_eq!(recovered.get(&path).map(String::as_str), Some(original.as_str()));
assert_eq!(cold.read_only(&path).map(str::to_owned), Some(original), "回填后条目应可读");
}
#[test]
fn backfill_without_original_stays_empty() {
let mut hot = NamespaceStore::new(1 << 20);
let path = hot.store("read_file", &"真原文".repeat(500));
let mut cold = NamespaceStore::default();
let recovered = cold.backfill_from_messages(
&[path.clone()],
&["无关A".to_string(), "无关B".to_string()],
);
assert!(recovered.is_empty(), "无对应原文不应误填");
assert_eq!(cold.read_only(&path), None);
assert!(cold.is_empty());
}
#[test]
fn backfill_duplicate_refs_recover_once() {
// 同一引用出现在多条历史消息:找回一次,store 只建一条
let mut hot = NamespaceStore::new(1 << 20);
let original = "z".repeat(3000);
let path = hot.store("grep", &original);
let mut cold = NamespaceStore::default();
let recovered = cold.backfill_from_messages(&[path.clone(), path.clone()], &[original]);
assert_eq!(recovered.len(), 1);
assert_eq!(cold.len(), 1);
}
} }
+3 -1
View File
@@ -68,7 +68,9 @@ fn non_read_file_large_result_also_gets_handle() {
let _ = std::fs::remove_dir_all(&base); let _ = std::fs::remove_dir_all(&base);
tool_offload::init_base(base.clone()); tool_offload::init_base(base.clone());
let body: String = (1..=1200).map(|i| format!("match {i}: src/foo.rs:{i}\n")).collect(); // 中性行夹具:不得含 error/panic/.rs: 等错误行信号——错误行保留策略会全量保留
// 命中行,用信号行做夹具会让「应被压缩」断言先天不成立(测的是普通大结果外置)
let body: String = (1..=1200).map(|i| format!("match {i}: src/foo.txt:{i}\n")).collect();
assert!(body.len() >= 8 * 1024, "夹具须 ≥8KB(句柄追加下限)"); assert!(body.len() >= 8 * 1024, "夹具须 ≥8KB(句柄追加下限)");
let out = extract_key_info(&body, "grep"); let out = extract_key_info(&body, "grep");
assert!(out.len() < body.len(), "应被压缩"); assert!(out.len() < body.len(), "应被压缩");
@@ -114,6 +114,8 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
loop_state: row.get("loop_state").unwrap_or(None), loop_state: row.get("loop_state").unwrap_or(None),
// status 列老库经 v50 迁移补建(NOT NULL DEFAULT 'active')。兜底迁移未跑/极端缺列时按 active。 // status 列老库经 v50 迁移补建(NOT NULL DEFAULT 'active')。兜底迁移未跑/极端缺列时按 active。
status: row.get("status").unwrap_or_else(|_| "active".to_string()), status: row.get("status").unwrap_or_else(|_| "active".to_string()),
// working_context_json 列老库经 v53 迁移补建。unwrap_or(None) 兜底(NULL=无 WorkingContext)。
working_context_json: row.get("working_context_json").unwrap_or(None),
}) })
} }
@@ -191,14 +193,14 @@ 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, origin, boundary_json, loop_state, status) "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, boundary_json, loop_state, status, working_context_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
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.origin, rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at, rec.origin,
rec.boundary_json, rec.loop_state, rec.status rec.boundary_json, rec.loop_state, rec.status, rec.working_context_json
], ],
) )
}, },
@@ -207,13 +209,16 @@ impl_repo!(
// update_full 的调用方(save_conversation 等)从 get_by_id 读旧记录再改元数据写回, // update_full 的调用方(save_conversation 等)从 get_by_id 读旧记录再改元数据写回,
// 读改写窗口内 loop 生命周期/终局态可变更——若 SET 含二者,旧值会覆盖新值(lost-update)。 // 读改写窗口内 loop 生命周期/终局态可变更——若 SET 含二者,旧值会覆盖新值(lost-update)。
// 生命周期只经 set_loop_state / set_status 单点写。 // 生命周期只经 set_loop_state / set_status 单点写。
// working_context_json 在 SET 列(V53):与 pinned_goals/boundary_json 同为 save 每轮
// 随行写的内存真相源快照,读改写窗口内无独立写入方,不存在 lost-update 面。
conn.execute( conn.execute(
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12, boundary_json = ?13 WHERE id = ?14", "UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12, boundary_json = ?13, working_context_json = ?14 WHERE id = ?15",
params![ params![
rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, 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.updated_at, rec.boundary_json, rec.id rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.boundary_json,
rec.working_context_json, rec.id
], ],
) )
} }
@@ -1186,6 +1191,7 @@ mod tests {
boundary_json: None, boundary_json: None,
loop_state: None, loop_state: None,
status: "active".into(), status: "active".into(),
working_context_json: None,
}) })
.await .await
.expect("insert"); .expect("insert");
@@ -1225,6 +1231,7 @@ mod tests {
boundary_json: None, boundary_json: None,
loop_state: None, loop_state: None,
status: "active".into(), status: "active".into(),
working_context_json: None,
}) })
.await .await
.expect("insert"); .expect("insert");
@@ -1236,4 +1243,59 @@ mod tests {
let got2 = repo.get_by_id("c1").await.expect("get").expect("row"); let got2 = repo.get_by_id("c1").await.expect("get").expect("row");
assert_eq!(got2.status, "incomplete", "写 incomplete 应可读回"); assert_eq!(got2.status, "incomplete", "写 incomplete 应可读回");
} }
/// V53 契约:working_context_json 经 insert → get_by_id → update_full 全程原样存取
/// (存储层按不透明 TEXT,结构解析归调用方);老库行(缺列插入)读回 None 不炸。
#[tokio::test]
async fn working_context_json_roundtrip_and_legacy_null() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiConversationRepo::new(&db);
let wc_json = r#"{"goals":[{"id":"t1","text":"修登录bug","status":"Active"}],"decisions":[],"unresolved":["端口未定"],"current_step":null,"version":{"goals_changed_at":1,"decisions_changed_at":0,"unresolved_changed_at":0,"step_changed_at":0,"current_turn":1}}"#;
let mut rec = AiConversationRecord {
id: "c1".into(),
title: None,
messages: "[]".into(),
provider_id: None,
model: None,
models: None,
archived: false,
pinned: false,
prompt_tokens: Some(0),
completion_tokens: Some(0),
pinned_goals: None,
pending_approvals: None,
created_at: "0".into(),
updated_at: "0".into(),
origin: None,
boundary_json: None,
loop_state: None,
status: "active".into(),
working_context_json: Some(wc_json.into()),
};
repo.insert(rec.clone()).await.expect("insert");
let got = repo.get_by_id("c1").await.expect("get").expect("row");
assert_eq!(got.working_context_json.as_deref(), Some(wc_json), "insert 后应原样读回");
// update_full 全量写(save_conversation 路径)后列随行更新
rec.title = Some("t".into());
rec.working_context_json = Some(r#"{"goals":[],"decisions":[],"unresolved":[],"current_step":null,"version":{"goals_changed_at":0,"decisions_changed_at":0,"unresolved_changed_at":0,"step_changed_at":0,"current_turn":0}}"#.into());
repo.update_full(&rec).await.expect("update");
let got2 = repo.get_by_id("c1").await.expect("get").expect("row");
assert!(got2.working_context_json.as_deref().unwrap_or_default().contains(r#""goals":[]"#), "update_full 后应写新快照");
// 老库兼容:绕过 repo 直插不带该列的行(模拟 V53 前老数据)→ 读回 None
{
let conn = db.conn();
let g = conn.lock().await;
g.execute(
"INSERT INTO ai_conversations (id, messages, created_at, updated_at, status) \
VALUES ('legacy', '[]', '0', '0', 'active')",
[],
)
.expect("raw insert legacy row");
}
let legacy = repo.get_by_id("legacy").await.expect("get").expect("legacy row");
assert!(legacy.working_context_json.is_none(), "老库行应为 None(无 WorkingContext)");
}
} }
+169
View File
@@ -1178,6 +1178,74 @@ impl KnowledgeRepo {
.await .await
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 知识路标读侧(devflow.md 投影通道):已发布且(人工验证 或 复用达标)的条目选摘。
///
/// 与检索族(search/search_keywords)的差别:路标无查询词,是「库里有什么」的静态选摘,
/// 供 system prompt 路标段铺开让 agent 知晓库藏、想起检索。入选门槛 = published 且
/// (verified = 1 OR reuse_count >= 3);排序 = 复用降序(唯一客观信号,对齐 search/top_used),
/// 同复用按 created_at 降序定稳定次序。verified 只作入选不作排序(复用是更强的实战信号)。
///
/// 两档拼接:项目档(source_project = project_id,上限 project_limit)在前——会话归属
/// 项目的知识最相关;跨项目补充档(非本项目含无归属条目,上限 global_limit)在后。
/// 两档 WHERE 互斥(= pid vs IS NULL/≠ pid),结果天然无重复。
/// project_id = None → 仅补充档(会话未解析出归属项目,项目档无从谈起)。
pub async fn list_waymark(
&self,
project_id: Option<&str>,
project_limit: usize,
global_limit: usize,
) -> Result<Vec<KnowledgeRecord>> {
let conn = self.conn.clone();
let project_id = project_id.map(|s| s.to_owned());
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
// 入选门槛与排序两档共用(verified 在 SQLite 存 0/1;复用达标压线 3 即入选)
let qualify = "status = 'published' AND (verified = 1 OR reuse_count >= 3)";
let order = " ORDER BY reuse_count DESC, created_at DESC LIMIT ";
let mut results = Vec::new();
// 项目档:会话归属项目内的合格条目(复用降序,上限 project_limit)
if let Some(pid) = &project_id {
let sql = format!(
"SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE {qualify} AND source_project = ?1{order}?2"
);
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
let rows = stmt
.query_map(params![pid, project_limit as i64], knowledge_from_row)
.map_err(storage_err)?;
for r in rows {
results.push(r.map_err(storage_err)?);
}
}
// 跨项目补充档:非本项目(含无归属全局条目)按复用降序补足 global_limit;
// WHERE 与项目档互斥 → 天然去重。project_id = None 时无排他条件,即全量合格条目。
// LIMIT 占位符编号随排他条件有无而变(?1 空档排他时归 LIMIT,有排他时排他占 ?1、LIMIT 占 ?2)
let exclude = if project_id.is_some() {
" AND (source_project IS NULL OR source_project != ?1)"
} else {
""
};
let lim_n = if project_id.is_some() { 2 } else { 1 };
let sql = format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE {qualify}{exclude}{order}?{lim_n}");
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
let rows = match &project_id {
Some(pid) => stmt
.query_map(params![pid, global_limit as i64], knowledge_from_row)
.map_err(storage_err)?,
None => stmt
.query_map(params![global_limit as i64], knowledge_from_row)
.map_err(storage_err)?,
};
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
} }
impl_repo!( impl_repo!(
@@ -1715,6 +1783,107 @@ mod tests {
assert_eq!(hits[0].id, "k1"); assert_eq!(hits[0].id, "k1");
} }
// ---------- 知识路标读侧(list_waymark,devflow.md 投影通道) ----------
/// 入选门槛:published 且(verified 或 复用达标);candidate/archived/复用不足均淘汰
#[tokio::test]
async fn waymark_qualifies_verified_or_reused_published_only() {
let repo = setup_repo().await;
// verified=true 复用 0 → 入选(验证豁免复用门槛)
let mut v = krec("v", "已验证条目", "...", "lesson", "published", None, 0);
v.verified = true;
repo.insert(v).await.unwrap();
// 未验证复用 3 → 入选(压线达标)
repo.insert(krec("r3", "复用达标条目", "...", "lesson", "published", None, 3))
.await
.unwrap();
// 未验证复用 2 → 淘汰
repo.insert(krec("r2", "复用不足条目", "...", "lesson", "published", None, 2))
.await
.unwrap();
// 复用再高,状态非 published(candidate)→ 淘汰
repo.insert(krec("cand", "候选未发布", "...", "lesson", "candidate", None, 99))
.await
.unwrap();
// archived → 淘汰
repo.insert(krec("arch", "已归档", "...", "lesson", "archived", None, 99))
.await
.unwrap();
let hits = repo.list_waymark(None, 20, 10).await.unwrap();
let ids: Vec<_> = hits.iter().map(|h| h.id.as_str()).collect();
assert_eq!(ids.len(), 2, "只应入选 已验证 + 复用达标 两条");
assert!(ids.contains(&"v") && ids.contains(&"r3"));
}
/// 两档序:项目档在前(复用降序,verified 豁免复用但仍按复用参与排序),
/// 跨项目补充档在后(复用降序);异项目高复用压不过同项目低复用(档位优先于复用)
#[tokio::test]
async fn waymark_project_tier_first_global_supplement_after() {
let repo = setup_repo().await;
repo.insert(krec("p9", "同项目复用9", "...", "lesson", "published", None, 9))
.await
.unwrap();
repo.insert(krec("p5", "同项目复用5", "...", "lesson", "published", None, 5))
.await
.unwrap();
// 同项目 verified 但零复用:入选项目档,排序落在复用条目之后
let mut pv = krec("pv", "同项目已验证零复用", "...", "lesson", "published", None, 0);
pv.verified = true;
repo.insert(pv).await.unwrap();
// 异项目高复用 → 补充档
let mut f = krec("f99", "异项目复用99", "...", "lesson", "published", None, 99);
f.source_project = Some("proj-2".to_string());
repo.insert(f).await.unwrap();
// 无归属全局条目 → 补充档
let mut g = krec("g10", "全局复用10", "...", "lesson", "published", None, 10);
g.source_project = None;
repo.insert(g).await.unwrap();
let hits = repo.list_waymark(Some("proj-1"), 20, 10).await.unwrap();
let ids: Vec<_> = hits.iter().map(|h| h.id.as_str()).collect();
assert_eq!(
ids,
vec!["p9", "p5", "pv", "f99", "g10"],
"项目档在前(复用降序),补充档在后(复用降序:异项目99 > 全局10)"
);
// 两档 WHERE 互斥的去重属性:任一条目恰好出现一次
assert_eq!(ids.iter().filter(|i| **i == "p9").count(), 1, "同项目条目不得因两档重复出现");
}
/// 各档上限独立生效:项目档取复用最高的 project_limit 条,补充档另计 global_limit 条
/// (条目复用全部压线达标,确保裁剪只来自 LIMIT 而非入选门槛)
#[tokio::test]
async fn waymark_limits_cap_tiers_independently() {
let repo = setup_repo().await;
for reuse in 3..=5 {
repo.insert(krec(&format!("p{reuse}"), "同项目", "...", "lesson", "published", None, reuse))
.await
.unwrap();
}
for reuse in 3..=5 {
let mut g = krec(&format!("g{reuse}"), "全局", "...", "lesson", "published", None, reuse);
g.source_project = None;
repo.insert(g).await.unwrap();
}
let hits = repo.list_waymark(Some("proj-1"), 2, 2).await.unwrap();
let ids: Vec<_> = hits.iter().map(|h| h.id.as_str()).collect();
assert_eq!(
ids,
vec!["p5", "p4", "g5", "g4"],
"项目档取复用最高的 2 条,补充档独立取全局最高的 2 条"
);
}
/// 空库 → 空结果(路标子块整体缺席的前提,投影侧据此零追加)
#[tokio::test]
async fn waymark_empty_db_returns_empty() {
let repo = setup_repo().await;
assert!(repo.list_waymark(Some("proj-1"), 20, 10).await.unwrap().is_empty());
assert!(repo.list_waymark(None, 20, 10).await.unwrap().is_empty());
}
// ---------- 复用即信任(auto_publish_if_reused) ---------- // ---------- 复用即信任(auto_publish_if_reused) ----------
#[tokio::test] #[tokio::test]
+86 -2
View File
@@ -265,6 +265,12 @@ impl AiMessageRepo {
/// 全量重写语义——以入参 records 为该对话的**唯一真相**,先删该 conv 全部旧行再批量插。 /// 全量重写语义——以入参 records 为该对话的**唯一真相**,先删该 conv 全部旧行再批量插。
/// 单事务保证「删 + 插」原子,无中间空窗(reload 不会读到半删半插的中间态)。 /// 单事务保证「删 + 插」原子,无中间空窗(reload 不会读到半删半插的中间态)。
/// ///
/// created_at 保出生时间(2026-10-17 修复批量污染):created_at 语义 = 消息首次入库时间,
/// 全量重写只该变内容不变出生时间。调用方(save_conversation)对整批 records 统一盖
/// 对话级 created_at(老对话 None 时 now 兜底),直插会把全对话消息 created_at 抹成同一秒。
/// 故 DELETE 前先取存量 `id → created_at` 映射,重插时同 id 行回填原值;新 id(真新消息)
/// 保留入参值。ai_messages 表无 updated_at 列,内容变更无单独时间戳(不在此列语义内)。
///
/// 设计权衡(非 dirty 增量,留 P2.1 优化): /// 设计权衡(非 dirty 增量,留 P2.1 优化):
/// - 内存 ContextManager 是运行时真相源,save 是同步点,每轮 save 全量回写简单可靠; /// - 内存 ContextManager 是运行时真相源,save 是同步点,每轮 save 全量回写简单可靠;
/// - INSERT OR IGNORE 幂等:records 内 id 重复或与残留行(理论不应有,事务已 DELETE)冲突跳过; /// - INSERT OR IGNORE 幂等:records 内 id 重复或与残留行(理论不应有,事务已 DELETE)冲突跳过;
@@ -274,7 +280,7 @@ impl AiMessageRepo {
pub async fn replace_conversation( pub async fn replace_conversation(
&self, &self,
conv_id: &str, conv_id: &str,
records: Vec<AiMessageRecord>, mut records: Vec<AiMessageRecord>,
) -> Result<()> { ) -> Result<()> {
let conn = self.conn.clone(); let conn = self.conn.clone();
let conv_id = conv_id.to_owned(); let conv_id = conv_id.to_owned();
@@ -282,7 +288,29 @@ impl AiMessageRepo {
let mut guard = conn.blocking_lock(); let mut guard = conn.blocking_lock();
let tx = guard.transaction().map_err(storage_err)?; let tx = guard.transaction().map_err(storage_err)?;
{ {
// 先删该 conv 全部旧行(全量重写语义) // 先取存量 id → created_at 映射(DELETE 前留证,重插回填保出生时间)
{
let mut stmt = tx
.prepare("SELECT id, created_at FROM ai_messages WHERE conversation_id = ?1")
.map_err(storage_err)?;
let rows = stmt
.query_map(params![conv_id], |r| {
Ok((r.get::<_, String>("id")?, r.get::<_, String>("created_at")?))
})
.map_err(storage_err)?;
let mut original: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for r in rows {
let (id, created_at) = r.map_err(storage_err)?;
original.insert(id, created_at);
}
for rec in &mut records {
if let Some(orig) = original.get(&rec.id) {
rec.created_at = orig.clone();
}
}
}
// 再删该 conv 全部旧行(全量重写语义)
tx.execute( tx.execute(
"DELETE FROM ai_messages WHERE conversation_id = ?1", "DELETE FROM ai_messages WHERE conversation_id = ?1",
params![conv_id], params![conv_id],
@@ -693,6 +721,62 @@ mod tests {
assert_eq!(got.len(), 1, "重复 replace 不应叠加"); assert_eq!(got.len(), 1, "重复 replace 不应叠加");
} }
/// replace_conversation 保 created_at 出生时间(2026-10-17 批量污染回归):
/// 调用方对整批统一盖一个 created_at(如对话级时间或 now),重写后既有 id 行
/// 的 created_at 必须逐行保留原入库值,不得被统一时间抹平;新 id 行保留入参值。
#[tokio::test]
async fn replace_conversation_preserves_created_at() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiMessageRepo::new(&db);
// 预置两行,created_at 各异(模拟真实逐条入库的出生时间)
let mut m0 = mk_msg("m0", "c", 0, "user", "v1");
m0.created_at = "2026-10-01T10:00:00.000".into();
let mut m1 = mk_msg("m1", "c", 1, "assistant", "v1");
m1.created_at = "2026-10-02T11:30:00.000".into();
repo.insert_batch(vec![m0, m1]).await.expect("insert");
// 重写:既有 id 内容变了、created_at 被调用方统一盖成同一时间;
// 另带一条新 id 消息(真新消息,该保留入参时间)。
let uniform_ts = "2026-10-17T21:25:01.000";
let rewrite = |id: &str, seq: i64, role: &str, content: &str| {
let mut m = mk_msg(id, "c", seq, role, content);
m.created_at = uniform_ts.into();
m
};
repo.replace_conversation(
"c",
vec![
rewrite("m0", 0, "user", "v2-改写内容"),
rewrite("m1", 1, "assistant", "v2-改写内容"),
rewrite("m2_new", 2, "user", "新消息"),
],
)
.await
.expect("replace");
let got = repo.list_by_conversation("c").await.expect("list");
assert_eq!(got.len(), 3);
// 逐行断言:既有 id 的 created_at 保持原值,内容确实变了
assert_eq!(got[0].id, "m0");
assert_eq!(got[0].created_at, "2026-10-01T10:00:00.000", "既有行 created_at 不得被重写抹平");
assert_eq!(got[0].content, "v2-改写内容");
assert_eq!(got[1].id, "m1");
assert_eq!(got[1].created_at, "2026-10-02T11:30:00.000", "既有行 created_at 不得被重写抹平");
assert_eq!(got[1].content, "v2-改写内容");
// 新 id(首次入库)保留入参 created_at
assert_eq!(got[2].id, "m2_new");
assert_eq!(got[2].created_at, uniform_ts, "新消息保留入参 created_at");
// 二次重写再验:出生时间已固化,连续重写不漂移
repo.replace_conversation("c", vec![rewrite("m0", 0, "user", "v3")])
.await
.expect("replace again");
let got = repo.list_by_conversation("c").await.expect("list");
assert_eq!(got.len(), 1);
assert_eq!(got[0].created_at, "2026-10-01T10:00:00.000", "连续重写 created_at 不漂移");
}
/// limit=0 契约:返空集(直传 SQLite LIMIT 0),不再旧版 max(1) 钳成 1 条。 /// limit=0 契约:返空集(直传 SQLite LIMIT 0),不再旧版 max(1) 钳成 1 条。
/// 锁死语义防回归:请求 0 条却回 1 条会让调用方误判页内有数据。 /// 锁死语义防回归:请求 0 条却回 1 条会让调用方误判页内有数据。
#[tokio::test] #[tokio::test]
+80 -16
View File
@@ -87,9 +87,9 @@ fn build_order_clause(order_by: Option<&str>) -> Result<String> {
/// 带「最近活跃时间」的项目记录(list_active_with_activity 专用返回结构)。 /// 带「最近活跃时间」的项目记录(list_active_with_activity 专用返回结构)。
/// ///
/// `last_active_at` = COALESCE(project_events 最新事件 created_at, projects.updated_at): /// `last_active_at` = MAX(tasks.updated_at 最新值, project_events 最新 created_at,
/// 反映业务活跃(任务/灵感/状态推进等事件),无事件项目回退 updated_at。供前端「最近活跃排序」 /// projects.updated_at):任务活跃为主信号(任务面是真实工作发生地),事件表已停写仅保留维度,
/// 展示,语义比 projects.updated_at(随元信息修改如改 description 也会刷新)更准确。 /// updated_at 兜底。供前端「最近活跃排序」展示与 system prompt 项目清单排序。
/// ///
/// Serialize 供 IPC 层直接序列化回前端(serde 字段名 snake_case,对齐 ProjectRecord)。 /// Serialize 供 IPC 层直接序列化回前端(serde 字段名 snake_case,对齐 ProjectRecord)。
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize)]
@@ -243,29 +243,35 @@ impl ProjectRepo {
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 列出未删除项目,**按「最近活跃」排序**(LEFT JOIN project_events 取最新事件时间, /// 列出未删除项目,**按「最近活跃」排序**(三源取大:任务活跃为主信号)。
/// COALESCE 回退 updated_at)。
/// ///
/// 业务语义:项目「最近活跃」应反映业务事件(任务/灵感/状态推进),而非 projects.updated_at /// 业务语义:last_active_at = MAX(tasks.updated_at 最新值, project_events 最新 created_at,
/// (后者随元信息修改如改 description/path 也会刷新,不反映真实业务活跃)。本方法通过子查询 /// projects.updated_at)。**任务活跃为主信号**——ai-chat 会话/任务面是真实工作发生地,
/// 取每个项目 project_events 最新 created_at,无事件项目回退 updated_at,排序列统一可比。 /// 任务停更即项目停更;project_events 已停写(现网空表,保留维度向后兼容);
/// projects.updated_at 仅元信息修改(改 description/path 也会刷新,不反映真实业务活跃),
/// 仅作兜底。无任务无事件的项目回退 updated_at,排序列统一可比。
/// ///
/// 子查询而非 JOIN:project_events 每项目可能 0..N 条,JOIN 会展开需 DISTINCT,子查询 /// 子查询而非 JOIN:tasks/project_events 每项目可能 0..N 条,JOIN 会展开需 DISTINCT,
/// `(SELECT MAX(created_at) FROM project_events WHERE project_id = projects.id)` 每行一次 /// 标量子查询每行一次聚合,语义清晰无笛卡尔积风险(SQLite 多参 MAX 为标量取大,
/// 聚合,语义清晰无笛卡尔积风险。单用户桌面应用项目数小,无性能压力。 /// IFNULL 兜空子查询 NULL)。单用户桌面应用项目数小,无性能压力。
/// ///
/// 返回 `ProjectActivityRecord`(ProjectRecord + last_active_at 字段),供前端排序展示。 /// 返回 `ProjectActivityRecord`(ProjectRecord + last_active_at 字段),供前端排序展示
/// 与 system prompt「当前项目」清单(build_context_lists)按活跃度排序。
pub async fn list_active_with_activity(&self) -> Result<Vec<ProjectActivityRecord>> { pub async fn list_active_with_activity(&self) -> Result<Vec<ProjectActivityRecord>> {
let conn = self.conn.clone(); let conn = self.conn.clone();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock(); let guard = conn.blocking_lock();
// COALESCE(子查询最新事件, projects.updated_at):无事件项目回退 updated_at, // 三源取大(SQLite 多参 MAX=标量取大,IFNULL 兜空子查询):任务活跃为主信号,
// 保证所有项目有可比活跃时间。ORDER BY 该列 DESC,同时间 created_at DESC 兜底稳定。 // 事件表已停写仅保留维度,updated_at 兜底。ORDER BY 该列 DESC,同时间 created_at DESC
// 兜底稳定。
let mut stmt = guard let mut stmt = guard
.prepare( .prepare(
"SELECT id, name, description, status, idea_id, path, stack, archived, created_at, updated_at, \ "SELECT id, name, description, status, idea_id, path, stack, archived, created_at, updated_at, \
COALESCE((SELECT MAX(created_at) FROM project_events WHERE project_id = projects.id), \ MAX(\
updated_at) AS last_active_at \ IFNULL((SELECT MAX(updated_at) FROM tasks WHERE project_id = projects.id AND deleted_at IS NULL), 0), \
IFNULL((SELECT MAX(created_at) FROM project_events WHERE project_id = projects.id), 0), \
updated_at\
) AS last_active_at \
FROM projects WHERE deleted_at IS NULL AND archived = 0 \ FROM projects WHERE deleted_at IS NULL AND archived = 0 \
ORDER BY last_active_at DESC, created_at DESC", ORDER BY last_active_at DESC, created_at DESC",
) )
@@ -840,4 +846,62 @@ mod tests {
assert_eq!(after.name, "原项目", "回收站项目内容不应被改写"); assert_eq!(after.name, "原项目", "回收站项目内容不应被改写");
assert_eq!(after.updated_at, current.updated_at, "updated_at 不应抖动"); assert_eq!(after.updated_at, current.updated_at, "updated_at 不应抖动");
} }
#[tokio::test]
async fn list_active_with_activity_orders_by_task_activity_over_created_at() {
// 回归(事故形状):项目 A created_at 较晚(创建序排前)但无任务/停更;项目 B
// created_at 较早但有一条 tasks.updated_at 最新 → 活跃序 B 排 A 前
// (任务活跃为主信号,治创建序误导 AI 把停摆项目当最高优先锚点)。
use crate::crud::task_repo::TaskRepo;
use crate::models::TaskRecord;
use df_types::types::TaskStatus;
let db = Database::open_in_memory().await.expect("open_in_memory");
let projects = ProjectRepo::new(&db);
let tasks = TaskRepo::new(&db);
// B 先建(早),A 后建(晚)——创建序会排 A 前
let mut early = prec("pb", "早建但活跃");
early.created_at = "1700000000000".to_string();
early.updated_at = "1700000000000".to_string();
let mut late = prec("pa", "晚建但停更");
late.created_at = "1700000010000".to_string();
late.updated_at = "1700000010000".to_string();
projects.insert(early).await.unwrap();
projects.insert(late).await.unwrap();
// B 挂一条最新任务;A 无任务 → B 活跃时间取任务 updated_at,A 回退自身 updated_at
let task = TaskRecord {
id: "tb".to_string(),
project_id: "pb".to_string(),
title: "B 的任务".to_string(),
description: String::new(),
status: TaskStatus::InProgress,
priority: 0,
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: None,
content_json: None,
module_id: None,
archived: false,
created_at: "1700000000000".to_string(),
updated_at: "1790000000000".to_string(),
};
tasks.insert(task).await.unwrap();
let listed = projects.list_active_with_activity().await.unwrap();
assert_eq!(listed.len(), 2);
assert_eq!(listed[0].record.id, "pb", "任务活跃最新的早建项目应排前");
assert_eq!(listed[1].record.id, "pa", "无任务的晚建项目应排后");
assert_eq!(
listed[0].last_active_at, "1790000000000",
"活跃时间应取该项目任务 updated_at 最新值"
);
}
} }
+74 -2
View File
@@ -65,7 +65,9 @@ pub fn run(conn: &Connection) -> Result<()> {
// V51 = 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基):队列/定时统一实体, // V51 = 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基):队列/定时统一实体,
// 调度器按 status+due_at 索引到点派发。纯新增表,可逆=DROP TABLE ai_outbox。 // 调度器按 status+due_at 索引到点派发。纯新增表,可逆=DROP TABLE ai_outbox。
// V52 = ai_messages.source 列(#958 消息溯源:外部渠道原始消息 ID JSON,NULL=桌面本端)。 // V52 = ai_messages.source 列(#958 消息溯源:外部渠道原始消息 ID JSON,NULL=桌面本端)。
let steps: [(i32, fn(&Connection) -> Result<()>); 52] = [ // V53 = WorkingContext 持久化(ai_conversations.working_context_json TEXT,跨会话召回 L4
// 重启不失忆:save 随行写,会话载入与 parked 恢复读还原,NULL=无上下文行为同旧)。
let steps: [(i32, fn(&Connection) -> Result<()>); 53] = [
(1, migrate_v1), (1, migrate_v1),
(2, migrate_v2), (2, migrate_v2),
(3, migrate_v3), (3, migrate_v3),
@@ -125,6 +127,8 @@ pub fn run(conn: &Connection) -> Result<()> {
(51, migrate_v51), (51, migrate_v51),
// V52 = ai_messages 加 source 列(#958 消息溯源:飞书等外部渠道原始消息 ID)。 // V52 = ai_messages 加 source 列(#958 消息溯源:飞书等外部渠道原始消息 ID)。
(52, migrate_v52), (52, migrate_v52),
// V53 = WorkingContext 持久化(ai_conversations.working_context_json,跨会话召回 L4)。
(53, migrate_v53),
]; ];
for (version, migrate_fn) in steps { for (version, migrate_fn) in steps {
@@ -1532,7 +1536,7 @@ fn migrate_v50(conn: &Connection) -> Result<()> {
/// 调度器启动时 reset_stale_sending 复位为 pending。 /// 调度器启动时 reset_stale_sending 复位为 pending。
/// 迁移链顶点版本(单一真值来源):**新增迁移时必须同步 +1**,否则下方迁移测试会失败 /// 迁移链顶点版本(单一真值来源):**新增迁移时必须同步 +1**,否则下方迁移测试会失败
/// (测试用它断言「全量迁移后 schema_version == 顶点」「重跑仍到顶点」——既防链中断,也免多处写死)。 /// (测试用它断言「全量迁移后 schema_version == 顶点」「重跑仍到顶点」——既防链中断,也免多处写死)。
pub const LATEST_SCHEMA_VERSION: i32 = 52; pub const LATEST_SCHEMA_VERSION: i32 = 53;
fn migrate_v51(conn: &Connection) -> Result<()> { fn migrate_v51(conn: &Connection) -> Result<()> {
if !table_exists(conn, "ai_outbox") { if !table_exists(conn, "ai_outbox") {
@@ -1584,6 +1588,27 @@ fn migrate_v52(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
/// V53: WorkingContext 持久化 — ai_conversations 加 working_context_json TEXT 列。
///
/// 跨会话召回 L4(重启不失忆):WorkingContext(自动目标/决策/未解项/当前步骤/脏标记版本)
/// 此前纯内存,重启后压缩重锚定回填的决策与未解项全丢,恢复的会话半失忆续跑。本列随 save
/// 每轮随行写、会话载入与 parked 恢复读还原;NULL=无上下文(老会话/全空),行为同旧。
/// 目标快照裁决语义另批,本列只做存储载体。
/// - **幂等**:column_exists 守卫(同 V44/V45/V49 模式),崩溃重跑/全链两遍不炸;
/// - **可逆**:`ALTER TABLE ai_conversations DROP COLUMN working_context_json`(SQLite 3.35+)。
fn migrate_v53(conn: &Connection) -> Result<()> {
if !column_exists(conn, "ai_conversations", "working_context_json") {
conn.execute(
"ALTER TABLE ai_conversations ADD COLUMN working_context_json TEXT",
[],
)?;
tracing::info!("v53: 补建 ai_conversations.working_context_json 列(WorkingContext 持久化)");
}
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [53])?;
tracing::info!("迁移 v53 完成: ai_conversations 加 working_context_json 列");
Ok(())
}
/// 回填 conversation_checkpoints.token_total:按 snapshot JSON 内逐条消息的 /// 回填 conversation_checkpoints.token_total:按 snapshot JSON 内逐条消息的
/// prompt_tokens+completion_tokens 求真实总量。 /// prompt_tokens+completion_tokens 求真实总量。
/// ///
@@ -2951,4 +2976,51 @@ mod tests {
"逐条相加,缺字段按 0" "逐条相加,缺字段按 0"
); );
} }
/// V53 幂等:最小老库补建 working_context_json 列;重跑(含版本号回退模拟崩溃断点)
/// 不炸不报 duplicate column;列可写可读(NULL 语义=无 WorkingContext)。
#[test]
fn v53_working_context_json_idempotent() {
let conn = setup_legacy_db();
assert!(
!column_exists(&conn, "ai_conversations", "working_context_json"),
"迁移前应无 working_context_json 列"
);
migrate_v53(&conn).expect("v53 首次迁移应成功");
assert!(column_exists(&conn, "ai_conversations", "working_context_json"), "应有 working_context_json 列");
let v: i64 = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, 53, "应写入版本号 53");
// 模拟崩溃断点:版本号回退但列已建 → 重跑走 column_exists 短路
conn.execute("DELETE FROM schema_version WHERE version = 53", []).unwrap();
migrate_v53(&conn).expect("列已存在时重跑应幂等跳过 ALTER 不报错");
let v2: i64 = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v2, 53, "重跑后版本号恢复 53");
// 列可写可读;老会话行(不带该列插入)读回 NULL
conn.execute(
"INSERT INTO ai_conversations (id, messages, created_at, updated_at, working_context_json) \
VALUES ('c1', '[]', '0', '0', '{\"goals\":[],\"decisions\":[]}')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES ('c2', '[]', '0', '0')",
[],
)
.unwrap();
let got: Option<String> = conn
.query_row("SELECT working_context_json FROM ai_conversations WHERE id = 'c1'", [], |r| r.get(0))
.unwrap();
assert!(got.is_some(), "working_context_json 应可写可读");
let legacy: Option<String> = conn
.query_row("SELECT working_context_json FROM ai_conversations WHERE id = 'c2'", [], |r| r.get(0))
.unwrap();
assert!(legacy.is_none(), "老会话行应为 NULL(无 WorkingContext)");
}
} }
+5
View File
@@ -400,6 +400,11 @@ pub struct AiConversationRecord {
/// error=循环错误终局。与 loop_state(运行期 running/parked,管「能否续跑」)正交—— /// error=循环错误终局。与 loop_state(运行期 running/parked,管「能否续跑」)正交——
/// 本列回答「会话如何结束」,供未完成率等统计。不进 update_full,走专用 set_status 单点写。 /// 本列回答「会话如何结束」,供未完成率等统计。不进 update_full,走专用 set_status 单点写。
pub status: String, pub status: String,
/// WorkingContext 持久化(V53,跨会话召回 L4 重启不失忆):WorkingContext JSON 序列化,
/// 存储层按不透明 TEXT 存取(结构归 df-ai)。NULL=无上下文(老会话/全空),行为同旧;
/// save 随行写/会话载入与 parked 恢复读还原,随会话级联(对齐 boundary_json 模式)。
#[serde(default)]
pub working_context_json: Option<String>,
} }
/// AI 工具执行审计记录 /// AI 工具执行审计记录
@@ -0,0 +1,48 @@
# ai-chat 上下文管理与信息组织·完善设计(2026-10-18)
> 用户令:ai-chat agent 上下文管理/信息组织开发完善;回复别太口水话(2026-10-18)。
> 证据输入(问题全量论证在彼,此处只引编号不重复):`temp/深查-aichat对话质量复测-2026-10-17.md`(E1-E13)、`temp/深查-aichat信息UX-2026-10-17.md`(F1-F6)。
> 现状判定:10-04 三大病(上下文失控/反复压缩/终局缺失)基本治愈;残量集中在**恢复链污染、终局态半闭环、存量脏数据、信息可达性、输出纪律**五个结构性面。
## 一、信息流模型与缺口定位
链路:`发起(直发/inject) → 目标提取(pinned_goals) → 上下文组装(system+摘要+近期) → 执行循环 → 压缩(view/cache) → 恢复(recovery) → 投影(轮桶/轨迹卡) → 呈现(桌面/飞书)`
| 缺口 | 根因(结构性,非散点) | 归并的散点 |
|---|---|---|
| **G-A 恢复链身份未建模** | 恢复消息无类型标记,兼具「系统信号」与「用户输入」双重身份,被目标提取源再次吸收 | E1 套娃、E3 二次恢复、看板 649 行恢复目标完整性守卫 |
| **G-B 终局态半闭环** | boundary_json/loop_state 字段已建、写入闭环未建(填充率 0) | P2-5、E3 竞态窗口 |
| **G-C 写入不保数据语义** | 写入方各自为政,全量重写/双写不保留原义 | E11 created_at 重写污染 1750 条、E5 存量目标不迁移、E12 双写 |
| **G-D 信息可达性瓶颈** | 呈现层硬上限+折叠体内不可见 | F4-1 会话 50 上限(实测 115 条已击穿)、F1-3+F3-2 注入降级藏审批、F6-1 |
| **G-E 输出纪律缺失** | 聚焦准则管「答什么」,不管「怎么答」——主提示词无回复风格条款 | 用户口水话反馈(本次令);摘要提示词已有该纪律可对齐 |
| **G-F 阴性结论无守卫** | 工具 0 命中被当事实断言,无复核引导 | E6 假阴性 3 轮错判链 |
## 二、修复批次(领单视角,M1 先行)
### M1 · P1 上下文正确性
1. **E1+E3 恢复链治本(G-A)**:恢复消息加 source 标记(与看板 961② inject 标记同基建,复用 V52 source 字段);build_recovery_message 过滤 `[系统恢复]` 前缀目标;目标写入侧拒收同前缀。防二次恢复:快照全部目标=completed 时短路,或恢复后 N 分钟窗口禁重复。
2. **E11 created_at 保义(G-C)**:message_repo.rs:285 全量重写保留原 created_at(records 读取带出原值回写)。
3. **E6 阴性存疑(G-F)**:search_files/grep 0 命中时结果强制附「阴性存疑,建议复核路径/glob」标记(seq 1549 hint 雏形转正)。
4. **E4 跑题换装验证**:三源活跃度排序已在树(第五十三段),换装后重放 6a5a3e04 seq1802 场景验证。
### M2 · P2 上下文域
5. **E5 存量目标一次性迁移(G-C)**:active 目标命中「执行/搜索/查看+工具参数」形态或 `[系统恢复]`/`[补充指令]` 前缀 → 降级为非目标或重取首条任务消息。
6. **E2 压缩降级透出**:压缩失败兜底时前端状态条「上下文已降级」,禁静默。
7. **E13 超时连败收敛**:同命令超时连败 N 次转「换方案/问用户」分支(与看板 B5d 退避策略同族,合并实施)。
8. **P2-5 终局闭环(G-B)**:停靠/终局时填充 boundary_json/loop_state,不只落 status。
### M3 · P2/P3 信息组织域
9. **F4-1 会话可达性(G-D)**:列表 offset 游标分页+侧栏「加载更多」;搜索走后端 LIKE(title+消息内容)。
10. **F5-1+F5-2 飞书审批闭环(同根一改双收)**:决议词表归一(trim/剥标点/同义词,复用 normalize_halfwidth)+ 订阅 AiToolCallCompleted 推终态卡;pending 为空时决议回「已失效」提示而非静默放行。
11. **F1-3+F3-2 折叠体可达(G-D,同根)**:executing 判定改「桶内存在 running/pending 工具」;scrollToFirstPending 前按 data-trace-key 预展开含 pending 的轨迹卡。
### M4 · 输出纪律+UX 小批
12. **G-E 提示词输出纪律**:system_prompt_parts 行为准则追加「回复风格」段——结论先行直接给动作/结果;省略复述用户问题、客套开场、总结性收尾;要点优先于段落;用户没问的不展开。中英双语同步+测试断言(对齐 prompt.rs 既有测试范式)。注意别过度约束:需要解释时照常解释,反的是空话不是内容。
13. **UX 确定性小批**:F1-1 i18n 补键 / F1-2+F3-1 主题 token 化 / F2-1 aria-label / F4-2 loaded 标志 / F6-2+F6-3 Esc 关闭 / 看板尾部队列带图项标记(10-18 残留)。
## 三、验收口径
- **复测脚本复跑**(复用 `temp/复测-aichat质量-1017/` 脚本,timestamp 列口径):恢复消息套娃=0、created_at 污染增量=0、boundary_json 填充率>0(新停靠会话 100%)。
- **可达性**:115 条活跃会话全量可从侧栏到达;审批卡在任何折叠形态下 scrollToFirstPending 均可见。
- **输出纪律**:3 个真实会话抽查,开场复述/空收尾/无信息客套=0;结论仍在首屏。
- 每批销账附验收命令输出(G4 惯例)。
@@ -0,0 +1,53 @@
# 上下文管理对标 u-claw 与大批量并行差距分析(2026-10-19)
> 用户令(2026-10-18 飞书):①大批量 AI 任务调度/多会话多任务并行是未来常态核心场景;②战略目标=DevFlow 部署 Linux 替代 u-claw+Claude Code(CC 效率不够高、资源消耗太大);③第一门槛=上下文管理三命题:丢记忆、历史信息召回、高效利用 token;④方法=学习 u-claw 历史提交记录补 devflow 场景不足。
## 一、u-claw 上下文管理能力画像(源自主仓 6 条相关提交,核心是 86e9216)
| # | 能力 | u-claw 实现 | 要点 |
|---|---|---|---|
| 1 | **token 水位判据** | 140K≈200K 窗口 70% 主判据(可配),体积判据降兜底 | 治「换代过早」:350KB 体积≈真实水位 20-35%,体积判据致 23 代/日→水位判据 <5/日 |
| 2 | **usage 真值采集** | 读流解析 message.usage(input+cache_read+cache_creation),失败静默 0 | 水位判据的数据底座 |
| 3 | **大内容外置纪律** | system prompt 处理规则:大结论写盘 out/,回复只给路径+3 行结论 | 治上下文滚雪球(attach/ 86 文件 25MB 实锤) |
| 4 | **换代通知推送** | rollover 等会话层事件经渠道真推送(非仅日志) | 用户对会话滚动有感知 |
| 5 | **/history 归档检索** | 无参列最近 10 代,关键词 grep summary | 跨代记忆召回入口 |
| 6 | **归档检索指引** | sopHint 注明 jsonl 字段与 grep→读 summary 操作路径 | 让 AI 自己会查历史 |
另:0a4f8dd 重上下文估算(防图片堆积)、a3f28dc 群聊上下文增量注入(按决策价值治理进度卡)。
## 二、devflow 现状对照(三命题 × 已治/缺口)
### 命题 1:丢记忆
- ✅ 已治:目标语义重构(用户任务原文入 pinned_goals)、恢复链快照([系统恢复]/[补充指令] 产物双侧过滤,10-18 批)、压缩摘要四段式(意图/决策/文件/约束+失败事实保留)、E2 降级透出(10-18)
- ❌ 缺口:**boundary/loop_state 填充率 0**(终局态建模半闭环)——「代」的显式语义缺失,会话边界靠消息流推断
### 命题 2:历史信息召回
- ✅ 已治:单会话内压缩可召回(archive jsonl+召回句柄行)、知识路标投影进系统提示词
- ❌ 缺口:**跨会话检索无入口**(u-claw /history 等价物缺失)——「上次那个任务怎么弄的」只能靠人翻侧栏;消息全文搜索未做(F4-1 只做了标题+列表分页)
### 命题 3:高效利用 token
- ✅ 已治:压缩 memoization+决策冻结(10-04 病②治愈)、尾部追加摘要保前缀缓存、E13 超时连败收敛
- ❌ 缺口:
- **usage 真值采集未落地**(6 月 P1 敲定至今暂缓;cache_hit≈0% 说明 cache 口径有列无真值)——水位判据缺数据底座
- **压缩触发判据待查**:压缩/请求比 4.8 偏频,疑体积/轮次判据过敏感(对标 u-claw 病灶同型)
- **大内容外置纪律缺失**:工具输出有 truncate,但「大结论写盘+回复给路径」没有
### 大批量并行新命题(u-claw 亦未解,devflow 需原创)
- 多会话同时压缩互不感知(各自预算独立,全局 token 消耗无视图)
- 并行任务的信息投影(侧栏/仪表盘聚合 N 个会话的进行态)
- 调度面:优先级/抢占/停靠恢复已有底座(TaskBoundary/parked),缺**全局调度器**视角
## 三、补差优先级(P0→P2)
| 级 | 项 | 移植来源 | 落点 |
|---|---|---|---|
| **P0** | usage 真值采集(stream_options.include_usage 三口径) | u-claw #2 | 6 月 P1 设计直接复活;先有数据再谈判据 |
| **P0** | 压缩触发改 token 水位主判据(≈窗口 70%,体积兜底) | u-claw #1 | maybe_auto_compress 判据重构,依赖上项 |
| **P1** | 跨会话归档检索:/history 等价(关键词搜消息全文+摘要) | u-claw #5/#6 | ai_messages 全文索引(FTS5 或 LIKE 起步);检索指令+AI 检索指引 |
| **P1** | boundary/loop_state 写入闭环(代语义显式化) | — | 停靠/终局时填充;换代通知推送(u-claw #4 同款) |
| **P2** | 大内容外置纪律(system prompt 处理规则+out/ 目录约定) | u-claw #3 | prompt.rs 处理规则段+workspace 目录约定 |
| **P2** | 并行资源治理:全局 token 预算视图+多会话压缩协防 | 原创 | 调度器域,与 Linux 化路线(看板 10-18 远程容器方向)合流设计 |
## 四、与 Linux 化战略的衔接
上下文管理是第一门槛(用户拍板),Linux 化是最终形态。依赖链:**usage 真值 → 水位判据 → 代语义+归档检索 → 并行调度治理 → server 化无头运行**。前三步在现有 Tauri 架构内即可推进(P0 两项纯 Rust 域),后两步与远程容器/df-tunnel 线合流。建议节奏:P0 两项作下一批(devflow agent 领单),P1 随后,P2 与 Linux 化立项合并设计。
@@ -0,0 +1,238 @@
# 压缩裁剪缓存友好化 — 设计
> 2026-10-11 · 只读勘察产物
> 主题:上下文压缩与裁剪以破坏 provider 前缀缓存为代价 → 在保住预算安全的前提下最大化缓存命中
> 行号快照:2026-10-11 工作区(context/mod.rs 与 context_helpers.rs 有并行代理在改,实施前须对齐最新行号)
---
## 〇、文档定位与既有工作衔接
本文档是「上下文分层组装器-落地方案-2026-10-10.md」病灶④(裁剪后前缀缓存整体失配)的**机制细化**,并新增覆盖压缩链路的一次压尽问题。该方案已给出验收口径:「稳态下请求前缀只追加、不位移;裁剪以批量滞后发生」——本文档给出达成该口径的具体算法、参数推理与断点布点。两文档不冲突:分层组装器治①(真值 token)②(O(n²))③(预算随窗口)⑤(召回),本文档专治④+压缩,可与 P2.1/P2.2 并行实施(见§八协调注)。
另:看板 AC-EFF-C1-2「每轮全量重发 O(n²) 部分缓解(cache_control+稳定前缀后第2轮起命中,增量滑窗留待后续批)」与「警示消息打断前缀缓存」两登记项,均由本文档方案覆盖。
---
## 一、两个缺陷的准确定性与证据
### 缺陷1:滑动裁剪致前缀缓存整体失配
`build_for_request`(`crates/df-ai/src/context/mod.rs:185`)超预算时走贪心最小裁剪:`for unit in &units { if history_tokens - removed <= available { break } removed += unit.token_sum }`(`mod.rs:211-219`)——**每次只裁到刚好塞下为止**。对话每增长一条消息就重新越过 `available` 一点,裁剪边界 `trim_end` 随之前移一个单元,发送视图的**第一条消息每轮都变**。前缀缓存(anthropic 显式 cache_control 与 openai_compat 自动前缀缓存同理)要求从第 0 字节起逐字节相同,首条即变 → 整段历史永远 miss。
已缓和但不治本的既有修正:
- 淘汰补偿摘要从头部 insert(0) 改尾部 push(user 角色,`mod.rs:250-261`),注释明言治「命中率掉到个位数」;
- `push_system_anchor` 尾部追加不位移前缀(`agentic/helpers.rs:28-44`、`context_lifecycle.rs:309-313`);
- `tool_view_decisions` 冻结压缩决策防跨轮翻转(`agentic/mod.rs:2004-2005`),`compress_cache` 按内容哈希记忆化摘要结果(`stall_detector.rs:368-379`)→ tool_result 视图摘要跨轮字节稳定,已不构成缓存威胁。
**残余根因**:裁剪边界本身逐轮爬行。注意一个连带效应——淘汰摘要每轮即时重生成,其内容在两次裁剪之间是静态的(被淘汰集不变 → `extract_top_keywords` 排序确定性,`context_helpers.rs:284-288`);「摘要逐轮漂移」正是裁剪逐轮爬行的投影。块状化后漂移自动收敛到裁剪事件点。
### 缺陷2:一次压尽
压缩触发:`available*6/10 < history_tokens`(`agentic/context_lifecycle.rs:129-136`),后台 LLM 摘要成功后 `compress_old_messages(protect_start)` 把 `[0, len-6)` 的**全部 active 消息一次性标 compressed**(`context_lifecycle.rs:308`、兜底路径 `:442`;`compress_old_messages` 实现 `mod.rs:670-699`)。sanitize step0 按 `is_active` 白名单过滤(`sanitize.rs:40-43`;`is_active`= status None/Active,`df-ai-core/provider.rs:91-99`)→ 被压消息从发送视图**整体蒸发**,视图前缀从第 0 条起全变,一次压缩 = 历史段全量缓存重建。信息损失同样一次集中(用户体感「丢历史」)。
---
## 二、现状机制盘点(设计依赖的既有事实)
| 机制 | 位置 | 事实 |
|---|---|---|
| 预算公式 | `context_helpers.rs:154-178` | `budget = (max_tokens − output_reserve) × safety_ratio`;默认 128K/8K/0.85 → **budget ≈ 101,836**;`available = budget − sys_tokens`(sys 5–10K → available ≈ 92–97K) |
| 裁剪触发 | `mod.rs:198,214` | `history_tokens > available`(100% 硬门) |
| 保护区 | `context_helpers.rs:683` | `PROTECT_COUNT = 6`(≈最近 2 轮);**context_lifecycle.rs:53 另有一份本地副本 6,双源待统一** |
| 淘汰单元 | `mod.rs:612-642` | 三元组原子(头+尾+紧随独立 assistant),压缩定位/分段标记/裁剪共用 |
| 压缩触发 | `context_lifecycle.rs:129-136` | `0.6×available` + `has_compressible_messages` + 非压缩中 |
| 压缩边界回退 | `mod.rs:676-683` | `compress_old_messages` 内建单元边界回退(只少压不多压)——**分批压缩可直接复用** |
| 摘要补偿 | `context_helpers.rs:332-364` | 淘汰摘要 ≥100 字符才产,硬顶 1000 字符(`:692`);关键词提炼确定性 |
| system 断点 | `anthropic_compat.rs:127-145` | 开关开 → 顶层 system 包 blocks + cache_control |
| 消息级断点 | `anthropic_compat.rs:243-247,339-376` | 打在**末条 user 之前相邻一条**(末条 user 是漂移边界:易变尾段/本轮新问题,打它永远不命中);注释明言预算 4 个 cache_control,现用 2 个 |
| 缓存观测 | `anthropic_compat.rs:723-731` | usage 已映射 `cache_read/cache_creation`——**命中率观测零成本可取** |
| 开关范式 | `prompt.rs:657-695` | settings KV `df-ai-anthropic-cache`,`parse_cache_flag`:空/true/1=开,false/0/杂值=关;进程级 AtomicBool 同步;`cache_split_provider_type` 按 provider gate;关=逐字节旧行为 |
| 易变尾段 | `prompt.rs:709-736`、`agentic/mod.rs:1983-1990`、`stream_relay.rs:791-795` | 日期/清单/augmentation/知识在**每次 send 时构建一次**(`commands/chat.rs:417,996,2209,2476`),loop 内多轮稳定,以 user 角色追加消息流末尾 |
| system 折叠 | `anthropic_compat.rs:128-133,153` | **关键发现:存储态所有 role=System 消息(不论位置)被抽离折叠进顶层 system(sys.join)**。压缩摘要/关键词兜底/话题标记/催办全走 `push_system_anchor(ChatMessage::system(...))`(`context_lifecycle.rs:313,449`、`agentic/mod.rs:1869,2510,2557`)→ 每次此类事件都改写顶层 system → **BP1 连带全失配**。淘汰摘要已因同因改 user 角色(`mod.rs:254-256` 注释),压缩摘要等锚点未跟进 |
---
## 三、设计总览
三机制分层,各管一段:
```
历史水位轴(×available)
0 0.55 0.75 1.0(=available)
| | | |
|<-- 压缩目标水位 C_low | |
| |<-- 批次≈0.2A |
| | ↑ 压缩触发 C_high(深清,后台,保语义)
| | ↑ 裁剪触发 1.0(应急,view-only)
| | |←→| 裁剪块≈0.2A → 裁到 T=0.8
```
- **裁剪=应急**(build_for_request,view-only):只在压缩失灵(后台失败连续多轮/未及生效)时兜底,块状跳变;
- **压缩=深清**(后台 LLM,改存储 status):常态水位管理,分批渐进;
- 稳态下裁剪永不触发(0.75+批内增长 < 1.0),历史段前缀在两次压缩事件之间逐字节稳定。
---
## 四、方案一:块状裁剪
### 4.1 算法
替换 `mod.rs:211-219` 贪心循环,改「目标水位 + 轮边界吸附」:
1. 触发条件不变:`history_tokens > available`(硬安全门,`mod.rs:214` 同式)。
2. 目标水位 `T × available`(T 默认 0.8):累计淘汰单元 token,直到 `history_tokens − removed ≤ T×available` **且**当前边界吸附到轮边界。
3. **轮边界锚点**:单元边界 `unit.end` 处,若下一时刻消息(未淘汰首条)role=User,则该 end 是轮起点——整问整答同生同死,淘汰摘要按轮覆盖语义完整。
4. 若扫到保护区(`protect_start`)仍无同时满足两条件的边界(病态:单轮 token 均值 > 块大小),回退现行为:取满足硬预算 `available` 的最后单元边界(任何边界,保证不破安全底线)。
两次裁剪之间:`trim_end` 不动 → 被淘汰集不变 → 淘汰摘要内容不变(`extract_top_keywords` 确定性)→ 视图前缀逐字节稳定 → BP2 每轮命中、openai_compat 自动前缀缓存每轮命中。
### 4.2 块大小取值推理
块大小不设独立常量,由水位差导出:`块 = (1−T) × (budget − sys_tokens)`,随模型窗口自适应缩放(与 output_reserve/safety_ratio 的关系:二者先折算进 budget,块是其再切片,无双重调参)。
默认参数下 `1−T=0.2` → 块 ≈ 18–19K tokens。设单轮增长均值 Δ(本应用工具密集,2–10K 常态):
| T | 块 | 稳定窗口 ⌊块/Δ⌋ | 命中率≈窗口/(窗口+1) | 单次裁剪信息损失 |
|---|---|---|---|---|
| 0.9 | ≈9K | 1–4 轮 | 50–80% | 最小 |
| **0.8** | ≈19K | **2–9 轮** | **67–90%** | 中 |
| 0.7 | ≈29K | 3–14 轮 | 75–93% | 大 |
T=0.8 是拐点:再往上窗口塌缩(裁剪频度回升、每次裁剪的「全量重建税」频度变高),再往下单次丢历史变狠。对照今天:超预算后命中率 ≈0%(首条每轮变)。**估算误差 ±15%**(`context_helpers.rs:26` 自述)被 20% 水位差吸收,不会刚裁完就再触。
### 4.3 块边界锚点选型对比
| 候选 | 评价 |
|---|---|
| **轮边界(推荐)** | 语义完整(问+全工具链+答同生死);淘汰摘要按轮覆盖;边界是稳定地标,只在裁剪事件移动;三元组原子性由 `build_eviction_units` 已保证,轮吸附只是再加一层 |
| 消息数 | 块 token 分布不可控(30 条可 5K 也可 80K),裁剪频度与稳定窗口忽大忽小;仅配作病态回退 |
| 纯 token 水位 | 作为**停止条件**必要(§4.1 第 2 步),但单独作边界会劈轮;与轮吸附组合使用 |
---
## 五、方案二:分批压缩
### 5.1 双水位滞后 + 批次
- **触发 C_high = 0.75×available**(现 0.6):提高压缩间隔——压缩事件=全量缓存重建税,频度直接决定命中率;0.75 仍留 0.25A 给后台 LLM 压缩完成并下轮生效的提前量(后台化设计 `context_lifecycle.rs:30-31` 的既有前提)。
- **批次目标 C_low = 0.55×available**:每批压最旧一段,压到 `history − C_low×A` 的 token 量为止,即**每批 ≈ 0.2A ≈ 19K tokens**(与裁剪块同刻度,共用推理)。
- 批边界计算:从最旧起累计淘汰单元至批次 token 量,**轮边界吸附**(同§4.1 第 3 步);`batch_end ≤ len − PROTECT_COUNT`(保护区语义不变)。传入 `compress_old_messages(batch_end)`——其内部单元回退(`mod.rs:676-683`)与占位未闭合跳过(`compress.rs:65-66`)零改动复用。
- **滞后再武装**:压后 history=C_low < C_high,需再增长 0.2A 才触发下一批 → 天然防连发;`is_compressing` 重入标志、`head_id` 版本哨兵(`context_lifecycle.rs:70-77`)、L0-3 后台化全部不动。
- 关键词兜底路径(`context_lifecycle.rs:437-449`)同步改传 batch_end:失败降级的损失也从「一次压尽」收敛为「压一批」。
### 5.2 缓存语义:为什么分批仍然值得
压缩从视图头部移除消息,新前缀=[摘要锚点]+[剩余],**任何一次压缩都是历史段全量重建,分批不减小单次破坏面**。分批的收益在别处:
1. 单次信息损失有界(0.2A≈19K),摘要质量随输入变小而升,用户「丢历史」体感摊薄;
2. 摘要锚点改为**追加链**(见§六 BP3):第 k+1 次压缩把新摘要接在摘要链尾,链之前的 [system+链头] 前缀仍命中——一次压尽则每次都从零重建;
3. 两次压缩间隔拉长(0.75 触发+0.2A 滞后 vs 现 0.6 触发即压尽再爬坡),事件频度下降。
### 5.3 与块状裁剪的先后序(分工)
```
常态: 0.55A ←压缩批次— 0.75A 触发 … 增长 … 1.0A 裁剪(不该到)
失灵: 压缩连续失败(关键词兜底也失败/后台挂)→ 历史爬到 1.0A
→ 块状裁剪应急跳到 0.8A(丢真历史,无摘要)→ 给压缩再争取一批窗口
```
裁剪是唯一硬安全门,保持 100% 触发不动;压缩是语义保全的常态路径。二者水位关系(T=0.8 > C_high=0.75)保证:只要压缩机制活着,裁剪永不出场。
---
## 六、方案三:缓存断点布点
anthropic 预算 4 个 cache_control(`anthropic_compat.rs:349`),现用 2,建议用到 3:
| 断点 | 位置 | 什么变化会打断它 |
|---|---|---|
| **BP1 顶层 system**(现有) | system blocks 尾块(`anthropic_compat.rs:137-141`) | ① custom_prompt 变更(稳定段含自定义段,`prompt.rs:727`);② **任何 System 角色锚点事件**(压缩摘要/关键词兜底/话题标记/催办——折叠进顶层 system,见§二末行);③ 提示词文本随代码版本变更 |
| **BP2 消息级**(现有) | 末条 user 前相邻一条(`:350-357`);有淘汰摘要时落摘要上(摘要两裁剪间静态,反而更稳) | ① 裁剪边界移动(块状化前每轮/块状化后仅裁剪事件);② 压缩事件;③ namespace LRU 淘汰的 notice 替换改写历史中段(`agentic/mod.rs:1965-1982`,低频);④ 新近度豁免滚动——末 2 条 tool_result 豁免过期转压缩时一次性改写(`stall_detector.rs:321-330`,每次新结果进来推移一次);⑤ sanitize 自愈(确定性,输入不变输出不变,不构成威胁) |
| **BP3 摘要链尾**(新增,可选) | 摘要锚点链之后第一条消息 | 仅压缩事件失效;为 [system+摘要链] 小前缀保命中,配合§5.2 追加链语义 |
易变尾段(日期/清单/augmentation/知识)在 BP2 之后,不打断任何断点(既有设计正确);其每次 send 重建的变化被 BP2 挡住,只自伤尾段自身(本来也不可缓存)。
### 6.1 必修项:system 锚点的顶层折叠(打断 BP1 的根)
**发现**:`convert_request` 把存储态全部 role=System 消息抽离折叠进顶层 system(`anthropic_compat.rs:128-133`),消息流中 `System => continue` 剔除(`:153`)。因此每次压缩摘要/话题标记/催办事件:顶层 system 内容变(BP1 失配)+ 消息数组少一条(BP2 位置迁移)→ 双断点同时重置。
**修法(view 层角色转换,存储不动)**:`build_for_request` 出口(sanitize 之后)把**下标>0 的 role=System 消息**映射为 role=User 发送。收益:
- BP1 内容回到纯 system_prompt 稳定段,锚点事件不再打断顶层缓存;
- 存储态仍是 System:`has_compressible_messages` 的 System 排除(`mod.rs:656`)、前端展示、压缩摘要定位语义全部不变——纯视图变更,与淘汰摘要先例(`mod.rs:254-256` role 必须 user)同构收口;
- openai_compat 侧同视图生效(system 留中原位,user 也合法,中性)。
细节:转换须在 sanitize step 之后(user 是最普通角色,不触发三元组/首条规则);首条 system_prompt(`agentic/mod.rs:1983`,组装层加入,不在 storage)不转换。
---
## 七、风险与回退
### 7.1 行为变更逐条
| # | 变更 | 风险 | 缓解 |
|---|---|---|---|
| 1 | 块状裁剪:单次多丢(裁到 T 水位而非刚好塞下) | 裁剪时刻上下文损失变大(有界:≤0.2A) | 换取两裁剪间全量命中;T 可调(拍板点1);病态回退保硬预算 |
| 2 | 分批压缩:C_high 0.6→0.75、批≈0.2A | 后台压缩生效前留给裁剪的余量 0.4A→0.25A;压缩事件频度上升(批小了) | is_compressing/版本哨兵/单元回退全复用;批失败下轮重试(滞后再武装);拍板点2 可保守维持 0.6 |
| 3 | system 锚点 view 层转 user | LLM 把摘要当作用户话语而非系统指令的语义差;多 provider 兼容面 | 纯 view-only 可瞬时回退;淘汰摘要已用同形态跑了数月无异常(`mod.rs:259-261`);拍板点3 |
| 4 | BP3 新增 | 网关对 cache_control 数量/位置的兼容 | 默认关,随开关;预算 4 内 |
### 7.2 开关设计(照 `df-ai-anthropic-cache` 范式)
- 新 settings KV **`df-ai-cache-friendly-context`**,`parse_cache_flag` 同语义(空/true/1=开;false/0/杂值=关),前端设置页一枚开关;
- 总开关 gate 三机制:块状裁剪 + 分批压缩 + system 锚点 view 转换;BP3 挂在同一开关(anthropic 缓存总开关 `df-ai-anthropic-cache` 仍独立管 cache_control 形态,两层正交);
- 开关关 = 三处全部旁路,行为与今日逐字节等价(与 `cache_split_provider_type` provider gate 叠加,非缓存受益 provider 不受 view 转换影响——不,view 转换与 provider 无关,应 gate 在新开关下全员生效或全员旁路,不由 provider 类型分叉);
- 裁剪/转换是 view-only(逐请求),回滚瞬时;压缩是存储 status 单向(已 compressed 不回滚,与今天回滚语义一致,可接受并在开关文档注明)。
### 7.3 回退路径
开关关 → 全量回旧行为。已落库的 compressed 状态保留(软删可追溯,历史数据默认保留);水位常量均在常量层(T/C_high/C_low),极端情况改常量即再调参,无需动逻辑。
---
## 八、分期
### 快赢(下一小批可入,改动小、收益确定)
| 项 | 内容 | 落点 | 量级 |
|---|---|---|---|
| W1 | **块状裁剪**(T 水位+轮吸附+病态回退) | `context/mod.rs:211-219` 替换 + 单测(水位/轮边界/病态三case) | S |
| W2 | **system 锚点 view 层转 user** | `build_for_request` 出口 + 单测(首条不转/转换后 sanitize 不破) | S |
| W3 | **命中率观测日志**:`cache_read/cache_creation` 已在 usage(`anthropic_compat.rs:728-730`),每轮 info 输出命中率 | stream 收尾处 | XS |
| W4 | BP3 摘要链尾断点(随 anthropic 开关) | `apply_message_cache_breakpoint` 加一档 | XS |
W3 最先:先有数据,后面所有调参有据。**协调注**:W1/W2 落在 context/mod.rs(并行代理在改),实施前 rebase;若分层组装器 P2.2(组装器结构化)先行合入,块状裁剪改为在组装器出口实施,算法不变。
### 结构性(需更多验证)
| 项 | 内容 | 依赖 |
|---|---|---|
| S1 | **分批压缩**(C_high/C_low 双水位+批边界+兜底同批)+ PROTECT_COUNT 双源统一(`context_helpers.rs:683` vs `context_lifecycle.rs:53`) | W3 数据验证压缩事件频度;狗粮长会话 |
| S2 | 水位再校准:T/C_high 按 W3 实测命中率-损失曲线微调 | S1 上线后一轮狗粮 |
| S3 | namespace LRU 淘汰 notice 的前缀断治理(notice 文案确定化已天然成立,残余为位置断,收益小后置) | 低优先 |
---
## 九、拍板点(4 项)
1. **裁剪目标水位 T**:推荐 **0.8**(块≈19K、稳定窗口 2–9 轮、命中 67–90%);备选 0.85(保守,少丢但裁剪频)——§4.2 表。
2. **压缩双水位**:推荐 **C_high=0.75 / C_low=0.55**(批≈0.2A,滞后 0.2A);备选维持现状 0.6 单水位(仅做分批不做提频调整)——§5.1。
3. **system 锚点 view 层转 user**:推荐**做**(治 BP1 折叠根因);备选不做(则压缩/话题标记事件继续双断点全失配,BP1 收益打折)——§6.1。
4. **总开关默认值**:推荐**默认开**(对齐 `df-ai-anthropic-cache` 默认开先例,看板 939⑧;view-only 为主可瞬时回退);备选默认关观察一轮——§7.2。
---
## 附:证据坐标索引
| 论断 | 坐标 |
|---|---|
| 贪心最小裁剪逐轮爬行 | `crates/df-ai/src/context/mod.rs:211-219`(触发 `:198,214`,切片 `:239-246`) |
| 淘汰摘要尾追+缓存注释 | `mod.rs:250-261`;确定性提炼 `context_helpers.rs:284-288,332-364` |
| 一次压尽 | `agentic/context_lifecycle.rs:129-136`(0.6 触发)、`:308,442`(compress_old_messages(protect_start))、`mod.rs:670-699` |
| is_active 白名单过滤 | `context/sanitize.rs:40-43`;`df-ai-core/provider.rs:91-99` |
| system 全量折叠(打断 BP1) | `anthropic_compat.rs:128-133,153`;锚点 system 入库 `context_lifecycle.rs:313,449`、`agentic/mod.rs:1869,2510,2557` |
| 消息级断点(末条 user 前) | `anthropic_compat.rs:243-247,339-376`;4 个预算 `:349`;usage 映射 `:723-731` |
| 开关范式 | `commands/ai/prompt.rs:657-695`;易变尾段拆分 `:709-736`、`stream_relay.rs:791-795`、每 send 构建一次 `commands/chat.rs:417,996,2209,2476` |
| tool_result 摘要已稳定(不威胁) | 冻结 `agentic/mod.rs:2004-2005`、`stall_detector.rs:352-363`;记忆化 `:368-379`;新近度豁免滚动 `:321-330` |
| 预算公式与保护区 | `context_helpers.rs:154-178,683,692`;双源 PROTECT_COUNT `context_lifecycle.rs:53` |
| 请求组装序 | `agentic/mod.rs:1143`(sys_tokens)、`:1957`(build_for_request)、`:1965-1982`(namespace 展开)、`:1983-1990`(system+history+尾段) |
| 上位目标与验收口径 | `docs/02-架构设计/专项设计/上下文分层组装器-落地方案-2026-10-10.md`(病灶④/「裁剪以批量滞后发生」) |
@@ -0,0 +1,132 @@
# DevFlow 布局样式大重构设计(2026-10-18)
> 依据:用户令「布局样式大重构,参考 D:\Downloads\u-agent」+「每页样式 DRY 提取统一定义」
> 输入:u-agent设计系统勘察-2026-10-18.md · 样式走查-1018/报告-布局全局审计.md · 报告-配色全局审计.md
> 原则:DRY 单一真值 · token 化 · 高密度(u-agent 13px 范式)· 双主题保留 · 紫 accent 品牌保留 · 视觉回归可控(dev 模式实时预览)
## 0. 现状诊断(三份勘察合并)
| 维度 | 现状 | u-agent 基准 | 差距 |
|---|---|---|---|
| 基准字号 | html 14px,aichat 已降 9-13,views 仍 11-14 | 13px 全局 | 功能页粗一档,密度断层 |
| 页头 | 三套并存(aichat 46px / Dashboard 18px 标题 / 功能页 24px h1 无固定高) | 46px 定高单行 | 全站统一为紧凑单行 |
| 侧栏 | 192px | 250-280px | 偏窄,功能页导航拥挤 |
| 按钮 | .btn 双基类分叉(global 34px vs settings 30px)+ 图标钮三档 | .cbtn 单基类 | 合一 30px |
| 徽章/tag | 圆角 3/4/6/8/10 五档并存;.df-tag 3px 字面量 | 小标签小圆角一档 | 收敛一档入 token |
| 圆角 token | 覆盖 75.5%,9 处 var 回退值写错(静默降档) | — | 修回退+字面量 97 处归 token |
| 间距 | 76.7% 收敛,7px×8/82px 手算对齐等离群 | 4px 刻度 | 离群归位+魔法数 token 化 |
| 字号 | 半像素 24 处(10.5/11.5/12.5)+17px 孤值 | 纯整数谱 | 归整 |
| 输入框 | 三档并存(21-22 / 26-27 / 30) | 30px 单档为主 | 行内搜索统一 26px 档 |
| 小节头 | 各页自造 | 9-10px uppercase mono 字距 统一范式 | 抽 .df-sec-h |
| 公共技法 | 无 | hairline 网格 kv 卡 / 发丝线行 / 染色两档 | 引入公共类 |
## 1. Token 层扩展(global.css :root,单一真值)
### 1.1 新增 token
```css
/* 字号谱(纯整数,对齐 u-agent) */
--df-fs-micro: 9px; /* 小节头/tag/计数 */
--df-fs-meta: 10px; /* 徽章/元数据 */
--df-fs-aux: 11px; /* 辅助文字/列表次级 */
--df-fs-ctrl: 12px; /* 控件文字/小按钮 */
--df-fs-body: 13px; /* 正文基准 */
--df-fs-title: 16px; /* 页头标题 */
/* 控件高度档 */
--df-h-btn: 30px; /* 主按钮(.btn 合一目标) */
--df-h-input: 26px; /* 行内输入/搜索 */
--df-h-modal-input: 30px;
--df-h-icon-btn: 26px; /* 图标钮统一档(22/26/28 收敛) */
/* 微内距暗刻度(chip/badge 族) */
--df-pad-chip: 1px 6px;
--df-pad-badge: 3px 8px;
/* 图标槽 */
--df-icon-gutter: 42px; /* ToolCard 图标槽(38/42 魔法数归一) */
/* 染色两档(语义色背景/边框透明度统一,双主题各配) */
/* 由 --df-*-bg(12% 底)+ 新增 --df-*-bd(30% 边)承担,消除散落 rgba 字面量 */
--df-success-bd / --df-warning-bd / --df-danger-bd / --df-info-bd / --df-accent-bd
```
### 1.2 修正既有错误
- 9 处 var 回退值写错(lg 8→10 / md 6→8 / xs 3→2):**删回退值**(token 必注入,回退是静默降档源)
- .df-tag 圆角 3px 字面量 → var(--df-radius-xs)
- body 基准 14px → 13px(line-height 1.5 保持);aichat 已对齐,功能页随页头波次跟进
## 2. 公共组件层(global.css/components.css 单一来源)
| 公共类 | 来源 | 定义去向 |
|---|---|---|
| `.btn` 族 | global+settings 双基类合一 30px/8px 16px/13px | global.css 唯一定义,settings.css 删副本 |
| `.df-sec-h` | u-agent 小节头范式(10px uppercase letter-spacing .5px mono tx3) | global.css 新增 |
| `.df-kv` / `.df-kv-i/k/v` | hairline 网格档案卡(gap:1px+bd 背景) | components.css 新增 |
| `.df-row` | 发丝线行(border-bottom+last-child none) | components.css 新增 |
| `.df-note` | 通知条(info/warning/danger 染色) | components.css 新增 |
| `.df-pill` | 状态胶囊(全圆+染色底) | components.css 新增 |
| `.df-tab` / `.df-tab--active` | 下划线 tab(2px accent 底线+计数 chip) | components.css 新增 |
| 既有 `.df-dot/.df-badge/.df-tag/.df-mono/.df-hstat` | 第五十四段已注入 | 保留,随 1.2 修圆角 |
## 3. 页面适配波(DRY 提取:各页 scoped 重复样式→公共类)
- **页头统一**:功能页 8 页 + Dashboard 全部改 `.page-header` 紧凑单行(h1 16px/500 + 定高 46px 等价 padding + header-actions gap 8px),删各页自造页头样式
- **侧栏 192→220px**(AppSidebar 一处)
- **输入框**:GitChanges/Provider 行内 21-22px → var(--df-h-input)
- **列表/详情页**:status-tag/status-* 徽章族(components.css 已有 13 个状态色)染边补齐 + 圆角统一;Details 页 kv 区改 .df-kv;tab 行改 .df-tab
- **settings 族**:settings.css .btn 副本删除并轨;面板内重复 card/row 样式提取
- **ai 族**(已对齐 workbench):仅扫尾——圆角字面量 9px 气泡入注释锚定、7px 代码块归 token、tag 圆角收敛
- **离群修复**:7px 间距→6/8、ProviderPanel 82px 手算对齐改结构、26px 图标位 calc、FilePreview outline-name 补 nowrap(确定 bug)、定宽输入/浮层补 max-width
## 4. 实施波次(文件零交集,逐波门禁 vue-tsc + 目检)
| 波 | 内容 | 文件面 |
|---|---|---|
| R0 底座 | token 扩展+回退修正+公共类新增+.btn 合一+body 13px | styles/global.css + components.css + settings/settings.css |
| R1 功能页族 | Projects/Tasks/Ideas/Knowledge/AuditLog 页头+密度+tag 收敛 | views/*.vue(5 页可再拆并行) |
| R2 详情页族 | ProjectDetail/TaskDetail/IdeaDetail(kv/tab/徽章/页头) | 3 个 Detail.vue 可并行 |
| R3 设置族 | settings.css 并轨+面板 DRY 提取 | components/settings/* |
| R4 ai 扫尾+杂项 | 圆角/token 扫尾+离群修复+FilePreview bug | components/ai/* + 杂项 |
| R5 配色收口 | 按配色审计报告优先级表 10 项实施(报告 temp/样式走查-1018/报告-配色全局审计.md):①幽灵 token 清零(含 --df-bg-secondary 等 6 处声明失效真 bug)②.status-* 徽章系 token 三件套③白叠层 64 处入 token(--df-hover/--df-zebra 浅色覆写黑叠层)④回退值 111 处全删⑤global.css 边框 rgba 改 color-mix⑥--df-neutral-bg⑦views 族 rgba 清零⑧--df-shadow-sm/md⑨alpha 收敛⑩豁免注释 | 按报告分布;R5 先补 token(叠层/中性/阴影)再批量替换 |
每波:前台同步代理实施 → 主代理 diff 核查 + vue-tsc → dev 窗口目检(用户可实时看)。R0 先行(其他波依赖新 token/类),R1-R4 在 R0 后并行。
## 5. 拍板点(按推荐值先行,用户可回改)
1. **基准字号 13px**(功能页降一档,信息密度对齐 u-agent;若觉偏小回 14 仅改一处 token 层 body 声明)
2. **侧栏 220px**(192→220 折中,非 250)
3. **页头 16px 标题+紧凑单行**(废 24px 大标题)
4. **按钮 30px 主档**(对齐 settings 既有 30px 而非 global 34px)
5. tag/badge 圆角统一 xs(2px)/sm(4px) 档
## 6. 单文件拆分轨([补4],与样式波错峰)
行数实测(2026-10-18):MessageList 3297 / ProjectDetail 1750 / TopBar 1700 / Tasks 1540 / ChatInput 1503 / IdeaDetail 1228 / ConversationSidebar 1226 / TaskDetail 1160 / AiChat 1145 / FilePreview 1144 / ProviderPanel 964 / FileExplorer 932 / ToolResultBody 895。
**拆分纪律**:
- 纯拆分不改行为——样式/逻辑原样搬迁,验收=vue-tsc 0 + 构建过 + dev 窗口目检无视觉回归
- 拆分边界按「区块自治」(模板区块+配套样式+相关逻辑成组),优先抽展示型子组件;共享状态经 props/emits 或既有 composable,不新造 store
- **与样式波互斥调度**:同一文件的样式适配与拆分不并行(样式先行,拆分在后——拆完再改样式要重新定位锚点)
- 拆分批次(样式波收口后启动,每批文件零交集):
- C1 ai 族:MessageList(3297→消息项/工具卡区/折叠条带等子件) → ChatInput(1503) → TopBar(1700)
- C2 项目族:ProjectDetail(1750) → FilePreview(1144) → FileExplorer(932)
- C3 任务/想法族:Tasks(1540) → TaskDetail(1160) → IdeaDetail(1228)
- C4 侧栏+设置族:ConversationSidebar(1226) → AiChat(1145) → ProviderPanel(964) → ToolResultBody(895)
- 每文件拆分目标:主文件 ≤600 行,子件单一职责
## 7. 整洁性轨(用户令 2026-10-18:代码整洁性全量处理+样式代码统一抽取,[补1] 共享的样式才抽取)
探针实测(dead-style-scan.json / dup-style-scan.json,脚本在 temp/样式走查-1018/):
- 死样式候选 368 处 / 56 文件(MessageList 34/ToolCard 25/TopBar 20/ActiveProjectsPanel 19 领衔);**假阳性规则**:Vue transition 生成类(xxx-enter/leave/from/to/active 由 `<transition name="xxx">` 隐式消费,非死)、动态拼接类(`'status-' + s`)、子组件透传类——删前须逐处验证
- 跨文件共享重复规则 49 条;典型双轨:`.ai-toast-*`(AiChat/ApprovalPopup)与 `.toast-*`(Knowledge/Settings)同义共存;`.confirm-actions`×3;`@keyframes spin/shimmer` 10/3 文件各写一份
**底座已落**(主代理亲码 global.css):`.df-toast` 四色族+过渡 / `@keyframes df-spin`/`df-shimmer` / `.df-confirm-actions`+过渡 / `.df-diff-pre` 族。
**D 批分路**(R5 收口后启动,文件族零交集,每路两件事:本族死样式验证删除 + 本族共享类消费替换):
- D-A ai 族+根组件(MessageList/ToolCard/TopBar/ConversationSidebar/ChatInput/ToolResultBody/AiChat/ToolCardList):死样式 100+ 处 + ai-toast 族消费 + spin/shimmer keyframes 消费 + .ai-tool-diff-pre 族并轨 .df-diff-pre
- D-B views+project 族(Knowledge/ApprovalPopup/Settings/Projects/ProjectDetail/Tasks/AuditLog/FilePreview/FileExplorer/GitChanges/ProjectCard):死样式 100+ 处 + .toast-* 族消费 + confirm-actions 消费
- D-C settings+layout+dashboard+knowledge 族(AdvancedSection/AllowedDirsPanel/UpdateBadge/ActiveProjectsPanel/KnowledgeDetail 等):死样式残余 + 确认弹层消费
- D4 TS/模板整洁(随各路顺带):未用 import/变量(vue-tsc noUnusedLocals 级)、console.log 残留、死模板分支——纯删不改语义
**验收纪律**:每路 vue-tsc 0 + grep 证据(死样式删除数/共享类消费数);transition 类假阳性必须 grep `<transition name=` 确认后再删。
@@ -0,0 +1,203 @@
# 跨会话召回四层架构 — 设计
> 日期: 2026-10-11 | 状态: 设计稿(待拍板) | 勘察: 只读实测锚点
> 决策背景: 用户拍板决策单 C1 走 B 重量路线(对齐 u-claw 分身养成层四层架构), 推翻原「先 A 后 B」建议(见 aichat上下文管理-派工与决策单-2026-09-29.md C1)。
> 前序文档: 跨会话记忆四层架构-设计-2026-09-29.md(首版四层稿, W1-W4 批次均未实施)与跨会话召回最小闭环-设计-2026-10-04.md(已拍板并落地, 构成本设计的存量底座)。本文档是 09-29 首版按 2026-10-11 现状的重勘与收编, 实施以本文档为准。
> 铁律(继承 u-claw, 09-29 首版已立): **跨会话召回内容严禁进 system prompt 与常驻组装段, 只走工具按需查**。工具描述可进 system(静态文本, 属稳定缓存前缀), 召回内容不进。u-claw 原案教训: 频繁变更的注入毁 provider 前缀缓存。
> 勘察基线: 2026-10-11 工作区。crates/df-ai 的 context/mod.rs 与 context_helpers.rs 有并行代理在改, 引用行号实施前须复核。
---
## 一、四层口径与 u-claw 的对齐
u-claw 分身养成层原案按数据流阶段分层(捕获/精炼/召回/验证, 见 memory project-uclaw-avatar-layers); devflow 侧 09-29 首版起按**记忆的时间半径**分层(轮内/跨轮/跨会话/跨重启), 本设计沿用时间半径口径, 理由是它与 devflow 现有基建一一对应, 逐层可独立验收。两套口径的语义交点在第三层: u-claw 的「召回严禁进 system prompt」铁律原样继承, 并在 devflow 语境下收紧(u-claw 原案保留了低频 CLAUDE.md 摘要注入通道, rollover 级更新保缓存; devflow 侧跨会话召回不做任何形式的自动注入, 连低频摘要注入也不要, 只走工具)。
| u-claw 阶段口径 | devflow 时间半径口径 | 对应关系 |
|---|---|---|
| L1 捕获(hooks 零延迟) | L1 轮内 | 轮内上下文采集与组装 |
| L2 精炼(信号过滤+小模型) | L2 跨轮 | 压缩精炼+WorkingContext 回填 |
| L3 召回(严禁进 system prompt) | L3 跨会话 | 本设计主体 |
| L4 验证(观察项) | L4 跨重启 | 目标快照+恢复语义(验证语义落在恢复续接正确性) |
---
## 二、四层在 devflow 的映射(存储/写入/读取/生命周期/现状)
### 2.1 L1 轮内(现状已建, 本设计不动)
- 存储载体: per_conv 内存(`ContextManager.messages`, `crates/df-ai/src/context/mod.rs`)+ `ai_conversations.messages` 落库。
- 写入时机: 每轮 push, 大工具结果引用化入账(`src-tauri/src/commands/ai/audit/mod.rs:1184-1191`)。
- 读取通道: 每轮组装, 滑窗 `build_for_request`(`context/mod.rs:185`)+ system 段 `build_runtime_prompt`(`agentic/mod.rs:3042`)。
- 生命周期: 会话级, 随会话存亡。
- 现状标注: 分层组装器方案(上下文分层组装器-落地方案-2026-10-10.md)管辖此层与组装结构, 本设计零改动。
### 2.2 L2 跨轮(现状已建, 2026-10-11 新增压缩存档, 本设计不动)
- 存储载体: 内存视图(compressed 状态标记/淘汰摘要)+ 压缩摘要锚点(system anchor 尾部追加, `agentic/context_lifecycle.rs:335`)+ **压缩原文存档**(`<data_dir>/context-archive/<conv_id>-archive.jsonl` 追加写, `crates/df-ai/src/context/archive.rs:35-39`)。
- 写入时机: 压缩水位 `available*6/10 < history_tokens` 触发(`context_lifecycle.rs:133-135`), 后台 LLM 产 nl_summary+json_card; WorkingContext 写入链 `begin_turn`(`commands/chat.rs:950`、`:2444`)与 `sync_after_compress`(`context_lifecycle.rs:337`); 存档追加写(`archive.rs:44`), 开关 `df-ai-compress-recall` 默认开(`commands/ai/prompt.rs:709-718`)。
- 读取通道: 摘要常驻锚点(会话内每轮可见); 原文经摘要尾部召回句柄行 read_file 按需回读(`archive.rs:72-74`)。
- 生命周期: 会话级; 存档文件跨重启仍在, 会话删除级联清理是已记录缺口(`archive.rs:7` 注释自证「留待后续挂点」)。
- 现状标注: 压缩可召回 2026-10-11 刚落地(工作区未提交); 记忆卡四类卡(C-m1, 上下文三层记忆-设计-2026-10-04.md)设计已立未实施, 落地后是 L2 的结构化产物。
### 2.3 L3 跨会话(本设计主体)
**存量底座(A 路线已落地部分, 2026-10-11 实测)**:
- 写入: 自动提炼 `maybe_spawn_extraction`(`commands/ai/knowledge_inject.rs:660`)挂在正常收敛点(`agentic/mod.rs:1636`, save 之后), 水位制防重(`EXTRACT_WATERMARKS` :70, 新增 10 条用户消息起提 :52, 提炼窗口 40 条 :48), 产 candidate 带 source_project 弱推断归属(:1180、:1212)与 source_ref 会话级溯源(:1216-1219); `knowledge_insert` 工具(`commands/ai/tools/knowledge.rs:95`, 固定 candidate, 但 handler 无会话上下文拿不到 conv_id, source_ref 记 "ai-chat" 渠道兜底, :13-16 注释自证)。
- 读取: 注入侧 `auto_inject` top-3 带项目软排序(`knowledge_inject.rs:520-521`, 检索走 `search_keywords_scoped`, `crates/df-storage/src/crud/idea_repo.rs:834`, 同项目第一档/全局第二档/异项目第三档); 工具侧 `knowledge_search`(`tools/knowledge.rs:51`, 仅 published, 分词检索, 单条 content 截 500 字 :41, 空结果带可执行指引 :72-77)。
- 把关: `knowledge_promote` 显式转正工具(`tools/knowledge.rs:165`), 复用达标自动晋升(默认关, `knowledge_inject.rs:549-565`), 生命线留痕(KnowledgeTimeline)。
**缺口(本设计补齐)**: 会话终态提炼(删除/停靠时点无提炼挂点)、工具写入的归属补全(缺会话上下文注入机制)、工具读取的项目维度(knowledge_search 未接 scoped 检索)、用户侧脉络卡通道。详见第三节。
### 2.4 L4 跨重启(持久化面盘点)
**已有**:
- 消息与上下文重建: messages 落库 + `restore_from_messages`(`context/mod.rs:310` 附近)。
- 目标钉扎持久化(G1 已落地): pinned_goals 每轮 save 落库(`commands/ai/conversation.rs:466-468`), 会话载入恢复(`commands/conversation.rs:492-495`)。
- 任务边界: boundary_json(V45, `crates/df-storage/src/migrations.rs:1366`)。
- 断点续跑(V49): loop_state 列(`migrations.rs:1446`), 启动扫描 running 改 parked(`src/state.rs:455-460`), 恢复链 `ai_list_recoverable`/`ai_recover_parked`/`recover_parked_inner`(`commands/conversation.rs:292-337`), 前端 G5 可恢复分组(`src/components/ai/ConversationSidebar.vue:208`), B5 无人值守引擎自动恢复优先于领单(`agentic/unattended.rs:1017-1018`)。
- 收敛快照事件: AiCompleted 透传 pinned_goals_snapshot(`agentic/mod.rs:1663`)。
**缺口(本设计补齐, 详见第四节)**: WorkingContext 不持久化(df-storage 全仓零引用, 重启后 goals/decisions/unresolved 全丢, 压缩重锚定与目标钉扎跨重启断裂); namespace_store 重启清空(`agentic/mod.rs:1975` 注释自证「重启后 namespace 清空」, 懒重建未做); 目标快照槽语义缺失(看板 D4 挂账); 压缩存档文件无删除级联。
---
## 三、L3 主体设计
### 3.1 写入侧: 三通道加一挂点
| 通道 | 时机 | 状态 |
|---|---|---|
| 自动提炼 | 每轮正常收敛后, 水位制(10 条新用户消息起提) | 已落地 |
| 工具沉淀 | 用户说「记住/记一下」时 agent 调 knowledge_insert | 已落地, 归属待补 |
| 显式转正 | 用户认可时 knowledge_promote | 已落地 |
| **会话终态提炼(新增)** | 会话删除前 + parked 停靠时 | 本设计新增 |
**终态提炼设计**:
- 触发点两处: ① `ai_conversation_delete`(`commands/conversation.rs:854`)删除动作前, 后台 fire-and-forget 执行, 提炼失败或未完成不阻塞删除(删除是用户显式意图, 延迟它属于体验倒退; 未及落库的条目随会话消失是可接受损失, 与「历史数据默认保留」不冲突, 因候选未及产生即无产物); ② `emit_task_parked` 停靠三件套处(`agentic/mod.rs:1464-1477`), 停靠意味着任务未完结, 此刻的目标/决策/未解项最有提炼价值。手动触发已有(`trigger_extraction_now`, `knowledge_inject.rs:819`), 不重复建。
- 强制清水位: 复用 `trigger_extraction_now` 的清哨兵语义(:827-828), 终态提炼不受「10 条新用户消息」水位限制, 短会话有实质产出也应提炼一次; 防重仍走 InFlight 水位, 与可能在飞的常规提炼互斥。
- 提炼范围: 常规提炼只看尾部 40 条窗口(:48); 终态提炼取全量 active 消息, 并把 WorkingContext 的 decisions/unresolved 一并喂给提炼提示词(此刻 working_context 在内存中是最新态), 让「本会话定了什么、卡在哪」成为提炼输入而非仅靠消息流重放。
- **提炼什么与不提炼什么的边界**: 提炼产出限「跨会话可复用的经验」: 决策与依据、事实发现(环境约束/结构/坑)、偏好与约定、踩坑。**未完成事项不进知识表**: 它是会话态不是知识, 归 L4 目标快照与停靠说明承接(与根治设计分工矩阵一致: knowledge 是高蒸馏沉淀, pinned_goals/任务态是实时意图, `docs/02-架构设计/上下文根治设计-20261006.md` 第十节)。此边界修正 09-29 首版「未完成事项提炼进知识库」的含糊表述。
- 工具写入归属补全: knowledge_insert 拿不到 conv_id 的根因是工具 handler 只有 args 入参(`tools/knowledge.rs:13-16`)。补法: 工具注册或执行侧传入当前活跃会话 id(与 R1 批的会话上下文注入机制同一件基建, 见 3.3), insert 前 `resolve_source_project`(`knowledge_inject.rs:948`)算归属, source_ref 记 `conv:{id}`, 与自动提炼产物同格式。
### 3.2 存储选型: 复用 knowledge 表, 零 schema 变更
| 选项 | 评价 |
|---|---|
| **A 复用 knowledges 表(推荐)** | 检索/复用计数/晋升/生命线/归属/溯源全套机制已在; kind 七种语义(踩坑/架构模式/诊断/评审规则等, `migrations.rs:1828-1830`)覆盖决策与事实类产出; 零迁移零新面 |
| B knowledges 补 scope 层级字段 | 知识库模型重审(2026-10-05)UC6 提案(会话/项目/全局); 触发制后置, 单项目桌面场景 source_project 已够, 宁少勿预留 |
| C 新建 session_memories 表 | 与 knowledges 的检索/晋升/代谢/留痕重复建设, 双轨违背 DRY; 09-29 首版未选, 本版明确否决 |
| D 复用压缩存档 JSONL 当跨会话记忆 | 存档是原文不是蒸馏产物, 无结构化元数据, 检索需全文件扫描; 根治设计第八节定位明确: 知识条目是晋升终点, 原文页池是全程。存档维持现状(会话内 read_file 召回, 已落地), 不升级为 L3 存储 |
**推荐 A**。与根治设计晋升单向门一致: 会话内页(压缩存档, 原始)到记忆卡(会话内, 中蒸馏, C-m1)到 knowledge 条目(跨会话, 高蒸馏)。L3 只收蒸馏产物, 原料留在 L2 侧。
### 3.3 读取侧: 只走工具按需查
**铁律的两层精确表述**(消解与组装器方案的表面冲突):
- 跨会话层(本设计): 召回内容一律走工具, 无任何自动注入通道。prompt 总 token 不随历史会话数增长(09-29 首版 W3 验收口径沿用)。
- 会话内层(组装器管辖): 分层组装器 P2.3 的 recall 段(会话内窗口外页/记忆卡)落发送视图尾部 user 角色、带配额闸, 不进 system 段, 与铁律字面一致; 其 v1 半径拍板已锁「仅当前会话」(组装器方案拍板点 4 选 A), 跨会话被明确延后到本设计, 且本设计选择工具通道而非注入通道, 比组装器的会话内置尾注入更保守。两文档无冲突: 组装器治会话内, 本设计治跨会话, 共用 `knowledge_inject` 检索面。
**工具语义(不新造 recall_memory, 强化学员 knowledge_search)**:
09-29 首版提案的 recall_memory 专用工具与已落地的 knowledge_search 语义重合(查「之前怎么解决的/有没有记过」, `tools/knowledge.rs:55` 描述已含使用时机引导), 再造一个违背 DRY。本设计对 knowledge_search 做两点强化:
1. 项目软排序接入: 检索改走 `search_keywords_scoped`, project_id 由调用时上下文解析(见下条)。同项目经验排前, 异项目与全局知识仍可召回不淹没(三档排序已在 df-storage 侧就绪, `idea_repo.rs:834-880`, 零新存储逻辑)。
2. 会话上下文注入机制: 工具 handler 现拿不到 conv_id(`tools/knowledge.rs:13-16` 自证), 需要在工具执行侧补一个「当前会话 id」的注入通道(注册时捕获或执行时从 conv_states 解析)。这是本设计唯一的新基建件, 同时服务 3.1 的写入归属与 3.3 的读取排序, 一件基建两处收益。
**引导注入方式**: 靠工具描述(静态, 随 system 进稳定缓存前缀, 现有描述已含使用时机)与空结果指引(:72-77 已有), **不新增每轮注入文字**。易变尾段是每轮变化区, 放常量引导既占 token 又无增量价值。
**注入侧与工具侧分工**: 注入侧(`auto_inject`, top-3, 项目软排序已接)是「高蒸馏 published 知识的被动注入」, 服务「agent 不知道自己不知道」的场景; 工具侧是「agent 主动按需查」, 服务明确的历史问题。两者互补且都已存在, 本设计不扩大注入剂量(top-3 配额不动)。
**脉络卡(用户侧辅助通道)**: 新会话或会话列表项上, 按 source_project 聚合展示同项目近期会话的决策/结论摘要(数据源: knowledge 表按 source_ref 反查会话 + ai_conversations 元数据, 纯前端读侧)。用户点「带入上下文」才转为显式 @提及注入。非 LLM 注入, 不触碰铁律; 把「要不要带历史」的决策权交给用户, 与 bot 决策接口哲学(用户只做决策)一致。
---
## 四、L4 与目标快照: 重启恢复还缺什么
现有 parked 续跑链(V49 扫描、ai_recover_parked、B5 引擎自动恢复)解决的是「loop 死了能接着跑」; 缺的是「接着跑时脑子里还带着之前的记忆与最终意图」。三个缺口:
**缺口 1: WorkingContext 持久化(最重)**。重启后 goals/decisions/unresolved 全丢, 压缩重锚定(sync_after_compress 回填的决策)与目标钉扎跨重启断裂, 恢复的会话等于半失忆续跑。设计:
- 载体: `ai_conversations` 加 `working_context_json` 列(V50, ALTER TABLE 可逆, 与 boundary_json V45/loop_state V49 同款模式)。
- 写入时机: `save_conversation` 每轮落库点, 与 pinned_goals 同段序列化 `conv.working_context`(`commands/ai/conversation.rs:466` 附近)。
- 恢复时机: 会话载入(commands/conversation.rs:492 恢复 pinned_goals 的同段)与 `recover_parked_inner` 反序列化回 per_conv。
- 不选 settings KV 单键: 全会话映射挤一个键, 清理随会话删除无法级联, 单点膨胀。
**缺口 2: 目标快照槽(对齐 u-claw goal.go L1/L2/L3 语义, 看板 D4 并入本设计)**。现状恢复语义是注入恢复引导消息重走 spawn 正典链, 没有「按最终有效意图续, 不重放历史目标流」的快照裁决。设计:
- 快照内容: Active 目标(WorkingContext.goals)+ pinned_goals + unresolved + updated_at + source。**不另立存储**, 就是 working_context_json 列的语义化读取(单一事实源, 宁少勿预留), pinned_goals 已有独立列继续沿用。
- 恢复语义: 恢复/续接时按快照对齐当前有效意图, 中途被调整作废的目标不复活; 停靠卡与恢复引导消息由快照生成, 防止「按旧目标继续跑偏」(D4 原始动机)。
- 验收沿用 09-29 首版 W4 口径: 会话 A 调整目标后重启, 续接时只认最终目标。
**缺口 3: namespace 懒重建**。重启后 namespace 引用只读得到淘汰占位(`agentic/mod.rs:1975`), 而 DB messages 存的是展开后原文(namespace_store 侧既有约定)。设计: per_conv 重建时从 messages 原文回填 store(懒: 首次展开命中该引用时回填, 避免载入即全量扫描)。独立小批, 量级 S。
**顺带挂点**: 压缩存档的会话删除级联清理(archive.rs:7 记录的缺口)挂在 R3 批的删除提炼同一条链路上(提炼完成后清理存档文件, 顺序: 先提炼后删文件), 避免单独再开删除挂点。
---
## 五、与既有机制的关系
**知识库读写闭环(看板「观察进化系统只写不读」验收, docs/todo.md:414-415)**: 本设计 L3 写入侧(自动提炼+工具沉淀+终态提炼三通道)与读取侧(注入面+工具面+脉络卡三面)就是该验收的实现载体。质量闸引用知识库模型重审(2026-10-05)的实施序: 准入分级(candidate 不注入, published 且 verified 或 reuse 达标才可注入)与置信重估(自动提取默认 medium, 治 87% high 通胀)是该设计已立项项, 本设计不重复立项, 仅保证终态提炼产物走同一套把关。
**与分层组装器方案(2026-10-10)**: 见 3.3 两层表述。补充一点衔接: 组装器的 MemoryCardSource(C-m1 记忆卡)属会话内 L2 产物; 记忆卡中被验证可复用的条目, 未来经终态提炼通道晋升进 knowledge 表(会话内中蒸馏到跨会话高蒸馏的单向门), 这是两套设计的汇合点, 实施上互不阻塞。
**与 u-claw 分身的关系: 各自实现, 语义对齐, 不共享组件**。理由: ①语言栈不同(Go 网关 vs Rust/Tauri 桌面应用), 抽公共 crate 的跨语言成本远大于收益; ②数据载体不同(u-claw 账本 JSONL+CLAUDE.md 通道 vs devflow SQLite knowledge 表+工具注册表), 硬对齐载体必有一方扭曲; ③真正需要一致的是四层语义、召回铁律、目标快照续接语义, 这些走设计档互引即可(本文档第一节即为对齐记录)。与「devflow集成u-claw-可行性研究-2026-09-28.md」的通道集成(ask_uclaw 工具形态)正交: 那是能力互调, 这是各自体内记忆架构, 不冲突。
---
## 六、分期(每批独立验收)
| 批 | 内容 | 依赖 | 量级 | 验收 |
|---|---|---|---|---|
| R1 | L3 读取强化: 会话上下文注入机制 + knowledge_search 接项目软排序 | 无(scoped 检索 df-storage 侧已就绪) | S-M | 新会话问「这个项目之前定了什么」调工具命中且同项目排前; prompt token 不随历史会话数增长 |
| R2 | L4 重启不失忆: working_context_json 列 + 保存/恢复接线 | 无 | M | 重启应用后压缩重锚定与目标钉扎仍生效 |
| R3 | L3 写入收口: 会话终态提炼(删除前+parked 停靠) + knowledge_insert 归属补全 + 存档级联清理 | R1(共用会话上下文注入机制) | M | 关会话/停靠后知识库新增归属正确条目; 删除会话后存档文件消失 |
| R4 | 目标快照槽+恢复续接语义(D4 并入) | R2(载体) | M | 调整目标后重启续接只认最终目标, 作废目标不复活 |
| R5 | namespace 懒重建 | 无 | S | 重启后旧引用可读原文, 淘汰占位消失 |
| R6 | 脉络卡(前端读侧) | R3(数据在) | S-M | 新会话可见同项目近期决策摘要, 「带入上下文」为显式动作 |
顺序: R1(读取先行见效, 依赖最少)到 R2 到 R3 到 R4, R5/R6 可随批插空。后置触发制(不在本设计实施): scope 层级字段、代谢任务、语义检索(知识库模型重审已立项, 触发制)。
---
## 七、拍板点(6 项)
1. **终态提炼触发点集合**: 建议三触发(删除前后台非阻断+parked 停靠+手动已有); 备选仅停靠+手动(删除走静默, 防删除动作被后台提炼占用资源, 代价是删除会话的经验流失)。见 3.1。
2. **knowledge_search 工具级项目软排序与会话上下文注入机制**: 建议做(跨会话工具查询才有项目维度, 且一件基建服务读写两处); 备选不做(工具保持全局检索, 项目维度仅注入侧, 量级降为 S)。见 3.3。
3. **WorkingContext 持久化载体**: 建议加列 working_context_json(V50 模式, 可迁移可回退, 随会话级联); 备选 settings KV 单键(免迁移, 但清理难、单点膨胀)。见四节缺口 1。
4. **目标快照与 D4 合并**: 建议并入本设计 R4 批(D4 原始诉求与快照槽同一件事, 拆开必双实现); 备选留 D4 单独走(则本设计 R4 改为只做存储载体)。见四节缺口 2。
5. **脉络卡是否本期做**: 建议做(用户可见收益最大, 纯读侧零注入风险); 备选后置(先跑工具通道看真实使用率)。见 3.3。
6. **未完成事项是否进知识表**: 建议不进(归 L4 快照与停靠说明, 知识表只收可复用经验, 防任务态污染知识面); 备选兼容收(kind 用 workflow_optimization+tags 标注 todo, 有「过期待办变死知识」风险, 依赖代谢任务兜底)。见 3.1 边界。
---
## 八、风险
**存储膨胀**: 终态提炼加自动提炼的 candidate 增长(基线 1073 条, 知识库模型重审 2026-10-05 快照)。治理: 提炼查重(title 相似命中即跳过或 reuse+1, 最小闭环设计第四节已列)、代谢任务(重审 UC4, 触发制后置)、存档级联清理(R3 子项)、注入与检索配额上限不动(top-3/top-10)。万条量级 LIKE+索引可承受(重审 E2 前提)。
**提炼质量与幻觉**: LLM 提炼可能编造事实或置信通胀(87% high 已实证)。治理: candidate 不进检索面(误写代价低, 人工审核可弃)、自动提取置信默认 medium(high 需证据, 重审 E4)、reasoning 字段留判断依据(`knowledge_inject.rs:1197-1198` 已回填)、promote 显式转正为唯一人为把关口、蒸馏来源质量权重(重审 E5, 后置)。终态提炼喂入 WorkingContext 决策属结构化输入, 幻觉面小于全文重放, 但仍按同一候选通道把关不豁免。
**隐私边界**: 全链本地 SQLite+本地 JSONL, 与「本地优先, 数据不出门」pivot 一致。三个注意点: embedding 管道维持默认关(C2 已拍板, 本设计不触碰); 终态提炼调用 LLM 发送会话内容, 与普通对话同一 provider 同一外发面, 无增量风险; 压缩存档明文落 data_dir, 敏感度与 DB messages 同级, 确保不进任何同步/上传/日志路径(现状无此路径, 保持即可)。
**缓存与注入纪律回归**: 最大长期风险是未来实现把召回内容「顺手」拼进 system 或常驻段(毁前缀缓存, u-claw 原案教训)。结构防线: 工具通道在组装结构上就不进组装、脉络卡纯前端、组装器召回段落尾 user 角色; 验收红线: 「prompt token 不随历史会话数增长」进每批回归口径, R1 验收即含此项。
---
## 附录: 证据坐标索引
| 论断 | 坐标 |
|---|---|
| 自动提炼挂正常收敛点 | `src-tauri/src/commands/ai/agentic/mod.rs:1636`; 水位制 `knowledge_inject.rs:660-720`(:52 阈值/:48 窗口/:70 水位) |
| 提炼产 candidate 带 source_project/溯源 | `knowledge_inject.rs:1180`(:1212)/(:1216-1219); 归属弱推断 `:948` |
| 注入侧项目软排序已接 | `knowledge_inject.rs:520-521`; `crates/df-storage/src/crud/idea_repo.rs:834-880`(三档 CASE :862-867) |
| 三知识工具与归属缺口自证 | `src-tauri/src/commands/ai/tools/knowledge.rs:49`(register)/(:51 search)/(:95 insert)/(:165 promote); 拿不到 conv `:13-16` |
| 压缩链与 WorkingContext 写入 | `context_lifecycle.rs:133-135`(水位)/(:335 锚点)/(:337 sync); `chat.rs:950`(:2444) begin_turn; `context_helpers.rs:886-996`(结构与方法) |
| 压缩存档(今日落地, 未提交) | `crates/df-ai/src/context/archive.rs:17`(:35 路径/:44 追加/:72 句柄); 开关 `prompt.rs:709-718`; 清理缺口 `archive.rs:7` |
| knowledge 表 schema | `crates/df-storage/src/migrations.rs:1835-1858`(V7, source_project :1846, kind 注释 :1828-1830) |
| L4 已有持久化面 | `conversation.rs:466-468`(pinned 落库)/`commands/conversation.rs:492-495`(恢复); V45 `migrations.rs:1366`; V49 `:1446`; 启动扫描 `state.rs:455-460`; 恢复链 `commands/conversation.rs:292-337`; 前端 `ConversationSidebar.vue:208` |
| L4 缺口自证 | df-storage 全仓无 working_context 引用(grep 实测); namespace 重启清空 `agentic/mod.rs:1975`; AiCompleted 快照透传 `agentic/mod.rs:1663` |
| 删除与停靠挂点 | `commands/conversation.rs:854`(ai_conversation_delete); `agentic/mod.rs:1464-1477`(emit_task_parked 三件套) |
| 上位设计与衔接 | 上下文根治设计-20261006.md(第七/八/九/十节); 上下文分层组装器-落地方案-2026-10-10.md(3.3/P2.3/拍板点4); 上下文三层记忆-设计-2026-10-04.md(C-m1); 知识库模型重审-精细化设计-2026-10-05.md(UC6/E4/E5/实施序) |
@@ -0,0 +1,30 @@
# 飞书通道 WS 假活:探活只告警不自愈
> 登记: AI-Ops 2026-10-11 22:15 · 来源: 用户 @ bot 零回应事故排查 · 实施归 devflow agent
## 现象
用户 20:07 后在飞书 @ bot 发消息,零回应。feishu-channel.log 尾部停在 20:07 「打包换装」watcher 终版,其后 2h+ 零「收到消息」事件——消息根本未达应用,非模型/会话层问题。
## 证据
- 日志: `~/AppData/Roaming/top.1216.devflow/feishu-channel.log`
- 累计 90 条探活告警「660s 无任何事件且无在途会话(疑似假活,持续出现请重启应用重建连接)」,跨 10-05~10-11 多日分布,连接时好时坏
- 时间线: 14:00 前持续告警 → 20:04-20:07 恢复(收到 4 条消息正常受理+终版) → 20:07 起再断,至 22:12 仍零入站,进程未重启(devflow.exe PID 14172 同前)
- 应用本身健康: trace.log 持续输出, B5 引擎「🟢 健康,空闲」
- 对照: u-claw 曾有同款(热重建不关旧连接致僵尸 WS,closeGraceful 须显式 Close),见 u-claw 记忆 uclaw-zombie-ws-connection
## 根因方向
WS 长连接假活: 探活任务已检测到「无事件+无在途」但仅打日志,无自动重建连接动作,恢复依赖新消息触发或人工重启。为何 20:04 能自愈一次、20:07 后不能,需读 SDK 连接层确认(是否依赖入站流量触发重连)。
## 修复建议
1. 探活告警连续 N 次(如 2 次,约 20 分钟)→ 主动断开并重建 WS 连接(显式 Close 旧连接防僵尸),重建结果写日志
2. 重建失败退避重试,持续失败升级通知(桌面通知/卡片)
3. 复核 90 条告警的历史分布,确认是否存在更早未被用户察觉的静默窗口
## 排除项
- 97「收到消息」vs 47「受理返回」差值: 新旧版本日志口径混杂(受理返回为后加日志),受理守卫拒绝仅 2 条且走旧「AI 正在生成中」路径,无消息丢失
- watcher 终版无「回复」行: 新版投递走卡片原地更新(edit_card),非回复消息,属正常
+51 -9
View File
@@ -353,6 +353,11 @@
### 附:F2 兜底前移(可选) ### 附:F2 兜底前移(可选)
- `ai_chat_stop` 的心跳 60s stale 判死+emit AiCompleted 兜底只在用户点停止时运行;可在 loop spawn 时自带一份,panic 死 loop 无需用户点停止也能收敛。 - `ai_chat_stop` 的心跳 60s stale 判死+emit AiCompleted 兜底只在用户点停止时运行;可在 loop spawn 时自带一份,panic 死 loop 无需用户点停止也能收敛。
### 🔍 工具结果压缩·错误行洪泛缺陷(2026-10-15 发现,未修)
- **现象**:`extract_key_info_inner` 的错误行保留策略无上限——结果里每个含 `error`/`panic`/`.rs:`/「失败」的行都全量保留并加 `[行 N]` 前缀,grep/rustc 类输出(恰是压缩的主要对象)错误行占多数时输出≥原文,压缩失效且无落盘句柄(`compressed.len() < content.len()` 门不过)。
- **佐证**:`crates/df-ai/tests/context_offload.rs` 的 `non_read_file_large_result_also_gets_handle` 用 `src/foo.rs:N` 做夹具,先天必挂——该集成测试历来未被 `--lib` 门禁覆盖, until 2026-10-15 全量 workspace 门禁才暴露(夹具已改中性行修绿,生产行为未动)。
- **修法方向**:错误行保留加封顶(如最多 N 条+「另有 M 条错误行省略」),超限走正常省略段;配套恢复信号行夹具的鉴别力测试。
# aichat 三路走查增量待办(2026-10-02 深夜) # aichat 三路走查增量待办(2026-10-02 深夜)
> 来源:上下文管理批收口期 rev-backend/rev-frontend/rev-crates 三路走查(历三次会话重启后续跑完成)。**全文与批次归并建议见 `docs/05-代码审查/aichat三路走查报告-2026-10-02.md`**。以下为可执行登记,开工以现场源码为准。 > 来源:上下文管理批收口期 rev-backend/rev-frontend/rev-crates 三路走查(历三次会话重启后续跑完成)。**全文与批次归并建议见 `docs/05-代码审查/aichat三路走查报告-2026-10-02.md`**。以下为可执行登记,开工以现场源码为准。
@@ -590,7 +595,7 @@
### 待办(按优先级) ### 待办(按优先级)
1. [ ] **攒批提交 47 文件**(已授权):停笔窗口执行,按主题 4 笔分组——①V49 断点续跑批(state/main/lib/sub_agent/df-storage) ②飞书渠道批(feishu_channel 四修+流式+D1-D3) ③纪律内置+知识库批(N1 helpers 等) ④前端流式链批(useStreamRenderer 等)。提交前 cargo check + vue-tsc 终验。 1. [ ] **攒批提交 47 文件**(已授权):停笔窗口执行,按主题 4 笔分组——①V49 断点续跑批(state/main/lib/sub_agent/df-storage) ②飞书渠道批(feishu_channel 四修+流式+D1-D3) ③纪律内置+知识库批(N1 helpers 等) ④前端流式链批(useStreamRenderer 等)。提交前 cargo check + vue-tsc 终验。
2. [ ] **C-r0 验证命令后台化**(验收:>90s 同步等待占比 <15%,现状 45%) 2. [x] **C-r0 验证命令后台化**(原验收:>90s 同步等待占比 <15%,现状 45%)——**【10-11 已实施并推送 c7d0ed9,代码就绪;实测口径待开关开启后取数】**:实现见下方 941 条;开关 KV `df-ai-run-command-background` **默认关**(=同步路径逐字节不变,故生效前占比不变属预期),需开开关后跑一段时间再核 <15% 目标。
3. [ ] **D2a ai_tool_executions 结果过期外置**(DB 193.7MB 半天+17.6MB,紧迫)+ D2d 归档 90 天随批 3. [ ] **D2a ai_tool_executions 结果过期外置**(DB 193.7MB 半天+17.6MB,紧迫)+ D2d 归档 90 天随批
4. [ ] **锁面+同步 IO 技术债**(196 处 async 锁抽查、commands/ai 内 5 文件 std::fs 混入) 4. [ ] **锁面+同步 IO 技术债**(196 处 async 锁抽查、commands/ai 内 5 文件 std::fs 混入)
5. [ ] **注入验证**:知识库 691b351d/c142e6b2 已 published+high,新会话确认铁律段带出 5. [ ] **注入验证**:知识库 691b351d/c142e6b2 已 published+high,新会话确认铁律段带出
@@ -640,6 +645,11 @@
### B5 无人值守续作引擎(2026-10-06 用户实证"总停下确定性任务"的根治,挂机治理核心缺口) ### B5 无人值守续作引擎(2026-10-06 用户实证"总停下确定性任务"的根治,挂机治理核心缺口)
- [ ] **B5a 挂钟闸分任务定级**:确定性任务(看板任务/用户拍板项)挂钟闸放宽(如 120min)或按预估时长配置;闸到点改「检查点+续跑」(存进度不停工),仅非确定性/失控疑似才硬停靠。 - [ ] **B5a 挂钟闸分任务定级**:确定性任务(看板任务/用户拍板项)挂钟闸放宽(如 120min)或按预估时长配置;闸到点改「检查点+续跑」(存进度不停工),仅非确定性/失控疑似才硬停靠。
- [ ] **目标提取质量闸**(2026-10-12 目标丢失事故实锤:用户抱怨句「丢失 会话上下文了 找回目标」被注册成任务目标、「继续」单字成目标、半截命令「ssh-proxy upload --name i」以 active 驱动续跑):写入侧把关——短文本/纯沟通词/疑问句不入 goals,含参数的命令目标须完整形态;恢复侧放行前过同闸
- [ ] **恢复消息递归套娃**(2026-10-12 实锤:重启恢复消息整段被当用户输入再提取,目标里嵌套上一次恢复全文,层层放大):恢复消息注入走旁路,不进目标提取源
- [ ] **G2 停滞判定叠输出变化维度**(2026-10-12 OSS迁移会话实证:轮询 grep DONE 同参数连发5次被防卡死拦截,但输出 skip 8000→8500 在变=有进展硬证据):样本键叠加输出内容哈希,输出仍在变不算停滞(真死循环输出也不变,非放松防线);stall_detector.rs 签名族
- [ ] **恢复目标完整性守卫**(同上实证:残缺目标「ssh-proxy upload --name i」进快照被恢复引导放行,AI 强行续跑):R4 恢复消息对参数明显不完整的目标标注「须先向用户确认参数」;偏产品语义,与快照线同族
- [ ] **压缩失败路径召回句柄核查**(同上实证:压缩失败兜底摘要+archive jsonl 归档正常,但截断参数仍丢失):核查④压缩可召回句柄行在压缩失败路径是否注入/AI 是否被引导召回;与知识路标层同族问题
- [ ] **B5b 停靠自动续作**:停靠/parked/批收敛后自动回读看板——有 parked 可续任务→自动 resume;有未完批次→自动领单续作;真需决策才停(附决策项)。无人值守的"永动"机制,CLAUDE.md 领单纪律的运行时化。 - [ ] **B5b 停靠自动续作**:停靠/parked/批收敛后自动回读看板——有 parked 可续任务→自动 resume;有未完批次→自动领单续作;真需决策才停(附决策项)。无人值守的"永动"机制,CLAUDE.md 领单纪律的运行时化。
- [ ] **B5c 审批无人值守策略**:挂机中审批拦截按用户预设策略处置(低风险自动批+留痕,高风险停下+通知),策略 yml 可配。验收:挂机 4h 连续推进模拟(确定性批次队列),零人工干预零停顿;超大任务挂钟闸到点走检查点续跑。 - [ ] **B5c 审批无人值守策略**:挂机中审批拦截按用户预设策略处置(低风险自动批+留痕,高风险停下+通知),策略 yml 可配。验收:挂机 4h 连续推进模拟(确定性批次队列),零人工干预零停顿;超大任务挂钟闸到点走检查点续跑。
@@ -664,7 +674,7 @@
- **推广路线**:devflow v1 验证 → 模板提炼进技能分发站 → u-claw 九实例各挂看板 → 多 bot 协作总线(N²→N+1) → 人-bot 协作(写板即派工)。 - **推广路线**:devflow v1 验证 → 模板提炼进技能分发站 → u-claw 九实例各挂看板 → 多 bot 协作总线(N²→N+1) → 人-bot 协作(写板即派工)。
- **前置约束**:仅异步任务适用;hook 随模板强制安装;多实例并发领单=[→]原子改写+git push 冲突天然仲裁。 - **前置约束**:仅异步任务适用;hook 随模板强制安装;多实例并发领单=[→]原子改写+git push 冲突天然仲裁。
- [ ] 32 泛化落地:devflow v1 跑通后,模板三件套提炼进技能分发站(skill 形态),九实例试点 2-3 个(先 AI-Ops 自领+devflow,跑一周数据再扩)。 - [ ] 32 泛化落地:devflow v1 跑通后,模板三件套提炼进技能分发站(skill 形态),九实例试点 2-3 个(先 AI-Ops 自领+devflow,跑一周数据再扩)。
33. [ ] **devflow.md MVP(十四节多角色论证定稿)**:①启动读取 devflow.md(规范纪律+领单两分区)+CLAUDE.md 并入 build_runtime_prompt 第七段,内容按相关性裁剪禁全量 ②警示区走 insert 消息范式 ③状态不文件化(DB 直注+H3 git 实测) ④知识投影补相关性元数据。砍除运行中文件刷新。v2+:agent 自主维护/知识投影动态化/跨会话投影。验收:启动双文件并入生效;警示事件下一轮可见;token 零缓存击穿。 33. [x] **devflow.md MVP(十四节多角色论证定稿)** ✅已实施(2026-10-12:devflow_md.rs 投影模块+第七段稳定尾追加双路径(主会话 build_runtime_prompt+子代理 spawn_agent 同源)/分区裁剪(纪律4000+领单2000+CLAUDE 4000/总6000)/conv_id 键会话级缓存零缓存击穿/缺失零回归;881 测试绿含 9 新):①启动读取 devflow.md(规范纪律+领单两分区)+CLAUDE.md 并入 build_runtime_prompt 第七段,内容按相关性裁剪禁全量 ②警示区走 insert 消息范式 ③状态不文件化(DB 直注+H3 git 实测) ④知识投影补相关性元数据。砍除运行中文件刷新。v2+:agent 自主维护/知识投影动态化/跨会话投影。验收:启动双文件并入生效;警示事件下一轮可见;token 零缓存击穿。
### 看板完整性防护(2026-10-06 todo.md 截断事故:文件被重写为 12 行+脱离 git 跟踪,内容经 git HEAD+残版合并零丢失恢复) ### 看板完整性防护(2026-10-06 todo.md 截断事故:文件被重写为 12 行+脱离 git 跟踪,内容经 git HEAD+残版合并零丢失恢复)
- [ ] **看板防护三件**:①条目数骤降告警——看板条目数单次变化 >50% 时视为异常(防静默截断),提交/重写前自检 ②看板修改一律原子写(tmp+rename)+保留前版快照(todo-快照-*.md 滚动一份) ③git add 覆盖检查——提交流程确认看板在跟踪列表(防选择性 add 遗漏致脱离跟踪)。根因:agent 提交/整理流程对 todo.md 组合失误(重写+跟踪移除),细节在其 transcript 不可见——机制防护优先于行为归因。 - [ ] **看板防护三件**:①条目数骤降告警——看板条目数单次变化 >50% 时视为异常(防静默截断),提交/重写前自检 ②看板修改一律原子写(tmp+rename)+保留前版快照(todo-快照-*.md 滚动一份) ③git add 覆盖检查——提交流程确认看板在跟踪列表(防选择性 add 遗漏致脱离跟踪)。根因:agent 提交/整理流程对 todo.md 组合失误(重写+跟踪移除),细节在其 transcript 不可见——机制防护优先于行为归因。
@@ -701,15 +711,17 @@
933. [ ] **输入框支持有序列表渲染(`1. ` 类飞书,2026-10-10 用户报)**:ChatInput 内输入 `1. `(或 `- `)后应有**列表预览/自动编号**效果(对齐飞书消息输入框),而非纯文本。**需设计**:触发判据(行首 `\d+. ` / `- ` / `* `)、渲染形态(实时预览块 or 仅高亮)、回车续写自动补序号、退格退出列表、与既有 `/` 技能联想、`@` 提及、代码围栏的优先级冲突处理。设计落 docs 后再实施。 933. [ ] **输入框支持有序列表渲染(`1. ` 类飞书,2026-10-10 用户报)**:ChatInput 内输入 `1. `(或 `- `)后应有**列表预览/自动编号**效果(对齐飞书消息输入框),而非纯文本。**需设计**:触发判据(行首 `\d+. ` / `- ` / `* `)、渲染形态(实时预览块 or 仅高亮)、回车续写自动补序号、退格退出列表、与既有 `/` 技能联想、`@` 提及、代码围栏的优先级冲突处理。设计落 docs 后再实施。
935. [ ] **定时消息(多条 + 定时列表,2026-10-10 用户报)**:支持把消息设定为定时发送,可设多条,并有统一「定时列表」管理(查看/改期/取消)。**需设计**:存储(表 or KV)、调度器(前端定时器 or 后端 tokio 任务,需在应用关闭后仍生效→倾向后端)、与队列/生成中状态的关系、失败重试与通知、UI 入口(CHATIN 旁「定时」按钮 + 列表抽屉)。设计落 docs 后再实施。 935. [ ] **定时消息(多条 + 定时列表,2026-10-10 用户报)**:支持把消息设定为定时发送,可设多条,并有统一「定时列表」管理(查看/改期/取消)。**需设计**:存储(表 or KV)、调度器(前端定时器 or 后端 tokio 任务,需在应用关闭后仍生效→倾向后端)、与队列/生成中状态的关系、失败重试与通知、UI 入口(CHATIN 旁「定时」按钮 + 列表抽屉)。设计落 docs 后再实施。
939. [ ] **上下文管理瓶颈清单(2026-10-10 五维只读走查,源码取证)** — 按收益排序,⚠️ `crates/df-ai/**` 被并行写者占用(工具结果外置 WIP)→ 带 ★ 的可先在 src-tauri 侧落,其余待解禁: 939. [ ] **上下文管理瓶颈清单(2026-10-10 五维只读走查,源码取证)** — 按收益排序,⚠️ `crates/df-ai/**` 被并行写者占用(工具结果外置 WIP)→ 带 ★ 的可先在 src-tauri 侧落,其余待解禁:
- **HIGH ①** `history_tokens` 与真实请求体量**脱钩**:工具结果经 namespace 引用化入内存(`audit/mod.rs:1183-1190` + `namespace_store.rs:40-55` ALWAYS_LARGE_TOOLS 命中即引用,与体积无关),估算器只数字符(`context_helpers.rs:62-105`)→ 单条上万 token 的大结果只记 ~15 token;**预算/裁剪门(`context/mod.rs:185-205`)/压缩门(`context_lifecycle.rs:103-108`)三重门槛形同虚设**,每轮实际重发展开后的全量历史(223:1 结构浪费根因)。修:push/外置时按原文体量入账,或估算器识别 ref 取真实大小。 - **HIGH ① 🟡 namespace 半边已修(2026-10-11,走查复核通过)**:ref 尾段携带原文字节数(?len=),估算器识别后按真实体量入账(旧 ~15 token→真实万级)。**走查五线复核**:ref 入口唯一(audit 三处全走 store_inner)/展开点=发送链(agentic/mod.rs:1968,LLM 每轮收原文,估算必须按原文)/估算无旁路(ContextManager 4 调用点全走 estimate_message)/压缩扣减闭环(compress_old_messages :687 同口径扣)/子代理同代码自动生效。**tool_offload 句柄与审计侧无需接入**(offload 发送即摘要,估算按实际发送量已准;审计侧无估算需求)——此前「待接」标注系误标已删。已知取舍:字节直入字符口径中文 3x 高估(方向安全,ASCII 代码准)。原描述::工具结果经 namespace 引用化入内存(`audit/mod.rs:1183-1190` + `namespace_store.rs:40-55` ALWAYS_LARGE_TOOLS 命中即引用,与体积无关),估算器只数字符(`context_helpers.rs:62-105`)→ 单条上万 token 的大结果只记 ~15 token;**预算/裁剪门(`context/mod.rs:185-205`)/压缩门(`context_lifecycle.rs:103-108`)三重门槛形同虚设**,每轮实际重发展开后的全量历史(223:1 结构浪费根因)。修:push/外置时按原文体量入账,或估算器识别 ref 取真实大小。
- **📋 决策记录(2026-10-11 用户令留档): namespace→offload 双轨归一方案已分析并否决,后续再consider**——提案=大结果入队即 offload 摘要+句柄(不进池无 ref 无展开),三维看似全面优(精准零偏差/无协议演化/单轨砍展开点)。**破坏性分析否决理由**:①致命=新近度豁免失效(RECENCY_EXEMPT_TOOL_RESULTS=2,末尾 2 条恒不压缩保 LLM 见刚读文件全文;offload 入队即摘要,豁免无法作用——重建延迟摘要机制=回到现状架构,复杂度反超);②落盘膨胀无界(池有 LRU 上界,offload 只增不减需新增配额+清理);③压缩链输入降级(context_lifecycle:177 展开原文喂摘要任务→变摘要的摘要);④readonly 缓存比对语义漂移(cache.rs:85 两不同原文可能同摘要误命中)。**重consider触发条件**(全部满足才重启):近度豁免有替代机制(如句柄随取随读的 agent 习惯训练/近度窗内读池不读盘)+落盘配额与清理策略设计定稿+readonly 缓存比对改原文哈希。
- **HIGH ②** 每轮 O(n) 全量深克隆 + sanitize + namespace 展开 + 摘要 + 估算(`context/mod.rs:198-205` / `agentic/mod.rs:1904-1921`,`1945-1957`,`1981-1984`)→ 会话内 O(n²) 累积(全量重发本体);治本=设计档 P2 分层组装。 - **HIGH ②** 每轮 O(n) 全量深克隆 + sanitize + namespace 展开 + 摘要 + 估算(`context/mod.rs:198-205` / `agentic/mod.rs:1904-1921`,`1945-1957`,`1981-1984`)→ 会话内 O(n²) 累积(全量重发本体);治本=设计档 P2 分层组装。
- **★HIGH ③** 预算上限**恒 128K**不随模型窗口(`commands/ai/mod.rs:1239` `ContextManager::new(ContextConfig::default())`;`context_helpers.rs:131/140-142`;子代理同 `sub_agent.rs:117-127`)→ 1M 窗口模型被当 128K 裁(能力降级),小窗口模型预算高于实际窗口。修:`ContextConfig` 字段 pub,可在 src-tauri 侧按 resolved_model 窗口构造(无需改 df-ai)。 - **★HIGH ③ ✅ 已修(两批)**:主会话=model_context.rs 按 resolved_model 窗口构造(b37279f,未知窗口保守回退 128K 零回归);子代理=sub_agent_context_config 继承父会话预算(10-11,同模型同窗口单一事实来源,恒 128K 缺口收口)。
- **HIGH ④(压缩)** 压缩**单向不可逆**:被压原文对 LLM 永久不可见(`context/mod.rs:664-699` 标 Compressed + `sanitize.rs:42` is_active 过滤出局),设计 C-r1「压缩不丢/可召回」未落地 → 方向:复用 P1 句柄模式,压缩写 pointer 可回读。 - **HIGH ④(压缩)** ✅ 已实施(2026-10-11):压缩原文 JSONL 存档(`data_dir/context-archive/<conv>-archive.jsonl` 追加写)+摘要尾部召回句柄行(经 push_system_anchor 锚点内,sanitize 安全)+开关 `df-ai-compress-recall` 默认开(src-tauri 读 KV 传参,df-ai 不直读 settings);存档失败降级无句柄不影响压缩;4 新测试,585+845 双绿。原设计 C-r1「压缩不丢/可召回」落地。
- MED ⑤ `chars_ratio=0.35` 中文低估 2-3 倍、base64 高估(`context_helpers.rs:29-44/73-74`);⑥ 保护区固定 6 条 + 补偿仅 300 字(`:648/:655`);⑦ 分层组装 P2 未落地(窗口外历史无检索召回);⑧ **★** system 可变段(日期+项目/任务清单)内嵌稳定段致**前缀缓存失效**(`prompt.rs:611-635` 缓存拆分开关默认关;清单描述不截断 `:565/:584`);⑨ 滑动裁剪致前缀缓存整体失配(`context/mod.rs:239-246`);⑩ 一次压尽(windows 式分批未做);⑪ **后台压缩无 epoch/内容版本守卫**——`clear()` 保留 per_conv 条目且复位 is_compressing → 窗口内旧摘要可注入已清空的新上下文(`context_lifecycle.rs:206-323`,loop 侧有 F1 epoch,此 task 侧无);⑫ 后台压缩**无 panic 兜底** → is_compressing 永真致压缩永久停摆。 - **④ 设计要点(2026-10-11 登记)→ ✅ 已按要点实施,偏离两处**:②句柄改 df-ai 内独立轻量存档(archive.rs,settings 在 src-tauri 侧 SQLite 跨 crate 不可直读,tool_offload 同因不复用);⑤磁盘治理按「只增不减」,级联清理留挂点注释。其余要点逐条落地。
- MED ⑤ ✅ 已修(2026-10-11):CJK 分档 1.2 token/字+base64 改图地板(与 url 同口径,估算对象从字符流修正为实际消耗),`weighted_char_tokens` 单点;⑥ ✅ 已修(补偿上限 300→1000,fda991c);⑦ 分层组装 P2 → **✅ 已解锁待实施**(2026-10-11 C1 拍板走 B:跨会话召回四层架构,[设计文档](./02-架构设计/专项设计/跨会话召回四层架构-设计-2026-10-11.md)——L1/L2 基建已齐,L3 只补四缺口(终态提炼/工具归属与项目排序/脉络卡/读取强化),L4 三缺口(WorkingContext 持久化/namespace 懒重建/目标快照),6 批分期 R1-R6 拍板 6 项;窗口外历史召回走 L3 工具通道铁律不进 system prompt)→ **✅ R1-R6 全部落地**(2026-10-11:R1 读取强化/R2 WorkingContext 持久化 V53/R5 namespace 懒重建先行;R3 终态提炼删除+停靠双挂点/R4 目标快照恢复续接防旧目标复活/R6 脉络卡纯读侧+带入上下文经输入框显式注入);⑧ ✅ 已修核验(2026-10-11):缓存拆分默认开(cd77c1d)+清单描述 120 字截断(truncate_desc)+消费链四调用点实通(chat.rs×3/helpers.rs×1),开关关=逐字等价旧路径;⑨ 滑动裁剪致前缀缓存整体失配+⑩ 一次压尽 → **✅ 快赢 W1-W4 已实施**(2026-10-11 拍板按推荐值,[设计文档](./02-架构设计/专项设计/压缩裁剪缓存友好化-设计-2026-10-11.md)):W1 块状裁剪(0.8×水位+轮边界吸附+病态回退,贪心路径保留为开关关回退)/W2 锚点 view 转 user(治 BP1 折叠根因)/W3 命中率观测日志(每轮 info 供调参)/W4 BP3 断点(机制就位,W2 开时自然旁路);总开关 `df-ai-cache-friendly-context` 默认开。**剩 S1 分批压缩**(C_high 0.75/C_low 0.55,待 W3 观测数据验证压缩事件频度后实施)+S2 水位再校准+S3 namespace 淘汰断(后置);⑪ ✅ 已修(内容版本守卫,context_lifecycle 提交前比对快照);⑫ ✅ 已修(reset_compressing_after_panic)。
- LOW ⑬ 估值双轨(estimated_prompt 准确用于路由 vs history_tokens 失真用于预算)从不互相校准;⑭ sys_tokens 未计工具 schema。 - LOW ⑬ 估值双轨(estimated_prompt 准确用于路由 vs history_tokens 失真用于预算)从不互相校准;⑭ sys_tokens 未计工具 schema。
- **已核良好**:裁剪三元组原子性正确(`context/mod.rs:612-642`);909 两处修复(锚点避让/单锁复位)现状有效,未见旧窗口复现。 - **已核良好**:裁剪三元组原子性正确(`context/mod.rs:612-642`);909 两处修复(锚点避让/单锁复位)现状有效,未见旧窗口复现。
941. [ ] **C-r0 验证命令后台化:方案已定,落地需拍板(行为变更)**(2026-10-10 Wave A 5a 勘察,代理按纪律拒改并交方案) 941. [x] **C-r0 验证命令后台化(2026-10-11 已实施并推送 c7d0ed9)**(原 2026-10-10 Wave A 5a 勘察)——**【10-11 实施完成】**:audit/bg_run.rs 新模块(~530 行)——开关 KV `df-ai-run-command-background`(**默认关=同步路径逐字节不变**)+`df-ai-run-command-grace-secs`(默认 90,解析失败/0 按默认);**仅主会话生效**,子代理独立循环不转后台;grace 三路竞速(Done/Expired/Aborted)+占用闸 BG_HOLDS+HoldGuard RAII+detached 自持 30s 心跳+会话删除自中止+失败记忆+完成通知;挂接 audit/mod.rs(签名升 &Arc,同步路径逐字保留仅抽 abort_when_invalid 工厂)+guard.rs(+9 行 transition 单点拦 Idle,终局放行)。自查修复真缺陷:peek_loop_exited 先读后 release(先释放吞标记致永久占用)。验证:cargo test -p devflow --lib 845 passed(含本项新 10 测)。**注**:loop 主体 agentic/mod.rs 零改动,占用语义收敛在 guard/conv_state 单点表达(比原预估更小)。
- 事实:`run_command` 是 >90s 桶的 **95%**(11.21h/11.84h,n=4250),阻塞点=`agentic/mod.rs:2235` 整轮同步 await 工具 → `audit/mod.rs:246 execute_with_heartbeat`(tokio::select! await 至工具返回)→ `tools/file.rs:1304` → `df-execute/src/shell.rs:464`(等子进程 exit)。 - 事实:`run_command` 是 >90s 桶的 **95%**(11.21h/11.84h,n=4250),阻塞点=`agentic/mod.rs:2235` 整轮同步 await 工具 → `audit/mod.rs:246 execute_with_heartbeat`(tokio::select! await 至工具返回)→ `tools/file.rs:1304` → `df-execute/src/shell.rs:464`(等子进程 exit)。
- **为何不能只改工具层**:轮次生命周期必须同步改写,否则态不自洽——① 心跳 task 随 `execute_with_heartbeat` 返回即 abort(`audit/mod.rs:393-408`),前端 130s 看门狗(`useAiEvents.ts:262`)靠它续命;② 后台运行期间该会话是 Generating 还是 Idle(属轮次语义,落点在 `mod.rs`+前端);③ 完成回填若为注入消息,**不得楔进 head 与其 tool result 之间**(否则复现 907 类 400);④ 给 LLM 返回「已后台」非终态 tool_result = 核心 ReAct 语义变更。 - **为何不能只改工具层**:轮次生命周期必须同步改写,否则态不自洽——① 心跳 task 随 `execute_with_heartbeat` 返回即 abort(`audit/mod.rs:393-408`),前端 130s 看门狗(`useAiEvents.ts:262`)靠它续命;② 后台运行期间该会话是 Generating 还是 Idle(属轮次语义,落点在 `mod.rs`+前端);③ 完成回填若为注入消息,**不得楔进 head 与其 tool result 之间**(否则复现 907 类 400);④ 给 LLM 返回「已后台」非终态 tool_result = 核心 ReAct 语义变更。
- 建议方案(待拍板):`audit/mod.rs:246` 把 tools future 交 `tokio::spawn`(`tools` 形参升 `&Arc<AiToolRegistry>`,调用点已 clone/持 Arc,签名变更对调用方零改动)+ grace 窗口 select;detached task 自持心跳/失败记忆/完成通知;完成回填复用 `emit_ai_chat_event` + `chat.rs:1133 inject_runtime_message` / `helpers.rs:60 try_continue_agent_loop`;KV 开关 `df-ai-run-command-background`(默认关)+ grace 秒可配;**loop 侧配套改动**(心跳接管/后台期 conv 态定义/收敛判定)在 `mod.rs`。 - 建议方案(待拍板):`audit/mod.rs:246` 把 tools future 交 `tokio::spawn`(`tools` 形参升 `&Arc<AiToolRegistry>`,调用点已 clone/持 Arc,签名变更对调用方零改动)+ grace 窗口 select;detached task 自持心跳/失败记忆/完成通知;完成回填复用 `emit_ai_chat_event` + `chat.rs:1133 inject_runtime_message` / `helpers.rs:60 try_continue_agent_loop`;KV 开关 `df-ai-run-command-background`(默认关)+ grace 秒可配;**loop 侧配套改动**(心跳接管/后台期 conv 态定义/收敛判定)在 `mod.rs`。
@@ -740,5 +752,35 @@
958. [x] **飞书通道图片入站能力(2026-10-11 用户发图实测,回「暂仅支持文本」提示卡)**:用户给 DevFlow 飞书 bot 发图,只收到降级提示——**勘察结论(AI-Ops 10-11,链路+可行性已核)**:非故障,系有意兜底(feishu_channel.rs:387,#25 静默丢修复);桌面端 ai-chat 图片链路**已完整**(ChatInput 粘贴/上传 → ContentPart::Image → chat.rs:920 has_image 自动转 vision 端点,anthropic 侧 url→base64 预拉已有),缺口**仅在飞书入站**。修(四件):①`feishu_channel.rs::handle_message` 增 image 分支(现 `message_type != "text"` 一刀切回提示卡);②拉图:飞书 im/v1/messages/{message_id}/resources/{file_key}?type=image 下载(file_key 从 image 消息 content JSON 取),SDK 无现成方法则手写 GET(租户 access_token 复用既有 client);③组装:字节→base64→ContentPart::Image+可选 caption 文本拼 user 消息,走既有 spawn 会话链路(vision 路由零改动);④开放平台侧:应用补 im:resource 只读权限。**注意**:①权限申领是对外变更(飞书后台),先拍板;②大图限流(base64 膨胀,建议 >10MB 拒收提示);③文件/PDF 等其他媒体不在本条范围。验收:发图给 bot,AI 能看到图并按图回答;非图媒体仍回现有提示卡。**【10-11 AI-Ops 实施完成(用户令「直接推进」),待换装+权限开通】**:①②③已全部落地 feishu_channel.rs 单文件——类型三分流(text/image/其余提示卡,image_key 取自 SDK 归一化 msg.resources 零手解析)/`download_feishu_image`(走 channel_messaging().download_message_resource 便捷方法,与 reply/send 同风格)/魔数判 media_type(sniff_image_mime 四格式,未知拒收防端点 400)+10MB 限流/引导词 IMAGE_INBOUND_PROMPT 喂模型(飞书 image 独立类型无法带文字)/白名单后下载(未授权不烧流量)+占位发票前失败早退/注入旁路排除图片(inject 只收文本,诚实落受理守卫)。验证:cargo check -p devflow 0 error + feishu_channel 测试 23 passed(含新 image_mime_sniff_four_formats)。**待两件**:①换装生效(重启应用,等令);②飞书开放平台给应用补「获取与上传图片或文件资源」权限(im:message.resource:readonly,后台操作,未开通时下载失败会回可读错误卡)。**【10-11 追加:引用化存储改造(用户指令「下载或转文件存储引用,信息存储只存一个引用,模型使用按需转换」)】**:base64 内嵌版升级为落盘引用版——图片字节落盘 `%APPDATA%/top.1216.devflow/media/<chat_id>/<uuid>.<ext>`(save_media_file,按会话分目录,不主动 GC),消息只存 `file://` 引用(ContentPart.url,base64 空),发送请求时 df-ai 新模块 image_ref.rs 单点展开(`inline_local_image_part`:读盘→base64;缺失降级 Text「[图片文件缺失]」不炸请求)——openai_compat(map 链插入)+anthropic_compat(content_part_to_block 入口)两臂接入,provider 转换层单点覆盖全部请求路径(主会话/子代理/标题/压缩+历史重放),零契约变更(url 字段语义扩展)。桌面粘贴图 base64 内嵌同族膨胀,后续批接同一机制(登记待办)。验证:df-ai 全量 **578 passed 0 failed**(新增 image_ref 3 测+openai/anthropic file:// 端到端各 1)+devflow check 0 error+feishu_channel 23 passed。**【10-11 第二轮:媒体域全面重构完成(用户令「推」+补1-补7),全链落地】**:①media_store.rs 新模块(媒体域单一归属,防臃肿)——hash 去重落盘(sha256 前 16hex,同内容同文件)+阿里云 OSS 双写(PUT Header 签名手写,失败降级 file:// 不阻塞)+文件名=`<hash16>[_<清洗原始名>]<ext>`(u-claw safeAttachName 模式:hash 保去重+原始名保可读,路径穿越清洗);②下载防线(对齐 u-claw):3 次指数退避+120s 超时+半写残件不留盘;③spawn offload 统一收口(send+resend 两处,vision 判定后):桌面粘贴图 base64→落盘引用,SQLite 不进大 base64;④消息溯源 V52(ai_messages.source 列,MessageSource{channel,message_id,chat_id,sender}),spawn 第 11 参穿透,飞书传真实 msg_id(可跳原消息追溯,多端混发可区分);⑤出站 [交付] 回传(u-claw replyLocalFiles 同模式):报告写 [交付] 路径行→自动上传飞书(图片→图片消息/其余→文件消息)+敏感名拦截(.env/id_rsa/token 等 8 特征);⑥引用统一走 url 字段(https=OSS 跨设备可解析/file://=本机 image_ref 展开),不自造 scheme。验证:cargo check --workspace 0 error+df-storage 214+12 绿/df-ai 578 绿/devflow 832 绿(新增 media_store 4+delivery 3+V52 迁移)。**待**:换装生效+飞书后台 im:resource 权限+media-oss-config KV 手配(设置面板后续批)。 958. [x] **飞书通道图片入站能力(2026-10-11 用户发图实测,回「暂仅支持文本」提示卡)**:用户给 DevFlow 飞书 bot 发图,只收到降级提示——**勘察结论(AI-Ops 10-11,链路+可行性已核)**:非故障,系有意兜底(feishu_channel.rs:387,#25 静默丢修复);桌面端 ai-chat 图片链路**已完整**(ChatInput 粘贴/上传 → ContentPart::Image → chat.rs:920 has_image 自动转 vision 端点,anthropic 侧 url→base64 预拉已有),缺口**仅在飞书入站**。修(四件):①`feishu_channel.rs::handle_message` 增 image 分支(现 `message_type != "text"` 一刀切回提示卡);②拉图:飞书 im/v1/messages/{message_id}/resources/{file_key}?type=image 下载(file_key 从 image 消息 content JSON 取),SDK 无现成方法则手写 GET(租户 access_token 复用既有 client);③组装:字节→base64→ContentPart::Image+可选 caption 文本拼 user 消息,走既有 spawn 会话链路(vision 路由零改动);④开放平台侧:应用补 im:resource 只读权限。**注意**:①权限申领是对外变更(飞书后台),先拍板;②大图限流(base64 膨胀,建议 >10MB 拒收提示);③文件/PDF 等其他媒体不在本条范围。验收:发图给 bot,AI 能看到图并按图回答;非图媒体仍回现有提示卡。**【10-11 AI-Ops 实施完成(用户令「直接推进」),待换装+权限开通】**:①②③已全部落地 feishu_channel.rs 单文件——类型三分流(text/image/其余提示卡,image_key 取自 SDK 归一化 msg.resources 零手解析)/`download_feishu_image`(走 channel_messaging().download_message_resource 便捷方法,与 reply/send 同风格)/魔数判 media_type(sniff_image_mime 四格式,未知拒收防端点 400)+10MB 限流/引导词 IMAGE_INBOUND_PROMPT 喂模型(飞书 image 独立类型无法带文字)/白名单后下载(未授权不烧流量)+占位发票前失败早退/注入旁路排除图片(inject 只收文本,诚实落受理守卫)。验证:cargo check -p devflow 0 error + feishu_channel 测试 23 passed(含新 image_mime_sniff_four_formats)。**待两件**:①换装生效(重启应用,等令);②飞书开放平台给应用补「获取与上传图片或文件资源」权限(im:message.resource:readonly,后台操作,未开通时下载失败会回可读错误卡)。**【10-11 追加:引用化存储改造(用户指令「下载或转文件存储引用,信息存储只存一个引用,模型使用按需转换」)】**:base64 内嵌版升级为落盘引用版——图片字节落盘 `%APPDATA%/top.1216.devflow/media/<chat_id>/<uuid>.<ext>`(save_media_file,按会话分目录,不主动 GC),消息只存 `file://` 引用(ContentPart.url,base64 空),发送请求时 df-ai 新模块 image_ref.rs 单点展开(`inline_local_image_part`:读盘→base64;缺失降级 Text「[图片文件缺失]」不炸请求)——openai_compat(map 链插入)+anthropic_compat(content_part_to_block 入口)两臂接入,provider 转换层单点覆盖全部请求路径(主会话/子代理/标题/压缩+历史重放),零契约变更(url 字段语义扩展)。桌面粘贴图 base64 内嵌同族膨胀,后续批接同一机制(登记待办)。验证:df-ai 全量 **578 passed 0 failed**(新增 image_ref 3 测+openai/anthropic file:// 端到端各 1)+devflow check 0 error+feishu_channel 23 passed。**【10-11 第二轮:媒体域全面重构完成(用户令「推」+补1-补7),全链落地】**:①media_store.rs 新模块(媒体域单一归属,防臃肿)——hash 去重落盘(sha256 前 16hex,同内容同文件)+阿里云 OSS 双写(PUT Header 签名手写,失败降级 file:// 不阻塞)+文件名=`<hash16>[_<清洗原始名>]<ext>`(u-claw safeAttachName 模式:hash 保去重+原始名保可读,路径穿越清洗);②下载防线(对齐 u-claw):3 次指数退避+120s 超时+半写残件不留盘;③spawn offload 统一收口(send+resend 两处,vision 判定后):桌面粘贴图 base64→落盘引用,SQLite 不进大 base64;④消息溯源 V52(ai_messages.source 列,MessageSource{channel,message_id,chat_id,sender}),spawn 第 11 参穿透,飞书传真实 msg_id(可跳原消息追溯,多端混发可区分);⑤出站 [交付] 回传(u-claw replyLocalFiles 同模式):报告写 [交付] 路径行→自动上传飞书(图片→图片消息/其余→文件消息)+敏感名拦截(.env/id_rsa/token 等 8 特征);⑥引用统一走 url 字段(https=OSS 跨设备可解析/file://=本机 image_ref 展开),不自造 scheme。验证:cargo check --workspace 0 error+df-storage 214+12 绿/df-ai 578 绿/devflow 832 绿(新增 media_store 4+delivery 3+V52 迁移)。**待**:换装生效+飞书后台 im:resource 权限+media-oss-config KV 手配(设置面板后续批)。
959. [ ] **占用期消息必达·收件箱续投(2026-10-11 u-claw 对照,补6)**:用户在 bot 任务占用期发的消息,devflow 现状=QUEUE_BUSY_MSG 拒绝+用户须重发;u-claw 已进化为 pending 收件箱落盘+scanner 自动续投+卡片原地刷新「已进入待投队列(无需重发)」——重试责任不在用户。修:受理守卫拒绝路径改落盘收件箱(ai_outbox 表 V51 已有,复用)+原地 PATCH 提示;空闲自动续投+结果回推。验收:任务占用期发消息不丢,任务完成自动处理并回推结果。 959. [ ] **占用期消息必达·收件箱续投(2026-10-11 u-claw 对照,补6)**:用户在 bot 任务占用期发的消息,devflow 现状=QUEUE_BUSY_MSG 拒绝+用户须重发;u-claw 已进化为 pending 收件箱落盘+scanner 自动续投+卡片原地刷新「已进入待投队列(无需重发)」——重试责任不在用户。修:受理守卫拒绝路径改落盘收件箱(ai_outbox 表 V51 已有,复用)+原地 PATCH 提示;空闲自动续投+结果回推。验收:任务占用期发消息不丢,任务完成自动处理并回推结果。
960. [ ] **连续消息聚合防抖「已收 N 条无补充即处理」(2026-10-11 u-claw 对照,补6)**:u-claw 连发消息聚合「📥 已收 %d 条,%ds 内无补充即开始处理」防抖后再统一处理;devflow 每条立即受理(连续消息第二条起被占用守卫拒)。修:飞书入站短窗防抖(聚合期间到达的同 chat 消息合并为一次任务,文本拼接+附件清单合并),配合 959 收件箱。设计先行(与队列/注入语义联动,防双头)。 960. [ ] **连续消息聚合防抖「已收 N 条无补充即处理」(2026-10-11 u-claw 对照,补6)**:u-claw 连发消息聚合「📥 已收 %d 条,%ds 内无补充即开始处理」防抖后再统一处理;devflow 每条立即受理(连续消息第二条起被占用守卫拒)。修:飞书入站短窗防抖(聚合期间到达的同 chat 消息合并为一次任务,文本拼接+附件清单合并),配合 959 收件箱。**设计要点**:防抖窗 1.5~2s(per-chat 首条到达起算,窗内消息合并:文本以空行拼接+image 附件合并为多图 parts);窗口期回 ACK 卡「已收 N 条,Ns 内无补充即处理」原地更新;窗结束按 959 口径受理(空闲即 spawn/占用即收件箱)。风险位:与 per-chat 锁/注入旁路/审批决议拦截的先后序,设计先行防双头。
961. [ ] **桌面轮次交互定位重定义(2026-10-11 用户拍板,补7 三条)**:①轨迹卡片收敛:除主回复气泡外交互收敛到一张「有动态+可折叠」的轨迹卡(现有轮次折叠类型),**修缺陷:正生成时卡片展开不了**;②轮次语义纠正:现「压缩后即新轮次」定位错——正确定义=**一次信息发起=一个任务轮次,中途补充信息(inject)仍属同一轮,全部完成后新发起才是新轮**;③轮次编号稳定化:现编号不稳定(刚是 5 一会下面又出现 1)——应固定为「该对话的第 1 轮/第 2 轮」持久编号(与消息 seq/轮边界锚点绑定,不随视图压缩/折叠漂移)。涉及:MessageList 折叠模型+轮次归属模型(压缩边界 vs 发起边界)+编号生成规则。设计先行,拆批实施。 961. [~] **桌面轮次交互定位重定义(2026-10-11 用户拍板,补7 三条)**:①轨迹卡片收敛:除主回复气泡外交互收敛到一张「有动态+可折叠」的轨迹卡(现有轮次折叠类型),**修缺陷:正生成时卡片展开不了**;②轮次语义纠正:现「压缩后即新轮次」定位错——正确定义=**一次信息发起=一个任务轮次,中途补充信息(inject)仍属同一轮,全部完成后新发起才是新轮**;③轮次编号稳定化:现编号不稳定(刚是 5 一会下面又出现 1)——应固定为「该对话的第 1 轮/第 2 轮」持久编号(与消息 seq/轮边界锚点绑定,不随视图压缩/折叠漂移)。
- ✅ **①已修(2026-10-11)**:执行中 step 卡支持点击展开(复用 expandedTraceIds,key 与完成态 trace 同构,执行中展开→完成转 trace 态不丢;展开区实时渲染该轮过程工具卡)。
- **②③设计要点(实施前拍板)**:轮边界判定锚点从「压缩摘要」改为「用户发起消息」——发起=桌面直发/飞书入站的 user 消息,补充=inject_runtime_message 写入的消息(需来源标记,建议复用 V52 source:{channel:"inject"} 与发起区分,归属判定 O(1));轮号=conv 内第 N 条「发起消息」序(数据派生,天然稳定,不随压缩/视图漂移),轨迹卡头/导航条/进度徽标统一取该口径(单一真值来源)。涉及:轮桶分桶逻辑(MessageList genBucketItems 上游)+轮号计算函数收敛+inject 消息 source 标记。
# 产品方向定位:执行 agent 运行到远程容器(2026-10-18 用户口述,设计先行未实施)
- **定位**:DevFlow 未来形态=执行 agent 跑在远程容器(本机只做控制面/交互面),对齐 u-claw 分身实例形态——桌面端发起任务→远程容器内 agent 全权执行→结果回传。收益:长任务不占本机/环境隔离/多实例并行/24h 无人值守天然成立。
- **衔接存量**:df-tunnel/u-relay 线(v3 wire 协议+ws 隧道,已在树)正是传输底座;TaskBoundary 无人值守体系(边界内自主)是权限面底座;两者拼合即远程执行骨架。
- **待设计问题**(立项时展开):远程执行体的工具面裁剪(本机路径语义如何映射)/媒体与文件存储归属(远程落盘 vs 回传)/审批与边界在远程侧的执行点/凭据注入方式/断线重连与会话恢复/多容器池调度。
- **本条为方向登记,实施等用户令。**
- [ ] 队列带图项零标记(2026-10-18 走查残留):队列行渲染在 AiChat.vue:81-115,带图队列项无图标记;与 F3-2(ToolCardList scrollToFirstPending 预展开)同批处理。
### 💡 2026-10-18 ai-chat 上下文管理与信息组织完善(设计已定,按批领单)
> 用户令「上下文管理/信息组织开发完善+回复别太口水话」。蓝图:[02-架构设计/专项设计/ai-chat上下文管理与信息组织完善设计-2026-10-18.md](../02-架构设计/专项设计/ai-chat上下文管理与信息组织完善设计-2026-10-18.md);证据:temp/深查-aichat对话质量复测-2026-10-17.md(E系)+temp/深查-aichat信息UX-2026-10-17.md(F系)。M1 为 P1 先行,条目锚点/修法/验收全在设计文档,此处只列索引。
- [x] M1-P1① 恢复链治本:恢复消息 source 标记+[系统恢复]目标双侧过滤+防二次恢复(E1/E3;与 961② 同基建,可并 649 行旧条销账)——✅ 验收:cargo test -p devflow --lib recovery **6 passed**(zero_nesting_with_polluted_goals/all_polluted_falls_back/drops_recovery_artifacts_idempotent 等);conversation.rs:415 双侧过滤+单点拦截在位
- [x] M1-P1② message_repo 全量重写保留原 created_at(E11;污染存量 1750 条不回刷,止增量即可)——✅ 验收:cargo test -p df-storage **14 passed** 含 replace_conversation_preserves_created_at ok
- [x] M1-P1③ search_files/grep 0 命中「阴性存疑」标记转正(E6)——✅ 22:11 AI-Ops 子代理补齐:file.rs negative_hit_hint 共享函数+grep/search_files 两处接入;验收:negative_hit_hint_states_doubt_and_scope ok
- [~] M1-P1④ 跑题修复换装验证(E4;修复在树,换装后重放 6a5a3e04 seq1802 验证)——**已在工作区实施待验收**(prompt.rs list_active_with_activity 三源活跃度排序+指纹缓存对齐;验证方式:跑 dev 后重放场景)
- [ ] M2 存量目标一次性迁移/压缩降级前端透出/超时连败收敛(并 B5d)/boundary_json+loop_state 写入闭环(E5/E2/E13/P2-5)
- [ ] M3 会话 offset 分页+后端搜索/飞书审批决议词归一+终态卡/executing 桶内活性判定+审批预展开(F4-1/F5-1+2/F1-3+F3-2)
- [x] M4-a 提示词回复风格纪律(反口水话,中英双语+测试断言)——✅ 22:11 AI-Ops 子代理补齐:prompt.rs 中英两版行为准则各加「回复风格/Response style」条款(结论先行/省客套复述收尾/要点优先/没问不展开/「省的是空话不是内容」);验收:system_prompt_zh/en_has_response_style_clause 2 passed(共 5 passed)
- [ ] M4-b UX 小批(i18n 补键/token 化/aria/loaded/Esc/队列带图标记)——未动,留板
- [x] **收尾令(用户 2026-10-18 21:41 飞书下达到 AI-Ops,转登记)**:M1 销账后——①按 G3 提交本批改动并 push origin main(推送令已拍板,限本批范围);②启动 dev 模式运行验证(重点验 M4 反口水话与 M1 恢复链);③完成信息经飞书推送用户。若 AI-Ops 监测到 M1 已销账而本条未执行,AI-Ops 代执行。——✅ 23:0x AI-Ops 执行完毕:四笔分组提交(05d0f89 上下文P1批/302c4bc 飞书闭环/25ec9cf 前端令牌批/fb83339 文档)已 push(3bb662f..fb83339);验收 cargo test 2281 passed 0 failed+vue-tsc EXIT 0;dev 已跑(vite:1420 HTTP 200);完成信息已飞书推送。
### 💡 2026-10-19 上下文管理对标 u-claw·大批量并行差距(AI-Ops 分析,待领单)
> 用户战略令(10-18 飞书):DevFlow 上 Linux 替代 u-claw+CC,第一门槛=上下文管理三命题(丢记忆/历史召回/token 高效),大批量多会话并行是常态场景。详情:[02-架构设计/专项设计/上下文管理对标u-claw与大批量并行差距分析-2026-10-19.md](../02-架构设计/专项设计/上下文管理对标u-claw与大批量并行差距分析-2026-10-19.md)
- [ ] P0-A usage 真值采集:stream_options.include_usage 三口径(input/cache_read/cache_creation),6 月 P1 设计复活(当时暂缓,结构层已回退干净)
- [ ] P0-B 压缩触发改 token 水位主判据(≈窗口 70% 可配,体积兜底;u-claw 86e9216 同款,治压缩/请求比 4.8 偏频),依赖 P0-A
- [ ] P1-A 跨会话归档检索(/history 等价):消息全文搜索(FTS5 或 LIKE 起步)+检索指令+AI 检索指引进 sop
- [ ] P1-B boundary/loop_state 写入闭环(代语义显式化+换代通知推送)
- [ ] P2-A 大内容外置纪律(大结论写盘 out/+回复给路径,system prompt 处理规则)
- [ ] P2-B 并行资源治理(全局 token 预算视图/多会话压缩协防)——与 Linux 化/远程容器方向合流设计
+10
View File
@@ -0,0 +1,10 @@
# 探测本机正在运行的 DevFlow 进程及其版本(只读)
$p = Get-Process devflow -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Path
if ($p) {
$v = (Get-Item $p).VersionInfo
Write-Host "PATH=$p"
Write-Host "FileVersion=$($v.FileVersion)"
Write-Host "ProductVersion=$($v.ProductVersion)"
} else {
Write-Host "NO_DEVFLOW_PROCESS"
}
+33
View File
@@ -0,0 +1,33 @@
# 校验 updater 验签密钥配对(终版): 比对 keyid(KEYNUM 8字节) —— minisign 格式对齐
# conf.pubkey blob(42B): SIGALG"Ed"[0..2] + KEYNUM[2..10] + PUBKEY[10..42]
# 私钥文件 blob(116B): SIGALG"Rs"[0..2] + KDFALG[2..4] + CKSUM[4..12] + SALT[12..28] + OPS[28..36] + MEM[36..44] + KEYNUM[44..52] + SIG[52..116]
$confPath = "E:\wk-lab\devflow\src-tauri\tauri.conf.json"
$keyPath = Join-Path $env:USERPROFILE ".tauri\devflow.key"
# 1. conf 侧: 提取 pubkey → 解码 → 取 KEYNUM
$raw = Get-Content $confPath -Raw
if ($raw -match '"pubkey"\s*:\s*"([A-Za-z0-9+/=]{40,400})"') { $confPubB64 = $Matches[1] } else { Write-Host "CONF_PUBKEY_NOT_FOUND"; exit 1 }
# conf.pubkey 是 base64(minisign .pub 文件文本),需二次解码取末行
$confText = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($confPubB64))
$pubLines = @($confText -split "`n" | Where-Object { $_.Trim() -and $_ -notmatch 'comment' } | ForEach-Object { $_.Trim() })
if ($pubLines.Count -lt 1) { Write-Host "CONF_PUB_FORMAT_UNEXPECTED"; exit 1 }
$confBlob = [Convert]::FromBase64String($pubLines[0])
$confKeynum = ($confBlob[2..9] | ForEach-Object { $_.ToString("x2") }) -join ""
Write-Host "conf keyid : $confKeynum (blob $($confBlob.Length)B)"
# 2. key 侧: 私钥文件 = 注释行 + 1 条 b64 blob(KEYNUM 未加密,明文可读)
if (-not (Test-Path $keyPath)) { Write-Host "KEY_NOT_FOUND: $keyPath"; exit 1 }
$b64Lines = @(Get-Content $keyPath | Where-Object { $_ -and $_ -notmatch 'comment' } | ForEach-Object { $_.Trim() })
if ($b64Lines.Count -lt 1) { Write-Host "KEY_FORMAT_UNEXPECTED"; exit 1 }
$secBlob = [Convert]::FromBase64String($b64Lines[0])
$secKeynum = ($secBlob[44..51] | ForEach-Object { $_.ToString("x2") }) -join ""
Write-Host "key keyid : $secKeynum (blob $($secBlob.Length)B)"
# 3. 配对判定
if ($confKeynum -eq $secKeynum) {
Write-Host "MATCH - updater 验签密钥配对正确(keyid $confKeynum), B3 安装验签可通过"
exit 0
} else {
Write-Host "MISMATCH - tauri.conf.json 的 pubkey 与签名私钥不配对, B3 安装验签将失败!"
exit 1
}
+58
View File
@@ -0,0 +1,58 @@
# 发布收尾:杀挂起脚本 -> 手动签名 0.1.3 -> latest.json -> 上传 -> 远端验证
$ErrorActionPreference = "Stop"
$repo = "E:\wk-lab\devflow"
$nsis = "$repo\target\release\bundle\nsis"
$exe = "$nsis\devflow_0.1.3_x64-setup.exe"
$key = "$env:USERPROFILE\.tauri\devflow.key"
$site = "suke-dev"
$remoteDir = "/var/www/app-site/download/devflow"
$baseUrl = "https://app.1216.top/download/devflow"
$notes = "版本更新与自动升级全链路上线:设置页新增「关于」,支持应用内检查/下载/安装更新"
# 1. 杀挂起的发布脚本与构建残留
Get-Process -Id 14328,2008,26208,28276,19412 -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
Write-Host "[1/5] 挂起进程已清理"
# 2. 手动签名(key 文件内容经 env 传入——-k/ev 传路径会被当 base64 解码,报 Invalid symbol 58)
Set-Location $repo
$env:TAURI_SIGNING_PRIVATE_KEY = (Get-Content $key -Raw).Trim()
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = ""
cmd /c "cargo tauri signer sign -p `"`" --app-version 0.1.3 `"$exe`" < NUL 2>&1"
if ($LASTEXITCODE -ne 0) { Write-Host "[失败] 签名"; exit 1 }
$sigPath = "$exe.sig"
if (-not (Test-Path $sigPath)) { Write-Host "[失败] .sig 未生成"; exit 1 }
Write-Host "[2/5] 签名完成: $sigPath"
# 3. 生成 latest.json(UTF-8 无 BOM,防 PS5.1 BOM 坑)
$sig = (Get-Content $sigPath -Raw).Trim()
$pubDate = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
$latestJson = [ordered]@{
version = "0.1.3"
notes = $notes
pub_date = $pubDate
platforms = [ordered]@{
"windows-x86_64" = [ordered]@{
signature = $sig
url = "$baseUrl/devflow_0.1.3_x64-setup.exe"
}
}
} | ConvertTo-Json -Depth 5
$latestPath = "$nsis\latest.json"
[System.IO.File]::WriteAllText($latestPath, $latestJson, (New-Object System.Text.UTF8Encoding($false)))
Write-Host "[3/5] latest.json 已生成 (version=0.1.3)"
# 4. 上传三件套
$env:MSYS_NO_PATHCONV = "1"
ssh-proxy exec -n $site -c "mkdir -p $remoteDir"
foreach ($f in @($exe, $sigPath, $latestPath)) {
$name = Split-Path $f -Leaf
Write-Host "[4/5] 上传 $name ..."
ssh-proxy upload --name $site --src $f --dst "$remoteDir/$name"
if ($LASTEXITCODE -ne 0) { Write-Host "[失败] 上传 $name"; exit 1 }
}
# 5. 远端验证
Write-Host "[5/5] 远端清单验证:"
ssh-proxy exec -n $site -c "ls -la $remoteDir | grep 0.1.3; head -c 200 $remoteDir/latest.json"
Write-Host ""
Write-Host "[发布完成] v0.1.3 清单地址: $baseUrl/latest.json"
+25
View File
@@ -0,0 +1,25 @@
# 后台启动 publish-release.ps1(tauri build 10 分钟级,run_command 600s 上限装不下)
# 用法: run_command 执行本文件 -> 立即返回 pid -> 之后轮询 release-out.log
$repo = 'E:\wk-lab\devflow'
$log = "$repo\target\release-out.log"
$errl = "$repo\target\release-err.log"
# 清掉上次 600s 超时可能残留的构建进程,防 cargo 构建锁互等
$killed = Get-Process cargo,cargo-tauri,rustc -ErrorAction SilentlyContinue
if ($killed) {
$killed | Stop-Process -Force
Write-Host ("清理残留构建进程: " + (($killed | Select-Object -ExpandProperty Id) -join ','))
} else {
Write-Host "无残留构建进程"
}
Remove-Item $log,$errl -ErrorAction SilentlyContinue
$p = Start-Process -FilePath 'powershell.exe' `
-ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File',"$repo\scripts\publish-release.ps1") `
-WorkingDirectory $repo `
-NoNewWindow `
-RedirectStandardOutput $log `
-RedirectStandardError $errl `
-PassThru
Set-Content -Path "$repo\target\release-pid.txt" -Value $p.Id -Encoding ascii
Write-Host "release started, pid=$($p.Id), log=$log"
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "devflow" name = "devflow"
version = "0.1.2" version = "0.1.3"
description = "DevFlow Desktop App" description = "DevFlow Desktop App"
authors = [""] authors = [""]
edition = "2021" edition = "2021"
@@ -30,9 +30,10 @@
//! L0-3(2026-08-09):压缩 LLM 调用改后台 fire-and-forget(tokio::spawn),不再同步阻塞本轮 //! L0-3(2026-08-09):压缩 LLM 调用改后台 fire-and-forget(tokio::spawn),不再同步阻塞本轮
//! loop(非流式 LLM 最长 60s)。当前轮用未压缩上下文正常走(不丢回复),压缩结果下一轮生效。 //! loop(非流式 LLM 最长 60s)。当前轮用未压缩上下文正常走(不丢回复),压缩结果下一轮生效。
use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use tauri::AppHandle; use tauri::{AppHandle, Manager};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use df_ai::context::ContextManager; use df_ai::context::ContextManager;
@@ -40,7 +41,7 @@ use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary};
use df_ai::provider::ChatMessage; use df_ai::provider::ChatMessage;
use df_storage::models::AiProviderRecord; use df_storage::models::AiProviderRecord;
use crate::state::LlmConcurrency; use crate::state::{AppState, LlmConcurrency};
// compress_via_llm 位于 commands::ai::compress(mod.rs 的 super::compress)。 // compress_via_llm 位于 commands::ai::compress(mod.rs 的 super::compress)。
use super::super::compress::compress_via_llm; use super::super::compress::compress_via_llm;
@@ -207,6 +208,17 @@ pub(super) async fn maybe_auto_compress(
return false; return false;
} }
// 压缩可召回开关(KV `df-ai-compress-recall`,默认开):开则压缩标记时把原文
// 存档 + 摘要尾部拼召回句柄。spawn 前读值拷贝,后台 task 不持 AppState 引用
//(State 借用非 'static,不可移入 task)。
let (recall_on, data_dir) = {
let st = app_handle.state::<AppState>();
(
super::super::prompt::compress_recall_enabled(&st).await,
st.data_dir.clone(),
)
};
// ── L0-3 后台化:压缩 LLM 调用改 fire-and-forget(tokio::spawn 独立 task) ── // ── L0-3 后台化:压缩 LLM 调用改 fire-and-forget(tokio::spawn 独立 task) ──
// //
// 原实现在此 `compress_via_llm(...).await` 同步阻塞整轮(非流式 LLM 最长 60s), // 原实现在此 `compress_via_llm(...).await` 同步阻塞整轮(非流式 LLM 最长 60s),
@@ -258,7 +270,7 @@ pub(super) async fn maybe_auto_compress(
); );
// 复位 is_compressing 收口进 keyword_fallback_commit 单锁(909①-补: // 复位 is_compressing 收口进 keyword_fallback_commit 单锁(909①-补:
// 原先此处单独取锁复位,与提交之间留并发二次压缩窗口)。 // 原先此处单独取锁复位,与提交之间留并发二次压缩窗口)。
keyword_fallback_commit( let degraded = keyword_fallback_commit(
&session_arc, &session_arc,
&conv_id, &conv_id,
protect_start, protect_start,
@@ -266,8 +278,19 @@ pub(super) async fn maybe_auto_compress(
pre_compress_tokens, pre_compress_tokens,
"压缩 provider 重建失败", "压缩 provider 重建失败",
&head_id, &head_id,
recall_on.then_some(data_dir.as_path()),
) )
.await; .await;
// E2:降级不再静默(实证 76730ce8 seq166 用户被迫原句重发)
if degraded {
emit_ai_chat_event(
&app_handle,
AiChatEvent::AiContextDegraded {
conversation_id: Some(conv_id.to_string()),
reason: "压缩 provider 重建失败,已降级为关键词摘要".into(),
},
);
}
return; return;
} }
}; };
@@ -305,12 +328,21 @@ pub(super) async fn maybe_auto_compress(
); );
return; return;
} }
if recall_on {
conv.messages.set_compress_recall(&data_dir, &conv_id);
}
let _compressed = conv.messages.compress_old_messages(protect_start); let _compressed = conv.messages.compress_old_messages(protect_start);
// 召回句柄拼在摘要尾部(存档落盘成功才有):LLM 需要原文时 read_file
// 回读;句柄行只进 system 锚点,前端事件仍用原 nl_summary 不外发。
let anchor_text = match conv.messages.take_archive_path() {
Some(p) => df_ai::context::archive::with_recall_line(&summary.nl_summary, &p),
None => summary.nl_summary.clone(),
};
// NL 摘要尾部追加(而非 insert_at(0) 头部插入):头部插入会位移整条 // NL 摘要尾部追加(而非 insert_at(0) 头部插入):头部插入会位移整条
// 前缀字节,使 DeepSeek 等自动前缀缓存的 provider 全部缓存失效 // 前缀字节,使 DeepSeek 等自动前缀缓存的 provider 全部缓存失效
// (命中率掉到个位数)。尾部追加紧邻本轮内容 LLM 仍可见,前缀稳定 // (命中率掉到个位数)。尾部追加紧邻本轮内容 LLM 仍可见,前缀稳定
// 命中率最大化。JSON 卡片回填 WorkingContext,决策随压缩重锚定。 // 命中率最大化。JSON 卡片回填 WorkingContext,决策随压缩重锚定。
conv.messages.push_system_anchor(ChatMessage::system(&summary.nl_summary)); conv.messages.push_system_anchor(ChatMessage::system(&anchor_text));
conv.messages.set_compressing(false); conv.messages.set_compressing(false);
conv.working_context.sync_after_compress(&summary.json_card); conv.working_context.sync_after_compress(&summary.json_card);
} }
@@ -362,7 +394,7 @@ pub(super) async fn maybe_auto_compress(
"[ai] 自动压缩失败(后台),降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})", "[ai] 自动压缩失败(后台),降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
KEYWORD_FALLBACK_ENABLED, KEYWORD_FALLBACK_ENABLED,
); );
keyword_fallback_commit( let degraded = keyword_fallback_commit(
&session_arc, &session_arc,
&conv_id, &conv_id,
protect_start, protect_start,
@@ -370,8 +402,20 @@ pub(super) async fn maybe_auto_compress(
pre_compress_tokens, pre_compress_tokens,
"压缩失败", "压缩失败",
&head_id, &head_id,
recall_on.then_some(data_dir.as_path()),
) )
.await; .await;
// E2:降级不再静默——AiError 语义是生成结束,此处 loop 仍在跑,
// 用专用事件让前端透出降级状态条/提示(不打断流)
if degraded {
emit_ai_chat_event(
&app_handle,
AiChatEvent::AiContextDegraded {
conversation_id: Some(conv_id.to_string()),
reason: "LLM 压缩失败,已降级为关键词摘要".into(),
},
);
}
} }
} }
}) // 闭 catch_unwind 内 async move }) // 闭 catch_unwind 内 async move
@@ -409,6 +453,9 @@ pub(super) async fn maybe_auto_compress(
/// **内容版本守卫(939⑪)**:`head_id` 为触发时快照的首条消息 id;提交前比对不符(期间 /// **内容版本守卫(939⑪)**:`head_id` 为触发时快照的首条消息 id;提交前比对不符(期间
/// `clear()` 重建致上下文整体替换)→ 直接返回:既不标 compressed / 不插旧关键词摘要污染 /// `clear()` 重建致上下文整体替换)→ 直接返回:既不标 compressed / 不插旧关键词摘要污染
/// 新上下文,也不复位 is_compressing(clear 已复位;复位会误伤新会话可能已启的新压缩信令)。 /// 新上下文,也不复位 is_compressing(clear 已复位;复位会误伤新会话可能已启的新压缩信令)。
///
/// `recall`:压缩可召回开关(Some = 开,携带存档 data_dir)。开则压缩标记时原文落盘存档,
/// 关键词摘要尾部拼召回句柄行;None 零行为变更(不落盘、摘要无句柄)。
async fn keyword_fallback_commit( async fn keyword_fallback_commit(
session_arc: &Arc<Mutex<AiSession>>, session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str, conv_id: &str,
@@ -417,11 +464,12 @@ async fn keyword_fallback_commit(
pre_tokens: u32, pre_tokens: u32,
log_head: &str, log_head: &str,
head_id: &Option<String>, head_id: &Option<String>,
) { recall: Option<&Path>,
) -> bool {
let inserted = { let inserted = {
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
if !session.per_conv.contains_key(conv_id) { if !session.per_conv.contains_key(conv_id) {
return; return false;
} }
let conv = session.conv(conv_id); let conv = session.conv(conv_id);
// 939⑪ 内容版本守卫:上下文已被整体替换 → 丢弃兜底提交(标志亦不复位)。 // 939⑪ 内容版本守卫:上下文已被整体替换 → 丢弃兜底提交(标志亦不复位)。
@@ -431,7 +479,7 @@ async fn keyword_fallback_commit(
"[ai] {}兜底提交前上下文版本已变(疑似 clear 重建),丢弃", "[ai] {}兜底提交前上下文版本已变(疑似 clear 重建),丢弃",
log_head log_head
); );
return; return false;
} }
// 复位与提交同锁原子(909①-补):消「锁外复位→再取锁提交」之间的并发二次压缩窗口。 // 复位与提交同锁原子(909①-补):消「锁外复位→再取锁提交」之间的并发二次压缩窗口。
conv.messages.set_compressing(false); conv.messages.set_compressing(false);
@@ -439,14 +487,23 @@ async fn keyword_fallback_commit(
// 开关关闭 → 回退原裸裁剪行为:不压缩不插摘要,仅完成复位。 // 开关关闭 → 回退原裸裁剪行为:不压缩不插摘要,仅完成复位。
false false
} else { } else {
// 压缩可召回:开(Some)则压缩标记时原文落盘存档,关键词摘要尾部拼召回句柄行。
if let Some(dir) = recall {
conv.messages.set_compress_recall(dir, conv_id);
}
let _compressed = conv.messages.compress_old_messages(protect_start); let _compressed = conv.messages.compress_old_messages(protect_start);
if keyword_fallback.is_empty() { if keyword_fallback.is_empty() {
false false
} else { } else {
// 召回句柄拼在摘要尾部(存档落盘成功才有),只进 system 锚点。
let anchor_text = match conv.messages.take_archive_path() {
Some(p) => df_ai::context::archive::with_recall_line(keyword_fallback, &p),
None => keyword_fallback.to_string(),
};
// 尾部追加(而非 insert_at(0) 头部插入):头部插入位移整条前缀字节, // 尾部追加(而非 insert_at(0) 头部插入):头部插入位移整条前缀字节,
// 使 DeepSeek 等自动前缀缓存 provider 全部缓存失效(命中率掉到个位数)。 // 使 DeepSeek 等自动前缀缓存 provider 全部缓存失效(命中率掉到个位数)。
// 尾部追加紧邻本轮内容 LLM 仍可见,前缀稳定命中率最大化。 // 尾部追加紧邻本轮内容 LLM 仍可见,前缀稳定命中率最大化。
conv.messages.push_system_anchor(ChatMessage::system(keyword_fallback)); conv.messages.push_system_anchor(ChatMessage::system(&anchor_text));
true true
} }
} }
@@ -459,6 +516,7 @@ async fn keyword_fallback_commit(
log_head log_head
); );
} }
inserted
} }
#[cfg(test)] #[cfg(test)]
@@ -487,7 +545,7 @@ mod tests {
let arc = Arc::new(Mutex::new(AiSession::new())); let arc = Arc::new(Mutex::new(AiSession::new()));
let head = seed_compressing(&arc, "c1", 4).await; let head = seed_compressing(&arc, "c1", 4).await;
keyword_fallback_commit(&arc, "c1", 4, "关键词锚点", 0, "压缩失败", &head).await; keyword_fallback_commit(&arc, "c1", 4, "关键词锚点", 0, "压缩失败", &head, None).await;
let session = arc.lock().await; let session = arc.lock().await;
let conv = session.conv_read("c1").expect("conv 应存在"); let conv = session.conv_read("c1").expect("conv 应存在");
@@ -510,7 +568,7 @@ mod tests {
let arc = Arc::new(Mutex::new(AiSession::new())); let arc = Arc::new(Mutex::new(AiSession::new()));
let head = seed_compressing(&arc, "c2", 4).await; let head = seed_compressing(&arc, "c2", 4).await;
keyword_fallback_commit(&arc, "c2", 4, "", 0, "压缩 provider 重建失败", &head).await; keyword_fallback_commit(&arc, "c2", 4, "", 0, "压缩 provider 重建失败", &head, None).await;
let session = arc.lock().await; let session = arc.lock().await;
let conv = session.conv_read("c2").expect("conv 应存在"); let conv = session.conv_read("c2").expect("conv 应存在");
@@ -527,7 +585,7 @@ mod tests {
async fn keyword_fallback_commit_missing_conv_noop() { async fn keyword_fallback_commit_missing_conv_noop() {
let arc = Arc::new(Mutex::new(AiSession::new())); let arc = Arc::new(Mutex::new(AiSession::new()));
keyword_fallback_commit(&arc, "gone", 4, "锚点", 0, "压缩失败", &None).await; keyword_fallback_commit(&arc, "gone", 4, "锚点", 0, "压缩失败", &None, None).await;
let session = arc.lock().await; let session = arc.lock().await;
assert!( assert!(
@@ -83,6 +83,15 @@ impl GeneratingGuard {
/// 迁移 ConvState + emit AiConvStateChanged(经无锁 ConvStateStore,同步无 await)。 /// 迁移 ConvState + emit AiConvStateChanged(经无锁 ConvStateStore,同步无 await)。
fn transition(&self, target: ConvState) { fn transition(&self, target: ConvState) {
// 后台工具占用闸:后台命令在飞时拦截 loop 退出的 Idle 迁移,
// 会话保持 Generating(受理守卫判占用、前端不显示空闲),并记录「loop 已退」
// 供 detached task 终局决策 try_continue 续跑;占用复位由最后一个后台任务
// 终局时放行(audit/bg_run.rs)。用户停止/致命错误路径不经过此闸(仅拦 Idle)。
if target == ConvState::Idle
&& crate::commands::ai::audit::bg_run::hold_blocks_idle(&self.conv_id)
{
return;
}
let conv_states = self let conv_states = self
.app_handle .app_handle
.state::<crate::state::AppState>() .state::<crate::state::AppState>()
+245 -26
View File
@@ -39,7 +39,7 @@ use df_storage::models::AiProviderRecord;
// ProviderAcquire 已随 stream_round 迁至 stream_relay.rs。 // ProviderAcquire 已随 stream_round 迁至 stream_relay.rs。
use crate::state::{AppState, LlmConcurrency}; use crate::state::{AppState, LlmConcurrency};
use super::audit::{process_tool_calls, ToolExecPolicy}; use super::audit::{process_tool_calls, take_tool_fail_park, ToolExecPolicy};
use super::event_bus::emit_ai_chat_event; use super::event_bus::emit_ai_chat_event;
// compress_via_llm 已随压缩逻辑迁至 context_lifecycle.rs(maybe_auto_compress 内调用)。 // compress_via_llm 已随压缩逻辑迁至 context_lifecycle.rs(maybe_auto_compress 内调用)。
use super::conversation::{save_conversation, TokenAccumulator}; use super::conversation::{save_conversation, TokenAccumulator};
@@ -527,6 +527,32 @@ pub(crate) async fn run_agentic_loop(
// 热改 agent_max_iterations 不影响在跑 loop)+ 清零上 loop 残留的注入顺延余量。 // 热改 agent_max_iterations 不影响在跑 loop)+ 清零上 loop 残留的注入顺延余量。
conv.loop_max_rounds = max_iterations; conv.loop_max_rounds = max_iterations;
conv.round_budget_extra = 0; conv.round_budget_extra = 0;
// 真目标源(2026-10-15 目标语义重构):pinned_goals 从「工具推理流水账」改为「用户任务原文」
// ——流水账模式下用户真实意图从不进目标体系,压缩/重启后 AI 即失忆(2026-10-12/15
// 两次丢目标事故同根)。首条任务型 user 消息写入首位;已有目标不覆盖(尊重
// UPDATE_GOALS 显式修正);[系统恢复] 引导与超短碎片不入(递归套娃教训)。
if conv.pinned_goals.is_empty() {
let task_text = conv
.messages
.iter()
.find(|m| {
matches!(m.role, df_ai::provider::MessageRole::User)
&& !m.content.starts_with("[系统恢复]")
&& !m.content.starts_with("[补充指令]")
&& m.content.chars().count() >= 4
})
.map(|m| {
let mut t: String = m.content.chars().take(GOAL_MAX_CHARS).collect();
if m.content.chars().count() > GOAL_MAX_CHARS {
t.push('…');
}
t
});
if let Some(t) = task_text {
conv.pinned_goals.push(super::GoalEntry::new(t));
tracing::info!(conv_id = %conv_id, "[ai] 会话目标取首条任务消息(目标语义重构)");
}
}
( (
conv.stop_flag.clone(), conv.stop_flag.clone(),
conv.notify.clone(), conv.notify.clone(),
@@ -969,7 +995,7 @@ pub(crate) async fn run_agentic_loop(
// (pinned_goals / working_context / workflow_dag_summary)消除原 G1/T5/T4 三处独立 lock, // (pinned_goals / working_context / workflow_dag_summary)消除原 G1/T5/T4 三处独立 lock,
// 且返回 pinned_goals 复用作 AiCompleted 快照(消除原 L1157 二次读)。拼接顺序+内容逐字节等价。 // 且返回 pinned_goals 复用作 AiCompleted 快照(消除原 L1157 二次读)。拼接顺序+内容逐字节等价。
let (system_prompt, pinned_goals_snapshot) = build_runtime_prompt( let (system_prompt, pinned_goals_snapshot) = build_runtime_prompt(
&session_arc, &conv_id, system_prompt, "", &session_arc, &conv_id, system_prompt, "", h3_watch_dir.as_deref(),
).await; ).await;
// ── Plan-driven Phase 1:LLM 规划端(AICHAT_PLAN_ENABLED 门控) ── // ── Plan-driven Phase 1:LLM 规划端(AICHAT_PLAN_ENABLED 门控) ──
@@ -1714,6 +1740,61 @@ async fn unwind_to_result<F: std::future::Future>(fut: F) -> Result<F::Output, S
} }
} }
/// 展开请求视图里的 namespace 引用:store 命中直取原文;未命中记入返回列表
/// (下标 + 引用路径)交懒回填,条目本身维持引用原样待替换。
fn collect_namespace_misses(history: &mut [ChatMessage], store: &df_ai::namespace_store::NamespaceStore) -> Vec<(usize, String)> {
let mut misses = Vec::new();
for (idx, m) in history.iter_mut().enumerate() {
if df_ai::namespace_store::is_namespace_ref(&m.content) {
let path = m.content.clone();
match store.read_only(&path) {
Some(original) => m.content = original.to_string(),
None => misses.push((idx, path)),
}
}
}
misses
}
/// 回填应用:找回的引用替换为原文;无原文(真淘汰:LRU 淘汰且消息里无原文)维持
/// 既有富提示/EVICTED_PLACEHOLDER 语义,不误填。
fn apply_namespace_views(
history: &mut [ChatMessage],
misses: &[(usize, String)],
store: &df_ai::namespace_store::NamespaceStore,
recovered: &HashMap<String, String>,
conv_id: &str,
) {
for (idx, path) in misses {
history[*idx].content = match recovered.get(path) {
Some(original) => original.clone(),
None => {
tracing::warn!(
conv_id = %conv_id,
path = %path,
"[namespace] 引用已淘汰,替换为提示文案"
);
store
.eviction_rich_notice(path)
.unwrap_or_else(|| df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string())
}
};
}
}
/// namespace 懒回填的原文源:该会话 DB messages 原文(落库约定存展开后原文)。
/// 仅回填触发时调用(常态零 DB 开销);读失败返回空列表,回填退化为占位语义不阻塞本轮。
async fn load_conv_message_contents(app_handle: &AppHandle, conv_id: &str) -> Vec<String> {
let state = app_handle.state::<AppState>();
match state.ai_messages.list_by_conversation(conv_id).await {
Ok(records) => records.into_iter().map(|r| r.content).collect(),
Err(e) => {
tracing::warn!(conv_id = %conv_id, error = %e, "[namespace] 回填原文拉取失败,维持占位");
Vec::new()
}
}
}
// ============================================================ // ============================================================
// run_agentic_loop 主循环体拆解(纯搬运,行为零变更,2026-10-01) // run_agentic_loop 主循环体拆解(纯搬运,行为零变更,2026-10-01)
@@ -1945,8 +2026,23 @@ async fn prepare_round_inputs(
// (本轮 stream_llm 不持 session_arc、不改 messages,重试无 push 发生,重建等价于复用)。 // (本轮 stream_llm 不持 session_arc、不改 messages,重试无 push 发生,重建等价于复用)。
// build_for_request 读 per_conv.messages。 // build_for_request 读 per_conv.messages。
// conv_id 来源:run_agentic_loop 入参。 // conv_id 来源:run_agentic_loop 入参。
let messages = { // 缓存友好上下文总开关(KV `df-ai-cache-friendly-context`,默认开):开则 build_for_request
let session = session_arc.lock().await; // 走块状裁剪 + system 锚点 view 层转 user;关则两者旁路(与旧路径逐字节等价)。
// 锁外读值拷贝(下方 build 锁段无 await,对齐 context_lifecycle 的开关读取范式)。
let cache_friendly = {
let st = app_handle.state::<AppState>();
super::prompt::cache_friendly_context_enabled(&st).await
};
let (mut history_msgs, namespace_misses) = {
let mut session = session_arc.lock().await;
// 缓存友好注入:先于下方只读借用(可变短借用与后续只读借用不冲突);
// 条目已消失则跳过,防 conv() 惰性创建孤儿状态
if session.conv_read(conv_id).is_some() {
session.conv(conv_id).messages.set_cache_friendly(cache_friendly);
// 本轮请求已含截至此刻的全部消息,注入计数清零(=已消化);此后到收敛前
// 再到的注入会计数,收敛出口凭 >0 续一轮消化(见收敛分支守卫)。
session.conv(conv_id).pending_injects = 0;
}
let conv = match session.conv_read(conv_id) { let conv = match session.conv_read(conv_id) {
Some(c) => c, Some(c) => c,
None => { None => {
@@ -1960,25 +2056,21 @@ async fn prepare_round_inputs(
// namespace_store 引用化进 messages;持久化(conversation.rs)已展开写 DB,但发 LLM 的 // namespace_store 引用化进 messages;持久化(conversation.rs)已展开写 DB,但发 LLM 的
// 实时请求此前未展开 → LLM 每轮只看到 "namespace://..." 占位符,AI 实际拿不到工具结果。 // 实时请求此前未展开 → LLM 每轮只看到 "namespace://..." 占位符,AI 实际拿不到工具结果。
// 此处展开,namespace 退化为内存/持久化层优化,LLM 永远看真实内容。 // 此处展开,namespace 退化为内存/持久化层优化,LLM 永远看真实内容。
// read_only None(条目被 LRU 淘汰 / 重启后 namespace 清空 / 跨会话残留引用)→ // read_only None(条目被 LRU 淘汰 / 重启后 store 清空)→ 记入未命中,下方懒回填;
// 替换为 EVICTED_PLACEHOLDER 提示文案(非保留无意义 namespace:// URI)+ warn 日志。 // 仍无原文才落占位文案(见 apply_namespace_views)。
for m in &mut history_msgs { let namespace_misses = collect_namespace_misses(&mut history_msgs, &session.namespace_store);
if df_ai::namespace_store::is_namespace_ref(&m.content) { (history_msgs, namespace_misses)
let path = m.content.clone(); };
if let Some(original) = session.namespace_store.read_only(&path) { // namespace 懒回填(跨会话召回设计 缺口3):重启后 store 清空,旧引用首次展开
m.content = original.to_string(); // 未命中时才触发——从该会话 DB messages 原文按内容哈希找回并重建 store 条目,
} else { // 避免载入即全量扫描;消息里无原文的真淘汰维持既有富提示/占位。
tracing::warn!( // DB 拉取在会话锁外(锁内禁长 I/O),回填与视图替换短暂重入锁。
conv_id = %conv_id, if !namespace_misses.is_empty() {
path = %path, let contents = load_conv_message_contents(app_handle, conv_id).await;
"[namespace] 引用已淘汰,替换为提示文案" let mut session = session_arc.lock().await;
); let refs: Vec<String> = namespace_misses.iter().map(|(_, p)| p.clone()).collect();
m.content = session let recovered = session.namespace_store.backfill_from_messages(&refs, &contents);
.namespace_store apply_namespace_views(&mut history_msgs, &namespace_misses, &session.namespace_store, &recovered, conv_id);
.eviction_rich_notice(&path)
.unwrap_or_else(|| df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string());
}
}
} }
let mut msgs = vec![ChatMessage::system(system_prompt)]; let mut msgs = vec![ChatMessage::system(system_prompt)];
msgs.extend(history_msgs); msgs.extend(history_msgs);
@@ -1987,8 +2079,7 @@ async fn prepare_round_inputs(
if let Some(tail) = volatile_tail { if let Some(tail) = volatile_tail {
merge_volatile_tail(&mut msgs, tail); merge_volatile_tail(&mut msgs, tail);
} }
msgs let messages = msgs;
};
// 改进4: tool_result view-only 摘要压缩(build_for_request 后,送 stream 前)。 // 改进4: tool_result view-only 摘要压缩(build_for_request 后,送 stream 前)。
// //
@@ -2156,7 +2247,10 @@ async fn finalize_round_output(
}) })
.collect(); .collect();
} else { } else {
update_pinned_goals(&mut session, conv_id, tool_calls_acc); // 工具推理流水账停写(2026-10-15 目标语义重构):「执行 xxx/查看 xxx」是手段记录
// 不是用户目标,入 pinned_goals 挤占真目标且压缩/重启后即失忆;工具轨迹已由
// 轨迹卡与审计表完整覆盖。目标来源收敛为:首条任务消息(入口写入)+UPDATE_GOALS
// 显式修正(上方分支,LLM 显式目标管理继续有效)。
} }
} }
} }
@@ -2283,6 +2377,35 @@ async fn finalize_round_output(
// 正常收敛(loop 主体置 converged 后 break) // 正常收敛(loop 主体置 converged 后 break)
// P2-1 主会话侧最小修复:收敛文本若被 max_tokens 腰斩,追加系统提示消息 // P2-1 主会话侧最小修复:收敛文本若被 max_tokens 腰斩,追加系统提示消息
// (对齐 MidStream 保文的系统提示机制),用户/下轮请求能看见截断而非当完整回复。 // (对齐 MidStream 保文的系统提示机制),用户/下轮请求能看见截断而非当完整回复。
// 注入未消化守卫(2026-10-14 实测):本轮组装后到达的补充指令,纯问答轮一轮收敛即
// 无人消费(本轮上下文已在生成中定型)。计数>0 → 插提示续一轮,下轮
// build_for_request 自然带上补充内容;计数已 take 清零,同批注入不重复触发。
let pending_injects = {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
std::mem::take(&mut session.conv(conv_id).pending_injects)
} else {
0
}
};
if pending_injects > 0 {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
let mut tip = ChatMessage::system(format!(
"用户在回答生成期间补充了 {pending_injects} 条消息(见对话记录中的补充指令);\
请结合补充内容继续或修正上面的回答,勿忽略。"
));
tip.model = Some(resolved_model.to_string());
conv.messages.push(tip);
}
tracing::info!(
conv_id = %conv_id, pending = pending_injects,
"[ai] 收敛时检出未消化注入,插提示续一轮消化"
);
// 不 return Converged:掉到函数尾部 RunTools(本轮无工具空转),
// loop 顶部正常续轮,补充指令获得完整消费轮。
} else {
if round_truncated_by_max_tokens { if round_truncated_by_max_tokens {
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) { if session.per_conv.contains_key(conv_id) {
@@ -2298,6 +2421,7 @@ async fn finalize_round_output(
return RoundEpilogue::Converged; return RoundEpilogue::Converged;
} }
} }
}
// 续轮内 LLM 调了工具(无论成败)→ 目标验证计数重置(说明在行动非空转,非卡死)。 // 续轮内 LLM 调了工具(无论成败)→ 目标验证计数重置(说明在行动非空转,非卡死)。
// 紧跟在 converged 判定之后、process_tool_calls 之前:本轮 has_tool_calls=true 到这里, // 紧跟在 converged 判定之后、process_tool_calls 之前:本轮 has_tool_calls=true 到这里,
@@ -2355,6 +2479,24 @@ async fn process_round_tools(
return ToolsOutcome::Exit; return ToolsOutcome::Exit;
} }
// B5d 工具连败熔断终局:同会话失败记忆拦截连击达阈且期间无成功 → 强制停靠。
// 拦截计数在 audit 层(execute_with_heartbeat)累积,停靠在 loop 层执行:guard 复位
// 与 emit_task_parked 正典序列只有本层触达,audit 层只判定不执行。停靠即终局,
// 不再给模型无视 blocked 提示继续重试的机会(实证:拦截后模型仍原样重试 10+ 次)。
if take_tool_fail_park(conv_id) {
tracing::warn!(
conv_id = %conv_id,
"[ai] 工具连续失败熔断:失败记忆拦截连击达阈,任务强制停靠"
);
guard.reset().await;
crate::commands::ai::emit_task_parked(
app_handle, conv_id,
"工具连续失败熔断(同一调用被失败记忆反复拦截仍重试),任务安全停靠;已完成进度已保存,调整思路后重发消息可继续",
"circuit_breaker",
);
return ToolsOutcome::Exit;
}
// AC-2 ①:单轮工具调用数超限 → 注入软提示(机制化,非静默,弱模型能感知)。 // AC-2 ①:单轮工具调用数超限 → 注入软提示(机制化,非静默,弱模型能感知)。
// 治单轮并行调用爆炸:LLM 看不到自己发了多少调用,必须在消息流里显式警告。 // 治单轮并行调用爆炸:LLM 看不到自己发了多少调用,必须在消息流里显式警告。
if is_tool_call_over_limit(round_tool_call_count) { if is_tool_call_over_limit(round_tool_call_count) {
@@ -3176,11 +3318,13 @@ fn completed_event(
// 做 snapshot,消除原 L1157 第二次 lock 读。 // 做 snapshot,消除原 L1157 第二次 lock 读。
// //
// 注:`_lang` 参数为未来 i18n 行为准则预留(当前 system_prompt 拼接未用 lang),保持调用点签名稳定。 // 注:`_lang` 参数为未来 i18n 行为准则预留(当前 system_prompt 拼接未用 lang),保持调用点签名稳定。
// 注:`work_dir` 供项目规则文件投影定位工作区(与被监视工作区同一目录),None=无授权目录。
async fn build_runtime_prompt( async fn build_runtime_prompt(
session_arc: &Arc<Mutex<AiSession>>, session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str, conv_id: &str,
base_prompt: String, base_prompt: String,
_lang: &str, _lang: &str,
work_dir: Option<&str>,
) -> (String, Vec<super::GoalEntry>) { ) -> (String, Vec<super::GoalEntry>) {
// 一次锁读四字段(pinned_goals / working_injection / workflow_dag_summary / workflow_id)。 // 一次锁读四字段(pinned_goals / working_injection / workflow_dag_summary / workflow_id)。
// conv 已删(None)→ goals=空 / injection=空 / dag=None,等价原各块 conv_read.unwrap_or_default 短路。 // conv 已删(None)→ goals=空 / injection=空 / dag=None,等价原各块 conv_read.unwrap_or_default 短路。
@@ -3306,6 +3450,12 @@ async fn build_runtime_prompt(
); );
} }
// 项目规则文件投影(devflow.md/CLAUDE.md):稳定尾段纯追加 —— 拼在全部既有段之后、
// 返回之前。内容经 devflow_md 模块按会话缓存(同会话只读一次盘),loop 内为不变量,
// 不击穿 provider prompt 缓存前缀;工作目录无规则文件时原样返回(缺失不影响既有组装)。
let system_prompt =
crate::commands::ai::devflow_md::append_projection(system_prompt, work_dir, conv_id).await;
(system_prompt, goals) (system_prompt, goals)
} }
@@ -3514,6 +3664,7 @@ fn push_assistant_message(
// ── update_pinned_goals: G1 目标钉扎提取+裁剪(扁平抽自原嵌套块,原 8 层 → 3 层) ── // ── update_pinned_goals: G1 目标钉扎提取+裁剪(扁平抽自原嵌套块,原 8 层 → 3 层) ──
// 从工具调用推理新目标 → 去重 add → 标记旧 active 为 completed → 超上限时淘汰最早 completed。 // 从工具调用推理新目标 → 去重 add → 标记旧 active 为 completed → 超上限时淘汰最早 completed。
// 入参 session 需外层调用方持锁;纯内存 mutate,无 await。 // 入参 session 需外层调用方持锁;纯内存 mutate,无 await。
#[allow(dead_code)]
fn update_pinned_goals( fn update_pinned_goals(
session: &mut AiSession, session: &mut AiSession,
conv_id: &str, conv_id: &str,
@@ -3813,3 +3964,71 @@ mod empty_round_guard_tests {
assert!(empty_round_should_park(4, 3)); assert!(empty_round_should_park(4, 3));
} }
} }
// namespace 懒回填契约(跨会话召回设计 缺口3):重启后 store 清空,旧引用首次展开
// 未命中时从 DB messages 原文按内容哈希回填;二次展开走 store 不再触发回填;
// 消息里无原文的真淘汰维持占位不误填。全链 = collect/apply 两纯函数 + store 回填 API。
#[cfg(test)]
mod namespace_backfill_tests {
use super::{apply_namespace_views, collect_namespace_misses};
use df_ai::namespace_store::{NamespaceStore, EVICTED_PLACEHOLDER};
use df_ai::provider::ChatMessage;
// 引用路径取自热 store(模拟重启前生成),与生产 store() 同源
fn ref_path_of(content: &str) -> String {
NamespaceStore::new(1 << 20).store("read_file", content)
}
fn history_with_ref(path: &str) -> Vec<ChatMessage> {
vec![ChatMessage::tool_result("tc1", path)]
}
#[test]
fn miss_backfills_from_message_original_and_expands() {
let original = "文件A的内容行\n".repeat(300);
let path = ref_path_of(&original);
let mut store = NamespaceStore::default(); // 重启后空 store
let mut history = history_with_ref(&path);
let misses = collect_namespace_misses(&mut history, &store);
assert_eq!(misses.len(), 1, "store 空应记未命中");
let recovered = store.backfill_from_messages(&[path.clone()], &[original.clone()]);
apply_namespace_views(&mut history, &misses, &store, &recovered, "conv-t");
assert_eq!(history[0].content, original, "展开应得到原文而非占位");
assert_eq!(
store.read_only(&path).map(str::to_owned),
Some(original),
"store 应已回填该条目",
);
}
#[test]
fn second_expansion_hits_store_without_rebackfill() {
let original = "x".repeat(4096);
let path = ref_path_of(&original);
let mut store = NamespaceStore::default();
let mut history = history_with_ref(&path);
let misses = collect_namespace_misses(&mut history, &store);
let recovered = store.backfill_from_messages(&[path.clone()], &[original.clone()]);
apply_namespace_views(&mut history, &misses, &store, &recovered, "conv-t");
// 下一轮重建视图:store 已命中,未命中为空 → 回填阶段(含 DB 拉取)不再触发
let mut history2 = history_with_ref(&path);
let misses2 = collect_namespace_misses(&mut history2, &store);
assert!(misses2.is_empty(), "二次展开应走 store,无未命中");
assert_eq!(history2[0].content, original);
}
#[test]
fn real_eviction_keeps_placeholder_without_wrong_fill() {
let original = "y".repeat(4096);
let path = ref_path_of(&original);
let mut store = NamespaceStore::default();
let mut history = history_with_ref(&path);
let misses = collect_namespace_misses(&mut history, &store);
// 消息里无对应原文(仅无关内容)→ 回填为空,维持占位,store 不误填
let recovered = store.backfill_from_messages(&[path.clone()], &["无关消息".to_string()]);
assert!(recovered.is_empty(), "无对应原文不应产生回填结果");
apply_namespace_views(&mut history, &misses, &store, &recovered, "conv-t");
assert_eq!(history[0].content, EVICTED_PLACEHOLDER);
assert_eq!(store.read_only(&path), None, "真淘汰不得误填 store");
}
}
+28 -11
View File
@@ -48,7 +48,7 @@ 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;
use tauri::{AppHandle, State}; use tauri::{AppHandle, Manager, State};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use df_ai::ai_tools::AiToolRegistry; use df_ai::ai_tools::AiToolRegistry;
@@ -113,16 +113,13 @@ pub(crate) const SUB_AGENT_WALL_TIMEOUT_SECS: u64 = 900;
/// 自达统一出口。 /// 自达统一出口。
pub(crate) const SUB_AGENT_TOOL_BATCH_HEADROOM_SECS: u64 = 65; pub(crate) const SUB_AGENT_TOOL_BATCH_HEADROOM_SECS: u64 = 65;
/// 子代理上下文窗口预算上限(token)。自动压缩触发阈值与 build_for_request 裁剪预算 /// 子代理会话的上下文配置(压缩/裁剪预算来源)。max_tokens **继承父会话预算**——
/// 的数据源,口径同 [`ContextConfig::max_tokens`]。默认 128_000 对齐 ContextConfig::default /// 子代理与父同模型同窗口(939③ 收口:原恒 128K,1M 窗口模型被子代理裁半是同款缺口),
/// ——接入压缩层不改动既有预算(零行为变更);值单点集中于此,后续按需分档只改本常量。 /// 父侧已按 model_context 按窗口构造,直接取单一事实来源,子侧不再自持口径。
const SUB_AGENT_CONTEXT_MAX_TOKENS: u32 = 128_000;
/// 子代理会话的上下文配置(压缩/裁剪预算来源)。仅覆写 max_tokens 为子代理常量,
/// 输出预留与安全系数沿用 ContextConfig::default,与主会话同一压缩链路口径一致。 /// 输出预留与安全系数沿用 ContextConfig::default,与主会话同一压缩链路口径一致。
fn sub_agent_context_config() -> ContextConfig { fn sub_agent_context_config(parent_max_tokens: u32) -> ContextConfig {
ContextConfig { ContextConfig {
max_tokens: SUB_AGENT_CONTEXT_MAX_TOKENS, max_tokens: parent_max_tokens,
..ContextConfig::default() ..ContextConfig::default()
} }
} }
@@ -755,10 +752,16 @@ async fn run_sub_agent_loop_inner(
// 先于 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()); session.conv_origins.insert(child_conv_id.clone(), SUB_AGENT_ORIGIN.to_string());
// 父预算先取(可变借用 conv 之前):子代理与父同模型,窗口预算单一事实来源在父侧
let parent_max_tokens = session
.conv(&parent_conv_id)
.messages
.config()
.max_tokens;
let conv = session.conv(&child_conv_id); let conv = session.conv(&child_conv_id);
// 上下文预算单点接入:子会话 ContextManager 用子代理配置(默认值与父会话一致), // 上下文预算单点接入:子会话 ContextManager 继承父预算(939③ 子代理半边),
// 供压缩触发阈值与 build_for_request 裁剪读取;新条目消息为空,直接替换无损。 // 供压缩触发阈值与 build_for_request 裁剪读取;新条目消息为空,直接替换无损。
conv.messages = ContextManager::new(sub_agent_context_config()); conv.messages = ContextManager::new(sub_agent_context_config(parent_max_tokens));
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);
conv.iteration_used = 0; conv.iteration_used = 0;
@@ -889,9 +892,23 @@ async fn run_sub_agent_loop_inner(
// ── prepare:build_for_request(预算裁剪)+ system prompt + namespace 展开 + // ── prepare:build_for_request(预算裁剪)+ system prompt + namespace 展开 +
// tool_result 视图压缩 + 占位配对断言 + 输入 token 估值(对齐主 loop prepare 段)── // tool_result 视图压缩 + 占位配对断言 + 输入 token 估值(对齐主 loop prepare 段)──
// 缓存友好上下文总开关(与主 loop 同款:KV `df-ai-cache-friendly-context`,默认开;
// 锁外读值拷贝,下方锁段无 await)。
let cache_friendly = {
let st = app_handle.state::<AppState>();
crate::commands::ai::prompt::cache_friendly_context_enabled(&st).await
};
let (messages, estimated_prompt) = { let (messages, estimated_prompt) = {
// mut:压缩决策冻结写回 per_conv(短锁段,无 await 夹杂) // mut:压缩决策冻结写回 per_conv(短锁段,无 await 夹杂)
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
// 缓存友好注入:先于下方只读借用(可变短借用与后续只读借用不冲突);
// 条目已消失则跳过,防 conv() 惰性创建孤儿状态
if session.conv_read(&child_conv_id).is_some() {
session
.conv(&child_conv_id)
.messages
.set_cache_friendly(cache_friendly);
}
let Some(conv) = session.conv_read(&child_conv_id) else { let Some(conv) = session.conv_read(&child_conv_id) else {
status = SubAgentStatus::Failed; status = SubAgentStatus::Failed;
stop_reason = "子会话条目已消失".to_string(); stop_reason = "子会话条目已消失".to_string();
+539
View File
@@ -0,0 +1,539 @@
//! run_command 超时转后台执行。
//!
//! 长命令(构建/测试)整轮阻塞 agent——`run_command` 占 >90s 工具桶的 95%。
//! 本模块把超 grace 窗口未完成的工具 future 移交 detached task:LLM 立即拿到
//! 「已转后台」非终态 tool_result 继续轮次(或自然收尾);命令跑完后结果经
//! inject_runtime_message 注入原会话,并按需 try_continue_agent_loop 续跑。
//!
//! 三条红线(勘察定):
//! - 心跳接管:execute_with_heartbeat 的心跳 task 随函数返回即 abort,前端 130s
//! 看门狗靠它续命——detached task 自持 30s 心跳并刷新 per_conv.last_heartbeat
//! (loop 存活证据,与 agentic heartbeat_loop / ai_chat_stop 兜底判活同源)。
//! - 会话占用:后台运行期会话必须维持 Generating(受理守卫判占用)。loop 收敛
//! 退出时 GeneratingGuard 的 Idle 迁移被占用闸拦截(guard.rs),最后一个
//! detached 终局时才放行复位——占用语义单点落在 guard/conv_state 层,loop
//! 主体零改动。用户停止(Stopping)与致命错误(Error)不拦截:前者终局时
//! 结果落 system 通知后放行,后者保持 Error 由用户重试流程自管。
//! - 回填禁楔:结果注入复用 inject_runtime_message(尾部追加,既有 system 锚点
//! 避让保护),不自写裸 insert——楔进 assistant(tool_calls) 头与其 tool result
//! 之间会破坏「result 紧随其头」的上游不变式直接报 400;同 tool_call_id
//! 也不得二次回填 tool_result
//! (非终态 result 已占位,重复 id 会触发 provider 400)。
//!
//! 开关:KV df-ai-run-command-background(默认关=同步路径逐字节不变)+
//! df-ai-run-command-grace-secs(默认 90,解析失败/0 按默认)。仅主会话生效:
//! 子代理有独立循环(run_sub_agent_loop)与挂钟预算,try_continue_agent_loop 是
//! 主循环恢复通道,子会话误续跑会绕开子代理全部约束,故 conv_origins 命中
//! sub_agent 一律不转后台(超时仍走原 outer_secs 腰斩)。
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use dashmap::DashMap;
use tauri::{AppHandle, Manager};
use crate::commands::ai::agentic::conv_state::ConvState;
use crate::commands::ai::agentic::helpers::insert_system_notice;
use crate::commands::ai::agentic::try_continue_agent_loop;
use crate::commands::ai::commands::chat::inject_runtime_message;
use crate::commands::ai::event_bus::emit_ai_chat_event;
use crate::commands::ai::AiChatEvent;
use crate::state::{AppState, SettingsNorm};
pub(crate) const BG_SWITCH_KEY: &str = "df-ai-run-command-background";
pub(crate) const BG_GRACE_KEY: &str = "df-ai-run-command-grace-secs";
const DEFAULT_GRACE_SECS: u64 = 90;
/// 与 sub_agent.rs SUB_AGENT_ORIGIN 同值(该 const 私有,此处按值比对 conv_origins)
const SUB_AGENT_ORIGIN: &str = "sub_agent";
// ============================================================
// 资格判定
// ============================================================
/// 后台化资格:开关开 + run_command + 主会话 → Some(grace 秒);否则 None
/// (调用方走同步原路径,行为逐字节不变)。每工具一次 KV 读(可接受成本),
/// 非 run_command 先短路不触 KV。
pub(crate) async fn background_grace_secs(
app: &AppHandle,
name: &str,
conv_id: &str,
) -> Option<u64> {
if name != "run_command" {
return None;
}
let state = app.state::<AppState>();
if !state.settings.get_bool_norm(BG_SWITCH_KEY, false).await {
return None;
}
let is_sub = {
let session = state.ai_session.lock().await;
session
.conv_origins
.get(conv_id)
.map(|o| o == SUB_AGENT_ORIGIN)
.unwrap_or(false)
};
if is_sub {
return None;
}
Some(parse_grace(state.settings.get_string_norm(BG_GRACE_KEY).await))
}
/// grace 解析:非法/0 归默认 90(0 会让命令瞬时转后台,视为配置错误不采纳)
fn parse_grace(raw: Option<String>) -> u64 {
raw.and_then(|s| s.parse::<u64>().ok())
.filter(|g| *g > 0)
.unwrap_or(DEFAULT_GRACE_SECS)
}
// ============================================================
// per-conv 后台占用登记(会话删除/停止感知的最小形态:per-conv 计数)
// ============================================================
/// in_flight = 在飞 detached 数;loop_exited = loop 已收敛退出但被占用闸拦下
/// 未复位 Idle(guard.rs 置位,终局决策消费)。
struct BgHold {
in_flight: usize,
loop_exited: bool,
}
static BG_HOLDS: OnceLock<DashMap<String, BgHold>> = OnceLock::new();
fn holds() -> &'static DashMap<String, BgHold> {
BG_HOLDS.get_or_init(DashMap::new)
}
/// 占用闸(guard.rs 调用):后台任务在飞时拦截 loop 退出的 Idle 迁移,并记录
/// 「loop 已退」供 detached 终局决策决定是否 try_continue 续跑。
pub(crate) fn hold_blocks_idle(conv_id: &str) -> bool {
let Some(mut hold) = holds().get_mut(conv_id) else {
return false;
};
if hold.in_flight == 0 {
return false;
}
hold.loop_exited = true;
true
}
/// 只读在飞判定(终局放行用,不带置位副作用)
fn hold_in_flight(conv_id: &str) -> bool {
holds().get(conv_id).map(|h| h.in_flight > 0).unwrap_or(false)
}
fn hold_acquire(conv_id: &str) {
holds()
.entry(conv_id.to_string())
.or_insert(BgHold { in_flight: 0, loop_exited: false })
.in_flight += 1;
}
/// hold 名额 RAII:正常路径显式 [`release`](Self::release)(返回是否最后一个);
/// detached task 异常 unwind 时 Drop 兜底释放,防名额泄漏致会话永久卡 Generating。
struct HoldGuard {
conv: String,
released: bool,
}
impl HoldGuard {
/// 接管 spawn_detached 已登记的名额(不重复 acquire)
fn takeover(conv_id: &str) -> Self {
Self { conv: conv_id.to_string(), released: false }
}
fn release(&mut self) -> bool {
if self.released {
return false;
}
self.released = true;
hold_release(&self.conv)
}
}
impl Drop for HoldGuard {
fn drop(&mut self) {
self.release();
}
}
/// 释放一个在飞名额,返回是否最后一个(终局复位语义只在最后一个生效)
fn hold_release(conv_id: &str) -> bool {
let last = match holds().get_mut(conv_id) {
Some(mut h) => {
h.in_flight = h.in_flight.saturating_sub(1);
h.in_flight == 0
}
None => true, // 条目缺失视为无在飞(防御,不该发生)
};
if last {
holds().remove(conv_id);
}
last
}
/// 读「loop 已退出」标记(只读不消费,须在 hold.release 前调——release 摘除条目
/// 会连带吞掉标记):true=loop 已收敛退出,最后一个名额终局时需 try_continue 续跑
/// 消费注入消息;false=loop 仍在飞,活跃 loop 下一轮自然消费,续跑反而抢断当前轮。
fn peek_loop_exited(conv_id: &str) -> bool {
holds()
.get(conv_id)
.map(|h| h.loop_exited)
.unwrap_or(false)
}
// ============================================================
// grace 竞速 + 移交
// ============================================================
/// grace 窗口三路竞速结果
pub(crate) enum GraceRace<R> {
/// 窗口内完成:与同步原路径结果完全一致
Done(R),
/// 窗口超时:future 未被消费,归还调用方移交 detached task
Expired,
/// 监视判死(会话删除/审批失效):同原提前退出语义
Aborted(String),
}
/// exec 与 grace 计时、监视 future 三路 select。select 只借 `&mut *exec`,
/// 借用随 select 结束解除,超时分支把未消费的 future 原样归还不违反所有权
/// (这正是「移交 detached」的机制基础:future 在竞速失败后仍可独立驱动)。
pub(crate) async fn race_grace<R, W>(
exec: Pin<Box<dyn Future<Output = R> + Send>>,
grace: Duration,
watch: W,
) -> (GraceRace<R>, Pin<Box<dyn Future<Output = R> + Send>>)
where
W: Future<Output = String>,
{
let mut exec = exec;
let raced = tokio::select! {
r = exec.as_mut() => GraceRace::Done(r),
_ = tokio::time::sleep(grace) => GraceRace::Expired,
reason = watch => GraceRace::Aborted(reason),
};
(raced, exec)
}
/// 非终态 tool_result:LLM 立即拿到明确「已转后台」信号,可继续轮或自然收尾;
/// 完成后结果自动回传本会话(ReAct 语义变更,开关默认关保回退)。
pub(crate) fn background_payload(grace: u64) -> serde_json::Value {
serde_json::json!({
"status": "background",
"message": format!("已转后台执行(超 {grace}s),完成后结果自动回传本会话"),
})
}
/// grace 超时移交:登记占用(先于返回,guard 占用闸随即生效)→ spawn detached。
/// **必须是普通同步函数(内部无 await)**:调用方(execute_with_heartbeat)以普通调用
/// 而非 await 进入,background_run 的 coroutine 不进调用方协程的 Send 验证链——否则
/// background_run→try_continue→run_agentic_loop→execute_with_heartbeat 构成自动 trait
/// 验证环(rustc E0391)。
pub(crate) fn spawn_detached(
exec: Pin<Box<dyn Future<Output = anyhow::Result<serde_json::Value>> + Send>>,
name: String,
args_for_memory: serde_json::Value,
app: AppHandle,
conv_id: String,
tool_call_id: String,
last_heartbeat: Option<Arc<AtomicU64>>,
) {
// F2 存活心跳证据源:detached 续写 per_conv.last_heartbeat(ai_chat_stop 兜底判活同源);
// conv 已删时给孤儿 Arc(store 无消费者,仅保类型)
let hb = last_heartbeat.unwrap_or_else(|| Arc::new(AtomicU64::new(0)));
hold_acquire(&conv_id);
tokio::spawn(background_run(exec, name, args_for_memory, app, conv_id, tool_call_id, hb));
}
/// detached task 本体:自持心跳 + 会话删除自中止 + 失败记忆 + 完成通知 + 回填续跑。
/// 后台失败同样有终局(注入失败消息),不静默蒸发。
async fn background_run(
mut exec: Pin<Box<dyn Future<Output = anyhow::Result<serde_json::Value>> + Send>>,
name: String,
args_for_memory: serde_json::Value,
app: AppHandle,
conv_id: String,
tool_call_id: String,
last_heartbeat: Arc<AtomicU64>,
) {
// 接管 spawn_detached 已登记的名额:panic unwind 时 Drop 兜底释放,防占用泄漏
let mut hold = HoldGuard::takeover(&conv_id);
// 自持心跳:接管随 execute_with_heartbeat 返回被 abort 的原心跳,30s emit +
// 刷 last_heartbeat,后台运行期前端看门狗持续续命
let hb_stop = Arc::new(AtomicBool::new(false));
let heartbeat = {
let app = app.clone();
let conv = conv_id.clone();
let stop = hb_stop.clone();
let hb_arc = last_heartbeat.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(30));
interval.tick().await; // 弃首 tick(对齐原心跳,首 tick 立即返回)
loop {
interval.tick().await;
if stop.load(Ordering::SeqCst) {
break;
}
hb_arc.store(df_types::now_millis() as u64, Ordering::SeqCst);
emit_ai_chat_event(&app, AiChatEvent::AiHeartbeat { conversation_id: Some(conv.clone()) });
}
})
};
// 会话删除自中止(对齐 execute_with_heartbeat 提前退出监视):执行中删除会话,
// 后台命令最多再跑 2s 即中止,不再空转到命令自身超时。审批失效监视不武装
// (detached 诞生于执行期,pending_approvals 必无此 id,按字面监视为死分支)
let watch_app = app.clone();
let watch_conv = conv_id.clone();
let deleted_watch = async move {
loop {
tokio::time::sleep(Duration::from_secs(2)).await;
let state = watch_app.state::<AppState>();
let session = state.ai_session.lock().await;
if session.deleted_convs.contains(&watch_conv) {
return format!("会话 {} 已删除", watch_conv);
}
}
};
let outcome = tokio::select! {
r = exec.as_mut() => Ok(r),
reason = deleted_watch => Err(reason),
};
hb_stop.store(true, Ordering::SeqCst);
heartbeat.abort();
// 失败记忆(与同步路径 postlude 同源):成功清零 / 失败累加,后台失败也进
// 断路统计,防 LLM 拿到失败后原样重调
let (ok, result_text) = match outcome {
Ok(Ok(v)) => {
crate::commands::ai::tool_failure_memory::record(&name, &args_for_memory, true, None);
(true, v.to_string())
}
Ok(Err(e)) => {
let msg = e.to_string();
crate::commands::ai::tool_failure_memory::record(&name, &args_for_memory, false, Some(msg.clone()));
(false, format!("工具 {} 后台执行失败: {}", name, msg))
}
Err(reason) => {
crate::commands::ai::tool_failure_memory::record(&name, &args_for_memory, false, Some(reason.clone()));
(false, format!("工具 {} 后台执行中止: {}", name, reason))
}
};
// 完成通知:emit Completed 让前端工具卡从「已转后台」收敛为真实结果
emit_ai_chat_event(&app, AiChatEvent::AiToolCallCompleted {
id: tool_call_id.clone(),
result: serde_json::Value::String(result_text.clone()),
conversation_id: Some(conv_id.clone()),
});
let note = if ok {
format!("后台命令「{name}」已执行完成,结果如下:\n{result_text}")
} else {
format!("后台命令「{name}」执行失败,详情如下:\n{result_text}")
};
let state = app.state::<AppState>();
// 先注入后释放名额:占用期 is_active 恒真(Generating),注入守卫全场景可过;
// 先释放会在「名额清零且 loop 已退」窗口露出非活跃态,注入误走降级分支
match inject_runtime_message(&state, &conv_id, &note).await {
Ok(true) => {
// 先读「loop 已退」再释放名额(release 摘除条目会吞标记);多名额时
// 标记保持 true,由最后一个名额的终局决策消费(was_last 门控防重复续跑)
let loop_gone = peek_loop_exited(&conv_id);
let was_last = hold.release();
if was_last && loop_gone {
// loop 已收敛退出:正典续跑通道消费注入消息(iteration 累计透传,
// 对齐 ai_approve 续跑口径,防反复续跑跑满轮次预算)
let start_iter = {
let session = state.ai_session.lock().await;
session.conv_read(&conv_id).map(|c| c.iteration_used).unwrap_or(0)
};
// Box 擦除内层 coroutine 类型:background_run → try_continue → run_agentic_loop
// → execute_with_heartbeat → spawn(background_run) 构成 Send 自动 trait 验证环,
// trait object 在此边截断(行为不变,仅类型层面)。
let continued = Box::pin(try_continue_agent_loop(&app, &state, &conv_id, start_iter))
as Pin<Box<dyn Future<Output = ()> + Send>>;
continued.await;
}
// 其余情况:loop 仍在飞/名额未尽,活跃 loop 下轮自然消费注入消息
}
Ok(false) => {
// conv 不存在(已删/未建):仅释放名额,状态随删除链路清理
hold.release();
}
Err(_) => {
// 非活跃(用户停止 Stopping / 致命错误 Error):结果落 system 通知留档
// (下轮打开会话可见),最后一个名额终局时放行占用复位
hold.release();
insert_system_notice(
&state.ai_session.clone(), &conv_id, &note,
"后台命令完成回填(会话非活跃,注入通道已关)",
).await;
if !hold_in_flight(&conv_id) {
release_occupancy(&app, &conv_id).await;
}
}
}
}
/// 终局放行:Generating/Stopping → Idle + 状态事件(对齐 guard.transition 的
/// emit + 托盘同步);Error 保持原态(用户重试流程自管,结果已落 system 通知)。
async fn release_occupancy(app: &AppHandle, conv_id: &str) {
let state = app.state::<AppState>();
let cur = state.conv_states.get(conv_id);
if !matches!(cur, ConvState::Generating | ConvState::Stopping) {
return;
}
if let Ok(next) = state.conv_states.transition(conv_id, ConvState::Idle) {
emit_ai_chat_event(app, AiChatEvent::AiConvStateChanged {
conv_state: next,
conversation_id: Some(conv_id.to_string()),
});
crate::tray::update_tray_runtime(app);
}
}
// ============================================================
// 单测(纯逻辑部分;execute_with_heartbeat 集成路径依赖 AppHandle 无测点,
// 开关关=同步路径逐字节不变,由存量测试全绿作回归证明)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
// ── 非终态 tool_result 话术(ReAct 语义变更的核心载体,锁定文案) ──
#[test]
fn payload_states_background_and_promises_callback() {
let p = background_payload(90);
assert_eq!(p["status"], "background");
let msg = p["message"].as_str().unwrap();
assert!(msg.contains("已转后台执行(超 90s)"), "实际: {msg}");
assert!(msg.contains("完成后结果自动回传本会话"), "实际: {msg}");
}
#[test]
fn payload_carries_configured_grace() {
let p = background_payload(30);
assert!(p["message"].as_str().unwrap().contains("(超 30s)"));
}
// ── grace 配置解析(默认 90 / 非法与 0 归默认 / 合法值采纳) ──
#[test]
fn grace_defaults_when_missing_or_invalid() {
assert_eq!(parse_grace(None), 90);
assert_eq!(parse_grace(Some("abc".to_string())), 90);
assert_eq!(parse_grace(Some("0".to_string())), 90, "0 视为配置错误归默认");
assert_eq!(parse_grace(Some("-5".to_string())), 90);
}
#[test]
fn grace_accepts_valid_value() {
assert_eq!(parse_grace(Some("30".to_string())), 30);
assert_eq!(parse_grace(Some("600".to_string())), 600);
}
// ── per-conv 占用登记(会话占用红线的注册表语义) ──
// 静态表进程级共享:各测用例用独立 conv key + 结束清理,防并行互踩
#[test]
fn hold_blocks_idle_only_while_in_flight() {
let key = "test-hold-basic-1";
assert!(!hold_blocks_idle(key), "未登记不拦截");
hold_acquire(key);
assert!(hold_blocks_idle(key), "在飞时拦截 Idle 迁移");
assert!(hold_release(key), "单名额释放即最后");
assert!(!hold_in_flight(key), "释放后无在飞");
assert!(!hold_blocks_idle(key), "释放后不再拦截");
holds().remove(key);
}
#[test]
fn hold_release_reports_last_only() {
let key = "test-hold-last-2";
hold_acquire(key);
hold_acquire(key);
assert!(!hold_release(key), "仍有 1 个在飞,非最后");
assert!(hold_release(key), "最后一个释放返 true");
assert!(!hold_in_flight(key));
}
#[test]
fn loop_exited_set_on_block_and_survives_until_release() {
let key = "test-hold-exit-3";
hold_acquire(key);
assert!(!peek_loop_exited(key), "未拦截前置位应为 false");
// loop 收敛退出经 guard 占用闸 → loop_exited 置位
assert!(hold_blocks_idle(key));
// 只读不消费:终局决策在 release 前读,多名额时标记保持供最后一个消费
assert!(peek_loop_exited(key));
assert!(peek_loop_exited(key), "peek 不消费,重复读恒 true");
holds().remove(key);
}
// ── grace 竞速(机制核心:窗口内完成=原路径结果一致;超时=future 可移交) ──
#[tokio::test]
async fn race_done_within_window_matches_direct_result() {
let exec = Box::pin(async {
Ok::<serde_json::Value, anyhow::Error>(serde_json::json!({"exit": 0, "out": "done"}))
});
let (raced, _fut) = race_grace(
exec,
Duration::from_secs(5),
std::future::pending::<String>(),
).await;
match raced {
GraceRace::Done(Ok(v)) => assert_eq!(v, serde_json::json!({"exit": 0, "out": "done"})),
other => panic!("grace 内完成应返 Done 且与同步结果一致,实际 {:?}", result_tag(&other)),
}
}
#[tokio::test]
async fn race_expired_hands_future_to_detached() {
let exec = Box::pin(async {
tokio::time::sleep(Duration::from_millis(200)).await;
Ok::<serde_json::Value, anyhow::Error>(serde_json::json!({"late": true}))
});
let (raced, mut fut) = race_grace(
exec,
Duration::from_millis(30),
std::future::pending::<String>(),
).await;
assert!(matches!(raced, GraceRace::Expired), "超时应返 Expired");
// 移交语义:future 未被消费,spawn 后仍跑到终局(detached 启动的机制本质)
let handle = tokio::spawn(async move { fut.as_mut().await });
let v = tokio::time::timeout(Duration::from_secs(10), handle).await
.expect("detached 续跑应在时限内完成").unwrap().unwrap();
assert_eq!(v, serde_json::json!({"late": true}));
}
#[tokio::test]
async fn race_watch_abort_wins_over_pending_exec() {
let exec = Box::pin(std::future::pending::<Result<serde_json::Value, anyhow::Error>>());
let (raced, _fut) = race_grace(
exec,
Duration::from_secs(60),
std::future::ready("会话 1 已删除".to_string()),
).await;
match raced {
GraceRace::Aborted(reason) => assert!(reason.contains("已删除")),
other => panic!("监视判死应返 Aborted,实际 {:?}", result_tag(&other)),
}
}
/// 测试辅助:竞速结果打标签(GraceRace 未要求 Debug, panic 消息用)
fn result_tag<R>(r: &GraceRace<R>) -> &'static str {
match r {
GraceRace::Done(_) => "Done",
GraceRace::Expired => "Expired",
GraceRace::Aborted(_) => "Aborted",
}
}
}
+291 -40
View File
@@ -24,6 +24,9 @@ use crate::commands::ai::is_destructive_command;
mod utils; mod utils;
pub(crate) use utils::{risk_from_str, risk_str, truncate_chars_ellipsis}; pub(crate) use utils::{risk_from_str, risk_str, truncate_chars_ellipsis};
// bg_run(audit/bg_run.rs):run_command 超时转后台(KV 默认关)。
pub(crate) mod bg_run;
// diff(audit/diff.rs):write_file 审批预览 diff 生成。 // diff(audit/diff.rs):write_file 审批预览 diff 生成。
// 第五批从本文件抽离,行为零变更。 // 第五批从本文件抽离,行为零变更。
mod diff; mod diff;
@@ -225,6 +228,76 @@ fn sub_agent_tool_clamped(allowed: Option<&[String]>, tool_name: &str) -> bool {
} }
} }
// B5d 工具连败升级(快速放弃+换路引导)。三级全部复用既有失败记忆(tool_failure_memory)
// 的连败计数,不另造计数轨道:首败=裸错误原样;第 2 次失败=错误包附软提示(勿原样重试);
// 第 3 次起=入口拦截包升级为强制换路通牒(换工具/查知识库/向用户说明)。拦截在本会话
// 连续达 PARK_AFTER_BLOCKS 且期间无任何工具成功 → take_tool_fail_park 请求 loop 层停靠
// (2026-10-12 实证:拦截后模型仍原样重试 10+ 次,软提示拦不住,须有终局)。
/// 第 2 次失败附带的软提示:提醒而非禁止,给模型自行调参换路的机会。
const HINT_AVOID_RETRY: &str = "已连续失败 2 次,勿盲目重试同命令;考虑调整参数或换路径";
/// 第 3 次起拦截包附带的强制换路提示(终局通牒,含知识库检索引导)。
const HINT_MUST_SWITCH: &str = "连续失败≥3次,禁止重试同命令,必须:①换工具或换路径 ②调 knowledge_search 查既有经验 ③仍无法绕过则向用户说明并停手";
/// 同会话失败记忆拦截连续达此值(且期间无任何工具成功)→ 请求 loop 层强制停靠。
const PARK_AFTER_BLOCKS: u32 = 5;
/// 同会话失败记忆拦截连击计数:键=conv_id,值=自该会话最近一次成功执行以来的拦截次数。
/// 与失败记忆分开记:拦截不触发工具执行,失败记忆的连败数停在阈值不再增长,连击数
/// 只能另记;按会话而非按调用键记,参数抖动绕开调用键时连击依旧成立。
static BLOCK_STREAKS: std::sync::OnceLock<std::sync::Mutex<HashMap<String, u32>>> =
std::sync::OnceLock::new();
fn bump_block_streak(conv_id: &str) -> u32 {
let streaks = BLOCK_STREAKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
let mut m = streaks.lock().unwrap_or_else(|p| p.into_inner());
let n = m.entry(conv_id.to_string()).or_insert(0);
*n += 1;
*n
}
/// 任一工具成功即打断连击(会话有产出,不再是纯挣扎状态)。
fn clear_block_streak(conv_id: &str) {
if let Some(m) = BLOCK_STREAKS.get() {
m.lock().unwrap_or_else(|p| p.into_inner()).remove(conv_id);
}
}
/// loop 层消费停靠请求:连击达 PARK_AFTER_BLOCKS 且未被成功清零 → 取走标志返 true。
pub(crate) fn take_tool_fail_park(conv_id: &str) -> bool {
let Some(m) = BLOCK_STREAKS.get() else { return false };
let mut map = m.lock().unwrap_or_else(|p| p.into_inner());
match map.get(conv_id) {
Some(&n) if n >= PARK_AFTER_BLOCKS => {
map.remove(conv_id);
true
}
_ => false,
}
}
/// 第 2 次失败的错误包附软提示;首败原样透传。
fn with_streak_hint(err: anyhow::Error, streak_hit: bool) -> anyhow::Error {
if streak_hit {
anyhow::anyhow!("{err}\n{HINT_AVOID_RETRY}")
} else {
err
}
}
/// 拦截包升级:hint 字段换为强制换路通牒,status/tool/consecutive_failures/last_error
/// 保留不丢。提示文本跨次恒定(不带递增计数):L1 断路器按错误文本哈希归一 key,
/// 变化文本会打散其连败累计;连击数走 tracing 日志与停靠判定,不进模型可见包。
fn upgrade_blocked_hint(blocked: &str) -> String {
let mut v: serde_json::Value = serde_json::from_str(blocked)
.unwrap_or_else(|_| serde_json::json!({ "status": "blocked_by_failure_memory" }));
if let Some(obj) = v.as_object_mut() {
obj.insert("hint".into(), serde_json::json!(HINT_MUST_SWITCH));
}
v.to_string()
}
/// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控 /// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控
/// 返回待审批的工具数量(0 = 全部自动执行完成) /// 返回待审批的工具数量(0 = 全部自动执行完成)
/// ///
@@ -243,8 +316,11 @@ fn sub_agent_tool_clamped(allowed: Option<&[String]>, tool_name: &str) -> bool {
/// 其 heartbeat_loop 随作用域退出,慢工具(大文件 patch / spawn_agent / run_command 长构建) /// 其 heartbeat_loop 随作用域退出,慢工具(大文件 patch / spawn_agent / run_command 长构建)
/// 整窗无心跳 → 前端看门狗误杀。复用同时获得 run_command 实时流式 sink、spawn_agent 父会话 /// 整窗无心跳 → 前端看门狗误杀。复用同时获得 run_command 实时流式 sink、spawn_agent 父会话
/// task-local 注入与 outer_secs 分档超时(spawn_agent=挂钟预算+60 / run_command=timeout_secs clamp)。 /// task-local 注入与 outer_secs 分档超时(spawn_agent=挂钟预算+60 / run_command=timeout_secs clamp)。
/// `tools` 形参为 `&Arc<AiToolRegistry>`:后台化移交需把工具 future 移入
/// detached task('static),调用点本就持有 Arc(process_tool_calls 三处 clone 局部 +
/// chat.rs 审批/授权两窗口 `&state.ai_tools`),签名升级对调用方零改动。
pub(crate) async fn execute_with_heartbeat( pub(crate) async fn execute_with_heartbeat(
tools: &AiToolRegistry, tools: &Arc<AiToolRegistry>,
name: &str, name: &str,
args: serde_json::Value, args: serde_json::Value,
app: &AppHandle, app: &AppHandle,
@@ -255,17 +331,20 @@ pub(crate) async fn execute_with_heartbeat(
use tokio::time::Duration; use tokio::time::Duration;
// ── B5d 工具失败记忆:执行前拦截「同 (conv,tool,args) 连败 ≥2 次」的重复调用 ── // ── B5d 工具失败记忆:执行前拦截「同 (conv,tool,args) 连败 ≥2 次」的重复调用 ──
// 治「404 连环十几次无退避换路」:拦截时回填结构化换路提示(改参数/换工具/报告放弃), // 治「404 连环十几次无退避换路」:拦截时回填结构化换路提示(强制换路通牒,升级版),
// 引导 LLM 换路而非原样重试。成功即清零(record 侧),防误杀。 // 引导 LLM 换路而非原样重试。成功即清零(record 侧),防误杀。
// 拦截直接返 Err(错误 tool_result),不执行工具——process_tool_calls 侧按既有错误路径处理。 // 拦截直接返 Err(错误 tool_result),不执行工具——process_tool_calls 侧按既有错误路径处理。
// 拦截同时累加会话连击,达 PARK_AFTER_BLOCKS 由 loop 层(process_round_tools)停靠收场。
if let Some(blocked) = super::tool_failure_memory::precheck(name, &args) { if let Some(blocked) = super::tool_failure_memory::precheck(name, &args) {
let blocks = bump_block_streak(conv_id);
tracing::warn!( tracing::warn!(
conv_id = %conv_id, conv_id = %conv_id,
tool = %name, tool = %name,
tool_call_id = %tool_call_id, tool_call_id = %tool_call_id,
"[failure-memory] 同调用连败达标,执行前拦截" blocks,
"[failure-memory] 同调用连败达标,执行前拦截(本会话累计第 {blocks} 次)"
); );
return Err(anyhow::anyhow!(blocked)); return Err(anyhow::anyhow!(upgrade_blocked_hint(&blocked)));
} }
// record 侧需 args 计算 memory_key,但下方 tools.execute(name, args) 会 move args。 // record 侧需 args 计算 memory_key,但下方 tools.execute(name, args) 会 move args。
// 此处留一份 clone 供执行后 record 使用(仅工具执行路径,非热循环,开销可接受)。 // 此处留一份 clone 供执行后 record 使用(仅工具执行路径,非热循环,开销可接受)。
@@ -358,6 +437,92 @@ pub(crate) async fn execute_with_heartbeat(
} else { } else {
60 60
}; };
// ── 后台化资格(run_command 专属,KV 默认关):None=同步原路径 ──
// 资格判定含主会话排除(子代理独立循环不转后台),详见 bg_run 模块文档。
// 前置于 exec_fut 构造:后台路径须以 Arc 克隆 + name 所有权重建工具 future
// (原构造借用 &tools/&name,非 'static,无法移交 detached task)。
let bg_grace = bg_run::background_grace_secs(app, name, conv_id).await;
// ── 提前退出监视(防空转):会话已删 / 审批窗口失效时中止工具执行 ──
// 进入快照:该 tool_call_id 是否仍在 pending_approvals。现网 4 个调用点(chat.rs ai_approve /
// ai_authorize_dir 两个审批执行窗口均「先 remove 后 execute」,本文件 low/trust 两条自动执行
// 路径从未 insert),进入时 pending 必无此 id——若按字面「不在 pending 即退出」实现,会把
// 所有已批准执行立即腰斩。故审批移除监视仅在「进入时仍在 pending」的调用形态下武装
// (现网不触发,为未来调用形态兜底);会话已删监视对所有调用点生效:执行中删除会话,
// 长工具最多再跑 2s 即中止,不再空转到超时上限。
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 exec_result = if let Some(grace) = bg_grace {
// ── 后台路径:grace 窗口三路 select(窗口内完成 / 超时移交 / 监视判死)──
// owned 'static future:Arc 克隆 + name/args 所有权重建(execute 语义不变:
// 查表 + handler 调用);command_stream sink 注入保留——后台期实时流式仍进原工具卡。
// 窗口内完成与同步路径同语义(含监视);超时把未消费 future 移交 detached task,
// 本函数立即返非终态 tool_result(「已转后台」),loop 与前端不再被该命令阻塞。
// outer_secs 在此被 grace 取代:后台期命令受工具内部 timeout_secs 约束,不再需要
// 外层硬腰斩(移交后外层腰斩语义无从谈起);监视判死仍生效(会话删除即中止)。
let tools_owned = tools.clone();
let name_owned = name.to_string();
let args_owned = args.clone();
let sink = crate::commands::ai::command_stream::CommandSink::new(
app.clone(),
tool_call_id.to_string(),
Some(conv_id.to_string()),
);
let exec = Box::pin(crate::commands::ai::tools::spawn_agent::scope_caller_conv(
conv_id.to_string(),
crate::commands::ai::command_stream::scope(
Some(sink),
async move { tools_owned.execute(&name_owned, args_owned).await },
),
));
let watch = abort_when_invalid(app.clone(), conv_id.to_string(), tc_watch, was_pending_at_entry);
let (raced, exec) =
bg_run::race_grace(exec, Duration::from_secs(grace), watch).await;
match raced {
bg_run::GraceRace::Done(r) => r,
bg_run::GraceRace::Expired => {
tracing::info!(
conv_id = %conv_id,
tool = %name,
tool_call_id = %tool_call_id,
grace,
"[ai] run_command 超 grace 窗口,移交后台执行(LLM 收非终态 tool_result)"
);
// 存活心跳证据源:detached 续写 per_conv.last_heartbeat(ai_chat_stop
// 兜底判活同源);conv 已删传 None(bg_run 给孤儿 Arc 仅保类型)
let detached_hb = {
let st = app.state::<crate::state::AppState>();
let session = st.ai_session.lock().await;
session.conv_read(conv_id).map(|c| c.last_heartbeat.clone())
};
bg_run::spawn_detached(
exec,
name.to_string(),
args_for_memory,
app.clone(),
conv_id.to_string(),
tool_call_id.to_string(),
detached_hb,
);
return Ok(bg_run::background_payload(grace));
}
bg_run::GraceRace::Aborted(reason) => {
tracing::warn!(
conv_id = %conv_id,
tool = %name,
tool_call_id = %tool_call_id,
"[ai] execute_with_heartbeat 提前退出: {reason}(防空转)"
);
Err(anyhow::anyhow!("工具 {} 提前中止: {}", name, reason))
}
}
} else {
// ── 同步原路径(构造段 + select 与历史版本逐字一致)──
// run_command 实时流式:经 task-local sink 注入(AppHandle + tool_call_id + conv_id), // run_command 实时流式:经 task-local sink 注入(AppHandle + tool_call_id + conv_id),
// handler 内 execute_streaming 每行 emit AiCommandOutput(治执行中黑盒)。 // handler 内 execute_streaming 每行 emit AiCommandOutput(治执行中黑盒)。
// 非 run_command 不注入 sink(handler 内 emit_output 静默 noop),零行为变更。 // 非 run_command 不注入 sink(handler 内 emit_output 静默 noop),零行为变更。
@@ -394,37 +559,7 @@ pub(crate) async fn execute_with_heartbeat(
conv_id.to_string(), conv_id.to_string(),
exec_fut, exec_fut,
); );
let abort_watch = abort_when_invalid(app.clone(), conv_id.to_string(), tc_watch, was_pending_at_entry);
// ── 提前退出监视(防空转):会话已删 / 审批窗口失效时中止工具执行 ──
// 进入快照:该 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); tokio::pin!(exec_fut);
// 三路 select:exec 正常完成 / outer_secs 兜底超时(既有语义,drop exec 腰斩) / // 三路 select:exec 正常完成 / outer_secs 兜底超时(既有语义,drop exec 腰斩) /
// 监视判死提前退出(同样 drop exec,防审批被删或会话已删后空转到超时上限)。 // 监视判死提前退出(同样 drop exec,防审批被删或会话已删后空转到超时上限)。
@@ -451,18 +586,60 @@ pub(crate) async fn execute_with_heartbeat(
Err(anyhow::anyhow!("工具 {} 提前中止: {}", name, reason)) Err(anyhow::anyhow!("工具 {} 提前中止: {}", name, reason))
} }
}; };
exec_result
};
// ── B5d 工具失败记忆:执行后回写(成功清零 / 失败递增并记最近错误) ── // ── B5d 工具失败记忆:执行后回写 + 连败升级提示 ──
// 与入口 precheck 配对:成功即删记忆(知识保鲜,防误杀);失败累加计数, // 与入口 precheck 配对:成功即删记忆(知识保鲜,防误杀)并打断拦截连击;失败累加计数,
// 达阈值后下次 precheck 拦截。error 摘要取 Err 文案(超时/提前退出/工具自身错误同源)。 // 达阈值后下次 precheck 拦截。error 摘要取 Err 文案(超时/提前退出/工具自身错误同源)。
match &exec_result { // 失败侧回探 precheck:命中即本次为第 2 次失败,错误包附软提示(第 3 次起由入口拦截包接管)。
Ok(_) => super::tool_failure_memory::record(name, &args_for_memory, true, None), let exec_result = match exec_result {
Err(e) => super::tool_failure_memory::record(name, &args_for_memory, false, Some(e.to_string())), Ok(v) => {
super::tool_failure_memory::record(name, &args_for_memory, true, None);
clear_block_streak(conv_id);
Ok(v)
} }
Err(e) => {
super::tool_failure_memory::record(name, &args_for_memory, false, Some(e.to_string()));
let streak_hit = super::tool_failure_memory::precheck(name, &args_for_memory).is_some();
Err(with_streak_hint(e, streak_hit))
}
};
exec_result exec_result
} }
/// 提前退出监视(防空转):会话已删 / 审批窗口失效时中止工具执行。
/// 工厂形态:同步路径与后台 grace 路径各消费一个实例(同一快照语义)。
///
/// 进入快照:该 tool_call_id 是否仍在 pending_approvals。现网 4 个调用点(chat.rs ai_approve /
/// ai_authorize_dir 两个审批执行窗口均「先 remove 后 execute」,本文件 low/trust 两条自动执行
/// 路径从未 insert),进入时 pending 必无此 id——若按字面「不在 pending 即退出」实现,会把
/// 所有已批准执行立即腰斩。故审批移除监视仅在「进入时仍在 pending」的调用形态下武装
/// (现网不触发,为未来调用形态兜底);会话已删监视对所有调用点生效:执行中删除会话,
/// 长工具最多再跑 2s 即中止,不再空转到超时上限。
fn abort_when_invalid(
app: AppHandle,
conv_id: String,
tool_call_id: String,
was_pending_at_entry: bool,
) -> impl std::future::Future<Output = String> {
async move {
loop {
// 2s 轮询:短锁纯读(锁不跨 await 持有);中止延迟 ≤2s 与锁竞争频率的权衡
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let st = app.state::<crate::state::AppState>();
let session = st.ai_session.lock().await;
if session.deleted_convs.contains(&conv_id) {
return format!("会话 {} 已删除", conv_id);
}
if was_pending_at_entry && !session.pending_approvals.contains_key(&tool_call_id) {
return format!("审批 {} 已不在挂起表", tool_call_id);
}
}
}
}
// ============================================================ // ============================================================
// 审批超时主动收敛(定时器) — 治「审批过期后用户不操作则永挂」 // 审批超时主动收敛(定时器) — 治「审批过期后用户不操作则永挂」
// ============================================================ // ============================================================
@@ -1631,4 +1808,78 @@ mod tests {
let (reason_0, _) = approval_timeout_park_parts(0); let (reason_0, _) = approval_timeout_park_parts(0);
assert!(reason_0.contains("0"), "任意分钟数可格式化(0=禁用超时不会到点,防御)"); assert!(reason_0.contains("0"), "任意分钟数可格式化(0=禁用超时不会到点,防御)");
} }
/// B5d 连败升级三级:首败裸错误 / 第 2 次失败附软提示 / 第 3 次起拦截包升级强制换路
/// 通牒(含 knowledge_search 引导);成功一次即清零,再败回到首败无提示。
/// 走真失败记忆(进程级静态),键取本测试专属路径防并行互踩。
#[test]
fn failure_streak_hint_escalates_three_levels() {
use crate::commands::ai::tool_failure_memory::{precheck, record};
let args = serde_json::json!({ "path": "/tmp/b5d-escalation-check.txt" });
// 首败:未达拦截阈值,无提示
record("read_file", &args, false, Some("not found".into()));
assert!(!precheck("read_file", &args).is_some(), "首败不拦截");
// 第 2 次失败:记录后回探预检命中,错误包附软提示
record("read_file", &args, false, Some("not found".into()));
let streak_hit = precheck("read_file", &args).is_some();
assert!(streak_hit, "两连败后回探应命中");
let hinted = with_streak_hint(anyhow::anyhow!("工具执行失败"), streak_hit).to_string();
assert!(hinted.contains(HINT_AVOID_RETRY), "第 2 次失败附软提示: {hinted}");
assert!(hinted.contains("工具执行失败"), "原始错误保留: {hinted}");
// 第 3 次起:拦截包升级为强制换路通牒,状态字段保留,文本跨次恒定(L1 断路器
// 按错误文本哈希归 key,变化文本会打散其累计)
let blocked = precheck("read_file", &args).unwrap();
let upgraded = upgrade_blocked_hint(&blocked);
let upgraded_again = upgrade_blocked_hint(&blocked);
assert!(upgraded.contains("blocked_by_failure_memory"), "状态字段保留: {upgraded}");
assert!(upgraded.contains(HINT_MUST_SWITCH), "拦截包附强制换路通牒: {upgraded}");
assert!(upgraded.contains("knowledge_search"), "通牒含知识库检索引导: {upgraded}");
assert_eq!(upgraded, upgraded_again, "升级文本跨次恒定");
// 成功一次即清零:再败回到首败,无提示
record("read_file", &args, true, None);
record("read_file", &args, false, Some("not found".into()));
assert!(!precheck("read_file", &args).is_some(), "成功清零后首败无提示");
assert!(
!with_streak_hint(anyhow::anyhow!("x"), precheck("read_file", &args).is_some())
.to_string()
.contains(HINT_AVOID_RETRY),
"首败错误包不附提示"
);
}
/// B5d 熔断终局:同会话失败记忆拦截连击达 PARK_AFTER_BLOCKS 触发停靠,取走即消费
/// (不重复触发);未达阈不触发。
#[test]
fn blocked_streak_parks_after_five() {
let conv = "conv-b5d-park-check";
clear_block_streak(conv);
for _ in 0..(PARK_AFTER_BLOCKS - 1) {
bump_block_streak(conv);
}
assert!(!take_tool_fail_park(conv), "未达阈不停靠");
bump_block_streak(conv);
assert!(take_tool_fail_park(conv), "拦截连击达 5 触发停靠");
assert!(!take_tool_fail_park(conv), "停靠取走即消费,不重复触发");
}
/// B5d 成功打断连击:达阈前任一工具成功即清零,连击从头再计(会话有产出不停靠)。
#[test]
fn tool_success_clears_block_streak() {
let conv = "conv-b5d-success-clear";
clear_block_streak(conv);
for _ in 0..(PARK_AFTER_BLOCKS - 1) {
bump_block_streak(conv);
}
clear_block_streak(conv);
assert!(!take_tool_fail_park(conv), "成功打断连击,不停靠");
for _ in 0..PARK_AFTER_BLOCKS {
bump_block_streak(conv);
}
assert!(take_tool_fail_park(conv), "清零后重新连击达阈仍停靠");
clear_block_streak(conv);
}
} }
@@ -633,6 +633,7 @@ mod tests {
boundary_json: None, boundary_json: None,
loop_state: None, loop_state: None,
status: "active".to_string(), status: "active".to_string(),
working_context_json: None,
}) })
.await .await
.expect("测试会话插入应成功"); .expect("测试会话插入应成功");
+47 -3
View File
@@ -1174,7 +1174,15 @@ pub(crate) async fn inject_runtime_message(
if !cs.is_active() { if !cs.is_active() {
return Err(format!("conv {conv_id} 非活跃态({cs:?}),不走注入")); return Err(format!("conv {conv_id} 非活跃态({cs:?}),不走注入"));
} }
let msg = ChatMessage::user(&format!("[补充指令] {message}")); let mut msg = ChatMessage::user(&format!("[补充指令] {message}"));
// 961② 轮次归属标记:注入=对当前轮的补充,不开新轮。前端轮次分桶按此判据
// (source.channel=="inject" 不递增轮号),与发起消息(桌面直发/飞书入站)区分
msg.source = Some(df_ai::provider::MessageSource {
channel: "inject".into(),
message_id: msg.id.clone().unwrap_or_default(),
chat_id: None,
sender: None,
});
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
if session.conv_read(conv_id).is_none() { if session.conv_read(conv_id).is_none() {
return Ok(false); return Ok(false);
@@ -1189,6 +1197,9 @@ pub(crate) async fn inject_runtime_message(
if extended { if extended {
conv.round_budget_extra += 1; conv.round_budget_extra += 1;
} }
// 未消化计数(2026-10-14 实测):纯问答轮一轮收敛无下一轮,末轮扩轮机制(loop_max>0 才生效)
// 兜不住;收敛出口凭此计数感知「组装后有注入到达」并续一轮消化。
conv.pending_injects = conv.pending_injects.saturating_add(1);
drop(session); drop(session);
// 落库(增量 save):与 loop 的 save 走同一 per-conv save 锁串行,幂等无交叉 // 落库(增量 save):与 loop 的 save 走同一 per-conv save 锁串行,幂等无交叉
save_conversation(&state.ai_session, &state.db, conv_id, None, None, true).await; save_conversation(&state.ai_session, &state.db, conv_id, None, None, true).await;
@@ -1196,6 +1207,20 @@ pub(crate) async fn inject_runtime_message(
Ok(true) Ok(true)
} }
/// 桌面端补充注入入口(桌面 AiChat 运行中发消息的 u-claw 语义:补充并进当前回答,不开新轮)。
/// 把 inject_runtime_message 暴露为 Tauri command,与飞书渠道(feishu_channel.rs)同源同一实现,
/// 锁纪律/末轮顺延语义收敛在 inject_runtime_message 单一真值,本 command 纯透传零附加逻辑。
/// 返回契约:Ok(true)=已注入(前端不入队,提示已并入当前回答);Ok(false)=会话不存在
/// (前端按普通新消息入队);Err=非活跃/Stopping(前端零破坏回退常规入队)。
#[tauri::command]
pub async fn ai_inject_runtime(
state: State<'_, AppState>,
conversation_id: String,
message: String,
) -> Result<bool, String> {
inject_runtime_message(&state, &conversation_id, &message).await
}
/// 批准/拒绝挂起的工具调用 /// 批准/拒绝挂起的工具调用
#[tauri::command] #[tauri::command]
pub async fn ai_approve( pub async fn ai_approve(
@@ -1960,6 +1985,9 @@ pub async fn ai_chat_compress_context(
language: Option<String>, language: Option<String>,
) -> Result<(), String> { ) -> Result<(), String> {
let provider_config = super::super::prompt::get_active_provider(&state).await?; let provider_config = super::super::prompt::get_active_provider(&state).await?;
// 压缩可召回开关(KV `df-ai-compress-recall`,默认开):开则压缩标记时原文落盘存档
// + 摘要尾部拼召回句柄;关零行为变更。
let recall_on = super::super::prompt::compress_recall_enabled(&state).await;
const PROTECT_COUNT: usize = 6; const PROTECT_COUNT: usize = 6;
let lang = language.unwrap_or_else(|| "zh-CN".to_string()); let lang = language.unwrap_or_else(|| "zh-CN".to_string());
@@ -2075,9 +2103,17 @@ pub async fn ai_chat_compress_context(
let __lock_t1295 = std::time::Instant::now(); let __lock_t1295 = std::time::Instant::now();
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
let conv = session.conv(&conv_id); let conv = session.conv(&conv_id);
if recall_on {
conv.messages.set_compress_recall(&state.data_dir, &conv_id);
}
let _compressed = conv.messages.compress_old_messages(compress_end); let _compressed = conv.messages.compress_old_messages(compress_end);
if !keyword_fallback.is_empty() { if !keyword_fallback.is_empty() {
conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback)); // 召回句柄拼在摘要尾部(存档落盘成功才有),只进上下文锚点。
let anchor_text = match conv.messages.take_archive_path() {
Some(p) => df_ai::context::archive::with_recall_line(&keyword_fallback, &p),
None => keyword_fallback.clone(),
};
conv.messages.insert_at(0, ChatMessage::system(&anchor_text));
} }
conv.messages.set_compressing(false); conv.messages.set_compressing(false);
log_slow_lock(__lock_t1295, "ai_chat_compress_context:1295"); log_slow_lock(__lock_t1295, "ai_chat_compress_context:1295");
@@ -2102,8 +2138,16 @@ pub async fn ai_chat_compress_context(
let __lock_t1312 = std::time::Instant::now(); let __lock_t1312 = std::time::Instant::now();
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
let conv = session.conv(&conv_id); let conv = session.conv(&conv_id);
if recall_on {
conv.messages.set_compress_recall(&state.data_dir, &conv_id);
}
let _compressed = conv.messages.compress_old_messages(compress_end); let _compressed = conv.messages.compress_old_messages(compress_end);
conv.messages.insert_at(0, ChatMessage::system(&summary)); // 召回句柄拼在摘要尾部(存档落盘成功才有),只进上下文锚点;前端事件仍用原摘要。
let summary_text = match conv.messages.take_archive_path() {
Some(p) => df_ai::context::archive::with_recall_line(&summary, &p),
None => summary.clone(),
};
conv.messages.insert_at(0, ChatMessage::system(&summary_text));
conv.messages.set_compressing(false); conv.messages.set_compressing(false);
log_slow_lock(__lock_t1312, "ai_chat_compress_context:1312"); log_slow_lock(__lock_t1312, "ai_chat_compress_context:1312");
} }
@@ -232,14 +232,24 @@ pub async fn ai_conversation_create(
/// ///
/// 截断按组独立:活跃/归档各自取 limit,归档项不挤占活跃名额(侧栏归档分组数据源依赖 /// 截断按组独立:活跃/归档各自取 limit,归档项不挤占活跃名额(侧栏归档分组数据源依赖
/// include_archived=true,见 useAiConversations.loadConversations)。 /// include_archived=true,见 useAiConversations.loadConversations)。
///
/// F4-1(2026-10-17)分页:active_offset/archived_offset 按组跳过前 N 条(默认 0=第一页,
/// 旧调用方零改动)。治「会话列表+搜索硬上限 50」——实测 115 条活跃主会话 57% 在桌面
/// 端零入口;侧栏首屏仍一页(50/组)保启动性能,「加载更多」按 offset 续拉,搜索前前端
/// 逐页拉满(搜索跑在已加载列表上,截断列表=搜索失灵)。offset 超组长度返回该组空页
/// (自然收敛,无需显式 has_more 字段:页内某组满额 limit 即可能还有下一页)。
#[tauri::command] #[tauri::command]
pub async fn ai_conversation_list( pub async fn ai_conversation_list(
state: State<'_, AppState>, state: State<'_, AppState>,
limit: Option<usize>, limit: Option<usize>,
include_archived: Option<bool>, include_archived: Option<bool>,
active_offset: Option<usize>,
archived_offset: Option<usize>,
) -> Result<Vec<serde_json::Value>, String> { ) -> Result<Vec<serde_json::Value>, String> {
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 active_offset = active_offset.unwrap_or(0);
let archived_offset = archived_offset.unwrap_or(0);
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)不进侧栏列表——轨迹在库可追溯,展示面只留主会话。 // origin 非空(子代理轨迹,V44)不进侧栏列表——轨迹在库可追溯,展示面只留主会话。
@@ -273,20 +283,25 @@ pub async fn ai_conversation_list(
"loop_state": r.loop_state, "loop_state": r.loop_state,
}) })
}).collect(); }).collect();
// 分组截断:活跃/归档各自独立取 limit,防归档项挤占活跃名额(前端侧栏活跃列表被截空)。 Ok(paginate_by_group(summaries, limit, active_offset, archived_offset))
// 原单 take(limit) 在 include_archived=true 时归档项混排会占满 50 名额。 }
let mut active = Vec::with_capacity(limit);
let mut archived = Vec::with_capacity(limit); /// F4-1:摘要按组独立分页——活跃/归档各自 skip(offset)+take(limit),活跃在前归档在后,
for s in summaries { /// 组内保持传入原序(created_at DESC)。offset=0 与旧「分组截断」口径逐字节等价
if s.get("archived").and_then(|v| v.as_bool()) == Some(true) { /// (回归保护见 paginate_first_page_matches_legacy_grouped_truncation)。
if archived.len() < limit { archived.push(s); } /// 抽纯函数供单测:分组正确性/offset 续拉/越界空页/归档不挤占活跃名额。
} else if active.len() < limit { fn paginate_by_group(
active.push(s); summaries: Vec<serde_json::Value>,
} limit: usize,
} active_offset: usize,
// 活跃在前(侧栏主区),归档在后(归档分组);各自保持 created_at DESC 原序。 archived_offset: usize,
active.extend(archived); ) -> Vec<serde_json::Value> {
Ok(active) let (active, archived): (Vec<_>, Vec<_>) = summaries
.into_iter()
.partition(|s| s.get("archived").and_then(|v| v.as_bool()) != Some(true));
let mut page: Vec<serde_json::Value> = active.into_iter().skip(active_offset).take(limit).collect();
page.extend(archived.into_iter().skip(archived_offset).take(limit));
page
} }
/// B3(无人值守可靠性专项):列出可恢复会话(loop_state='parked')。 /// B3(无人值守可靠性专项):列出可恢复会话(loop_state='parked')。
@@ -358,12 +373,23 @@ pub(crate) async fn recover_parked_inner(
if !state.conv_states.can_accept_request(conversation_id) { if !state.conv_states.can_accept_request(conversation_id) {
return Err("该会话正在生成中,无需恢复".to_string()); return Err("该会话正在生成中,无需恢复".to_string());
} }
let message = PARKED_RECOVERY_MESSAGE.to_string(); // 目标快照:按最终有效意图生成恢复引导消息,不重放历史目标流。
// WorkingContext 与 pinned_goals 两列语义化读取;全空回退 PARKED_RECOVERY_MESSAGE。
let working_context = working_context_from_snapshot(record.working_context_json.as_deref());
let pinned = parse_pinned_goals(record.pinned_goals.as_deref());
let message = build_recovery_message(&working_context, &pinned);
// V50 终局态复位:恢复即重新进行,清 incomplete(停靠时已标)。loop 侧收尾单点在 agentic 主循环, // V50 终局态复位:恢复即重新进行,清 incomplete(停靠时已标)。loop 侧收尾单点在 agentic 主循环,
// 此处先复位保证续作期间语义正确;续作后正常收敛/再次停靠会再各自覆写。 // 此处先复位保证续作期间语义正确;续作后正常收敛/再次停靠会再各自覆写。
if let Err(e) = state.ai_conversations.set_status(conversation_id, "active").await { if let Err(e) = state.ai_conversations.set_status(conversation_id, "active").await {
tracing::warn!(conv_id = %conversation_id, error = %e, "[ai] 恢复会话复位 status=active 失败"); tracing::warn!(conv_id = %conversation_id, error = %e, "[ai] 恢复会话复位 status=active 失败");
} }
// WorkingContext 随行恢复(V53):重启后 per_conv 若无内存态(引擎自动恢复未先 switch),
// 惰性建 per_conv 并从 DB 快照带回压缩重锚定的决策/未解项;已有内存态(switch 已还原)
// 重解同一快照幂等无害。
{
let mut session = state.ai_session.lock().await;
session.conv(conversation_id).working_context = working_context;
}
spawn_agentic_session( spawn_agentic_session(
app, app,
state, state,
@@ -380,6 +406,139 @@ pub(crate) async fn recover_parked_inner(
.await .await
} }
/// 恢复产物前缀:恢复链自身生成的引导消息以它开头(恢复产物非用户意图,任何情况下
/// 都不该作为有效目标回放——2026-10-15 目标语义重构约定)。递归套娃根因:恢复消息
/// 经 begin_turn(chat.rs:950/2461)被记为 WorkingContext Active 目标落库,下次恢复
/// 回放时被当有效目标嵌套进新恢复消息,逐层加深(实证 conv 76730ce8 seq 250:
/// 目标文本=上一条恢复消息原文截 200 字符)。防线收敛在恢复链单点:
/// parse_pinned_goals 反解剔除 + build_recovery_message 回放剔除,同判据。
pub(crate) const RECOVERY_ARTIFACT_PREFIX: &str = "[系统恢复]";
/// 恢复产物判定:目标文本以恢复前缀开头即恢复链自产物,不作为有效目标。
fn is_recovery_artifact(text: &str) -> bool {
text.starts_with(RECOVERY_ARTIFACT_PREFIX)
}
/// 注入产物判定(E5):[补充指令] 是运行中用户插话的通道标记,非任务意图本身
/// (实证 434c8c40 active goal=「[补充指令] 123 hi」占位)。与恢复产物同治:
/// 载入侧滤出内存,下轮 save 重写即自愈落库。
fn is_injection_artifact(text: &str) -> bool {
text.starts_with("[补充指令]")
}
/// 引导产物总判定(恢复+注入),双侧(载入/回放)统一用。
fn is_guide_artifact(text: &str) -> bool {
is_recovery_artifact(text) || is_injection_artifact(text)
}
/// 目标快照:按最终有效意图生成 parked 恢复引导消息。
///
/// 「按快照续不重放历史目标流」:取 WorkingContext.goals 与 pinned_goals 中仍 Active 的
/// 目标 + unresolved 未解项;中途被调整作废(Cancelled)或已完成(Completed)的目标不复活;
/// 恢复产物([系统恢复] 前缀)剔除——回放它即递归套娃,历史污染目标在此单点拦截。
/// 快照全空(旧会话无快照/全被过滤)回退 [`PARKED_RECOVERY_MESSAGE`],行为同今日。
/// 不另立存储:快照即 working_context_json + pinned_goals 两列的语义化读取(单一真值来源)。
pub(crate) fn build_recovery_message(
working_context: &df_ai::context_helpers::WorkingContext,
pinned_goals: &[crate::commands::ai::GoalEntry],
) -> String {
use df_ai::context_helpers::GoalStatus;
let mut seen = std::collections::HashSet::new();
let mut active: Vec<&str> = Vec::new();
for g in &working_context.goals {
if matches!(g.status, GoalStatus::Active)
&& !is_guide_artifact(&g.text)
&& seen.insert(g.text.clone())
{
active.push(&g.text);
}
}
for g in pinned_goals {
if matches!(g.status, crate::commands::ai::GoalStatus::Active)
&& !is_guide_artifact(&g.text)
&& seen.insert(g.text.clone())
{
active.push(&g.text);
}
}
if active.is_empty() && working_context.unresolved.is_empty() {
return PARKED_RECOVERY_MESSAGE.to_string();
}
let mut msg = String::from(
"[系统恢复] 应用重启导致上次任务中断,已完成进度已保留。请按以下目标快照继续完成未竟任务(中途已作废或已完成的目标不重放):\n",
);
if !active.is_empty() {
msg.push_str("\n有效目标:\n");
for t in &active {
msg.push_str("- ");
msg.push_str(t);
msg.push('\n');
}
}
if !working_context.unresolved.is_empty() {
msg.push_str("\n未解事项:\n");
for u in &working_context.unresolved {
msg.push_str("- ");
msg.push_str(u);
msg.push('\n');
}
}
msg.push_str("\n若快照不足以判断进度,先向用户确认已完成步骤再继续。");
msg
}
/// pinned_goals 落库反解(新旧格式兼容):新格式 [{text,status}] 保 status,
/// 旧格式 ["text"] 转 Active;非数组/坏 JSON 一律空(降级不炸)。switch 恢复与
/// parked 恢复共用(DRY,单一事实源)。恢复产物([系统恢复] 前缀)剔除——存量
/// 污染目标不进内存,switch 载入后下轮 save 重写 pinned_goals 即自愈落库。
pub(crate) fn parse_pinned_goals(raw: Option<&str>) -> Vec<crate::commands::ai::GoalEntry> {
let parsed: Option<serde_json::Value> = raw.and_then(|s| serde_json::from_str(s).ok());
match parsed {
Some(serde_json::Value::Array(arr)) => arr
.iter()
.filter_map(|v| {
if let Some(obj) = v.as_object() {
let text = obj.get("text")?.as_str()?.to_string();
let status = obj
.get("status")
.and_then(|s| s.as_str())
.map(|s| {
if s == "completed" {
crate::commands::ai::GoalStatus::Completed
} else {
crate::commands::ai::GoalStatus::Active
}
})
.unwrap_or(crate::commands::ai::GoalStatus::Active);
Some(crate::commands::ai::GoalEntry { text, status })
} else {
let text = v.as_str()?.to_string();
Some(crate::commands::ai::GoalEntry::new(text))
}
})
.filter(|e| !is_guide_artifact(&e.text))
.collect(),
_ => vec![],
}
}
/// WorkingContext 落库快照反解(V53 working_context_json,恢复侧单一入口)。
/// None/空串(老会话列 NULL=无 WorkingContext)与解析失败一律返回空 WorkingContext:
/// 行为同今日的半失忆续跑,坏数据降级不炸恢复链路。与落库侧 working_context_snapshot 成对。
pub(crate) fn working_context_from_snapshot(
json: Option<&str>,
) -> df_ai::context_helpers::WorkingContext {
json.filter(|s| !s.is_empty())
.and_then(|s| match serde_json::from_str(s) {
Ok(wc) => Some(wc),
Err(e) => {
tracing::warn!("working_context_json 反解失败(按空上下文降级): {e}");
None
}
})
.unwrap_or_else(df_ai::context_helpers::WorkingContext::new)
}
/// 切换到指定对话(从 DB 加载 messages 到内存 + 返回 messages 给前端) /// 切换到指定对话(从 DB 加载 messages 到内存 + 返回 messages 给前端)
#[tauri::command] #[tauri::command]
pub async fn ai_conversation_switch( pub async fn ai_conversation_switch(
@@ -490,35 +649,13 @@ pub async fn ai_conversation_switch(
if !mem_has_unsaved { if !mem_has_unsaved {
conv.messages.restore_from_messages(messages); conv.messages.restore_from_messages(messages);
// 从 DB 恢复 pinned_goals 到 per_conv(G1 目标钉扎持久化) // 从 DB 恢复 pinned_goals 到 per_conv(G1 目标钉扎持久化)
// 兼容旧格式:["text1","text2"] 转为 [{text, status: active}] // 新旧格式兼容解析收敛到 parse_pinned_goals(与 parked 恢复共享,单一事实源)。
let pinned_goals: Vec<crate::commands::ai::GoalEntry> = { conv.pinned_goals = parse_pinned_goals(record.pinned_goals.as_deref());
let raw: Option<serde_json::Value> = record.pinned_goals // WorkingContext 随行读载(V53 working_context_json):DB 快照还原内存真相源,
.as_deref() // 压缩重锚定的决策/未解项跨重启不断裂。与 pinned_goals 同守卫(内存有未落库
.and_then(|s| serde_json::from_str(s).ok()); // 新消息时不覆盖,DB 快照只回填干净内存)。
match raw { conv.working_context =
Some(serde_json::Value::Array(arr)) => { working_context_from_snapshot(record.working_context_json.as_deref());
arr.iter().filter_map(|v| {
// 新格式: {"text":"...","status":"active"}
if let Some(obj) = v.as_object() {
let text = obj.get("text")?.as_str()?.to_string();
let status = obj.get("status").and_then(|s| s.as_str())
.map(|s| if s == "completed" {
crate::commands::ai::GoalStatus::Completed
} else {
crate::commands::ai::GoalStatus::Active
}).unwrap_or(crate::commands::ai::GoalStatus::Active);
Some(crate::commands::ai::GoalEntry { text, status })
} else {
// 旧格式: "text" → 转 active
let text = v.as_str()?.to_string();
Some(crate::commands::ai::GoalEntry::new(text))
}
}).collect()
}
_ => vec![],
}
};
conv.pinned_goals = pinned_goals;
} }
// 无人值守任务边界随行读载(V45 boundary_json):DB 快照还原内存真相源。 // 无人值守任务边界随行读载(V45 boundary_json):DB 快照还原内存真相源。
// 仅未在生成分支执行(already_live 时内存即真相,不覆盖)。解析失败按无边界降级 // 仅未在生成分支执行(already_live 时内存即真相,不覆盖)。解析失败按无边界降级
@@ -880,6 +1017,16 @@ pub async fn ai_conversation_delete(
} }
}; };
// 跨会话召回(终态提炼删除挂点):删除动作前后台提炼一次,失败/未完成不阻塞删除
// (删除是用户显式意图,延迟属体验倒退)。挂点内同步取走内存快照(全量 active 消息
// + WorkingContext 决策),LLM 提炼在后台;收尾顺带级联清理压缩存档(先提炼后删文件)。
crate::commands::ai::knowledge_inject::spawn_terminal_extraction(
&state,
&conversation_id,
Some(state.data_dir.clone()),
)
.await;
// 单事务删 ai_messages 子行 + ai_conversations 主行(防孤儿消息)。 // 单事务删 ai_messages 子行 + ai_conversations 主行(防孤儿消息)。
// 顺序:先删数据再摘内存(下方 per_conv.remove)。 // 顺序:先删数据再摘内存(下方 per_conv.remove)。
state.ai_conversations.delete_with_messages(&conversation_id).await.map_err(err_str)?; state.ai_conversations.delete_with_messages(&conversation_id).await.map_err(err_str)?;
@@ -1379,6 +1526,73 @@ mod tests {
assert_eq!(back.timestamp, Some(123)); assert_eq!(back.timestamp, Some(123));
} }
// ============================================================
// F4-1 会话列表分页(paginate_by_group:活跃/归档各自 offset+limit)
// ============================================================
fn conv_val(id: &str, archived: bool) -> serde_json::Value {
serde_json::json!({ "id": id, "archived": archived })
}
/// 首页(offset=0)与旧「分组截断」口径等价:各组前 limit 条,活跃在前归档在后。
#[test]
fn paginate_first_page_matches_legacy_grouped_truncation() {
// 60 活跃 + 60 归档,limit 50:旧口径各取 50(归档不挤占活跃名额)
let summaries: Vec<_> = (0..60).map(|i| conv_val(&format!("a{i}"), false))
.chain((0..60).map(|i| conv_val(&format!("r{i}"), true)))
.collect();
let page = paginate_by_group(summaries, 50, 0, 0);
assert_eq!(page.len(), 100, "各组独立取满 50");
assert!(page.iter().take(50).all(|s| s["archived"] == false), "前 50 全活跃");
assert!(page.iter().skip(50).all(|s| s["archived"] == true), "后 50 全归档");
assert_eq!(page[0]["id"], "a0");
assert_eq!(page[49]["id"], "a49");
assert_eq!(page[50]["id"], "r0");
assert_eq!(page[99]["id"], "r49");
}
/// offset 续拉:第二页从各组 offset 处继续,两组独立推进互不影响
/// (115 活跃场景:第一页 a0..a49,第二页 active_offset=50 拉 a50..a99)。
#[test]
fn paginate_offset_advances_each_group_independently() {
let summaries: Vec<_> = (0..115).map(|i| conv_val(&format!("a{i}"), false))
.chain((0..3).map(|i| conv_val(&format!("r{i}"), true)))
.collect();
// 第二页:活跃 offset 50(满页 50 条),归档 offset 0(仅 3 条全回)
let page2 = paginate_by_group(summaries.clone(), 50, 50, 0);
assert_eq!(page2.len(), 53);
assert_eq!(page2[0]["id"], "a50");
assert_eq!(page2[49]["id"], "a99");
assert_eq!(page2[50]["id"], "r0", "归档组从 0 起独立分页,不被活跃 offset 影响");
// 第三页:活跃 offset 100(尾页 15 条),归档 offset 3(越界空)
let page3 = paginate_by_group(summaries, 50, 100, 3);
assert_eq!(page3.len(), 15);
assert_eq!(page3[0]["id"], "a100");
assert_eq!(page3[14]["id"], "a114");
assert!(page3.iter().all(|s| s["archived"] == false), "归档越界返回该组空页");
}
/// offset 超组长度的组返回空页(自然收敛);两组同时越界返回空 Vec。
#[test]
fn paginate_offset_beyond_length_yields_empty() {
let summaries = vec![conv_val("a0", false), conv_val("r0", true)];
assert!(paginate_by_group(summaries.clone(), 50, 5, 0).iter().all(|s| s["archived"] == true));
assert!(paginate_by_group(summaries.clone(), 50, 0, 5).iter().all(|s| s["archived"] == false));
assert!(paginate_by_group(summaries, 50, 5, 5).is_empty());
}
/// 输入混排(归档穿插在活跃间,list_all 全量 DESC 实际形态)时 partition 仍按组归位,
/// 组内保持传入原序。
#[test]
fn paginate_partition_from_interleaved_input() {
let summaries = vec![
conv_val("a0", false), conv_val("r0", true), conv_val("a1", false), conv_val("r1", true),
];
let page = paginate_by_group(summaries, 50, 0, 0);
let ids: Vec<&str> = page.iter().map(|s| s["id"].as_str().unwrap()).collect();
assert_eq!(ids, vec!["a0", "a1", "r0", "r1"], "活跃全量在前(原序),归档全量在后(原序)");
}
// ============================================================ // ============================================================
// F-260619-03 批次 B 端到端 round-trip(写路径全链:映射 → replace → list → 还原) // F-260619-03 批次 B 端到端 round-trip(写路径全链:映射 → replace → list → 还原)
// ============================================================ // ============================================================
@@ -1594,6 +1808,7 @@ mod tests {
boundary_json: None, boundary_json: None,
loop_state: None, loop_state: None,
status: "active".to_string(), status: "active".to_string(),
working_context_json: None,
}) })
.await .await
.expect("测试会话插入应成功"); .expect("测试会话插入应成功");
@@ -1625,4 +1840,151 @@ mod tests {
// 无子代理的父返空 // 无子代理的父返空
assert!(find_sub_agent_conversations(&db, "conv-none").await.unwrap().is_empty()); assert!(find_sub_agent_conversations(&db, "conv-none").await.unwrap().is_empty());
} }
// ── 目标快照恢复语义 ──
fn wc_with_goals(texts: &[(&str, df_ai::context_helpers::GoalStatus)]) -> df_ai::context_helpers::WorkingContext {
let mut wc = df_ai::context_helpers::WorkingContext::new();
wc.goals = texts
.iter()
.map(|(t, s)| df_ai::context_helpers::GoalItem {
id: format!("g-{}", t),
text: t.to_string(),
status: s.clone(),
})
.collect();
wc
}
#[test]
fn recovery_message_uses_snapshot_and_skips_dead_goals() {
use df_ai::context_helpers::GoalStatus;
// 调整后重启只认最终集:作废(Cancelled)与已完成(Completed)不出现,Active 与钉扎目标全保留
let wc = wc_with_goals(&[
("旧目标已作废", GoalStatus::Cancelled),
("交付 v2", GoalStatus::Active),
("旧目标已完成", GoalStatus::Completed),
]);
let pinned = vec![crate::commands::ai::GoalEntry::new("钉扎:每天跑测试".to_string())];
let msg = build_recovery_message(&wc, &pinned);
assert!(msg.contains("交付 v2"), "Active 目标须在快照: {msg}");
assert!(msg.contains("钉扎:每天跑测试"), "钉扎目标须在快照: {msg}");
assert!(!msg.contains("旧目标已作废"), "作废目标不复活: {msg}");
assert!(!msg.contains("旧目标已完成"), "已完成目标不重放: {msg}");
assert!(msg.contains("未解事项") == false || !wc.unresolved.is_empty());
}
#[test]
fn recovery_message_unresolved_included_and_dedup_works() {
use df_ai::context_helpers::GoalStatus;
let mut wc = wc_with_goals(&[("同一目标", GoalStatus::Active)]);
wc.unresolved.push("端口冲突未确认".into());
let pinned = vec![crate::commands::ai::GoalEntry::new("同一目标".to_string())];
let msg = build_recovery_message(&wc, &pinned);
assert_eq!(msg.matches("同一目标").count(), 1, "去重后只出现一次: {msg}");
assert!(msg.contains("端口冲突未确认"), "未解事项须进快照: {msg}");
}
#[test]
fn recovery_message_empty_snapshot_falls_back_to_constant() {
let wc = df_ai::context_helpers::WorkingContext::new();
let msg = build_recovery_message(&wc, &[]);
assert_eq!(msg, PARKED_RECOVERY_MESSAGE, "快照全空回退原常量,行为同今日");
}
#[test]
fn parse_pinned_goals_new_and_legacy_formats() {
// 新格式保 status;旧格式转 Active;非数组/坏 JSON 空降级
let new_fmt = parse_pinned_goals(Some(r#"[{"text":"a","status":"completed"},{"text":"b"}]"#));
assert_eq!(new_fmt.len(), 2);
assert!(matches!(new_fmt[0].status, crate::commands::ai::GoalStatus::Completed));
assert!(matches!(new_fmt[1].status, crate::commands::ai::GoalStatus::Active));
let legacy = parse_pinned_goals(Some(r#"["旧文本"]"#));
assert_eq!(legacy.len(), 1);
assert!(matches!(legacy[0].status, crate::commands::ai::GoalStatus::Active));
assert!(parse_pinned_goals(Some("not-json")).is_empty());
assert!(parse_pinned_goals(None).is_empty());
}
// ── 恢复产物过滤(递归套娃防线,conv 76730ce8 seq 250 实证) ──
/// 构造一条上一代恢复消息全文(截 200 字符,复刻 begin_turn 误记目标的现场形态)。
fn prev_recovery_text() -> String {
let full = format!(
"{} 应用重启导致上次任务中断,已完成进度已保留。请按以下目标快照继续完成未竟任务:\n\n有效目标:\n- 检查 各个 u-claw 实例状态\n\n若快照不足以判断进度,先向用户确认已完成步骤再继续。",
RECOVERY_ARTIFACT_PREFIX
);
full.chars().take(200).collect()
}
#[test]
fn recovery_message_zero_nesting_with_polluted_goals() {
use df_ai::context_helpers::GoalStatus;
// 嵌套恢复现场:WorkingContext.goals 与 pinned_goals 都躺着上一条恢复消息全文,
// 两侧 Active——未过滤时它会被当有效目标嵌进新恢复消息(套娃逐层加深)。
let wc = wc_with_goals(&[
(&prev_recovery_text(), GoalStatus::Active),
("交付 v2 登录接口", GoalStatus::Active),
]);
let pinned = vec![
crate::commands::ai::GoalEntry::new(prev_recovery_text()),
crate::commands::ai::GoalEntry::new("补齐单测覆盖".to_string()),
];
let msg = build_recovery_message(&wc, &pinned);
// 输出只允许保留消息头一处恢复前缀(零套娃),正常用户目标不受影响
assert_eq!(
msg.matches(RECOVERY_ARTIFACT_PREFIX).count(),
1,
"恢复产物目标必须被剔除,输出仅消息头一处前缀: {msg}"
);
assert!(msg.contains("交付 v2 登录接口"), "正常用户目标须保留: {msg}");
assert!(msg.contains("补齐单测覆盖"), "正常钉扎目标须保留: {msg}");
assert!(
!msg.contains("有效目标:\n- [系统恢复]"),
"上一代恢复消息不得作为目标行回放: {msg}"
);
}
#[test]
fn recovery_message_all_polluted_falls_back_to_constant() {
use df_ai::context_helpers::GoalStatus;
// 快照只剩恢复产物(历史最深套娃态):全部过滤后回退常量,套娃链在此截断
let wc = wc_with_goals(&[(&prev_recovery_text(), GoalStatus::Active)]);
let pinned = vec![crate::commands::ai::GoalEntry::new(prev_recovery_text())];
assert_eq!(
build_recovery_message(&wc, &pinned),
PARKED_RECOVERY_MESSAGE,
"全污染快照过滤后按空快照回退常量"
);
}
#[test]
fn parse_pinned_goals_drops_recovery_artifacts_idempotent() {
// 存量污染反解剔除:恢复前缀条目不进内存(幂等——清洗后再解析结果不变)
let polluted = serde_json::to_string(&vec![
crate::commands::ai::GoalEntry::new(prev_recovery_text()),
crate::commands::ai::GoalEntry::new("正常目标保留".to_string()),
])
.unwrap();
let parsed = parse_pinned_goals(Some(&polluted));
assert_eq!(parsed.len(), 1, "恢复产物条目须剔除: {parsed:?}");
assert_eq!(parsed[0].text, "正常目标保留");
// 幂等:清洗结果重解析不变
let cleaned = serde_json::to_string(&parsed).unwrap();
assert_eq!(parse_pinned_goals(Some(&cleaned)).len(), 1);
}
#[test]
fn parse_pinned_goals_drops_injection_artifacts() {
// E5:[补充指令] 是通道标记非任务意图(434c8c40「[补充指令] 123 hi」占位实证),
// 与恢复产物同治——载入侧剔除,下轮 save 重写自愈。
let polluted = serde_json::to_string(&vec![
crate::commands::ai::GoalEntry::new("[补充指令] 123 hi".to_string()),
crate::commands::ai::GoalEntry::new("升级工具链到最新版".to_string()),
])
.unwrap();
let parsed = parse_pinned_goals(Some(&polluted));
assert_eq!(parsed.len(), 1, "注入产物条目须剔除: {parsed:?}");
assert_eq!(parsed[0].text, "升级工具链到最新版");
}
} }
+358
View File
@@ -0,0 +1,358 @@
//! 脉络卡数据面(跨会话召回四层架构 L3 用户侧辅助通道):
//! 按 source_project 聚合 published 知识,给前端新会话入口展示「这个项目之前定了什么」。
//!
//! 铁律红线:本通道只喂「用户看的卡」,不进 system、不进消息流——用户点「带入上下文」
//! 才经正常发送路径显式注入(用户决策,非 LLM 自动召回)。
use tauri::State;
use crate::commands::ai::knowledge_inject::resolve_source_project;
/// 单条脉络摘要(字段前端契约,snake_case)
#[derive(serde::Serialize, Clone, Debug)]
pub struct ContextBrief {
pub title: String,
pub summary: String,
pub source_ref: Option<String>,
pub source_project: Option<String>,
pub reuse_count: i32,
/// 知识条目最近更新时间,原样透供前端展示「这条什么时候定的」
pub updated_at: String,
/// 可跳转的来源会话 id(自 source_ref 解析,消息级引用经 ai_messages 反查);
/// 解析不出(无源/引用失效)为 None,前端降级纯文本标题
pub source_conv: Option<String>,
}
/// 项目脉络分组
#[derive(serde::Serialize, Clone, Debug)]
pub struct ProjectBriefs {
pub source_project: String,
/// 是否为当前会话归属项目(仅传 focus_conv 且解析命中时为 true,前端做置顶高亮)
pub focused: bool,
pub briefs: Vec<ContextBrief>,
}
/// 单条摘要截断上限(字符):脉络卡是扫一眼的导航,不是全文阅读,超长压到一行半
const BRIEF_SUMMARY_MAX_CHARS: usize = 120;
fn truncate_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let mut out: String = s.chars().take(max).collect();
out.push('…');
out
}
/// source_ref 溯源引用的三种形态:对话级 `conv:{id}` 直接可跳;
/// 消息级 `conv_msg:{msg_id}` 须查 ai_messages 反查会话;其余(空/异形)不可跳。
enum SourceConvRef {
Conv(String),
Msg(String),
None,
}
fn parse_source_conv(source_ref: Option<&str>) -> SourceConvRef {
let Some(r) = source_ref else {
return SourceConvRef::None;
};
if let Some(id) = r.strip_prefix("conv:") {
if id.is_empty() {
return SourceConvRef::None;
}
return SourceConvRef::Conv(id.to_string());
}
if let Some(id) = r.strip_prefix("conv_msg:") {
if id.is_empty() {
return SourceConvRef::None;
}
return SourceConvRef::Msg(id.to_string());
}
SourceConvRef::None
}
/// 回填 source_conv:对话级引用直接取 id,消息级引用批量反查 ai_messages(一条 IN 查询,
/// 引用失效/表查不到保持 None,前端降级纯文本)。
async fn fill_source_convs(db: &df_storage::db::Database, groups: &mut [ProjectBriefs]) {
for g in groups.iter_mut() {
for b in g.briefs.iter_mut() {
if let SourceConvRef::Conv(id) = parse_source_conv(b.source_ref.as_deref()) {
b.source_conv = Some(id);
}
}
}
let msg_ids: Vec<String> = groups
.iter()
.flat_map(|g| g.briefs.iter())
.filter_map(|b| match parse_source_conv(b.source_ref.as_deref()) {
SourceConvRef::Msg(id) => Some(id),
_ => None,
})
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
if msg_ids.is_empty() {
return;
}
let conn = db.conn();
let guard = conn.lock().await;
let Ok(mut stmt) = guard.prepare("SELECT conversation_id FROM ai_messages WHERE id = ?1")
else {
return;
};
let mut found: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for id in &msg_ids {
if let Ok(rows) = stmt.query_map([id], |row| row.get::<_, String>(0)) {
for conv_id in rows.flatten() {
found.insert(id.clone(), conv_id);
}
}
}
drop(stmt);
for g in groups.iter_mut() {
for b in g.briefs.iter_mut() {
if b.source_conv.is_some() {
continue;
}
if let SourceConvRef::Msg(id) = parse_source_conv(b.source_ref.as_deref()) {
b.source_conv = found.get(&id).cloned();
}
}
}
}
/// 聚合纯函数:published 记录 → 按项目分组脉络。无归属项目(source_project None)的知识
/// 不入脉络卡(无分组锚点,注入侧 top-3 已覆盖全局知识);每组按 reuse_count 降序(唯一
/// 客观信号),组间按组内最高复用数降序(活跃项目排前);`focus_project` 命中的组置顶
/// (当前会话归属项目优先展示),未命中/不传时顺序与置顶逻辑不存在时完全一致。
fn aggregate_briefs(
records: Vec<df_storage::models::KnowledgeRecord>,
per_project: usize,
focus_project: Option<&str>,
) -> Vec<ProjectBriefs> {
use std::collections::HashMap;
let mut groups: HashMap<String, Vec<ContextBrief>> = HashMap::new();
for r in records {
let Some(proj) = r.source_project.clone() else {
continue;
};
let brief = ContextBrief {
title: truncate_chars(&r.title, 60),
summary: truncate_chars(&r.content, BRIEF_SUMMARY_MAX_CHARS),
source_ref: r.source_ref,
source_project: Some(proj.clone()),
reuse_count: r.reuse_count,
updated_at: r.updated_at,
source_conv: None,
};
groups.entry(proj).or_default().push(brief);
}
let mut out: Vec<ProjectBriefs> = groups
.into_iter()
.map(|(source_project, mut briefs)| {
briefs.sort_by(|a, b| b.reuse_count.cmp(&a.reuse_count));
briefs.truncate(per_project);
let focused = focus_project == Some(source_project.as_str());
ProjectBriefs { source_project, focused, briefs }
})
.collect();
out.sort_by(|a, b| {
let top = |g: &ProjectBriefs| g.briefs.first().map(|b| b.reuse_count).unwrap_or(0);
top(b).cmp(&top(a))
});
if let Some(focus) = focus_project {
if let Some(pos) = out.iter().position(|g| g.source_project == focus) {
let focused_group = out.remove(pos);
out.insert(0, focused_group);
}
}
out
}
/// 脉络卡数据:published 知识按 source_project 聚合(每项目取复用最高的 `per_project` 条)。
/// `focus_conv` 传当前会话 id 时解析其归属项目,命中组置顶并带 `focused` 标记;解析失败/
/// 无归属时与不传该参数行为完全一致(软排序只调顺序,不报错不缩面)。
/// 纯读侧,无状态无副作用;knowledge 表空/无归属项目时返空(前端不渲染卡)。
#[tauri::command]
pub async fn ai_context_briefs(
state: State<'_, crate::AppState>,
per_project: Option<usize>,
focus_conv: Option<String>,
) -> Result<Vec<ProjectBriefs>, String> {
let records = state
.knowledge
.list_by_status("published")
.await
.map_err(|e| e.to_string())?;
let focus_project = match focus_conv.as_deref() {
Some(conv_id) => resolve_source_project(&state.db, conv_id, &[]).await,
None => None,
};
let mut out = aggregate_briefs(records, per_project.unwrap_or(5), focus_project.as_deref());
fill_source_convs(&state.db, &mut out).await;
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn rec(title: &str, content: &str, project: Option<&str>, reuse: i32) -> df_storage::models::KnowledgeRecord {
df_storage::models::KnowledgeRecord {
id: format!("k-{title}"),
kind: "pitfall".into(),
title: title.into(),
content: content.into(),
tags: None,
status: "published".into(),
confidence: Some("high".into()),
reuse_count: reuse,
verified: true,
source_project: project.map(Into::into),
source_ref: Some("conv:test".into()),
reasoning: None,
embedding_status: None,
created_at: "1700000000".into(),
updated_at: "1700000000".into(),
}
}
#[test]
fn briefs_group_by_project_and_sort_by_reuse() {
let recs = vec![
rec("甲项目低复用", "c1", Some("proj-a"), 1),
rec("甲项目高复用", "c2", Some("proj-a"), 9),
rec("乙项目", "c3", Some("proj-b"), 3),
rec("无归属不入卡", "c4", None, 99),
];
let out = aggregate_briefs(recs, 5, None);
assert_eq!(out.len(), 2, "两项目分组,无归属剔除: {out:?}");
assert_eq!(out[0].source_project, "proj-a", "组间按最高复用降序");
assert_eq!(out[0].briefs[0].title, "甲项目高复用", "组内按复用降序");
assert!(out.iter().all(|g| !g.focused), "无 focus 时任何组不带置顶标记");
assert!(out.iter().all(|g| g.briefs.iter().all(|b| b.source_project.is_some())));
}
#[test]
fn briefs_truncate_per_project_and_summary() {
let recs: Vec<_> = (0..8)
.map(|i| rec(&format!("条{i}"), &"长".repeat(200), Some("proj"), i))
.collect();
let out = aggregate_briefs(recs, 3, None);
assert_eq!(out[0].briefs.len(), 3, "每项目截断到 per_project");
assert!(out[0].briefs[0].summary.chars().count() <= BRIEF_SUMMARY_MAX_CHARS + 1);
}
#[test]
fn focus_project_group_moves_to_front() {
let recs = vec![
rec("乙高复用", "c1", Some("proj-b"), 9),
rec("甲低复用", "c2", Some("proj-a"), 1),
];
let out = aggregate_briefs(recs, 5, Some("proj-a"));
assert_eq!(out[0].source_project, "proj-a", "会话归属项目组置顶(复用更低也排前): {out:?}");
assert!(out[0].focused, "命中组带 focused 标记");
assert!(!out[1].focused, "非命中组保持 focused=false");
}
fn group_order(groups: &[ProjectBriefs]) -> Vec<&str> {
groups.iter().map(|g| g.source_project.as_str()).collect()
}
#[test]
fn focus_unmatched_project_keeps_order() {
let recs = vec![
rec("乙高复用", "c1", Some("proj-b"), 9),
rec("甲低复用", "c2", Some("proj-a"), 1),
];
let plain = aggregate_briefs(recs.clone(), 5, None);
let unmatched = aggregate_briefs(recs, 5, Some("proj-x"));
assert_eq!(group_order(&plain), group_order(&unmatched), "解析无归属/未知项目时顺序不变");
assert!(unmatched.iter().all(|g| !g.focused), "无命中组不误标记");
}
#[test]
fn brief_carries_updated_at() {
let mut r = rec("条目", "c", Some("proj"), 1);
r.updated_at = "1777777777".into();
let out = aggregate_briefs(vec![r], 5, None);
assert_eq!(out[0].briefs[0].updated_at, "1777777777", "updated_at 原样透传");
}
#[test]
fn source_ref_parses_to_conv_msg_or_none() {
assert!(matches!(
parse_source_conv(Some("conv:abc-1")),
SourceConvRef::Conv(id) if id == "abc-1"
));
assert!(matches!(
parse_source_conv(Some("conv_msg:msg_123_4")),
SourceConvRef::Msg(id) if id == "msg_123_4"
));
assert!(matches!(parse_source_conv(Some("conv:")), SourceConvRef::None));
assert!(matches!(parse_source_conv(Some("weird:x")), SourceConvRef::None));
assert!(matches!(parse_source_conv(None), SourceConvRef::None));
}
#[tokio::test]
async fn fill_source_convs_resolves_both_ref_kinds() {
let db = df_storage::db::Database::open_in_memory()
.await
.expect("内存库");
{
let conn = db.conn();
let guard = conn.lock().await;
guard
.execute(
"CREATE TABLE IF NOT EXISTS ai_messages (id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL)",
[],
)
.expect("建表");
guard
.execute(
"INSERT INTO ai_messages (id, conversation_id, seq, role, created_at) VALUES \
('msg_1_0', 'conv-a', 1, 'assistant', '0'), ('msg_2_0', 'conv-b', 2, 'assistant', '0')",
[],
)
.expect("造数");
}
let mut groups = vec![ProjectBriefs {
source_project: "proj".into(),
focused: false,
briefs: vec![
ContextBrief {
title: "对话级".into(),
summary: "s".into(),
source_ref: Some("conv:conv-a".into()),
source_project: Some("proj".into()),
reuse_count: 2,
updated_at: "1".into(),
source_conv: None,
},
ContextBrief {
title: "消息级".into(),
summary: "s".into(),
source_ref: Some("conv_msg:msg_2_0".into()),
source_project: Some("proj".into()),
reuse_count: 1,
updated_at: "1".into(),
source_conv: None,
},
ContextBrief {
title: "失效引用".into(),
summary: "s".into(),
source_ref: Some("conv_msg:msg_gone".into()),
source_project: Some("proj".into()),
reuse_count: 0,
updated_at: "1".into(),
source_conv: None,
},
],
}];
fill_source_convs(&db, &mut groups).await;
let briefs = &groups[0].briefs;
assert_eq!(briefs[0].source_conv.as_deref(), Some("conv-a"), "对话级直接取 id");
assert_eq!(briefs[1].source_conv.as_deref(), Some("conv-b"), "消息级反查回填");
assert_eq!(briefs[2].source_conv, None, "失效引用降级 None");
}
}
+118 -1
View File
@@ -267,6 +267,24 @@ async fn save_conversation_serialized(
} }
/// save_conversation 内部实现(无超时,由外层 save_with_meta 包 timeout 调用)。 /// save_conversation 内部实现(无超时,由外层 save_with_meta 包 timeout 调用)。
/// WorkingContext 落库快照序列化(V53 working_context_json,save 每轮随行写)。
/// 四段全空(无目标/决策/未解项/步骤)返回 None——NULL=无上下文,列语义干净,
/// 老会话恢复行为同旧;序列化失败 warn 降级为 None(仅丢本轮快照,下轮 save 重写)。
fn working_context_snapshot(wc: &df_ai::context_helpers::WorkingContext) -> Option<String> {
if wc.goals.is_empty() && wc.decisions.is_empty() && wc.unresolved.is_empty()
&& wc.current_step.is_none()
{
return None;
}
match serde_json::to_string(wc) {
Ok(s) => Some(s),
Err(e) => {
tracing::warn!("WorkingContext 序列化失败(本轮快照降级为 NULL): {e}");
None
}
}
}
async fn save_conversation_inner( async fn save_conversation_inner(
session_arc: &Arc<Mutex<AiSession>>, session_arc: &Arc<Mutex<AiSession>>,
db: &Arc<Database>, db: &Arc<Database>,
@@ -295,7 +313,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, conv_origin, conv_boundary) = { let (provider_id, created_at, pinned_goals, msg_len, persisted_count, needs_full_rewrite, mut persist_msgs, conv_origin, conv_boundary, conv_working_ctx) = {
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 复活。
@@ -335,6 +353,9 @@ async fn save_conversation_inner(
let pinned_goals = conv.pinned_goals.clone(); let pinned_goals = conv.pinned_goals.clone();
// 无人值守任务边界随行落库(V45 boundary_json):每轮 save 同步内存真相源快照 // 无人值守任务边界随行落库(V45 boundary_json):每轮 save 同步内存真相源快照
let conv_boundary = conv.boundary.clone(); let conv_boundary = conv.boundary.clone();
// WorkingContext 随行落库快照(V53 working_context_json,跨会话召回 L4):
// 压缩重锚定的决策/未解项跨重启不断裂,恢复侧经 working_context_from_snapshot 反解
let conv_working_ctx = working_context_snapshot(&conv.working_context);
let _ = __wait; // 诊断:lock 等待时长(下行 eprintln 输出 lock 段总时长) let _ = __wait; // 诊断:lock 等待时长(下行 eprintln 输出 lock 段总时长)
( (
provider_id, provider_id,
@@ -346,6 +367,7 @@ async fn save_conversation_inner(
cloned, cloned,
conv_origin, conv_origin,
conv_boundary, conv_boundary,
conv_working_ctx,
) )
}; };
let __lock_total = __lock_start.elapsed(); let __lock_total = __lock_start.elapsed();
@@ -471,6 +493,8 @@ async fn save_conversation_inner(
rec.boundary_json = conv_boundary.as_ref() rec.boundary_json = conv_boundary.as_ref()
.map(|b| serde_json::to_string(b).unwrap_or_default()) .map(|b| serde_json::to_string(b).unwrap_or_default())
.filter(|s| !s.is_empty()); .filter(|s| !s.is_empty());
// WorkingContext 随行持久化(V53):None(全空/序列化失败)保持 NULL
rec.working_context_json = conv_working_ctx;
// update_full 仍写 messages 列(保留旧值,本批不改 messages 字段),写元数据 + updated_at // update_full 仍写 messages 列(保留旧值,本批不改 messages 字段),写元数据 + updated_at
if let Err(e) = conv_repo.update_full(&rec).await { if let Err(e) = conv_repo.update_full(&rec).await {
tracing::warn!("更新对话元数据失败 {conv_id}: {e}"); tracing::warn!("更新对话元数据失败 {conv_id}: {e}");
@@ -548,6 +572,7 @@ async fn save_conversation_inner(
loop_state: if conv_is_active { Some("running".to_string()) } else { None }, loop_state: if conv_is_active { Some("running".to_string()) } else { None },
// V50 终局态:新落库默认 active(进行中);终局由收尾路径 set_status 覆写。 // V50 终局态:新落库默认 active(进行中);终局由收尾路径 set_status 覆写。
status: "active".to_string(), status: "active".to_string(),
working_context_json: conv_working_ctx,
}; };
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}");
@@ -850,4 +875,96 @@ mod tests {
truncate_decision(&mut m); truncate_decision(&mut m);
assert_eq!(m.content, short); assert_eq!(m.content, short);
} }
// ---------- WorkingContext 持久化(V53,跨会话召回 L4) ----------
/// 保存→重载 WorkingContext 字段一致:goals/decisions/unresolved/current_step/version
/// 全回(save 侧 working_context_snapshot 正典序列化 + 恢复侧 working_context_from_snapshot
/// 正典反解 + repo 落库往返,即 save_conversation/会话载入/parked 恢复三条链共用的两个端点)。
#[tokio::test]
async fn working_context_snapshot_roundtrip_via_repo() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiConversationRepo::new(&db);
let mut wc = df_ai::context_helpers::WorkingContext::new();
wc.version.current_turn = 7;
wc.goals.push(df_ai::context_helpers::GoalItem {
id: "t7".into(),
text: "重构存储层".into(),
status: df_ai::context_helpers::GoalStatus::Active,
});
wc.decisions.push(df_ai::context_helpers::DecisionItem {
question: "迁移号?".into(),
decision: "顺延 V53".into(),
reason: "V50-V52 已占用".into(),
});
wc.unresolved.push("端口未定".into());
wc.current_step = Some(df_ai::context_helpers::StepInfo {
step_name: "迁移".into(),
status: df_ai::context_helpers::StepStatus::InProgress,
});
let snap = working_context_snapshot(&wc).expect("非空上下文应产出快照");
repo.insert(df_storage::models::AiConversationRecord {
id: "c-wc".into(),
title: None,
messages: "[]".into(),
provider_id: None,
model: None,
models: None,
archived: false,
pinned: false,
prompt_tokens: Some(0),
completion_tokens: Some(0),
pinned_goals: None,
pending_approvals: None,
created_at: "0".into(),
updated_at: "0".into(),
origin: None,
boundary_json: None,
loop_state: None,
status: "active".into(),
working_context_json: Some(snap),
})
.await
.expect("insert");
let loaded = repo.get_by_id("c-wc").await.expect("get").expect("row");
let restored = crate::commands::ai::commands::conversation::working_context_from_snapshot(
loaded.working_context_json.as_deref(),
);
assert_eq!(restored.goals.len(), 1, "goals 应回");
assert_eq!(restored.goals[0].text, "重构存储层");
assert_eq!(restored.goals[0].status, df_ai::context_helpers::GoalStatus::Active);
assert_eq!(restored.decisions.len(), 1, "decisions 应回");
assert_eq!(restored.decisions[0].decision, "顺延 V53");
assert_eq!(restored.decisions[0].reason, "V50-V52 已占用");
assert_eq!(restored.unresolved, vec!["端口未定".to_string()], "unresolved 应回");
let step = restored.current_step.expect("current_step 应回");
assert_eq!(step.step_name, "迁移");
assert_eq!(step.status, df_ai::context_helpers::StepStatus::InProgress);
assert_eq!(restored.version.current_turn, 7, "脏标记版本应回");
}
/// 无持久化恢复=空 WorkingContext 不报错:全空上下文 save 侧写 NULL(不产脏 JSON);
/// 旧会话列 NULL/空串/坏 JSON 恢复一律空上下文(行为同今日),坏数据降级不 panic。
#[test]
fn working_context_empty_snapshot_none_and_null_restore_empty() {
let empty = df_ai::context_helpers::WorkingContext::new();
assert!(
working_context_snapshot(&empty).is_none(),
"全空上下文快照应为 None(落 NULL,列语义干净)"
);
use crate::commands::ai::commands::conversation::working_context_from_snapshot;
let restored = working_context_from_snapshot(None);
assert!(restored.goals.is_empty() && restored.decisions.is_empty());
assert!(restored.unresolved.is_empty() && restored.current_step.is_none());
let blank = working_context_from_snapshot(Some(""));
assert!(blank.goals.is_empty(), "空串按无上下文");
let bad = working_context_from_snapshot(Some("not-json"));
assert!(bad.decisions.is_empty(), "坏 JSON 降级为空上下文不 panic");
}
} }
+702
View File
@@ -0,0 +1,702 @@
//! devflow.md / CLAUDE.md 项目认知投影(项目规则文件 + 知识库路标 → system prompt 稳定尾段)。
//!
//! 定位:对标 CLAUDE.md 的"启动读一遍静态规则文件",本土化为双文件并读——
//! - `devflow.md`:agent 生态自己的项目认知文件,v1 取两个分区(规范纪律 / 领单);
//! - `CLAUDE.md`:既有通用约定,同目录存在则并入(增强非依赖)。
//! 两文件并存时 devflow.md 优先(段内排序在前 + 显式冲突注明)。
//!
//! 知识路标子段(知识投影 v2):规则文件只说"该怎么做",路标补"库里有啥"——已发布且
//! (人工验证或复用达标)的知识条目按「同项目档 + 跨项目补充档」铺开成紧凑行,
//! 让 agent 知晓知识库藏、在工具故障/重复失败时想起用 knowledge_search 检索,
//! 而不是死磕。读侧走 df-storage [`KnowledgeRepo::list_waymark`],会话项目归属复用
//! [`resolve_source_project`] 既有机制,不自写 SQL 不造双轨。
//!
//! 缓存红线:投影段纯追加在 system prompt 全部既有段之后,内容按会话缓存——
//! 同一会话(含其派生的子代理)只在首次组装时读一次盘 + 查一次库,此后复用同一文本,
//! 保证段内容会话内逐字节不变,不击穿 provider 的 prompt 缓存前缀。
//! 会话粒度取 conv_id 作缓存键:新会话重新组装(拿到文件与库的最新内容),旧会话不受扰动。
//!
//! 海纳川流:两文件缺失 / 读取失败 / 内容为空,且知识库无合格条目 → 投影段为空,
//! prompt 与不接本模块逐字一致(规则文件与路标都是增强输入,缺失不影响内生运作)。
//!
//! 裁剪纪律:注入的 token 才是成本——规则分区按字符上限截断,路标条目数被查询
//! LIMIT 封顶 + 行内标题截断,整体体量有界。超限按 Unicode 字符截断
//! (中文一字一符,按字节会截断 UTF-8 序列产生乱码)并注明。
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use df_storage::crud::KnowledgeRepo;
use df_storage::db::Database;
use df_storage::models::KnowledgeRecord;
use crate::state::AllowedDirs;
// ── 裁剪上限(字符数,中文一字一符) ──
//
// 分区上限兜住单分区体量,总上限兜住整段体量;取值按"规范纪律最厚、领单次之、
// CLAUDE.md 与纪律区同量级"的内容形态分配,叠加后总上限仍可能触发二次截断。
/// 规范纪律分区上限
const DISCIPLINE_MAX_CHARS: usize = 4000;
/// 领单分区上限
const CLAIM_MAX_CHARS: usize = 2000;
/// CLAUDE.md 并入段上限
const CLAUDE_MD_MAX_CHARS: usize = 4000;
/// 投影段总上限(各分区叠加后的二次硬闸)
const SECTION_MAX_CHARS: usize = 6000;
/// 投影段缓存上限(条目)。会话粒度缓存,长跑进程防内存膨胀;超限整体清空
/// (清空后下次组装重读盘,内容只会更新不会错,正确性不受损)。
const SECTION_CACHE_MAX: usize = 64;
// ── 知识路标子段参数 ──
//
// 条目数由查询 LIMIT 封顶(项目档 + 补充档),行内标题截断注省略号——双层结构上界
// 使路标块体量有界(约 30 行 × 行宽上限),无须再设整块字符闸。
/// 会话归属项目档上限(条)
const WAYMARK_PROJECT_MAX: usize = 20;
/// 跨项目补充档上限(条)
const WAYMARK_GLOBAL_MAX: usize = 10;
/// 路标行标题上限(字符):超长截断防长标题破坏紧凑行形态
const WAYMARK_TITLE_MAX_CHARS: usize = 60;
/// devflow.md 规范分区识别的标题关键字(领单优先判,防"领单纪律"被纪律关键字截胡)
const CLAIM_HEADING_KEY: &str = "领单";
const DISCIPLINE_HEADING_KEYS: [&str; 2] = ["纪律", "规范"];
static SECTION_CACHE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
fn section_cache() -> &'static Mutex<HashMap<String, String>> {
SECTION_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// 知识库读侧句柄:进程级 AppHandle → AppState.db(生产装配点,与 spawn_agent 全局
/// 同款"注册期无法捕获、运行期读取"形态)。AppHandle 未注入 / AppState 未托管
/// (早期启动、单元测试)→ None,路标子块整体缺席,其余组装零扰动。
fn knowledge_db() -> Option<Arc<Database>> {
use tauri::Manager;
let app = crate::commands::ai::tools::spawn_agent::app_handle()?;
let state = app.try_state::<crate::state::AppState>()?;
Some(state.db.clone())
}
/// 工作目录解析:首个持久授权目录(排序取首稳定)。
///
/// 与主链被监视工作区取同一约定,保证主会话与子代理对"项目根"的认知一致;
/// HashSet 迭代序不定,必须排序后取首才是稳定值。
pub(crate) fn work_dir_of(allowed: &AllowedDirs) -> Option<String> {
let mut dirs: Vec<PathBuf> = allowed.persistent.iter().cloned().collect();
dirs.sort();
dirs.first().map(|p| p.to_string_lossy().to_string())
}
/// 把投影段纯追加到 system prompt 尾部(两条组装路径的统一入口)。
///
/// 无投影内容时原样返回(调用方 prompt 逐字不变);有则 `\n\n` 接续,
/// 调用方既有段内容不受任何影响(稳定尾段语义)。
pub(crate) async fn append_projection(prompt: String, work_dir: Option<&str>, conv_id: &str) -> String {
let kb = knowledge_db();
match projection_section(work_dir, conv_id, kb).await {
Some(seg) => format!("{}\n\n{}", prompt, seg),
None => prompt,
}
}
/// 取会话的投影段:缓存命中直接复用(同会话零重复读盘查库);未命中组装一次并入缓存。
///
/// 缓存值空串 = 本会话已知无投影(文件缺失且库无合格条目),同样走缓存避免每轮白查。
/// 子代理以父会话 conv_id 作键,天然复用父会话已读内容。
/// `kb`:知识库读侧句柄(None = 单元测试/未托管态,路标子块整体缺席)。
async fn projection_section(work_dir: Option<&str>, conv_id: &str, kb: Option<Arc<Database>>) -> Option<String> {
{
let cache = section_cache().lock().unwrap_or_else(|p| p.into_inner());
if let Some(hit) = cache.get(conv_id) {
return (!hit.is_empty()).then(|| hit.clone());
}
}
let dir = work_dir.map(PathBuf::from);
let conv = conv_id.to_string();
// 规则文件读盘(阻塞线程池,失败按缺失处理)与知识路标查库相互独立,并发取
let (files, waymark) = tokio::join!(
tokio::task::spawn_blocking(move || read_pair(dir)),
async {
match kb {
Some(db) => build_waymark(&db, &conv).await,
None => None,
}
}
);
let (devflow, claude) = files.unwrap_or_else(|_| (None, None));
let (discipline, claiming) = devflow
.as_deref()
.map(extract_partitions)
.unwrap_or((String::new(), String::new()));
let section = combine_sections(build_section(&discipline, &claiming, claude.as_deref()), waymark);
let mut cache = section_cache().lock().unwrap_or_else(|p| p.into_inner());
if cache.len() >= SECTION_CACHE_MAX {
cache.clear();
}
cache.insert(conv_id.to_string(), section.clone().unwrap_or_default());
section
}
/// 规则文件投影 + 知识路标子块的拼装(纯函数):路标恒居规则投影之后(同一稳定尾段内);
/// 双双缺席 → None(调用方零追加,零回归)。仅路标在场 → 路标独占投影段(规则文件头语不出现)。
fn combine_sections(rules: Option<String>, waymark: Option<String>) -> Option<String> {
match (rules, waymark) {
(Some(mut rules), Some(wm)) => {
rules.push_str("\n\n");
rules.push_str(&wm);
Some(rules)
}
(Some(rules), None) => Some(rules),
(None, Some(wm)) => Some(format!("## 项目认知投影\n\n{wm}")),
(None, None) => None,
}
}
/// 组装知识路标子块:解析会话归属项目(既有机制) → 两档查询 → 渲染紧凑行。
/// 查询失败降级缺席(warn 留痕,不阻断组装:路标是增强输入,故障不该影响 prompt 主链)。
async fn build_waymark(db: &Arc<Database>, conv_id: &str) -> Option<String> {
let project = crate::commands::ai::knowledge_inject::resolve_source_project(db, conv_id, &[]).await;
match KnowledgeRepo::new(db)
.list_waymark(project.as_deref(), WAYMARK_PROJECT_MAX, WAYMARK_GLOBAL_MAX)
.await
{
Ok(list) if !list.is_empty() => Some(render_waymark(&list)),
Ok(_) => None,
Err(e) => {
tracing::warn!(error = %e, conv_id, "[devflow-md] 知识路标查询失败,子块缺席");
None
}
}
}
/// 渲染路标子块(纯函数):段首说明 + 紧凑行 + 段尾固定提示。
/// 标题压平空白(库内标题可能含换行,直拼会破坏单行形态)并截断注省略号;
/// 空白标题条目跳过(渲染不出可读行的脏数据不入段)。
fn render_waymark(entries: &[KnowledgeRecord]) -> String {
let mut wm = String::from("### 知识库路标\n项目知识库路标(详情用 knowledge_search 按标题关键词检索)");
for e in entries {
let flat = e.title.split_whitespace().collect::<Vec<_>>().join(" ");
if flat.is_empty() {
continue;
}
let count = flat.chars().count();
let mut title: String = flat.chars().take(WAYMARK_TITLE_MAX_CHARS).collect();
if count > WAYMARK_TITLE_MAX_CHARS {
title.push('…');
}
wm.push_str(&format!("\n- {title} | 复用{}次", e.reuse_count));
}
wm.push_str("\n上述经验条目可直接复用;遇到工具故障/重复失败先检索路标");
wm
}
/// 读工作目录下的两份规则文件,内容 trim 后返回;目录缺失 / 读失败 / 空白内容 → None。
fn read_pair(dir: Option<PathBuf>) -> (Option<String>, Option<String>) {
let Some(dir) = dir else {
return (None, None);
};
(read_trimmed(&dir.join("devflow.md")), read_trimmed(&dir.join("CLAUDE.md")))
}
fn read_trimmed(path: &Path) -> Option<String> {
std::fs::read_to_string(path)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
/// 拼装规则文件投影段(纯函数):分区截取、上限截断、优先级注明都在这里定形。
/// 三路输入全空 → None(路标子块在场时由 combine_sections 单独成段)。
fn build_section(discipline: &str, claiming: &str, claude: Option<&str>) -> Option<String> {
let claude = claude.map(str::trim).filter(|c| !c.is_empty());
if discipline.is_empty() && claiming.is_empty() && claude.is_none() {
return None;
}
let has_devflow = !discipline.is_empty() || !claiming.is_empty();
let mut sec = String::from("## 项目认知投影\n以下规则来自项目仓库文件(会话启动时读取)");
// 优先级只在双文件并存时注明,单文件无冲突可注明
if has_devflow && claude.is_some() {
sec.push_str(",devflow.md 与 CLAUDE.md 并存,冲突以 devflow.md 为准");
}
sec.push(':');
if !discipline.is_empty() {
sec.push_str("\n\n### 规范纪律(devflow.md)\n");
sec.push_str(&cap_chars(discipline, DISCIPLINE_MAX_CHARS));
}
if !claiming.is_empty() {
sec.push_str("\n\n### 领单纪律(devflow.md)\n");
sec.push_str(&cap_chars(claiming, CLAIM_MAX_CHARS));
}
if let Some(cla) = claude {
sec.push_str("\n\n### 项目规范(CLAUDE.md)\n");
sec.push_str(&cap_chars(cla, CLAUDE_MD_MAX_CHARS));
}
// 总上限二次硬闸:各分区上限叠加后仍可能超,整段再截一次
if sec.chars().count() > SECTION_MAX_CHARS {
let cut: String = sec.chars().take(SECTION_MAX_CHARS).collect();
return Some(format!("{}\n…(投影段超出总上限已截断)", cut));
}
Some(sec)
}
/// 按字符数截断并注明(纯函数):超 `max` 截断追加标注,否则原样返回。
fn cap_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let cut: String = s.chars().take(max).collect();
format!("{}\n…(超上限已截断)", cut)
}
// ── devflow.md 分区解析 ──
//
// v1 取两个分区:规范纪律 / 领单。识别方式 = markdown 标题关键字命中,命中块的
// 更深层子标题视为块内子结构一并保留;未命中关键标题的块不注入(状态/警示类
// 分区不属 v1 范围,天然被裁掉)。全文无命中分区的扁平写法回退为整文件按
// 规范纪律处理——规则文件形态与 CLAUDE.md 同构,不因没写小节标题而失传。
#[derive(Clone, Copy, PartialEq)]
enum Partition {
Discipline,
Claim,
}
/// 识别 markdown 标题行,返回 (级别, 标题文本);非标题 / 纯装饰线 → None。
fn heading_of(line: &str) -> Option<(usize, &str)> {
let t = line.trim_start();
let level = t.len() - t.trim_start_matches('#').len();
if level == 0 || level > 6 {
return None;
}
let text = t[level..].trim();
if text.is_empty() {
return None;
}
Some((level, text))
}
fn classify_heading(text: &str) -> Option<Partition> {
if text.contains(CLAIM_HEADING_KEY) {
return Some(Partition::Claim);
}
if DISCIPLINE_HEADING_KEYS.iter().any(|k| text.contains(k)) {
return Some(Partition::Discipline);
}
None
}
/// 从 devflow.md 全文提取 (规范纪律, 领单) 两个分区正文(纯函数,未截断)。
fn extract_partitions(content: &str) -> (String, String) {
let mut discipline: Vec<String> = Vec::new();
let mut claiming: Vec<String> = Vec::new();
// 当前块:(分区, 起始标题级别, 正文);正文不含起始标题行(投影段会重加小标题)
let mut cur: Option<(Partition, usize, String)> = None;
for line in content.lines() {
let heading = heading_of(line);
// 块边界:块内遇到不深于起始层级的标题 → 收块,由新标题决定是否开下一块
let boundary = matches!((&cur, &heading), (Some((_, lv, _)), Some((h_lv, _))) if h_lv <= lv);
if boundary {
let (kind, _, body) = cur.take().expect("boundary 判定保证 cur 存在");
push_block(kind, body, &mut discipline, &mut claiming);
} else if let Some((_, _, body)) = cur.as_mut() {
// 更深层级标题属块内子结构,连行保留
body.push_str(line);
body.push('\n');
continue;
}
if let Some((lv, text)) = heading {
if let Some(kind) = classify_heading(text) {
cur = Some((kind, lv, String::new()));
}
}
}
if let Some((kind, _, body)) = cur.take() {
push_block(kind, body, &mut discipline, &mut claiming);
}
let dev = discipline.join("\n").trim().to_string();
let claim = claiming.join("\n").trim().to_string();
if dev.is_empty() && claim.is_empty() {
return (content.trim().to_string(), String::new());
}
(dev, claim)
}
fn push_block(kind: Partition, body: String, discipline: &mut Vec<String>, claiming: &mut Vec<String>) {
let trimmed = body.trim();
if trimmed.is_empty() {
return;
}
match kind {
Partition::Discipline => discipline.push(trimmed.to_string()),
Partition::Claim => claiming.push(trimmed.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
/// 测试临时目录计数器:进程内唯一,防并发测试互踩 + 防缓存键串扰
static TMP_SEQ: AtomicUsize = AtomicUsize::new(0);
fn unique_tag() -> String {
format!("{}-{}", std::process::id(), TMP_SEQ.fetch_add(1, Ordering::SeqCst))
}
fn temp_project_dir() -> PathBuf {
let dir = std::env::temp_dir().join(format!("devflow-md-test-{}", unique_tag()));
std::fs::create_dir_all(&dir).expect("建临时目录");
dir
}
fn write_file(dir: &Path, name: &str, content: &str) {
std::fs::write(dir.join(name), content).expect("写测试文件");
}
// ── 零回归:无两文件 → prompt 逐字不变 ──
#[tokio::test]
async fn missing_files_leave_prompt_unchanged() {
let base = "BASE PROMPT 段落".to_string();
let dir = temp_project_dir();
let out = append_projection(base.clone(), Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag())).await;
assert_eq!(out, base, "两文件缺失时 prompt 必须逐字不变");
let out_no_dir = append_projection(base.clone(), None, &format!("conv-{}", unique_tag())).await;
assert_eq!(out_no_dir, base, "无授权目录时 prompt 必须逐字不变");
std::fs::remove_dir_all(&dir).ok();
}
// ── 只有 CLAUDE.md → 并入生效 ──
#[tokio::test]
async fn claude_only_merged() {
let dir = temp_project_dir();
write_file(&dir, "CLAUDE.md", "# 项目约定\n- 提交信息用中文");
let out = append_projection("BASE".to_string(), Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag())).await;
assert!(out.starts_with("BASE"), "既有段必须是前缀");
assert!(out.contains("提交信息用中文"), "CLAUDE.md 正文须并入");
assert!(out.contains("### 项目规范(CLAUDE.md)"), "并入段须带 CLAUDE.md 标注");
assert!(!out.contains("devflow.md"), "仅 CLAUDE.md 时不得出现 devflow.md 标注");
std::fs::remove_dir_all(&dir).ok();
}
// ── 双文件并存 → 优先级正确(devflow.md 在前 + 冲突注明,双内容齐备) ──
#[tokio::test]
async fn both_files_devflow_priority() {
let dir = temp_project_dir();
write_file(&dir, "devflow.md", "## 规范纪律\n- 禁 sed 改代码\n\n## 领单纪律\n- 领单后改 [→]");
write_file(&dir, "CLAUDE.md", "# 旧约定\n- 旧规则条目");
let out = append_projection("BASE".to_string(), Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag())).await;
let dev_pos = out.find("禁 sed 改代码").expect("devflow.md 纪律正文须注入");
let claim_pos = out.find("领单后改").expect("领单分区须注入");
let claude_pos = out.find("旧规则条目").expect("CLAUDE.md 正文须并入");
assert!(dev_pos < claude_pos && claim_pos < claude_pos, "devflow.md 内容必须排在 CLAUDE.md 之前(优先级)");
assert!(out.contains("冲突以 devflow.md 为准"), "双文件并存须注明优先级");
assert!(out.contains("### 规范纪律(devflow.md)"));
assert!(out.contains("### 领单纪律(devflow.md)"));
assert!(out.contains("### 项目规范(CLAUDE.md)"));
std::fs::remove_dir_all(&dir).ok();
}
// ── 超长裁剪:分区上限 + 总上限生效并注明 ──
#[test]
fn caps_truncate_with_marker() {
let long = "纪".repeat(DISCIPLINE_MAX_CHARS + 100);
let section = build_section(&long, "", None).expect("非空输入必有段");
let discipline_part = section.split("### 规范纪律(devflow.md)\n").nth(1).unwrap_or("");
assert!(discipline_part.contains("超上限已截断"), "分区超限须注明");
let kept = discipline_part.split("\n…(超上限已截断)").next().unwrap();
assert_eq!(kept.chars().count(), DISCIPLINE_MAX_CHARS, "分区保留长度须恰为上限");
// 总上限:各分区压线 + CLAUDE.md 超量,叠加必超总闸
let discipline = "a".repeat(DISCIPLINE_MAX_CHARS);
let claiming = "b".repeat(CLAIM_MAX_CHARS);
let claude = "c".repeat(CLAUDE_MD_MAX_CHARS);
let section = build_section(&discipline, &claiming, Some(&claude)).expect("非空输入必有段");
assert!(section.chars().count() <= SECTION_MAX_CHARS + "…(投影段超出总上限已截断)".len());
assert!(section.ends_with("…(投影段超出总上限已截断)"), "总超限须整段截断并注明");
}
// ── 稳定尾段:前段逐字保留,投影段独占尾部 ──
#[tokio::test]
async fn stable_tail_append() {
let dir = temp_project_dir();
write_file(&dir, "CLAUDE.md", "- 规则条目");
let base = "## 行为准则\n- 既有段内容\n";
let out = append_projection(base.to_string(), Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag())).await;
assert!(out.starts_with(base), "既有段必须逐字保留为前缀");
let tail = out.strip_prefix(base).unwrap();
assert!(tail.starts_with("\n\n## 项目认知投影"), "投影段须紧跟既有段之后成稳定尾段");
std::fs::remove_dir_all(&dir).ok();
}
// ── 会话缓存:同会话只读一次盘,文件中途改写不影响本会话;新会话读到新内容 ──
#[tokio::test]
async fn cached_per_conversation() {
let dir = temp_project_dir();
let tag = unique_tag();
write_file(&dir, "CLAUDE.md", "- 第一版规则");
let conv = format!("conv-{}", tag);
let first = append_projection("B".to_string(), Some(dir.to_str().unwrap()), &conv).await;
write_file(&dir, "CLAUDE.md", "- 第二版规则");
let again = append_projection("B".to_string(), Some(dir.to_str().unwrap()), &conv).await;
assert_eq!(first, again, "同会话第二次组装必须复用缓存(文件改写不影响)");
assert!(first.contains("第一版规则"));
let fresh = append_projection("B".to_string(), Some(dir.to_str().unwrap()), &format!("conv-{}-new", tag)).await;
assert!(fresh.contains("第二版规则"), "新会话须重读盘拿到最新内容");
std::fs::remove_dir_all(&dir).ok();
}
// ── 分区解析:关键字命中 / 子标题保留 / 未命中块裁掉 / 扁平回退 ──
#[test]
fn partition_extraction() {
let content = "\
# 项目认知
前言段落应被裁掉。
## 规范纪律
- 纪律条目一
### 提交规范
- 子标题下的纪律条目
## 看板摘要
- 未命中关键字的块应被裁掉
## 领单纪律
- 领单条目一
";
let (dev, claim) = extract_partitions(content);
assert!(dev.contains("纪律条目一"), "规范纪律块须命中");
assert!(dev.contains("子标题下的纪律条目"), "块内子标题结构须保留");
assert!(!dev.contains("看板摘要") && !dev.contains("前言段落"), "未命中块与前言须裁掉");
assert_eq!(claim, "- 领单条目一", "领单分区须独立提取");
// 扁平文件(无命中标题)回退:整文件按规范纪律
let (dev, claim) = extract_partitions("- 扁平规则一\n- 扁平规则二\n");
assert_eq!(dev, "- 扁平规则一\n- 扁平规则二");
assert!(claim.is_empty());
// 深层标题起始的块:同级才截断
let (dev, claim) = extract_partitions("### 领单\n- 深层领单条目\n\n## 其他\n- 其他块内容\n");
assert_eq!(claim, "- 深层领单条目", "深层起始块到同级标题截断");
assert!(!dev.contains("其他块内容"));
}
// ── 工作目录:排序取首稳定(HashSet 迭代序不定,不排序结果不可复现) ──
#[test]
fn work_dir_sorted_first() {
let mut allowed = AllowedDirs::default();
allowed.persistent.insert(PathBuf::from("E:/wk-b/b-project"));
allowed.persistent.insert(PathBuf::from("E:/wk-a/a-project"));
assert_eq!(work_dir_of(&allowed).as_deref(), Some("E:/wk-a/a-project"));
let empty = AllowedDirs::default();
assert_eq!(work_dir_of(&empty), None, "无授权目录返 None");
}
// ── 构建纯函数:全空 None / 单区缺省不产生空小节 ──
#[test]
fn build_section_edge_cases() {
assert!(build_section("", "", None).is_none(), "三路全空须 None");
assert!(build_section("", "", Some(" ")).is_none(), "空白 CLAUDE.md 视同缺失");
let only_claim = build_section("", "- 领单规则", None).expect("单分区非空须有段");
assert!(only_claim.contains("### 领单纪律(devflow.md)"));
assert!(!only_claim.contains("### 规范纪律"), "空分区不得产生空小节");
assert!(!only_claim.contains("冲突以"), "单文件不得注明双文件优先级");
}
// ============================================================
// 知识路标子段(kb 句柄经 projection_section 显式注入,不经进程级全局:
// 单测并发零串扰,append_projection 生产路径的句柄解析另由 app_handle 缺席态天然覆盖)
// ============================================================
use df_storage::crud::{AiConversationRepo, ProjectRepo};
use df_storage::models::{AiConversationRecord, KnowledgeRecord};
/// 知识条目 fixture(published,未验证,归属可选)
fn kentry(id: &str, title: &str, reuse: i32, verified: bool, project: Option<&str>) -> KnowledgeRecord {
KnowledgeRecord {
id: id.to_string(),
kind: "lesson".to_string(),
title: title.to_string(),
content: "正文".to_string(),
tags: Some("[]".to_string()),
status: "published".to_string(),
confidence: None,
reuse_count: reuse,
verified,
source_project: project.map(|p| p.to_string()),
source_ref: None,
reasoning: None,
embedding_status: None,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
}
}
/// 插入测试项目(同 knowledge_inject 测试口径)
async fn ins_project(db: &Database, id: &str, path: &str) {
ProjectRepo::new(db)
.insert(df_storage::models::ProjectRecord {
id: id.into(),
name: "路标项目".into(),
description: String::new(),
status: df_types::types::ProjectStatus::Planning,
idea_id: None,
path: Some(path.into()),
stack: None,
archived: false,
created_at: "2026-01-01".into(),
updated_at: "2026-01-01".into(),
})
.await
.unwrap();
}
/// 插入测试会话,带 boundary_json(dir_allowlist 单目录 → 级 a 归属解析用)
async fn ins_conv_with_boundary(db: &Database, conv_id: &str, allow_dir: &str) {
let boundary = crate::commands::ai::boundary::TaskBoundary {
risk_ceiling: df_ai::ai_tools::RiskLevel::Medium,
dir_allowlist: vec![allow_dir.to_string()],
forbid_destructive: true,
budget: None,
};
AiConversationRepo::new(db)
.insert(AiConversationRecord {
id: conv_id.into(),
title: Some("t".into()),
messages: "[]".into(),
provider_id: None,
model: None,
models: None,
archived: false,
pinned: false,
prompt_tokens: None,
completion_tokens: None,
pinned_goals: None,
pending_approvals: None,
created_at: "2026-01-01".into(),
updated_at: "2026-01-01".into(),
origin: None,
boundary_json: Some(serde_json::to_string(&boundary).unwrap()),
loop_state: None,
status: "active".into(),
working_context_json: None,
})
.await
.unwrap();
}
async fn memory_kb() -> Arc<Database> {
Arc::new(Database::open_in_memory().await.expect("内存库"))
}
// ── 空库零回归:注入空库句柄,投影段与无库时逐字一致 ──
#[tokio::test]
async fn empty_kb_zero_regression() {
let db = memory_kb().await;
let dir = temp_project_dir();
// 无规则文件 + 空库 → None(prompt 逐字不变)
let none = projection_section(Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag()), Some(db.clone())).await;
assert_eq!(none, None, "空库且无规则文件,投影段必须缺席");
// 有规则文件 + 空库 → 规则照常注入,路标子块零出现(其余行为零变化)
write_file(&dir, "CLAUDE.md", "- 规则条目");
let rules_only = projection_section(Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag()), Some(db)).await;
let sec = rules_only.expect("有规则文件必有段");
assert!(sec.contains("### 项目规范(CLAUDE.md)"));
assert!(!sec.contains("知识库路标"), "空库不得出现路标子段");
std::fs::remove_dir_all(&dir).ok();
}
// ── 入选过滤:verified 或复用达标才入段,渲染为紧凑行 + 段首说明 + 段尾固定提示 ──
#[tokio::test]
async fn waymark_selects_verified_or_reused() {
let db = memory_kb().await;
let repo = KnowledgeRepo::new(&db);
repo.insert(kentry("ok-v", "已验证经验", 0, true, None)).await.unwrap();
repo.insert(kentry("ok-r", "复用达标经验", 3, false, None)).await.unwrap();
repo.insert(kentry("no-r", "复用不足应淘汰", 2, false, None)).await.unwrap();
let mut cand = kentry("no-c", "候选未发布应淘汰", 99, false, None);
cand.status = "candidate".to_string();
repo.insert(cand).await.unwrap();
let sec = projection_section(None, &format!("conv-{}", unique_tag()), Some(db))
.await
.expect("有合格条目必有段");
assert!(sec.starts_with("## 项目认知投影"), "仅路标在场时须独占投影段");
assert!(sec.contains("项目知识库路标(详情用 knowledge_search 按标题关键词检索)"), "段首说明行");
assert!(sec.contains("\n- 已验证经验 | 复用0次"), "verified 豁免复用门槛入段");
assert!(sec.contains("\n- 复用达标经验 | 复用3次"), "复用压线达标入段");
assert!(!sec.contains("复用不足应淘汰"), "复用不足不入段");
assert!(!sec.contains("候选未发布应淘汰"), "非 published 不入段");
assert!(
sec.ends_with("上述经验条目可直接复用;遇到工具故障/重复失败先检索路标"),
"段尾固定提示行"
);
}
// ── 项目优先:boundary 解析出归属项目后,项目档在前,跨项目补充档按复用降序 ──
#[tokio::test]
async fn waymark_project_tier_first() {
let db = memory_kb().await;
ins_project(&db, "p-way", "e:/wk-lab/waymark-proj").await;
ins_conv_with_boundary(&db, "conv-way", "e:/wk-lab/waymark-proj").await;
let repo = KnowledgeRepo::new(&db);
repo.insert(kentry("same1", "同项目条目", 5, false, Some("p-way"))).await.unwrap();
repo.insert(kentry("foreign", "异项目高复用条目", 99, false, Some("p-other"))).await.unwrap();
repo.insert(kentry("global1", "全局无归属条目", 10, false, None)).await.unwrap();
let sec = projection_section(None, "conv-way", Some(db)).await.expect("必有段");
let same = sec.find("同项目条目").expect("同项目条目须入项目档");
let global = sec.find("全局无归属条目").expect("全局条目须入补充档");
let foreign = sec.find("异项目高复用条目").expect("异项目条目须入补充档");
assert!(
same < foreign && foreign < global,
"项目档在前;补充档内复用降序(异项目99 先于全局10)"
);
}
// ── 会话缓存:同会话复用首查结果,库内追加不影响;新会话查到新条目 ──
#[tokio::test]
async fn waymark_cached_per_conversation() {
let db = memory_kb().await;
let repo = KnowledgeRepo::new(&db);
repo.insert(kentry("a1", "首批经验", 3, false, None)).await.unwrap();
let conv = format!("conv-{}", unique_tag());
let first = projection_section(None, &conv, Some(db.clone())).await.expect("必有段");
assert!(first.contains("首批经验"));
repo.insert(kentry("b2", "次批经验", 3, false, None)).await.unwrap();
let again = projection_section(None, &conv, Some(db.clone())).await.expect("必有段");
assert_eq!(first, again, "同会话必须复用缓存(库内追加不影响)");
assert!(!again.contains("次批经验"));
let fresh = projection_section(None, &format!("{conv}-new"), Some(db)).await.expect("必有段");
assert!(fresh.contains("次批经验"), "新会话须重查库拿到新条目");
}
// ── 渲染纯函数:标题压平换行 + 截断注省略号 + 空白标题跳过 ──
#[test]
fn render_waymark_flattens_and_caps_title() {
let mut long = kentry("long", "", 7, false, None);
long.title = format!("长{}标题", "词".repeat(60)); // 63 字,超 60 上限
let mut multiline = kentry("multi", "含\n换行\t与制表", 2, true, None);
multiline.verified = true;
let blank = kentry("blank", " \n ", 1, true, None);
let out = render_waymark(&[long, multiline, blank]);
let line = out.lines().find(|l| l.starts_with("- 长")).expect("截断标题行在场");
assert!(line.ends_with("… | 复用7次"), "超长标题须截断注省略号");
assert!(line.matches('词').count() <= WAYMARK_TITLE_MAX_CHARS, "标题不得超上限");
assert!(out.contains("- 含 换行 与制表 | 复用2次"), "换行/制表压平为单空格");
assert!(!out.contains("- |"), "空白标题条目须跳过");
}
}
+433 -62
View File
@@ -27,7 +27,7 @@ use super::boundary::TaskBoundary;
use crate::commands::ai::audit::truncate_chars_ellipsis; use crate::commands::ai::audit::truncate_chars_ellipsis;
use crate::commands::err_str; use crate::commands::err_str;
use super::{AiSession}; use super::{AiSession, PerConvState};
// ============================================================ // ============================================================
// 知识注入/提炼参数(集中收口,不散魔法数) // 知识注入/提炼参数(集中收口,不散魔法数)
@@ -902,6 +902,250 @@ const EXTRACTION_SYSTEM_PROMPT: &str = "你是知识提炼引擎,从 AI 对话
4. 输出纯 JSON,无任何额外文字\n\ 4. 输出纯 JSON,无任何额外文字\n\
5. 最多提取 5 条,按复用价值降序排列(只保留高价值条目,宁缺毋滥)"; 5. 最多提取 5 条,按复用价值降序排列(只保留高价值条目,宁缺毋滥)";
/// 终态提炼提示词 — 在常规提示词上加终态边界(设计 §3.1):产出限跨会话可复用经验,
/// 未完成事项/待办是会话任务态不是知识,归 L4 目标快照与停靠说明承接,不进知识表。
fn terminal_extraction_system_prompt() -> String {
format!(
"{}\n6. 本次提炼发生在会话终态(删除前或停靠),只提炼跨会话可复用的经验:决策与依据、\
事实发现(环境约束/结构/坑)、偏好与约定、踩坑;未完成事项、待办、下一步计划是会话\
任务态不是知识,一律不要提炼",
EXTRACTION_SYSTEM_PROMPT
)
}
// ============================================================
// 会话终态提炼(跨会话召回写入收口):删除前 + parked 停靠两挂点
// ============================================================
/// 终态提炼内存快照(挂点时刻):全量 active 消息 + WorkingContext 决策/未解项。
/// 快照即输入——删除链路调用方随即删 per_conv 与 DB 行,后台提炼只认快照不回读。
struct TerminalExtractSnapshot {
/// 全量 active user/assistant 消息(时间序,不受常规提炼尾部窗口限制)
messages: Vec<ChatMessage>,
/// WorkingContext 已确认决策(此刻内存最新态,决策 + 依据拼合)
decisions: Vec<String>,
/// 未解项(仅供提炼理解上下文,提示词已约定不作为知识产出)
unresolved: Vec<String>,
}
impl TerminalExtractSnapshot {
/// 有实质产出才提:active 消息达常规提炼的门槛量,或 WorkingContext 已有沉淀
/// (压缩后 active 消息少但决策/未解项仍在的会话,这些结构化输入本身值得提一次)。
fn has_extractable_input(&self) -> bool {
self.messages.len() >= 4 || !self.decisions.is_empty() || !self.unresolved.is_empty()
}
}
/// 决策条目单行文本:决策 + 依据(依据空则只留决策)。
fn decision_line(d: &df_ai::context_helpers::DecisionItem) -> String {
if d.reason.trim().is_empty() {
d.decision.clone()
} else {
format!("{}(依据: {})", d.decision, d.reason)
}
}
/// 从 per_conv 取终态提炼快照(须在挂点锁内同步调用:删除链路随后即删 per_conv)。
fn take_terminal_snapshot(conv: &PerConvState) -> TerminalExtractSnapshot {
TerminalExtractSnapshot {
messages: conv
.messages
.iter()
.filter(|m| {
matches!(m.role, MessageRole::User | MessageRole::Assistant) && m.is_active()
})
.cloned()
.collect(),
decisions: conv
.working_context
.decisions
.iter()
.map(decision_line)
.collect(),
unresolved: conv.working_context.unresolved.clone(),
}
}
/// 压缩存档级联清理(删除链路):会话删除后其存档文件再无召回方,一并移除。
/// 路径规则只读引用 df-ai 压缩召回存档约定(<data_dir>/context-archive/<conv_id>-archive.jsonl)。
fn remove_conv_archive(data_dir: &std::path::Path, conv_id: &str) {
let path = data_dir
.join("context-archive")
.join(format!("{}-archive.jsonl", conv_id));
match std::fs::remove_file(&path) {
Ok(()) => tracing::info!(conv_id, path = %path.display(), "[knowledge] 会话压缩存档已级联清理"),
// 无存档(该会话未触发过压缩召回)是常态,静默
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => tracing::warn!(conv_id, error = %e, path = %path.display(), "[knowledge] 会话压缩存档清理失败(非阻断)"),
}
}
/// 会话终态提炼入口(写入收口):删除前 + parked 停靠两挂点共用。
///
/// 与常规提炼的差异:强制清水位(清 Done 节奏水位与哨兵,不受「10 条新用户消息」水位
/// 限制,复用手动提炼的清哨兵语义);提炼范围取全量 active 消息并喂入 WorkingContext
/// 决策/未解项;产出边界限跨会话可复用经验,未完成事项不进知识表。防重走 InFlight
/// 水位,与可能在飞的常规提炼互斥。
///
/// 挂点同步完成的只有快照与水位占位(微秒级),LLM 提炼后台 fire-and-forget,
/// 调用方(删除动作/停靠事件)不被阻塞。`cleanup_archive_dir`: Some = 删除链路,
/// 提炼收尾后级联清理压缩存档(先提炼后删文件);提炼未发生的三类出口
/// (无内存态/占位互斥/provider 不可用)存档照清,防孤儿文件。
pub(crate) async fn spawn_terminal_extraction(
state: &AppState,
conv_id: &str,
cleanup_archive_dir: Option<std::path::PathBuf>,
) {
// 会话标题(生命线溯源展示用):此刻行还在(删除链路调用方在挂点返回后才删行),
// 后台任务不再回读 DB,取不到降级占位。
let conv_title = conv_title_or_placeholder(
AiConversationRepo::new(&state.db)
.get_by_id(conv_id)
.await
.ok()
.flatten()
.and_then(|c| c.title),
);
// 快照 + 强制清水位同一锁临界区(TOCTOU 对齐 maybe_spawn_extraction):快照取此刻
// 内存最新态(含未落库轮次),清哨兵语义与手动提炼一致——只清 Done 节奏水位,
// InFlight 占位保留作互斥(下方占位检查拦截)。
let snapshot = {
let mut session = state.ai_session.lock().await;
let snap = session.conv_read(conv_id).map(take_terminal_snapshot);
if snap.is_some() {
session.conv(conv_id).knowledge_extracted = false;
let mut wms = EXTRACT_WATERMARKS.lock().expect("提炼水位互斥锁中毒");
if matches!(wms.get(conv_id), Some(ExtractWatermark::Done(_))) {
wms.remove(conv_id);
}
}
snap
};
// 出口 1:无内存态——会话从未在本进程打开(消息只在 DB),无 WorkingContext 可喂,
// 删除链路即将删行,不做 DB 抢读竞态(设计:未及提炼的随会话消失是可接受损失)。
let Some(snapshot) = snapshot else {
if let Some(dir) = &cleanup_archive_dir {
remove_conv_archive(dir, conv_id);
}
return;
};
if !snapshot.has_extractable_input() {
if let Some(dir) = &cleanup_archive_dir {
remove_conv_archive(dir, conv_id);
}
return;
}
// provider 先于 InFlight 占位解析:解析失败的出口不再占位(占位先置会因无任务
// 收口而永久卡死该会话的后续提炼)。
let provider_config = match super::prompt::get_active_provider(state).await {
Ok(p) => p,
Err(e) => {
tracing::warn!(conv_id, error = %e, "[knowledge] 终态提炼跳过:无可用 provider");
if let Some(dir) = &cleanup_archive_dir {
remove_conv_archive(dir, conv_id);
}
return;
}
};
// 出口 2:InFlight 占位互斥——在飞常规提炼已在覆盖近期内容,不叠加刷 candidate。
{
let mut wms = EXTRACT_WATERMARKS.lock().expect("提炼水位互斥锁中毒");
if matches!(wms.get(conv_id), Some(ExtractWatermark::InFlight)) {
tracing::info!(conv_id, "[knowledge] 终态提炼跳过:已有提炼任务进行中");
if let Some(dir) = &cleanup_archive_dir {
remove_conv_archive(dir, conv_id);
}
return;
}
wms.insert(conv_id.to_string(), ExtractWatermark::InFlight);
}
let db = state.db.clone();
let session_arc = state.ai_session.clone();
let llm_concurrency = state.llm_concurrency.clone();
let conv_id = conv_id.to_string();
spawn_with_inflight_guard(conv_id.clone(), async move {
let result =
extract_terminal_knowledge(&db, &conv_id, &snapshot, &provider_config, &llm_concurrency, &conv_title)
.await;
match &result {
Ok(inserted) => tracing::info!(conv_id = %conv_id, inserted, "[knowledge] 终态提炼完成"),
Err(e) => tracing::warn!(conv_id = %conv_id, error = %e, "[knowledge] 终态提炼失败(非阻断)"),
}
// 水位收口:任意出口记 Done 保节奏(与手动/自动路径同语义);哨兵只在会话仍在
// 内存时置位(get_mut 不惰性重建——删除链路的 per_conv 已随删除消失)。
{
let mut session = session_arc.lock().await;
let rounds = session
.conv_read(&conv_id)
.map(|c| count_user_rounds(c.messages.iter()))
.unwrap_or(0);
if matches!(result, Ok(n) if n > 0) {
if let Some(conv) = session.per_conv.get_mut(&conv_id) {
conv.knowledge_extracted = true;
}
}
EXTRACT_WATERMARKS
.lock()
.expect("提炼水位互斥锁中毒")
.insert(conv_id.clone(), ExtractWatermark::Done(rounds));
}
// 删除链路:提炼收尾后级联清理压缩存档(先提炼后删文件)。
if let Some(dir) = cleanup_archive_dir {
remove_conv_archive(&dir, &conv_id);
}
});
}
/// 终态提炼:全量 active 消息 + WorkingContext 决策/未解项喂提炼,产出限跨会话可复用
/// 经验(未完成事项由提示词边界拦截),落库走与常规提炼同一核心。
async fn extract_terminal_knowledge(
db: &Arc<Database>,
conv_id: &str,
snapshot: &TerminalExtractSnapshot,
provider_config: &AiProviderRecord,
llm_concurrency: &LlmConcurrency,
conv_title: &str,
) -> anyhow::Result<usize> {
let mut conv_text = String::new();
// WorkingContext 此刻内存最新态:让「本会话定了什么、卡在哪」成为提炼输入而非仅靠
// 消息流重放;未解项标注仅供理解上下文,配合提示词边界不产出为知识。
if !snapshot.decisions.is_empty() {
conv_text.push_str("[本会话已确认的决策]\n");
for d in &snapshot.decisions {
conv_text.push_str(d);
conv_text.push('\n');
}
}
if !snapshot.unresolved.is_empty() {
conv_text.push_str("[本会话未解决的事项(仅供理解上下文,不是知识)]\n");
for u in &snapshot.unresolved {
conv_text.push_str(u);
conv_text.push('\n');
}
}
conv_text.push_str("\n[对话内容]\n");
for m in &snapshot.messages {
let role = match m.role {
MessageRole::User => "用户",
MessageRole::Assistant => "助手",
_ => continue,
};
conv_text.push_str(&format!("[{}]: {}\n\n", role, m.content));
}
run_extraction_core(
db,
conv_id,
provider_config,
llm_concurrency,
conv_title,
&snapshot.messages,
&terminal_extraction_system_prompt(),
&conv_text,
)
.await
}
/// 弱推断会话唯一提及的项目名(级 b,**纯函数**:仅解析消息文本,不碰 DB)。 /// 弱推断会话唯一提及的项目名(级 b,**纯函数**:仅解析消息文本,不碰 DB)。
/// ///
/// 反向扫最近 20 条 user 消息,收集全部 `[项目:名称]` 标记(chat.rs `extract_pinned_goal` /// 反向扫最近 20 条 user 消息,收集全部 `[项目:名称]` 标记(chat.rs `extract_pinned_goal`
@@ -945,7 +1189,9 @@ fn unique_mentioned_project(messages: &[ChatMessage]) -> Option<&str> {
/// ///
/// 两级皆不命中 → None(全局知识,注入侧不过滤,行为与存量一致)。 /// 两级皆不命中 → None(全局知识,注入侧不过滤,行为与存量一致)。
/// DB 查询失败一律降级 None(warn 留痕,非阻断:归属推断失败不该杀死提炼主流程)。 /// DB 查询失败一律降级 None(warn 留痕,非阻断:归属推断失败不该杀死提炼主流程)。
async fn resolve_source_project(db: &Arc<Database>, conv_id: &str, messages: &[ChatMessage]) -> Option<String> { /// 工具侧(knowledge_search)以空消息复用本函数:注入侧与工具侧同一归属口径,
/// 级 b 依赖消息扫描,工具 handler 无消息入参,空切片下仅级 a boundary 生效。
pub(crate) async fn resolve_source_project(db: &Arc<Database>, conv_id: &str, messages: &[ChatMessage]) -> Option<String> {
let project_repo = ProjectRepo::new(db); let project_repo = ProjectRepo::new(db);
let conv_repo = AiConversationRepo::new(db); let conv_repo = AiConversationRepo::new(db);
@@ -1003,10 +1249,10 @@ fn normalize_dir_for_match(dir: &str) -> String {
s.to_lowercase() s.to_lowercase()
} }
/// 从对话中提炼知识,产出 candidate 写入知识库 /// 从对话中提炼知识,产出 candidate 写入知识库(常规自动/手动提炼入口)
/// ///
/// 流程: 读对话消息 → 过滤 user/assistant 取最近 [`KNOWLEDGE_EXTRACT_WINDOW`] 条 → /// 流程: 读对话消息 → 过滤 user/assistant 取最近 [`KNOWLEDGE_EXTRACT_WINDOW`] 条 →
/// LLM 提炼(强制 JSON) → 解析 → 批量插入 candidate /// 组装提炼输入 → [`run_extraction_core`] 完成提炼与落库
/// ///
/// 返回值: 成功插入的 candidate 条数(inserted)。调用方(maybe_spawn_extraction)据此置 /// 返回值: 成功插入的 candidate 条数(inserted)。调用方(maybe_spawn_extraction)据此置
/// `knowledge_extracted` 去重标志(≥1 才置位,0 条不置位—允许下次自动触发重试)。 /// `knowledge_extracted` 去重标志(≥1 才置位,0 条不置位—允许下次自动触发重试)。
@@ -1021,12 +1267,7 @@ async fn extract_knowledge_from_conversation(
.get_by_id(conv_id) .get_by_id(conv_id)
.await? .await?
.ok_or_else(|| anyhow::anyhow!("对话不存在: {}", conv_id))?; .ok_or_else(|| anyhow::anyhow!("对话不存在: {}", conv_id))?;
// 对话标题(生命线溯源用,空标题降级为占位,避免字节切片风险) let conv_title = conv_title_or_placeholder(conv.title.clone());
let conv_title = conv
.title
.clone()
.filter(|t| !t.trim().is_empty())
.unwrap_or_else(|| "未命名对话".to_string());
// 消息读取:优先 ai_messages 表(消息拆分存储真相源),表空时 fallback 旧 messages JSON 列(老库兼容) // 消息读取:优先 ai_messages 表(消息拆分存储真相源),表空时 fallback 旧 messages JSON 列(老库兼容)
// 注意:DB 查询 Err 不能吞成空 Vec(否则 records.is_empty() 误为真 → 走旧 messages JSON 回退, // 注意:DB 查询 Err 不能吞成空 Vec(否则 records.is_empty() 误为真 → 走旧 messages JSON 回退,
@@ -1080,17 +1321,8 @@ async fn extract_knowledge_from_conversation(
return Ok(0); // 太短,不值得提炼(0 条,不置去重标志) return Ok(0); // 太短,不值得提炼(0 条,不置去重标志)
} }
// F-260619-04 P1 消息级溯源:取末条 assistant 消息 id(本轮 AI 产出知识的载体)。 // 构造提炼输入:常规提炼只看尾部窗口(终态提炼走 extract_terminal_knowledge 另入口,
// 反向扫描全量 messages(不限 recent 6 条,确保取到对话最新 assistant,即便它不在 // 全量 active + WorkingContext 上下文一并喂提炼)
// 提炼窗口内也属于本轮 AI 产出)。老数据消息无 id → None,source_ref 降级 None,
// 展示侧兼容 conv: 旧格式 + 无 source_ref。
let last_assistant_msg_id: Option<&str> = messages
.iter()
.rev()
.find(|m| matches!(m.role, MessageRole::Assistant))
.and_then(|m| m.id.as_deref());
// 构造提炼消息: system 指令 + 对话内容(user 角色)
let mut conv_text = String::new(); let mut conv_text = String::new();
for m in recent.iter().rev() { for m in recent.iter().rev() {
let role = match m.role { let role = match m.role {
@@ -1101,8 +1333,92 @@ async fn extract_knowledge_from_conversation(
conv_text.push_str(&format!("[{}]: {}\n\n", role, m.content)); conv_text.push_str(&format!("[{}]: {}\n\n", role, m.content));
} }
run_extraction_core(
db,
conv_id,
provider_config,
llm_concurrency,
&conv_title,
&messages,
EXTRACTION_SYSTEM_PROMPT,
&conv_text,
)
.await
}
/// 会话标题降级(提炼生命线溯源展示用):空白标题降级占位。
fn conv_title_or_placeholder(title: Option<String>) -> String {
title
.filter(|t| !t.trim().is_empty())
.unwrap_or_else(|| "未命名对话".to_string())
}
/// LLM 产出单条 → candidate 知识记录(常规/终态提炼共用的落库统一口径)。
/// kind 缺失或标题/正文为空返回 None(调用方跳过该条);status 恒 candidate(LLM 写入
/// 不自动发布,与工具沉淀同信任级);归属与溯源 source_ref 由调用方按统一口径给定。
fn candidate_record_from_item(
item: &serde_json::Value,
source_project: Option<&str>,
source_ref: Option<String>,
) -> Option<KnowledgeRecord> {
let kind = item.get("kind").and_then(|v| v.as_str())?.to_string();
let title = item.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string();
let content = item.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string();
// 空白标题/正文与 kind 缺失同判:残缺条目跳过(与 knowledge_insert 工具入参校验同口径)
if title.trim().is_empty() || content.trim().is_empty() {
return None;
}
let tags = item.get("tags").map(|v| serde_json::to_string(v).unwrap_or_else(|_| "[]".into()));
let confidence = item.get("confidence").and_then(|v| v.as_str()).map(|s| s.to_string());
// 回填 AI 判断依据(prompt 要求的 reasoning 字段,此前被丢弃)
let reasoning = item.get("reasoning").and_then(|v| v.as_str()).map(|s| s.to_string());
let now = crate::commands::now_millis();
Some(KnowledgeRecord {
id: new_id(),
kind,
title,
content,
tags,
status: "candidate".to_string(),
confidence,
reuse_count: 0,
verified: false,
// 归属项目弱推断结果(级 a boundary/级 b 唯一 mention),None=全局
source_project: source_project.map(|p| p.to_string()),
source_ref,
reasoning,
embedding_status: None,
created_at: now.clone(),
updated_at: now,
})
}
/// 提炼核心(常规尾部窗口/终态全量共用):LLM 提炼 → 解析截断打捞 → 归属推断 →
/// candidate 批量落库。提炼输入由调用方组装;归属与消息级溯源口径统一
/// (source_ref 记 conv_msg:{assistant_msg_id},老数据无 id 降级 conv:{conv_id})。
async fn run_extraction_core(
db: &Arc<Database>,
conv_id: &str,
provider_config: &AiProviderRecord,
llm_concurrency: &LlmConcurrency,
conv_title: &str,
attribution_messages: &[ChatMessage],
system_prompt: &str,
conv_text: &str,
) -> anyhow::Result<usize> {
// 消息级溯源:末条 assistant 消息 id 是本轮 AI 产出知识的载体(常规路径反向扫全量
// messages,压缩消息也在内,确保取到对话最新 assistant;老数据无 id → 降级对话级)。
let last_assistant_msg_id: Option<&str> = attribution_messages
.iter()
.rev()
.find(|m| matches!(m.role, MessageRole::Assistant))
.and_then(|m| m.id.as_deref());
let source_ref = Some(match last_assistant_msg_id {
Some(mid) => format!("conv_msg:{}", mid),
None => format!("conv:{}", conv_id),
});
let extract_messages = vec![ let extract_messages = vec![
ChatMessage::system(EXTRACTION_SYSTEM_PROMPT), ChatMessage::system(system_prompt),
ChatMessage::user(&format!("请从以下对话中提炼可复用知识:\n\n{}", conv_text)), ChatMessage::user(&format!("请从以下对话中提炼可复用知识:\n\n{}", conv_text)),
]; ];
// F-01 阶段5: 知识提炼路由 — TaskRequirements(Standard,无工具)。 // F-01 阶段5: 知识提炼路由 — TaskRequirements(Standard,无工具)。
@@ -1177,50 +1493,14 @@ async fn extract_knowledge_from_conversation(
let knowledge_repo = KnowledgeRepo::new(db); let knowledge_repo = KnowledgeRepo::new(db);
let timeline = crate::commands::knowledge_timeline::KnowledgeTimeline::new(db); let timeline = crate::commands::knowledge_timeline::KnowledgeTimeline::new(db);
// 跨会话召回(D1 弱推断):本轮产出知识归属项目。None=全局知识(行为与存量一致)。 // 跨会话召回(D1 弱推断):本轮产出知识归属项目。None=全局知识(行为与存量一致)。
let source_project = resolve_source_project(db, conv_id, &messages).await; let source_project = resolve_source_project(db, conv_id, attribution_messages).await;
if let Some(pid) = &source_project { if let Some(pid) = &source_project {
tracing::info!(conv_id, project_id = %pid, "[KNOWLEDGE-EXTRACT] 本轮产出知识归属项目"); tracing::info!(conv_id, project_id = %pid, "[KNOWLEDGE-EXTRACT] 本轮产出知识归属项目");
} }
let mut inserted = 0; let mut inserted = 0;
for item in &items { for item in &items {
let kind = match item.get("kind").and_then(|v| v.as_str()) { let Some(record) = candidate_record_from_item(item, source_project.as_deref(), source_ref.clone()) else {
Some(k) => k.to_string(),
None => continue,
};
let title = item.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string();
let content = item.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string();
if title.is_empty() || content.is_empty() {
continue; continue;
}
let tags = item.get("tags").map(|v| serde_json::to_string(v).unwrap_or_else(|_| "[]".into()));
let confidence = item.get("confidence").and_then(|v| v.as_str()).map(|s| s.to_string());
// 回填 AI 判断依据(prompt 要求的 reasoning 字段,此前被丢弃)
let reasoning = item.get("reasoning").and_then(|v| v.as_str()).map(|s| s.to_string());
let now = crate::commands::now_millis();
let record = KnowledgeRecord {
id: new_id(),
kind,
title,
content,
tags,
status: "candidate".to_string(),
confidence,
reuse_count: 0,
verified: false,
// 跨会话召回(D1):归属项目弱推断结果(级 a boundary/级 b 唯一 mention),None=全局
source_project: source_project.clone(),
// F-260619-04 P1 消息级溯源:conv_msg:{assistant_msg_id} 精确定位本轮 AI 产出。
// last_assistant_msg_id 为 None(老数据无 id)→ 降级 conv:{conv_id} 对话级,
// 展示侧双格式解析兼容。
source_ref: Some(match last_assistant_msg_id {
Some(mid) => format!("conv_msg:{}", mid),
None => format!("conv:{}", conv_id),
}),
reasoning: reasoning.clone(),
embedding_status: None,
created_at: now.clone(),
updated_at: now,
}; };
match knowledge_repo.insert(record.clone()).await { match knowledge_repo.insert(record.clone()).await {
Ok(_) => { Ok(_) => {
@@ -1235,8 +1515,8 @@ async fn extract_knowledge_from_conversation(
.record_extracted( .record_extracted(
&record.id, &record.id,
conv_id, conv_id,
&conv_title, conv_title,
reasoning.as_deref().unwrap_or(""), record.reasoning.as_deref().unwrap_or(""),
last_assistant_msg_id, last_assistant_msg_id,
) )
.await; .await;
@@ -1521,6 +1801,96 @@ mod tests {
assert!(out.contains(&"编译".to_string())); assert!(out.contains(&"编译".to_string()));
assert!(out.contains(&"失败".to_string())); assert!(out.contains(&"失败".to_string()));
} }
// ---------- 会话终态提炼(跨会话召回写入收口) ----------
/// LLM 产出单条 → candidate 记录统一口径:溯源 source_ref 由调用方给定(消息级
/// conv_msg:{id}/对话级 conv:{id}),状态恒 candidate 不自动发布,归属项目透传。
#[test]
fn candidate_record_carries_source_ref_and_candidate_status() {
let item = serde_json::json!({
"kind": "pitfall",
"title": "跨端比对乱名",
"content": "传输层截断假象,两端各算 md5 数值裁决",
"tags": ["proxy"],
"confidence": "medium",
"reasoning": "可复用的排障口径"
});
let rec = candidate_record_from_item(&item, Some("p1"), Some("conv_msg:m-1".into()))
.expect("完整条目应产出记录");
assert_eq!(rec.status, "candidate", "LLM 产出恒为候选,不自动发布");
assert_eq!(rec.source_ref.as_deref(), Some("conv_msg:m-1"));
assert_eq!(rec.source_project.as_deref(), Some("p1"));
assert_eq!(rec.reasoning.as_deref(), Some("可复用的排障口径"));
}
/// kind 缺失或标题/正文空白的条目跳过,不产出残缺记录刷 candidate。
#[test]
fn candidate_record_skips_incomplete_items() {
let no_kind = serde_json::json!({"title": "t", "content": "c"});
assert!(candidate_record_from_item(&no_kind, None, None).is_none());
let blank_title = serde_json::json!({"kind": "pitfall", "title": " ", "content": "c"});
assert!(candidate_record_from_item(&blank_title, None, None).is_none());
}
/// 终态快照:取全量 active user/assistant 消息(压缩/软删消息排除)+ WorkingContext
/// 决策(决策+依据拼合)与未解项。
#[test]
fn terminal_snapshot_takes_active_messages_and_working_context() {
let mut conv = PerConvState::new();
conv.messages.push(ChatMessage::user("早期问题"));
let mut compressed = ChatMessage::assistant("已压缩回复");
compressed.status = Some(df_ai::provider::MessageStatus::Compressed);
conv.messages.push(compressed);
conv.messages.push(ChatMessage::assistant("最新回复"));
conv.working_context.decisions.push(df_ai::context_helpers::DecisionItem {
question: "用哪个端口".into(),
decision: "统一走 3307 代理".into(),
reason: "防直连风控".into(),
});
conv.working_context.unresolved.push("证书续期未验证".into());
let snap = take_terminal_snapshot(&conv);
assert_eq!(snap.messages.len(), 2, "非 active 消息不进终态提炼");
assert_eq!(snap.decisions, vec!["统一走 3307 代理(依据: 防直连风控)".to_string()]);
assert_eq!(snap.unresolved, vec!["证书续期未验证".to_string()]);
assert!(snap.has_extractable_input());
}
/// 空闲寒暄会话(消息过少且 WorkingContext 无沉淀)不提,省一次无效 LLM 调用。
#[test]
fn terminal_snapshot_chitchat_not_extractable() {
let mut conv = PerConvState::new();
conv.messages.push(ChatMessage::user("在吗"));
conv.messages.push(ChatMessage::assistant("在"));
assert!(!take_terminal_snapshot(&conv).has_extractable_input());
}
/// 存档级联清理:对应会话存档文件消失,他会话不受波及;重复清理幂等(NotFound 静默)。
#[test]
fn remove_conv_archive_deletes_only_target() {
let base = std::env::temp_dir().join(format!("d2a-arch-{}", std::process::id()));
let dir = base.join("context-archive");
std::fs::create_dir_all(&dir).unwrap();
let target = dir.join("conv-x-archive.jsonl");
std::fs::write(&target, "{\"role\":\"user\",\"content\":\"历史\"}\n").unwrap();
let other = dir.join("conv-y-archive.jsonl");
std::fs::write(&other, "keep").unwrap();
remove_conv_archive(&base, "conv-x");
assert!(!target.exists(), "对应会话存档应级联清理");
assert!(other.exists(), "他会话存档不受波及");
remove_conv_archive(&base, "conv-x"); // 幂等:文件已不在不报错
let _ = std::fs::remove_dir_all(&base);
}
/// 空白标题降级占位(生命线溯源展示不为空)。
#[test]
fn conv_title_blank_falls_back_to_placeholder() {
assert_eq!(conv_title_or_placeholder(None), "未命名对话");
assert_eq!(conv_title_or_placeholder(Some(" ".into())), "未命名对话");
assert_eq!(conv_title_or_placeholder(Some("标题".into())), "标题");
}
} }
#[cfg(test)] #[cfg(test)]
@@ -1616,6 +1986,7 @@ mod salvage_tests {
boundary_json: boundary.map(|b| serde_json::to_string(b).unwrap()), boundary_json: boundary.map(|b| serde_json::to_string(b).unwrap()),
loop_state: None, loop_state: None,
status: "active".into(), status: "active".into(),
working_context_json: None,
}) })
.await .await
.unwrap(); .unwrap();
+26
View File
@@ -36,6 +36,8 @@ pub mod command_stream;
pub mod commands; pub mod commands;
pub mod compress; pub mod compress;
pub mod conversation; pub mod conversation;
// 项目规则文件投影(devflow.md/CLAUDE.md):会话启动读取并入 system prompt 稳定尾段
pub(crate) mod devflow_md;
pub mod event_bus; pub mod event_bus;
pub mod http; pub mod http;
pub mod fetch_url; pub mod fetch_url;
@@ -74,6 +76,8 @@ pub mod unattended;
pub mod outbox; pub mod outbox;
// W-b 异步唤醒原语:内存 registry 三条件(delay/file-appear/process-exit)满足即注入续跑 // W-b 异步唤醒原语:内存 registry 三条件(delay/file-appear/process-exit)满足即注入续跑
pub mod wake; pub mod wake;
// 脉络卡数据面:published 知识按项目聚合,纯读侧喂前端「带入上下文」决策
pub mod context_briefs;
use agentic::conv_state::{ConvState, ConvStateStore}; use agentic::conv_state::{ConvState, ConvStateStore};
@@ -313,6 +317,11 @@ pub enum AiChatEvent {
/// 摘要 system 消息已由后端 insert_at,后续 stream 自然带出,无需整会话刷新。 /// 摘要 system 消息已由后端 insert_at,后续 stream 自然带出,无需整会话刷新。
/// miniapp 仍插摘要气泡(对端发生压缩告知用户)。 /// miniapp 仍插摘要气泡(对端发生压缩告知用户)。
AiAutoCompressed { conversation_id: Option<String>, summary: String }, AiAutoCompressed { conversation_id: Option<String>, summary: String },
/// E2 压缩失败降级透出(2026-10-18):LLM 压缩失败走关键词摘要兜底时,loop 仍在继续
/// (不发 AiError——其 case 无条件 setStreaming(false) 会误判生成结束),但用户须知晓
/// 上下文已降级(实证 76730ce8 seq166:静默降级→AI 失忆→用户被迫原句重发)。
/// 前端弹一次性状态提示,不中断流。
AiContextDegraded { conversation_id: Option<String>, reason: String },
/// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21): /// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
/// ///
/// agent 断路器(§2.2)熔断 / 自省(识别"我反复失败")时发,区别于 prompt 教 AI /// agent 断路器(§2.2)熔断 / 自省(识别"我反复失败")时发,区别于 prompt 教 AI
@@ -1061,6 +1070,10 @@ pub struct PerConvState {
/// 原轮次终点时读后清零消费、续跑一轮,让补充指令获得带工具的完整消费轮。 /// 原轮次终点时读后清零消费、续跑一轮,让补充指令获得带工具的完整消费轮。
/// 同锁读写(session lock 串行);loop 入口清零防跨 loop 残留。 /// 同锁读写(session lock 串行);loop 入口清零防跨 loop 残留。
pub round_budget_extra: usize, pub round_budget_extra: usize,
/// 未消化注入计数(2026-10-14 实测):inject_runtime_message 每次 +1;每轮 build_for_request
/// 组装后清零(消息已进本轮请求=消化);收敛出口查 >0 则不收敛续一轮,治「纯问答轮
/// 一轮收敛,生成期间到达的补充指令无人消费只能等下次请求」的丢失。内存态不落库。
pub pending_injects: u32,
/// 当前 agent 循环的语言设置(用于审批后恢复循环) /// 当前 agent 循环的语言设置(用于审批后恢复循环)
pub agent_language: Option<String>, pub agent_language: Option<String>,
/// 用户指定模型 override(主对话专用,语义同 AiSession.model_override) /// 用户指定模型 override(主对话专用,语义同 AiSession.model_override)
@@ -1262,6 +1275,7 @@ impl PerConvState {
iteration_used: 0, iteration_used: 0,
loop_max_rounds: 0, loop_max_rounds: 0,
round_budget_extra: 0, round_budget_extra: 0,
pending_injects: 0,
agent_language: None, agent_language: None,
model_override: None, model_override: None,
session_trust: HashSet::new(), session_trust: HashSet::new(),
@@ -1496,4 +1510,16 @@ pub(crate) fn emit_task_parked(app: &tauri::AppHandle, conv_id: &str, reason: &s
"任务已停靠", "任务已停靠",
reason, reason,
); );
// ④ 跨会话召回(终态提炼停靠挂点):停靠意味着任务未完结,此刻的目标/决策/未解项
// 最有提炼价值。fire-and-forget 不阻塞事件/通知链路,提炼失败仅内部留痕;
// 挂点同步快照在后台任务首段完成(本函数同步返回,不持 session 锁)。
{
use tauri::Manager;
let app = app.clone();
let conv_id = conv_id.to_string();
tauri::async_runtime::spawn(async move {
let state = app.state::<crate::state::AppState>();
knowledge_inject::spawn_terminal_extraction(&state, &conv_id, None).await;
});
}
} }
+57 -1
View File
@@ -242,6 +242,9 @@ async fn dispatch_claimed(
emit(app, "ai-outbox-sent", &item.id, &conv, None, None); emit(app, "ai-outbox-sent", &item.id, &conv, None, None);
emit(app, "ai-outbox-changed", &item.id, &conv, None, None); emit(app, "ai-outbox-changed", &item.id, &conv, None, None);
tracing::info!("[outbox] 派发 {} → 会话 {conv}", item.id); tracing::info!("[outbox] 派发 {} → 会话 {conv}", item.id);
// 959 结果回推:待发项派发后完成结果推回来源渠道(飞书 chat 映射)。
// 桌面端在看的会话重复可见无害;未绑 chat 时 push_to_chat 内部静默跳过
spawn_completion_push(app.clone(), conv.clone());
Ok(conv_id) Ok(conv_id)
} }
Err(err) => { Err(err) => {
@@ -444,6 +447,19 @@ pub async fn ai_outbox_enqueue(
skill: Option<String>, skill: Option<String>,
model_override: Option<String>, model_override: Option<String>,
conversation_id: String, conversation_id: String,
) -> Result<String, String> {
enqueue_internal(&app, &state, text, due_at, skill, model_override, conversation_id).await
}
/// 入队内部口(#959):前端 command 与飞书受理守卫拒绝路径共用,单点校验/唤醒/事件。
pub(crate) async fn enqueue_internal(
app: &AppHandle,
state: &AppState,
text: String,
due_at: Option<i64>,
skill: Option<String>,
model_override: Option<String>,
conversation_id: String,
) -> Result<String, String> { ) -> Result<String, String> {
validate_text(&text)?; validate_text(&text)?;
validate_conv_id(&conversation_id)?; validate_conv_id(&conversation_id)?;
@@ -477,7 +493,7 @@ pub async fn ai_outbox_enqueue(
.map_err(|e| format!("待发项写入失败: {e}"))?; .map_err(|e| format!("待发项写入失败: {e}"))?;
// 尽快项/到期项即时唤醒调度器(周期 tick 仅兜底);事件供各窗口视图刷新。 // 尽快项/到期项即时唤醒调度器(周期 tick 仅兜底);事件供各窗口视图刷新。
notify().notify_one(); notify().notify_one();
emit(&app, "ai-outbox-changed", &id, &conversation_id, None, None); emit(app, "ai-outbox-changed", &id, &conversation_id, None, None);
Ok(id) Ok(id)
} }
@@ -731,3 +747,43 @@ mod tests {
assert!(ensure_cancellable(STATUS_CANCELED).is_err()); assert!(ensure_cancellable(STATUS_CANCELED).is_err());
} }
} }
/// 959 结果回推任务:订阅事件总线等该会话完成(AiAgentFinished),终版报告推回
/// 来源渠道(push_to_chat 按 conv 反查飞书 chat 映射)。35min 超时弃守(对齐渠道
/// watcher 量级;引擎仍在跑则结果在桌面端可见,不阻塞)。
fn spawn_completion_push(app: AppHandle, conv: String) {
tauri::async_runtime::spawn(async move {
let state = app.state::<AppState>();
let mut rx = state.ai_event_bus.subscribe();
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(35 * 60);
loop {
let ev = tokio::time::timeout_at(deadline, rx.recv()).await;
match ev {
Err(_) => {
tracing::warn!("[outbox] 完成回推超时弃守 conv={conv}");
return;
}
Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => return,
Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue,
Ok(Ok(v)) => {
if v.get("type").and_then(|t| t.as_str()) != Some("AiAgentFinished") {
continue;
}
if v.get("conversation_id").and_then(|c| c.as_str()) != Some(conv.as_str()) {
continue;
}
let summary = v
.get("summary")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
if summary.is_empty() {
return;
}
crate::feishu_channel::push_to_chat(&app, &summary, Some(&conv)).await;
return;
}
}
}
});
}
+92 -10
View File
@@ -127,6 +127,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
- {}\n\ - {}\n\
- {}\n\ - {}\n\
- {}\n\ - {}\n\
- Response style: lead with the conclusion and the action/result; omit restating the user's question, pleasantries, and summary closers; prefer bullet points over paragraphs; do not expand on what was not asked. Explain whenever explanation is needed — cut filler, not substance.\n\
## Focus\n\ ## Focus\n\
- Always center your response on the core goal of the user's current request; the previous round's topic is only background, not the current task.\n\ - Always center your response on the core goal of the user's current request; the previous round's topic is only background, not the current task.\n\
- When the user switches topics (a clear new intent), follow the latest request; do not drag the old topic into the new answer.\n\ - When the user switches topics (a clear new intent), follow the latest request; do not drag the old topic into the new answer.\n\
@@ -160,6 +161,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
- {}\n\ - {}\n\
- {}\n\ - {}\n\
- {}\n\ - {}\n\
- 回复风格:结论先行,直接给动作/结果;不复述用户问题,不做客套开场和总结性收尾;要点优先于段落;用户没问的不展开。需要解释时照常解释——省的是空话,不是内容。\n\
## 聚焦准则\n\ ## 聚焦准则\n\
- 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\ - 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\
- 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\ - 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\
@@ -445,7 +447,10 @@ async fn entity_fingerprint(db: &Arc<Database>, sql: &'static str) -> Option<Str
/// - 日期:env_profile_line 注入当日日期,跨天必须失效(否则缓存串日期过期)。 /// - 日期:env_profile_line 注入当日日期,跨天必须失效(否则缓存串日期过期)。
/// - lang:system_prompt_parts / 各策略段 / 注明语随 lang 分支。 /// - lang:system_prompt_parts / 各策略段 / 注明语随 lang 分支。
/// - custom_prompt:与 build 内读的同一设置,变更即失效。 /// - custom_prompt:与 build 内读的同一设置,变更即失效。
/// - 活跃 project/task 的 id+updated_at:新增/软删/改 updated_at 均反映到指纹(比 MAX 更稳)。 /// - 活跃 project/task 指纹:task 取 id+updated_at;project 取 id+活跃时间(三源取大:
/// tasks.updated_at 最新/project_events 最新 created_at/projects.updated_at,仅未归档未删除,
/// 与「当前项目」清单的 list_active_with_activity 排序输入对齐)——任务更新/归档变化均
/// 反映到指纹,清单排序变了缓存串必失效。
/// - 去重排除集:被@实体走 augmentation 精准投影,排除集变化会改变清单内容,须纳入指纹。 /// - 去重排除集:被@实体走 augmentation 精准投影,排除集变化会改变清单内容,须纳入指纹。
async fn system_prompt_fingerprint( async fn system_prompt_fingerprint(
state: &AppState, state: &AppState,
@@ -463,7 +468,11 @@ async fn system_prompt_fingerprint(
.unwrap_or_default(); .unwrap_or_default();
let proj_fp = entity_fingerprint( let proj_fp = entity_fingerprint(
&state.db, &state.db,
"SELECT id, updated_at FROM projects WHERE deleted_at IS NULL", "SELECT p.id, MAX(\
IFNULL((SELECT MAX(t.updated_at) FROM tasks t WHERE t.project_id = p.id AND t.deleted_at IS NULL), 0), \
IFNULL((SELECT MAX(e.created_at) FROM project_events e WHERE e.project_id = p.id), 0), \
IFNULL(p.updated_at, 0)\
) FROM projects p WHERE p.deleted_at IS NULL AND p.archived = 0",
) )
.await?; .await?;
let task_fp = entity_fingerprint( let task_fp = entity_fingerprint(
@@ -586,24 +595,25 @@ async fn build_context_lists(
) -> String { ) -> String {
let (_, proj_label, task_label) = system_prompt_parts(lang); let (_, proj_label, task_label) = system_prompt_parts(lang);
let mut out = String::new(); let mut out = String::new();
// 附加当前数据上下文 // 附加当前数据上下文。项目段按最近活跃排序(任务活跃为主信号),治创建序误导项目绑定
if let Ok(projects) = state.projects.list_active().await { // (创建序会把早停摆项目排在清单首位,AI 误当最高优先项目锚点)。
if let Ok(projects) = state.projects.list_active_with_activity().await {
if !projects.is_empty() { if !projects.is_empty() {
out.push_str(proj_label); out.push_str(proj_label);
// system prompt 前缀克制:仅最近 20 个项目,防 context 膨胀 // system prompt 前缀克制:仅最近 20 个项目,防 context 膨胀
// 去重:被 @ 的项目跳过(已有 augmentation 精准投影,清单再现致同一实体两次入 prompt) // 去重:被 @ 的项目跳过(已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)
for p in projects.iter().take(20) { for p in projects.iter().take(20) {
if excluded_project_ids.iter().any(|id| id == &p.id) { if excluded_project_ids.iter().any(|id| id == &p.record.id) {
continue; continue;
} }
out.push_str(&format!( out.push_str(&format!(
"- {} (id: {}) ({}): {}\n", "- {} (id: {}) ({}): {}\n",
p.name, p.record.name,
p.id, p.record.id,
p.status.as_str(), p.record.status.as_str(),
truncate_desc(&p.description, CONTEXT_DESC_MAX_CHARS) truncate_desc(&p.record.description, CONTEXT_DESC_MAX_CHARS)
)); ));
if let Some(ref dir) = p.path { if let Some(ref dir) = p.record.path {
out.push_str(&format!(" 目录: {}\n", dir)); out.push_str(&format!(" 目录: {}\n", dir));
} }
} }
@@ -681,6 +691,10 @@ fn parse_cache_flag(v: &str) -> bool {
/// 同步:anthropic_compat::convert_request 读取同一进程级开关决定 system 形态 /// 同步:anthropic_compat::convert_request 读取同一进程级开关决定 system 形态
/// (数组+cache_control / 纯字符串),保证开关关时非官方网关零风险。 /// (数组+cache_control / 纯字符串),保证开关关时非官方网关零风险。
/// 键名保持历史名(向后兼容,已有键不换名),语义已扩展覆盖 openai_compat 前缀稳定拆分。 /// 键名保持历史名(向后兼容,已有键不换名),语义已扩展覆盖 openai_compat 前缀稳定拆分。
///
/// 同处顺带读缓存友好上下文总开关 `df-ai-cache-friendly-context`(默认开,值语义同
/// [`parse_cache_flag`])同步 BP3 摘要链尾断点开关(anthropic_compat 不读本侧 KV,
/// 照 set_anthropic_cache_enabled 进程级同步先例)。
pub(crate) async fn cache_enabled(state: &AppState) -> bool { pub(crate) async fn cache_enabled(state: &AppState) -> bool {
let v = state let v = state
.settings .settings
@@ -691,9 +705,51 @@ pub(crate) async fn cache_enabled(state: &AppState) -> bool {
.unwrap_or_default(); .unwrap_or_default();
let enabled = parse_cache_flag(&v); let enabled = parse_cache_flag(&v);
df_ai::anthropic_compat::set_anthropic_cache_enabled(enabled); df_ai::anthropic_compat::set_anthropic_cache_enabled(enabled);
let friendly_v = state
.settings
.get("df-ai-cache-friendly-context")
.await
.ok()
.flatten()
.unwrap_or_default();
df_ai::anthropic_compat::set_bp3_enabled(parse_cache_flag(&friendly_v));
enabled enabled
} }
/// 读取压缩可召回开关(AppState settings KV `df-ai-compress-recall`,默认开)。
///
/// 值语义:显式 "false"/"0"(设置页开关关)→ 关;缺省/空(从未设置)/"true"/"1"/其余杂值
/// → 开(按规格仅显式关闭生效)。开时调用方在压缩标记前把 data_dir + conv_id 注入
/// ContextManager(`set_compress_recall`):被压原文落盘存档,压缩摘要尾部拼召回句柄行
/// (LLM 经 read_file 回读);关时零行为变更(不落盘、摘要无句柄行)。
pub(crate) async fn compress_recall_enabled(state: &AppState) -> bool {
let v = state
.settings
.get("df-ai-compress-recall")
.await
.ok()
.flatten()
.unwrap_or_default();
!(v == "false" || v == "0")
}
/// 读取缓存友好上下文总开关(AppState settings KV `df-ai-cache-friendly-context`,默认开)。
///
/// 值语义同 [`parse_cache_flag`]:缺省/空/"true"/"1" → 开;"false"/"0"/杂值 → 关。
/// 开时调用方在 `build_for_request` 前把值注入 ContextManager(`set_cache_friendly`):
/// 超预算裁剪走块状(目标水位+轮边界吸附)+ 存储态 system 锚点 view 层转 user;
/// 关时两者旁路,裁剪与视图与旧路径逐字节等价。
pub(crate) async fn cache_friendly_context_enabled(state: &AppState) -> bool {
let v = state
.settings
.get("df-ai-cache-friendly-context")
.await
.ok()
.flatten()
.unwrap_or_default();
parse_cache_flag(&v)
}
/// 构建 system prompt(Anthropic prompt caching 模式)。 /// 构建 system prompt(Anthropic prompt caching 模式)。
/// ///
/// 返回 `(system 稳定段, 易变尾段)`: /// 返回 `(system 稳定段, 易变尾段)`:
@@ -875,6 +931,32 @@ mod tests {
assert!(prefix.contains("聚焦准则")); assert!(prefix.contains("聚焦准则"));
} }
// M4 回复风格纪律:中文版行为准则含「回复风格」条款(反口水话,不反内容)
#[test]
fn system_prompt_zh_has_response_style_clause() {
let (prefix, _, _) = system_prompt_parts("zh-CN");
assert!(
prefix.contains("回复风格"),
"中文 system prompt 应含回复风格条款,实际: {}",
prefix
);
// 核心条款抽样:结论先行 + 「省的是空话,不是内容」的度(需要解释时照常解释)
assert!(prefix.contains("结论先行"));
assert!(prefix.contains("省的是空话,不是内容"));
}
// M4 回复风格纪律:英文版 Guidelines 含 Response style 条款
#[test]
fn system_prompt_en_has_response_style_clause() {
let (prefix, _, _) = system_prompt_parts("en");
assert!(
prefix.contains("Response style"),
"英文 system prompt 应含 Response style 条款,实际: {}",
prefix
);
assert!(prefix.contains("cut filler, not substance"));
}
// 文档探索策略段(中/英)存在且含关键引导 // 文档探索策略段(中/英)存在且含关键引导
#[test] #[test]
fn doc_exploration_strategy_section_present_zh_en() { fn doc_exploration_strategy_section_present_zh_en() {
@@ -25,6 +25,25 @@ struct FailureEntry {
last_error: String, last_error: String,
} }
/// 工具级超时连败计数(E13):治「同工具跨签名调参重试」逃逸——run_command 换 timeout
/// 档位即换 key,签名级拦截不触发,8 连败照烧轮次(434c8c40 实证)。此处按工具聚合:
/// 连续超时(错误特征匹配)≥3 次 → 该工具下次执行前注入软收敛警告(不硬拦,防误杀
/// 同工具的轻量调用),信息给足引导 LLM 换方案/问用户。该工具任一次成功即清零。
static TOOL_TIMEOUT_STREAKS: OnceLock<Mutex<HashMap<String, u32>>> = OnceLock::new();
/// 超时连败软收敛阈值(跨签名聚合口径)。
pub const TIMEOUT_STREAK_WARN_AFTER: u32 = 3;
fn timeout_streaks() -> &'static Mutex<HashMap<String, u32>> {
TOOL_TIMEOUT_STREAKS.get_or_init(|| Mutex::new(HashMap::new()))
}
/// 错误摘要是否属超时族(中英特征,大小写不敏感)。
fn is_timeout_error(error: &str) -> bool {
let lower = error.to_lowercase();
lower.contains("超时") || lower.contains("timeout") || lower.contains("timed out")
}
/// 进程级失败记忆(全 conv 共享单锁;锁内零 await,微秒级)。 /// 进程级失败记忆(全 conv 共享单锁;锁内零 await,微秒级)。
static FAILURE_MEMORY: OnceLock<Mutex<HashMap<String, FailureEntry>>> = OnceLock::new(); static FAILURE_MEMORY: OnceLock<Mutex<HashMap<String, FailureEntry>>> = OnceLock::new();
@@ -70,34 +89,50 @@ fn sort_json(v: &serde_json::Value) -> serde_json::Value {
} }
} }
/// 执行前检查:同 key 连败达标 → 拦截,返回结构化失败提示(LLM 可读换路引导)。 /// 执行前检查:同 key 连败达标 → 拦截;工具级超时连败达标 → 软收敛警告(不拦执行)。
/// 未达标/无记忆 → None 放行。 /// 两者都未达标/无记忆 → None 放行。
pub fn precheck(tool: &str, args: &serde_json::Value) -> Option<String> { pub fn precheck(tool: &str, args: &serde_json::Value) -> Option<String> {
let key = memory_key(tool, args); let key = memory_key(tool, args);
let mem = memory().lock().unwrap_or_else(|p| p.into_inner()); let mem = memory().lock().unwrap_or_else(|p| p.into_inner());
let Some(entry) = mem.get(&key) else { return None }; if let Some(entry) = mem.get(&key) {
if entry.consecutive >= ABORT_AFTER_CONSECUTIVE_FAILURES { if entry.consecutive >= ABORT_AFTER_CONSECUTIVE_FAILURES {
Some(format!( return Some(format!(
"{{\"status\":\"blocked_by_failure_memory\",\"tool\":\"{tool}\",\"consecutive_failures\":{},\"last_error\":\"{}\",\"hint\":\"同一调用已连败 {} 次,已自动拦截。请勿原样重试:①检查参数(路径/SQL/命令拼写) ②换工具或换路径 ③若确认无法绕过,向用户报告放弃原因。\"}}", "{{\"status\":\"blocked_by_failure_memory\",\"tool\":\"{tool}\",\"consecutive_failures\":{},\"last_error\":\"{}\",\"hint\":\"同一调用已连败 {} 次,已自动拦截。请勿原样重试:①检查参数(路径/SQL/命令拼写) ②换工具或换路径 ③若确认无法绕过,向用户报告放弃原因。\"}}",
entry.consecutive, entry.consecutive,
entry.last_error.replace('\"', "'"), entry.last_error.replace('\"', "'"),
entry.consecutive entry.consecutive
)) ));
} else {
None
} }
}
// E13 工具级超时连败(跨签名聚合):换参数/换档位逃不出工具维度。
let streaks = timeout_streaks().lock().unwrap_or_else(|p| p.into_inner());
if let Some(streak) = streaks.get(tool) {
if *streak >= TIMEOUT_STREAK_WARN_AFTER {
return Some(format!(
"{{\"status\":\"timeout_streak_warning\",\"tool\":\"{tool}\",\"timeout_streak\":{},\"hint\":\"该工具已连续超时 {} 次(不同参数/档位都超)。继续原路大概率仍超时:①把任务拆小分步执行 ②换更轻量的方式(降低数据量/改后台执行) ③若确实需要长时间运行,向用户说明并确认后再继续。\"}}",
streak, streak
));
}
}
None
} }
/// 执行后回写:成功清零删除,失败递增并记最近错误。 /// 执行后回写:成功清零删除(签名级+工具级超时计数),失败递增并记最近错误;
/// 失败属超时族时工具级超时连败 +1。
pub fn record(tool: &str, args: &serde_json::Value, ok: bool, error: Option<String>) { pub fn record(tool: &str, args: &serde_json::Value, ok: bool, error: Option<String>) {
let key = memory_key(tool, args); let key = memory_key(tool, args);
let mut mem = memory().lock().unwrap_or_else(|p| p.into_inner()); let mut mem = memory().lock().unwrap_or_else(|p| p.into_inner());
let mut streaks = timeout_streaks().lock().unwrap_or_else(|p| p.into_inner());
if ok { if ok {
mem.remove(&key); mem.remove(&key);
streaks.remove(tool);
} else { } else {
let e = mem.entry(key).or_insert(FailureEntry { consecutive: 0, last_error: String::new() }); let e = mem.entry(key).or_insert(FailureEntry { consecutive: 0, last_error: String::new() });
e.consecutive += 1; e.consecutive += 1;
e.last_error = error.unwrap_or_default().chars().take(200).collect(); e.last_error = error.unwrap_or_default().chars().take(200).collect();
if is_timeout_error(&e.last_error) {
*streaks.entry(tool.to_string()).or_insert(0) += 1;
}
} }
} }
@@ -145,4 +180,38 @@ mod tests {
let a = json!({"command": "cargo test", "working_dir": "E:/x"}); let a = json!({"command": "cargo test", "working_dir": "E:/x"});
assert!(memory_key("run_command", &a).contains("cmd=cargo test")); assert!(memory_key("run_command", &a).contains("cmd=cargo test"));
} }
#[test]
fn timeout_streak_aggregates_across_signatures_and_soft_warns() {
clear();
timeout_streaks().lock().unwrap().clear();
// 换档位=换签名(E13 逃逸路径:超时档拼在命令里即不同 key):签名级各 1 次均不拦;
// 工具级 streak 在第 3 次超时发生后达 3,同工具再调用(含当次迭代)即软警告
for (i, timeout) in ["60", "300", "600"].iter().enumerate() {
let args = json!({"command": format!("cargo build --timeout {timeout}")});
record("run_command", &args, false, Some(format!("命令执行超时({timeout}s)")));
if i < 2 {
assert!(precheck("run_command", &args).is_none(), "签名级 1 次且 streak<3 不拦");
}
}
// 新签名(第 4 档):工具级超时连败 ≥3 → 软收敛警告(放行执行但注入换路引导)
let args4 = json!({"command": "cargo build --timeout 900"});
let warn = precheck("run_command", &args4).unwrap();
assert!(warn.contains("timeout_streak_warning"));
assert!(warn.contains("\"timeout_streak\":3"));
// 同工具任一成功 → 工具级清零
record("run_command", &json!({"command": "ls"}), true, None);
assert!(precheck("run_command", &args4).is_none());
}
#[test]
fn non_timeout_failures_do_not_aggregate_to_timeout_streak() {
clear();
timeout_streaks().lock().unwrap().clear();
for path in ["/a.rs", "/b.rs", "/c.rs"] {
record("read_file", &json!({"path": path}), false, Some("文件不存在".into()));
}
// 3 连败但非超时族 → 无工具级警告
assert!(precheck("read_file", &json!({"path": "/d.rs"})).is_none());
}
} }
@@ -1410,6 +1410,7 @@ fn make_test_spawn_agent_ctx(db: &Arc<Database>) -> crate::commands::ai::tools::
crate::commands::ai::agentic::conv_state::ConvStateStore::new(), crate::commands::ai::agentic::conv_state::ConvStateStore::new(),
), ),
llm_concurrency: LlmConcurrency::new(3, 2), llm_concurrency: LlmConcurrency::new(3, 2),
allowed_dirs: Arc::new(tokio::sync::RwLock::new(crate::state::AllowedDirs::default())),
db: db.clone(), db: db.clone(),
sub_agents: crate::commands::ai::agentic::sub_agent::new_registry(), sub_agents: crate::commands::ai::agentic::sub_agent::new_registry(),
} }
@@ -1,14 +1,13 @@
//! D2a 工具结果过期外置 + D2d 90 天归档:治 devflow.db 膨胀(193.7MB,审计大结果为主要头)。 //! D2a 工具结果过期外置:治 devflow.db 膨胀(193.7MB,审计大结果为主要头)。
//! //!
//! 设计(对齐 u-claw 成熟实践「大内容外置+摘要留指针」): //! 设计(对齐 u-claw 成熟实践「大内容外置+摘要留指针」):
//! - 超阈值(8KB)的审计 result 正文落盘至 {app_data}/tool-results/{yyyy-MM}/{id}.txt, //! - 超阈值(8KB)的审计 result 正文落盘至 {app_data}/tool-results/{yyyy-MM}/{id}.txt,
//! DB 只留句柄 JSON(offloaded 标记 + 路径 + 头部摘要 + 大小 + 工具名/时间)。 //! DB 只留句柄 JSON(offloaded 标记 + 路径 + 头部摘要 + 大小 + 工具名/时间)。
//! - 句柄可读:句柄含 path,用户/agent 需要细节时按路径找回(工具结果在 messages 侧 //! - 句柄可读:句柄含 path,用户/agent 需要细节时按路径找回(工具结果在 messages 侧
//! 已有 namespace 外置,本模块治审计表侧——两处同构,互不影响)。 //! 已有 namespace 外置,本模块治审计表侧——两处同构,互不影响)。
//! - 归档:2026-10-09 用户决策,原设计的 90 天清理已停用,外置文件全部保留不删。 //! - 归档清理已彻底移除(2026-10-11):原 90 天归档设计(2026-10-06 提出,2026-10-09 停用)
//! 用户明确「外置文件超过 90 天也不能删,暂时都保留」。cleanup_expired 本体与单测保留 //! 现整体删除,外置文件只增不减、永不清理。read_offloaded 读回入口已接线
//! (未来若需恢复只改 unattended.rs 调用点),但当前无任何调用点,外置文件只增不减。 //! (IPC ai_read_offloaded_result),句柄永远可读回。
//! read_offloaded 读回入口已接线(IPC ai_read_offloaded_result),句柄永远可读回。
//! - 历史数据默认保留禁清理(全局规则),与本次决策一致,外置派生文件同样保留。 //! - 历史数据默认保留禁清理(全局规则),与本次决策一致,外置派生文件同样保留。
use std::path::PathBuf; use std::path::PathBuf;
@@ -35,11 +34,6 @@ pub fn offload_result(id: &str, tool_name: &str, result: &str) -> String {
/// (上下文敏感),审计侧存证要求低,8KB 平衡 IO 频率与瘦身收益。 /// (上下文敏感),审计侧存证要求低,8KB 平衡 IO 频率与瘦身收益。
pub const OFFLOAD_THRESHOLD_BYTES: usize = 8 * 1024; pub const OFFLOAD_THRESHOLD_BYTES: usize = 8 * 1024;
/// 归档阈值(原设计 2026-10-06):外置文件 90 天后可清理。
/// 2026-10-09 用户决策:90 天清理已停用,外置文件全部保留,常量保留供未来恢复。
#[allow(dead_code)]
pub const ARCHIVE_AFTER_DAYS: u64 = 90;
/// 句柄 JSON 字段名(读取降级判据)。 /// 句柄 JSON 字段名(读取降级判据)。
const HANDLE_MARK: &str = "__offloaded__"; const HANDLE_MARK: &str = "__offloaded__";
@@ -65,7 +59,7 @@ pub fn offload_if_large(base: &PathBuf, id: &str, tool_name: &str, result: &str)
return result.to_string(); return result.to_string();
} }
let head: String = result.chars().take(200).collect(); let head: String = result.chars().take(200).collect();
// meta 旁路文件:cleanup_expired 的认领判据(防误删同目录非本模块产物) // meta 旁路文件:标记本模块自产物(带 offloaded 标记),供溯源识别
let meta = serde_json::json!({ HANDLE_MARK: true, "id": id, "tool": tool_name }); let meta = serde_json::json!({ HANDLE_MARK: true, "id": id, "tool": tool_name });
let _ = std::fs::write(file.with_extension("meta.json"), meta.to_string()); let _ = std::fs::write(file.with_extension("meta.json"), meta.to_string());
let handle = serde_json::json!({ let handle = serde_json::json!({
@@ -87,57 +81,6 @@ pub fn read_offloaded(path: &str) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|e| format!("外置结果读取失败(文件可能已被外部删除): {e}")) std::fs::read_to_string(path).map_err(|e| format!("外置结果读取失败(文件可能已被外部删除): {e}"))
} }
/// 清理超期外置文件(仅本模块目录内带 marker 头的文件;失败静默记日志)。
/// 返回 (删除数, 释放字节)。全局规则「历史数据默认保留」的对齐说明:本函数只删
/// offload 自产的派生文件(原文曾入 DB 后被替换为句柄),非用户原始数据。
///
/// 2026-10-09 用户决策:已停用,外置文件超过 90 天也不删,暂时全部保留。
/// 调用点已从 unattended.rs 摘除(改为空转)。函数本体与单测保留供未来恢复。
#[allow(dead_code)]
pub fn cleanup_expired(base: &PathBuf) -> (usize, u64) {
let root = base.join("tool-results");
let Ok(entries) = std::fs::read_dir(&root) else { return (0, 0) };
let cutoff = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
.saturating_sub(ARCHIVE_AFTER_DAYS * 86400);
let mut removed = 0usize;
let mut freed = 0u64;
for month in entries.flatten() {
let Ok(files) = std::fs::read_dir(month.path()) else { continue };
for f in files.flatten() {
let p = f.path();
if p.extension().and_then(|e| e.to_str()) != Some("txt") {
continue;
}
// marker 头校验:句柄 JSON 写入文件首行注释不行(txt 是原文)——改判据:
// 同名 .meta.json 存在且 offloaded=true 才认领。写入侧同步产出 meta。
let meta = p.with_extension("meta.json");
let Ok(meta_str) = std::fs::read_to_string(&meta) else { continue };
let Ok(v) = serde_json::from_str::<serde_json::Value>(&meta_str) else { continue };
if !v.get(HANDLE_MARK).and_then(|b| b.as_bool()).unwrap_or(false) {
continue;
}
let Ok(md) = f.metadata() else { continue };
let Ok(modified) = md.modified() else { continue };
let mtime = modified
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if mtime < cutoff {
let size = md.len();
if std::fs::remove_file(&p).is_ok() {
let _ = std::fs::remove_file(&meta);
removed += 1;
freed += size;
}
}
}
}
(removed, freed)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+38 -3
View File
@@ -1098,7 +1098,7 @@ pub fn register(
} }
// output_mode 分派返回结构 // output_mode 分派返回结构
let result = match output_mode { let mut result = match output_mode {
"files_with_matches" => { "files_with_matches" => {
// 仅返命中文件路径列表(去重,顺序保留首次命中) // 仅返命中文件路径列表(去重,顺序保留首次命中)
let files: Vec<String> = matches_out.iter() let files: Vec<String> = matches_out.iter()
@@ -1183,6 +1183,11 @@ pub fn register(
}) })
} }
}; };
// 0 命中「阴性存疑」提示:路径/glob 写法/大小写任一不对都静默 0 命中,
// LLM 拿空结果易直接当「不存在」下结论(实测假阴性被当事实断言 3 轮),强制附一行复核提示。
if total == 0 {
result["hint"] = serde_json::json!(negative_hit_hint("路径/glob 写法/大小写 -i"));
}
Ok(result) Ok(result)
} }
); );
@@ -1228,10 +1233,13 @@ pub fn register(
let mut out = serde_json::json!({ let mut out = serde_json::json!({
"path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more "path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more
}); });
// total=0 时空结果引导(治 LLM 搜不到就放弃/盲目重试)。 // total=0 时空结果引导(治 LLM 搜不到就放弃/盲目重试)+ 阴性存疑提示(0 命中≠不存在)。
// 注:search_files 已大小写不敏感(内部 pattern.to_lowercase + 包含匹配),文案勿写"换大小写"。 // 注:search_files 已大小写不敏感(内部 pattern.to_lowercase + 包含匹配),文案勿写"换大小写"。
if total == 0 { if total == 0 {
out["hint"] = serde_json::json!("未找到匹配文件。可尝试: ① 放宽关键词(更短/字符更少); ② 用 grep 工具做跨文件内容正则搜索(pattern 支持正则, -i 大小写不敏感)。注: search_files 为文件名大小写不敏感包含匹配"); out["hint"] = serde_json::json!(format!(
"{}\n未找到匹配文件。可尝试: ① 补 recursive=true 搜子目录(不传只搜当前层); ② 放宽关键词(更短/字符更少); ③ 用 grep 工具做跨文件内容正则搜索(pattern 支持正则, -i 大小写不敏感)。注: search_files 为文件名大小写不敏感包含匹配",
negative_hit_hint("路径/recursive")
));
} }
Ok(out) Ok(out)
} }
@@ -1675,6 +1683,16 @@ fn file_not_found_error(path: &str) -> anyhow::Error {
anyhow::anyhow!(msg) anyhow::anyhow!(msg)
} }
/// 搜索工具(search_files/grep)0 命中「阴性存疑」提示。
///
/// 治勘察假阴性被当事实断言:search_files 未传 recursive 只搜根目录,
/// 0 命中被 LLM 直接当「功能未实施」连续断言 3 轮。路径/glob/recursive 等范围
/// 参数写错时同样静默 0 命中,故空结果强制附本提示让 LLM 复核后再下结论。
/// 措辞克制一行;`scope` 按工具传入各自的复核点。
fn negative_hit_hint(scope: &str) -> String {
format!("⚠ 0 命中不一定代表不存在——请复核搜索范围({scope})后再下结论")
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -2003,6 +2021,23 @@ mod tests {
assert!(msg.contains("相近文件候选"), "应含相近文件候选,实际: {msg}"); assert!(msg.contains("相近文件候选"), "应含相近文件候选,实际: {msg}");
assert!(msg.contains("target.rs"), "候选应含 target.rs,实际: {msg}"); assert!(msg.contains("target.rs"), "候选应含 target.rs,实际: {msg}");
} }
// ============================================================
// negative_hit_hint — 搜索工具 0 命中阴性存疑提示(治假阴性被当事实断言)
// ============================================================
/// 两搜索工具 0 命中提示共用句式:声明「0 命中≠不存在」+ 按工具各自的复核点。
#[test]
fn negative_hit_hint_states_doubt_and_scope() {
// grep 侧复核点:路径/glob 写法/大小写
let grep_hint = negative_hit_hint("路径/glob 写法/大小写 -i");
assert!(grep_hint.contains("0 命中不一定代表不存在"), "应声明阴性存疑,实际: {grep_hint}");
assert!(grep_hint.contains("复核搜索范围"), "应引导复核,实际: {grep_hint}");
assert!(grep_hint.contains("glob"), "grep 侧应含 glob 复核点,实际: {grep_hint}");
// search_files 侧复核点:recursive(实测根因是未传 recursive 只搜根目录)
let search_hint = negative_hit_hint("路径/recursive");
assert!(search_hint.contains("recursive"), "search_files 侧应含 recursive 复核点,实际: {search_hint}");
}
} }
+231 -9
View File
@@ -11,9 +11,12 @@
//! ②knowledge_promote 显式转正(对话内人工/agent 拍板,仅 candidate 可转,与自动晋升 //! ②knowledge_promote 显式转正(对话内人工/agent 拍板,仅 candidate 可转,与自动晋升
//! 互不影响)。insert 只负责落候选,转正须显式调用 promote,不存在隐式发布。 //! 互不影响)。insert 只负责落候选,转正须显式调用 promote,不存在隐式发布。
//! //!
//! 溯源说明:工具 handler 只有 args 入参,无会话上下文注入机制,拿不到 conv_id, //! 会话上下文通道:handler 仍只有 args 入参,会话 id 由执行链注入——所有工具执行
//! 故 source_ref 记渠道标记 "ai-chat" 兜底(标识写入来源是 AI 对话,精确到会话待 //! (process_tool_calls 与审批/授权窗口)统一经 execute_with_heartbeat 包
//! 上下文注入机制具备后补全)。 //! spawn_agent::scope_caller_conv,handler 内 caller_conv() 读取。search 消费:
//! 解析当前会话归属项目接三档软排序,无 scope(直调)或归属推断失败降级全局检索;
//! insert 消费:source_ref 记 conv:{id} 与自动提炼产物同格式,归属项目同口径推断,
//! 无 scope 降级渠道标记 "ai-chat" 兜底溯源。
//! //!
//! kind 默认值:任务草案默认 "fact",但 knowledges 表合法 kind 是 7 种 KnowledgeKind //! kind 默认值:任务草案默认 "fact",但 knowledges 表合法 kind 是 7 种 KnowledgeKind
//! snake_case(review_rule/prompt_template/pitfall/architecture_pattern/diagnosis/ //! snake_case(review_rule/prompt_template/pitfall/architecture_pattern/diagnosis/
@@ -31,7 +34,8 @@ use df_storage::models::KnowledgeRecord;
use df_types::types::new_id; use df_types::types::new_id;
use crate::commands::ai::audit::truncate_chars_ellipsis; use crate::commands::ai::audit::truncate_chars_ellipsis;
use crate::commands::ai::knowledge_inject::{extract_query_keywords, spawn_embedding_for_record}; use crate::commands::ai::knowledge_inject::{extract_query_keywords, resolve_source_project, spawn_embedding_for_record};
use crate::commands::ai::tools::spawn_agent::caller_conv;
use crate::commands::knowledge_timeline::KnowledgeTimeline; use crate::commands::knowledge_timeline::KnowledgeTimeline;
use crate::commands::now_millis; use crate::commands::now_millis;
use crate::state::load_knowledge_config; use crate::state::load_knowledge_config;
@@ -52,7 +56,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
registry, registry,
db: Arc<Database>, db: Arc<Database>,
"knowledge_search", "knowledge_search",
"检索本地知识库(仅已发布知识,query 自动分词提取关键词匹配,标题命中优先、复用次数降序)。使用时机:回答前需要过往经验/项目约定/踩坑记录时调用,如用户问「之前怎么解决的」「有没有相关约定」。用户说「记住/记一下/以后注意」时不要用本工具,改用 knowledge_insert。参数:query=知识主题关键词或自然语言描述(必填,自动分词),limit=返回条数(可选,默认 5,上限 10)", "检索本地知识库(仅已发布知识,query 自动分词提取关键词匹配,当前会话关联项目的知识排前,其后按标题命中优先、复用次数降序)。使用时机:回答前需要过往经验/项目约定/踩坑记录时调用,如用户问「之前怎么解决的」「有没有相关约定」。用户说「记住/记一下/以后注意」时不要用本工具,改用 knowledge_insert。参数:query=知识主题关键词或自然语言描述(必填,自动分词),limit=返回条数(可选,默认 5,上限 10)",
RiskLevel::Low, RiskLevel::Low,
schema: object_schema(vec![("query", "string", true), ("limit", "integer", false)]), schema: object_schema(vec![("query", "string", true), ("limit", "integer", false)]),
args => { args => {
@@ -66,7 +70,16 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
// 分词(ASCII 词 + 中文 2-gram)后逐词匹配,任一关键词命中即入候选。 // 分词(ASCII 词 + 中文 2-gram)后逐词匹配,任一关键词命中即入候选。
// 仅已发布(include_candidates=false):LLM 检索面与人工审核面一致。 // 仅已发布(include_candidates=false):LLM 检索面与人工审核面一致。
let keywords = extract_query_keywords(query); let keywords = extract_query_keywords(query);
let hits = repo.search_keywords(&keywords, false, limit).await?; // 会话上下文通道(caller_conv)解析归属项目接三档软排序:同项目排前,全局与
// 异项目仍可召回不淹没。软排序只改顺序不改命中面;无 scope(直调)或归属
// 推断失败降级 project_id=None,与全局检索行为逐字一致,不报错。
let current_project = match caller_conv() {
Some(conv_id) => resolve_source_project(&db, &conv_id, &[]).await,
None => None,
};
let hits = repo
.search_keywords_scoped(&keywords, false, limit, current_project.as_deref())
.await?;
// 空结果返回可执行的下一步指引而非裸空数组:弱模型拿到 [] 常直接回「没找到」 // 空结果返回可执行的下一步指引而非裸空数组:弱模型拿到 [] 常直接回「没找到」
// 结束,带上「让用户口述 + insert 沉淀」能把检索失败转化为知识积累入口。 // 结束,带上「让用户口述 + insert 沉淀」能把检索失败转化为知识积累入口。
if hits.is_empty() { if hits.is_empty() {
@@ -123,6 +136,16 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
.collect(); .collect();
serde_json::to_string(&items).unwrap_or_else(|_| "[]".to_string()) serde_json::to_string(&items).unwrap_or_else(|_| "[]".to_string())
}); });
// 写入归属(跨会话召回写入收口):会话上下文通道解析当前会话,source_ref 记
// conv:{id} 与自动提炼产物同格式,归属项目同口径推断(boundary 级 a);
// 无 scope(直调)降级渠道标记兜底溯源,与 search 无 scope 降级同风格。
let (source_project, source_ref) = match caller_conv() {
Some(conv_id) => (
resolve_source_project(&db, &conv_id, &[]).await,
Some(format!("conv:{}", conv_id)),
),
None => (None, Some("ai-chat".to_string())),
};
let now = now_millis(); let now = now_millis();
let record = KnowledgeRecord { let record = KnowledgeRecord {
id: new_id(), id: new_id(),
@@ -135,9 +158,8 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
confidence: None, confidence: None,
reuse_count: 0, reuse_count: 0,
verified: false, verified: false,
source_project: None, source_project,
// handler 无会话上下文,拿不到 conv_id,记渠道标记兜底溯源 source_ref,
source_ref: Some("ai-chat".to_string()),
reasoning: None, reasoning: None,
embedding_status: None, embedding_status: None,
created_at: now.clone(), created_at: now.clone(),
@@ -204,3 +226,203 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
} }
); );
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::ai::boundary::TaskBoundary;
use crate::commands::ai::tools::spawn_agent::scope_caller_conv;
use serde_json::json;
async fn test_db() -> Arc<Database> {
Arc::new(Database::open_in_memory().await.unwrap())
}
async fn ins_project(db: &Arc<Database>, id: &str, path: &str) {
df_storage::crud::ProjectRepo::new(db)
.insert(df_storage::models::ProjectRecord {
id: id.into(),
name: format!("项目-{id}"),
description: String::new(),
status: df_types::types::ProjectStatus::Planning,
idea_id: None,
path: Some(path.into()),
stack: None,
archived: false,
created_at: "2026-01-01".into(),
updated_at: "2026-01-01".into(),
})
.await
.unwrap();
}
async fn ins_conv_with_boundary(db: &Arc<Database>, id: &str, dirs: &[&str]) {
let boundary = TaskBoundary {
risk_ceiling: RiskLevel::Medium,
dir_allowlist: dirs.iter().map(|d| d.to_string()).collect(),
forbid_destructive: true,
budget: None,
};
df_storage::crud::AiConversationRepo::new(db)
.insert(df_storage::models::AiConversationRecord {
id: id.into(),
title: Some("t".into()),
messages: "[]".into(),
provider_id: None,
model: None,
models: None,
archived: false,
pinned: false,
prompt_tokens: None,
completion_tokens: None,
pinned_goals: None,
pending_approvals: None,
created_at: "2026-01-01".into(),
updated_at: "2026-01-01".into(),
origin: None,
boundary_json: Some(serde_json::to_string(&boundary).unwrap()),
loop_state: None,
status: "active".into(),
working_context_json: None,
})
.await
.unwrap();
}
async fn ins_knowledge(db: &Arc<Database>, title: &str, project: Option<&str>, reuse: i32) {
let now = now_millis();
df_storage::crud::KnowledgeRepo::new(db)
.insert(KnowledgeRecord {
id: new_id(),
kind: "pitfall".into(),
title: title.into(),
content: format!("{title} 正文"),
tags: None,
status: "published".into(),
confidence: None,
reuse_count: reuse,
verified: false,
source_project: project.map(|p| p.into()),
source_ref: None,
reasoning: None,
embedding_status: None,
created_at: now.clone(),
updated_at: now,
})
.await
.unwrap();
}
fn search_titles(out: &serde_json::Value) -> Vec<&str> {
out["results"]
.as_array()
.unwrap()
.iter()
.map(|r| r["title"].as_str().unwrap())
.collect()
}
/// 注入通道端到端 + 项目软排序:scope 内执行 search,按会话 boundary 解析归属项目,
/// 同项目条目排前(异项目即便复用次数更高,项目档位是第一排序键也压不过)。
#[tokio::test]
async fn search_scoped_ranks_same_project_first() {
let db = test_db().await;
ins_project(&db, "p1", "e:/wk-lab/proj-a").await;
ins_project(&db, "p2", "e:/wk-lab/proj-b").await;
ins_conv_with_boundary(&db, "conv-1", &["e:/wk-lab/proj-a/"]).await;
// 两标题对查询「部署踩坑」的 2-gram 命中分相同,全局序下复用 9 的 p2 在前;
// scoped 序下 p1 靠项目档位反超。
ins_knowledge(&db, "部署踩坑实录", Some("p1"), 0).await;
ins_knowledge(&db, "部署踩坑指南", Some("p2"), 9).await;
let mut registry = AiToolRegistry::new();
register(&mut registry, &db);
let out = scope_caller_conv("conv-1".to_string(), async {
registry
.execute("knowledge_search", json!({"query": "部署踩坑"}))
.await
})
.await
.unwrap();
assert_eq!(
search_titles(&out),
vec!["部署踩坑实录", "部署踩坑指南"],
"同项目知识应排前(项目档位为第一排序键)"
);
}
/// 无会话上下文(未 scope 直调)降级全局检索:不报错,排序回落标题命中+复用降序,
/// 高复用异项目条目回首位,证明走的是全局序而非 scoped 序。
#[tokio::test]
async fn search_without_scope_falls_back_to_global() {
let db = test_db().await;
ins_knowledge(&db, "部署踩坑实录", Some("p1"), 0).await;
ins_knowledge(&db, "部署踩坑指南", Some("p2"), 9).await;
let mut registry = AiToolRegistry::new();
register(&mut registry, &db);
let out = registry
.execute("knowledge_search", json!({"query": "部署踩坑"}))
.await
.unwrap();
assert_eq!(
search_titles(&out),
vec!["部署踩坑指南", "部署踩坑实录"],
"无项目上下文应回落全局序(复用降序)且不报错"
);
}
/// 注入通道在位性:scope 内 caller_conv 解析出注入的会话 id(search 与 insert 沉淀
/// 通道共用同一读取点,后者写入收口批接入);scope 外读不到(None,直调防御)。
#[tokio::test]
async fn caller_conv_channel_resolves_inside_scope() {
assert!(caller_conv().is_none(), "scope 外应读不到会话 id");
let seen = scope_caller_conv("conv-chan".to_string(), async { caller_conv() }).await;
assert_eq!(seen.as_deref(), Some("conv-chan"), "scope 内应解析出注入的会话 id");
}
/// 写入归属补全(写入收口):scope 内 insert,source_ref 记 conv:{id} 与自动提炼
/// 产物同格式,归属项目按会话 boundary 同口径解析(级 a)。
#[tokio::test]
async fn insert_scoped_records_conv_source_ref() {
let db = test_db().await;
ins_project(&db, "p1", "e:/wk-lab/proj-a").await;
ins_conv_with_boundary(&db, "conv-9", &["e:/wk-lab/proj-a/"]).await;
let mut registry = AiToolRegistry::new();
register(&mut registry, &db);
let out = scope_caller_conv("conv-9".to_string(), async {
registry
.execute("knowledge_insert", json!({"title": "端口约定", "content": "数据库统一走 3307 代理"}))
.await
})
.await
.unwrap();
assert_eq!(out["status"], "candidate");
let rec = df_storage::crud::KnowledgeRepo::new(&db)
.get_by_id(out["id"].as_str().unwrap())
.await
.unwrap()
.expect("insert 返回的 id 应可回读");
assert_eq!(rec.source_ref.as_deref(), Some("conv:conv-9"), "溯源与自动提炼产物同格式");
assert_eq!(rec.source_project.as_deref(), Some("p1"), "归属项目按 boundary 解析");
}
/// 无 scope(直调)降级渠道标记兜底溯源,归属项目留空,不报错。
#[tokio::test]
async fn insert_without_scope_falls_back_to_channel_ref() {
let db = test_db().await;
let mut registry = AiToolRegistry::new();
register(&mut registry, &db);
let out = registry
.execute("knowledge_insert", json!({"title": "要点", "content": "内容"}))
.await
.unwrap();
let rec = df_storage::crud::KnowledgeRepo::new(&db)
.get_by_id(out["id"].as_str().unwrap())
.await
.unwrap()
.expect("insert 返回的 id 应可回读");
assert_eq!(rec.source_ref.as_deref(), Some("ai-chat"));
assert_eq!(rec.source_project, None);
}
}
+13 -1
View File
@@ -17,7 +17,7 @@
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::{Mutex, RwLock};
use df_ai::ai_tools::{ use df_ai::ai_tools::{
AiToolRegistry, GoalMeta, GoalStyle, RiskLevel, SigMeta, SigValKind, ToolMeta, AiToolRegistry, GoalMeta, GoalStyle, RiskLevel, SigMeta, SigValKind, ToolMeta,
@@ -52,6 +52,9 @@ pub struct SpawnAgentCtx {
pub conv_states: Arc<ConvStateStore>, pub conv_states: Arc<ConvStateStore>,
/// LLM 并发控制(子循环 global + per_conv(child) 双份额) /// LLM 并发控制(子循环 global + per_conv(child) 双份额)
pub llm_concurrency: LlmConcurrency, pub llm_concurrency: LlmConcurrency,
/// 文件访问授权白名单(子代理投影段定位工作区:首个持久授权目录;
/// 与 AppState.allowed_dirs 共享同一 Arc)
pub allowed_dirs: Arc<RwLock<crate::state::AllowedDirs>>,
/// 数据库句柄(子循环 provider 解析) /// 数据库句柄(子循环 provider 解析)
pub db: Arc<Database>, pub db: Arc<Database>,
/// 级联取消注册表(parent→children,与 AppState.sub_agent_registry 共享同一 Arc) /// 级联取消注册表(parent→children,与 AppState.sub_agent_registry 共享同一 Arc)
@@ -268,6 +271,15 @@ async fn spawn_agent_handler(
let task_prompt = build_task_prompt(&task, &allowed, &trimmed); let task_prompt = build_task_prompt(&task, &allowed, &trimmed);
// P2-3:系统提示词注入实际生效的预算值(父可传 1..20,子代理按真实预算规划) // P2-3:系统提示词注入实际生效的预算值(父可传 1..20,子代理按真实预算规划)
let system_prompt = sub_agent_system_prompt(max_rounds, &parent_lang); let system_prompt = sub_agent_system_prompt(max_rounds, &parent_lang);
// 项目规则文件投影与主会话同源:父会话工作区的 devflow.md/CLAUDE.md 对子代理同样生效
// (以父会话为缓存键,复用父会话已读内容零重复读盘;无授权目录/无文件时原样返回)。
let work_dir = {
let snap = ctx.allowed_dirs.read().await;
crate::commands::ai::devflow_md::work_dir_of(&snap)
};
let system_prompt = crate::commands::ai::devflow_md::append_projection(
system_prompt, work_dir.as_deref(), &parent_conv_id,
).await;
// P3-5:spawn 时刻快照父会话 save 元数据(active provider/created_at)。子代理后台 // P3-5:spawn 时刻快照父会话 save 元数据(active provider/created_at)。子代理后台
// 长跑收尾时用户可能已切走会话,出口落库若读 active_* 会继承别的会话元数据; // 长跑收尾时用户可能已切走会话,出口落库若读 active_* 会继承别的会话元数据;
+15 -29
View File
@@ -1044,34 +1044,8 @@ async fn run_tick(app: &AppHandle, cfg: &EngineConfig) {
} }
// 37 监控轮询:每轮末尾自监控快照(活性/熔断/失败计数),异常推飞书,正常落日志 // 37 监控轮询:每轮末尾自监控快照(活性/熔断/失败计数),异常推飞书,正常落日志
run_self_monitor(app, cfg).await; run_self_monitor(app, cfg).await;
// 归档清理已停用(2026-10-09 用户决策):外置文件超过 90 天也不删,全部保留。 // 归档清理已彻底移除(2026-10-11):90 天归档设计整体删除,外置文件永不清理,
// 原行为是每日一次清 90 天前外置派生文件。现摘除调用点,cleanup_expired 本体与单测 // 详见 tool_result_offload.rs 头注释。
// 保留,未来若需恢复只改此处。
run_daily_offload_cleanup(app).await;
}
/// 每日归档清理,已停用(2026-10-09 用户决策:外置文件全部保留,不按 90 天删)。
///
/// 保留函数骨架作为恢复点:若未来恢复归档,取消下方注释即可,cleanup_expired 本体
/// 与单测均未删。当前为空转,不触碰任何外置文件。
async fn run_daily_offload_cleanup(_app: &AppHandle) {
// 原逻辑:挂钟桶去重(同一天只跑一次),再调 cleanup_expired 删 90 天前文件。
// 用户决策改为全部保留,故摘除调用点,函数保留为空转。
//
// use std::sync::atomic::AtomicU64;
// static LAST_DAY: AtomicU64 = AtomicU64::new(0);
// let day = std::time::SystemTime::now()
// .duration_since(std::time::UNIX_EPOCH)
// .map(|d| d.as_secs() / 86400)
// .unwrap_or(0);
// if LAST_DAY.swap(day, Ordering::SeqCst) == day {
// return; // 今日已跑
// }
// let data_dir = _app.path().app_data_dir().unwrap_or_default();
// let (removed, freed) = crate::commands::ai::tool_result_offload::cleanup_expired(&data_dir);
// if removed > 0 {
// tracing::info!("归档清理:删除 {removed} 个过期外置文件,释放 {freed} 字节");
// }
} }
// ============================================================ // ============================================================
@@ -1289,10 +1263,22 @@ async fn recover_one_parked(app: &AppHandle) -> Result<String, String> {
if !state.conv_states.can_accept_request(&conv_id) { if !state.conv_states.can_accept_request(&conv_id) {
return Err(format!("会话 {} 正在生成中,无需恢复", conv_id)); return Err(format!("会话 {} 正在生成中,无需恢复", conv_id));
} }
// 恢复引导消息与 IPC 正典同源:按目标快照生成,快照全空回退常量。
// 引擎路径同步拿快照语义,防「引擎自动恢复按旧目标跑偏」。
let working_context = crate::commands::ai::commands::conversation::working_context_from_snapshot(
record.working_context_json.as_deref(),
);
let pinned = crate::commands::ai::commands::conversation::parse_pinned_goals(
record.pinned_goals.as_deref(),
);
let message = crate::commands::ai::commands::conversation::build_recovery_message(
&working_context,
&pinned,
);
spawn_agentic_session( spawn_agentic_session(
app, app,
&state, &state,
crate::commands::ai::commands::conversation::PARKED_RECOVERY_MESSAGE.to_string(), message,
None, None,
None, None,
None, None,
File diff suppressed because it is too large Load Diff
+3
View File
@@ -496,6 +496,8 @@ pub fn run() {
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,
// 桌面端补充注入(运行中会话 u-claw 语义:补充并进当前回答,不开新轮不入队)
commands::ai::ai_inject_runtime,
// F-15 阶段2 手动上下文管理:分段归档 + LLM 压缩 // F-15 阶段2 手动上下文管理:分段归档 + LLM 压缩
commands::ai::ai_chat_clear_context, commands::ai::ai_chat_clear_context,
commands::ai::ai_chat_compress_context, commands::ai::ai_chat_compress_context,
@@ -523,6 +525,7 @@ pub fn run() {
commands::ai::ai_conversation_peek, commands::ai::ai_conversation_peek,
// S3 完成卡备料:按父会话反查子代理轨迹精简元数据(只读) // S3 完成卡备料:按父会话反查子代理轨迹精简元数据(只读)
commands::ai::ai_sub_agent_list, commands::ai::ai_sub_agent_list,
commands::ai::context_briefs::ai_context_briefs,
commands::ai::ai_conversation_load_more, commands::ai::ai_conversation_load_more,
commands::ai::ai_conversation_delete, commands::ai::ai_conversation_delete,
commands::ai::ai_conversation_rename, commands::ai::ai_conversation_rename,
+47 -7
View File
@@ -98,6 +98,20 @@ impl MediaStore {
media_type: &str, media_type: &str,
original_name: Option<&str>, original_name: Option<&str>,
) -> Result<String, String> { ) -> Result<String, String> {
self.save_with_path(bytes, media_type, original_name)
.await
.map(|(url, _)| url)
}
/// 落盘并返回 (引用 url, 本机绝对路径)。文件消息入站用:模型 read_file 只认本机
/// 路径,OSS `https://` 引用对本机读取无意义,须另取落盘路径;图片入站只要引用,
/// 走 save 薄包装。落盘/双写/降级语义与 save 完全一致
pub async fn save_with_path(
&self,
bytes: &[u8],
media_type: &str,
original_name: Option<&str>,
) -> Result<(String, std::path::PathBuf), String> {
let key = content_key(bytes, media_type, original_name); let key = content_key(bytes, media_type, original_name);
let dir = self.data_dir.join("media"); let dir = self.data_dir.join("media");
tokio::fs::create_dir_all(&dir) tokio::fs::create_dir_all(&dir)
@@ -112,26 +126,33 @@ impl MediaStore {
return Err(format!("媒体写入失败: {e}")); return Err(format!("媒体写入失败: {e}"));
} }
} }
match &self.oss { let url = match &self.oss {
Some(cfg) => match upload_to_oss(cfg, &key, bytes, media_type).await { Some(cfg) => match upload_to_oss(cfg, &key, bytes, media_type).await {
Ok(()) => Ok(public_url(cfg, &key)), Ok(()) => public_url(cfg, &key),
Err(e) => { Err(e) => {
tracing::warn!(key = %key, err = %e, "OSS 上传失败,引用降级本机 file://"); tracing::warn!(key = %key, err = %e, "OSS 上传失败,引用降级本机 file://");
Ok(local_ref(&path)) local_ref(&path)
} }
}, },
None => Ok(local_ref(&path)), None => local_ref(&path),
} };
Ok((url, path))
} }
} }
/// 内容 sha256 全量 hex。落盘键与展示名回退共用同一 hash 口径(飞书文件入站无原始名
/// 时展示名取 `file_<hash 前 8>`,与落盘 hash 命名同源可对账)
pub fn content_hash(bytes: &[u8]) -> String {
use sha2::Digest as _;
hex::encode(sha2::Sha256::digest(bytes))
}
/// 内容寻址文件名:`<sha256前16hex>[_<清洗原始名>]<ext>`(对齐 u-claw safeAttachName /// 内容寻址文件名:`<sha256前16hex>[_<清洗原始名>]<ext>`(对齐 u-claw safeAttachName
/// 模式)——hash 前缀保去重(同内容必同文件)且排序聚簇,原始名缀后保目录可读。 /// 模式)——hash 前缀保去重(同内容必同文件)且排序聚簇,原始名缀后保目录可读。
/// 平铺不分会话目录:会话归属经 DB 消息引用反查,文件系统不编码会话(否则同图跨会话 /// 平铺不分会话目录:会话归属经 DB 消息引用反查,文件系统不编码会话(否则同图跨会话
/// 重复存储,去重失效);个人桌面应用媒体量级百~千,hash 前缀平铺可管理,分桶待量级上来再说 /// 重复存储,去重失效);个人桌面应用媒体量级百~千,hash 前缀平铺可管理,分桶待量级上来再说
fn content_key(bytes: &[u8], media_type: &str, original_name: Option<&str>) -> String { fn content_key(bytes: &[u8], media_type: &str, original_name: Option<&str>) -> String {
use sha2::Digest as _; let digest = content_hash(bytes)[..16].to_string();
let digest = hex::encode(&sha2::Sha256::digest(bytes)[..8]);
// 扩展名:原始名自带 ext 优先(保真),否则按 media_type 规范化 // 扩展名:原始名自带 ext 优先(保真),否则按 media_type 规范化
let name = original_name.map(sanitize_name).filter(|s| !s.is_empty()); let name = original_name.map(sanitize_name).filter(|s| !s.is_empty());
let ext = name let ext = name
@@ -280,6 +301,25 @@ mod tests {
tokio::fs::remove_dir_all(&dir).await.ok(); tokio::fs::remove_dir_all(&dir).await.ok();
} }
#[tokio::test]
async fn save_with_path_returns_readable_local_path() {
// 文件消息入站契约:引用 url 之外另给本机路径(模型 read_file 只认本机路径),
// 通用文件落盘字节无损可读
let dir = std::env::temp_dir().join(format!("df-media-test-{}", df_types::types::new_id()));
let store = MediaStore::new(dir.clone(), None);
let (url, path) = store
.save_with_path(b"doc-bytes", "application/octet-stream", Some("合同.pdf"))
.await
.unwrap();
assert!(url.starts_with("file://"));
assert_eq!(
path.file_name().unwrap().to_string_lossy(),
format!("{}_合同.pdf", &content_hash(b"doc-bytes")[..16])
);
assert_eq!(tokio::fs::read(&path).await.unwrap(), b"doc-bytes");
tokio::fs::remove_dir_all(&dir).await.ok();
}
#[test] #[test]
fn content_key_stable_and_ext_aware() { fn content_key_stable_and_ext_aware() {
let k1 = content_key(b"abc", "image/png", None); let k1 = content_key(b"abc", "image/png", None);
+1
View File
@@ -362,6 +362,7 @@ impl AppState {
session: ai_session.clone(), session: ai_session.clone(),
conv_states: conv_states.clone(), conv_states: conv_states.clone(),
llm_concurrency: llm_concurrency.clone(), llm_concurrency: llm_concurrency.clone(),
allowed_dirs: allowed_dirs.clone(),
db: db.clone(), db: db.clone(),
sub_agents: sub_agent_registry.clone(), sub_agents: sub_agent_registry.clone(),
}; };
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"productName": "devflow", "productName": "devflow",
"version": "0.1.2", "version": "0.1.3",
"identifier": "top.1216.devflow", "identifier": "top.1216.devflow",
"build": { "build": {
"beforeDevCommand": "bun dev", "beforeDevCommand": "bun dev",
+12 -11
View File
@@ -31,16 +31,16 @@
<!-- 走查 P3-16:全局 toast 统一收进同一纵向堆叠容器。原错误 toast 与托盘提示气泡 <!-- 走查 P3-16:全局 toast 统一收进同一纵向堆叠容器。原错误 toast 与托盘提示气泡
各自 fixed 同位(bottom:24px 居中),两者同现时互相压盖、后弹者盖住先弹者。 --> 各自 fixed 同位(bottom:24px 居中),两者同现时互相压盖、后弹者盖住先弹者。 -->
<div class="df-toast-stack"> <div class="app-toast-stack">
<!-- 全局错误 toast(根级,主窗口与分离窗口共用)--> <!-- 全局错误 toast(根级,主窗口与分离窗口共用)-->
<transition name="toast"> <transition name="toast">
<div v-if="errorMsg" class="df-toast">⚠ {{ errorMsg }}</div> <div v-if="errorMsg" class="app-toast">⚠ {{ errorMsg }}</div>
</transition> </transition>
<!-- 关窗后台运行气泡(主窗关闭被拦截且仍有会话运行时,后端 emit tray-hide-notice): <!-- 关窗后台运行气泡(主窗关闭被拦截且仍有会话运行时,后端 emit tray-hide-notice):
告知会话转入后台继续生成、托盘图标可随时唤起,复用 df-toast 形态 --> 告知会话转入后台继续生成、托盘图标可随时唤起,复用 app-toast 形态 -->
<transition name="toast"> <transition name="toast">
<div v-if="trayNoticeMsg" class="df-toast df-toast--info">{{ trayNoticeMsg }}</div> <div v-if="trayNoticeMsg" class="app-toast app-toast--info">{{ trayNoticeMsg }}</div>
</transition> </transition>
</div> </div>
@@ -114,7 +114,7 @@ watch(() => projectStore.error, (err) => {
}) })
// ── 关窗后台运行气泡(主窗关闭被拦截且仍有会话运行时,后端 emit tray-hide-notice)── // ── 关窗后台运行气泡(主窗关闭被拦截且仍有会话运行时,后端 emit tray-hide-notice)──
// 与错误 toast 同款 df-toast 形态,信息语义走 --info 变体底色,不与错误红混用 // 与错误 toast 同款 app-toast 形态,信息语义走 --info 变体底色,不与错误红混用
const trayNoticeMsg = ref('') const trayNoticeMsg = ref('')
let trayNoticeTimer: ReturnType<typeof setTimeout> | null = null let trayNoticeTimer: ReturnType<typeof setTimeout> | null = null
let _unlistenTrayHideNotice: (() => void) | null = null let _unlistenTrayHideNotice: (() => void) | null = null
@@ -353,20 +353,21 @@ onUnmounted(() => {
/* ═══ 全局 toast 纵向堆叠容器(走查 P3-16)═══ /* ═══ 全局 toast 纵向堆叠容器(走查 P3-16)═══
容器独占 fixed 定位,内部 flex 纵排 + 底对齐,多条 toast 依次向上堆叠不再同位互盖。 容器独占 fixed 定位,内部 flex 纵排 + 底对齐,多条 toast 依次向上堆叠不再同位互盖。
子项保持 .df-toast 原形态(定宽自适应),定位字段从子项上移到容器。 */ 子项 .app-toast 独立命名(2026-10-18 与 global.css .df-toast 顶部居中族撞名根治,
.df-toast-stack { 不再依赖定位覆盖对冲),主题保持根级强信号形态(实底白字)。 */
.app-toast-stack {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; align-items: center; gap: 8px; display: flex; flex-direction: column; align-items: center; gap: 8px;
z-index: 9999; max-width: 90vw; pointer-events: none; z-index: 9999; max-width: 90vw; pointer-events: none;
} }
.df-toast { .app-toast {
background: var(--df-danger); color: #fff; background: var(--df-danger); color: #fff;
padding: 10px 18px; border-radius: var(--df-radius-sm); padding: 10px 18px; border-radius: var(--df-radius-sm);
font-size: 13px; max-width: 90vw; font-size: 13px; max-width: 90vw;
box-shadow: 0 4px 16px rgba(0,0,0,0.3); box-shadow: var(--df-shadow-md);
} }
.toast-enter-active, .toast-leave-active { transition: opacity 0.25s var(--df-ease), transform 0.25s var(--df-ease); } .toast-enter-active, .toast-leave-active { transition: opacity 0.25s var(--df-ease), transform 0.25s var(--df-ease); }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(10px); } .toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(10px); }
/* 后台运行提示:同 df-toast 形态,信息语义用 accent 底色与错误红区分 */ /* 后台运行提示:同 app-toast 形态,信息语义用 accent 底色与错误红区分(实底需白字) */
.df-toast--info { background: var(--df-accent); } .app-toast--info { background: var(--df-accent); color: #fff; }
</style> </style>
+9
View File
@@ -418,4 +418,13 @@ export const aiApi = {
listTaskConversations(taskId: string): Promise<{ conversation_id: string; title: string | null; tool_name: string; requested_at: string }[]> { listTaskConversations(taskId: string): Promise<{ conversation_id: string; title: string | null; tool_name: string; requested_at: string }[]> {
return invoke('list_task_conversations', { taskId }) return invoke('list_task_conversations', { taskId })
}, },
/**
* 脉络卡:published 知识按 source_project 聚合(每项目取复用最高 perProject 条)。
* focusConv 标记来源为当前会话的分组(focused=true 供前端高亮);后端未合版本会忽略该参数。
* 纯读侧;用户点「带入上下文」才经正常发送路径显式注入,不触碰 L3 工具召回铁律。
*/
listContextBriefs(perProject?: number, focusConv?: string): Promise<{ source_project: string; focused?: boolean; briefs: { title: string; summary: string; source_ref: string | null; source_conv: string | null; source_project: string | null; reuse_count: number; updated_at: string }[] }[]> {
return invoke('ai_context_briefs', { perProject: perProject ?? null, focusConv: focusConv ?? null })
},
} }
+10
View File
@@ -104,6 +104,8 @@ export interface ProjectRecord {
path: string | null path: string | null
/** 技术栈 JSON 数组字符串(如 ["rust","vue"],null=未探测) */ /** 技术栈 JSON 数组字符串(如 ["rust","vue"],null=未探测) */
stack: string | null stack: string | null
/** 归档标记(归档功能 P2,V48)。true=归档态,退出主列表仅归档箱可见;详情页据此区分「已归档」与「不存在」 */
archived?: boolean
created_at: string created_at: string
updated_at: string updated_at: string
/** /**
@@ -537,6 +539,9 @@ export type AiChatEvent = ({
} | { } | {
// 上下文已清空:前端刷新消息列表 + toast 提示 // 上下文已清空:前端刷新消息列表 + toast 提示
type: 'AiContextCleared' type: 'AiContextCleared'
} | {
// E2 压缩失败降级(关键词摘要兜底,loop 仍在跑):前端 toast 提示不中断流
type: 'AiContextDegraded'; reason: string
}) & { }) & {
conversation_id?: ConvId conversation_id?: ConvId
} }
@@ -565,6 +570,11 @@ export interface AiMessage {
id: MessageId id: MessageId
role: 'user' | 'assistant' | 'tool' | 'system' role: 'user' | 'assistant' | 'tool' | 'system'
content: string content: string
/**
* 消息来源(958 溯源/961 轮次归属):undefined=null=桌面本端发起;'inject'=运行中
* 补充注入(961② 轮次分桶判据,归属当前轮不开新轮);'feishu'=飞书入站发起。
*/
source?: { channel: string; message_id?: string; chat_id?: string; sender?: string } | null
/** /**
* 推理过程文本(DeepSeek reasoning_content / Anthropic thinking 同通道,后端落库 * 推理过程文本(DeepSeek reasoning_content / Anthropic thinking 同通道,后端落库
* ai_messages.reasoning_content)。undefined=该轮无思考内容。历史消息 reload 时 * ai_messages.reasoning_content)。undefined=该轮无思考内容。历史消息 reload 时
+44 -254
View File
@@ -53,17 +53,9 @@
<div v-if="autoCompressedFlash" class="ai-autocompressed-hint">{{ t('aiChat.autoCompressed') }}</div> <div v-if="autoCompressedFlash" class="ai-autocompressed-hint">{{ t('aiChat.autoCompressed') }}</div>
<!-- 无人值守边界配置卡(默认收起=现状零变化):发起时一次授权,生成中锁编辑 --> <!-- 无人值守边界配置卡(默认收起=现状零变化):发起时一次授权,生成中锁编辑 -->
<TaskBoundaryCard @toast="(p) => showToast(p.msg, p.type)" /> <TaskBoundaryCard @toast="(p) => showToast(p.msg, p.type)" />
<!-- AE-2025-07: Agentic 循环进度条(C 状态语义化) <!-- AE-2025-07: Agentic 循环进度条(C 状态语义化)。
仅 agentic 多轮(streaming 且收到过 AiAgentRound round>0 事件)显示; C4b 拆分至 ai-chat/agentic-progress-bar.vue(纯展示,store 单例直读,零行为变更) -->
单轮问答 agentRound 恒 0,不显示(避免误显)。 <AgenticProgressBar />
文案:第 {round} 轮 · 累计 {total} 次调用 · 进行中/待审批;
total 由前端聚合当前轮次 toolCalls(useTurnTrace),完成后进度条隐藏,
完成聚合语义由 MessageList 的执行轨迹卡承接。
降级省略:max(总轮次)后端 AiAgentRound 未透传 max_rounds,不显。 -->
<div v-if="showAgenticProgress" class="ai-agentic-progress">
<span class="ai-agentic-progress-dot"></span>
<span class="ai-agentic-progress-text">{{ agenticProgressText }}</span>
</div>
<!-- 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 主动问用户)。 -->
@@ -75,46 +67,10 @@
path 挂起归一进 ToolCard 内联审批(单真相源:状态层合);关(false,兜底回退)时挂载 path 挂起归一进 ToolCard 内联审批(单真相源:状态层合);关(false,兜底回退)时挂载
DirAuthDialog 独立弹窗老链路。两路共存,appSettings 随时回退无需改代码。 --> DirAuthDialog 独立弹窗老链路。两路共存,appSettings 随时回退无需改代码。 -->
<DirAuthDialog v-if="!unifiedApproval" @toast="(p) => showToast(p.msg, p.type)" /> <DirAuthDialog v-if="!unifiedApproval" @toast="(p) => showToast(p.msg, p.type)" />
<!-- 待发送队列(生成中排队的消息,完成后自动续发;仅显示当前活跃会话的条目) --> <!-- 待发送队列(生成中排队的消息,完成后自动续发;仅显示当前活跃会话的条目)。
<div v-if="activeQueue.length > 0" class="ai-queue" :class="{ 'ai-queue--timeout': queueTimedOut }"> C4b 拆分至 ai-chat/queue-panel.vue(L2 发送韧性 + V7 排队时钟 + 队列编辑/回填随迁,
<div class="ai-queue-head"> confirm 自持同 ChatInput 模式;hasUnsentInput/fillDraft 经 props/emits 接 ChatInput ref) -->
<span class="ai-queue-title">{{ $t('aiChat.queueTitle', { n: activeQueue.length }) }}</span> <QueuePanel :has-unsent-input="hasUnsentInput" @fill-draft="onQueueFillDraft" />
<div class="ai-queue-actions">
<button v-if="queueTimedOut" class="ai-queue-force" @click="handleForceSend">{{ $t('ai.forceSendBtn') }}</button>
<button class="ai-queue-clear" @click="handleClearQueue">{{ $t('aiChat.clearQueue') }}</button>
</div>
</div>
<div class="ai-queue-list">
<div v-for="(item, idx) in activeQueue" :key="idx" class="ai-queue-item">
<!-- 编辑态只编 text:inline input,回车/失焦写回,ESC 取消 -->
<template v-if="editingQueueIdx === idx">
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
<input
v-model="editingQueueText"
class="ai-queue-edit-input"
type="text"
@keydown="onQueueEditKeydown"
@blur="saveEditQueued"
/>
<button class="ai-queue-btn" @mousedown.prevent="saveEditQueued" :title="$t('aiChat.queueSave')" :aria-label="$t('aiChat.queueSave')">✓</button>
<button class="ai-queue-btn" @mousedown.prevent="cancelEditQueued" :title="$t('aiChat.queueCancel')" :aria-label="$t('aiChat.queueCancel')">✕</button>
</template>
<template v-else>
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
<span class="ai-queue-text" :title="item.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="handleSendQueuedNow(idx)" :title="$t('aiChat.queueSendNow')">{{ $t('aiChat.queueSendNow') }}</button>
<button class="ai-queue-x" @click="store.cancelQueued(idx)" :title="$t('common.cancel')" :aria-label="$t('common.cancel')">×</button>
</template>
</div>
</div>
</div>
<!-- 第二批抽离: ChatInput 子组件(输入框+附件+技能/实体联想+发送/停止) --> <!-- 第二批抽离: ChatInput 子组件(输入框+附件+技能/实体联想+发送/停止) -->
<ChatInput <ChatInput
ref="chatInputRef" ref="chatInputRef"
@@ -132,28 +88,14 @@
:danger-label="confirmState.dangerLabel" :danger-label="confirmState.dangerLabel"
@result="answerConfirm" @result="answerConfirm"
/> />
<!-- 错误 toast(零依赖,复用 Settings.vue 同款 reactive toast;分离窗口无 App.vue 根 toast,故本组件自管) --> <!-- 错误 toast(零依赖 useToast;分离窗口无 App.vue 根 toast,故本组件自管;样式消费全局 .df-toast 族,error 映射 danger) -->
<Transition name="ai-toast"> <Transition name="df-toast">
<div v-if="toast.visible" class="ai-toast" :class="'ai-toast-' + toast.type">{{ toast.msg }}</div> <div v-if="toast.visible" class="df-toast" :class="toast.type === 'error' ? 'df-toast--danger' : 'df-toast--' + toast.type">{{ toast.msg }}</div>
</Transition> </Transition>
<!-- I12(2026-10-05):键盘快捷键速查。Teleport 到 body 防 .ai-panel overflow 裁剪; <!-- I12(2026-10-05):键盘快捷键速查。C4b 拆分至 ai-chat/shortcuts-overlay.vue
主窗/分离窗各自实例独立渲染,入口在 TopBar ... 菜单(emit show-shortcuts) --> (Teleport 到 body 防 .ai-panel overflow 裁剪;主窗/分离窗各自实例独立渲染,
<Teleport to="body"> 入口在 TopBar ... 菜单 emit show-shortcuts;visible 状态留父供 Esc 仲裁) -->
<div v-if="shortcutsVisible" class="ai-shortcuts-mask" @click.self="shortcutsVisible = false"> <ShortcutsOverlay :visible="shortcutsVisible" @close="shortcutsVisible = false" />
<div class="ai-shortcuts-panel" role="dialog" :aria-label="t('aiChat.shortcutsTitle')">
<div class="ai-shortcuts-head">
<span class="ai-shortcuts-title">{{ t('aiChat.shortcutsTitle') }}</span>
<button class="ai-shortcuts-close" :aria-label="t('aiChat.scCloseOverlay')" @click="shortcutsVisible = false">✕</button>
</div>
<ul class="ai-shortcuts-list">
<li v-for="s in shortcuts" :key="s.key" class="ai-shortcuts-row">
<span class="ai-shortcuts-key">{{ s.key }}</span>
<span class="ai-shortcuts-act">{{ s.label }}</span>
</li>
</ul>
</div>
</div>
</Teleport>
</div> </div>
</template> </template>
@@ -176,10 +118,13 @@ import DirAuthDialog from './ai/DirAuthDialog.vue'
import { useConfirm } from '../composables/useConfirm' import { useConfirm } from '../composables/useConfirm'
import { useToast } from '../composables/useToast' import { useToast } from '../composables/useToast'
import { useProjectStore } from '../stores/project' import { useProjectStore } from '../stores/project'
import { collectTurnStats, turnMessages } from '../composables/ai/useTurnTrace'
import { useAiContext } from '../composables/ai/useAiContext' import { useAiContext } from '../composables/ai/useAiContext'
import { emitReattachToMain, registerMainReattachListener, emitOpenSettings, registerMainOpenSettingsListener } from '@/composables/ai/useAiWindow' import { emitReattachToMain, registerMainReattachListener, emitOpenSettings, registerMainOpenSettingsListener } from '@/composables/ai/useAiWindow'
import type { AiMessage } from '../api/types' import type { AiMessage, SkillInfo } from '../api/types'
// C4b 拆分(第五批): Agentic 进度条/待发送队列/快捷键速查 三自有区块抽至 ai-chat/ 子目录
import AgenticProgressBar from './ai-chat/agentic-progress-bar.vue'
import QueuePanel from './ai-chat/queue-panel.vue'
import ShortcutsOverlay from './ai-chat/shortcuts-overlay.vue'
const props = defineProps<{ const props = defineProps<{
detached?: boolean detached?: boolean
@@ -262,22 +207,7 @@ const messageListRef = ref<InstanceType<typeof MessageList> | null>(null)
// 头部审批徽标 — 当前对话积压的待审批工具数(>0 时显示) // 头部审批徽标 — 当前对话积压的待审批工具数(>0 时显示)
const pendingApprovalCount = computed(() => store.state.pendingApprovals.length) const pendingApprovalCount = computed(() => store.state.pendingApprovals.length)
// Agentic 循环进度条 // C4b: Agentic 循环进度条(显示条件+文案 computed)已随 ai-chat/agentic-progress-bar.vue 迁出
// 仅 agentic 多轮(streaming 且 agentRound>0)显示;单轮问答不收 AiAgentRound 故恒隐藏。
const showAgenticProgress = computed(() =>
store.state.streaming && store.state.agentRound > 0,
)
// 进度文案(C 状态语义化):「第 N 轮 · 累计 M 次调用 · 进行中/待审批」。
// 累计调用数由前端聚合当前轮次消息流上的 toolCalls(后端事件无累计字段,不改协议);
// 替代旧「循环 N · X个工具完成」——旧 completedTools 每轮清零,轮初叙述期恒显 0 造成歧义。
// 「已完成」态不出现在状态栏:完成后进度条隐藏(既有设计),完成聚合由执行轨迹卡承接。
const agenticProgressText = computed(() => {
const pending = store.state.pendingApprovals.length
const total = collectTurnStats(turnMessages(store.state.messages)).total
return pending > 0
? t('ai.agenticProgress', { round: store.state.agentRound, total, pending })
: t('ai.agenticProgressNoPending', { round: store.state.agentRound, total })
})
/** 点击徽标:滚动到第一个 pending_approval 卡片(委托 MessageList.scrollToFirstPending) */ /** 点击徽标:滚动到第一个 pending_approval 卡片(委托 MessageList.scrollToFirstPending) */
function scrollToFirstPending(): void { function scrollToFirstPending(): void {
@@ -337,10 +267,7 @@ const projectsStore = useProjectStore()
// 滚动边沿/跟随意图重置随 MessageList 迁移,由子组件自管。 // 滚动边沿/跟随意图重置随 MessageList 迁移,由子组件自管。
watch(() => store.state.activeConversationId, () => { watch(() => store.state.activeConversationId, () => {
if (editingMsgId.value) cancelEdit() if (editingMsgId.value) cancelEdit()
// UX-P3: 队列编辑态(editingQueueIdx/editingQueueText)绑定旧会话队列在 activeQueue // UX-P3: 队列编辑态重置已随 QueuePanel 迁出(子组件内自 watch activeConversationId)。
// 视图内的下标,切会话后 activeQueue 已换成新会话的集合,残留 idx/text 会错挂到
// 新会话同下标队列项上,随消息编辑态一并重置。
if (editingQueueIdx.value !== null) cancelEditQueued()
}) })
@@ -485,135 +412,23 @@ function onBeforeUnloadGuard(e: BeforeUnloadEvent) {
// (ChatInput 经 emit('sent')/emit('cancel-edit')/emit('error') 与父交互) // (ChatInput 经 emit('sent')/emit('cancel-edit')/emit('error') 与父交互)
// ── L2 发送韧性:排队超时检测 + 强制发送 ── // ── L2 发送韧性:排队超时检测 + 强制发送 ──
// (activeQueue 过滤视图已随 queue-panel.vue 子组件迁移,父侧死副本删除)
/** 当前活跃会话的待发送队列(按 activeConversationId 过滤)。
* 队列是全局数组,多会话并发可各自排队;旧实现渲染全部会话的 queue,切/新建后会显示
* 旧会话排队消息。此处仅显当前活跃会话条目,相关操作(编辑/删除/立即发送/清空/超时)均按此收敛。 */
const activeQueue = computed(() =>
store.state.queue.filter(q => q.conversationId === store.state.activeConversationId),
)
// ── V7 排队时钟:响应式 tick 驱动排队项已等待时长 ── // ── V7 排队时钟:响应式 tick 驱动排队项已等待时长 ──
// 原 queueWaitSeconds 在 computed 内直接取 Date.now(),非响应式依赖:纯等待期(无 store 变更) // 原 queueWaitSeconds 在 computed 内直接取 Date.now(),非响应式依赖:纯等待期(无 store 变更)
// 永不重算,秒数冻结;且只覆盖队首。改为有排队时起 1s interval 推进 nowTick,展示函数读 // 队列超时横幅/强制发送/清空/等待时钟(V7 nowTick)已随 queue-panel.vue 子组件迁移
// nowTick 建立依赖实现每秒刷新;队列清空(含队首进入发送被移除)即关表,卸载时显式清理防泄漏。 // (2026-10-18 UI 重构批,子组件自带 interval 与清理),父侧死副本删除(vue-tsc TS6133;
const nowTick = ref(Date.now()) // 旧父组件时钟在无消费方后仍每秒空转,一并消除)。
let queueTickTimer: ReturnType<typeof setInterval> | null = null
/** 队首排队是否已超 30s(仅看当前活跃会话队列)。 /** QueuePanel emit fill-draft 的父侧转发:写入 ChatInput 草稿(拆分纪律:ChatInput ref 留父) */
* UX-P2-A1:isQueueTimedOut 内取 Date.now() 非响应式,纯等待期(无 store 变更)本 computed function onQueueFillDraft(text: string, skill: SkillInfo | null) {
* 永不重算,超时横幅/强制发送按钮延迟到下一次无关 store 变更才出现。显式读 nowTick chatInputRef.value?.fillDraft(text, skill)
* (V7 排队时钟,有排队时 1s interval 推进)建立每秒依赖,超时判定随 tick 每秒重算。 */
const queueTimedOut = computed(() => {
void nowTick.value
return store.isQueueTimedOut(store.state.activeConversationId)
})
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(作用于当前活跃会话) */
async function handleForceSend() {
await store.tryForceSend(confirmDialog, store.state.activeConversationId)
}
/** UI-4(看板 943)清空队列:二次确认后全清当前活跃会话排队消息。
* 一键误触会静默丢掉全部排队消息且不可恢复,复用本组件 ConfirmDialog 先例
* (同 tryForceSend 的 confirmFn 注入形态),不引 window.confirm(仓风格)。 */
async function handleClearQueue() {
const convId = store.state.activeConversationId
if (!convId) return
const confirmed = await confirmDialog(t('aiChat.clearQueueConfirm'))
if (confirmed) store.clearQueue(convId)
} }
// ── 队列消息编辑(只编 text)+ 立即发送(插队)── // ── 队列消息编辑(只编 text)+ 立即发送(插队)──
/** 当前正在编辑的队列项 index;null=非编辑态 */ // 队列编辑态(编辑中 index/文本/进取消存取)已随 queue-panel.vue 子组件迁移(2026-10-18
const editingQueueIdx = ref<number | null>(null) // UI 重构批),父组件死副本删除(vue-tsc TS6133);IME 守卫等行为在子组件内同名实现。
/** 编辑态输入框文本(回车写回 / ESC 取消还原) */
const editingQueueText = ref('')
/** 进入编辑态:记录 index + 快照当前 text(activeQueue 视图内下标) */
function startEditQueued(idx: number) {
const item = activeQueue.value[idx]
if (!item) return
editingQueueIdx.value = idx
editingQueueText.value = item.text
}
/** 取消编辑:还原非编辑态,不写回 */
function cancelEditQueued() {
editingQueueIdx.value = null
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 */
function saveEditQueued() {
if (editingQueueIdx.value === null) return
store.editQueued(editingQueueIdx.value, editingQueueText.value)
editingQueueIdx.value = null
editingQueueText.value = ''
}
/** 立即发送:委托 store.sendQueuedNow(移除+stop+sendMessage) */
async function handleSendQueuedNow(idx: number) {
await store.sendQueuedNow(idx)
}
/** I4 队列回填:取出该条填回输入框重新编辑(从队列移除,可改完再发)。
* cancelQueued 内部经 activeQueueRealIndex 做过滤视图下标映射,越界 no-op;
* 项携带技能时按名找回 SkillInfo 回挂 chip,找不到(技能已卸载)只回填文本,
* 与队列原地编辑只保 text 同一取舍。 */
async function backfillQueued(idx: number) {
const item = activeQueue.value[idx]
if (!item) return
// UX-P3: 输入框已有未发送草稿(文本/图片/技能 chip 任一非空)时回填会静默覆盖,
// 先 confirm 确认;取消则直接返回,队列项原样保留(尚未 cancelQueued)。
if (chatInputRef.value?.hasUnsentInput()) {
if (!await confirmDialog(t('aiChat.backfillOverwriteConfirm'))) 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 单例共享)。
@@ -627,8 +442,7 @@ onBeforeUnmount(() => {
// 归还 listener 租约(最后一份归还才真拆单例 listener;双重归还/下溢由 useAiEvents dev 断言暴露) // 归还 listener 租约(最后一份归还才真拆单例 listener;双重归还/下溢由 useAiEvents dev 断言暴露)
_listenerRelease?.() _listenerRelease?.()
_listenerRelease = null _listenerRelease = null
// V7 排队时钟 interval 随组件卸载清理(watch 随卸载停但 interval 本体不自停,须显式清) // V7 排队时钟已随 queue-panel.vue 迁移,其 interval 由子组件 onBeforeUnmount 自清。
if (queueTickTimer !== null) { clearInterval(queueTickTimer); queueTickTimer = null }
// 全局快捷键卸载时移除 window listener,防分离窗口/切组件后泄漏 // 全局快捷键卸载时移除 window listener,防分离窗口/切组件后泄漏
window.removeEventListener('keydown', onGlobalKeydown) window.removeEventListener('keydown', onGlobalKeydown)
// 摘除未发送输入保护(分离窗口关闭/页面刷新触发 beforeunload) // 摘除未发送输入保护(分离窗口关闭/页面刷新触发 beforeunload)
@@ -786,16 +600,7 @@ async function toggleAlwaysOnTop() {
// I12(2026-10-05):顶部 ... 菜单入口(TopBar emit show-shortcuts)→ 此处 Teleport 展示速查浮层。 // I12(2026-10-05):顶部 ... 菜单入口(TopBar emit show-shortcuts)→ 此处 Teleport 展示速查浮层。
// 键位表 shortcuts 与 onGlobalKeydown 同文件同居——键区调整时表随手同步,防速查与真实键位漂移。 // 键位表 shortcuts 与 onGlobalKeydown 同文件同居——键区调整时表随手同步,防速查与真实键位漂移。
const shortcutsVisible = ref(false) const shortcutsVisible = ref(false)
// computed:随 locale 即时翻译(仅面板打开时消费,开销可忽略);t 已在本组件上方 useI18n 解构 // 键位表已随 shortcuts-overlay.vue 子组件迁移(C4b),父侧死副本删除(vue-tsc TS6133)。
const shortcuts = computed(() => [
{ key: 'Ctrl+N', label: t('aiChat.scNewConv') },
{ key: 'Ctrl+I', label: t('aiChat.scFocusInput') },
{ key: 'Ctrl+R', label: t('aiChat.scRegenerate') },
{ key: 'Ctrl+L', label: t('aiChat.scClearChat') },
{ key: 'Ctrl+K', label: t('aiChat.scSearch') },
{ key: 'Ctrl+B', label: t('aiChat.scToggleSidebar') },
{ key: 'Esc', label: t('aiChat.scCloseOverlay') },
])
function showShortcuts() { function showShortcuts() {
shortcutsVisible.value = true shortcutsVisible.value = true
} }
@@ -893,6 +698,10 @@ async function initContextEventListeners(): Promise<void> {
onError: (_convId, message) => { onError: (_convId, message) => {
showToast(t('aiChat.compressError', { msg: message }), 'error') showToast(t('aiChat.compressError', { msg: message }), 'error')
}, },
// E2 压缩失败降级:仅提示不刷新不打断流(loop 仍在跑,实证静默降级致用户原句重发)
onContextDegraded: (_convId, reason) => {
showToast(t('aiChat.contextDegraded', { reason }), 'warning')
},
}) })
} }
</script> </script>
@@ -902,7 +711,7 @@ async function initContextEventListeners(): Promise<void> {
复用按钮抽 components.css 全局。本块仅保留 AiChat 自身元素:面板布局/聊天区/input-area 复用按钮抽 components.css 全局。本块仅保留 AiChat 自身元素:面板布局/聊天区/input-area
容器/agentic 进度/待发队列/toast。子组件元素由各子组件 scoped 负责,父级 scoped 不渗透。) ═══ */ 容器/agentic 进度/待发队列/toast。子组件元素由各子组件 scoped 负责,父级 scoped 不渗透。) ═══ */
.ai-panel { .ai-panel {
position: relative; /* 容纳 .ai-toast(absolute,相对面板定位,嵌入/分离模式均生效) */ position: relative; /* toast 已改全局 .df-toast(fixed 定位);relative 保留兜底其他绝对定位子元素 */
display: flex; display: flex;
flex-direction: row; flex-direction: row;
height: 100%; height: 100%;
@@ -924,7 +733,7 @@ async function initContextEventListeners(): Promise<void> {
TopBar.vue scoped 自持 + .ai-btn-icon* 抽 components.css 全局共享, TopBar.vue scoped 自持 + .ai-btn-icon* 抽 components.css 全局共享,
原 AiChat scoped 不渗透子组件根,删此重复块零视觉变更) */ 原 AiChat scoped 不渗透子组件根,删此重复块零视觉变更) */
/* ── 「信任所有目录」放行横幅(开关开启期间常驻,warning 色系对齐 .ai-toast-warning) ── */ /* ── 「信任所有目录」放行横幅(开关开启期间常驻,warning 色系对齐 .df-toast--warning) ── */
.ai-auth-bypass-banner { .ai-auth-bypass-banner {
padding: 5px 14px; padding: 5px 14px;
background: var(--df-warning-bg); background: var(--df-warning-bg);
@@ -1054,27 +863,8 @@ async function initContextEventListeners(): Promise<void> {
/* (.ai-btn-icon:disabled / .ai-btn-spinner / @keyframes ai-btn-spin 已抽 components.css 全局共享) */ /* (.ai-btn-icon:disabled / .ai-btn-spinner / @keyframes ai-btn-spin 已抽 components.css 全局共享) */
/* ═══ 错误 toast(复用 Settings.vue .toast 样式,前缀 ai- 防 scoped 冲突) ═══ */ /* ═══ 错误 toast:消费 global.css .df-toast 四色族 + .df-toast-enter/leave 过渡
.ai-toast { (2026-10-18 整洁波双轨合一,原 .ai-toast-* 本地族删除;error 态映射 --danger) ═══ */
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
padding: 8px 14px;
border-radius: var(--df-radius);
font-size: 12px;
z-index: 100;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
max-width: 80%;
overflow-wrap: anywhere;
}
.ai-toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
.ai-toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
.ai-toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); }
/* ToastType 新增 'success' 兜底样式 */
.ai-toast-success { background: var(--df-success-bg); color: var(--df-success); border: 0.5px solid var(--df-success); }
.ai-toast-enter-active, .ai-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
.ai-toast-enter-from, .ai-toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
/* ═══ I12 快捷键速查面板(Teleport 到 body,scoped 对 Teleport 内容仍生效—— /* ═══ I12 快捷键速查面板(Teleport 到 body,scoped 对 Teleport 内容仍生效——
Vue scoped 把 data 属性打在编译期节点上,与挂载位置无关) ═══ */ Vue scoped 把 data 属性打在编译期节点上,与挂载位置无关) ═══ */
@@ -1091,7 +881,7 @@ async function initContextEventListeners(): Promise<void> {
background: var(--df-bg-card); background: var(--df-bg-card);
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg, 8px); border-radius: var(--df-radius-lg, 8px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); box-shadow: var(--df-shadow-sm);
width: 320px; width: 320px;
max-width: calc(100vw - 48px); max-width: calc(100vw - 48px);
padding: 14px 16px; padding: 14px 16px;
@@ -1116,7 +906,7 @@ async function initContextEventListeners(): Promise<void> {
padding: 2px 4px; padding: 2px 4px;
border-radius: 4px; border-radius: 4px;
} }
.ai-shortcuts-close:hover { color: var(--df-text); background: var(--df-bg-secondary); } .ai-shortcuts-close:hover { color: var(--df-text); background: var(--df-bg-card); }
.ai-shortcuts-list { .ai-shortcuts-list {
list-style: none; list-style: none;
margin: 0; margin: 0;
@@ -1132,9 +922,9 @@ async function initContextEventListeners(): Promise<void> {
font-size: 12px; font-size: 12px;
} }
.ai-shortcuts-key { .ai-shortcuts-key {
font-family: var(--df-mono, monospace); font-family: var(--df-font-mono);
font-size: 11px; font-size: 11px;
background: var(--df-bg-secondary); background: var(--df-bg-card);
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: 4px; border-radius: 4px;
padding: 2px 8px; padding: 2px 8px;
+72 -50
View File
@@ -21,9 +21,9 @@
<span v-html="toolIcon(tc.name)"></span> <span v-html="toolIcon(tc.name)"></span>
</span> </span>
<div class="ai-tool-header-text"> <div class="ai-tool-header-text">
<span class="ai-tool-name">{{ toolDisplayName(tc) }}</span> <span class="ai-tool-name df-mono">{{ toolDisplayName(tc) }}</span>
<span v-if="tc.status === 'running'" class="ai-tool-sub">{{ $t('aiTool.running') }}</span> <span v-if="tc.status === 'running'" class="ai-tool-sub df-badge df-badge--info ai-tool-chip-run">{{ $t('aiTool.running') }}</span>
<span v-else-if="isFailed" class="ai-tool-sub ai-tool-sub--failed" :title="summaryTitle">{{ $t('aiTool.executionFailed') }}</span> <span v-else-if="isFailed" class="ai-tool-sub ai-tool-sub--failed df-badge df-badge--danger" :title="summaryTitle">{{ $t('aiTool.executionFailed') }}</span>
<span v-else-if="resultSummary" class="ai-tool-sub" :title="summaryTitle">{{ resultSummary }}</span> <span v-else-if="resultSummary" class="ai-tool-sub" :title="summaryTitle">{{ resultSummary }}</span>
</div> </div>
<span class="ai-tool-status-dot" :class="'ai-tool-status-dot--' + (isFailed ? 'failed' : tc.status)" :title="statusTitle"></span> <span class="ai-tool-status-dot" :class="'ai-tool-status-dot--' + (isFailed ? 'failed' : tc.status)" :title="statusTitle"></span>
@@ -31,7 +31,7 @@
hover 显绝对起止时刻,相对时间不再占卡片行(B 聚合卡统一)。 --> hover 显绝对起止时刻,相对时间不再占卡片行(B 聚合卡统一)。 -->
<span <span
v-if="durationText" v-if="durationText"
class="ai-tool-duration" class="ai-tool-duration df-mono"
:title="durationTitle" :title="durationTitle"
>{{ durationText }}</span> >{{ durationText }}</span>
<span v-if="!shouldKeepOpen(tc)" class="ai-tool-collapse-hint" :class="{ 'is-open': isExpanded }">▸</span> <span v-if="!shouldKeepOpen(tc)" class="ai-tool-collapse-hint" :class="{ 'is-open': isExpanded }">▸</span>
@@ -56,8 +56,9 @@
<!-- 审批:参数内容 + 风险提示 + 按钮 --> <!-- 审批:参数内容 + 风险提示 + 按钮 -->
<div v-if="tc.status === 'pending_approval'" class="ai-tool-approval"> <div v-if="tc.status === 'pending_approval'" class="ai-tool-approval">
<!-- 风险徽章:High 橙红/Medium 黄,一眼识别操作等级(与左侧着色条同色系) --> <!-- 风险徽章:High 橙红/Medium 黄,一眼识别操作等级(与左侧着色条同色系)
<div v-if="riskLevel !== 'low'" class="ai-tool-risk-badge" :class="'is-' + riskLevel"> wave2:迁 .df-badge 透明彩底三件套(danger/warning 变体) -->
<div v-if="riskLevel !== 'low'" class="ai-tool-risk-badge df-badge" :class="riskLevel === 'high' ? 'df-badge--danger' : 'df-badge--warning'">
{{ riskLevel === 'high' ? $t('aiTool.riskHigh') : $t('aiTool.riskMedium') }} {{ riskLevel === 'high' ? $t('aiTool.riskHigh') : $t('aiTool.riskMedium') }}
</div> </div>
<div v-if="visibleArgs.length" class="ai-tool-approval-args"> <div v-if="visibleArgs.length" class="ai-tool-approval-args">
@@ -81,7 +82,7 @@
</div> </div>
<!-- AE-2025-03: write_file 审批 diff 预览(红删绿增,行级)。旧文件不存在→tc.diff 为空,回退显上方 args content --> <!-- AE-2025-03: write_file 审批 diff 预览(红删绿增,行级)。旧文件不存在→tc.diff 为空,回退显上方 args content -->
<div v-if="tc.diff" class="ai-tool-approval-diff"> <div v-if="tc.diff" class="ai-tool-approval-diff">
<pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in diffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre> <pre class="df-diff-pre"><code><span v-for="(ln, idx) in diffLines" :key="idx" class="df-diff-line" :class="'df-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
</div> </div>
<!-- path_auth 审批链阶段3b:按 tc.kind 分两组按钮。 <!-- path_auth 审批链阶段3b:按 tc.kind 分两组按钮。
- kind='path'(路径授权挂起):once/session/always/deny 四选项,调 authorizeDir - kind='path'(路径授权挂起):once/session/always/deny 四选项,调 authorizeDir
@@ -415,7 +416,7 @@ const visibleArgs = computed(() => {
不做 body 高度动画(v-show display 切换 + flex gap 会留双倍间距致布局跳变,见 ToolCardList 注释)。 */ 不做 body 高度动画(v-show display 切换 + flex gap 会留双倍间距致布局跳变,见 ToolCardList 注释)。 */
transition: border-color 0.2s var(--df-ease), box-shadow 0.2s var(--df-ease), opacity 0.2s var(--df-ease); transition: border-color 0.2s var(--df-ease), box-shadow 0.2s var(--df-ease), opacity 0.2s var(--df-ease);
} }
.ai-tool-card--pending_approval { border-color: rgba(240,199,94,0.3); box-shadow: 0 0 0 1px rgba(240,199,94,0.06); } .ai-tool-card--pending_approval { border-color: var(--df-warning-bd); box-shadow: 0 0 0 1px rgba(240,199,94,0.06); }
.ai-tool-card--completed { border-color: rgba(61,219,160,0.2); } .ai-tool-card--completed { border-color: rgba(61,219,160,0.2); }
.ai-tool-card--rejected { border-color: rgba(240,101,101,0.2); opacity: 0.55; } .ai-tool-card--rejected { border-color: rgba(240,101,101,0.2); opacity: 0.55; }
/* 工具失败态——AR-6 下 status 仍为 completed,故单独覆盖边框与 completed 区分 */ /* 工具失败态——AR-6 下 status 仍为 completed,故单独覆盖边框与 completed 区分 */
@@ -424,13 +425,17 @@ const visibleArgs = computed(() => {
.ai-tool-sub--failed { color: var(--df-danger); } .ai-tool-sub--failed { color: var(--df-danger); }
.ai-tool-rejected { .ai-tool-rejected {
display: flex; align-items: center; gap: 6px; display: flex; align-items: center; gap: 6px;
padding: 4px 10px 8px 42px; font-size: 11px; color: var(--df-danger); padding: 4px 10px 8px var(--df-icon-gutter); font-size: 11px; color: var(--df-danger);
} }
.ai-tool-rejected-icon { font-weight: 500; } .ai-tool-rejected-icon { font-weight: 500; }
/* wave2:折叠轨迹卡的展开态左边 2px 强调边(对齐 workbench cv.on 范式);
只作用于可折叠卡(completed),pending 的 data-risk 左缘与 running 强制展开态不参与 */
.ai-tool-card--completed:not(.ai-tool-card--collapsed) { border-left: 2px solid var(--df-info); }
/* -- 卡片折叠态(running/pending 始终展开,completed/rejected 默认折叠为单行 header) -- */ /* -- 卡片折叠态(running/pending 始终展开,completed/rejected 默认折叠为单行 header) -- */
.ai-tool-card--collapsed .ai-tool-header { cursor: pointer; } .ai-tool-card--collapsed .ai-tool-header { cursor: pointer; }
.ai-tool-card--collapsed .ai-tool-header:hover { background: rgba(255,255,255,0.03); } .ai-tool-card--collapsed .ai-tool-header:hover { background: var(--df-bg-card-hover); }
.ai-tool-body { overflow: hidden; } .ai-tool-body { overflow: hidden; }
.ai-tool-collapse-hint { .ai-tool-collapse-hint {
font-size: 10px; color: var(--df-text-dim); margin-left: auto; flex-shrink: 0; font-size: 10px; color: var(--df-text-dim); margin-left: auto; flex-shrink: 0;
@@ -444,9 +449,9 @@ const visibleArgs = computed(() => {
width: 24px; height: 24px; border-radius: var(--df-radius-sm); width: 24px; height: 24px; border-radius: var(--df-radius-sm);
display: flex; align-items: center; justify-content: center; flex-shrink: 0; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
} }
.ai-tool-icon-wrap--file-read { background: rgba(94,175,240,0.12); color: var(--df-info); } .ai-tool-icon-wrap--file-read { background: var(--df-info-bg); color: var(--df-info); }
.ai-tool-icon-wrap--file-write { background: var(--df-warning-bg); color: var(--df-warning); } .ai-tool-icon-wrap--file-write { background: var(--df-warning-bg); color: var(--df-warning); }
.ai-tool-icon-wrap--dir { background: rgba(240,199,94,0.12); color: var(--df-warning); } .ai-tool-icon-wrap--dir { background: var(--df-warning-bg); color: var(--df-warning); }
.ai-tool-icon-wrap--create { background: var(--df-success-bg); color: var(--df-success); } .ai-tool-icon-wrap--create { background: var(--df-success-bg); color: var(--df-success); }
.ai-tool-icon-wrap--delete { background: var(--df-danger-bg); color: var(--df-danger); } .ai-tool-icon-wrap--delete { background: var(--df-danger-bg); color: var(--df-danger); }
.ai-tool-icon-wrap--list { background: var(--df-accent-bg); color: var(--df-accent); } .ai-tool-icon-wrap--list { background: var(--df-accent-bg); color: var(--df-accent); }
@@ -455,14 +460,40 @@ const visibleArgs = computed(() => {
display: flex; flex-direction: row; flex-wrap: wrap; align-items: baseline; display: flex; flex-direction: row; flex-wrap: wrap; align-items: baseline;
column-gap: 8px; row-gap: 1px; flex: 1; min-width: 0; column-gap: 8px; row-gap: 1px; flex: 1; min-width: 0;
} }
/* wave2:工具名迁 mono 元数据(df-mono 接管字体,此处 10px dim 收敛视觉) */
.ai-tool-name { .ai-tool-name {
font-size: 12px; font-weight: 500; color: var(--df-text); font-size: 10px; font-weight: 500; color: var(--df-text-dim);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 0;
} }
.ai-tool-sub { .ai-tool-sub {
font-size: 10px; color: var(--df-text-dim); font-size: 10px; color: var(--df-text-dim);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
} }
/* wave2:状态 chip 迁 .df-badge(running=info+脉冲点 / failed=danger);
scoped 双类压过 .ai-tool-sub 灰字,色板与 df-badge 变体对齐(token 化) */
.ai-tool-sub.df-badge {
font-size: 10px;
padding: 1px 6px;
line-height: 16px;
}
.ai-tool-sub.df-badge--info {
background: var(--df-info-bg);
color: var(--df-info);
border-color: color-mix(in srgb, var(--df-info) 30%, transparent);
}
.ai-tool-sub.df-badge--danger {
background: var(--df-danger-bg);
color: var(--df-danger);
border-color: color-mix(in srgb, var(--df-danger) 30%, transparent);
}
/* running chip 内嵌脉冲点(全局 df-pulse 节奏,对齐 df-dot--run 活性语义) */
.ai-tool-chip-run::before {
content: '';
width: 5px; height: 5px; border-radius: 50%;
background: var(--df-info);
flex-shrink: 0;
animation: df-pulse 1.5s infinite;
}
/* -- Status dot (脉冲动画) -- */ /* -- Status dot (脉冲动画) -- */
.ai-tool-status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; } .ai-tool-status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
@@ -487,16 +518,16 @@ const visibleArgs = computed(() => {
.ai-tool-status-dot--rejected { background: var(--df-danger); } .ai-tool-status-dot--rejected { background: var(--df-danger); }
/* -- Skeleton (running 状态) -- */ /* -- Skeleton (running 状态) -- */
.ai-tool-skeleton { padding: 4px 10px 10px 42px; display: flex; flex-direction: column; gap: 5px; } .ai-tool-skeleton { padding: 4px 10px 10px var(--df-icon-gutter); display: flex; flex-direction: column; gap: 5px; }
.ai-tool-skeleton-line { .ai-tool-skeleton-line {
height: 8px; border-radius: var(--df-radius-xs); height: 8px; border-radius: var(--df-radius-xs);
background: linear-gradient(90deg, var(--df-bg) 25%, var(--df-bg-card-hover) 50%, var(--df-bg) 75%); background: linear-gradient(90deg, var(--df-bg) 25%, var(--df-bg-card-hover) 50%, var(--df-bg) 75%);
background-size: 200% 100%; animation: shimmer 1.5s infinite; background-size: 200% 100%; animation: df-shimmer 1.5s infinite;
} }
/* -- 实时流式输出(running 态:run_command 行 / ask_uclaw 进度块) -- */ /* -- 实时流式输出(running 态:run_command 行 / ask_uclaw 进度块) -- */
.ai-tool-stream { .ai-tool-stream {
padding: 4px 10px 10px 42px; padding: 4px 10px 10px var(--df-icon-gutter);
display: flex; flex-direction: column; gap: 2px; display: flex; flex-direction: column; gap: 2px;
max-height: 160px; overflow-y: auto; max-height: 160px; overflow-y: auto;
} }
@@ -507,53 +538,47 @@ const visibleArgs = computed(() => {
white-space: pre-wrap; word-break: break-all; white-space: pre-wrap; word-break: break-all;
} }
.ai-tool-stream-more { .ai-tool-stream-more {
font-size: 10px; color: var(--df-text-tertiary); font-size: 10px; color: var(--df-text-dim);
padding-top: 2px; font-style: italic; padding-top: 2px; font-style: italic;
} }
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
/* -- 审批参数展示 -- */ /* -- 审批参数展示 -- */
.ai-tool-approval-args { .ai-tool-approval-args {
padding: 4px 10px 6px; margin-bottom: 6px; padding: 4px 10px 6px; margin-bottom: 6px;
border-top: 0.5px solid var(--df-border); border-bottom: 0.5px solid var(--df-border); border-top: 0.5px solid var(--df-border); border-bottom: 0.5px solid var(--df-border);
background: var(--df-bg-elevated, rgba(0, 0, 0, 0.03)); background: var(--df-bg);
} }
.ai-tool-arg { display: flex; gap: 8px; padding: 2px 0; font-size: 11px; line-height: 1.5; } .ai-tool-arg { display: flex; gap: 8px; padding: 2px 0; font-size: 11px; line-height: 1.5; }
.ai-tool-arg-key { flex-shrink: 0; min-width: 64px; color: var(--df-text-secondary); font-family: var(--df-font-mono); } .ai-tool-arg-key { flex-shrink: 0; min-width: 64px; color: var(--df-text-secondary); font-family: var(--df-font-mono); }
.ai-tool-arg-val { flex: 1; word-break: break-all; white-space: pre-wrap; font-family: var(--df-font-mono); color: var(--df-text-primary); } .ai-tool-arg-val { flex: 1; word-break: break-all; white-space: pre-wrap; font-family: var(--df-font-mono); color: var(--df-text); }
.ai-tool-approval-reason { .ai-tool-approval-reason {
padding: 4px 10px 6px; margin-bottom: 6px; font-size: 11px; padding: 4px 10px 6px; margin-bottom: 6px; font-size: 11px;
color: var(--df-warning, #d97706); background: var(--df-warning-bg, rgba(217, 119, 6, 0.08)); color: var(--df-warning); background: var(--df-warning-bg);
border-radius: var(--df-radius-sm, 4px); border-radius: var(--df-radius-sm);
} }
/* 审批挂起时长提示(>2分钟橙色,>5分钟红色) */ /* 审批挂起时长提示(>2分钟橙色,>5分钟红色) */
.ai-tool-wait-time { .ai-tool-wait-time {
padding: 3px 10px; margin-bottom: 6px; font-size: 11px; padding: 3px 10px; margin-bottom: 6px; font-size: 11px;
font-family: var(--df-font-mono); font-family: var(--df-font-mono);
color: var(--df-text-dim, #888); color: var(--df-text-dim);
border-radius: var(--df-radius-sm, 4px); border-radius: var(--df-radius-sm);
} }
.ai-tool-wait-time.is-long { .ai-tool-wait-time.is-long {
color: var(--df-warning, #d97706); color: var(--df-warning);
animation: pulseGlow 1.5s ease-in-out infinite; animation: pulseGlow 1.5s ease-in-out infinite;
} }
.ai-tool-wait-time.is-urgent { .ai-tool-wait-time.is-urgent {
color: var(--df-danger, #e5484d); color: var(--df-danger);
animation: pulseGlow 1s ease-in-out infinite; animation: pulseGlow 1s ease-in-out infinite;
} }
/* write_file 审批 diff 预览区(红删绿增行级) */ /* write_file 审批 diff 预览区(红删绿增行级);
基类 .df-diff-pre(+code)/.df-diff-line 消费 global.css 共享定义,此处仅留行配色变体 */
.ai-tool-approval-diff { margin: 0 10px 8px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm, 4px); overflow: hidden; } .ai-tool-approval-diff { margin: 0 10px 8px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm, 4px); overflow: hidden; }
.ai-tool-diff-pre { .df-diff-line--add { color: var(--df-success); background: var(--df-success-bg); }
margin: 0; padding: 6px 0; max-height: 240px; overflow: auto; .df-diff-line--del { color: var(--df-danger); background: var(--df-danger-bg); }
font-family: var(--df-font-mono); font-size: 11px; line-height: 1.5; background: var(--df-bg-card); .df-diff-line--ctx { color: var(--df-text-dim); }
}
.ai-tool-diff-pre code { display: block; white-space: pre; }
.ai-tool-diff-line { display: block; padding: 0 10px; white-space: pre-wrap; word-break: break-all; }
.ai-tool-diff-line--add { color: var(--df-success, #3ddb9c); background: var(--df-success-bg, rgba(61, 219, 160, 0.08)); }
.ai-tool-diff-line--del { color: var(--df-danger, #f06565); background: var(--df-danger-bg, rgba(240, 101, 101, 0.08)); }
.ai-tool-diff-line--ctx { color: var(--df-text-dim, #888); }
/* -- 审批按钮 -- */ /* -- 审批按钮 -- */
.ai-tool-actions { display: flex; gap: 6px; padding: 0 10px 8px; } .ai-tool-actions { display: flex; gap: 6px; padding: 0 10px 8px; }
@@ -566,11 +591,11 @@ const visibleArgs = computed(() => {
.ai-tool-btn--approve:hover { background: var(--df-success); color: #fff; border-color: var(--df-success); } .ai-tool-btn--approve:hover { background: var(--df-success); color: #fff; border-color: var(--df-success); }
/* M4:session 按钮(accent 描边态,介于 approve 弱背景与 always 强 warning 之间;对齐 DirAuthDialog session 语义) */ /* M4:session 按钮(accent 描边态,介于 approve 弱背景与 always 强 warning 之间;对齐 DirAuthDialog session 语义) */
.ai-tool-btn--session { .ai-tool-btn--session {
background: var(--df-accent-bg, rgba(123, 111, 240, 0.12)); background: var(--df-accent-bg);
color: var(--df-accent, #7b6ff0); color: var(--df-accent);
border-color: color-mix(in srgb, var(--df-accent, #7b6ff0) 40%, transparent); border-color: color-mix(in srgb, var(--df-accent) 40%, transparent);
} }
.ai-tool-btn--session:hover { background: var(--df-accent, #7b6ff0); color: #fff; border-color: var(--df-accent, #7b6ff0); } .ai-tool-btn--session:hover { background: var(--df-accent); color: #fff; border-color: var(--df-accent); }
/* path_auth 审批链阶段3b:always 按钮(warning 色调,与 DirAuthDialog.vue 同款语义) */ /* path_auth 审批链阶段3b:always 按钮(warning 色调,与 DirAuthDialog.vue 同款语义) */
.ai-tool-btn--always { background: var(--df-warning-bg); color: var(--df-warning); } .ai-tool-btn--always { background: var(--df-warning-bg); color: var(--df-warning); }
.ai-tool-btn--always:hover { background: var(--df-warning); color: #000; border-color: var(--df-warning); } .ai-tool-btn--always:hover { background: var(--df-warning); color: #000; border-color: var(--df-warning); }
@@ -587,15 +612,14 @@ const visibleArgs = computed(() => {
.ai-tool-btn-icon :deep(svg) { display: block; } .ai-tool-btn-icon :deep(svg) { display: block; }
.ai-tool-btn-spinner { .ai-tool-btn-spinner {
width: 12px; height: 12px; border: 1.5px solid currentColor; border-top-color: transparent; width: 12px; height: 12px; border: 1.5px solid currentColor; border-top-color: transparent;
border-radius: 50%; display: inline-block; flex-shrink: 0; animation: aiToolBtnSpin 0.7s linear infinite; border-radius: 50%; display: inline-block; flex-shrink: 0; animation: df-spin 0.7s linear infinite;
} }
@keyframes aiToolBtnSpin { to { transform: rotate(360deg); } }
/* -- 审批 loading 超时兜底 toast(复用 AiChat 同款视觉 token) -- */ /* -- 审批 loading 超时兜底 toast(复用 AiChat 同款视觉 token) -- */
.ai-tool-toast { .ai-tool-toast {
position: absolute; top: 6px; left: 50%; transform: translateX(-50%); position: absolute; top: 6px; left: 50%; transform: translateX(-50%);
padding: 6px 12px; border-radius: var(--df-radius-sm); font-size: 11px; z-index: 100; padding: 6px 12px; border-radius: var(--df-radius-sm); font-size: 11px; z-index: 100;
box-shadow: 0 4px 16px rgba(0,0,0,0.3); max-width: 90%; overflow-wrap: anywhere; pointer-events: none; box-shadow: var(--df-shadow-sm); max-width: 90%; overflow-wrap: anywhere; pointer-events: none;
} }
.ai-tool-toast--warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); } .ai-tool-toast--warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
.ai-tool-toast-enter-active, .ai-tool-toast-leave-active { transition: opacity 0.2s, transform 0.2s; } .ai-tool-toast-enter-active, .ai-tool-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
@@ -605,15 +629,13 @@ const visibleArgs = computed(() => {
.ai-tool-card[data-risk='high'] { border-left: 2px solid var(--df-danger); } .ai-tool-card[data-risk='high'] { border-left: 2px solid var(--df-danger); }
.ai-tool-card[data-risk='medium'] { border-left: 2px solid var(--df-warning); } .ai-tool-card[data-risk='medium'] { border-left: 2px solid var(--df-warning); }
/* -- 风险徽章(审批区首行,High 橙红底/Medium 黄底,10px 大写字距) -- */ /* -- 风险徽章(审批区首行):wave2 迁 .df-badge 透明彩底(danger/warning 变体接管配色),
此处只留布局(独立成行+外边距) -- */
.ai-tool-risk-badge { .ai-tool-risk-badge {
display: inline-flex; align-items: center; align-self: flex-start; align-self: flex-start;
padding: 2px 8px; margin: 6px 10px 0; margin: 6px 10px 0;
font-size: 10px; font-weight: 600; letter-spacing: 0.5px; font-weight: 600; letter-spacing: 0.5px;
border-radius: var(--df-radius-sm);
} }
.ai-tool-risk-badge.is-high { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid color-mix(in srgb, var(--df-danger) 40%, transparent); }
.ai-tool-risk-badge.is-medium { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent); }
/* 审批区改纵向 flex(徽章需独立成行,原块级布局不变) */ /* 审批区改纵向 flex(徽章需独立成行,原块级布局不变) */
.ai-tool-approval { display: flex; flex-direction: column; } .ai-tool-approval { display: flex; flex-direction: column; }
+9 -18
View File
@@ -13,8 +13,8 @@
<button class="ai-btn ai-btn--sm ai-btn--danger" :disabled="batchProcessing" @click="$emit('batch-approve', 'reject')"> <button class="ai-btn ai-btn--sm ai-btn--danger" :disabled="batchProcessing" @click="$emit('batch-approve', 'reject')">
{{ $t('aiTool.rejectAll') }} {{ $t('aiTool.rejectAll') }}
</button> </button>
<!-- High 风险计数徽标:批量批准将二次确认的项数,提示操作后果 --> <!-- High 风险计数徽标:批量批准将二次确认的项数,提示操作后果(wave2 迁 .df-badge danger) -->
<span v-if="highRiskPendingCount > 0" class="ai-batch-high-risk-hint"> <span v-if="highRiskPendingCount > 0" class="ai-batch-high-risk-hint df-badge df-badge--danger">
{{ $t('aiTool.batchHighRiskHint', { n: highRiskPendingCount }) }} {{ $t('aiTool.batchHighRiskHint', { n: highRiskPendingCount }) }}
</span> </span>
</div> </div>
@@ -26,8 +26,8 @@
<div v-if="group.calls.length >= 2" class="ai-tool-group-header" @click="toggleGroupCollapse(gi)"> <div v-if="group.calls.length >= 2" class="ai-tool-group-header" @click="toggleGroupCollapse(gi)">
<span class="ai-tool-group-icon" v-html="groupIcon(group.name)"></span> <span class="ai-tool-group-icon" v-html="groupIcon(group.name)"></span>
<span class="ai-tool-group-name">{{ groupDisplayName(group) }}</span> <span class="ai-tool-group-name">{{ groupDisplayName(group) }}</span>
<!-- R7 组头失败徽标:折叠组内失败卡不展开也能从组头发现(红字计数) --> <!-- R7 组头失败徽标:折叠组内失败卡不展开也能从组头发现(wave2 迁 .df-badge danger) -->
<span v-if="groupFailCounts[gi] > 0" class="ai-tool-group-fail">{{ $t('aiTool.groupFailCount', { n: groupFailCounts[gi] }) }}</span> <span v-if="groupFailCounts[gi] > 0" class="ai-tool-group-fail df-badge df-badge--danger">{{ $t('aiTool.groupFailCount', { n: groupFailCounts[gi] }) }}</span>
<span class="ai-tool-group-chevron" :class="{ 'is-open': !isGroupCollapsed(gi) }">▸</span> <span class="ai-tool-group-chevron" :class="{ 'is-open': !isGroupCollapsed(gi) }">▸</span>
</div> </div>
<ToolCard <ToolCard
@@ -451,23 +451,17 @@ function groupIcon(name: string): string {
gap: 8px; gap: 8px;
} }
/* -- 批量操作栏 -- */ /* -- 批量操作栏(wave2:白透明底残留改 token,浅色主题成立) -- */
.ai-batch-approve { .ai-batch-approve {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 6px 10px; padding: 6px 10px;
border-radius: var(--df-radius-sm); border-radius: var(--df-radius-sm);
background: rgba(255,255,255,0.02); background: var(--df-bg-card);
} }
/* 批量批准将二次确认的 High 风险项数徽标(橙红警示,不抢按钮焦点) */ /* 批量批准将二次确认的 High 风险项数徽标:配色由 df-badge--danger 接管,此处只留布局 */
.ai-batch-high-risk-hint { .ai-batch-high-risk-hint {
font-size: 10px;
color: var(--df-danger);
background: var(--df-danger-bg);
border: 0.5px solid color-mix(in srgb, var(--df-danger) 40%, transparent);
border-radius: var(--df-radius-sm);
padding: 2px 8px;
white-space: nowrap; white-space: nowrap;
} }
/* .ai-btn 基类:批量审批按钮(历史重构遗漏定义,补全治本) */ /* .ai-btn 基类:批量审批按钮(历史重构遗漏定义,补全治本) */
@@ -512,16 +506,13 @@ function groupIcon(name: string): string {
color: var(--df-text-secondary); color: var(--df-text-secondary);
} }
.ai-tool-group-header:hover { .ai-tool-group-header:hover {
background: rgba(255,255,255,0.03); background: var(--df-bg-card-hover);
} }
/* R7 组头失败徽标:失败计数红字,margin-left auto 推到 chevron 之前右对齐 */ /* R7 组头失败徽标:配色由 df-badge--danger 接管,此处只留布局(右对齐推到 chevron 前) */
.ai-tool-group-fail { .ai-tool-group-fail {
margin-left: auto; margin-left: auto;
margin-right: 2px; margin-right: 2px;
font-size: 10px;
font-weight: 600; font-weight: 600;
color: var(--df-danger);
opacity: 0.9;
white-space: nowrap; white-space: nowrap;
} }
.ai-tool-group-icon { .ai-tool-group-icon {
+29 -47
View File
@@ -120,12 +120,12 @@
<span v-if="typeof parsed?.encoding === 'string' && parsed.encoding !== 'utf-8'" class="ai-tool-write-meta">{{ $t('aiTool.encodingLabel', { enc: parsed.encoding }) }}</span> <span v-if="typeof parsed?.encoding === 'string' && parsed.encoding !== 'utf-8'" class="ai-tool-write-meta">{{ $t('aiTool.encodingLabel', { enc: parsed.encoding }) }}</span>
<!-- R12(2026-10-05):覆盖写入的行级 diff 回看(后端 diff 字段,新建/base64 为 null 不渲染) --> <!-- R12(2026-10-05):覆盖写入的行级 diff 回看(后端 diff 字段,新建/base64 为 null 不渲染) -->
<div v-if="resultDiffLines.length" class="ai-tool-write-diff"> <div v-if="resultDiffLines.length" class="ai-tool-write-diff">
<pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in resultDiffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre> <pre class="df-diff-pre"><code><span v-for="(ln, idx) in resultDiffLines" :key="idx" class="df-diff-line" :class="'df-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
<button v-if="diffTotal > DIFF_PREVIEW_LINES" type="button" class="ai-tool-diff-toggle" @click="diffExpanded = !diffExpanded">{{ diffExpanded ? $t('aiTool.diffCollapse') : $t('aiTool.diffExpand', { n: diffTotal }) }}</button> <button v-if="diffTotal > DIFF_PREVIEW_LINES" type="button" class="ai-tool-diff-toggle" @click="diffExpanded = !diffExpanded">{{ diffExpanded ? $t('aiTool.diffCollapse') : $t('aiTool.diffExpand', { n: diffTotal }) }}</button>
</div> </div>
</div> </div>
<!-- patch_file 结果:文件路径/字节增减 + unified diff 红删绿增行级预览(复用 ai-tool-diff-pre/--add/--del/--ctx 既有样式) <!-- patch_file 结果:文件路径/字节增减 + unified diff 红删绿增行级预览(复用 df-diff-pre/--add/--del/--ctx 样式)
后端 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">
@@ -136,7 +136,7 @@
</div> </div>
<div v-if="parsed?.warning" class="ai-tool-approval-reason"><span aria-hidden="true">⚠</span> {{ parsed.warning }}</div> <div v-if="parsed?.warning" class="ai-tool-approval-reason"><span aria-hidden="true">⚠</span> {{ parsed.warning }}</div>
<div v-if="resultDiffLines.length" class="ai-tool-approval-diff"> <div v-if="resultDiffLines.length" class="ai-tool-approval-diff">
<pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in resultDiffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre> <pre class="df-diff-pre"><code><span v-for="(ln, idx) in resultDiffLines" :key="idx" class="df-diff-line" :class="'df-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
<!-- R12a:长 diff 展开入口(默认预览 120 行,点开全量) --> <!-- R12a:长 diff 展开入口(默认预览 120 行,点开全量) -->
<button v-if="diffTotal > DIFF_PREVIEW_LINES" type="button" class="ai-tool-diff-toggle" @click="diffExpanded = !diffExpanded">{{ diffExpanded ? $t('aiTool.diffCollapse') : $t('aiTool.diffExpand', { n: diffTotal }) }}</button> <button v-if="diffTotal > DIFF_PREVIEW_LINES" type="button" class="ai-tool-diff-toggle" @click="diffExpanded = !diffExpanded">{{ diffExpanded ? $t('aiTool.diffCollapse') : $t('aiTool.diffExpand', { n: diffTotal }) }}</button>
</div> </div>
@@ -468,7 +468,7 @@ watch(() => props.tc.status, (s) => {
} }
.ai-tool-grep-match { .ai-tool-grep-match {
padding: 3px 12px; padding: 3px 12px;
border-bottom: 0.5px solid rgba(255,255,255,0.03); border-bottom: 0.5px solid var(--df-hover);
} }
.ai-tool-grep-match:last-child { .ai-tool-grep-match:last-child {
border-bottom: none; border-bottom: none;
@@ -511,7 +511,7 @@ watch(() => props.tc.status, (s) => {
word-break: break-all; word-break: break-all;
background: rgba(61,219,160,0.05); background: rgba(61,219,160,0.05);
padding: 2px 6px; padding: 2px 6px;
border-radius: var(--df-radius-xs, 3px); border-radius: var(--df-radius-xs);
} }
.ai-tool-grep-context { .ai-tool-grep-context {
margin: 2px 0 0; margin: 2px 0 0;
@@ -521,8 +521,8 @@ watch(() => props.tc.status, (s) => {
font-family: var(--df-font-mono); font-family: var(--df-font-mono);
font-size: 10px; font-size: 10px;
color: var(--df-text-dim); color: var(--df-text-dim);
background: rgba(255,255,255,0.02); background: var(--df-hover);
border-radius: var(--df-radius-xs, 3px); border-radius: var(--df-radius-xs);
} }
.ai-tool-grep-context code { .ai-tool-grep-context code {
display: block; display: block;
@@ -533,7 +533,7 @@ watch(() => props.tc.status, (s) => {
font-family: var(--df-font-sans); font-family: var(--df-font-sans);
font-size: 10px; font-size: 10px;
color: var(--df-text-dim); color: var(--df-text-dim);
background: rgba(255,255,255,0.04); background: var(--df-hover);
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm); border-radius: var(--df-radius-sm);
padding: 2px 8px; padding: 2px 8px;
@@ -586,13 +586,13 @@ watch(() => props.tc.status, (s) => {
.ai-tool-dir-entry { .ai-tool-dir-entry {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 7px; gap: 6px;
padding: 3px 12px; padding: 3px 12px;
font-size: 11px; font-size: 11px;
transition: background 0.1s; transition: background 0.1s;
} }
.ai-tool-dir-entry:hover { .ai-tool-dir-entry:hover {
background: rgba(255,255,255,0.03); background: var(--df-hover);
} }
.ai-tool-dir-entry--directory .ai-tool-dir-name { .ai-tool-dir-entry--directory .ai-tool-dir-name {
font-weight: 500; font-weight: 500;
@@ -650,8 +650,8 @@ watch(() => props.tc.status, (s) => {
padding: 4px 10px 6px; padding: 4px 10px 6px;
margin-bottom: 6px; margin-bottom: 6px;
font-size: 11px; font-size: 11px;
color: var(--df-warning, #d97706); color: var(--df-warning);
background: var(--df-warning-bg, rgba(217, 119, 6, 0.08)); background: var(--df-warning-bg);
border-radius: var(--df-radius-sm, 4px); border-radius: var(--df-radius-sm, 4px);
} }
.ai-tool-approval-diff { .ai-tool-approval-diff {
@@ -660,29 +660,11 @@ watch(() => props.tc.status, (s) => {
border-radius: var(--df-radius-sm, 4px); border-radius: var(--df-radius-sm, 4px);
overflow: hidden; overflow: hidden;
} }
.ai-tool-diff-pre { /* diff 基类 .df-diff-pre(+code)/.df-diff-line 消费 global.css 共享定义(2026-10-18 整洁波
margin: 0; 双轨合一,与 ToolCard.vue 审批 diff 同源),此处仅留行配色变体 */
padding: 6px 0; .df-diff-line--add { color: var(--df-success); background: var(--df-success-bg); }
max-height: 240px; .df-diff-line--del { color: var(--df-danger); background: var(--df-danger-bg); }
overflow: auto; .df-diff-line--ctx { color: var(--df-text-dim); }
font-family: var(--df-font-mono);
font-size: 11px;
line-height: 1.5;
background: var(--df-bg-card);
}
.ai-tool-diff-pre code {
display: block;
white-space: pre;
}
.ai-tool-diff-line {
display: block;
padding: 0 10px;
white-space: pre-wrap;
word-break: break-all;
}
.ai-tool-diff-line--add { color: var(--df-success, #3ddb9c); background: var(--df-success-bg, rgba(61, 219, 160, 0.08)); }
.ai-tool-diff-line--del { color: var(--df-danger, #f06565); background: var(--df-danger-bg, rgba(240, 101, 101, 0.08)); }
.ai-tool-diff-line--ctx { color: var(--df-text-dim, #888); }
/* -- Generic Result -- /* -- Generic Result --
max-height 100px→200px:通用结果常含 JSON/结构化文本,100px 仅约 5 行,默认滚动条挤压可读性; max-height 100px→200px:通用结果常含 JSON/结构化文本,100px 仅约 5 行,默认滚动条挤压可读性;
@@ -741,12 +723,12 @@ watch(() => props.tc.status, (s) => {
border-radius: var(--df-radius-sm); border-radius: var(--df-radius-sm);
background: transparent; background: transparent;
color: var(--df-text-dim); color: var(--df-text-dim);
font-size: 10.5px; font-size: 10px;
cursor: pointer; cursor: pointer;
} }
.ai-tool-cmd-toggle:hover { .ai-tool-cmd-toggle:hover {
color: var(--df-text); color: var(--df-text);
background: var(--df-bg-hover, rgba(255,255,255,0.06)); background: var(--df-hover);
} }
.ai-tool-cmd-output { .ai-tool-cmd-output {
margin: 0; margin: 0;
@@ -803,8 +785,8 @@ watch(() => props.tc.status, (s) => {
} }
.ai-tool-http-trunc-warn { .ai-tool-http-trunc-warn {
padding: 4px 10px; padding: 4px 10px;
font-size: 10.5px; font-size: 10px;
color: var(--df-warning, #f0c75e); color: var(--df-warning);
border-top: 0.5px solid var(--df-border); border-top: 0.5px solid var(--df-border);
} }
@@ -828,10 +810,10 @@ watch(() => props.tc.status, (s) => {
} }
.ai-tool-fetch-mode--static, .ai-tool-fetch-mode--static,
.ai-tool-fetch-mode--obscura { .ai-tool-fetch-mode--obscura {
color: var(--df-success, #3ddb9c); color: var(--df-success);
} }
.ai-tool-fetch-mode--static_fallback { .ai-tool-fetch-mode--static_fallback {
color: var(--df-warning, #f0c75e); color: var(--df-warning);
} }
.ai-tool-fetch-title { .ai-tool-fetch-title {
padding: 4px 10px 2px; padding: 4px 10px 2px;
@@ -845,8 +827,8 @@ watch(() => props.tc.status, (s) => {
.ai-tool-obscura-hint { .ai-tool-obscura-hint {
margin: 4px 10px 6px; margin: 4px 10px 6px;
padding: 6px 8px; padding: 6px 8px;
background: var(--df-warning-bg, rgba(240,199,94,0.08)); background: var(--df-warning-bg);
border: 0.5px solid var(--df-warning, #f0c75e); border: 0.5px solid var(--df-warning);
border-radius: var(--df-radius-sm, 4px); border-radius: var(--df-radius-sm, 4px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -854,8 +836,8 @@ watch(() => props.tc.status, (s) => {
} }
.ai-tool-obscura-hint-text { .ai-tool-obscura-hint-text {
font-family: var(--df-font-sans); font-family: var(--df-font-sans);
font-size: 10.5px; font-size: 10px;
color: var(--df-warning, #f0c75e); color: var(--df-warning);
line-height: 1.5; line-height: 1.5;
word-break: break-word; word-break: break-word;
} }
@@ -870,7 +852,7 @@ watch(() => props.tc.status, (s) => {
font-weight: 500; font-weight: 500;
border-radius: var(--df-radius-sm, 4px); border-radius: var(--df-radius-sm, 4px);
border: none; border: none;
background: var(--df-accent, #5b8def); background: var(--df-accent);
color: #fff; color: #fff;
cursor: pointer; cursor: pointer;
font-family: var(--df-font-sans); font-family: var(--df-font-sans);
@@ -891,5 +873,5 @@ watch(() => props.tc.status, (s) => {
padding: 3px 12px; padding: 3px 12px;
cursor: pointer; cursor: pointer;
} }
.ai-tool-diff-toggle:hover { color: var(--df-text); background: var(--df-bg-secondary); } .ai-tool-diff-toggle:hover { color: var(--df-text); background: var(--df-bg-card); }
</style> </style>
@@ -0,0 +1,72 @@
<template>
<!-- AE-2025-07: Agentic 循环进度条(C 状态语义化)
仅 agentic 多轮(streaming 且收到过 AiAgentRound round>0 事件)显示;
单轮问答 agentRound 恒 0,不显示(避免误显)。
文案:第 {round} 轮 · 累计 {total} 次调用 · 进行中/待审批;
total 由前端聚合当前轮次 toolCalls(useTurnTrace),完成后进度条隐藏,
完成聚合语义由 MessageList 的执行轨迹卡承接。
降级省略:max(总轮次)后端 AiAgentRound 未透传 max_rounds,不显。
C4b 拆分自 AiChat.vue(纯展示,store 单例直读,零行为变更)。 -->
<div v-if="showAgenticProgress" class="ai-agentic-progress">
<span class="ai-agentic-progress-dot"></span>
<span class="ai-agentic-progress-text">{{ agenticProgressText }}</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { collectTurnStats, turnMessages } from '../../composables/ai/useTurnTrace'
const store = useAiStore()
const { t } = useI18n()
// 仅 agentic 多轮(streaming 且 agentRound>0)显示;单轮问答不收 AiAgentRound 故恒隐藏。
const showAgenticProgress = computed(() =>
store.state.streaming && store.state.agentRound > 0,
)
// 进度文案(C 状态语义化):「第 N 轮 · 累计 M 次调用 · 进行中/待审批」。
// 累计调用数由前端聚合当前轮次消息流上的 toolCalls(后端事件无累计字段,不改协议);
// 替代旧「循环 N · X个工具完成」——旧 completedTools 每轮清零,轮初叙述期恒显 0 造成歧义。
// 「已完成」态不出现在状态栏:完成后进度条隐藏(既有设计),完成聚合由执行轨迹卡承接。
const agenticProgressText = computed(() => {
const pending = store.state.pendingApprovals.length
const total = collectTurnStats(turnMessages(store.state.messages)).total
return pending > 0
? t('ai.agenticProgress', { round: store.state.agentRound, total, pending })
: t('ai.agenticProgressNoPending', { round: store.state.agentRound, total })
})
</script>
<style scoped>
/* ═══ Agentic 循环进度条(样式随区块自 AiChat.vue 迁入,零视觉变更) ═══ */
.ai-agentic-progress {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
padding: 3px 8px;
background: color-mix(in srgb, var(--df-accent) 8%, transparent);
border-radius: var(--df-radius);
font-size: 11px;
color: var(--df-text-dim);
user-select: none;
}
.ai-agentic-progress-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--df-accent);
flex-shrink: 0;
animation: ai-agentic-pulse 1.2s ease-in-out infinite;
}
.ai-agentic-progress-text {
font-family: var(--df-font-mono);
white-space: nowrap;
}
@keyframes ai-agentic-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
</style>
+288
View File
@@ -0,0 +1,288 @@
<template>
<!-- ═══ 待发送队列(生成中排队的消息,完成后自动续发;仅显示当前活跃会话的条目)
C4b 拆分自 AiChat.vue 队列区(模板 + L2 发送韧性 + V7 排队时钟 + 队列编辑/回填,零行为变更;
store 单例共享,confirm 自持一份 useConfirm + ConfirmDialog——同 ChatInput/ConversationSidebar
模式:useConfirm 非单例,子组件发起的 confirm 须本组件渲染) ═══ -->
<div v-if="activeQueue.length > 0" class="ai-queue" :class="{ 'ai-queue--timeout': queueTimedOut }">
<div class="ai-queue-head">
<span class="ai-queue-title">{{ $t('aiChat.queueTitle', { n: activeQueue.length }) }}</span>
<div class="ai-queue-actions">
<button v-if="queueTimedOut" class="ai-queue-force" @click="handleForceSend">{{ $t('ai.forceSendBtn') }}</button>
<button class="ai-queue-clear" @click="handleClearQueue">{{ $t('aiChat.clearQueue') }}</button>
</div>
</div>
<div class="ai-queue-list">
<div v-for="(item, idx) in activeQueue" :key="idx" class="ai-queue-item">
<!-- 编辑态只编 text:inline input,回车/失焦写回,ESC 取消 -->
<template v-if="editingQueueIdx === idx">
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
<input
v-model="editingQueueText"
class="ai-queue-edit-input"
type="text"
@keydown="onQueueEditKeydown"
@blur="saveEditQueued"
/>
<button class="ai-queue-btn" @mousedown.prevent="saveEditQueued" :title="$t('aiChat.queueSave')" :aria-label="$t('aiChat.queueSave')">✓</button>
<button class="ai-queue-btn" @mousedown.prevent="cancelEditQueued" :title="$t('aiChat.queueCancel')" :aria-label="$t('aiChat.queueCancel')">✕</button>
</template>
<template v-else>
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
<span class="ai-queue-text" :title="item.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="handleSendQueuedNow(idx)" :title="$t('aiChat.queueSendNow')">{{ $t('aiChat.queueSendNow') }}</button>
<button class="ai-queue-x" @click="store.cancelQueued(idx)" :title="$t('common.cancel')" :aria-label="$t('common.cancel')">×</button>
</template>
</div>
</div>
</div>
<!-- 确认弹层(强制发送/清空/回填覆盖二次确认;同 ChatInput 自持模式) -->
<ConfirmDialog
:visible="confirmState.visible"
:msg="confirmState.msg"
:danger-label="confirmState.dangerLabel"
@result="answerConfirm"
/>
</template>
<script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import ConfirmDialog from '../ConfirmDialog.vue'
import { useConfirm } from '../../composables/useConfirm'
import type { SkillInfo } from '../../api/types'
// 回填链路跨组件:输入框 ref(chatInputRef)在父,本组件经 props 读草稿态(hasUnsentInput)、
// 经 emit 写草稿(fill-draft),由父转发 ChatInput——共享函数留主文件 props/emits 传(拆分纪律)。
const props = defineProps<{
/** 父侧 ChatInput.hasUnsentInput 转发(回填覆盖未发送草稿前的 confirm 守卫) */
hasUnsentInput: () => boolean
}>()
const emit = defineEmits<{
/** 队列项回填:父转发 ChatInput.fillDraft(text, skill) 写入输入框草稿 */
(e: 'fill-draft', text: string, skill: SkillInfo | null): void
}>()
const store = useAiStore()
const { t } = useI18n()
// ── 确认弹层(useConfirm 非单例,子组件自持一份 + 自渲染 ConfirmDialog,同 ChatInput 先例) ──
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
/** 当前活跃会话的待发送队列(按 activeConversationId 过滤)。
* 队列是全局数组,多会话并发可各自排队;旧实现渲染全部会话的 queue,切/新建后会显示
* 旧会话排队消息。此处仅显当前活跃会话条目,相关操作(编辑/删除/立即发送/清空/超时)均按此收敛。 */
const activeQueue = computed(() =>
store.state.queue.filter(q => q.conversationId === store.state.activeConversationId),
)
// ── V7 排队时钟:响应式 tick 驱动排队项已等待时长 ──
// 原 queueWaitSeconds 在 computed 内直接取 Date.now(),非响应式依赖:纯等待期(无 store 变更)
// 永不重算,秒数冻结;且只覆盖队首。改为有排队时起 1s interval 推进 nowTick,展示函数读
// nowTick 建立依赖实现每秒刷新;队列清空(含队首进入发送被移除)即关表,卸载时显式清理防泄漏。
const nowTick = ref(Date.now())
let queueTickTimer: ReturnType<typeof setInterval> | null = null
/** 队首排队是否已超 30s(仅看当前活跃会话队列)。
* UX-P2-A1:isQueueTimedOut 内取 Date.now() 非响应式,纯等待期(无 store 变更)本 computed
* 永不重算,超时横幅/强制发送按钮延迟到下一次无关 store 变更才出现。显式读 nowTick
* (V7 排队时钟,有排队时 1s interval 推进)建立每秒依赖,超时判定随 tick 每秒重算。 */
const queueTimedOut = computed(() => {
void nowTick.value
return store.isQueueTimedOut(store.state.activeConversationId)
})
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(作用于当前活跃会话) */
async function handleForceSend() {
await store.tryForceSend(confirmDialog, store.state.activeConversationId)
}
/** UI-4(看板 943)清空队列:二次确认后全清当前活跃会话排队消息。
* 一键误触会静默丢掉全部排队消息且不可恢复,复用 ConfirmDialog 先例
* (同 tryForceSend 的 confirmFn 注入形态),不引 window.confirm(仓风格)。 */
async function handleClearQueue() {
const convId = store.state.activeConversationId
if (!convId) return
const confirmed = await confirmDialog(t('aiChat.clearQueueConfirm'))
if (confirmed) store.clearQueue(convId)
}
// ── 队列消息编辑(只编 text)+ 立即发送(插队)──
/** 当前正在编辑的队列项 index;null=非编辑态 */
const editingQueueIdx = ref<number | null>(null)
/** 编辑态输入框文本(回车写回 / ESC 取消还原) */
const editingQueueText = ref('')
// UX-P3: 队列编辑态(editingQueueIdx/editingQueueText)绑定旧会话队列在 activeQueue
// 视图内的下标,切会话后 activeQueue 已换成新会话的集合,残留 idx/text 会错挂到
// 新会话同下标队列项上,切会话即重置(原随父 watch activeConversationId,拆分后本组件自管)。
watch(() => store.state.activeConversationId, () => {
if (editingQueueIdx.value !== null) cancelEditQueued()
})
/** 进入编辑态:记录 index + 快照当前 text(activeQueue 视图内下标) */
function startEditQueued(idx: number) {
const item = activeQueue.value[idx]
if (!item) return
editingQueueIdx.value = idx
editingQueueText.value = item.text
}
/** 取消编辑:还原非编辑态,不写回 */
function cancelEditQueued() {
editingQueueIdx.value = null
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 */
function saveEditQueued() {
if (editingQueueIdx.value === null) return
store.editQueued(editingQueueIdx.value, editingQueueText.value)
editingQueueIdx.value = null
editingQueueText.value = ''
}
/** 立即发送:委托 store.sendQueuedNow(移除+stop+sendMessage) */
async function handleSendQueuedNow(idx: number) {
await store.sendQueuedNow(idx)
}
/** I4 队列回填:取出该条填回输入框重新编辑(从队列移除,可改完再发)。
* cancelQueued 内部经 activeQueueRealIndex 做过滤视图下标映射,越界 no-op;
* 项携带技能时按名找回 SkillInfo 回挂 chip,找不到(技能已卸载)只回填文本,
* 与队列原地编辑只保 text 同一取舍。 */
async function backfillQueued(idx: number) {
const item = activeQueue.value[idx]
if (!item) return
// UX-P3: 输入框已有未发送草稿(文本/图片/技能 chip 任一非空)时回填会静默覆盖,
// 先 confirm 确认;取消则直接返回,队列项原样保留(尚未 cancelQueued)。
if (props.hasUnsentInput()) {
if (!await confirmDialog(t('aiChat.backfillOverwriteConfirm'))) return
}
const skill = item.skill
? store.state.skills.find(s => s.name === item.skill) ?? null
: null
store.cancelQueued(idx)
emit('fill-draft', item.text, skill)
}
onBeforeUnmount(() => {
// V7 排队时钟 interval 随组件卸载清理(watch 随卸载停但 interval 本体不自停,须显式清)
if (queueTickTimer !== null) { clearInterval(queueTickTimer); queueTickTimer = null }
})
</script>
<style scoped>
/* ═══ 待发送队列(样式随区块自 AiChat.vue 迁入,零视觉变更) ═══ */
.ai-queue {
margin-bottom: 6px;
padding: 6px 8px;
background: color-mix(in srgb, var(--df-accent) 8%, transparent);
border: 0.5px solid color-mix(in srgb, var(--df-accent) 30%, transparent);
border-radius: var(--df-radius);
}
.ai-queue--timeout {
background: color-mix(in srgb, var(--df-warning) 10%, transparent);
border-color: color-mix(in srgb, var(--df-warning) 40%, transparent);
}
.ai-queue-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.ai-queue-title { font-size: 11px; color: var(--df-text-dim); font-weight: 600; }
.ai-queue--timeout .ai-queue-title { color: var(--df-warning); }
.ai-queue-actions { display: flex; align-items: center; gap: 8px; }
.ai-queue-force {
background: var(--df-warning); border: none; color: #000; cursor: pointer;
font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: calc(var(--df-radius) - 2px);
}
.ai-queue-force:hover { filter: brightness(1.1); }
.ai-queue-clear { background: none; border: none; color: var(--df-text-dim); cursor: pointer; font-size: 11px; padding: 0; }
.ai-queue-clear:hover { color: var(--df-text); }
.ai-queue-list { display: flex; flex-direction: column; gap: 3px; }
.ai-queue-item {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 6px;
background: var(--df-bg-card);
border-radius: calc(var(--df-radius) - 2px);
font-size: 12px;
}
.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 {
color: var(--df-text);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.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); }
/* 队列项编辑/立即发送按钮 + 编辑态 input */
.ai-queue-btn {
background: none; border: none; color: var(--df-text-dim); cursor: pointer;
font-size: 11px; line-height: 1; padding: 0 4px; flex-shrink: 0;
}
.ai-queue-btn:hover { color: var(--df-accent); }
.ai-queue-edit-input {
flex: 1; min-width: 0; font-size: 12px; padding: 1px 4px;
background: var(--df-bg-card); color: var(--df-text);
border: 0.5px solid var(--df-accent);
border-radius: calc(var(--df-radius) - 2px);
outline: none;
}
/* 聚焦可见环:边框恒为 accent 聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
.ai-queue-edit-input:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
</style>
@@ -0,0 +1,117 @@
<template>
<!-- I12(2026-10-05):键盘快捷键速查。Teleport 到 body 防 .ai-panel overflow 裁剪;
主窗/分离窗各自实例独立渲染,入口在 TopBar ... 菜单(emit show-shortcuts)。
C4b 拆分自 AiChat.vue(visible 状态留父——onGlobalKeydown Esc 仲裁需读写,开/关经 props/emits) -->
<Teleport to="body">
<div v-if="visible" class="ai-shortcuts-mask" @click.self="emit('close')">
<div class="ai-shortcuts-panel" role="dialog" :aria-label="t('aiChat.shortcutsTitle')">
<div class="ai-shortcuts-head">
<span class="ai-shortcuts-title">{{ t('aiChat.shortcutsTitle') }}</span>
<button class="ai-shortcuts-close" :aria-label="t('aiChat.scCloseOverlay')" @click="emit('close')">✕</button>
</div>
<ul class="ai-shortcuts-list">
<li v-for="s in shortcuts" :key="s.key" class="ai-shortcuts-row">
<span class="ai-shortcuts-key">{{ s.key }}</span>
<span class="ai-shortcuts-act">{{ s.label }}</span>
</li>
</ul>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
defineProps<{
/** 面板显隐(状态留父:TopBar emit show-shortcuts 置 true,onGlobalKeydown Esc 分支仲裁) */
visible: boolean
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
const { t } = useI18n()
// 键位表 shortcuts 与父 AiChat onGlobalKeydown 同族——键区调整时两边同步,防速查与真实键位漂移。
// computed:随 locale 即时翻译(仅面板打开时消费,开销可忽略)。
const shortcuts = computed(() => [
{ key: 'Ctrl+N', label: t('aiChat.scNewConv') },
{ key: 'Ctrl+I', label: t('aiChat.scFocusInput') },
{ key: 'Ctrl+R', label: t('aiChat.scRegenerate') },
{ key: 'Ctrl+L', label: t('aiChat.scClearChat') },
{ key: 'Ctrl+K', label: t('aiChat.scSearch') },
{ key: 'Ctrl+B', label: t('aiChat.scToggleSidebar') },
{ key: 'Esc', label: t('aiChat.scCloseOverlay') },
])
</script>
<style scoped>
/* ═══ I12 快捷键速查面板(Teleport 到 body,scoped 对 Teleport 内容仍生效——
Vue scoped 把 data 属性打在编译期节点上,与挂载位置无关;样式随区块自 AiChat.vue 迁入) ═══ */
.ai-shortcuts-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.ai-shortcuts-panel {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg, 8px);
box-shadow: var(--df-shadow-sm);
width: 320px;
max-width: calc(100vw - 48px);
padding: 14px 16px;
}
.ai-shortcuts-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.ai-shortcuts-title {
font-size: 13px;
font-weight: 600;
color: var(--df-text);
}
.ai-shortcuts-close {
background: transparent;
border: none;
color: var(--df-text-secondary);
cursor: pointer;
font-size: 12px;
padding: 2px 4px;
border-radius: 4px;
}
.ai-shortcuts-close:hover { color: var(--df-text); background: var(--df-bg-card); }
.ai-shortcuts-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.ai-shortcuts-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
}
.ai-shortcuts-key {
font-family: var(--df-font-mono);
font-size: 11px;
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: 4px;
padding: 2px 8px;
color: var(--df-text);
}
.ai-shortcuts-act { color: var(--df-text-secondary); }
</style>
+18 -16
View File
@@ -108,7 +108,9 @@ function onDismiss() {
</script> </script>
<style scoped> <style scoped>
/* 浮层 — 右上角 fixed,z-index 高于 AI 面板与其他模态 */ /* 浮层 — 右上角 fixed,z-index 高于 AI 面板与其他模态
wave2(2026-10-18):danger 系透明彩底范式(待审批=需用户处置的阻断信号)——
弹层底 bg-card+边框+10px 圆角,边框 color-mix 30%+左缘 3px 强调,标题同色 12px/600 */
.approval-overlay { .approval-overlay {
position: fixed; position: fixed;
top: 16px; top: 16px;
@@ -121,10 +123,10 @@ function onDismiss() {
min-width: 260px; min-width: 260px;
max-width: 360px; max-width: 360px;
background: var(--df-bg-card); background: var(--df-bg-card);
border: 0.5px solid var(--df-accent); border: 0.5px solid color-mix(in srgb, var(--df-danger) 30%, transparent);
border-left: 3px solid var(--df-accent); border-left: 3px solid var(--df-danger);
border-radius: var(--df-radius-md); border-radius: var(--df-radius-lg);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px var(--df-accent-soft); box-shadow: var(--df-shadow-md), 0 0 0 1px color-mix(in srgb, var(--df-danger) 12%, transparent);
cursor: pointer; cursor: pointer;
transition: transform 0.18s var(--df-ease), border-color 0.18s var(--df-ease), box-shadow 0.18s var(--df-ease); transition: transform 0.18s var(--df-ease), border-color 0.18s var(--df-ease), box-shadow 0.18s var(--df-ease);
user-select: none; user-select: none;
@@ -132,8 +134,8 @@ function onDismiss() {
.approval-overlay:hover { .approval-overlay:hover {
transform: translateX(-2px); transform: translateX(-2px);
border-color: var(--df-accent); border-color: color-mix(in srgb, var(--df-danger) 55%, transparent);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.5), 0 0 0 2px var(--df-accent); box-shadow: var(--df-shadow-md), 0 0 0 2px color-mix(in srgb, var(--df-danger) 45%, transparent);
} }
.approval-overlay-icon { .approval-overlay-icon {
@@ -144,8 +146,8 @@ function onDismiss() {
width: 36px; width: 36px;
height: 36px; height: 36px;
border-radius: 50%; border-radius: 50%;
background: var(--df-accent-bg); background: color-mix(in srgb, var(--df-danger) 10%, transparent);
color: var(--df-accent); color: var(--df-danger);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -172,15 +174,15 @@ function onDismiss() {
} }
.approval-overlay-title { .approval-overlay-title {
font-size: 13px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: var(--df-text); color: var(--df-danger);
margin-bottom: 2px; margin-bottom: 2px;
} }
.approval-overlay-desc { .approval-overlay-desc {
font-size: 11px; font-size: 12px;
color: var(--df-text-dim); color: var(--df-text-secondary);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -190,8 +192,8 @@ function onDismiss() {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 22px; width: var(--df-h-icon-btn);
height: 22px; height: var(--df-h-icon-btn);
border: none; border: none;
border-radius: var(--df-radius-sm); border-radius: var(--df-radius-sm);
background: transparent; background: transparent;
@@ -202,7 +204,7 @@ function onDismiss() {
} }
.approval-overlay-close:hover { .approval-overlay-close:hover {
background: var(--df-sidebar-hover); background: var(--df-bg-card-hover);
color: var(--df-text); color: var(--df-text);
} }
+1 -1
View File
@@ -85,7 +85,7 @@ onMounted(async () => {
border-radius: 8px; border-radius: 8px;
padding: 8px 12px; padding: 8px 12px;
margin: 8px 0; margin: 8px 0;
background: var(--df-bg-secondary); background: var(--df-bg-card);
font-size: 12px; font-size: 12px;
} }
.ci-header { .ci-header {
+170 -33
View File
@@ -45,6 +45,9 @@
@select="selectMention" @select="selectMention"
@update:mention-index="mentionIndex = $event" @update:mention-index="mentionIndex = $event"
/> />
<!-- 2026-10-18 wave2: 工具行(workbench .inp-row 范式)——textarea 下方分隔线,
左侧功能钮 + 右侧发送/停止钮;纯布局重排,按钮类名/事件/禁用判定零变化 -->
<div class="ai-input-row">
<!-- 2026-08-06:📎 图片选择按钮(tauri 原生对话框选图 → 已配置上传端点则上传换 URL,否则本地 base64 引用) --> <!-- 2026-08-06:📎 图片选择按钮(tauri 原生对话框选图 → 已配置上传端点则上传换 URL,否则本地 base64 引用) -->
<button <button
type="button" type="button"
@@ -115,12 +118,14 @@
class="ai-send-btn" class="ai-send-btn"
:class="{ 'ai-send-btn--active': canSend }" :class="{ 'ai-send-btn--active': canSend }"
:disabled="!canSend" :disabled="!canSend"
:aria-label="$t('aiChat.queueSendNow')" :title="$t('aiChat.send')"
:aria-label="$t('aiChat.send')"
@click="handleSend" @click="handleSend"
> >
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg> <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button> </button>
</div> </div>
</div>
<!-- ⑥.4 Phase 4: @项目展开摘要(提取至 EnrichmentPanel.vue) --> <!-- ⑥.4 Phase 4: @项目展开摘要(提取至 EnrichmentPanel.vue) -->
<EnrichmentPanel <EnrichmentPanel
:has-enrichment="hasEnrichment" :has-enrichment="hasEnrichment"
@@ -132,6 +137,12 @@
/> />
<!-- 定时/待发箱抽屉(⏰ 入口;Teleport 到 body 渲染,此处仅挂组件) --> <!-- 定时/待发箱抽屉(⏰ 入口;Teleport 到 body 渲染,此处仅挂组件) -->
<OutboxDrawer :open="outboxOpen" @close="outboxOpen = false" /> <OutboxDrawer :open="outboxOpen" @close="outboxOpen = false" />
<!-- F2-4(走查-信息UX 面二):示例卡覆盖未发送草稿的二次确认弹层(组件自持,见 setup 注释) -->
<ConfirmDialog
:visible="confirmState.visible"
:msg="confirmState.msg"
@result="answerConfirm"
/>
<div class="ai-input-hint">{{ $t('aiChat.inputHint') }}</div> <div class="ai-input-hint">{{ $t('aiChat.inputHint') }}</div>
</div> </div>
</template> </template>
@@ -145,7 +156,7 @@
// - 出错 emit('error', { msg, type }) → 父 showToast(单一 toast 源) // - 出错 emit('error', { msg, type }) → 父 showToast(单一 toast 源)
// - 示例问题卡片在父(空状态区),父调 expose sendExamplePrompt 触发本组件发送 // - 示例问题卡片在父(空状态区),父调 expose sendExamplePrompt 触发本组件发送
// store 单例共享:useAiStore(sendMessage/editMessage/stopChat)、useProjectStore(@ 联想源) // store 单例共享:useAiStore(sendMessage/editMessage/stopChat)、useProjectStore(@ 联想源)
import { ref, computed, nextTick, watch } from 'vue' import { ref, computed, nextTick, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai' import { useAiStore } from '../../stores/ai'
import { useProjectStore } from '../../stores/project' import { useProjectStore } from '../../stores/project'
@@ -153,7 +164,7 @@ import { useAppSettingsStore } from '../../stores/appSettings'
import { getConvState } from '../../composables/ai/useAiEvents' import { getConvState } from '../../composables/ai/useAiEvents'
import { continueList, exitListOnBackspace } from '../../composables/ai/useListContinuation' import { continueList, exitListOnBackspace } from '../../composables/ai/useListContinuation'
import { textIdle } from '../../composables/ai/useAiPendingState' import { textIdle } from '../../composables/ai/useAiPendingState'
import { getConvDraft, setConvDraft, classifySendError, type SendErrorKind } from '../../composables/ai/aiShared' import { getConvDraft, setConvDraft, classifySendError, convDraftAppends, takeConvDraftAppend, 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'
@@ -162,6 +173,8 @@ import ImageInput from './ImageInput.vue'
import MentionPopover from './MentionPopover.vue' import MentionPopover from './MentionPopover.vue'
import EnrichmentPanel from './EnrichmentPanel.vue' import EnrichmentPanel from './EnrichmentPanel.vue'
import OutboxDrawer from './OutboxDrawer.vue' import OutboxDrawer from './OutboxDrawer.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import { useConfirm } from '../../composables/useConfirm'
import { taskApi } from '../../api/task' import { taskApi } from '../../api/task'
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } from '../../api/types' import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } from '../../api/types'
import type { MentionItem } from './MentionItem' import type { MentionItem } from './MentionItem'
@@ -1078,6 +1091,8 @@ async function handleSend() {
// 生成中不再拦截:sendMessage 会把消息排入待发送队列,完成后自动续发 // 生成中不再拦截:sendMessage 会把消息排入待发送队列,完成后自动续发
// 先清输入(无论立即发还是入队);队列满/IPC 失败时 catch 回填 // 先清输入(无论立即发还是入队);队列满/IPC 失败时 catch 回填
inputText.value = '' inputText.value = ''
// F2-2:草稿已消费,立即落盘删项(不等防抖——快速关窗 300ms 内会残留已发送稿)
persistDraftNow()
pendingSkill.value = null pendingSkill.value = null
pendingImages.value = [] pendingImages.value = []
// Input Augmentation: 清空 mention 区间(与 inputText 清空同处,保证下条消息从空白态起步) // Input Augmentation: 清空 mention 区间(与 inputText 清空同处,保证下条消息从空白态起步)
@@ -1113,12 +1128,20 @@ async function handleSend() {
emit('sent') emit('sent')
} }
/** 点击示例问题卡片:填入输入框并发送(复用 handleSend 同款发送路径,错误回退一致) */ /** 点击示例问题卡片:填入输入框并发送(复用 handleSend 同款发送路径,错误回退一致)。
* F2-4(走查-信息UX 面二):输入框已有未发送草稿(文本/图片/技能 chip 任一)时先确认,
* 取消则原样保留草稿——对齐父级 backfillQueued 的覆盖确认语义,不再静默替换。 */
async function sendExamplePrompt(i18nKey: string) { async function sendExamplePrompt(i18nKey: string) {
if (hasUnsentInput() && !(await confirmDialog(t('aiChat.exampleOverwriteConfirm')))) return
inputText.value = t(i18nKey) inputText.value = t(i18nKey)
await handleSend() await handleSend()
} }
// ── F2-4:覆盖确认弹层(仓风格不引 window.confirm;AiChat 的 confirmDialog 状态机在父级
// 经模板绑定,子组件复用需父接线 → 本组件自持一份 useConfirm + ConfirmDialog,
// 行为/样式与父级 backfillQueued / clearQueue 确认同范式) ──
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// ── 父调用 expose(父 startEdit/cancelEdit/beforeunload 调用) ── // ── 父调用 expose(父 startEdit/cancelEdit/beforeunload 调用) ──
/** UX-09 进入编辑态:回填末条 user 内容到输入框,聚焦,光标置末 */ /** UX-09 进入编辑态:回填末条 user 内容到输入框,聚焦,光标置末 */
@@ -1140,7 +1163,9 @@ function startEditFocus(msg: AiMessage): void {
const len = el.value.length const len = el.value.length
el.setSelectionRange(len, len) el.setSelectionRange(len, len)
el.style.height = 'auto' el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px' // F2-3(走查-信息UX):编辑态回填高度复用 INPUT_MAX_HEIGHT,与 autoResize 同源;
// 原硬编码 120 使长草稿进编辑态首屏被压矮,任一按键才弹回 200
el.style.height = Math.min(el.scrollHeight, INPUT_MAX_HEIGHT) + 'px'
} }
}) })
} }
@@ -1155,6 +1180,8 @@ function clearInput(): void {
mentionOpen.value = false mentionOpen.value = false
mentionStart.value = -1 mentionStart.value = -1
if (inputEl.value) inputEl.value.style.height = 'auto' if (inputEl.value) inputEl.value.style.height = 'auto'
// F2-2:清空即落盘(取消编辑不应残留旧稿到下次重启)
persistDraftNow()
} }
/** I4 队列回填:父把取出的队列项文本填回输入框重新编辑。 /** I4 队列回填:父把取出的队列项文本填回输入框重新编辑。
@@ -1184,12 +1211,78 @@ function focus(): void {
} }
// ── V8 per-conv 草稿:切会话保存/恢复,防 A 会话未发送草稿串到 B 输入框 ── // ── V8 per-conv 草稿:切会话保存/恢复,防 A 会话未发送草稿串到 B 输入框 ──
// 草稿真相源在 aiShared.convDrafts(内存 Map,不落 localStorage)。父组件 AiChat 的 // 会话内真相源在 aiShared.convDrafts(内存 Map);F2-2(走查-信息UX 面二)追加落盘镜像:
// 键入过程防抖写 localStorage,重启/换装后经回退链恢复。父组件 AiChat 的
// activeConversationId watcher 注册更早先执行:编辑态切换时父先 cancelEdit 清输入, // activeConversationId watcher 注册更早先执行:编辑态切换时父先 cancelEdit 清输入,
// 本 watcher 后执行,旧会话保存到的是清空后的空串,与既有「切会话丢弃编辑缓冲」语义一致。 // 本 watcher 后执行,旧会话保存到的是清空后的空串,与既有「切会话丢弃编辑缓冲」语义一致。
// F2-2:per-conv 草稿 localStorage 镜像(df-ai-* 键约定;单 JSON 对象,LRU 限 20 条,
// 空串收敛删项——与内存 Map 同语义)。读写全程 try/catch:持久化是尽力而为,
// 解析失败/配额满不阻断输入主链。
const DRAFTS_KEY = 'df-ai-conv-drafts'
const DRAFTS_MAX = 20
function readDraftsStore(): Record<string, string> {
try {
const parsed: unknown = JSON.parse(localStorage.getItem(DRAFTS_KEY) || '{}')
return parsed && typeof parsed === 'object' ? (parsed as Record<string, string>) : {}
} catch {
return {}
}
}
function persistDraft(convId: string, text: string): void {
const all = readDraftsStore()
if (text) {
// 删后重插把该会话挪到键序末尾,Object 键序=写入序,溢出时从头淘汰即 LRU
delete all[convId]
all[convId] = text
const keys = Object.keys(all)
for (let i = 0; i < keys.length - DRAFTS_MAX; i++) delete all[keys[i]]
} else {
delete all[convId]
}
try {
localStorage.setItem(DRAFTS_KEY, JSON.stringify(all))
} catch {
// 配额满等:放弃本次落盘,内存草稿不受影响
}
}
// 防抖落盘:逐键写盘不必要,300ms 静默期合并;发送/清空/切会话/卸载走立即落盘收口
let draftPersistTimer: ReturnType<typeof setTimeout> | null = null
function persistDraftNow(): void {
if (draftPersistTimer) {
clearTimeout(draftPersistTimer)
draftPersistTimer = null
}
const convId = store.state.activeConversationId
if (convId) persistDraft(convId, inputText.value)
}
watch(inputText, () => {
if (draftPersistTimer) clearTimeout(draftPersistTimer)
draftPersistTimer = setTimeout(persistDraftNow, 300)
})
// F2-2:卸载(面板收起/关窗)前冲刷在途防抖,防最后 300ms 键入的草稿丢失
onBeforeUnmount(() => {
if (draftPersistTimer) persistDraftNow()
})
watch(() => store.state.activeConversationId, (nv, ov) => { watch(() => store.state.activeConversationId, (nv, ov) => {
if (ov) setConvDraft(ov, inputText.value) // immediate:挂载时活跃会话已就位(分离窗/恢复会话直开)也走恢复链,不只在切换时
if (ov) {
setConvDraft(ov, inputText.value)
persistDraft(ov, inputText.value)
}
inputText.value = getConvDraft(nv) inputText.value = getConvDraft(nv)
// 内存 Map 无记录时回退落盘稿(重启后 Map 为空;同会话内 Map 恒比落盘新,优先)
if (!inputText.value && nv) {
const persisted = readDraftsStore()[nv]
if (typeof persisted === 'string') inputText.value = persisted
}
// 随输入框的临时态不跨会话携带:mention 区间偏移只对旧文本有效,照搬到恢复的文本会渲染错位 // 随输入框的临时态不跨会话携带:mention 区间偏移只对旧文本有效,照搬到恢复的文本会渲染错位
// chip;技能 chip 与待发图片同理,残留到新会话会静默混入下一条消息 // chip;技能 chip 与待发图片同理,残留到新会话会静默混入下一条消息
pendingMentionSpans.value = [] pendingMentionSpans.value = []
@@ -1205,7 +1298,24 @@ watch(() => store.state.activeConversationId, (nv, ov) => {
mentionStart.value = -1 mentionStart.value = -1
autoResize() autoResize()
}) })
}) }, { immediate: true })
// ── 脉络卡「带入上下文」外部投递:以输入框实时内容为基底追加(草稿 Map 不含键入
// 过程,直接写 Map 会丢当前输入),一次性消费防重放;追加后聚焦输入框由用户决断 ──
watch(
() => convDraftAppends.has(store.state.activeConversationId ?? ''),
(has) => {
if (!has) return
const convId = store.state.activeConversationId
const chunk = takeConvDraftAppend(convId)
if (!chunk) return
inputText.value = inputText.value ? `${inputText.value}\n${chunk}` : chunk
nextTick(() => {
autoResize()
focus()
})
},
)
// ── I5 发送失败错误分类文案:类别到 i18n 键的映射(分类逻辑在 aiShared.classifySendError)。 // ── I5 发送失败错误分类文案:类别到 i18n 键的映射(分类逻辑在 aiShared.classifySendError)。
// other 复用既有 toastSendFail 文案,行为与改造前一致 ── // other 复用既有 toastSendFail 文案,行为与改造前一致 ──
@@ -1242,32 +1352,33 @@ defineExpose({
零视觉变更。原 AiChat scoped 不渗透子组件根,输入框/技能联想/发送按钮元素失配 → 本组件自持。 零视觉变更。原 AiChat scoped 不渗透子组件根,输入框/技能联想/发送按钮元素失配 → 本组件自持。
CSS 变量(--df-*)由根级 global.css 提供,共享同一份。.ai-input-area 容器留父级 AiChat scoped。) ═══ --> CSS 变量(--df-*)由根级 global.css 提供,共享同一份。.ai-input-area 容器留父级 AiChat scoped。) ═══ -->
<style scoped> <style scoped>
/* 2026-10-18 wave2: 输入盒迁移 workbench .inp-box 范式——bg-card 底+0.5px 边框+9px 圆角,
focus-within 边框转 info;纵向布局让全宽图片预览行自然换行(替代原 flex-wrap 手法) */
.ai-input-wrap { .ai-input-wrap {
position: relative; position: relative;
display: flex; display: flex;
/* F-260614-05 Phase 2b: flex-wrap 让全宽的图片预览行强制 textarea+按钮换行到下一行 */ flex-direction: column;
flex-wrap: wrap;
align-items: center;
gap: 6px;
background: var(--df-bg-card); background: var(--df-bg-card);
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg); border-radius: 9px;
padding: 6px 8px; padding: 9px 12px;
transition: border-color 0.15s; transition: border-color 0.15s;
} }
/* F-260614-05 Phase 2b: 图片预览样式已提取到 styles/components.css 全局(ImageInput.vue 共享) */ /* F-260614-05 Phase 2b: 图片预览样式已提取到 styles/components.css 全局(ImageInput.vue 共享) */
.ai-input-wrap:focus-within { .ai-input-wrap:focus-within {
border-color: color-mix(in srgb, var(--df-accent) 40%, transparent); border-color: var(--df-info);
} }
/* ── 技能 chip / 联想浮层样式已提取到 styles/components.css 全局(SkillMention.vue + MentionPopover.vue 共享)── */ /* ── 技能 chip / 联想浮层样式已提取到 styles/components.css 全局(SkillMention.vue + MentionPopover.vue 共享)── */
/* ── UX-10 §1.4: @ 实体引用浮层样式已随 MentionPopover.vue 提取 ── */ /* ── UX-10 §1.4: @ 实体引用浮层样式已随 MentionPopover.vue 提取 ── */
/* textarea 透明无边框占满输入盒首行(workbench .inp-box textarea 范式);
autosize 高度参数(min/height/max-height)为行为契约,禁动 */
.ai-input { .ai-input {
flex: 1; width: 100%;
background: transparent; background: transparent;
border: none; border: none;
outline: none; outline: none;
@@ -1284,6 +1395,19 @@ defineExpose({
.ai-input::placeholder { .ai-input::placeholder {
color: var(--df-text-dim); color: var(--df-text-dim);
} }
/* 工具行(workbench .inp-row 范式):textarea 下方 --df-border 分隔线,左功能钮+右发送钮 */
.ai-input-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
padding-top: 6px;
border-top: 0.5px solid var(--df-border);
}
/* 发送钮(workbench .isend 范式):info→accent 135° 渐变+深色 icon+600 字重;
禁用态 opacity .4(禁用判定逻辑不动)。原 --active 实心紫规则并入基类,类名绑定保留无害 */
.ai-send-btn { .ai-send-btn {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1292,38 +1416,37 @@ defineExpose({
height: 28px; height: 28px;
border: none; border: none;
border-radius: var(--df-radius); border-radius: var(--df-radius);
background: var(--df-bg-raised); background: linear-gradient(135deg, var(--df-info), var(--df-accent));
color: var(--df-text-dim); color: color-mix(in srgb, var(--df-bg) 70%, #000);
font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background 0.15s, color 0.15s; transition: filter 0.15s, opacity 0.15s;
flex-shrink: 0; flex-shrink: 0;
margin-left: auto;
} }
.ai-send-btn:not(:disabled):hover {
filter: brightness(1.1);
}
/* 功能钮统一 28px 高 bg-card 边框钮(workbench 工具行左侧范式),hover 边框亮 */
.ai-img-pick-btn { .ai-img-pick-btn {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 28px; width: 28px;
height: 28px; height: 28px;
border: none; border: 0.5px solid var(--df-border);
border-radius: var(--df-radius); border-radius: var(--df-radius);
background: transparent; background: var(--df-bg-card);
color: var(--df-text-dim); color: var(--df-text-dim);
cursor: pointer; cursor: pointer;
transition: background 0.15s, color 0.15s; transition: background 0.15s, color 0.15s, border-color 0.15s;
flex-shrink: 0; flex-shrink: 0;
margin-right: 4px;
} }
.ai-img-pick-btn:hover { .ai-img-pick-btn:hover {
background: var(--df-bg-raised); background: var(--df-bg-card-hover);
border-color: var(--df-border-strong);
color: var(--df-text); color: var(--df-text);
} }
.ai-send-btn--active {
background: var(--df-accent);
color: #fff;
}
.ai-send-btn--active:hover {
background: var(--df-accent-hover);
}
.ai-send-btn:disabled { .ai-send-btn:disabled {
opacity: 0.4; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
@@ -1347,20 +1470,34 @@ defineExpose({
.ai-send-btn--retry:hover { .ai-send-btn--retry:hover {
background: #d63a3f; background: #d63a3f;
} }
/* 命令提示 chip(workbench .icmd 范式):mono 10px --df-text-dim */
.ai-input-hint { .ai-input-hint {
font-family: var(--df-font-mono);
font-size: 10px; font-size: 10px;
color: var(--df-text-dim); color: var(--df-text-dim);
text-align: right; text-align: right;
margin-top: 4px; margin-top: 4px;
opacity: 0.6;
} }
/* ── ⑥.4 Phase 4: @项目展开摘要样式已随 EnrichmentPanel.vue 提取 ── */ /* ── ⑥.4 Phase 4: @项目展开摘要样式已随 EnrichmentPanel.vue 提取 ── */
/* B1: 非视觉模型贴图警示条(待发送图片预览行下方,warning 色轻量提示,不抢输入框注意力) */ /* B1: 非视觉模型贴图警示条(待发送图片预览行下方,warning 色轻量提示,不抢输入框注意力) */
.ai-img-warn-chip { .ai-img-warn-chip {
font-size: 10.5px; font-size: 10px;
color: var(--df-warning); color: var(--df-warning);
padding: 1px 4px; padding: 1px 4px;
} }
/* ── 2026-10-18 wave2: 草稿/mention 弹层对齐 workbench 弹层范式 ──
SkillMention/MentionPopover 共用 styles/components.css 的 .ai-skill-* 全局基类(禁碰),
经 :deep 在此集中覆写一份(两子组件弹层同源,避免各自 scoped 重复):
8px 圆角+浅阴影;选中项 bg-card-hover+左 2px 强调边(inset 阴影实现,零布局位移) */
.ai-input-wrap :deep(.ai-skill-popover) {
border-radius: var(--df-radius-md);
box-shadow: var(--df-shadow-sm);
}
.ai-input-wrap :deep(.ai-skill-item--active) {
background: var(--df-bg-card-hover);
box-shadow: inset 2px 0 0 var(--df-accent);
}
</style> </style>
+5 -5
View File
@@ -104,16 +104,16 @@ watch(
<style scoped> <style scoped>
.conflict-resolver { .conflict-resolver {
border: 1px solid var(--df-warning, #ffc107); border: 1px solid var(--df-warning);
border-radius: 8px; border-radius: 8px;
padding: 8px 12px; padding: 8px 12px;
margin: 8px 0; margin: 8px 0;
background: var(--df-bg-secondary); background: var(--df-bg-card);
} }
.conflict-header { .conflict-header {
font-weight: 600; font-weight: 600;
margin-bottom: 8px; margin-bottom: 8px;
color: var(--df-warning, #ffc107); color: var(--df-warning);
} }
.conflict-item { .conflict-item {
border-top: 1px solid var(--df-border); border-top: 1px solid var(--df-border);
@@ -153,8 +153,8 @@ watch(
padding: 2px 6px; padding: 2px 6px;
border-radius: 4px; border-radius: 4px;
} }
.badge--file { background: rgba(255, 193, 7, 0.15); color: #ffc107; } .badge--file { background: var(--df-warning-bg); color: var(--df-warning); }
.badge--semantic { background: rgba(220, 53, 69, 0.15); color: #dc3545; } .badge--semantic { background: var(--df-danger-bg); color: var(--df-danger); }
.conflict-file code { font-size: 12px; color: var(--df-text); } .conflict-file code { font-size: 12px; color: var(--df-text); }
.conflict-diffs { .conflict-diffs {
display: grid; display: grid;
+233
View File
@@ -0,0 +1,233 @@
<template>
<!-- 脉络卡:同项目近期决策摘要,纯读侧。用户点「带入上下文」向输入框投递追加文本,
由用户编辑后自行发送——注入决策权在用户,不触碰 L3 召回铁律。
有源会话的条目标题可点跳回原文,空源或跳转失败静默不打断阅读。 -->
<div class="ctx-briefs">
<button
class="ctx-briefs-toggle"
:title="t('aiChat.contextBriefsToggle')"
@click="toggle"
>
<svg
aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
:style="{ transform: expanded ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }"
><polyline points="9 18 15 12 9 6"/></svg>
<span>{{ t('aiChat.contextBriefsTitle') }}</span>
<span v-if="!expanded && totalBriefs > 0" class="ctx-briefs-count">{{ totalBriefs }}</span>
</button>
<div v-if="expanded" class="ctx-briefs-body">
<div v-if="loading" class="ctx-briefs-empty">{{ t('aiChat.contextBriefsLoading') }}</div>
<div v-else-if="groups.length === 0" class="ctx-briefs-empty">{{ t('aiChat.contextBriefsEmpty') }}</div>
<div v-for="g in groups" v-else :key="g.source_project" class="ctx-briefs-group">
<div class="ctx-briefs-project">
<span v-if="g.focused" class="ctx-briefs-focus-dot" />{{ g.source_project }}
</div>
<div v-for="b in g.briefs" :key="g.source_project + b.title" class="ctx-briefs-item">
<div class="ctx-briefs-item-head">
<button
v-if="b.source_conv"
class="ctx-briefs-item-title ctx-briefs-item-link"
@click="openSource(b)"
>{{ b.title }}</button>
<span v-else class="ctx-briefs-item-title">{{ b.title }}</span>
<span class="ctx-briefs-item-time">{{ formatRelative(b.updated_at) }}</span>
</div>
<div class="ctx-briefs-item-summary">{{ b.summary }}</div>
<button
class="ctx-briefs-bring"
:title="t('aiChat.contextBriefsBringIn')"
@click="bringIn(g, b)"
>{{ t('aiChat.contextBriefsBringIn') }}</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { aiApi } from '../../api/ai'
import { useAiStore } from '../../stores/ai'
import { pushConvDraftAppend } from '../../composables/ai/aiShared'
import { useAiConversations } from '../../composables/ai/useAiConversations'
import { formatRelative } from '../../utils/time'
interface ContextBrief {
title: string
summary: string
source_ref: string | null
source_conv: string | null
source_project: string | null
reuse_count: number
updated_at: string
}
interface ProjectBriefs {
source_project: string
focused?: boolean
briefs: ContextBrief[]
}
const { t } = useI18n()
const store = useAiStore()
const { openConversation } = useAiConversations()
const expanded = ref(false)
const loading = ref(false)
const groups = ref<ProjectBriefs[]>([])
const totalBriefs = computed(() => groups.value.reduce((n, g) => n + g.briefs.length, 0))
/** 拉取脉络,按当前会话标注聚焦分组;会话已切走时丢弃过期响应(防旧 focused 污染新列表) */
async function load() {
const convId = store.state.activeConversationId ?? undefined
loading.value = true
try {
const result = await aiApi.listContextBriefs(undefined, convId)
if ((store.state.activeConversationId ?? undefined) === convId) groups.value = result
} catch {
if ((store.state.activeConversationId ?? undefined) === convId) groups.value = []
} finally {
loading.value = false
}
}
async function toggle() {
expanded.value = !expanded.value
if (expanded.value && groups.value.length === 0) await load()
}
/** 切会话重新拉取(focused 跟随当前会话);收起态只清缓存,保持按需加载 */
watch(() => store.state.activeConversationId, () => {
groups.value = []
if (expanded.value) void load()
})
/** 跳回摘要来源会话(source_conv 由后端自 source_ref 解析,消息级引用已反查);
* 源为空或已删除(not_found)一律静默,不加 toast */
async function openSource(b: ContextBrief) {
if (!b.source_conv) return
try {
await openConversation(b.source_conv)
} catch {
// 静默:跳转失败不打断阅读
}
}
/** 「带入上下文」= 向当前会话输入框投递追加文本(ChatInput 以实时内容为基底合并),
* 用户编辑确认后自行发送——注入决策权在用户,不触碰 L3 召回铁律 */
function bringIn(g: ProjectBriefs, b: ContextBrief) {
const convId = store.state.activeConversationId
if (!convId) return
pushConvDraftAppend(convId, `参考项目「${g.source_project}」的既有结论「${b.title}」:${b.summary}`)
}
</script>
<style scoped>
/* wave2(2026-10-18):旧 token 全量迁 --df-*;统一透明彩底卡范式,accent 系(脉络=系统功能信号)
——边框 color-mix 30% + 底 6% + 标题同色 12px/600 + 正文 secondary 12px + 透明底描边钮 */
.ctx-briefs {
border-bottom: 0.5px solid var(--df-border);
padding: 4px 10px;
font-size: 12px;
}
.ctx-briefs-toggle {
display: flex;
align-items: center;
gap: 5px;
width: 100%;
padding: 3px 2px;
border: none;
background: none;
color: var(--df-accent);
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.ctx-briefs-count {
background: var(--df-accent-soft);
color: var(--df-accent);
border-radius: var(--df-radius-md);
padding: 0 6px;
font-size: 11px;
font-weight: 400;
}
.ctx-briefs-body {
max-height: 220px;
overflow-y: auto;
padding: 6px 8px;
background: color-mix(in srgb, var(--df-accent) 6%, transparent);
border: 0.5px solid color-mix(in srgb, var(--df-accent) 30%, transparent);
border-radius: var(--df-radius);
margin-bottom: 4px;
}
.ctx-briefs-empty {
color: var(--df-text-dim);
padding: 6px 2px;
}
.ctx-briefs-project {
font-weight: 600;
color: var(--df-text);
font-size: 12px;
padding: 5px 2px 2px;
}
.ctx-briefs-focus-dot {
display: inline-block;
width: 5px;
height: 5px;
margin-right: 4px;
border-radius: 50%;
background: var(--df-accent);
vertical-align: 2px;
}
.ctx-briefs-item {
padding: 4px 2px;
border-top: 0.5px dashed var(--df-border);
}
.ctx-briefs-item-head {
display: flex;
align-items: baseline;
gap: 6px;
}
.ctx-briefs-item-title {
font-weight: 500;
color: var(--df-text);
}
.ctx-briefs-item-link {
padding: 0;
border: none;
background: none;
font: inherit;
text-align: left;
cursor: pointer;
}
.ctx-briefs-item-link:hover {
color: var(--df-accent);
}
.ctx-briefs-item-time {
margin-left: auto;
flex-shrink: 0;
font-size: 10px;
color: var(--df-text-dim);
font-family: var(--df-font-mono);
}
.ctx-briefs-item-summary {
color: var(--df-text-secondary);
font-size: 12px;
margin: 2px 0;
line-height: 1.4;
}
.ctx-briefs-bring {
border: 0.5px solid color-mix(in srgb, var(--df-accent) 40%, transparent);
background: transparent;
color: var(--df-accent);
border-radius: var(--df-radius-sm);
padding: 1px 8px;
font-size: 11px;
cursor: pointer;
}
.ctx-briefs-bring:hover {
background: var(--df-accent-soft);
}
</style>
+153 -46
View File
@@ -32,6 +32,8 @@
/> />
<kbd class="ai-conv-search-kbd">{{ t('aiChat.searchShortcutHint') }}</kbd> <kbd class="ai-conv-search-kbd">{{ t('aiChat.searchShortcutHint') }}</kbd>
</div> </div>
<!-- 脉络卡:同项目近期决策摘要,纯读侧,「带入上下文」追加进当前会话输入框 -->
<ContextBriefsCard />
<div class="ai-conv-list"> <div class="ai-conv-list">
<!-- UX-06 §3.1: 搜索态(searchResults 非 null)→ 平铺,活跃+归档全量按 updated_at DESC --> <!-- UX-06 §3.1: 搜索态(searchResults 非 null)→ 平铺,活跃+归档全量按 updated_at DESC -->
<template v-if="searchResults"> <template v-if="searchResults">
@@ -50,6 +52,18 @@
@keydown="onItemKeydown($event, conv.id, true)" @keydown="onItemKeydown($event, conv.id, true)"
> >
<div class="ai-conv-item-main"> <div class="ai-conv-item-main">
<!-- wave2 u-agent 范式: 左侧 7px 状态点。字段源 store.isGenerating(conv_state
generating/stopping/compressed) / pendingCountOf(审批挂起) / loop_state='parked',
无 error 字段故不映射;优先级 generating > 待审批 > parked > 灰 -->
<span
class="df-dot ai-conv-status"
:class="store.isGenerating(conv.id) ? 'df-dot--run ai-conv-status--run'
: pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger'
: conv.loop_state === 'parked' ? 'ai-conv-status--paused'
: 'df-dot--stop'"
:title="store.isGenerating(conv.id) ? t('aiChat.generating') : undefined"
aria-hidden="true"
></span>
<!-- UX走查 P3-1: 搜索态此前无重命名入口(无 startRename 分支),与分组视图降级不一致; <!-- UX走查 P3-1: 搜索态此前无重命名入口(无 startRename 分支),与分组视图降级不一致;
双击入口对齐分组视图,菜单入口供键盘用户(P2-6) --> 双击入口对齐分组视图,菜单入口供键盘用户(P2-6) -->
<input <input
@@ -68,7 +82,6 @@
@dblclick.stop="startRename(conv)" @dblclick.stop="startRename(conv)"
> >
{{ 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>
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) --> <!-- 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 v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 --> <!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
@@ -143,6 +156,16 @@
@keydown="onItemKeydown($event, conv.id, true)" @keydown="onItemKeydown($event, conv.id, true)"
> >
<div class="ai-conv-item-main"> <div class="ai-conv-item-main">
<!-- wave2 u-agent 范式: 左侧 7px 状态点(映射源同搜索态块注) -->
<span
class="df-dot ai-conv-status"
:class="store.isGenerating(conv.id) ? 'df-dot--run ai-conv-status--run'
: pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger'
: conv.loop_state === 'parked' ? 'ai-conv-status--paused'
: 'df-dot--stop'"
:title="store.isGenerating(conv.id) ? t('aiChat.generating') : undefined"
aria-hidden="true"
></span>
<input <input
v-if="editingConvId === conv.id" v-if="editingConvId === conv.id"
:ref="setEditEl" :ref="setEditEl"
@@ -166,8 +189,6 @@
class="ai-conv-item-title-badge" class="ai-conv-item-title-badge"
width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
><polyline points="20 6 9 17 4 12"/></svg> ><polyline points="20 6 9 17 4 12"/></svg>
<!-- F-09: 多会话并发生成指示器(后台/当前会话生成中均显脉冲点,多会话可同时显示) -->
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) --> <!-- 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 v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 --> <!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
@@ -223,6 +244,8 @@
@keydown.space.prevent="onRecover(conv.id)" @keydown.space.prevent="onRecover(conv.id)"
> >
<div class="ai-conv-item-main"> <div class="ai-conv-item-main">
<!-- wave2: 本组数据源 ai_list_recoverable(loop_state='parked' 完备真相),整组恒 parked→静态琥珀点 -->
<span class="df-dot ai-conv-status ai-conv-status--paused" aria-hidden="true"></span>
<span class="ai-conv-item-title"> <span class="ai-conv-item-title">
{{ conv.title || t('aiChat.newConversation') }} {{ conv.title || t('aiChat.newConversation') }}
<span class="ai-conv-recover-badge">⏸</span> <span class="ai-conv-recover-badge">⏸</span>
@@ -258,9 +281,18 @@
@keydown="onItemKeydown($event, conv.id)" @keydown="onItemKeydown($event, conv.id)"
> >
<div class="ai-conv-item-main"> <div class="ai-conv-item-main">
<!-- wave2 u-agent 范式: 归档项同款状态点(归档为静息态,默认落灰) -->
<span
class="df-dot ai-conv-status"
:class="store.isGenerating(conv.id) ? 'df-dot--run ai-conv-status--run'
: pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger'
: conv.loop_state === 'parked' ? 'ai-conv-status--paused'
: 'df-dot--stop'"
:title="store.isGenerating(conv.id) ? t('aiChat.generating') : undefined"
aria-hidden="true"
></span>
<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>
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) --> <!-- 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 v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 --> <!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
@@ -294,6 +326,17 @@
</div> </div>
</template> </template>
<!-- F4-1: 会话列表分页——底部「加载更多」续拉下一页(首屏 50/组,归档组同口径放开) -->
<div
v-if="convListPaging.hasMore"
class="ai-conv-load-more"
role="button"
tabindex="0"
@click="onLoadMore"
@keydown.enter.prevent="onLoadMore"
@keydown.space.prevent="onLoadMore"
>{{ convListPaging.loading ? t('common.loading') : t('gitChanges.loadMore') }}</div>
<div v-if="store.state.conversations.length === 0" class="ai-conv-empty"> <div v-if="store.state.conversations.length === 0" class="ai-conv-empty">
{{ t('aiChat.emptyConversation') }} {{ t('aiChat.emptyConversation') }}
</div> </div>
@@ -336,7 +379,8 @@ import { convPendingApprovals } from '../../composables/ai/aiShared'
// 镜像由边界配置卡在切会话/写入时渐进填充,无批量 IPC 属契约限制) // 镜像由边界配置卡在切会话/写入时渐进填充,无批量 IPC 属契约限制)
import { getConvBoundary, fetchConvBoundary } from '../../composables/ai/useTaskBoundary' import { getConvBoundary, fetchConvBoundary } from '../../composables/ai/useTaskBoundary'
// UX走查 P2-7 只读订阅:rename/archive/pin 失败通知由 useAiConversations 发布,本组件弹就近 toast // UX走查 P2-7 只读订阅:rename/archive/pin 失败通知由 useAiConversations 发布,本组件弹就近 toast
import { convOpFailNotice } from '../../composables/ai/useAiConversations' // F4-1: 会话列表分页游标(reactive,绑「加载更多」显隐/loading)+ 续拉/拉满动作
import { convOpFailNotice, convListPaging, loadMoreConversations, loadAllConversations } from '../../composables/ai/useAiConversations'
import { useToast } from '../../composables/useToast' import { useToast } from '../../composables/useToast'
import { formatRelative } from '../../utils/time' import { formatRelative } from '../../utils/time'
import type { AiConversationSummary } from '../../api/types' import type { AiConversationSummary } from '../../api/types'
@@ -660,6 +704,28 @@ const searchResults = computed<AiConversationSummary[] | null>(
() => store.filteredConversations.value, () => store.filteredConversations.value,
) )
// ── F4-1: 会话列表分页(加载更多按钮 + 搜索全量兜底) ──
/** 「加载更多」:续拉下一页(失败/无新增静默,按钮 loading 态由 convListPaging 驱动) */
function onLoadMore(): void {
void loadMoreConversations()
}
// 搜索输入即拉满:搜索过滤(stores.filteredConversations)跑在已加载列表上,未拉满时
// 第 51 条起的会话搜不到(硬 50 截断=搜索失灵)——输入非空且还有未拉页时一次拉尽,
// 之后搜索自然覆盖全量;已拉满(hasMore=false)零开销不触发。
watch(() => store.state.searchQuery, (q) => {
if (q.trim() && convListPaging.hasMore && !convListPaging.loading) {
void loadAllConversations()
}
})
// 加载更多/搜索拉满追加的新会话补拉边界徽标镜像(A10 空洞随列表加深扩大;
// hydrate 对已入镜像的会话跳过,常规刷新时零 IPC)
watch(() => store.state.conversations.length, () => {
void hydrateBoundaryBadges()
})
// ── 侧栏宽度拖拽 ── // ── 侧栏宽度拖拽 ──
const resizeState = { startX: 0, startWidth: 0 } const resizeState = { startX: 0, startWidth: 0 }
function onResizeMove(e: MouseEvent) { function onResizeMove(e: MouseEvent) {
@@ -754,7 +820,7 @@ defineExpose({
transition: background 0.15s var(--df-ease); transition: background 0.15s var(--df-ease);
} }
.ai-sidebar-resize-handle:hover { .ai-sidebar-resize-handle:hover {
background: var(--df-accent, #4f8cff); background: var(--df-accent);
opacity: 0.5; opacity: 0.5;
} }
/* 拖拽中全局锁 cursor + 禁选中文本(挂在 body 上,跨元素生效) */ /* 拖拽中全局锁 cursor + 禁选中文本(挂在 body 上,跨元素生效) */
@@ -771,18 +837,26 @@ body.ai-sidebar-resizing * {
border-bottom: 0.5px solid var(--df-border); border-bottom: 0.5px solid var(--df-border);
} }
.ai-conv-sidebar-title { .ai-conv-sidebar-title {
font-size: 12px; font-size: 13px;
font-weight: 500; font-weight: 600;
color: var(--df-text); color: var(--df-text);
} }
/* UX-06 §3.1: 对话搜索框(紧贴 header 下,样式跟随周围输入控件) */ /* UX-06 §3.1: 对话搜索框(wave2 u-agent .sb-search 范式:容器成框,卡底+focus 边框 info) */
.ai-conv-search { .ai-conv-search {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
padding: 6px 10px; margin: 8px 10px;
border-bottom: 0.5px solid var(--df-border); padding: 0 8px;
height: 30px;
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
transition: border-color 0.15s var(--df-ease);
}
.ai-conv-search:focus-within {
border-color: var(--df-info);
} }
.ai-conv-search-icon { .ai-conv-search-icon {
flex: none; flex: none;
@@ -798,8 +872,8 @@ body.ai-sidebar-resizing * {
font-size: 12px; font-size: 12px;
color: var(--df-text); color: var(--df-text);
} }
/* 聚焦可见环:无边框输入聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */ /* 聚焦可见环:容器已随 :focus-within 变 info 边框,键盘环同色补强(scoped 特异性须盖全局兜底) */
.ai-conv-search-input:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; } .ai-conv-search-input:focus-visible { outline: 2px solid var(--df-info); outline-offset: 1px; }
.ai-conv-search-input::placeholder { .ai-conv-search-input::placeholder {
color: var(--df-text-dim); color: var(--df-text-dim);
} }
@@ -817,12 +891,15 @@ body.ai-sidebar-resizing * {
.ai-conv-list { .ai-conv-list {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 4px; padding: 4px 6px; /* u-agent .sb-list 范式;滚动条走 global ::-webkit-scrollbar token 体系 */
} }
/* wave2 u-agent 范式: 分组头 10px uppercase + letter-spacing 1px + dim */
.ai-conv-group-title { .ai-conv-group-title {
padding: 8px 8px 3px; padding: 10px 8px 4px;
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--df-text-dim); color: var(--df-text-dim);
user-select: none; user-select: none;
} }
@@ -849,7 +926,10 @@ body.ai-sidebar-resizing * {
.ai-conv-group-count { .ai-conv-group-count {
margin-left: 2px; margin-left: 2px;
font-weight: 400; font-weight: 400;
opacity: 0.7; font-family: var(--df-font-mono); /* mono 计数(df-mono 范式),upper/lsp 回退保证数字不散 */
font-size: 10px;
letter-spacing: 0;
opacity: 0.9;
} }
.ai-conv-group-empty { .ai-conv-group-empty {
padding: 2px 8px 4px 20px; padding: 2px 8px 4px 20px;
@@ -857,40 +937,48 @@ body.ai-sidebar-resizing * {
color: var(--df-text-dim); color: var(--df-text-dim);
font-style: italic; font-style: italic;
} }
/* wave2 u-agent .cv 范式: 单行条目,8px 级密度;hover/active 同走灰阶卡底(GitHub 两级同底、
active 由左 2px accent 实线区分),深浅主题同 token 自动适配 */
.ai-conv-item { .ai-conv-item {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 6px 8px; padding: 8px;
border-radius: var(--df-radius); border-radius: var(--df-radius);
cursor: pointer; cursor: pointer;
transition: background 0.15s; transition: background 0.15s;
gap: 4px; gap: 6px;
} }
.ai-conv-item:hover { .ai-conv-item:hover {
background: var(--df-sidebar-hover); background: var(--df-bg-card-hover);
} }
.ai-conv-item--active { .ai-conv-item--active {
background: var(--df-sidebar-active); background: var(--df-bg-card-hover);
box-shadow: inset 2px 0 0 var(--df-accent);
} }
/* 切换进行时(UX走查 P1):与活跃项同底色示"已选中"(乐观预高亮),降不透明度示"加载中"; /* 切换进行时(UX走查 P1):与活跃项同底色示"已选中"(乐观预高亮),降不透明度示"加载中";
IPC 返回后 activeConversationId 落定、本类移除,视觉无缝衔接;失败回滚高亮回原活跃项 */ IPC 返回后 activeConversationId 落定、本类移除,视觉无缝衔接;失败回滚高亮回原活跃项 */
.ai-conv-item--switching { .ai-conv-item--switching {
background: var(--df-sidebar-active); background: var(--df-bg-card-hover);
opacity: 0.7; 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;
} }
/* wave2: 行内布局对齐 .cv-top([状态点][标题省略][时间]) */
.ai-conv-item-main { .ai-conv-item-main {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: row;
gap: 2px; align-items: center;
gap: 6px;
} }
.ai-conv-item-title { .ai-conv-item-title {
font-size: 11px; flex: 1;
min-width: 0;
font-size: 12px;
font-weight: 500;
color: var(--df-text); color: var(--df-text);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -899,7 +987,6 @@ body.ai-sidebar-resizing * {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 3px; gap: 3px;
min-width: 0;
} }
/* UX-2025-20 §7.4: AI 生成标题淡入(0.3s opacity + transform 过渡) */ /* UX-2025-20 §7.4: AI 生成标题淡入(0.3s opacity + transform 过渡) */
.ai-conv-item-title--flash { .ai-conv-item-title--flash {
@@ -914,34 +1001,35 @@ body.ai-sidebar-resizing * {
to { opacity: 1; transform: translateX(0); } to { opacity: 1; transform: translateX(0); }
} }
.ai-conv-item-edit { .ai-conv-item-edit {
flex: 1 1 0; /* wave2: main 已行化,输入框按 flex 填充(min-width:0 防 input 固有宽撑破窄侧栏) */
min-width: 0;
width: auto;
font-size: 11px; font-size: 11px;
color: var(--df-text); color: var(--df-text);
width: 100%;
height: 18px; height: 18px;
padding: 0 4px; padding: 0 4px;
border: 1px solid var(--df-accent); border: 1px solid var(--df-accent);
border-radius: var(--df-radius-sm); border-radius: var(--df-radius-sm);
background: var(--df-bg, #fff); background: var(--df-bg);
outline: none; outline: none;
box-sizing: border-box; box-sizing: border-box;
} }
/* 聚焦可见环:边框恒为 accent 聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */ /* 聚焦可见环:边框恒为 accent 聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
.ai-conv-item-edit:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; } .ai-conv-item-edit:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
/* wave2 u-agent 范式: 时间/计数元数据 mono 10px dim(.df-mono 同款视觉) */
.ai-conv-item-time { .ai-conv-item-time {
font-size: 9px; flex: none;
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text-dim); color: var(--df-text-dim);
} }
/* 多会话并发生成指示器(会话项标题行内脉冲点,多会话可同时显示) */ /* wave2 u-agent .st 状态点变体: 公共 .df-dot/.df-dot--run/.df-dot--stop 管造型与绿/灰底,
.ai-conv-gen-dot { 此处只补动效与缺位色(全 token): run 绿+glow+脉冲(global df-pulse)、danger 待审批红、paused 停靠琥珀 */
display: inline-block; .ai-conv-status--run { box-shadow: 0 0 6px var(--df-success); animation: df-pulse 1.5s infinite; }
width: 6px; .ai-conv-status--danger { background: var(--df-danger); }
height: 6px; .ai-conv-status--paused { background: var(--df-warning); }
border-radius: 50%;
background: var(--df-accent);
flex-shrink: 0;
animation: ai-conv-gen-pulse 1.4s ease-in-out infinite;
}
@keyframes ai-conv-gen-pulse { @keyframes ai-conv-gen-pulse {
/* 供 .ai-conv-recover-dot 分组级脉冲用(原行内 gen-dot 已由状态点接管) */
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); }
} }
@@ -952,7 +1040,7 @@ body.ai-sidebar-resizing * {
height: 6px; height: 6px;
border-radius: 50%; border-radius: 50%;
margin-right: 5px; margin-right: 5px;
background: var(--df-warning, #f0c75e); background: var(--df-warning);
animation: ai-conv-gen-pulse 2s ease-in-out infinite; animation: ai-conv-gen-pulse 2s ease-in-out infinite;
} }
.ai-conv-item--recoverable { .ai-conv-item--recoverable {
@@ -963,7 +1051,7 @@ body.ai-sidebar-resizing * {
margin-left: 2px; margin-left: 2px;
font-size: 10px; font-size: 10px;
line-height: 1; line-height: 1;
color: var(--df-warning, #f0c75e); color: var(--df-warning);
cursor: help; cursor: help;
} }
/* V1: 跨会话待审批徽标(橙点计数,warning 色系与待审批语义一致;固定尺寸不挤标题换行) */ /* V1: 跨会话待审批徽标(橙点计数,warning 色系与待审批语义一致;固定尺寸不挤标题换行) */
@@ -972,7 +1060,7 @@ body.ai-sidebar-resizing * {
height: 12px; height: 12px;
padding: 0 3px; padding: 0 3px;
border-radius: 6px; border-radius: 6px;
background: var(--df-warning, #f0c75e); background: var(--df-warning);
color: #000; color: #000;
font-size: 9px; font-size: 9px;
font-weight: 600; font-weight: 600;
@@ -1029,9 +1117,13 @@ body.ai-sidebar-resizing * {
color: var(--df-text); color: var(--df-text);
} }
/* 已置顶对话视觉强调(左边线,不依赖菜单展开,一眼识别)。 /* 已置顶对话视觉强调(左边线,不依赖菜单展开,一眼识别)。
pinned 类挂在 .ai-conv-item 上(操作收进更多菜单后,按钮不再常驻,改 item 级类)。 */ pinned 类挂在 .ai-conv-item 上(操作收进更多菜单后,按钮不再常驻,改 item 级类)。
wave2: 选中态同占左缘,置顶线降为 45% 透明 accent 让位,选中实线优先 */
.ai-conv-item--pinned { .ai-conv-item--pinned {
box-shadow: inset 2px 0 0 var(--df-accent, var(--df-text)); box-shadow: inset 2px 0 0 color-mix(in srgb, var(--df-accent) 45%, transparent);
}
.ai-conv-item--active.ai-conv-item--pinned {
box-shadow: inset 2px 0 0 var(--df-accent);
} }
/* ── 「更多」操作菜单(置顶/归档/删除)── /* ── 「更多」操作菜单(置顶/归档/删除)──
「...」按钮 hover 时随 actions 浮出(同原 4 按钮交互),点击展开下拉。 「...」按钮 hover 时随 actions 浮出(同原 4 按钮交互),点击展开下拉。
@@ -1053,8 +1145,8 @@ body.ai-sidebar-resizing * {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1px; gap: 1px;
background: var(--df-bg, #fff); background: var(--df-bg);
border: 1px solid var(--df-border, #e2e2e2); border: 1px solid var(--df-border);
border-radius: var(--df-radius-sm); border-radius: var(--df-radius-sm);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
} }
@@ -1085,6 +1177,21 @@ body.ai-sidebar-resizing * {
font-size: 11px; font-size: 11px;
color: var(--df-text-dim); color: var(--df-text-dim);
} }
/* F4-1: 列表底部「加载更多」(轻量文字行,hover 同列表项灰阶卡底范式) */
.ai-conv-load-more {
margin: 4px 2px;
padding: 6px 8px;
border-radius: var(--df-radius);
font-size: 11px;
color: var(--df-text-dim);
text-align: center;
cursor: pointer;
user-select: none;
}
.ai-conv-load-more:hover {
background: var(--df-bg-card-hover);
color: var(--df-text);
}
/* UX走查 P2-7: 会话操作失败就近 toast(仅 error 一型)。视觉对齐 AiChat .ai-toast, /* UX走查 P2-7: 会话操作失败就近 toast(仅 error 一型)。视觉对齐 AiChat .ai-toast,
但 AiChat 版是 scoped 样式渗透不进来,故自持一份;侧栏窄,max-width 收 85% */ 但 AiChat 版是 scoped 样式渗透不进来,故自持一份;侧栏窄,max-width 收 85% */
@@ -1099,7 +1206,7 @@ body.ai-sidebar-resizing * {
z-index: 30; /* 高于操作菜单(20),菜单展开时失败反馈不被盖 */ z-index: 30; /* 高于操作菜单(20),菜单展开时失败反馈不被盖 */
max-width: 85%; max-width: 85%;
overflow-wrap: anywhere; overflow-wrap: anywhere;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); box-shadow: var(--df-shadow-sm);
background: var(--df-danger-bg); background: var(--df-danger-bg);
color: var(--df-danger); color: var(--df-danger);
border: 0.5px solid var(--df-danger); border: 0.5px solid var(--df-danger);
+2 -2
View File
@@ -154,12 +154,12 @@ watch(() => visibleDirAuths.value.map(d => d.id).join('|'), async () => {
border-radius: var(--df-radius); border-radius: var(--df-radius);
} }
.ai-dir-auth-text { .ai-dir-auth-text {
font-size: 11.5px; font-size: 11px;
font-weight: 600; font-weight: 600;
color: var(--df-warning); color: var(--df-warning);
} }
.ai-dir-auth-hint { .ai-dir-auth-hint {
font-size: 10.5px; font-size: 10px;
color: var(--df-text-dim); color: var(--df-text-dim);
opacity: 0.9; opacity: 0.9;
word-break: break-all; word-break: break-all;
+6 -5
View File
@@ -34,7 +34,7 @@ const examplePrompts = [
<div class="ai-empty"> <div class="ai-empty">
<template v-if="!hasProvider"> <template v-if="!hasProvider">
<div class="ai-empty-icon"> <div class="ai-empty-icon">
<svg aria-hidden="true" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--df-warning)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> <svg aria-hidden="true" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</div> </div>
<p class="ai-empty-text">{{ $t('aiChat.emptyNoProvider') }}</p> <p class="ai-empty-text">{{ $t('aiChat.emptyNoProvider') }}</p>
<p class="ai-empty-sub">{{ $t('aiChat.emptyNoProviderHint') }}</p> <p class="ai-empty-sub">{{ $t('aiChat.emptyNoProviderHint') }}</p>
@@ -46,7 +46,7 @@ const examplePrompts = [
</template> </template>
<template v-else> <template v-else>
<div class="ai-empty-icon"> <div class="ai-empty-icon">
<svg aria-hidden="true" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--df-text-dim)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg> <svg aria-hidden="true" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
</div> </div>
<p class="ai-empty-text">{{ $t('aiChat.emptyTitle') }}</p> <p class="ai-empty-text">{{ $t('aiChat.emptyTitle') }}</p>
<p class="ai-empty-sub">{{ $t('aiChat.emptyHint') }}</p> <p class="ai-empty-sub">{{ $t('aiChat.emptyHint') }}</p>
@@ -73,7 +73,8 @@ const examplePrompts = [
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
} }
.ai-empty-icon { margin-bottom: 4px; opacity: 0.4; } /* wave2:空态图标统一 dim 色(经 currentColor 接管 stroke),引导文字 secondary 层级 */
.ai-empty-icon { margin-bottom: 4px; opacity: 0.4; color: var(--df-text-dim); }
.ai-empty-text { .ai-empty-text {
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
@@ -81,7 +82,7 @@ const examplePrompts = [
} }
.ai-empty-sub { .ai-empty-sub {
font-size: 12px; font-size: 12px;
color: var(--df-text-dim); color: var(--df-text-secondary);
text-align: center; text-align: center;
line-height: 1.5; line-height: 1.5;
} }
@@ -117,7 +118,7 @@ const examplePrompts = [
border-radius: var(--df-radius); border-radius: var(--df-radius);
background: var(--df-bg-card); background: var(--df-bg-card);
color: var(--df-text-secondary); color: var(--df-text-secondary);
font-size: 11.5px; font-size: 11px;
line-height: 1.4; line-height: 1.4;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
+14 -14
View File
@@ -74,30 +74,30 @@ function handleSelectOption(opt: string): void {
</script> </script>
<style scoped> <style scoped>
/* 复用 MaxRoundsCard 样式基调(warning 色系,求助属"需用户介入"信号,视觉一致) */ /* wave2(2026-10-18):统一透明彩底卡范式,danger 系(断路器熔断=最高警示级)——
边框 color-mix 30% + 底 8% + 标题同色 12px/600 + 正文 secondary 12px + 透明底描边钮 */
.ai-help-required { .ai-help-required {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 3px; gap: 3px;
margin-bottom: 6px; margin-bottom: 6px;
padding: 6px 8px; padding: 6px 8px;
background: color-mix(in srgb, var(--df-warning) 10%, transparent); background: color-mix(in srgb, var(--df-danger) 8%, transparent);
border: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent); border: 0.5px solid color-mix(in srgb, var(--df-danger) 30%, transparent);
border-radius: var(--df-radius); border-radius: var(--df-radius);
} }
.ai-help-required-text { .ai-help-required-text {
font-size: 11.5px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: var(--df-warning); color: var(--df-danger);
} }
.ai-help-required-hint { .ai-help-required-hint {
font-size: 10.5px; font-size: 12px;
color: var(--df-text-dim); color: var(--df-text-secondary);
opacity: 0.9;
word-break: break-all; word-break: break-all;
/* M7:context 含错误堆栈,长栈撑高卡片。等宽字体利堆栈行对齐;max-height + overflow-y:auto /* M7:context 含错误堆栈,长栈撑高卡片。等宽字体利堆栈行对齐;max-height + overflow-y:auto
短内容自动不显滚动条,长堆栈滚动扫看(比折叠实用,无需展开按钮)。 */ 短内容自动不显滚动条,长堆栈滚动扫看(比折叠实用,无需展开按钮)。 */
font-family: var(--df-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); font-family: var(--df-font-mono);
max-height: 200px; max-height: 200px;
overflow-y: auto; overflow-y: auto;
} }
@@ -109,15 +109,15 @@ function handleSelectOption(opt: string): void {
margin-top: 2px; margin-top: 2px;
} }
.ai-help-required-btn { .ai-help-required-btn {
border: 0.5px solid color-mix(in srgb, var(--df-warning) 60%, transparent); border: 0.5px solid color-mix(in srgb, var(--df-danger) 40%, transparent);
background: var(--df-warning); background: color-mix(in srgb, var(--df-danger) 10%, transparent);
color: #000; color: var(--df-danger);
cursor: pointer; cursor: pointer;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
padding: 3px 10px; padding: 3px 10px;
border-radius: calc(var(--df-radius) - 2px); border-radius: calc(var(--df-radius) - 2px);
transition: filter 0.15s var(--df-ease); transition: background 0.15s var(--df-ease);
} }
.ai-help-required-btn:hover { filter: brightness(1.1); } .ai-help-required-btn:hover { background: color-mix(in srgb, var(--df-danger) 16%, transparent); }
</style> </style>
+2 -2
View File
@@ -84,7 +84,7 @@ const emit = defineEmits<{
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
/* 类型标签小色块(项目/任务区分) */ /* 类型标签小色块(项目/任务区分)。2026-10-18: 原底色引用未定义 token 静默失效,修正为 bg-card-hover */
.ai-mention-item-type { .ai-mention-item-type {
flex-shrink: 0; flex-shrink: 0;
padding: 1px 5px; padding: 1px 5px;
@@ -92,7 +92,7 @@ const emit = defineEmits<{
font-size: 9px; font-size: 9px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.3px; letter-spacing: 0.3px;
background: var(--df-bg-hover); background: var(--df-bg-card-hover);
color: var(--df-text-dim); color: var(--df-text-dim);
} }
.ai-mention-item-type--project { .ai-mention-item-type--project {
+19 -14
View File
@@ -149,29 +149,34 @@ const imageParts = computed(() => msgImageParts(props.msg))
align-items: flex-start; align-items: flex-start;
flex-direction: row-reverse; flex-direction: row-reverse;
} }
/* 头像通用 */ /* 头像通用 — workbench .av 范式:24px 圆角方块渐变,用户 warning→danger 系,
字色随主题底色反转保证渐变上对比;内部字符 9px 700(当前为 svg 图标,字号字重为字符兜底) */
.ai-msg-avatar { .ai-msg-avatar {
width: 22px; width: 24px;
height: 22px; height: 24px;
border-radius: 50%; border-radius: var(--df-radius);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
font-size: 9px;
font-weight: 700;
} }
.ai-msg-avatar--user { .ai-msg-avatar--user {
background: var(--df-info-bg, rgba(100,181,246,0.15)); background: linear-gradient(135deg, color-mix(in srgb, var(--df-warning) 70%, var(--df-danger)), var(--df-danger));
color: var(--df-info, #64b5f6); color: var(--df-bg);
} }
/* 气泡通用 */ /* 气泡通用 — workbench 聊天范式:用户气泡=bg-card 底+0.5px 边框+9px 圆角。
9px 圆角与 9px 13px 内距均为 u-agent workbench 移植值,勿规整 */
.ai-msg-bubble { .ai-msg-bubble {
border-radius: 10px; border-radius: 9px;
padding: 8px 12px; padding: 9px 13px;
word-break: break-word; word-break: break-word;
max-width: 80%; max-width: 80%;
} }
.ai-msg-bubble--user { .ai-msg-bubble--user {
background: var(--df-info-bg, rgba(100,181,246,0.1)); background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
color: var(--df-text); color: var(--df-text);
} }
/* chip */ /* chip */
@@ -181,13 +186,13 @@ const imageParts = computed(() => msgImageParts(props.msg))
border-radius: 4px; border-radius: 4px;
font-size: 11px; font-size: 11px;
margin: 0 2px; margin: 0 2px;
background: var(--df-accent-bg, rgba(108,99,255,0.1)); background: var(--df-accent-bg);
color: var(--df-accent, #6c63ff); color: var(--df-accent);
} }
/* 技能调用徽章(问题3):与 mention chip 同视觉家族,亮一档紫色底 + ⚡ 前缀区分 */ /* 技能调用徽章(问题3):与 mention chip 同视觉家族,亮一档紫色底 + ⚡ 前缀区分 */
.ai-msg-chip--skill { .ai-msg-chip--skill {
background: var(--df-accent-bg, rgba(108,99,255,0.18)); background: var(--df-accent-bg);
color: var(--df-accent, #6c63ff); color: var(--df-accent);
font-weight: 600; font-weight: 600;
} }
/* 图片 */ /* 图片 */
+229 -182
View File
@@ -45,7 +45,7 @@ const emit = defineEmits<{
}>() }>()
const store = useAiStore() const store = useAiStore()
const { t } = useI18n() const { t, locale } = useI18n()
// ── 消息任务关联(有的消息有,有的没):从 toolCalls[].args 派生 ── // ── 消息任务关联(有的消息有,有的没):从 toolCalls[].args 派生 ──
// 数据源:AiToolCallInfo.args 已携带工具入参,任务工具的 task_id 就在其中 // 数据源:AiToolCallInfo.args 已携带工具入参,任务工具的 task_id 就在其中
@@ -676,23 +676,28 @@ const _decoCache = new Map<string, string>()
const DECO_CACHE_LIMIT = 800 const DECO_CACHE_LIMIT = 800
function decorateCodeHtml(raw: string): string { function decorateCodeHtml(raw: string): string {
if (!raw || !raw.includes('<pre><code')) return raw if (!raw || !raw.includes('<pre><code')) return raw
const hit = _decoCache.get(raw) // F1-1:复制按钮文案走 i18n(v-html 渲染层拿不到 $t,经参数注入);locale 入缓存键,
// 否则切语言后命中旧缓存,按钮文案停留在旧语言。locale 读取同时建立响应式依赖,
// 切语言时调用方(computed/渲染函数)重算拿到新语言装饰结果。
const cacheKey = locale.value + '\u0000' + raw
const hit = _decoCache.get(cacheKey)
if (hit !== undefined) { if (hit !== undefined) {
// LRU:命中即置为最近使用(delete+set 移到 Map 末尾),淘汰时优先保留热项 // LRU:命中即置为最近使用(delete+set 移到 Map 末尾),淘汰时优先保留热项
_decoCache.delete(raw) _decoCache.delete(cacheKey)
_decoCache.set(raw, hit) _decoCache.set(cacheKey, hit)
return hit return hit
} }
// marked 的 code renderer 统一输出 <pre><code class="hljs ...">…</code></pre> // marked 的 code renderer 统一输出 <pre><code class="hljs ...">…</code></pre>
//(含缩进码块),代码内容已 HTML 转义,正则非贪婪到首个闭合标签不会越界。 //(含缩进码块),代码内容已 HTML 转义,正则非贪婪到首个闭合标签不会越界。
// R4 语言角标:code class 形如 hljs language-ts,捕获 language-xxx 输出到 .md-code-block // R4 语言角标:code class 形如 hljs language-ts,捕获 language-xxx 输出到 .md-code-block
// 的 data-lang,CSS ::after 显示;无语言(裸块/auto 猜测无 language- 类)不显角标。 // 的 data-lang,CSS ::after 显示;无语言(裸块/auto 猜测无 language- 类)不显角标。
const copyLabel = t('aiChat.copyCode')
const out = raw.replace( const out = raw.replace(
/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g, /<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g,
(_m: string, attrs: string, body: string) => { (_m: string, attrs: string, body: string) => {
const lang = /language-([a-z0-9+#.-]+)/i.exec(attrs)?.[1] ?? '' const lang = /language-([a-z0-9+#.-]+)/i.exec(attrs)?.[1] ?? ''
const langAttr = lang ? ` data-lang="${lang}"` : '' const langAttr = lang ? ` data-lang="${lang}"` : ''
return `<div class="md-code-block"${langAttr}><button type="button" class="md-code-copy">复制</button><pre><code${attrs}>${body}</code></pre></div>` return `<div class="md-code-block"${langAttr}><button type="button" class="md-code-copy">${copyLabel}</button><pre><code${attrs}>${body}</code></pre></div>`
}, },
) )
// 有界 LRU 淘汰:超限删最久未用项(Map 首项)后插入,替代原整体 clear // 有界 LRU 淘汰:超限删最久未用项(Map 首项)后插入,替代原整体 clear
@@ -700,7 +705,7 @@ function decorateCodeHtml(raw: string): string {
const oldest = _decoCache.keys().next().value const oldest = _decoCache.keys().next().value
if (oldest !== undefined) _decoCache.delete(oldest) if (oldest !== undefined) _decoCache.delete(oldest)
} }
_decoCache.set(raw, out) _decoCache.set(cacheKey, out)
return out return out
} }
/** renderMd 包装:渲染 + 代码块装饰(双层 memo:renderMd 的 _mdCache + 本缓存,未变即零成本) */ /** renderMd 包装:渲染 + 代码块装饰(双层 memo:renderMd 的 _mdCache + 本缓存,未变即零成本) */
@@ -744,7 +749,7 @@ async function onMdCodeCopyClick(e: MouseEvent): Promise<void> {
} }
// 成功态短暂反馈(直接改按钮 DOM;下次 v-html 重渲染自然还原,无需状态机) // 成功态短暂反馈(直接改按钮 DOM;下次 v-html 重渲染自然还原,无需状态机)
const prev = btn.textContent const prev = btn.textContent
btn.textContent = ok ? '已复制' : '复制失败' btn.textContent = ok ? t('aiChat.copied') : t('aiChat.copyFailed')
btn.classList.add(ok ? 'is-ok' : 'is-err') btn.classList.add(ok ? 'is-ok' : 'is-err')
setTimeout(() => { setTimeout(() => {
btn.textContent = prev btn.textContent = prev
@@ -924,13 +929,12 @@ function collapsedPreviewHtml(msg: AiMessage): string {
// R3:折叠条带量 —— 折叠态按钮显示「已折叠 N 字符 / 约 M tokens,点击展开」。 // R3:折叠条带量 —— 折叠态按钮显示「已折叠 N 字符 / 约 M tokens,点击展开」。
// 折叠量按 M1 的标定估算(400px 视口 ≈ 阈值字符量,见 LONG_MSG_THRESHOLD 注), // 折叠量按 M1 的标定估算(400px 视口 ≈ 阈值字符量,见 LONG_MSG_THRESHOLD 注),
// 即 content.length - 阈值;token 为粗估(中英混合 1 token ≈ 2.5 字符),只供量级感知。 // 即 content.length - 阈值;token 为粗估(中英混合 1 token ≈ 2.5 字符),只供量级感知。
// 文案硬编码中文:i18n locale 文件本批锁定(他代理并行追加,不新增键), // 文案走 i18n(F1-1 补键);展开态仍复用既有 aiChat.collapse 键。
// 展开态仍复用既有 aiChat.collapse 键。
const CHAR_PER_TOKEN_EST = 2.5 const CHAR_PER_TOKEN_EST = 2.5
function collapsedLongLabel(msg: AiMessage): string { function collapsedLongLabel(msg: AiMessage): string {
const folded = Math.max(0, (msg.content?.length ?? 0) - LONG_MSG_THRESHOLD) const folded = Math.max(0, (msg.content?.length ?? 0) - LONG_MSG_THRESHOLD)
const tokens = Math.max(1, Math.round(folded / CHAR_PER_TOKEN_EST)) const tokens = Math.max(1, Math.round(folded / CHAR_PER_TOKEN_EST))
return `已折叠 ${folded} 字符 / 约 ${tokens} tokens,点击展开` return t('aiChat.foldedSummary', { chars: folded, tokens })
} }
/** /**
@@ -971,6 +975,11 @@ function thinkPlainText(m: AiMessage): string {
const src = m.reasoning_content || '' const src = m.reasoning_content || ''
return src.replace(/\s+/g, ' ').trim() return src.replace(/\s+/g, ' ').trim()
} }
/** 压缩摘要 pill 单行:纯文本空白归一截断(961 轮次记录形态,title 承载全文) */
function compactSnippet(content: string | undefined): string {
const txt = (content || '').replace(/\s+/g, ' ').trim()
return txt.length > 80 ? txt.slice(0, 80) + '…' : txt
}
/** pill 单行文本:纯文本截断 + 空轮回退文案(title 承载全文,此处只管单行) */ /** pill 单行文本:纯文本截断 + 空轮回退文案(title 承载全文,此处只管单行) */
function thinkSnippet(m: AiMessage): string { function thinkSnippet(m: AiMessage): string {
const txt = thinkPlainText(m) const txt = thinkPlainText(m)
@@ -1012,7 +1021,7 @@ function traceHoverTitle(startTs: number, endTs: number): string {
/** /**
* 执行中实时步骤摘要:「第 N 轮 · ✓ N 次调用完成 · 失败 N · 正在 {工具} · ⏳ N 项待审批」。 * 执行中实时步骤摘要:「第 N 轮 · ✓ N 次调用完成 · 失败 N · 正在 {工具} · ⏳ N 项待审批」。
* 轮次号与轨迹卡同源(flushBucket 的 traceCount,执行中=将成为的轨迹序号); * 轮次号与轨迹卡同源(flushBucket 的 currentRound,发起计数口径见 renderItems);
* 失败/拒绝/待审批段仅非零时显示;全部无数据回退「过程记录」。 * 失败/拒绝/待审批段仅非零时显示;全部无数据回退「过程记录」。
*/ */
function stepText(s: TurnCallStats, round: number): string { function stepText(s: TurnCallStats, round: number): string {
@@ -1021,7 +1030,10 @@ function stepText(s: TurnCallStats, round: number): string {
if (done > 0) parts.push(t('ai.trace.stepDone', { n: done })) if (done > 0) parts.push(t('ai.trace.stepDone', { n: done }))
if (s.fail > 0) parts.push(t('ai.trace.fail', { n: s.fail })) if (s.fail > 0) parts.push(t('ai.trace.fail', { n: s.fail }))
if (s.rejected > 0) parts.push(t('ai.trace.rejected', { n: s.rejected })) if (s.rejected > 0) parts.push(t('ai.trace.rejected', { n: s.rejected }))
// 即时摘要(U2 同口径):工具执行中显「正在 {工具}」;工具间隙(LLM 写正文/思考)显
// 「生成回复中」——折叠卡头任何时刻都有「现在在干什么」,消除无工具期的静默观感
if (s.running > 0 && s.runningName) parts.push(t('ai.trace.stepRunning', { name: s.runningName })) if (s.running > 0 && s.runningName) parts.push(t('ai.trace.stepRunning', { name: s.runningName }))
else parts.push(t('ai.trace.generating'))
if (s.pending > 0) parts.push(t('ai.trace.stepPending', { n: s.pending })) if (s.pending > 0) parts.push(t('ai.trace.stepPending', { n: s.pending }))
return parts.join(' · ') || t('ai.trace.processOnly') return parts.join(' · ') || t('ai.trace.processOnly')
} }
@@ -1030,9 +1042,9 @@ type RenderItem =
| { kind: 'msg'; key: string; msg: AiMessage; final?: boolean } | { kind: 'msg'; key: string; msg: AiMessage; final?: boolean }
| { kind: 'sep'; key: string; seg: MessageSegment & { key: string } } | { kind: 'sep'; key: string; seg: MessageSegment & { key: string } }
// A 过程压缩:完成轮次的执行轨迹卡(traceMsgs 为折叠在内的过程消息)与执行中的实时步骤摘要; // A 过程压缩:完成轮次的执行轨迹卡(traceMsgs 为折叠在内的过程消息)与执行中的实时步骤摘要;
// round=轨迹序号(卡头「第 N 轮」,与执行中步骤行、轮次导航条同一计数来源) // round=会话轮号(卡头「第 N 轮」,961③ 发起计数口径,导航条/步骤行同源)
| { kind: 'trace'; key: string; round: number; stats: TurnCallStats; startTs: number; endTs: number; tokens: number; traceMsgs: AiMessage[]; expanded: boolean } | { kind: 'trace'; key: string; round: number; stats: TurnCallStats; startTs: number; endTs: number; tokens: number; traceMsgs: AiMessage[]; expanded: boolean }
| { kind: 'step'; key: string; round: number; stats: TurnCallStats } | { kind: 'step'; key: string; round: number; stats: TurnCallStats; traceMsgs: AiMessage[]; expanded: boolean }
// 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),逐卡一项 // S1 子代理完成卡(AiAgentFinished 事件):非消息数据(per-conv 存 aiShared),逐卡一项
@@ -1121,7 +1133,12 @@ function genBucketItems(
pushSep(msgs[0].timestamp) pushSep(msgs[0].timestamp)
// 闪烁修复(2026-10-09):step 项 key 与下方完成态 trace 项对齐(同为 trace-{首条id}), // 闪烁修复(2026-10-09):step 项 key 与下方完成态 trace 项对齐(同为 trace-{首条id}),
// 使「执行中 step → 完成态 trace」切换时 Vue 复用同一 DOM 节点(key 稳定),不销毁重建。 // 使「执行中 step → 完成态 trace」切换时 Vue 复用同一 DOM 节点(key 稳定),不销毁重建。
items.push({ kind: 'step', key: 'trace-' + prev[0].id, round, stats: collectTurnStats(msgs) }) // 961①: step 卡可展开——key 与完成态 trace 同构,复用 expandedTraceIds,
// 执行中展开看实时过程,完成后切换 trace 态展开不丢
items.push({
kind: 'step', key: 'trace-' + prev[0].id, round, stats: collectTurnStats(msgs),
traceMsgs: prev, expanded: expandedTraceIds.value.has('trace-' + prev[0].id),
})
} else { } else {
pushSep(cur.timestamp) pushSep(cur.timestamp)
} }
@@ -1132,7 +1149,8 @@ function genBucketItems(
let fi = -1 let fi = -1
for (let i = msgs.length - 1; i >= 0; i--) { for (let i = msgs.length - 1; i >= 0; i--) {
const c = msgs[i].content const c = msgs[i].content
if (c && c.trim() && !isToolResultJson(c)) { fi = i; break } // compressed(压缩摘要)不竞选最终答复:摘要是过程记录,轨迹卡内 pill 呈现
if (c && c.trim() && !isToolResultJson(c) && msgs[i].status !== 'compressed') { fi = i; break }
} }
const finalMsg = fi >= 0 ? msgs[fi] : msgs[msgs.length - 1] const finalMsg = fi >= 0 ? msgs[fi] : msgs[msgs.length - 1]
// 过程消息 = finalMsg 之外全部(含其后尾气泡):异常收尾(错误/停止/流中断)时末轮气泡 // 过程消息 = finalMsg 之外全部(含其后尾气泡):异常收尾(错误/停止/流中断)时末轮气泡
@@ -1193,9 +1211,12 @@ const renderItems = computed<RenderItem[]>(() => {
const items: RenderItem[] = [] const items: RenderItem[] = []
let bucket: AiMessage[] = [] let bucket: AiMessage[] = []
let lastTs = 0 let lastTs = 0
// 轨迹序号计数:轨迹卡头「第 N 轮」与执行中步骤行同一来源(computed 重算从 0 起, // 961②③ 轮次号=「用户发起」计数(单一真值来源,数据派生天然稳定):
// 计数即轨迹卡出现顺序,与轮次导航条序号一致) // 发起=user 且非补充注入(source.channel!='inject',老数据无 source 视为发起);
let traceCount = 0 // 补充注入/压缩摘要/错误气泡均归属当前轮不递增——修复旧口径两漂移根源:
// ①旧 traceCount=轨迹卡出现顺序,单消息桶不计数致编号随桶形态漂移;
// ②压缩摘要切桶即开新号(用户表象「压缩后就是新轮次」)。
let currentRound = 0
const pushSep = (ts: number) => { const pushSep = (ts: number) => {
if (needsTimeSep(ts, lastTs)) { if (needsTimeSep(ts, lastTs)) {
@@ -1209,10 +1230,15 @@ const renderItems = computed<RenderItem[]>(() => {
bucket = [] bucket = []
if (msgs.length === 0) return if (msgs.length === 0) return
const lastStoreId = store.state.messages[store.state.messages.length - 1]?.id const lastStoreId = store.state.messages[store.state.messages.length - 1]?.id
const executing = isViewingGenerating.value && msgs[msgs.length - 1].id === lastStoreId // F1-3(10-17 走查):执行中判定补充「桶内存在 running/pending_approval 工具」。
// 轨迹/步骤项产生时轮号自增(仅 msgs.length>1 会产出 step/trace;轮号计入桶签名, // 仅靠「桶末=store 末条」时,生成中注入补充消息会在 store 末尾 push 本地 user 气泡,
// 前序桶变化致轮号位移时该桶正确重算) // 判定瞬间失配 → 执行桶被误判为完成态,降级成默认折叠 trace 卡,正在跑/待审批的
const round = msgs.length > 1 ? ++traceCount : traceCount // 工具卡跌进折叠体不可见(下一轮 assistant push 才自愈)。桶内工具仍活跃即说明该轮
// 未收敛,保持 step 形态内联可见;工具全部收敛后回到末条比对,历史桶不受影响(BUG-261001)。
const hasLiveTool = msgs.some(m => (m.toolCalls ?? []).some(tc => tc.status === 'running' || tc.status === 'pending_approval'))
const executing = isViewingGenerating.value && (msgs[msgs.length - 1].id === lastStoreId || hasLiveTool)
// 961③: 轮号=当前发起计数(与轨迹卡是否产出解耦,单消息桶不吞号)
const round = currentRound
const built = buildBucket(msgs, round, executing, lastTs) const built = buildBucket(msgs, round, executing, lastTs)
for (const it of built.items) items.push(it) for (const it of built.items) items.push(it)
lastTs = built.endTs lastTs = built.endTs
@@ -1232,8 +1258,18 @@ const renderItems = computed<RenderItem[]>(() => {
continue continue
} }
const m = seg.msg const m = seg.msg
// assistant 非错误非压缩进轮次桶(compressed 用户诉求不折叠,原样展开) // 961②: 发起边界=user 且非补充注入——flush 当前轮并递增轮号;补充注入归属
if (m.role === 'assistant' && !m.isError && m.status !== 'compressed') { // 当前轮原样渲染不递增。压缩摘要/错误气泡也不递增(压缩后仍是同轮过程)
if (m.role === 'user') {
flushBucket()
if (m.source?.channel !== 'inject') currentRound++
pushSep(m.timestamp)
items.push({ kind: 'msg', key: 'm-' + m.id, msg: m })
continue
}
// assistant 非错误进轮次桶——compressed(压缩摘要)也进桶(用户令 10-11:压缩摘要
// 的动作作为轮次记录折叠,随轨迹卡收起;旧口径「原样展开」作废,961② 已定其归属当前轮)
if (m.role === 'assistant' && !m.isError) {
bucket.push(m) bucket.push(m)
} else { } else {
flushBucket() flushBucket()
@@ -1540,7 +1576,7 @@ watch(() => store.state.activeConversationId, (newId, oldId) => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
cancelPendingRaf() cancelPendingRaf()
document.removeEventListener('click', closeTokenPopoverOnOutsideClick) document.removeEventListener('click', closeTokenPopoverOnOutsideClick)
document.removeEventListener('keydown', closeAgentTraceOnEsc) document.removeEventListener('keydown', closeOverlaysOnEsc)
}) })
// token 详情面板:点击外部关闭(token 区内的 @click.stop 已阻冒泡,故文档级点击必为外部) // token 详情面板:点击外部关闭(token 区内的 @click.stop 已阻冒泡,故文档级点击必为外部)
@@ -1549,11 +1585,14 @@ function closeTokenPopoverOnOutsideClick(): void {
} }
document.addEventListener('click', closeTokenPopoverOnOutsideClick) document.addEventListener('click', closeTokenPopoverOnOutsideClick)
// 子代理轨迹浮层 Esc 关闭(与遮罩点击/X 同为关闭出口;仅浮层开着时消费,不干扰其他快捷键) // 浮层 Esc 关闭:子代理轨迹浮层 + token 详情 popover(F6-3:后者原先仅外点关闭,
function closeAgentTraceOnEsc(e: KeyboardEvent): void { // 键盘用户开了面板只能点别处)。两者同时开着时逐层关(先浮层后 popover),Esc 不冒泡旁路。
if (e.key === 'Escape' && agentPeekVisible.value) closeAgentTrace() function closeOverlaysOnEsc(e: KeyboardEvent): void {
if (e.key !== 'Escape') return
if (agentPeekVisible.value) closeAgentTrace()
else if (tokenPopoverMsgId.value !== null) tokenPopoverMsgId.value = null
} }
document.addEventListener('keydown', closeAgentTraceOnEsc) document.addEventListener('keydown', closeOverlaysOnEsc)
// Markdown 预热(父原 loadMarkdown 在 onMounted 调,子组件同样幂等——useMarkdown 单例, // Markdown 预热(父原 loadMarkdown 在 onMounted 调,子组件同样幂等——useMarkdown 单例,
// 多次调用安全,确保子组件挂载即预热)。 // 多次调用安全,确保子组件挂载即预热)。
@@ -1633,8 +1672,27 @@ defineExpose({
<!-- 展开态 rail 面板:每轮=思考单行 pill(点击展开全文)+工具卡,图标锚定左轨连线贯通 --> <!-- 展开态 rail 面板:每轮=思考单行 pill(点击展开全文)+工具卡,图标锚定左轨连线贯通 -->
<div v-if="item.expanded" class="ai-trace-body"> <div v-if="item.expanded" class="ai-trace-body">
<div v-for="m in item.traceMsgs" :key="m.id" class="ai-trace-round"> <div v-for="m in item.traceMsgs" :key="m.id" class="ai-trace-round">
<!-- 压缩摘要(用户令 10-11):轮次过程记录形态,pill 单行+点击展开全文,
复用思考 pill 展开态(expandedThinkIds/toggleThink 同交互语义零新状态) -->
<template v-if="m.status === 'compressed'">
<button
class="ai-think-pill"
:title="m.content"
@click="toggleThink(m.id)"
>
<span class="ai-think-ic">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-9-9"/><path d="M21 3l-9 9"/></svg>
</span>
<span class="ai-think-text">{{ $t('ai.trace.compressed') }} · {{ compactSnippet(m.content) }}</span>
</button>
<div
v-if="expandedThinkIds.has(m.id) && m.content?.trim()"
class="ai-think-full ai-md"
v-html="renderMdDecorated(m.content)"
></div>
</template>
<!-- 空内容轮(只剩「过程记录」占位)不渲染思考 pill,降噪;工具卡照常 --> <!-- 空内容轮(只剩「过程记录」占位)不渲染思考 pill,降噪;工具卡照常 -->
<template v-if="hasThinkBody(m)"> <template v-else-if="hasThinkBody(m)">
<button <button
class="ai-think-pill" class="ai-think-pill"
:title="thinkFullText(m)" :title="thinkFullText(m)"
@@ -1676,12 +1734,35 @@ defineExpose({
骨架与完成态 .ai-trace-card 完全同构(卡头结构一致),仅 chevron→脉冲点、右侧→「进行中」, 骨架与完成态 .ai-trace-card 完全同构(卡头结构一致),仅 chevron→脉冲点、右侧→「进行中」,
使 step→trace 切换时只 patch 内部文字,无高度跳变/闪烁 --> 使 step→trace 切换时只 patch 内部文字,无高度跳变/闪烁 -->
<div v-else-if="item.kind === 'step'" class="ai-msg-slot ai-msg-slot--proc"> <div v-else-if="item.kind === 'step'" class="ai-msg-slot ai-msg-slot--proc">
<div class="ai-trace-card ai-trace-card--running"> <div class="ai-trace-card ai-trace-card--running" :class="{ 'ai-trace-card--open': item.expanded }">
<div class="ai-trace-head"> <div
class="ai-trace-head"
role="button"
tabindex="0"
:aria-expanded="item.expanded"
@click="toggleTrace(item.key)"
@keydown.enter.prevent="toggleTrace(item.key)"
@keydown.space.prevent="toggleTrace(item.key)"
>
<span class="ai-trace-chevron" :class="{ 'is-open': item.expanded }">▸</span>
<span class="ai-step-dot"></span> <span class="ai-step-dot"></span>
<span class="ai-trace-title">{{ $t('ai.trace.roundN', { n: item.round }) }}</span> <span class="ai-trace-title">{{ $t('ai.trace.roundN', { n: item.round }) }}</span>
<span class="ai-trace-agg">{{ stepText(item.stats, item.round) }}</span> <span class="ai-trace-agg">{{ stepText(item.stats, item.round) }}</span>
<span class="ai-trace-toggle ai-trace-toggle--running">{{ $t('ai.trace.running') }}</span> <span class="ai-trace-toggle">{{ item.expanded ? $t('ai.trace.collapse') : $t('ai.trace.running') }}</span>
</div>
<!-- 961① 执行中展开:实时过程回看(每条过程消息的工具卡),与完成态 trace 展开同诉求 -->
<div v-if="item.expanded" class="ai-trace-body">
<template v-for="m in item.traceMsgs" :key="m.id">
<div v-if="m.toolCalls && m.toolCalls.length > 0" class="ai-trace-round">
<ToolCardList
:toolCalls="m.toolCalls"
@approve="({ id, approved, decision }) => store.approveToolCall(id, approved, decision)"
@batch-approve="(decision) => store.batchApprove(decision)"
@install-obscura="onInstallObscura"
@pending-out-of-view="onPendingOutOfView"
/>
</div>
</template>
</div> </div>
</div> </div>
</div> </div>
@@ -2073,7 +2154,7 @@ defineExpose({
background: var(--df-bg-card); background: var(--df-bg-card);
color: var(--df-text-dim); color: var(--df-text-dim);
cursor: pointer; cursor: pointer;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25); box-shadow: var(--df-shadow-sm);
z-index: 15; z-index: 15;
transition: color 0.15s var(--df-ease), border-color 0.15s var(--df-ease); transition: color 0.15s var(--df-ease), border-color 0.15s var(--df-ease);
} }
@@ -2093,7 +2174,7 @@ defineExpose({
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: var(--df-warning, #e8a33d); background: var(--df-warning);
animation: ai-btb-pulse 2s ease-in-out infinite; animation: ai-btb-pulse 2s ease-in-out infinite;
} }
@keyframes ai-btb-pulse { @keyframes ai-btb-pulse {
@@ -2104,14 +2185,8 @@ defineExpose({
/* -- Empty State 样式已随 EmptyState.vue 提取 -- */ /* -- Empty State 样式已随 EmptyState.vue 提取 -- */
/* -- Message Layout -- */ /* -- Message Layout -- */
.ai-msg-user { /* 用户消息行 .ai-msg-user 随 MessageItem.vue 抽离(自带 scoped,本组件 scoped 不渗透其内部),
position: relative; 原重复定义删除(2026-10-18 整洁波,死样式验证:模板零引用+scoped 隔离永不命中) */
display: flex;
gap: 8px;
align-items: flex-start;
flex-direction: row-reverse; /* 头像在右:DOM 仍 avatar 在先,视觉反转成【气泡】【头像】靠右 */
margin-top: -3px; /* user 与上方消息间距略收(全局 gap 14 → 视觉 11),更紧凑 */
}
.ai-msg-ai { .ai-msg-ai {
position: relative; position: relative;
display: flex; display: flex;
@@ -2129,7 +2204,8 @@ defineExpose({
gap: 8px; gap: 8px;
} }
/* -- Avatar -- */ /* -- Avatar -- workbench .av 范式:24px 圆角方块渐变(用户 warning→danger 系 / AI info→accent 系),
字色随主题底色反转保证渐变上对比;内部字符 9px 700(当前为 svg 图标,字号字重为字符兜底) */
.ai-msg-avatar { .ai-msg-avatar {
width: 24px; width: 24px;
height: 24px; height: 24px;
@@ -2138,41 +2214,44 @@ defineExpose({
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
font-size: 9px;
font-weight: 700;
} }
.ai-msg-avatar--user { /* .ai-msg-avatar--user 随 MessageItem.vue 抽离(自带 scoped) */
background: var(--df-info-bg);
color: var(--df-info);
}
.ai-msg-avatar--ai { .ai-msg-avatar--ai {
background: var(--df-accent-bg); background: linear-gradient(135deg, color-mix(in srgb, var(--df-info) 70%, var(--df-accent)), var(--df-accent));
color: var(--df-accent); color: var(--df-bg);
} }
/* -- Bubble -- */ /* -- Bubble -- workbench 聊天范式:用户=bg-card 底+0.5px 边框+9px 圆角;AI=无气泡(透明底直接排版)。
9px 圆角与 9px 13px 内距均为 u-agent workbench 移植值,勿规整 */
.ai-msg-bubble { .ai-msg-bubble {
position: relative; position: relative;
font-size: 13px; font-size: 13px;
line-height: 1.6; line-height: 1.6;
max-width: var(--df-msg-max-width); max-width: var(--df-msg-max-width);
padding: 8px 12px; padding: 9px 13px;
border-radius: var(--df-radius-lg); border-radius: 9px;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.ai-msg-bubble--user { /* 用户气泡 .ai-msg-bubble--user 随 MessageItem.vue 抽离(自带 scoped,原「同范式兜底」
background: var(--df-accent); 实为 scoped 隔离永不命中的死规则,2026-10-18 整洁波删除) */
color: #fff;
border-bottom-right-radius: var(--df-radius-sm);
}
.ai-msg-bubble--ai { .ai-msg-bubble--ai {
background: var(--df-bg-card); background: transparent;
border: none;
padding: 0;
color: var(--df-text); color: var(--df-text);
border: 0.5px solid var(--df-border);
border-bottom-left-radius: var(--df-radius-sm);
} }
/* 错误气泡对齐 workbench .blocked-card 范式:30% 红边框+6% 红底+12px 字;
AI 无气泡后本卡自含 padding/边框/圆角(排在 --ai 后覆盖) */
.ai-msg-bubble--error { .ai-msg-bubble--error {
background: var(--df-danger-bg); padding: 10px 13px;
border: 1px solid color-mix(in srgb, var(--df-danger) 30%, transparent);
border-radius: 9px;
background: color-mix(in srgb, var(--df-danger) 6%, transparent);
color: var(--df-danger); color: var(--df-danger);
border-color: rgba(240,101,101,0.3); font-size: 12px;
margin-top: 6px;
} }
/* 修正 的 display:block 方案——display:block 让 table 失去表格 /* 修正 的 display:block 方案——display:block 让 table 失去表格
布局上下文,tr/td 脱离 table 上下文塌成块级堆叠丢列对齐(用户截图反馈测试报告四列表格错乱)。 布局上下文,tr/td 脱离 table 上下文塌成块级堆叠丢列对齐(用户截图反馈测试报告四列表格错乱)。
@@ -2187,8 +2266,8 @@ defineExpose({
overflow-x: auto; overflow-x: auto;
} }
/* M1:超长 assistant 文本折叠——max-height 截断 + 底部渐变遮罩 + 展开按钮。 /* M1:超长 assistant 文本折叠——max-height 截断 + 底部渐变遮罩 + 展开按钮。
背景 var(--df-bg-card) 对齐 .ai-msg-bubble--ai(L876):渐变淡出到气泡背景, AI 气泡已透明(workbench 范式):渐变淡出到页面背景 --df-bg,视觉上内容「沉入」页面
视觉上内容「沉入」气泡而非被硬切。overflow:hidden 覆盖 .ai-md 的 overflow-x:auto。 */ 而非被硬切。overflow:hidden 覆盖 .ai-md 的 overflow-x:auto。 */
.ai-msg-bubble--collapsed { .ai-msg-bubble--collapsed {
max-height: 400px; max-height: 400px;
overflow: hidden; overflow: hidden;
@@ -2201,7 +2280,7 @@ defineExpose({
left: 0; left: 0;
right: 0; right: 0;
height: 48px; height: 48px;
background: linear-gradient(transparent, var(--df-bg-card)); background: linear-gradient(transparent, var(--df-bg));
pointer-events: none; /* 让点击穿透到下方展开按钮 */ pointer-events: none; /* 让点击穿透到下方展开按钮 */
} }
/* 展开按钮:气泡底部居中,置于渐变遮罩之上 */ /* 展开按钮:气泡底部居中,置于渐变遮罩之上 */
@@ -2225,10 +2304,12 @@ defineExpose({
background: var(--df-bg-card-hover); background: var(--df-bg-card-hover);
} }
/* ── JSON 工具结果折叠防御(对齐 miniapp .msg-toolresult 视觉:灰 #b0b0b0 / 11px / /* ── JSON 工具结果折叠防御(对齐 miniapp .msg-toolresult 视觉:弱化灰字 / 11px /
等宽 / max-height 80px 裁剪):assistant content 为纯 JSON 工具结果时弱化为折叠块, 等宽 / max-height 80px 裁剪):assistant content 为纯 JSON 工具结果时弱化为折叠块,
不渲染 markdown 大气泡刷屏。默认 max-height 80px + overflow-y:auto 可滚动, 不渲染 markdown 大气泡刷屏。默认 max-height 80px + overflow-y:auto 可滚动,
点击整块切换 --expanded(移除高度限制)展开/收起完整内容。 ── */ 点击整块切换 --expanded(移除高度限制)展开/收起完整内容。
F1-2:灰字由硬编码 #b0b0b0 改 --df-text-dim——硬编码值在浅色主题白底上
对比度约 2.6:1 不可读;token 双主题各有值,弱化语义不变。 ── */
.ai-msg-json { .ai-msg-json {
max-height: 80px; max-height: 80px;
overflow-y: auto; overflow-y: auto;
@@ -2239,7 +2320,7 @@ defineExpose({
font-family: var(--df-font-mono); font-family: var(--df-font-mono);
font-size: 11px; font-size: 11px;
line-height: 1.5; line-height: 1.5;
color: #b0b0b0; color: var(--df-text-dim);
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
} }
@@ -2257,27 +2338,8 @@ defineExpose({
border-color: var(--df-accent); border-color: var(--df-accent);
} }
/* ── Input Augmentation: 用户气泡内 mention chip(圆角徽标,复用 ChatInput .ai-skill-chip-name 风格) ── /* ── mention chip(.ai-msg-chip 族)随 MessageItem.vue 抽离(自带 scoped,含 --skill 变体;
用户气泡背景 = --df-accent(主题强调色,如深色蓝/紫),chip 用半透明白底+深字 保证对比度; 此处原 kind 变体因 scoped 隔离永不命中,2026-10-18 整洁波删除) ── */
按 kind 区分颜色变体(project/task/idea/skill),与 @ mention 浮层 .ai-mention-item-type--* 视觉呼应。 */
.ai-msg-chip {
display: inline-block;
padding: 1px 6px;
margin: 0 1px;
border-radius: var(--df-radius);
font-size: 0.9em;
font-weight: 600;
line-height: 1.4;
background: rgba(255, 255, 255, 0.22);
color: #fff;
vertical-align: baseline;
white-space: nowrap;
}
/* kind 颜色变体(用更亮的底色区分,但保持白字可读) */
.ai-msg-chip--project { background: rgba(255, 255, 255, 0.32); }
.ai-msg-chip--task { background: rgba(255, 255, 255, 0.26); }
.ai-msg-chip--idea { background: rgba(255, 255, 255, 0.22); border: 1px solid rgba(255, 255, 255, 0.35); }
.ai-msg-chip--skill { background: rgba(0, 0, 0, 0.18); }
/* ── UX 密度改造:居中时间分隔线(相邻消息间隔 ≥30 分钟或跨天; /* ── UX 密度改造:居中时间分隔线(相邻消息间隔 ≥30 分钟或跨天;
per-block 相对时间漂移无价值已移除,精确时刻在消息容器 title) ── */ per-block 相对时间漂移无价值已移除,精确时刻在消息容器 title) ── */
@@ -2300,10 +2362,13 @@ defineExpose({
background: color-mix(in srgb, var(--df-text-dim) 22%, transparent); background: color-mix(in srgb, var(--df-text-dim) 22%, transparent);
} }
/* ── 最终回答卡:左 2px 品牌色强调边条 + 背景微抬升(与轨迹内思考 pill 明确分层) ── */ /* ── 最终回答卡:左 2px 品牌色强调边条 + 背景微抬升(与轨迹内思考 pill 明确分层);
AI 无气泡后本卡自含 padding 保持卡感(普通 AI 消息仍无气泡) ── */
.ai-msg-bubble--answer { .ai-msg-bubble--answer {
border-left: 2px solid var(--df-accent); border-left: 2px solid var(--df-accent);
background: color-mix(in srgb, var(--df-accent) 4%, var(--df-bg-card)); border-radius: var(--df-radius);
background: color-mix(in srgb, var(--df-accent) 4%, transparent);
padding: 8px 12px;
} }
/* ── 轮次导航条(仅多轮长会话 ≥3 张轨迹卡出现,sticky 置顶,只做跳转) ── */ /* ── 轮次导航条(仅多轮长会话 ≥3 张轨迹卡出现,sticky 置顶,只做跳转) ── */
@@ -2347,8 +2412,8 @@ defineExpose({
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 22px; width: var(--df-h-icon-btn);
height: 22px; height: var(--df-h-icon-btn);
border: none; border: none;
border-radius: var(--df-radius-sm); border-radius: var(--df-radius-sm);
background: rgba(0, 0, 0, 0.35); background: rgba(0, 0, 0, 0.35);
@@ -2358,14 +2423,13 @@ defineExpose({
transition: opacity 0.2s; transition: opacity 0.2s;
flex-shrink: 0; flex-shrink: 0;
} }
.ai-msg-user:hover .ai-copy-btn,
.ai-msg-ai:hover .ai-copy-btn--ai { .ai-msg-ai:hover .ai-copy-btn--ai {
opacity: 1; opacity: 1;
} }
.ai-copy-btn:hover { .ai-copy-btn:hover {
background: rgba(0, 0, 0, 0.55); background: rgba(0, 0, 0, 0.55);
} }
/* 图片复制按钮排在文本复制按钮左侧(right 让位 22px 按钮 + 4px 间距) */ /* 图片复制按钮排在文本复制按钮左侧(right 让位 26px 按钮 + 4px 间距) */
.ai-copy-btn--image { .ai-copy-btn--image {
right: 30px; right: 30px;
} }
@@ -2375,6 +2439,23 @@ defineExpose({
.ai-md :deep(.md-code-block) { .ai-md :deep(.md-code-block) {
position: relative; position: relative;
} }
/* workbench .code 范式:比 --df-bg 更深一档的底 + 边框 + md 圆角 + mono 11px,
覆盖全局 ai-md.css 的 .ai-md pre(现有 hljs github-dark 语法高亮色不动)。
浅色主题降为 8% 黑:45% 全量在浅底上会压垮正文/高亮对比,违背双主题成立铁律。 */
.ai-md :deep(pre) {
background: color-mix(in srgb, var(--df-bg), #000 45%);
border: 1px solid var(--df-border);
border-radius: var(--df-radius-md);
font-family: var(--df-font-mono);
font-size: 11px;
}
.ai-md :deep(pre code) {
font-size: 11px;
line-height: 1.6;
}
[data-theme='light'] .ai-md :deep(pre) {
background: color-mix(in srgb, var(--df-bg), #000 8%);
}
.ai-md :deep(.md-code-copy) { .ai-md :deep(.md-code-copy) {
position: absolute; position: absolute;
top: 6px; top: 6px;
@@ -2400,8 +2481,8 @@ defineExpose({
} }
.ai-md :deep(.md-code-copy.is-ok) { .ai-md :deep(.md-code-copy.is-ok) {
opacity: 1; opacity: 1;
color: var(--df-success, #4caf50); color: var(--df-success);
border-color: var(--df-success, #4caf50); border-color: var(--df-success);
} }
.ai-md :deep(.md-code-copy.is-err) { .ai-md :deep(.md-code-copy.is-err) {
opacity: 1; opacity: 1;
@@ -2451,7 +2532,7 @@ defineExpose({
/* Fatal 错误(不可重试)的提示文案——替代隐藏的「重试」按钮, /* Fatal 错误(不可重试)的提示文案——替代隐藏的「重试」按钮,
说明为何无重试入口,引导走「去设置」。 */ 说明为何无重试入口,引导走「去设置」。 */
.ai-msg-not-retryable { .ai-msg-not-retryable {
font-size: 10.5px; font-size: 10px;
color: var(--df-text-dim); color: var(--df-text-dim);
font-style: italic; font-style: italic;
align-self: center; align-self: center;
@@ -2460,12 +2541,12 @@ defineExpose({
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 3px; gap: 3px;
padding: 2px 7px; padding: 2px 6px;
border: none; border: none;
border-radius: var(--df-radius-sm); border-radius: var(--df-radius-sm);
background: transparent; background: transparent;
color: var(--df-text-dim); color: var(--df-text-dim);
font-size: 10.5px; font-size: 10px;
line-height: 1.4; line-height: 1.4;
cursor: pointer; cursor: pointer;
transition: background 0.15s, color 0.15s; transition: background 0.15s, color 0.15s;
@@ -2497,11 +2578,11 @@ defineExpose({
margin-top: 4px; margin-top: 4px;
padding: 3px 8px; padding: 3px 8px;
border-radius: var(--df-radius-sm); border-radius: var(--df-radius-sm);
background: color-mix(in srgb, var(--df-warning, #ffc107) 10%, transparent); background: color-mix(in srgb, var(--df-warning) 10%, transparent);
width: fit-content; width: fit-content;
} }
.ai-msg-truncated-text { .ai-msg-truncated-text {
font-size: 10.5px; font-size: 10px;
line-height: 1.4; line-height: 1.4;
color: var(--df-text-dim); color: var(--df-text-dim);
} }
@@ -2532,7 +2613,7 @@ defineExpose({
padding-top: 6px; padding-top: 6px;
border-top: 0.5px solid var(--df-border); border-top: 0.5px solid var(--df-border);
font-family: var(--df-font-mono); font-family: var(--df-font-mono);
font-size: 9px; font-size: 10px;
color: var(--df-text-dim); color: var(--df-text-dim);
opacity: 0.7; opacity: 0.7;
} }
@@ -2550,8 +2631,9 @@ defineExpose({
cursor: pointer; cursor: pointer;
opacity: 1; opacity: 1;
} }
/* workbench .round-meta 范式:元数据 hover 变 --df-info */
.ai-token-usage-trigger:hover { .ai-token-usage-trigger:hover {
color: var(--df-text); color: var(--df-info);
opacity: 1; opacity: 1;
} }
.ai-token-sep { .ai-token-sep {
@@ -2559,7 +2641,7 @@ defineExpose({
} }
/* G4.3:估算值标注(估算非真实,弱化 dim + warning 色调,不喧宾夺主) */ /* G4.3:估算值标注(估算非真实,弱化 dim + warning 色调,不喧宾夺主) */
.ai-token-est { .ai-token-est {
color: var(--df-warning, #f0c75e); color: var(--df-warning);
opacity: 0.85; opacity: 0.85;
} }
/* 详情面板:复用 TopBar popout 风格(absolute / right 0 / bg-card / border / shadow) */ /* 详情面板:复用 TopBar popout 风格(absolute / right 0 / bg-card / border / shadow) */
@@ -2570,7 +2652,7 @@ defineExpose({
margin-top: 4px; margin-top: 4px;
padding: 8px 10px; padding: 8px 10px;
min-width: 200px; min-width: 200px;
background: var(--df-bg-card, var(--df-bg)); background: var(--df-bg-card);
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: 4px; border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
@@ -2610,23 +2692,7 @@ defineExpose({
font-weight: 500; font-weight: 500;
} }
/* 用户气泡内的图片(多模态消息渲染) */ /* 用户气泡内图片(.ai-msg-images/.ai-msg-image)随 MessageItem.vue 抽离(自带 scoped) */
.ai-msg-images {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.ai-msg-image {
max-width: 200px;
max-height: 200px;
width: auto;
height: auto;
border-radius: var(--df-radius);
object-fit: contain;
display: block;
background: var(--df-bg);
}
/* ═══ 压缩摘要 system 块(compressed 原文已展开,摘要置顶提示发给 AI 的是它) ═══ /* ═══ 压缩摘要 system 块(compressed 原文已展开,摘要置顶提示发给 AI 的是它) ═══
用户诉求:压缩只后端省 token,界面不藏消息。原文 compressed 照常 user/ai 气泡展开, 用户诉求:压缩只后端省 token,界面不藏消息。原文 compressed 照常 user/ai 气泡展开,
@@ -2705,7 +2771,7 @@ defineExpose({
.ai-trace-head { .ai-trace-head {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 7px; gap: 8px;
padding: 6px 10px; padding: 6px 10px;
font-size: 11px; font-size: 11px;
cursor: pointer; cursor: pointer;
@@ -2738,6 +2804,11 @@ defineExpose({
font-size: 10px; font-size: 10px;
color: var(--df-text-dim); color: var(--df-text-dim);
} }
/* workbench .round-meta 范式:轮次/时间/token 小元数据 hover 变 --df-info(折叠交互不动) */
.ai-trace-head:hover .ai-trace-agg,
.ai-trace-head:hover .ai-trace-tokens {
color: var(--df-info);
}
/* 任务关联 chip(仅含任务工具调用的轮次渲染):accent 弱化底 + hover 提亮,点击跳任务 */ /* 任务关联 chip(仅含任务工具调用的轮次渲染):accent 弱化底 + hover 提亮,点击跳任务 */
.ai-trace-task-chip { .ai-trace-task-chip {
flex-shrink: 0; flex-shrink: 0;
@@ -2758,7 +2829,8 @@ defineExpose({
} }
/* ── 轨迹展开态 rail 面板:左轨竖线贯通,思考 pill 与工具卡图标锚定其上(git-graph 式轮次结构)。 /* ── 轨迹展开态 rail 面板:左轨竖线贯通,思考 pill 与工具卡图标锚定其上(git-graph 式轮次结构)。
轨 x=22px 对齐工具卡图标中心(卡内 padding 10px + 24px 图标位一半),pill 灯泡图标同 x。 ── */ 轨 x=22px 对齐工具卡图标中心(卡内 padding 10px + 24px 图标位一半),pill 灯泡图标同 x。
workbench .tr 范式:1px 连线用 --df-border。 ── */
.ai-trace-body { .ai-trace-body {
position: relative; position: relative;
padding: 2px 10px 8px 0; padding: 2px 10px 8px 0;
@@ -2770,7 +2842,7 @@ defineExpose({
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 1px; width: 1px;
background: color-mix(in srgb, var(--df-text-dim) 22%, transparent); background: var(--df-border);
} }
.ai-trace-round { .ai-trace-round {
position: relative; position: relative;
@@ -2784,10 +2856,10 @@ defineExpose({
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 7px; gap: 6px;
width: 100%; width: 100%;
min-height: 24px; min-height: 24px;
padding: 3px 8px 3px 38px; padding: 3px 8px 3px var(--df-icon-gutter);
border: none; border: none;
border-radius: var(--df-radius-sm); border-radius: var(--df-radius-sm);
background: transparent; background: transparent;
@@ -2804,16 +2876,16 @@ defineExpose({
} }
.ai-think-ic { .ai-think-ic {
position: absolute; position: absolute;
left: 10px; left: 11px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
width: 24px; width: 22px;
height: 24px; height: 22px;
border-radius: var(--df-radius-sm); border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: var(--df-accent-bg); background: color-mix(in srgb, var(--df-accent) 15%, transparent);
color: var(--df-accent); color: var(--df-accent);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -2828,14 +2900,14 @@ defineExpose({
flex: 0 0 auto; flex: 0 0 auto;
white-space: nowrap; white-space: nowrap;
font-family: var(--df-font-mono); font-family: var(--df-font-mono);
font-size: 9.5px; font-size: 10px;
color: var(--df-text-dim); color: var(--df-text-dim);
opacity: 0.75; opacity: 0.75;
margin-left: auto; margin-left: auto;
} }
/* 展开的完整思考段落:保持既有卡片样式(气泡卡同底),挂在 pill 下方缩进对齐正文 */ /* 展开的完整思考段落:保持既有卡片样式(气泡卡同底),挂在 pill 下方缩进对齐正文 */
.ai-think-full { .ai-think-full {
margin: 2px 0 6px 38px; margin: 2px 0 6px var(--df-icon-gutter);
padding: 8px 12px; padding: 8px 12px;
background: var(--df-bg-card); background: var(--df-bg-card);
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
@@ -2848,30 +2920,14 @@ defineExpose({
} }
/* 执行中态轨迹卡(骨架与完成态完全同构,仅色/态微差):accent 底 + 脉冲点替代 chevron, /* 执行中态轨迹卡(骨架与完成态完全同构,仅色/态微差):accent 底 + 脉冲点替代 chevron,
step→trace 切换时卡片高度/内边距一致,只 patch 内部文字 → 无跳变 */ step→trace 切换时卡片高度/内边距一致,只 patch 内部文字 → 无跳变。
(.ai-trace-toggle--running/.ai-step-line/.ai-step-text 旧单行摘要骨架已废:step 卡
改用 trace-card 同构骨架,模板零引用,2026-10-18 整洁波删除) */
.ai-trace-card--running { .ai-trace-card--running {
border-style: solid; border-style: solid;
border-color: color-mix(in srgb, var(--df-accent) 28%, transparent); border-color: color-mix(in srgb, var(--df-accent) 28%, transparent);
background: color-mix(in srgb, var(--df-accent) 7%, transparent); background: color-mix(in srgb, var(--df-accent) 7%, transparent);
} }
.ai-trace-toggle--running {
color: var(--df-text-dim);
opacity: 0.9;
}
/* 执行中实时步骤摘要(已完成步骤收进一行,accent 呼吸点对齐状态栏 .ai-agentic-progress) */
.ai-step-line {
display: flex;
align-items: center;
gap: 6px;
max-width: var(--df-msg-max-width);
padding: 4px 10px;
border-radius: var(--df-radius);
background: color-mix(in srgb, var(--df-accent) 7%, transparent);
font-size: 11px;
color: var(--df-text-secondary);
user-select: none;
}
.ai-step-dot { .ai-step-dot {
width: 6px; width: 6px;
height: 6px; height: 6px;
@@ -2880,13 +2936,6 @@ defineExpose({
flex-shrink: 0; flex-shrink: 0;
animation: ai-step-pulse 1.2s ease-in-out infinite; animation: ai-step-pulse 1.2s ease-in-out infinite;
} }
.ai-step-text {
font-family: var(--df-font-mono);
font-size: 10.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@keyframes ai-step-pulse { @keyframes ai-step-pulse {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.3; } 50% { opacity: 0.3; }
@@ -2901,7 +2950,7 @@ defineExpose({
max-width: var(--df-msg-max-width); max-width: var(--df-msg-max-width);
padding: 4px 10px; padding: 4px 10px;
border-radius: var(--df-radius); border-radius: var(--df-radius);
background: color-mix(in srgb, var(--df-warning, #f0c75e) 8%, transparent); background: color-mix(in srgb, var(--df-warning) 8%, transparent);
font-size: 11px; font-size: 11px;
color: var(--df-text-secondary); color: var(--df-text-secondary);
user-select: none; user-select: none;
@@ -2910,13 +2959,13 @@ defineExpose({
width: 5px; width: 5px;
height: 5px; height: 5px;
border-radius: 50%; border-radius: 50%;
background: var(--df-warning, #f0c75e); background: var(--df-warning);
flex-shrink: 0; flex-shrink: 0;
animation: ai-step-pulse 1.2s ease-in-out infinite; animation: ai-step-pulse 1.2s ease-in-out infinite;
} }
.ai-retry-text { .ai-retry-text {
font-family: var(--df-font-mono); font-family: var(--df-font-mono);
font-size: 10.5px; font-size: 10px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -2950,10 +2999,10 @@ defineExpose({
flex-shrink: 0; flex-shrink: 0;
} }
.ai-agent-fin--warn { .ai-agent-fin--warn {
background: color-mix(in srgb, var(--df-warning, #f0c75e) 8%, transparent); background: color-mix(in srgb, var(--df-warning) 8%, transparent);
} }
.ai-agent-fin--warn .ai-agent-fin-dot { .ai-agent-fin--warn .ai-agent-fin-dot {
background: var(--df-warning, #f0c75e); background: var(--df-warning);
} }
.ai-agent-fin--err { .ai-agent-fin--err {
background: color-mix(in srgb, var(--df-danger) 10%, transparent); background: color-mix(in srgb, var(--df-danger) 10%, transparent);
@@ -2976,7 +3025,7 @@ defineExpose({
flex-shrink: 0; flex-shrink: 0;
} }
.ai-agent-fin--warn .ai-agent-fin-status { .ai-agent-fin--warn .ai-agent-fin-status {
color: var(--df-warning, #f0c75e); color: var(--df-warning);
} }
.ai-agent-fin--err .ai-agent-fin-status { .ai-agent-fin--err .ai-agent-fin-status {
color: var(--df-danger); color: var(--df-danger);
@@ -2989,7 +3038,8 @@ defineExpose({
.ai-agent-fin-meta { .ai-agent-fin-meta {
flex-shrink: 0; flex-shrink: 0;
font-family: var(--df-font-mono); font-family: var(--df-font-mono);
font-size: 10.5px; font-size: 10px;
color: var(--df-text-dim);
} }
/* 被拒工具 chip(P2-1):琥珀警示层与 truncated 同色系;限宽防长清单挤掉摘要行 */ /* 被拒工具 chip(P2-1):琥珀警示层与 truncated 同色系;限宽防长清单挤掉摘要行 */
.ai-agent-fin-rejected { .ai-agent-fin-rejected {
@@ -3000,9 +3050,9 @@ defineExpose({
white-space: nowrap; white-space: nowrap;
padding: 0 6px; padding: 0 6px;
border-radius: 999px; border-radius: 999px;
font-size: 10.5px; font-size: 10px;
background: color-mix(in srgb, var(--df-warning, #f0c75e) 16%, transparent); background: color-mix(in srgb, var(--df-warning) 16%, transparent);
color: var(--df-warning, #f0c75e); color: var(--df-warning);
} }
/* 无轨迹(启动失败)卡:禁点视觉,hover 不提亮(cursor 维持默认) */ /* 无轨迹(启动失败)卡:禁点视觉,hover 不提亮(cursor 维持默认) */
.ai-agent-fin--notrace { .ai-agent-fin--notrace {
@@ -3012,7 +3062,7 @@ defineExpose({
.ai-agent-fin--notrace:hover { .ai-agent-fin--notrace:hover {
background: color-mix(in srgb, var(--df-text-secondary) 6%, transparent); background: color-mix(in srgb, var(--df-text-secondary) 6%, transparent);
} }
/* peek 拉取中的加载微标(挂在具体卡右端) */ /* peek 拉取中的加载微标(挂在具体卡右端);旋转动画消费 global.css @keyframes df-spin */
.ai-agent-fin-spin { .ai-agent-fin-spin {
flex-shrink: 0; flex-shrink: 0;
width: 10px; width: 10px;
@@ -3020,10 +3070,7 @@ defineExpose({
border: 1.5px solid color-mix(in srgb, var(--df-text-secondary) 35%, transparent); border: 1.5px solid color-mix(in srgb, var(--df-text-secondary) 35%, transparent);
border-top-color: var(--df-text-secondary); border-top-color: var(--df-text-secondary);
border-radius: 50%; border-radius: 50%;
animation: ai-agent-fin-spin 0.8s linear infinite; animation: df-spin 0.8s linear infinite;
}
@keyframes ai-agent-fin-spin {
to { transform: rotate(360deg); }
} }
/* ═══ S3 子代理轨迹浮层(只读,关即弃) ═══ */ /* ═══ S3 子代理轨迹浮层(只读,关即弃) ═══ */
@@ -3044,7 +3091,7 @@ defineExpose({
background: var(--df-bg-raised); background: var(--df-bg-raised);
border: 0.5px solid var(--df-border-strong); border: 0.5px solid var(--df-border-strong);
border-radius: var(--df-radius-lg); border-radius: var(--df-radius-lg);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4); box-shadow: var(--df-shadow-md);
overflow: hidden; overflow: hidden;
} }
.ai-agent-peek-head { .ai-agent-peek-head {
@@ -3056,7 +3103,7 @@ defineExpose({
flex-shrink: 0; flex-shrink: 0;
} }
.ai-agent-peek-title { .ai-agent-peek-title {
font-size: 12.5px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: var(--df-text); color: var(--df-text);
} }
@@ -3101,7 +3148,7 @@ defineExpose({
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; gap: 6px;
font-size: 10.5px; font-size: 10px;
font-weight: 600; font-weight: 600;
color: var(--df-text-secondary); color: var(--df-text-secondary);
margin-bottom: 4px; margin-bottom: 4px;
@@ -3125,7 +3172,7 @@ defineExpose({
.ai-agent-peek-msg--tool .ai-agent-peek-text, .ai-agent-peek-msg--tool .ai-agent-peek-text,
.ai-agent-peek-msg--system .ai-agent-peek-text { .ai-agent-peek-msg--system .ai-agent-peek-text {
color: var(--df-text-secondary); color: var(--df-text-secondary);
font-size: 11.5px; font-size: 11px;
} }
/* 浮层头身份标识行:置于标题下方,窄容器内单行省略 */ /* 浮层头身份标识行:置于标题下方,窄容器内单行省略 */
.ai-agent-peek-head-text { .ai-agent-peek-head-text {
@@ -3146,7 +3193,7 @@ defineExpose({
.ai-agent-peek-agent { .ai-agent-peek-agent {
flex-shrink: 0; flex-shrink: 0;
font-family: var(--df-font-mono); font-family: var(--df-font-mono);
font-size: 10.5px; font-size: 10px;
color: var(--df-info); color: var(--df-info);
background: var(--df-info-bg); background: var(--df-info-bg);
border-radius: var(--df-radius-sm); border-radius: var(--df-radius-sm);
+25 -10
View File
@@ -73,7 +73,7 @@
// OutboxDrawer:定时/待发消息统一列表(跨会话),支持 改期/立即发送/取消 + 底部新建。 // OutboxDrawer:定时/待发消息统一列表(跨会话),支持 改期/立即发送/取消 + 底部新建。
// 挂载点在 ChatInput.vue(⏰ 入口按钮旁,Teleport 自脱离输入区布局); // 挂载点在 ChatInput.vue(⏰ 入口按钮旁,Teleport 自脱离输入区布局);
// 后端 ai_outbox_* 命令未接线时 invoke 静默降级(useAiOutbox 内),抽屉空态可用。 // 后端 ai_outbox_* 命令未接线时 invoke 静默降级(useAiOutbox 内),抽屉空态可用。
import { ref, computed, watch } from 'vue' import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAiOutbox, type AiOutboxItem } from '../../composables/ai/useAiOutbox' import { useAiOutbox, type AiOutboxItem } from '../../composables/ai/useAiOutbox'
import { useAiStore } from '../../stores/ai' import { useAiStore } from '../../stores/ai'
@@ -96,6 +96,20 @@ watch(() => props.open, (v) => {
if (v) void refresh() if (v) void refresh()
}) })
// F6-2(走查-信息UX 面六):Esc 关抽屉(与遮罩点击/✕ 同出口,键盘可达)。
// 监听挂 document:AiChat 全局 Esc 仲裁在 window 层,document 先于 window 收到冒泡,
// 此处消费即收口(stopPropagation),防「关抽屉的同时误关分离窗」一次按键双效果;
// IME 组词中的 Esc(229/isComposing)归输入法,不关抽屉(仓内三处同款守卫)。
function onEscClose(e: KeyboardEvent): void {
if (!props.open || e.key !== 'Escape') return
if (e.isComposing || e.keyCode === 229) return
e.preventDefault()
e.stopPropagation()
emit('close')
}
document.addEventListener('keydown', onEscClose)
onBeforeUnmount(() => document.removeEventListener('keydown', onEscClose))
// ── 列表展示 ── // ── 列表展示 ──
// 排序:活跃在前(sending→pending→failed),终态沉底;同级按 due_at 升序(尽快项 due null 排最前) // 排序:活跃在前(sending→pending→failed),终态沉底;同级按 due_at 升序(尽快项 due null 排最前)
@@ -230,7 +244,8 @@ async function onCreate(): Promise<void> {
.outbox-panel { .outbox-panel {
width: min(420px, 92vw); width: min(420px, 92vw);
height: 100%; height: 100%;
background: var(--df-bg-card); /* wave2:抽屉底 bg-raised(比 bg-card 抬一级,与遮罩分层) */
background: var(--df-bg-raised);
border-left: 0.5px solid var(--df-border); border-left: 0.5px solid var(--df-border);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -259,7 +274,7 @@ async function onCreate(): Promise<void> {
border-radius: var(--df-radius); border-radius: var(--df-radius);
} }
.outbox-close:hover { .outbox-close:hover {
background: var(--df-bg-raised); background: var(--df-bg-card-hover);
color: var(--df-text); color: var(--df-text);
} }
.outbox-list { .outbox-list {
@@ -309,8 +324,8 @@ async function onCreate(): Promise<void> {
border-color: color-mix(in srgb, var(--df-warning) 40%, transparent); border-color: color-mix(in srgb, var(--df-warning) 40%, transparent);
} }
.outbox-badge--failed { .outbox-badge--failed {
color: #e5484d; color: var(--df-danger);
border-color: color-mix(in srgb, #e5484d 40%, transparent); border-color: color-mix(in srgb, var(--df-danger) 40%, transparent);
} }
.outbox-text { .outbox-text {
font-size: 12px; font-size: 12px;
@@ -336,7 +351,7 @@ async function onCreate(): Promise<void> {
transition: background 0.15s, color 0.15s; transition: background 0.15s, color 0.15s;
} }
.outbox-act:hover:not(:disabled) { .outbox-act:hover:not(:disabled) {
background: var(--df-bg-raised); background: var(--df-bg-card-hover);
color: var(--df-text); color: var(--df-text);
} }
.outbox-act:disabled { .outbox-act:disabled {
@@ -353,12 +368,12 @@ async function onCreate(): Promise<void> {
color: #fff; color: #fff;
} }
.outbox-act--danger:hover:not(:disabled) { .outbox-act--danger:hover:not(:disabled) {
color: #e5484d; color: var(--df-danger);
border-color: color-mix(in srgb, #e5484d 40%, transparent); border-color: color-mix(in srgb, var(--df-danger) 40%, transparent);
} }
.outbox-dt { .outbox-dt {
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
background: var(--df-bg-raised); background: var(--df-bg-card);
color: var(--df-text); color: var(--df-text);
font-size: 11px; font-size: 11px;
padding: 3px 6px; padding: 3px 6px;
@@ -382,7 +397,7 @@ async function onCreate(): Promise<void> {
box-sizing: border-box; box-sizing: border-box;
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius); border-radius: var(--df-radius);
background: var(--df-bg-raised); background: var(--df-bg-card);
color: var(--df-text); color: var(--df-text);
font-size: 12px; font-size: 12px;
font-family: var(--df-font-sans); font-family: var(--df-font-sans);
+21 -16
View File
@@ -276,7 +276,8 @@ const echoSummary = computed(() => {
</script> </script>
<style scoped> <style scoped>
/* 输入区上方的轻量折叠条,视觉克制度对齐 .ai-input-hint(不抢输入框焦点注意力) */ /* 输入区上方的轻量折叠条,视觉克制度对齐 .ai-input-hint(不抢输入框焦点注意力)
wave2(2026-10-18):info 系透明彩底范式(授权边界=信息/配置信号,非警示) */
.ai-boundary { .ai-boundary {
margin-bottom: 4px; margin-bottom: 4px;
font-size: 11px; font-size: 11px;
@@ -292,7 +293,7 @@ const echoSummary = computed(() => {
color: var(--df-text-dim); color: var(--df-text-dim);
font-size: 11px; font-size: 11px;
cursor: pointer; cursor: pointer;
border-radius: var(--df-radius-sm, 4px); border-radius: var(--df-radius-sm);
max-width: 100%; max-width: 100%;
} }
.ai-boundary-toggle:hover { color: var(--df-text); } .ai-boundary-toggle:hover { color: var(--df-text); }
@@ -301,26 +302,29 @@ const echoSummary = computed(() => {
flex-shrink: 0; flex-shrink: 0;
} }
.ai-boundary-chevron.is-open { transform: rotate(90deg); } .ai-boundary-chevron.is-open { transform: rotate(90deg); }
.ai-boundary-toggle-label { font-weight: 600; } /* 标题同色(info)12px 600 */
.ai-boundary-toggle-label { font-weight: 600; font-size: 12px; color: var(--df-info); }
.ai-boundary-echo { .ai-boundary-echo {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
color: var(--df-accent); color: var(--df-info);
opacity: 0.85; opacity: 0.85;
} }
.ai-boundary-body { .ai-boundary-body {
margin-top: 4px; margin-top: 4px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 7px; gap: 8px;
padding: 8px; padding: 8px;
background: color-mix(in srgb, var(--df-accent) 5%, transparent); background: color-mix(in srgb, var(--df-info) 6%, transparent);
border: 0.5px solid color-mix(in srgb, var(--df-accent) 20%, transparent); border: 0.5px solid color-mix(in srgb, var(--df-info) 30%, transparent);
border-radius: var(--df-radius); border-radius: var(--df-radius);
font-size: 12px;
color: var(--df-text-secondary);
} }
.ai-boundary-locked { .ai-boundary-locked {
font-size: 10.5px; font-size: 10px;
color: var(--df-warning); color: var(--df-warning);
} }
.ai-boundary-loadfail { .ai-boundary-loadfail {
@@ -328,7 +332,7 @@ const echoSummary = computed(() => {
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; gap: 6px;
font-size: 10.5px; font-size: 10px;
color: var(--df-danger); color: var(--df-danger);
} }
.ai-boundary-row { .ai-boundary-row {
@@ -350,7 +354,7 @@ const echoSummary = computed(() => {
color: var(--df-text-dim); color: var(--df-text-dim);
white-space: nowrap; white-space: nowrap;
} }
.ai-boundary-unlimited input { accent-color: var(--df-accent); margin: 0; } .ai-boundary-unlimited input { accent-color: var(--df-info); margin: 0; }
.ai-boundary-unlimited:has(input:disabled) { cursor: not-allowed; opacity: 0.6; } .ai-boundary-unlimited:has(input:disabled) { cursor: not-allowed; opacity: 0.6; }
.ai-boundary-input { .ai-boundary-input {
flex: 1; flex: 1;
@@ -363,7 +367,7 @@ const echoSummary = computed(() => {
border-radius: calc(var(--df-radius) - 2px); border-radius: calc(var(--df-radius) - 2px);
outline: none; outline: none;
} }
.ai-boundary-input:focus { border-color: color-mix(in srgb, var(--df-accent) 40%, transparent); } .ai-boundary-input:focus { border-color: color-mix(in srgb, var(--df-info) 40%, transparent); }
.ai-boundary-input:disabled { opacity: 0.5; cursor: not-allowed; } .ai-boundary-input:disabled { opacity: 0.5; cursor: not-allowed; }
.ai-boundary-input--num { flex: 0 0 90px; } .ai-boundary-input--num { flex: 0 0 90px; }
.ai-boundary-mini-btn { .ai-boundary-mini-btn {
@@ -413,10 +417,11 @@ const echoSummary = computed(() => {
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
/* 一次授权钮:info 系透明底描边钮(对应色操作钮范式) */
.ai-boundary-confirm { .ai-boundary-confirm {
border: none; border: 0.5px solid color-mix(in srgb, var(--df-info) 40%, transparent);
background: var(--df-accent); background: color-mix(in srgb, var(--df-info) 10%, transparent);
color: #fff; color: var(--df-info);
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
padding: 4px 10px; padding: 4px 10px;
@@ -424,7 +429,7 @@ const echoSummary = computed(() => {
border-radius: calc(var(--df-radius) - 2px); border-radius: calc(var(--df-radius) - 2px);
transition: background 0.15s var(--df-ease); transition: background 0.15s var(--df-ease);
} }
.ai-boundary-confirm:hover:not(:disabled) { background: var(--df-accent-hover); } .ai-boundary-confirm:hover:not(:disabled) { background: color-mix(in srgb, var(--df-info) 18%, transparent); }
.ai-boundary-confirm:disabled { opacity: 0.5; cursor: not-allowed; } .ai-boundary-confirm:disabled { opacity: 0.5; cursor: not-allowed; }
.ai-boundary-clear { .ai-boundary-clear {
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
@@ -442,7 +447,7 @@ const echoSummary = computed(() => {
.ai-boundary-field.is-error span { color: var(--df-danger); } .ai-boundary-field.is-error span { color: var(--df-danger); }
/* A11: 脏编辑提示条(切会话保留编辑时的可见说明) */ /* A11: 脏编辑提示条(切会话保留编辑时的可见说明) */
.ai-boundary-dirty { .ai-boundary-dirty {
font-size: 10.5px; font-size: 10px;
color: var(--df-warning); color: var(--df-warning);
} }
</style> </style>
+20 -17
View File
@@ -99,15 +99,16 @@ async function handleStopArchive(): Promise<void> {
</script> </script>
<style scoped> <style scoped>
/* 复用 HelpRequiredCard 样式基调(warning 色系,停靠属"需用户拍板"信号,视觉一致) */ /* wave2(2026-10-18):统一透明彩底卡范式,danger 系(非正常终局停靠=需用户拍板信号)——
边框 color-mix 30% + 底 8% + 标题同色 12px/600 + 正文 secondary 12px + 透明底描边钮 */
.ai-task-parked { .ai-task-parked {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 3px; gap: 3px;
margin-bottom: 6px; margin-bottom: 6px;
padding: 6px 8px; padding: 6px 8px;
background: color-mix(in srgb, var(--df-warning) 10%, transparent); background: color-mix(in srgb, var(--df-danger) 8%, transparent);
border: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent); border: 0.5px solid color-mix(in srgb, var(--df-danger) 30%, transparent);
border-radius: var(--df-radius); border-radius: var(--df-radius);
} }
.ai-task-parked-head { .ai-task-parked-head {
@@ -115,37 +116,39 @@ async function handleStopArchive(): Promise<void> {
align-items: center; align-items: center;
gap: 6px; gap: 6px;
} }
/* stop_kind 标签:透明 danger 描边 chip;fatal 连续失败重入耗尽=实底警示级最高 */
.ai-task-parked-tag { .ai-task-parked-tag {
flex-shrink: 0; flex-shrink: 0;
padding: 0 6px; padding: 0 6px;
border-radius: calc(var(--df-radius) - 2px); border-radius: calc(var(--df-radius) - 2px);
background: var(--df-warning); background: color-mix(in srgb, var(--df-danger) 12%, transparent);
color: #000; border: 0.5px solid color-mix(in srgb, var(--df-danger) 30%, transparent);
color: var(--df-danger);
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
line-height: 16px; line-height: 16px;
} }
.ai-task-parked-tag--fatal { .ai-task-parked-tag--fatal {
background: var(--df-danger); background: var(--df-danger);
border-color: var(--df-danger);
color: #fff; color: #fff;
} }
.ai-task-parked-title { .ai-task-parked-title {
font-size: 11.5px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: var(--df-warning); color: var(--df-danger);
} }
.ai-task-parked-reason { .ai-task-parked-reason {
font-size: 11px; font-size: 12px;
color: var(--df-text-dim); color: var(--df-text-secondary);
opacity: 0.9;
word-break: break-word; word-break: break-word;
white-space: pre-wrap; white-space: pre-wrap;
} }
/* preserved 行与 reason 同色系但更弱一级:保证信息在,不与真实原因抢视觉权重 */ /* preserved 行与 reason 同色系但更弱一级:保证信息在,不与真实原因抢视觉权重 */
.ai-task-parked-preserved { .ai-task-parked-preserved {
font-size: 10.5px; font-size: 11px;
color: var(--df-text-dim); color: var(--df-text-dim);
opacity: 0.8; opacity: 0.85;
} }
.ai-task-parked-actions { .ai-task-parked-actions {
display: flex; display: flex;
@@ -155,17 +158,17 @@ async function handleStopArchive(): Promise<void> {
margin-top: 2px; margin-top: 2px;
} }
.ai-task-parked-btn { .ai-task-parked-btn {
border: 0.5px solid color-mix(in srgb, var(--df-warning) 60%, transparent); border: 0.5px solid color-mix(in srgb, var(--df-danger) 40%, transparent);
background: var(--df-warning); background: color-mix(in srgb, var(--df-danger) 10%, transparent);
color: #000; color: var(--df-danger);
cursor: pointer; cursor: pointer;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
padding: 3px 10px; padding: 3px 10px;
border-radius: calc(var(--df-radius) - 2px); border-radius: calc(var(--df-radius) - 2px);
transition: filter 0.15s var(--df-ease); transition: background 0.15s var(--df-ease), color 0.15s var(--df-ease);
} }
.ai-task-parked-btn:hover { filter: brightness(1.1); } .ai-task-parked-btn:hover { background: color-mix(in srgb, var(--df-danger) 16%, transparent); }
.ai-task-parked-btn:disabled { opacity: 0.5; cursor: not-allowed; } .ai-task-parked-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.ai-task-parked-btn--muted { .ai-task-parked-btn--muted {
background: transparent; background: transparent;
+109 -78
View File
@@ -14,7 +14,7 @@
<button <button
v-if="pendingApprovalCount > 0" v-if="pendingApprovalCount > 0"
type="button" type="button"
class="ai-approval-badge" class="ai-approval-badge df-badge df-badge--danger"
:title="$t('aiChat.pendingApprovalHint')" :title="$t('aiChat.pendingApprovalHint')"
@click="emit('scroll-to-pending')" @click="emit('scroll-to-pending')"
> >
@@ -24,7 +24,7 @@
<button <button
v-if="unattendedStatus?.breaker_tripped" v-if="unattendedStatus?.breaker_tripped"
type="button" type="button"
class="ai-unattended-badge ai-unattended-badge--tripped" class="ai-unattended-badge df-badge df-badge--danger"
:title="$t('aiChat.unattendedResetHint')" :title="$t('aiChat.unattendedResetHint')"
@click="resetUnattendedBreaker" @click="resetUnattendedBreaker"
> >
@@ -33,7 +33,7 @@
<button <button
v-else-if="unattendedStatus?.enabled" v-else-if="unattendedStatus?.enabled"
type="button" type="button"
class="ai-unattended-badge" class="ai-unattended-badge df-badge df-badge--accent"
:title="$t('aiChat.unattendedBadgeHint')" :title="$t('aiChat.unattendedBadgeHint')"
@click="jumpToUnattendedSettings" @click="jumpToUnattendedSettings"
> >
@@ -50,7 +50,7 @@
:title="store.state.providers.length > 1 ? $t('aiChat.providerSwitchSetsDefault') : ''" :title="store.state.providers.length > 1 ? $t('aiChat.providerSwitchSetsDefault') : ''"
@click="cycleProvider" @click="cycleProvider"
> >
<span class="provider-dot" :class="'provider-dot--' + providerStatus"></span> <span class="df-dot provider-dot" :class="'provider-dot--' + providerStatus"></span>
<span class="provider-name">{{ activeProviderName }}</span> <span class="provider-name">{{ activeProviderName }}</span>
</button> </button>
<div class="ai-provider-bar ai-provider-empty" v-else> <div class="ai-provider-bar ai-provider-empty" v-else>
@@ -182,7 +182,7 @@
</div> </div>
<!-- #14 桌面任务卡:后台在途任务(多会话并发生成中,点击切回) --> <!-- #14 桌面任务卡:后台在途任务(多会话并发生成中,点击切回) -->
<div v-if="runningTasks.length || recoverableTasks.length" class="ai-running-tasks-inline"> <div v-if="runningTasks.length || recoverableTasks.length" class="ai-running-tasks-inline">
<button type="button" class="ai-running-tasks-badge ai-tool-badge" :class="{ 'is-active': runningTasksExpanded }" @click="togglePanel('running')" :title="$t('aiChat.runningTasks')"> <button type="button" class="ai-running-tasks-badge ai-tool-badge df-badge df-badge--info" :class="{ 'is-active': runningTasksExpanded }" @click="togglePanel('running')" :title="$t('aiChat.runningTasks')">
<span class="ai-running-tasks-pulse"></span> <span class="ai-running-tasks-pulse"></span>
{{ runningTasks.length }} {{ runningTasks.length }}
</button> </button>
@@ -236,6 +236,8 @@
<span class="ai-history-text">{{ h.text }}</span> <span class="ai-history-text">{{ h.text }}</span>
<span class="ai-history-time">{{ h.time }}</span> <span class="ai-history-time">{{ h.time }}</span>
</div> </div>
<!-- F6-1:目标消息在折叠段内未渲染,面板不收 + 行内提示 -->
<div v-if="locateMissHint" class="ai-locate-miss">{{ locateMissHint }}</div>
</div> </div>
</Transition> </Transition>
</div> </div>
@@ -251,6 +253,8 @@
<span class="ai-summary-label">{{ $t('aiChat.compressedSummaryLabel') }} #{{ summaryMsgs.length - i }}</span> <span class="ai-summary-label">{{ $t('aiChat.compressedSummaryLabel') }} #{{ summaryMsgs.length - i }}</span>
<p class="ai-summary-preview">{{ s.text }}</p> <p class="ai-summary-preview">{{ s.text }}</p>
</div> </div>
<!-- F6-1:摘要项同享定位未命中提示(压缩段折叠时 system 消息同样不在 DOM) -->
<div v-if="locateMissHint" class="ai-locate-miss">{{ locateMissHint }}</div>
</div> </div>
</Transition> </Transition>
</div> </div>
@@ -413,6 +417,7 @@ onMounted(() => { void refreshUnattended() })
onBeforeUnmount(() => { onBeforeUnmount(() => {
unattendedDisposed = true unattendedDisposed = true
if (unattendedTimer) { clearTimeout(unattendedTimer); unattendedTimer = null } if (unattendedTimer) { clearTimeout(unattendedTimer); unattendedTimer = null }
if (locateMissTimer) { clearTimeout(locateMissTimer); locateMissTimer = null }
}) })
// ══ 更多菜单 popout 状态(上下文管理操作收纳)══ // ══ 更多菜单 popout 状态(上下文管理操作收纳)══
@@ -768,8 +773,23 @@ const summaryMsgs = computed<SummaryItem[]>(() => msgDerived.value.summaries)
const historyExpanded = ref(false) const historyExpanded = ref(false)
/** 点击历史消息项 → 滚动定位到对应的消息气泡 */ /** 点击历史消息项 → 滚动定位到对应的消息气泡 */
// F6-1(走查-信息UX 面六):目标消息在折叠段(已归档/已压缩)内时,折叠态不渲染
// [data-msg-id] 元素,querySelector 必空。原实现先收面板再查 DOM,未命中即静默无反应;
// 现改为面板保持展开 + 行内提示引导展开分隔条。展开折叠段的状态在 MessageList 内持有,
// 本组件不可达,提示是最小闭环(不跨组件撬状态)。
const locateMissHint = ref('')
let locateMissTimer: ReturnType<typeof setTimeout> | null = null
function showLocateMiss(): void {
locateMissHint.value = t('aiChat.historyLocateInSegment')
if (locateMissTimer) clearTimeout(locateMissTimer)
locateMissTimer = setTimeout(() => {
locateMissHint.value = ''
locateMissTimer = null
}, 5000)
}
function scrollToMessage(msgId: string) { function scrollToMessage(msgId: string) {
historyExpanded.value = false
// 消息列表已有 data-msg-id 属性,直接 DOM 查询定位。 // 消息列表已有 data-msg-id 属性,直接 DOM 查询定位。
// msgId 由后端生成,可能含 CSS 选择器元字符(: . [ ] / 空格 等), // msgId 由后端生成,可能含 CSS 选择器元字符(: . [ ] / 空格 等),
// 模板拼 selector 会被选择器解析器当伪类/类名异常 → querySelector 抛错或返回 null。 // 模板拼 selector 会被选择器解析器当伪类/类名异常 → querySelector 抛错或返回 null。
@@ -787,8 +807,13 @@ function scrollToMessage(msgId: string) {
el = Array.from(all).find(n => n.getAttribute('data-msg-id') === msgId) ?? null el = Array.from(all).find(n => n.getAttribute('data-msg-id') === msgId) ?? null
} }
if (el) { if (el) {
locateMissHint.value = ''
historyExpanded.value = false
el.scrollIntoView({ behavior: 'smooth', block: 'center' }) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
return
} }
// 未命中:不收面板,提示替代静默
showLocateMiss()
} }
/** 目标/历史下拉容器 DOM 引用(用于点击外部检测) */ /** 目标/历史下拉容器 DOM 引用(用于点击外部检测) */
@@ -824,6 +849,7 @@ const contextExpanded = ref(false)
/** 弹出面板互斥:goals/history/summary/context/running 五个 badge 面板,点一个收起其他,再点当前切换。 */ /** 弹出面板互斥:goals/history/summary/context/running 五个 badge 面板,点一个收起其他,再点当前切换。 */
function togglePanel(panel: 'goals' | 'history' | 'summary' | 'context' | 'running') { function togglePanel(panel: 'goals' | 'history' | 'summary' | 'context' | 'running') {
locateMissHint.value = ''
const refs = { goals: goalsExpanded, history: historyExpanded, summary: summaryExpanded, context: contextExpanded, running: runningTasksExpanded } const refs = { goals: goalsExpanded, history: historyExpanded, summary: summaryExpanded, context: contextExpanded, running: runningTasksExpanded }
const willOpen = !refs[panel].value const willOpen = !refs[panel].value
goalsExpanded.value = historyExpanded.value = summaryExpanded.value = contextExpanded.value = runningTasksExpanded.value = false goalsExpanded.value = historyExpanded.value = summaryExpanded.value = contextExpanded.value = runningTasksExpanded.value = false
@@ -965,56 +991,37 @@ onBeforeUnmount(() => {
white-space: nowrap; white-space: nowrap;
max-width: 200px; max-width: 200px;
} }
/* 审批徽标(待审批>0 时显于头部,点击跳首个审批卡;div→button 后归零默认样式) */ /* 审批徽标(待审批>0 时显于头部,点击跳首个审批卡)
2026-10-18 wave2:迁 .df-badge--danger 透明彩底徽章(色由全局公共类接管,此处只留布局) */
.ai-approval-badge { .ai-approval-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
background: var(--df-warning-bg);
color: var(--df-warning);
padding: 2px 8px;
border-radius: var(--df-radius-sm);
font-size: 11px;
font-weight: 500; font-weight: 500;
white-space: nowrap; white-space: nowrap;
transition: opacity 0.15s var(--df-ease); transition: opacity 0.15s var(--df-ease);
font-family: inherit;
appearance: none; appearance: none;
/* review ③:div→button 后 UA outset 边框需显式归零(appearance:none 不含 border) */
border: none;
} }
.ai-approval-badge:hover { .ai-approval-badge:hover {
opacity: 0.85; opacity: 0.85;
} }
.ai-approval-badge:focus-visible { .ai-approval-badge:focus-visible {
outline: 1.5px solid var(--df-accent, #4a9eff); outline: 1.5px solid var(--df-accent);
outline-offset: 1px; outline-offset: 1px;
} }
/* 挂机引擎态徽章(开启=accent 弱底;熔断=红底红字,点击复位) */ /* 挂机引擎态徽章(开启=accent / 熔断=danger,均由 df-badge 变体接管,此处只留布局) */
.ai-unattended-badge { .ai-unattended-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
background: var(--df-accent-bg);
color: var(--df-accent);
padding: 2px 8px;
border-radius: var(--df-radius-sm);
font-size: 11px;
font-weight: 500; font-weight: 500;
white-space: nowrap; white-space: nowrap;
font-family: inherit;
appearance: none; appearance: none;
border: none;
transition: opacity 0.15s var(--df-ease); transition: opacity 0.15s var(--df-ease);
} }
.ai-unattended-badge:hover { .ai-unattended-badge:hover {
opacity: 0.85; opacity: 0.85;
} }
.ai-unattended-badge--tripped {
background: var(--df-danger-bg);
color: var(--df-danger);
border: 0.5px solid var(--df-danger);
}
/* ═══ 模型选择器(对话透明化 L1:紧凑下拉) ═══ */ /* ═══ 模型选择器(对话透明化 L1:紧凑下拉) ═══ */
.ai-model-picker { .ai-model-picker {
@@ -1053,25 +1060,28 @@ onBeforeUnmount(() => {
display: flex; display: flex;
gap: 4px; gap: 4px;
} }
/* ── 图标按钮(共享工具类,header-actions 内清空/压缩/最大化/分离/关闭等)── */ /* ── 图标按钮(共享工具类,header-actions 内清空/压缩/最大化/分离/关闭等)
2026-10-18 wave2:统一 workbench .hd-btn 范式——bg-card 底+边框+md 圆角+hover 边框亮 ── */
.ai-btn-icon { .ai-btn-icon {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 26px; width: 26px;
height: 26px; height: 26px;
border: none; border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm); border-radius: var(--df-radius-md);
background: transparent; background: var(--df-bg-card);
color: var(--df-text-dim); color: var(--df-text-dim);
cursor: pointer; cursor: pointer;
transition: background 0.15s, color 0.15s; transition: border-color 0.15s var(--df-ease), color 0.15s var(--df-ease), background 0.15s var(--df-ease);
} }
.ai-btn-icon:hover { .ai-btn-icon:hover {
background: var(--df-bg-card); border-color: var(--df-border-strong);
background: var(--df-bg-card-hover);
color: var(--df-text-secondary); color: var(--df-text-secondary);
} }
.ai-btn-icon--active { .ai-btn-icon--active {
border-color: color-mix(in srgb, var(--df-accent) 45%, transparent);
background: var(--df-accent-bg); background: var(--df-accent-bg);
color: var(--df-accent); color: var(--df-accent);
} }
@@ -1080,7 +1090,8 @@ onBeforeUnmount(() => {
cursor: not-allowed; cursor: not-allowed;
} }
.ai-btn-icon:disabled:hover { .ai-btn-icon:disabled:hover {
background: transparent; border-color: var(--df-border);
background: var(--df-bg-card);
color: var(--df-text-dim); color: var(--df-text-dim);
} }
/* ── 更多菜单 popout(清空/压缩上下文等低频操作收纳;CSS 原缺失致打开样式错乱)── */ /* ── 更多菜单 popout(清空/压缩上下文等低频操作收纳;CSS 原缺失致打开样式错乱)── */
@@ -1094,7 +1105,7 @@ onBeforeUnmount(() => {
background: var(--df-bg-card); background: var(--df-bg-card);
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius); border-radius: var(--df-radius);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); box-shadow: var(--df-shadow-sm);
z-index: 100; z-index: 100;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1113,16 +1124,13 @@ onBeforeUnmount(() => {
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
} }
.ai-more-item:hover:not(:disabled) { background: var(--df-bg-hover, rgba(0,0,0,0.06)); color: var(--df-text); } .ai-more-item:hover:not(:disabled) { background: var(--df-bg-card-hover); color: var(--df-text); }
.ai-more-item:disabled { opacity: 0.35; cursor: not-allowed; } .ai-more-item:disabled { opacity: 0.35; cursor: not-allowed; }
/* Transition:淡入 + 下滑 */ /* Transition:淡入 + 下滑 */
.ai-more-popout-enter-active, .ai-more-popout-leave-active { transition: opacity .15s, transform .15s; } .ai-more-popout-enter-active, .ai-more-popout-leave-active { transition: opacity .15s, transform .15s; }
.ai-more-popout-enter-from, .ai-more-popout-leave-to { opacity: 0; transform: translateY(-4px); } .ai-more-popout-enter-from, .ai-more-popout-leave-to { opacity: 0; transform: translateY(-4px); }
/* 压缩中 spinner 图标旋转动画(@keyframes ai-btn-spin 在父级 AiChat.vue 全局定义, /* 压缩中 spinner:.ai-btn-spinner(含 ai-btn-spin 旋转动画)由 components.css 全局定义,
scoped 不重命名 @keyframes,此处按名引用) */ 原本地重复 animation 声明删除(2026-10-18 整洁波,逐字重复零视觉变更) */
.ai-btn-spinner {
animation: ai-btn-spin 0.9s linear infinite;
}
/* 底部工具区(标题左 + 工具右) */ /* 底部工具区(标题左 + 工具右) */
.ai-bottom-tools { .ai-bottom-tools {
display: flex; display: flex;
@@ -1192,6 +1200,10 @@ onBeforeUnmount(() => {
.ai-task-progress--warn .ai-task-progress-dot { .ai-task-progress--warn .ai-task-progress-dot {
animation-duration: 3.2s; animation-duration: 3.2s;
} }
/* 警示档(静默>10min)状态点转琥珀:给判断材料,不自动判死 */
.ai-task-progress--warn .ai-task-progress-dot {
background: var(--df-warning);
}
.ai-task-progress-stage { .ai-task-progress-stage {
font-size: 11px; font-size: 11px;
color: var(--df-text-dim); color: var(--df-text-dim);
@@ -1200,39 +1212,45 @@ onBeforeUnmount(() => {
font-size: 11px; font-size: 11px;
color: color-mix(in srgb, var(--df-warning) 70%, var(--df-text-secondary)); color: color-mix(in srgb, var(--df-warning) 70%, var(--df-text-secondary));
} }
/* ═══ Provider Bar (header 中间区;div→button 后归零默认样式) ═══ */ /* ═══ Provider Bar (header 中间区)
2026-10-18 wave2:对齐 workbench .session-status pill 范式——bg-card 底+边框+8px 圆角+12px 字,
状态点迁 .df-dot(7px 尺寸由全局公共类提供);div→button 后归零默认样式 ═══ */
.ai-provider-bar { .ai-provider-bar {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; gap: 6px;
font-size: 11px; font-size: 12px;
color: var(--df-text-secondary); color: var(--df-text-secondary);
white-space: nowrap; white-space: nowrap;
font-family: inherit; font-family: inherit;
appearance: none; appearance: none;
background: none; background: var(--df-bg-card);
border: none; border: 0.5px solid var(--df-border);
padding: 0; border-radius: var(--df-radius-md);
padding: 4px 12px;
transition: border-color 0.15s var(--df-ease);
} }
.ai-provider-bar--switchable { .ai-provider-bar--switchable {
cursor: pointer; cursor: pointer;
} }
.ai-provider-bar--switchable:hover { .ai-provider-bar--switchable:hover {
color: var(--df-text-primary); border-color: var(--df-border-strong);
color: var(--df-text);
} }
.ai-provider-bar--switchable:focus-visible { .ai-provider-bar--switchable:focus-visible {
outline: 1.5px solid var(--df-accent, #4a9eff); outline: 1.5px solid var(--df-accent);
outline-offset: 2px; outline-offset: 2px;
border-radius: 2px; border-radius: var(--df-radius-md);
} }
/* provider 连通状态点:灰=未测/绿=最近成功/红=最近失败(基于最后 assistant 消息 isError) */ /* provider 连通状态点:灰=未测/绿=最近成功/红=最近失败(基于最后 assistant 消息 isError);
.provider-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--df-text-dim); flex-shrink: 0; transition: background .2s; } 尺寸/圆角走全局 .df-dot,此处只管色 */
.provider-dot { flex-shrink: 0; transition: background .2s; background: var(--df-text-dim); }
.provider-dot--ok { background: var(--df-success); } .provider-dot--ok { background: var(--df-success); }
.provider-dot--error { background: var(--df-danger); } .provider-dot--error { background: var(--df-danger); }
.provider-name { .provider-name {
font-size: 11px; font-size: 12px;
font-family: var(--df-font-mono); font-family: var(--df-font-mono);
color: var(--df-text-dim); color: var(--df-text-secondary);
} }
.ai-provider-empty { .ai-provider-empty {
background: var(--df-danger-bg); background: var(--df-danger-bg);
@@ -1265,7 +1283,7 @@ onBeforeUnmount(() => {
appearance: none; appearance: none;
} }
.ai-tool-badge:focus-visible { .ai-tool-badge:focus-visible {
outline: 1.5px solid var(--df-accent, #4a9eff); outline: 1.5px solid var(--df-accent);
outline-offset: 1px; outline-offset: 1px;
} }
.ai-tool-badge svg { vertical-align: middle; } /* 图标在行高内垂直居中(与数字同基线)*/ .ai-tool-badge svg { vertical-align: middle; } /* 图标在行高内垂直居中(与数字同基线)*/
@@ -1291,11 +1309,17 @@ onBeforeUnmount(() => {
align-items: center; align-items: center;
gap: 4px; gap: 4px;
} }
/* wave2:后台会话徽章迁 .df-badge--info(透明彩底 info 系);scoped 需显式覆写压过 .ai-tool-badge 灰底 */
.ai-running-tasks-badge.df-badge--info {
background: var(--df-info-bg);
color: var(--df-info);
border-color: color-mix(in srgb, var(--df-info) 30%, transparent);
}
.ai-running-tasks-pulse { .ai-running-tasks-pulse {
width: 6px; width: 6px;
height: 6px; height: 6px;
border-radius: 50%; border-radius: 50%;
background: var(--df-success, #34c77b); background: var(--df-success);
animation: ai-running-pulse 2s ease-in-out infinite; animation: ai-running-pulse 2s ease-in-out infinite;
} }
@keyframes ai-running-pulse { @keyframes ai-running-pulse {
@@ -1311,9 +1335,9 @@ onBeforeUnmount(() => {
max-width: 400px; max-width: 400px;
max-height: 300px; max-height: 300px;
overflow-y: auto; overflow-y: auto;
background: var(--df-bg); background: var(--df-bg-raised);
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm); border-radius: var(--df-radius);
box-shadow: 0 2px 8px rgba(0,0,0,0.1); box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 4px 0; padding: 4px 0;
} }
@@ -1331,14 +1355,14 @@ onBeforeUnmount(() => {
color: var(--df-text); color: var(--df-text);
} }
.ai-running-task-item:hover { .ai-running-task-item:hover {
background: var(--df-hover, rgba(128,128,128,0.08)); background: var(--df-bg-card-hover);
} }
.ai-running-task-dot { .ai-running-task-dot {
flex-shrink: 0; flex-shrink: 0;
width: 6px; width: 6px;
height: 6px; height: 6px;
border-radius: 50%; border-radius: 50%;
background: var(--df-success, #34c77b); background: var(--df-success);
} }
.ai-running-task-title { .ai-running-task-title {
flex: 1; flex: 1;
@@ -1349,7 +1373,7 @@ onBeforeUnmount(() => {
.ai-running-task-round { .ai-running-task-round {
flex-shrink: 0; flex-shrink: 0;
font-size: 11px; font-size: 11px;
color: var(--df-text-secondary, #888); color: var(--df-text-secondary);
} }
/* V49 可恢复分区:琥珀点+「可恢复」徽标,与进行中(绿)区分 */ /* V49 可恢复分区:琥珀点+「可恢复」徽标,与进行中(绿)区分 */
.ai-running-tasks-sep { .ai-running-tasks-sep {
@@ -1358,7 +1382,7 @@ onBeforeUnmount(() => {
background: var(--df-border); background: var(--df-border);
} }
.ai-running-task-dot--parked { .ai-running-task-dot--parked {
background: var(--df-warning, #e6a23c); background: var(--df-warning);
} }
/* B4 parked 行改双钮布局(切回 | 恢复),恢复钮琥珀描边醒目 */ /* B4 parked 行改双钮布局(切回 | 恢复),恢复钮琥珀描边醒目 */
.ai-running-task-item--parked { .ai-running-task-item--parked {
@@ -1385,21 +1409,21 @@ onBeforeUnmount(() => {
color: var(--df-text); color: var(--df-text);
} }
.ai-running-task-main:hover { .ai-running-task-main:hover {
background: var(--df-hover, rgba(128,128,128,0.08)); background: var(--df-bg-card-hover);
} }
.ai-running-task-recover { .ai-running-task-recover {
flex-shrink: 0; flex-shrink: 0;
margin-right: 10px; margin-right: 10px;
padding: 2px 8px; padding: 2px 8px;
border: 1px solid var(--df-warning, #e6a23c); border: 1px solid var(--df-warning);
border-radius: 4px; border-radius: var(--df-radius-sm);
background: none; background: none;
cursor: pointer; cursor: pointer;
font-size: 11px; font-size: 11px;
color: var(--df-warning, #e6a23c); color: var(--df-warning);
} }
.ai-running-task-recover:hover { .ai-running-task-recover:hover {
background: var(--df-warning, #e6a23c); background: var(--df-warning);
color: #fff; color: #fff;
} }
.ai-history-inline-list { .ai-history-inline-list {
@@ -1411,9 +1435,9 @@ onBeforeUnmount(() => {
max-width: 400px; max-width: 400px;
max-height: 300px; max-height: 300px;
overflow-y: auto; overflow-y: auto;
background: var(--df-bg); background: var(--df-bg-raised);
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm); border-radius: var(--df-radius);
box-shadow: 0 2px 8px rgba(0,0,0,0.1); box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 4px 0; padding: 4px 0;
margin-top: 2px; margin-top: 2px;
@@ -1454,6 +1478,13 @@ onBeforeUnmount(() => {
color: var(--df-text-dim); color: var(--df-text-dim);
font-family: var(--df-font-mono); font-family: var(--df-font-mono);
} }
/* F6-1:历史/摘要定位未命中提示(目标消息在折叠段内,引导展开分隔条) */
.ai-locate-miss {
padding: 6px 10px;
font-size: 11px;
line-height: 1.4;
color: var(--df-warning);
}
/* ═══ 上下文摘要(底部) ═══ */ /* ═══ 上下文摘要(底部) ═══ */
.ai-summary-inline { .ai-summary-inline {
@@ -1470,9 +1501,9 @@ onBeforeUnmount(() => {
max-width: 420px; max-width: 420px;
max-height: 360px; max-height: 360px;
overflow-y: auto; overflow-y: auto;
background: var(--df-bg); background: var(--df-bg-raised);
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm); border-radius: var(--df-radius);
box-shadow: 0 2px 8px rgba(0,0,0,0.1); box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 6px 0; padding: 6px 0;
margin-top: 2px; margin-top: 2px;
@@ -1518,9 +1549,9 @@ onBeforeUnmount(() => {
z-index: 100; z-index: 100;
min-width: 280px; min-width: 280px;
max-width: 420px; max-width: 420px;
background: var(--df-bg); background: var(--df-bg-raised);
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm); border-radius: var(--df-radius);
box-shadow: 0 2px 8px rgba(0,0,0,0.1); box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-top: 2px; margin-top: 2px;
} }
@@ -1571,9 +1602,9 @@ onBeforeUnmount(() => {
z-index: 100; z-index: 100;
min-width: 200px; min-width: 200px;
max-width: 360px; max-width: 360px;
background: var(--df-bg, #fff); background: var(--df-bg-raised);
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm, 4px); border-radius: var(--df-radius);
box-shadow: 0 2px 8px rgba(0,0,0,0.1); box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 4px 0; padding: 4px 0;
margin-top: 2px; margin-top: 2px;
@@ -1601,7 +1632,7 @@ onBeforeUnmount(() => {
.ai-goal-check { color: var(--df-success); font-weight: 500; flex-shrink: 0; } .ai-goal-check { color: var(--df-success); font-weight: 500; flex-shrink: 0; }
.ai-goal-remove { border: none; background: transparent; color: var(--df-text-dim); cursor: pointer; padding: 0 2px; font-size: 13px; line-height: 1; flex-shrink: 0; } .ai-goal-remove { border: none; background: transparent; color: var(--df-text-dim); cursor: pointer; padding: 0 2px; font-size: 13px; line-height: 1; flex-shrink: 0; }
.ai-goal-remove:hover { .ai-goal-remove:hover {
color: var(--df-danger, #e5484d); color: var(--df-danger);
} }
.provider-hint { .provider-hint {
font-size: 11px; font-size: 11px;
@@ -0,0 +1,913 @@
<template>
<!-- ═══ 会话列表体(拆自 ConversationSidebar.vue C4a,零行为变更) ═══ -->
<!-- UX-06 §3.1: 搜索态(searchResults 非 null)→ 平铺,活跃+归档全量按 updated_at DESC -->
<template v-if="searchResults">
<div
v-for="conv in searchResults"
:key="'s-' + conv.id"
class="ai-conv-item"
:class="{
'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--pinned': conv.pinned,
}"
tabindex="0"
@click="onSelectSearchResult(conv.id)"
@keydown="onItemKeydown($event, conv.id, true)"
>
<div class="ai-conv-item-main">
<!-- wave2 u-agent 范式: 左侧 7px 状态点。字段源 store.isGenerating(conv_state
generating/stopping/compressed) / pendingCountOf(审批挂起) / loop_state='parked',
无 error 字段故不映射;优先级 generating > 待审批 > parked > 灰 -->
<span
class="df-dot ai-conv-status"
:class="store.isGenerating(conv.id) ? 'df-dot--run ai-conv-status--run'
: pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger'
: conv.loop_state === 'parked' ? 'ai-conv-status--paused'
: 'df-dot--stop'"
:title="store.isGenerating(conv.id) ? t('aiChat.generating') : undefined"
aria-hidden="true"
></span>
<!-- UX走查 P3-1: 搜索态此前无重命名入口(无 startRename 分支),与分组视图降级不一致;
双击入口对齐分组视图,菜单入口供键盘用户(P2-6) -->
<input
v-if="editingConvId === conv.id"
:ref="setEditEl"
v-model="editText"
class="ai-conv-item-edit"
@keydown="onRenameKeydown($event, conv.id)"
@blur="commitRename(conv.id)"
@click.stop
/>
<span
v-else
class="ai-conv-item-title"
:title="conv.title || t('aiChat.doubleClickToRename')"
@dblclick.stop="startRename(conv)"
>
{{ conv.title || t('aiChat.newConversation') }}
<!-- 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 v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')">
<svg aria-hidden="true" width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
</span>
</span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
</div>
<div class="ai-conv-item-actions">
<div class="ai-conv-item-more">
<button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')" :aria-label="t('aiChat.moreActions')">
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
</button>
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
<button class="ai-conv-actions-opt" @click.stop="startRename(conv); closeActionsMenu()">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
{{ t('aiChat.renameConversation') }}
</button>
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
</button>
<button class="ai-conv-actions-opt" @click.stop="store.archiveConversation(conv.id, !conv.archived); closeActionsMenu()">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg>
{{ conv.archived ? t('aiChat.unarchive') : t('aiChat.archive') }}
</button>
<button class="ai-conv-actions-opt ai-conv-actions-opt--danger" @click.stop="emit('confirm-delete', conv.id); closeActionsMenu()">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{{ t('common.delete') }}
</button>
</div>
</div>
</div>
</div>
<div v-if="searchResults.length === 0" class="ai-conv-empty">
{{ t('aiChat.searchEmpty') }}
</div>
</template>
<!-- 非搜索态:原时间分组(活跃) + 归档分组 -->
<template v-else>
<!-- 活跃对话:按 今天/昨天/更早 分组 -->
<template v-for="group in groupedActive" :key="'g-' + group.key">
<div
class="ai-conv-group-title ai-conv-group-title--fold"
role="button"
tabindex="0"
:aria-expanded="!store.state.foldedGroups[group.key]"
@click="store.toggleGroupFold(group.key)"
@keydown.enter.prevent="store.toggleGroupFold(group.key)"
@keydown.space.prevent="store.toggleGroupFold(group.key)"
>
<span class="ai-conv-group-arrow" :class="{ 'is-folded': store.state.foldedGroups[group.key] }">▸</span>
{{ group.label }}
<span class="ai-conv-group-count">({{ group.items.length }})</span>
</div>
<div
v-if="group.key === 'today' && !group.items.length"
v-show="!store.state.foldedGroups[group.key]"
class="ai-conv-group-empty"
>{{ t('aiChat.groupEmpty') }}</div>
<div
v-for="conv in group.items"
v-show="!store.state.foldedGroups[group.key]"
:key="conv.id"
:ref="(el) => setItemEl(conv.id, el)"
class="ai-conv-item"
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }"
tabindex="0"
@click="beginSwitch(conv.id)"
@keydown="onItemKeydown($event, conv.id, true)"
>
<div class="ai-conv-item-main">
<!-- wave2 u-agent 范式: 左侧 7px 状态点(映射源同搜索态块注) -->
<span
class="df-dot ai-conv-status"
:class="store.isGenerating(conv.id) ? 'df-dot--run ai-conv-status--run'
: pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger'
: conv.loop_state === 'parked' ? 'ai-conv-status--paused'
: 'df-dot--stop'"
:title="store.isGenerating(conv.id) ? t('aiChat.generating') : undefined"
aria-hidden="true"
></span>
<input
v-if="editingConvId === conv.id"
:ref="setEditEl"
v-model="editText"
class="ai-conv-item-edit"
@keydown="onRenameKeydown($event, conv.id)"
@blur="commitRename(conv.id)"
@click.stop
/>
<span
v-else
class="ai-conv-item-title"
:class="{ 'ai-conv-item-title--flash': titleFlash && conv.id === store.state.activeConversationId }"
@dblclick.stop="startRename(conv)"
:title="conv.title || t('aiChat.doubleClickToRename')"
>
{{ conv.title || t('aiChat.newConversation') }}
<!-- UX-2025-20 §7.4:AI 生成标题标识小图标(标题非空非占位时显示) -->
<svg aria-hidden="true"
v-if="conv.title && titleFlash && conv.id === store.state.activeConversationId"
class="ai-conv-item-title-badge"
width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
><polyline points="20 6 9 17 4 12"/></svg>
<!-- 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 v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')">
<svg aria-hidden="true" width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
</span>
</span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
</div>
<div class="ai-conv-item-actions">
<div class="ai-conv-item-more">
<button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')" :aria-label="t('aiChat.moreActions')">
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
</button>
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
<button class="ai-conv-actions-opt" @click.stop="startRename(conv); closeActionsMenu()">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
{{ t('aiChat.renameConversation') }}
</button>
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
</button>
<button class="ai-conv-actions-opt" @click.stop="store.archiveConversation(conv.id, true); closeActionsMenu()">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg>
{{ t('aiChat.archive') }}
</button>
<button class="ai-conv-actions-opt ai-conv-actions-opt--danger" @click.stop="emit('confirm-delete', conv.id); closeActionsMenu()">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{{ t('common.delete') }}
</button>
</div>
</div>
</div>
</div>
</template>
<!-- G5 可恢复分组(loop_state='parked',断点续跑闭环读侧;置于活跃分组后、归档前) -->
<template v-if="recoverable.length">
<div class="ai-conv-group-title">
<span class="ai-conv-recover-dot" aria-hidden="true"></span>
{{ t('aiChat.recoverableCount', { n: recoverable.length }) }}
</div>
<div
v-for="conv in recoverable"
:key="'r-' + conv.id"
class="ai-conv-item ai-conv-item--recoverable"
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId }"
tabindex="0"
:title="t('aiChat.recoverableHint')"
@click="onRecover(conv.id)"
@keydown.enter.prevent="onRecover(conv.id)"
@keydown.space.prevent="onRecover(conv.id)"
>
<div class="ai-conv-item-main">
<!-- wave2: 本组数据源 ai_list_recoverable(loop_state='parked' 完备真相),整组恒 parked→静态琥珀点 -->
<span class="df-dot ai-conv-status ai-conv-status--paused" aria-hidden="true"></span>
<span class="ai-conv-item-title">
{{ conv.title || t('aiChat.newConversation') }}
<span class="ai-conv-recover-badge">⏸</span>
</span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
</div>
</div>
</template>
<!-- 归档分组(可折叠) -->
<template v-if="archivedConvs.length">
<div
class="ai-conv-group-title ai-conv-group-title--fold"
role="button"
tabindex="0"
:aria-expanded="!store.state.archivedCollapsed"
@click="store.toggleArchivedFold()"
@keydown.enter.prevent="store.toggleArchivedFold()"
@keydown.space.prevent="store.toggleArchivedFold()"
>
<span class="ai-conv-group-arrow" :class="{ 'is-folded': store.state.archivedCollapsed }">▸</span>
{{ t('aiChat.archivedCount', { n: archivedConvs.length }) }}
</div>
<div
v-for="conv in archivedConvs"
v-show="!store.state.archivedCollapsed"
:key="'a-' + conv.id"
:ref="(el) => setItemEl(conv.id, el)"
class="ai-conv-item ai-conv-item--archived"
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }"
tabindex="0"
@click="beginSwitch(conv.id)"
@keydown="onItemKeydown($event, conv.id)"
>
<div class="ai-conv-item-main">
<!-- wave2 u-agent 范式: 归档项同款状态点(归档为静息态,默认落灰) -->
<span
class="df-dot ai-conv-status"
:class="store.isGenerating(conv.id) ? 'df-dot--run ai-conv-status--run'
: pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger'
: conv.loop_state === 'parked' ? 'ai-conv-status--paused'
: 'df-dot--stop'"
:title="store.isGenerating(conv.id) ? t('aiChat.generating') : undefined"
aria-hidden="true"
></span>
<span class="ai-conv-item-title" :title="conv.title || ''">
{{ conv.title || t('aiChat.newConversation') }}
<!-- 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 v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')">
<svg aria-hidden="true" width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
</span>
</span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
</div>
<div class="ai-conv-item-actions">
<div class="ai-conv-item-more">
<button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')" :aria-label="t('aiChat.moreActions')">
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
</button>
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
</button>
<button class="ai-conv-actions-opt" @click.stop="store.archiveConversation(conv.id, false); closeActionsMenu()">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="12" y1="13" x2="12" y2="17"/><polyline points="10 15 12 13 14 15"/></svg>
{{ t('aiChat.unarchive') }}
</button>
<button class="ai-conv-actions-opt ai-conv-actions-opt--danger" @click.stop="emit('confirm-delete', conv.id); closeActionsMenu()">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{{ t('common.delete') }}
</button>
</div>
</div>
</div>
</div>
</template>
<!-- F4-1: 会话列表分页——底部「加载更多」续拉下一页(首屏 50/组,归档组同口径放开) -->
<div
v-if="convListPaging.hasMore"
class="ai-conv-load-more"
role="button"
tabindex="0"
@click="onLoadMore"
@keydown.enter.prevent="onLoadMore"
@keydown.space.prevent="onLoadMore"
>{{ convListPaging.loading ? t('common.loading') : t('gitChanges.loadMore') }}</div>
<div v-if="store.state.conversations.length === 0" class="ai-conv-empty">
{{ t('aiChat.emptyConversation') }}
</div>
</template>
</template>
<script setup lang="ts">
/**
* 会话列表体 — 拆自 ConversationSidebar.vue(C4a 大文件拆分,零行为变更)。
*
* 职责:侧栏列表两态渲染(搜索态平铺 / 时间分组+可恢复+归档)及项级自治状态:
* 重命名(editingConvId/editText)、更多操作菜单(actionsOpenId + document 级外部点击/Esc 关闭)、
* 切换过渡(pendingSwitchId)、项 DOM ref 收集(M3 展开折叠组后 scrollIntoView)、
* 标题生成淡入(titleFlash)、时间分组归桶(groupedActive/archivedConvs)。
*
* 跨文件依赖(策略同侧栏第一批:store 共享走 useAiStore,不 props 传 store):
* - props.pendingCountOf:V1 待审批计数(主文件 computed + 2.5s 轮询兜底锚定,函数透传保响应式);
* - props.recoverable:G5 可恢复清单(主文件 onMounted 拉取,侧栏开合不重复 IPC),恢复动作 emit('recover');
* - emit('confirm-delete'):删除需父级 ConfirmDialog,侧栏主文件继续上抛 AiChat。
* 边界徽标只读消费 getConvBoundary(reactive Map,模板内读取可追踪),直接 import 同源 composable。
*/
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount, type ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../../stores/ai'
import { getConvBoundary } from '../../../composables/ai/useTaskBoundary'
import { convListPaging, loadMoreConversations } from '../../../composables/ai/useAiConversations'
import { formatRelative } from '../../../utils/time'
import type { AiConversationSummary } from '../../../api/types'
/** G5 可恢复会话条目(主文件数据源透传,字段口径同 aiApi.listRecoverable) */
interface RecoverableConv { id: string; title: string | null; model: string | null; updated_at: string }
defineProps<{
/** V1 跨会话待审批计数(主文件 computed:三响应式源锚定 + 轮询兜底;函数透传,模板调用可追踪) */
pendingCountOf: (convId: string) => number
/** G5 可恢复分组数据(主文件拉取与防重,本组件纯渲染 + emit 恢复动作) */
recoverable: RecoverableConv[]
}>()
const emit = defineEmits<{
/** 删除对话(需二次确认),上抛侧栏主文件继续交 AiChat 弹 ConfirmDialog */
(e: 'confirm-delete', id: string): void
/** G5 恢复 parked 会话,主文件持 recoveringId 防重 + toast 反馈 */
(e: 'recover', id: string): void
}>()
const { t } = useI18n()
const store = useAiStore()
// ── 标题生成淡入(UX-2025-20 §7.4):监听当前对话标题从空→非空触发 ──
const titleFlash = ref(false)
let _titleFlashTimer: ReturnType<typeof setTimeout> | null = null
const activeConv = computed(() =>
store.state.conversations.find(c => c.id === store.state.activeConversationId) || null,
)
watch(
() => activeConv.value?.title,
(newTitle, oldTitle) => {
// 标题从空→非空才触发淡入,其他变化不闪
if (!newTitle || oldTitle) return
titleFlash.value = true
if (_titleFlashTimer) clearTimeout(_titleFlashTimer)
_titleFlashTimer = setTimeout(() => { titleFlash.value = false }, 320)
},
)
// ── 对话项 inline 重命名 ──
const editingConvId = ref<string | null>(null)
const editText = ref('')
let editEl: HTMLInputElement | null = null
function setEditEl(el: Element | { $el?: Element } | null): void {
editEl = (el as HTMLInputElement) || null
}
function startRename(conv: { id: string; title: string | null }) {
editingConvId.value = conv.id
editText.value = conv.title || ''
nextTick(() => {
editEl?.focus()
editEl?.select()
})
}
async function commitRename(id: string) {
const title = editText.value.trim()
if (title && editingConvId.value === id) {
await store.renameConversation(id, title)
}
editingConvId.value = null
}
function cancelRename() {
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()
}
}
// UX走查 P2-6: 会话项键盘可达(tabindex=0)。Enter/Space 等价点击选中;仅焦点落在本项 div
// 自身时处理——焦点在内部按钮/重命名输入框时 target≠currentTarget,避免与控件自身语义双触发。
// renamable=true 的会话项(搜索结果/时间分组,即双击标题可重命名的项)额外支持 F2 键盘重命名,
// 作为 .ai-conv-item-title 双击入口的键盘等价(文件管理器惯例;归档项不支持重命名故不传该标志)。
function onItemKeydown(e: KeyboardEvent, convId: string, renamable = false) {
if (e.isComposing || e.keyCode === 229) return
if (e.target !== e.currentTarget) return
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
beginSwitch(convId)
} else if (renamable && e.key === 'F2') {
e.preventDefault()
const conv = store.state.conversations.find(c => c.id === convId)
if (conv) startRename(conv)
}
}
// ── 会话项「更多」操作菜单(置顶/归档/删除)──
const actionsOpenId = ref<string | null>(null)
function toggleActionsMenu(id: string) {
actionsOpenId.value = actionsOpenId.value === id ? null : id
}
function closeActionsMenu() {
actionsOpenId.value = null
}
// 点击外部关闭更多操作菜单
function onActionsOutsideClick(e: MouseEvent) {
if (!actionsOpenId.value) return
const target = e.target as HTMLElement | null
if (target?.closest('.ai-conv-item-more')) return
actionsOpenId.value = null
}
// UX走查 P2-6: 操作菜单原仅外部点击可关,键盘用户开菜单后无 Esc 逃逸;IME 守卫防组词取消误关
function onActionsEscClose(e: KeyboardEvent) {
if (!actionsOpenId.value) return
if (e.isComposing || e.keyCode === 229) return
if (e.key === 'Escape') 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,退出搜索模式回原时间分组视图。
* M3:命中项可能在折叠的时间分组或折叠的归档分组内,选中态不可见。切回分组视图后展开所在分组
* 并 scrollIntoView,确保选中项立即可见(原仅切 searchQuery,选中项落在折叠组里看不到高亮)。
*/
function onSelectSearchResult(id: string) {
// 走 beginSwitch 挂切换过渡态(乐观预高亮),与分组视图点击口径一致
beginSwitch(id)
store.state.searchQuery = ''
// nextTick 后视图已切回分组态;ensureVisible 展开所在组 + 滚动定位
void nextTick(() => ensureActiveVisible())
}
// ── M3:活跃会话项 DOM ref 收集(id → el),用于展开折叠组后 scrollIntoView ──
// Vue 3 函数 ref:每项 :ref="(el) => setItemEl(id, el)"。卸载时 el 为 null,删 key 防 Map 泄漏。
const itemEls = new Map<string, Element>()
function setItemEl(id: string, el: Element | ComponentPublicInstance | null): void {
if (!el) {
itemEls.delete(id)
return
}
// 组件实例取 $el,但本项是普通 div(el 即 Element)
itemEls.set(id, el as Element)
}
/**
* M3:确保当前活跃会话在分组视图可见。若它落在折叠的时间分组(今天/昨天/更早)或折叠的归档分组,
* 展开该组让 v-show 渲染出来,再 scrollIntoView。用于搜索点选、切对话后选中态不被折叠吞掉。
*/
function ensureActiveVisible(): void {
const id = store.state.activeConversationId
if (!id) return
const conv = store.state.conversations.find(c => c.id === id)
if (!conv) return
// 先按归档态/时间桶决定应展开哪个组折叠态
if (conv.archived) {
if (store.state.archivedCollapsed) store.state.archivedCollapsed = false
} else {
const bucket = timeBucket(conv.updated_at)
if (store.state.foldedGroups[bucket]) store.state.foldedGroups[bucket] = false
}
// 折叠态翻转后 v-show 需一帧才渲染,再 nextTick 取 el 滚动
void nextTick(() => {
const el = itemEls.get(id)
if (el) {
;(el as HTMLElement).scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
})
}
/** 该会话是否已授权无人值守边界(徽标显隐)。直接读 reactive Map,渲染效应自动追踪增删 */
function hasBoundary(convId: string): boolean {
return getConvBoundary(convId) !== null
}
// ── 对话分组:今天 / 昨天 / 更早 ──
const bucketLabels = computed<Record<string, string>>(() => ({
today: t('aiChat.today'),
yesterday: t('aiChat.yesterday'),
earlier: t('aiChat.earlier'),
}))
function timeBucket(ts: string): 'today' | 'yesterday' | 'earlier' {
const t = parseInt(ts, 10)
if (!Number.isFinite(t)) return 'earlier'
const d = new Date(t)
const now = new Date()
const sameDay = d.getFullYear() === now.getFullYear()
&& d.getMonth() === now.getMonth()
&& d.getDate() === now.getDate()
if (sameDay) return 'today'
const yest = new Date(now)
yest.setDate(now.getDate() - 1)
if (d.getFullYear() === yest.getFullYear()
&& d.getMonth() === yest.getMonth()
&& d.getDate() === yest.getDate()) return 'yesterday'
return 'earlier'
}
const groupedActive = computed(() => {
const map = new Map<string, AiConversationSummary[]>()
for (const c of store.state.conversations) {
if (c.archived) continue
const b = timeBucket(c.updated_at)
if (!map.has(b)) map.set(b, [])
map.get(b)!.push(c)
}
for (const items of map.values()) {
items.sort((a, b) => Number(b.pinned ? 1 : 0) - Number(a.pinned ? 1 : 0)
|| Number(b.updated_at) - Number(a.updated_at))
}
const groups: { key: string; label: string; items: AiConversationSummary[] }[] = []
for (const key of ['today', 'yesterday', 'earlier']) {
const items = map.get(key) || []
if (key === 'today' || items.length) groups.push({ key, label: bucketLabels.value[key], items })
}
return groups
})
const archivedConvs = computed(() =>
store.state.conversations
.filter(c => c.archived)
.sort((a, b) => Number(b.pinned ? 1 : 0) - Number(a.pinned ? 1 : 0)
|| Number(b.updated_at) - Number(a.updated_at)),
)
const searchResults = computed<AiConversationSummary[] | null>(
() => store.filteredConversations.value,
)
// ── F4-1: 会话列表分页(加载更多按钮 + 搜索全量兜底) ──
/** 「加载更多」:续拉下一页(失败/无新增静默,按钮 loading 态由 convListPaging 驱动) */
function onLoadMore(): void {
void loadMoreConversations()
}
/** G5 恢复动作透传主文件(主文件持 recoveringId 防重与 toast 反馈),模板零改动 */
function onRecover(id: string) {
emit('recover', id)
}
onMounted(() => {
// 点击外部关闭更多操作菜单
document.addEventListener('click', onActionsOutsideClick)
// 操作菜单 Esc 关闭(P2-6,与外部点击同生命周期)
document.addEventListener('keydown', onActionsEscClose)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onActionsOutsideClick)
document.removeEventListener('keydown', onActionsEscClose)
// 释放标题淡入计时器
if (_titleFlashTimer) { clearTimeout(_titleFlashTimer); _titleFlashTimer = null }
})
</script>
<!-- ═══ 会话列表体 scoped 样式(拆自 ConversationSidebar.vue,零视觉变更;列表滚动容器
.ai-conv-sidebar/.ai-conv-list 等壳层样式留主文件,本组件只持项/分组/菜单/徽标层) ═══ -->
<style scoped>
/* wave2 u-agent 范式: 分组头 10px uppercase + letter-spacing 1px + dim */
.ai-conv-group-title {
padding: 10px 8px 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--df-text-dim);
user-select: none;
}
.ai-conv-group-title--fold {
cursor: pointer;
display: flex;
align-items: center;
gap: 3px;
}
.ai-conv-group-title--fold:hover {
color: var(--df-text);
}
.ai-conv-group-arrow {
display: inline-block;
transition: transform 0.15s;
font-size: 9px;
}
.ai-conv-group-arrow.is-folded {
transform: rotate(0deg);
}
.ai-conv-group-arrow:not(.is-folded) {
transform: rotate(90deg);
}
.ai-conv-group-count {
margin-left: 2px;
font-weight: 400;
font-family: var(--df-font-mono); /* mono 计数(df-mono 范式),upper/lsp 回退保证数字不散 */
font-size: 10px;
letter-spacing: 0;
opacity: 0.9;
}
.ai-conv-group-empty {
padding: 2px 8px 4px 20px;
font-size: 10px;
color: var(--df-text-dim);
font-style: italic;
}
/* wave2 u-agent .cv 范式: 单行条目,8px 级密度;hover/active 同走灰阶卡底(GitHub 两级同底、
active 由左 2px accent 实线区分),深浅主题同 token 自动适配 */
.ai-conv-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px;
border-radius: var(--df-radius);
cursor: pointer;
transition: background 0.15s;
gap: 6px;
}
.ai-conv-item:hover {
background: var(--df-bg-card-hover);
}
.ai-conv-item--active {
background: var(--df-bg-card-hover);
box-shadow: inset 2px 0 0 var(--df-accent);
}
/* 切换进行时(UX走查 P1):与活跃项同底色示"已选中"(乐观预高亮),降不透明度示"加载中";
IPC 返回后 activeConversationId 落定、本类移除,视觉无缝衔接;失败回滚高亮回原活跃项 */
.ai-conv-item--switching {
background: var(--df-bg-card-hover);
opacity: 0.7;
}
.ai-conv-item--archived .ai-conv-item-title {
opacity: 0.6;
}
/* wave2: 行内布局对齐 .cv-top([状态点][标题省略][时间]) */
.ai-conv-item-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
}
.ai-conv-item-title {
flex: 1;
min-width: 0;
font-size: 12px;
font-weight: 500;
color: var(--df-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 3px;
}
/* UX-2025-20 §7.4: AI 生成标题淡入(0.3s opacity + transform 过渡) */
.ai-conv-item-title--flash {
animation: ai-title-flash 0.3s var(--df-ease);
}
.ai-conv-item-title-badge {
flex-shrink: 0;
color: var(--df-success);
}
@keyframes ai-title-flash {
from { opacity: 0; transform: translateX(-3px); }
to { opacity: 1; transform: translateX(0); }
}
.ai-conv-item-edit {
flex: 1 1 0; /* wave2: main 已行化,输入框按 flex 填充(min-width:0 防 input 固有宽撑破窄侧栏) */
min-width: 0;
width: auto;
font-size: 11px;
color: var(--df-text);
height: 18px;
padding: 0 4px;
border: 1px solid var(--df-accent);
border-radius: var(--df-radius-sm);
background: var(--df-bg);
outline: none;
box-sizing: border-box;
}
/* 聚焦可见环:边框恒为 accent 聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
.ai-conv-item-edit:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
/* wave2 u-agent 范式: 时间/计数元数据 mono 10px dim(.df-mono 同款视觉) */
.ai-conv-item-time {
flex: none;
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text-dim);
}
/* wave2 u-agent .st 状态点变体: 公共 .df-dot/.df-dot--run/.df-dot--stop 管造型与绿/灰底,
此处只补动效与缺位色(全 token): run 绿+glow+脉冲(global df-pulse)、danger 待审批红、paused 停靠琥珀 */
.ai-conv-status--run { box-shadow: 0 0 6px var(--df-success); animation: df-pulse 1.5s infinite; }
.ai-conv-status--danger { background: var(--df-danger); }
.ai-conv-status--paused { background: var(--df-warning); }
@keyframes ai-conv-gen-pulse {
/* 供 .ai-conv-recover-dot 分组级脉冲用(原行内 gen-dot 已由状态点接管) */
0%, 100% { opacity: 0.35; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1.15); }
}
/* G5 可恢复分组(parked 会话,amber 系提示「需注意/可继续」语义;⏸ 徽标+分组标题小圆点) */
.ai-conv-recover-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 5px;
background: var(--df-warning);
animation: ai-conv-gen-pulse 2s ease-in-out infinite;
}
.ai-conv-item--recoverable {
cursor: pointer;
}
.ai-conv-recover-badge {
flex-shrink: 0;
margin-left: 2px;
font-size: 10px;
line-height: 1;
color: var(--df-warning);
cursor: help;
}
/* V1: 跨会话待审批徽标(橙点计数,warning 色系与待审批语义一致;固定尺寸不挤标题换行) */
.ai-conv-approve-badge { flex-shrink: 0;
min-width: 12px;
height: 12px;
padding: 0 3px;
border-radius: 6px;
background: var(--df-warning);
color: #000;
font-size: 9px;
font-weight: 600;
line-height: 12px;
text-align: center;
cursor: help;
}
/* 无人值守边界徽标(盾形,accent 色系与「已授权」语义一致;纯标记不计数) */
.ai-conv-boundary-badge {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
border-radius: 6px;
background: color-mix(in srgb, var(--df-accent) 18%, transparent);
color: var(--df-accent);
cursor: help;
}
.ai-conv-item-actions {
display: none;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.ai-conv-item:hover .ai-conv-item-actions {
display: flex;
}
/* UX走查 P2-6: 键盘 Tab 进会话项(项自身 tabindex=0,或焦点在项内按钮/输入框)时同样浮出
操作按钮——原仅 :hover 渲染,键盘用户永远够不到「...」菜单触发点 */
.ai-conv-item:focus-within .ai-conv-item-actions {
display: flex;
}
/* 菜单展开时强制 actions 可见(鼠标移到菜单上 item 不再 hover,防菜单消失) */
.ai-conv-item:has(.ai-conv-actions-menu) .ai-conv-item-actions {
display: flex;
}
.ai-conv-item-act {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: none;
border-radius: var(--df-radius-sm);
background: transparent;
color: var(--df-text-dim);
cursor: pointer;
flex-shrink: 0;
}
.ai-conv-item-act:hover {
background: var(--df-sidebar-hover);
color: var(--df-text);
}
/* 已置顶对话视觉强调(左边线,不依赖菜单展开,一眼识别)。
pinned 类挂在 .ai-conv-item 上(操作收进更多菜单后,按钮不再常驻,改 item 级类)。
wave2: 选中态同占左缘,置顶线降为 45% 透明 accent 让位,选中实线优先 */
.ai-conv-item--pinned {
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--df-accent) 45%, transparent);
}
.ai-conv-item--active.ai-conv-item--pinned {
box-shadow: inset 2px 0 0 var(--df-accent);
}
/* ── 「更多」操作菜单(置顶/归档/删除)──
「...」按钮 hover 时随 actions 浮出(同原 4 按钮交互),点击展开下拉。
菜单绝对定位浮于 item 下方右侧,窄侧栏避免溢出。 */
.ai-conv-item-more {
position: relative;
display: flex;
align-items: center;
flex-shrink: 0;
}
.ai-conv-actions-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 2px;
z-index: 20;
min-width: 104px;
padding: 2px;
display: flex;
flex-direction: column;
gap: 1px;
background: var(--df-bg);
border: 1px solid var(--df-border);
border-radius: var(--df-radius-sm);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.ai-conv-actions-opt {
display: flex;
align-items: center;
gap: 6px;
border: none;
background: transparent;
text-align: left;
padding: 5px 8px;
font-size: 11px;
color: var(--df-text);
cursor: pointer;
border-radius: var(--df-radius-sm);
white-space: nowrap;
}
.ai-conv-actions-opt:hover {
background: var(--df-sidebar-hover);
}
.ai-conv-actions-opt--danger:hover {
background: var(--df-danger-bg);
color: var(--df-danger);
}
.ai-conv-empty {
padding: 16px 8px;
text-align: center;
font-size: 11px;
color: var(--df-text-dim);
}
/* F4-1: 列表底部「加载更多」(轻量文字行,hover 同列表项灰阶卡底范式) */
.ai-conv-load-more {
margin: 4px 2px;
padding: 6px 8px;
border-radius: var(--df-radius);
font-size: 11px;
color: var(--df-text-dim);
text-align: center;
cursor: pointer;
user-select: none;
}
.ai-conv-load-more:hover {
background: var(--df-bg-card-hover);
color: var(--df-text);
}
</style>
+50 -62
View File
@@ -114,7 +114,7 @@
</div> </div>
</div> </div>
<div v-else class="eval-report eval-report--muted"> <div v-else class="eval-report eval-report--muted">
<button class="btn-evaluate" :disabled="evaluating" @click="$emit('evaluate')"> <button class="btn btn-primary" :disabled="evaluating" @click="$emit('evaluate')">
{{ evaluating ? $t('ideas.evaluating') : $t('ideas.startEval') }} {{ evaluating ? $t('ideas.evaluating') : $t('ideas.startEval') }}
</button> </button>
<!-- P1-⑥ 重试样式:错误提示分两行,重试用 ghost btn-sm(替代突兀的内联 accent 按钮) --> <!-- P1-⑥ 重试样式:错误提示分两行,重试用 ghost btn-sm(替代突兀的内联 accent 按钮) -->
@@ -690,7 +690,7 @@ watch(() => props.idea.id, loadHistory)
gap: 12px; gap: 12px;
margin-bottom: var(--df-gap-head); margin-bottom: var(--df-gap-head);
} }
.detail-title { font-size: 20px; font-weight: 500; color: var(--df-text); flex: 1; min-width: 0; } .detail-title { font-size: var(--df-fs-title); font-weight: 500; color: var(--df-text); flex: 1; min-width: 0; }
/* P0-② 标题行右侧操作组:立项按钮 + 状态 badge 一行排布 */ /* P0-② 标题行右侧操作组:立项按钮 + 状态 badge 一行排布 */
.detail-header-actions { .detail-header-actions {
display: flex; display: flex;
@@ -752,7 +752,7 @@ watch(() => props.idea.id, loadHistory)
padding: 14px 16px; padding: 14px 16px;
background: var(--df-accent-bg); background: var(--df-accent-bg);
border-left: 3px solid var(--df-accent); border-left: 3px solid var(--df-accent);
border-radius: 0 8px 8px 0; border-radius: 0 var(--df-radius-md) var(--df-radius-md) 0;
} }
/* 弱化态(空数据/加载中占位,原 inline opacity:0.5 统一为 class) */ /* 弱化态(空数据/加载中占位,原 inline opacity:0.5 统一为 class) */
.eval-report--muted { opacity: 0.5; } .eval-report--muted { opacity: 0.5; }
@@ -764,47 +764,48 @@ watch(() => props.idea.id, loadHistory)
background: var(--df-bg-raised); background: var(--df-bg-raised);
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius); border-radius: var(--df-radius);
padding: 1rem; padding: 13px;
margin-bottom: 1rem; margin-bottom: 13px;
} }
.debate-container { .debate-container {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 1rem; gap: 13px;
margin-bottom: 1.5rem; margin-bottom: 20px;
} }
.debate-column { .debate-column {
padding: 1rem; padding: 13px;
border-radius: var(--df-radius); border-radius: var(--df-radius);
position: relative; position: relative;
} }
/* 正反方论据栏底/边走语义 token(双主题自适应) */
.debate-column.positive { .debate-column.positive {
background: rgba(61, 219, 160, 0.08); background: var(--df-success-bg);
border: 0.5px solid rgba(61, 219, 160, 0.2); border: 0.5px solid var(--df-success-bd);
} }
.debate-column.negative { .debate-column.negative {
background: rgba(240, 101, 101, 0.08); background: var(--df-danger-bg);
border: 0.5px solid rgba(240, 101, 101, 0.2); border: 0.5px solid var(--df-danger-bd);
} }
.debate-column h4 { .debate-column h4 {
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
margin: 0 0 0.5rem; margin: 0 0 7px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 7px;
} }
.confidence-bar { .confidence-bar {
height: 4px; height: 4px;
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
border-radius: var(--df-radius-xs); border-radius: var(--df-radius-xs);
margin-bottom: 0.25rem; margin-bottom: 3px;
overflow: hidden; overflow: hidden;
} }
@@ -820,13 +821,13 @@ watch(() => props.idea.id, loadHistory)
.confidence-text { .confidence-text {
font-size: 11px; font-size: 11px;
color: var(--df-text-dim); color: var(--df-text-dim);
margin-bottom: 0.5rem; margin-bottom: 7px;
} }
.debate-column .thesis { .debate-column .thesis {
font-size: 12px; font-size: 12px;
color: var(--df-text); color: var(--df-text);
margin-bottom: 0.5rem; margin-bottom: 7px;
font-weight: 500; font-weight: 500;
} }
@@ -839,16 +840,16 @@ watch(() => props.idea.id, loadHistory)
.debate-column li { .debate-column li {
font-size: 11px; font-size: 11px;
color: var(--df-text-secondary); color: var(--df-text-secondary);
margin-bottom: 0.25rem; margin-bottom: 3px;
padding-left: 0.5rem; padding-left: 7px;
} }
.analyst-conclusion { .analyst-conclusion {
background: var(--df-bg-card); background: var(--df-bg-card);
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius); border-radius: var(--df-radius);
padding: 1rem; padding: 13px;
margin-bottom: 1rem; margin-bottom: 13px;
} }
/* assessment-badge 系列已提取到 styles/components.css 全局 */ /* assessment-badge 系列已提取到 styles/components.css 全局 */
@@ -857,39 +858,39 @@ watch(() => props.idea.id, loadHistory)
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
color: var(--df-text); color: var(--df-text);
margin: 0.5rem 0; margin: 7px 0;
} }
.net-sentiment { .net-sentiment {
font-size: 12px; font-size: var(--df-fs-ctrl);
padding: 4px 8px; padding: var(--df-pad-badge);
border-radius: var(--df-radius-xs); border-radius: var(--df-radius-xs);
display: inline-block; display: inline-block;
margin: 0.5rem 0; margin: 7px 0;
} }
.net-sentiment.positive { background: rgba(61, 219, 160, 0.15); color: var(--df-success); } .net-sentiment.positive { background: var(--df-success-bg); color: var(--df-success); }
.net-sentiment.negative { background: rgba(240, 101, 101, 0.15); color: var(--df-danger); } .net-sentiment.negative { background: var(--df-danger-bg); color: var(--df-danger); }
.net-sentiment.neutral { background: var(--df-bg-raised); color: var(--df-text-secondary); } .net-sentiment.neutral { background: var(--df-bg-raised); color: var(--df-text-secondary); }
.summary { .summary {
font-size: 12px; font-size: 12px;
color: var(--df-text-secondary); color: var(--df-text-secondary);
line-height: 1.5; line-height: 1.5;
margin: 0.5rem 0; margin: 7px 0;
} }
.action-recommendations { .action-recommendations {
background: var(--df-bg-raised); background: var(--df-bg-raised);
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius); border-radius: var(--df-radius);
padding: 1rem; padding: 13px;
} }
.action-recommendations h4 { .action-recommendations h4 {
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
margin: 0 0 0.5rem; margin: 0 0 7px;
} }
.action-recommendations ul { .action-recommendations ul {
@@ -901,12 +902,12 @@ watch(() => props.idea.id, loadHistory)
.action-recommendations li { .action-recommendations li {
font-size: 12px; font-size: 12px;
color: var(--df-text-secondary); color: var(--df-text-secondary);
margin-bottom: 0.25rem; margin-bottom: 3px;
padding-left: 0.5rem; padding-left: 7px;
} }
/* P0-① 详细论据折叠切换条 */ /* P0-① 详细论据折叠切换条 */
.debate-toggle-row { margin-bottom: 0.75rem; } .debate-toggle-row { margin-bottom: 10px; }
.debate-toggle-btn { .debate-toggle-btn {
background: transparent; background: transparent;
border: none; border: none;
@@ -924,23 +925,10 @@ watch(() => props.idea.id, loadHistory)
transition: transform 0.15s; transition: transform 0.15s;
} }
.debate-toggle-arrow.is-open { transform: rotate(180deg); } .debate-toggle-arrow.is-open { transform: rotate(180deg); }
.debate-detail { margin-top: 0.5rem; } .debate-detail { margin-top: 7px; }
.debate-detail .debate-container { margin-bottom: 0.75rem; } .debate-detail .debate-container { margin-bottom: 10px; }
.btn-evaluate { /* 「开始评估」按钮并轨全局 .btn .btn-primary(30px 主档),本地副本删除 */
background: var(--df-accent);
color: #fff;
border: none;
padding: 8px 16px;
border-radius: var(--df-radius);
font-size: 13px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.btn-evaluate:hover {
background: var(--df-accent-hover);
}
/* P1-⑥ 重试样式:错误提示与重试按钮分行,重试用 ghost btn-sm(替代突兀的内联 accent 按钮) */ /* P1-⑥ 重试样式:错误提示与重试按钮分行,重试用 ghost btn-sm(替代突兀的内联 accent 按钮) */
.eval-error { .eval-error {
@@ -952,11 +940,11 @@ watch(() => props.idea.id, loadHistory)
.btn-retry { margin-top: 4px; } .btn-retry { margin-top: 4px; }
.eval-mode-tag { .eval-mode-tag {
font-size: 11px; font-size: var(--df-fs-aux);
font-weight: 400; font-weight: 400;
padding: 1px 8px; padding: var(--df-pad-chip);
border-radius: var(--df-radius-xs); border-radius: var(--df-radius-xs);
background: rgba(255, 217, 61, 0.15); background: var(--df-warning-bg);
color: var(--df-warning); color: var(--df-warning);
margin-left: 6px; margin-left: 6px;
vertical-align: middle; vertical-align: middle;
@@ -991,26 +979,26 @@ watch(() => props.idea.id, loadHistory)
font-size: 12px; font-size: 12px;
} }
.version-badge { .version-badge {
font-size: 11px; font-size: var(--df-fs-aux);
font-weight: 500; font-weight: 500;
padding: 2px 8px; padding: var(--df-pad-badge);
border-radius: var(--df-radius-xs); border-radius: var(--df-radius-xs);
background: var(--df-accent-soft); background: var(--df-accent-soft);
color: var(--df-accent); color: var(--df-accent);
} }
.latest-tag { .latest-tag {
font-size: 10px; font-size: var(--df-fs-meta);
font-weight: 500; font-weight: 500;
padding: 1px 6px; padding: var(--df-pad-chip);
border-radius: var(--df-radius-xs); border-radius: var(--df-radius-xs);
background: rgba(100, 255, 218, 0.15); background: var(--df-success-bg);
color: var(--df-success); color: var(--df-success);
} }
.history-eval-mode { .history-eval-mode {
font-size: 11px; font-size: var(--df-fs-aux);
padding: 1px 6px; padding: var(--df-pad-chip);
border-radius: var(--df-radius-xs); border-radius: var(--df-radius-xs);
background: rgba(255, 217, 61, 0.12); background: var(--df-warning-bg);
color: var(--df-warning); color: var(--df-warning);
} }
.history-score { .history-score {
@@ -1046,7 +1034,7 @@ watch(() => props.idea.id, loadHistory)
/* ===== 标签 ===== */ /* ===== 标签 ===== */
.tag-list { display: flex; gap: 8px; flex-wrap: wrap; } .tag-list { display: flex; gap: 8px; flex-wrap: wrap; }
.tag { .tag {
font-size: 12px; padding: 4px 10px; font-size: var(--df-fs-ctrl); padding: var(--df-pad-badge);
border-radius: var(--df-radius-xs); border-radius: var(--df-radius-xs);
background: var(--df-accent-bg); background: var(--df-accent-bg);
color: var(--df-accent); color: var(--df-accent);
@@ -1055,7 +1043,7 @@ watch(() => props.idea.id, loadHistory)
/* ===== 关联灵感 ===== */ /* ===== 关联灵感 ===== */
.related-list { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; } .related-list { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.related-chip { .related-chip {
font-size: 12px; padding: 4px 10px; font-size: var(--df-fs-ctrl); padding: var(--df-pad-badge);
border-radius: var(--df-radius-xs); border-radius: var(--df-radius-xs);
background: var(--df-accent-bg); background: var(--df-accent-bg);
color: var(--df-accent); color: var(--df-accent);
+9 -8
View File
@@ -400,13 +400,14 @@ watch(renderedContent, () => {
/* 状态徽章 / 置信度 */ /* 状态徽章 / 置信度 */
.kn-card-status { font-size: 11px; font-weight: 500; padding: 2px 8px; border-radius: var(--df-radius-xs); white-space: nowrap; } .kn-card-status { font-size: 11px; font-weight: 500; padding: 2px 8px; border-radius: var(--df-radius-xs); white-space: nowrap; }
.st-candidate { background: rgba(255,217,61,0.15); color: var(--df-warning); } /* 状态/置信度徽章底统一走语义 token(双主题自适应,与 Knowledge.vue 同规) */
.st-pending_review { background: rgba(108,159,255,0.15); color: var(--df-info); } .st-candidate { background: var(--df-warning-bg); color: var(--df-warning); }
.st-published { background: rgba(100,255,218,0.15); color: var(--df-success); } .st-pending_review { background: var(--df-info-bg); color: var(--df-info); }
.st-archived { background: rgba(90,99,128,0.2); color: var(--df-text-dim); } .st-published { background: var(--df-success-bg); color: var(--df-success); }
.conf-high { background: rgba(100,255,218,0.15); color: var(--df-success); padding: 1px 6px; border-radius: var(--df-radius-xs); } .st-archived { background: var(--df-neutral-bg); color: var(--df-text-dim); }
.conf-medium { background: rgba(255,217,61,0.15); color: var(--df-warning); padding: 1px 6px; border-radius: var(--df-radius-xs); } .conf-high { background: var(--df-success-bg); color: var(--df-success); padding: 1px 6px; border-radius: var(--df-radius-xs); }
.conf-low { background: rgba(255,107,107,0.15); color: var(--df-danger); padding: 1px 6px; border-radius: var(--df-radius-xs); } .conf-medium { background: var(--df-warning-bg); color: var(--df-warning); padding: 1px 6px; border-radius: var(--df-radius-xs); }
.conf-low { background: var(--df-danger-bg); color: var(--df-danger); padding: 1px 6px; border-radius: var(--df-radius-xs); }
/* 溯源 */ /* 溯源 */
.section-title { font-size: 13px; font-weight: 500; color: var(--df-text); margin-bottom: 10px; } .section-title { font-size: 13px; font-weight: 500; color: var(--df-text); margin-bottom: 10px; }
@@ -439,7 +440,7 @@ watch(renderedContent, () => {
} }
.detail-content.content-collapsed::after { .detail-content.content-collapsed::after {
content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 28px; content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 28px;
background: linear-gradient(to bottom, transparent, var(--df-bg-panel, var(--df-bg-card))); background: linear-gradient(to bottom, transparent, var(--df-bg-card));
pointer-events: none; pointer-events: none;
} }
.detail-content { position: relative; } .detail-content { position: relative; }
+8 -8
View File
@@ -94,7 +94,7 @@ const knowledgeStore = useKnowledgeStore()
const appSettings = useAppSettingsStore() const appSettings = useAppSettingsStore()
// ── 侧边栏收缩状态(持久化到 appSettings,小窗口自动收缩) ── // ── 侧边栏收缩状态(持久化到 appSettings,小窗口自动收缩) ──
// 展开 192px(文字+图标) / 收缩 56px(仅图标,hover tooltip 显示文字) // 展开 220px(文字+图标) / 收缩 56px(仅图标,hover tooltip 显示文字)
// 优先级:用户手动设置 > 小窗口自动收缩(≤768px) // 优先级:用户手动设置 > 小窗口自动收缩(≤768px)
const COLLAPSE_THRESHOLD = 768 const COLLAPSE_THRESHOLD = 768
// useSetting 响应式读(loadAll 异步填充缓存后 watch 自动同步刷新)—— // useSetting 响应式读(loadAll 异步填充缓存后 watch 自动同步刷新)——
@@ -172,8 +172,8 @@ const secondaryNav = [
<style scoped> <style scoped>
.df-sidebar { .df-sidebar {
width: 192px; width: 220px;
min-width: 192px; min-width: 220px;
background: var(--df-sidebar-bg); background: var(--df-sidebar-bg);
border-right: 0.5px solid var(--df-border); border-right: 0.5px solid var(--df-border);
display: flex; display: flex;
@@ -219,8 +219,8 @@ const secondaryNav = [
font-family: var(--df-font-mono); font-family: var(--df-font-mono);
font-size: 10px; font-size: 10px;
color: var(--df-text-dim); color: var(--df-text-dim);
background: rgba(255,255,255,0.04); background: var(--df-hover);
padding: 1px 6px; padding: var(--df-pad-chip);
border-radius: var(--df-radius-xs); border-radius: var(--df-radius-xs);
} }
@@ -323,7 +323,7 @@ const secondaryNav = [
font-family: var(--df-font-mono); font-family: var(--df-font-mono);
font-size: 10px; font-size: 10px;
font-weight: 500; font-weight: 500;
padding: 1px 6px; padding: var(--df-pad-chip);
border-radius: var(--df-radius); border-radius: var(--df-radius);
min-width: 18px; min-width: 18px;
text-align: center; text-align: center;
@@ -360,8 +360,8 @@ const secondaryNav = [
font-family: var(--df-font-mono); font-family: var(--df-font-mono);
font-size: 9px; font-size: 9px;
color: var(--df-text-dim); color: var(--df-text-dim);
background: rgba(255,255,255,0.04); background: var(--df-hover);
padding: 1px 5px; padding: var(--df-pad-chip);
border-radius: var(--df-radius-sm); border-radius: var(--df-radius-sm);
} }
+7 -6
View File
@@ -167,7 +167,7 @@ async function onInstall() {
color: var(--df-accent); color: var(--df-accent);
font-size: 12px; font-size: 12px;
cursor: pointer; cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); box-shadow: var(--df-shadow-sm);
transition: filter 0.15s; transition: filter 0.15s;
} }
.update-badge-btn:hover { .update-badge-btn:hover {
@@ -193,7 +193,7 @@ async function onInstall() {
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg); border-radius: var(--df-radius-lg);
background: var(--df-bg-card); background: var(--df-bg-card);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); box-shadow: var(--df-shadow-sm);
} }
.update-pop-head { .update-pop-head {
font-size: 13px; font-size: 13px;
@@ -213,11 +213,12 @@ async function onInstall() {
.update-pop-progress { .update-pop-progress {
margin-bottom: 12px; margin-bottom: 12px;
} }
/* 进度条轨道:原 --df-bg-hover 为未定义幽灵 token(靠回退侥幸渲染),改中性底 */
.update-pop-bar { .update-pop-bar {
width: 100%; width: 100%;
height: 6px; height: 6px;
border-radius: 3px; border-radius: 3px;
background: var(--df-bg-hover, rgba(128, 128, 128, 0.2)); background: var(--df-neutral-bg);
overflow: hidden; overflow: hidden;
} }
.update-pop-fill { .update-pop-fill {
@@ -234,10 +235,10 @@ async function onInstall() {
.update-pop-error { .update-pop-error {
margin-bottom: 12px; margin-bottom: 12px;
padding: 8px 10px; padding: 8px 10px;
border-radius: var(--df-radius-md, 6px); border-radius: var(--df-radius-md);
background: rgba(220, 53, 69, 0.12); background: var(--df-danger-bg);
font-size: 12px; font-size: 12px;
color: var(--df-danger, #dc3545); color: var(--df-danger);
line-height: 1.5; line-height: 1.5;
word-break: break-all; word-break: break-all;
} }
+4 -16
View File
@@ -64,7 +64,7 @@
</div> </div>
<!-- M31: 审批失败反馈(store approveHumanApproval 内部 catch 吞错,失败时 pendingApproval 不清空) <!-- M31: 审批失败反馈(store approveHumanApproval 内部 catch 吞错,失败时 pendingApproval 不清空)
对话框保持开启,用户可看到错误并重试 --> 对话框保持开启,用户可看到错误并重试 -->
<div v-if="toast.visible" class="ad-toast" :class="'ad-toast-' + toast.type">{{ toast.msg }}</div> <div v-if="toast.visible" class="df-toast df-toast--bottom" :class="toast.type === 'error' ? 'df-toast--danger' : 'df-toast--' + toast.type">{{ toast.msg }}</div>
</div> </div>
</template> </template>
@@ -162,19 +162,7 @@ watch(() => pendingApproval.value, (newApproval) => {
</script> </script>
<style scoped> <style scoped>
/* M31: 审批失败 toast(scoped 自治,不引入全局样式;沿用 Projects.vue .toast 视觉规范) */ /* M31: 审批失败 toast(样式走全局 .df-toast 族+--bottom 底部变体;
.ad-toast { scoped 仅保留 z-index 覆写——本组件浮在弹层(z≈10000)之上,toast 须更高) */
position: fixed; .df-toast { z-index: 10001; }
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 10001;
padding: 10px 18px;
border-radius: var(--df-radius-md);
font-size: 13px;
font-weight: 500;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.3);
pointer-events: none;
}
.ad-toast-error { background: var(--df-danger); color: #fff; }
</style> </style>
+5 -4
View File
@@ -442,7 +442,7 @@ function bindEdgeDeleteButtons() {
graph.on('edge:mouseleave', onEdgeLeave) graph.on('edge:mouseleave', onEdgeLeave)
} }
/** 依赖类型 → 边颜色 */ /** 依赖类型 → 边颜色(X6 画布演示色板·品牌/演示豁免,不入 token) */
function depTypeColor(depType: string): string { function depTypeColor(depType: string): string {
switch (depType) { switch (depType) {
case 'api': return '#5378e8' case 'api': return '#5378e8'
@@ -490,7 +490,7 @@ onBeforeUnmount(() => {
height: 100%; height: 100%;
background: var(--df-bg); background: var(--df-bg);
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg, 8px); border-radius: var(--df-radius-lg);
overflow: hidden; overflow: hidden;
position: relative; position: relative;
} }
@@ -593,11 +593,12 @@ onBeforeUnmount(() => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 200; z-index: 200;
box-shadow: 0 2px 6px rgba(0,0,0,0.4); box-shadow: var(--df-shadow-md);
transition: background 0.15s; transition: background 0.15s;
pointer-events: auto; pointer-events: auto;
} }
/* hover 由 danger 派生加深,替换孤色 */
.dep-edge-delete-btn:hover { .dep-edge-delete-btn:hover {
background: #c24040; background: color-mix(in srgb, var(--df-danger) 75%, #000);
} }
</style> </style>
+18 -68
View File
@@ -488,11 +488,8 @@ onUnmounted(() => { _unlistenDataChanged?.() })
async function onScanSubmodules() { async function onScanSubmodules() {
moduleDropdownOpen.value = false moduleDropdownOpen.value = false
try { try {
const n = await moduleApi.scanProjectModules(props.projectId) await moduleApi.scanProjectModules(props.projectId)
await loadModules() await loadModules()
if (n > 0) {
console.log('[FileExplorer] 扫描到新工程:', n)
}
} catch (e) { } catch (e) {
Message.error(t('common.unknownError')) Message.error(t('common.unknownError'))
console.error('[FileExplorer] 扫描子仓库失败:', e) console.error('[FileExplorer] 扫描子仓库失败:', e)
@@ -594,7 +591,7 @@ async function onRemoveModule() {
max-height: 100%; max-height: 100%;
background: var(--df-bg); background: var(--df-bg);
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg, 8px); border-radius: var(--df-radius-lg);
overflow: hidden; overflow: hidden;
} }
@@ -625,7 +622,7 @@ async function onRemoveModule() {
padding: 5px 10px; padding: 5px 10px;
background: var(--df-bg); background: var(--df-bg);
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm, 4px); border-radius: var(--df-radius-sm);
color: var(--df-text); color: var(--df-text);
font-size: 12px; font-size: 12px;
cursor: pointer; cursor: pointer;
@@ -651,12 +648,11 @@ async function onRemoveModule() {
max-width: 320px; max-width: 320px;
background: var(--df-bg-card); background: var(--df-bg-card);
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm, 4px); border-radius: var(--df-radius-sm);
box-shadow: 0 6px 16px rgba(0,0,0,0.25); box-shadow: var(--df-shadow-sm);
z-index: 20; z-index: 20;
max-height: 280px; max-height: 280px;
overflow-y: auto; overflow-y: auto;
scrollbar-width: thin;
} }
.module-dropdown-item { .module-dropdown-item {
@@ -665,10 +661,10 @@ async function onRemoveModule() {
gap: 2px; gap: 2px;
padding: 8px 12px; padding: 8px 12px;
cursor: pointer; cursor: pointer;
border-bottom: 0.5px solid rgba(255,255,255,0.04); border-bottom: 0.5px solid var(--df-hover);
} }
.module-dropdown-item:last-child { border-bottom: none; } .module-dropdown-item:last-child { border-bottom: none; }
.module-dropdown-item:hover { background: rgba(255,255,255,0.04); } .module-dropdown-item:hover { background: var(--df-hover); }
.module-dropdown-item.active { .module-dropdown-item.active {
background: rgba(255,255,255,0.06); background: rgba(255,255,255,0.06);
border-left: 2px solid var(--df-accent); border-left: 2px solid var(--df-accent);
@@ -778,7 +774,7 @@ async function onRemoveModule() {
.view-tab:hover { .view-tab:hover {
color: var(--df-text); color: var(--df-text);
background: rgba(255,255,255,0.03); background: var(--df-hover);
} }
.view-tab.active { .view-tab.active {
@@ -809,7 +805,9 @@ async function onRemoveModule() {
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
padding: 4px 4px 4px 0; padding: 4px 4px 4px 0;
scrollbar-width: thin; /* 滚动条样式走全局 ::-webkit-scrollbar(5px + var(--df-scrollbar) 主题色):
此处不得设 scrollbar-width —— Chromium 121+ 一旦设了标准 scrollbar-* 属性,
该元素 ::-webkit-scrollbar 定制即失效,文件列表会回退成原生样式滚动条(不随主题) */
} }
.explorer-changes { .explorer-changes {
@@ -817,13 +815,6 @@ async function onRemoveModule() {
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
min-height: 0; min-height: 0;
scrollbar-width: thin;
}
.explorer-changes .gc-body {
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
} }
.tree-loading { .tree-loading {
@@ -865,8 +856,8 @@ async function onRemoveModule() {
.tree-loading .spinner { .tree-loading .spinner {
width: 20px; width: 20px;
height: 20px; height: 20px;
border: 2px solid var(--df-border, #444); border: 2px solid var(--df-border);
border-top-color: var(--df-accent, #3a8); border-top-color: var(--df-accent);
border-radius: 50%; border-radius: 50%;
animation: df-spin 0.8s linear infinite; animation: df-spin 0.8s linear infinite;
} }
@@ -888,43 +879,8 @@ async function onRemoveModule() {
min-height: 200px; min-height: 200px;
} }
/* 全局滚动条样式(薄) */ /* 滚动条统一样式(styles/global.css 全局 ::-webkit-scrollbar:5px + var(--df-scrollbar))已覆盖,
.explorer-sidebar ::-webkit-scrollbar { 此处不重复声明 —— 原局部副本硬编码白色系 rgba,浅色主题下 thumb 不可见 */
width: 5px;
height: 5px;
}
.explorer-sidebar ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: var(--df-radius-xs);
}
.explorer-sidebar ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.14);
}
.explorer-sidebar ::-webkit-scrollbar-track {
background: transparent;
}
/* 文件列表(.explorer-tree)滚动条显式对齐全局,与右侧预览区一致 */
.explorer-tree ::-webkit-scrollbar {
width: 5px;
height: 5px;
}
.explorer-tree ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: var(--df-radius-xs);
}
.explorer-tree ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.14);
}
.explorer-tree ::-webkit-scrollbar-track {
background: transparent;
}
/* 窄窗口自适应 */ /* 窄窗口自适应 */
@media (max-width: 900px) { @media (max-width: 900px) {
@@ -946,20 +902,14 @@ async function onRemoveModule() {
} }
} }
/* spinner(刷新按钮内) */ /* spinner(刷新按钮内);df-spin keyframes 消费 global.css 全局定义(原本地重复定义已删) */
.spinner { .spinner {
width: 10px; width: 10px;
height: 10px; height: 10px;
border: 1.5px solid var(--df-border, #444); border: 1.5px solid var(--df-border);
border-top-color: var(--df-accent, #3a8); border-top-color: var(--df-accent);
border-radius: 50%; border-radius: 50%;
animation: df-spin 0.8s linear infinite; animation: df-spin 0.8s linear infinite;
display: inline-block; display: inline-block;
} }
@keyframes df-spin {
to {
transform: rotate(360deg);
}
}
</style> </style>
+24 -36
View File
@@ -171,7 +171,7 @@
import { ref, watch, computed, onUnmounted, nextTick } from 'vue' import { ref, watch, computed, onUnmounted, nextTick } from 'vue'
import { moduleApi, type OutlineSymbol } from '@/api/module' import { moduleApi, type OutlineSymbol } from '@/api/module'
import hljs from 'highlight.js/lib/common' import hljs from 'highlight.js/lib/common'
import { useMarkdown, useRendered } from '@/composables/useMarkdown' import { useRendered } from '@/composables/useMarkdown'
import { escapeHtml } from '@/utils/html' import { escapeHtml } from '@/utils/html'
const props = defineProps<{ const props = defineProps<{
@@ -184,7 +184,6 @@ const props = defineProps<{
externalDiff?: string externalDiff?: string
}>() }>()
const { } = useMarkdown()
const content = ref('') const content = ref('')
const htmlContent = ref('') const htmlContent = ref('')
const loading = ref(false) const loading = ref(false)
@@ -469,12 +468,12 @@ const outlineGroups = computed(() => {
.map((g) => ({ key: g, labelKey: GROUP_LABEL_KEY[g], items: buckets.get(g)! })) .map((g) => ({ key: g, labelKey: GROUP_LABEL_KEY[g], items: buckets.get(g)! }))
}) })
/** 点击符号滚动到对应行:偏移 = 代码区 padding-top 14px + (line-1) × 行高(12.5px × 1.55)。 */ /** 点击符号滚动到对应行:偏移 = 代码区 padding-top 14px + (line-1) × 行高(12px × 1.55)。 */
function gotoOutlineLine(line: number) { function gotoOutlineLine(line: number) {
activeOutlineLine.value = line activeOutlineLine.value = line
const el = codeScrollRef.value const el = codeScrollRef.value
if (!el) return if (!el) return
const lineHeight = 12.5 * 1.55 const lineHeight = 12 * 1.55
const topPad = 14 // 对齐 .preview-code / .preview-line-numbers 的 padding-top const topPad = 14 // 对齐 .preview-code / .preview-line-numbers 的 padding-top
el.scrollTo({ top: Math.max(0, topPad + (line - 1) * lineHeight), behavior: 'smooth' }) el.scrollTo({ top: Math.max(0, topPad + (line - 1) * lineHeight), behavior: 'smooth' })
} }
@@ -732,11 +731,6 @@ onUnmounted(() => {
padding: 24px; padding: 24px;
} }
.binary-icon {
font-size: 40px;
opacity: 0.8;
}
.binary-title { .binary-title {
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
@@ -748,7 +742,7 @@ onUnmounted(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
background: rgba(255, 255, 255, 0.04); background: var(--df-hover);
border-radius: 8px; border-radius: 8px;
padding: 12px 16px; padding: 12px 16px;
min-width: 280px; min-width: 280px;
@@ -771,7 +765,7 @@ onUnmounted(() => {
.binary-meta-value { .binary-meta-value {
color: var(--df-text-secondary); color: var(--df-text-secondary);
font-family: var(--df-font-mono, Consolas, monospace); font-family: var(--df-font-mono, Consolas, monospace);
font-size: 12.5px; font-size: 12px;
word-break: break-all; word-break: break-all;
} }
@@ -785,7 +779,7 @@ onUnmounted(() => {
} }
.preview-error { color: var(--df-danger); } .preview-error { color: var(--df-danger); }
.binary-icon { font-size: 32px; opacity: 0.5; } .binary-icon { font-size: 32px; opacity: 0.5; } /* 2026-10-18 合并重复定义:40px/0.8 旧版被本行完全覆盖,已删 */
.preview-image { .preview-image {
display: flex; display: flex;
@@ -799,7 +793,7 @@ onUnmounted(() => {
.preview-image img { .preview-image img {
max-width: 100%; max-width: 100%;
height: auto; height: auto;
border-radius: var(--df-radius-sm, 4px); border-radius: var(--df-radius-sm);
} }
/* Markdown 视图:独立滚动容器(preview-body 是 overflow:hidden,不设 overflow 会被裁剪无法滚) */ /* Markdown 视图:独立滚动容器(preview-body 是 overflow:hidden,不设 overflow 会被裁剪无法滚) */
@@ -814,7 +808,7 @@ onUnmounted(() => {
margin: 0; margin: 0;
padding: 14px 16px; padding: 14px 16px;
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, 'Courier New', monospace); font-family: var(--df-font-mono, 'Cascadia Code', Consolas, 'Courier New', monospace);
font-size: 12.5px; font-size: 12px;
line-height: 1.55; line-height: 1.55;
white-space: pre; white-space: pre;
overflow: visible; overflow: visible;
@@ -838,7 +832,7 @@ onUnmounted(() => {
padding: 14px 0; padding: 14px 0;
min-width: 44px; min-width: 44px;
text-align: right; text-align: right;
background: rgba(255, 255, 255, 0.03); background: var(--df-hover);
border-right: 0.5px solid var(--df-border); border-right: 0.5px solid var(--df-border);
user-select: none; user-select: none;
position: sticky; position: sticky;
@@ -850,7 +844,7 @@ onUnmounted(() => {
.preview-line-num { .preview-line-num {
padding: 0 10px 0 8px; padding: 0 10px 0 8px;
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace); font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace);
font-size: 12.5px; font-size: 12px;
line-height: 1.55; line-height: 1.55;
color: var(--df-text-dim); color: var(--df-text-dim);
opacity: 0.5; opacity: 0.5;
@@ -859,24 +853,25 @@ onUnmounted(() => {
.preview-line-num.is-active { .preview-line-num.is-active {
color: var(--df-accent); color: var(--df-accent);
opacity: 1; opacity: 1;
background: rgba(255, 255, 255, 0.05); background: var(--df-hover);
} }
/* Diff 视图 — 在自身区域滚动,不溢出到父级 */ /* Diff 视图 — 在自身区域滚动,不溢出到父级(2026-10-18 合并文末重复定义) */
.preview-diff { .preview-diff {
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace); font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace);
font-size: 12px; font-size: 12px;
line-height: 1.55; line-height: 1.55;
overflow: auto; overflow: auto;
min-height: 0; min-height: 0;
height: 100%;
flex: 1; flex: 1;
} }
.spinner { .spinner {
width: 16px; width: 16px;
height: 16px; height: 16px;
border: 2px solid var(--df-border, #444); border: 2px solid var(--df-border);
border-top-color: var(--df-accent, #3a8); border-top-color: var(--df-accent);
border-radius: 50%; border-radius: 50%;
animation: df-spin 0.8s linear infinite; animation: df-spin 0.8s linear infinite;
display: inline-block; display: inline-block;
@@ -890,7 +885,7 @@ onUnmounted(() => {
padding: 4px 10px; padding: 4px 10px;
font-size: 11px; font-size: 11px;
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm, 4px); border-radius: var(--df-radius-sm);
background: transparent; background: transparent;
color: var(--df-text-secondary); color: var(--df-text-secondary);
cursor: pointer; cursor: pointer;
@@ -927,7 +922,7 @@ onUnmounted(() => {
padding: 4px 9px; padding: 4px 9px;
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: 8px; border-radius: 8px;
background: rgba(255, 255, 255, 0.04); background: var(--df-hover);
color: var(--df-text-secondary); color: var(--df-text-secondary);
font-size: 12px; font-size: 12px;
cursor: pointer; cursor: pointer;
@@ -958,7 +953,7 @@ onUnmounted(() => {
background: rgba(20, 22, 34, 0.95); background: rgba(20, 22, 34, 0.95);
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: 10px; border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); box-shadow: var(--df-shadow-md);
overflow: hidden; overflow: hidden;
} }
@@ -1031,7 +1026,7 @@ onUnmounted(() => {
gap: 6px; gap: 6px;
padding: 2px 6px; padding: 2px 6px;
border-radius: 6px; border-radius: 6px;
font-size: 11.5px; font-size: 11px;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
color: var(--df-text-secondary); color: var(--df-text-secondary);
@@ -1039,7 +1034,7 @@ onUnmounted(() => {
transition: background 0.15s; transition: background 0.15s;
} }
.outline-item:hover { background: rgba(255, 255, 255, 0.05); color: var(--df-text); } .outline-item:hover { background: var(--df-hover); color: var(--df-text); }
.outline-item.is-active { .outline-item.is-active {
background: rgba(255, 255, 255, 0.09); background: rgba(255, 255, 255, 0.09);
@@ -1047,7 +1042,7 @@ onUnmounted(() => {
box-shadow: inset 2px 0 0 var(--df-accent); box-shadow: inset 2px 0 0 var(--df-accent);
} }
/* 分组徽章配色:类型蓝 / 函数绿 / 变量橙 / 模块紫 */ /* 分组徽章配色:类型蓝 / 函数绿 / 变量橙 / 模块紫(语法色板·演示豁免,不入 token) */
.outline-item.grp-type .outline-kind { color: #4FC3F7; } .outline-item.grp-type .outline-kind { color: #4FC3F7; }
.outline-item.grp-fn .outline-kind { color: #81C784; } .outline-item.grp-fn .outline-kind { color: #81C784; }
.outline-item.grp-var .outline-kind { color: #FFB74D; } .outline-item.grp-var .outline-kind { color: #FFB74D; }
@@ -1066,6 +1061,7 @@ onUnmounted(() => {
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
} }
.outline-line { .outline-line {
@@ -1076,15 +1072,6 @@ onUnmounted(() => {
opacity: 0.5; opacity: 0.5;
} }
/* Diff 视图 */
.preview-diff {
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace);
font-size: 12px;
line-height: 1.55;
overflow: auto;
height: 100%;
}
.diff-line { .diff-line {
display: flex; display: flex;
padding: 0 8px; padding: 0 8px;
@@ -1115,6 +1102,7 @@ onUnmounted(() => {
overflow: hidden; overflow: hidden;
} }
/* Diff 行底(差异色·演示豁免,与 git 语义 token 并行的语法高亮族) */
.diff-add { .diff-add {
background: rgba(40, 160, 70, 0.12); background: rgba(40, 160, 70, 0.12);
} }
@@ -1137,7 +1125,7 @@ onUnmounted(() => {
color: var(--df-text); color: var(--df-text);
} }
@keyframes df-spin { to { transform: rotate(360deg); } } /* df-spin keyframes 消费 global.css 全局定义(原本地同名重复定义已删,2026-10-18 整洁波) */
</style> </style>
<!-- highlight.js github-dark 主题(全局,非 scoped) --> <!-- highlight.js github-dark 主题(全局,非 scoped) -->
+8 -13
View File
@@ -384,16 +384,16 @@ watch(
gap: 6px; gap: 6px;
padding: 3px 8px; padding: 3px 8px;
cursor: pointer; cursor: pointer;
border-radius: var(--df-radius-sm, 4px); border-radius: var(--df-radius-sm);
transition: background 0.12s; transition: background 0.12s;
} }
.tree-row:hover { .tree-row:hover {
background: var(--df-bg-card, rgba(255, 255, 255, 0.04)); background: var(--df-bg-card);
} }
.tree-row-file.is-selected { .tree-row-file.is-selected {
background: var(--df-accent, #3a8); background: var(--df-accent);
color: #fff; color: #fff;
} }
@@ -415,8 +415,8 @@ watch(
display: inline-block; display: inline-block;
width: 9px; width: 9px;
height: 9px; height: 9px;
border: 1.5px solid var(--df-border, #444); border: 1.5px solid var(--df-border);
border-top-color: var(--df-accent, #3a8); border-top-color: var(--df-accent);
border-radius: 50%; border-radius: 50%;
animation: df-spin 0.8s linear infinite; animation: df-spin 0.8s linear infinite;
vertical-align: middle; vertical-align: middle;
@@ -544,19 +544,14 @@ watch(
color: #e05050; color: #e05050;
} }
/* spinner;df-spin keyframes 消费 global.css 全局定义(原本地重复定义已删,2026-10-18 整洁波) */
.spinner { .spinner {
width: 12px; width: 12px;
height: 12px; height: 12px;
border: 1.5px solid var(--df-border, #444); border: 1.5px solid var(--df-border);
border-top-color: var(--df-accent, #3a8); border-top-color: var(--df-accent);
border-radius: 50%; border-radius: 50%;
animation: df-spin 0.8s linear infinite; animation: df-spin 0.8s linear infinite;
display: inline-block; display: inline-block;
} }
@keyframes df-spin {
to {
transform: rotate(360deg);
}
}
</style> </style>
+17 -43
View File
@@ -422,7 +422,6 @@ async function loadStatus() {
const showBranchPicker = ref(false) const showBranchPicker = ref(false)
const branchList = ref<{ name: string; is_current: boolean }[]>([]) const branchList = ref<{ name: string; is_current: boolean }[]>([])
const branchLoading = ref(false) const branchLoading = ref(false)
const currentBranch = ref('')
async function onSwitchBranch() { async function onSwitchBranch() {
if (showBranchPicker.value) { if (showBranchPicker.value) {
@@ -435,7 +434,6 @@ async function onSwitchBranch() {
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
} catch (e) { } catch (e) {
branchList.value = [] branchList.value = []
actionError.value = String(e) actionError.value = String(e)
@@ -607,7 +605,7 @@ watch(() => props.refreshKey, () => {
} }
.branch-switch-btn:hover { .branch-switch-btn:hover {
color: var(--df-text); color: var(--df-text);
background: rgba(255,255,255,0.05); background: var(--df-hover);
} }
/* Tabs */ /* Tabs */
@@ -620,14 +618,14 @@ watch(() => props.refreshKey, () => {
.gc-tab.active { color: var(--df-text); border-bottom-color: var(--df-accent); } .gc-tab.active { color: var(--df-text); border-bottom-color: var(--df-accent); }
.gc-tab:hover { color: var(--df-text); } .gc-tab:hover { color: var(--df-text); }
.gc-body { flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0; scrollbar-width: thin; } .gc-body { flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0; }
.gc-changes { min-height: 0; } .gc-changes { min-height: 0; }
/* 分组 */ /* 分组 */
.gc-group-header { .gc-group-header {
padding: 6px 14px; font-size: 11px; font-weight: 600; padding: 6px 14px; font-size: 11px; font-weight: 600;
color: var(--df-text-dim); text-transform: uppercase; letter-spacing: 0.5px; color: var(--df-text-dim); text-transform: uppercase; letter-spacing: 0.5px;
background: rgba(255,255,255,0.02); border-bottom: 0.5px solid var(--df-border); background: var(--df-hover); border-bottom: 0.5px solid var(--df-border);
position: sticky; top: 0; z-index: 1; position: sticky; top: 0; z-index: 1;
} }
@@ -645,8 +643,8 @@ watch(() => props.refreshKey, () => {
.gc-search-icon { color: var(--df-text-dim); flex-shrink: 0; } .gc-search-icon { color: var(--df-text-dim); flex-shrink: 0; }
.gc-search-input { .gc-search-input {
flex: 1; min-width: 0; flex: 1; min-width: 0;
background: rgba(255,255,255,0.04); border: 0.5px solid var(--df-border); background: var(--df-hover); border: 0.5px solid var(--df-border);
border-radius: 4px; padding: 4px 8px; border-radius: 4px; height: var(--df-h-input); padding: 0 8px;
color: var(--df-text); font-size: 12px; outline: none; color: var(--df-text); font-size: 12px; outline: none;
} }
.gc-search-input:focus { border-color: var(--df-accent); } .gc-search-input:focus { border-color: var(--df-accent); }
@@ -658,13 +656,13 @@ watch(() => props.refreshKey, () => {
.gc-search-clear:hover { color: var(--df-text); } .gc-search-clear:hover { color: var(--df-text); }
/* 提交列表 */ /* 提交列表 */
.gc-history-list { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: thin; } .gc-history-list { flex: 1; min-height: 0; overflow-y: auto; }
.gc-commit-row { .gc-commit-row {
display: flex; flex-direction: column; gap: 2px; display: flex; flex-direction: column; gap: 2px;
padding: 6px 14px; border-bottom: 0.5px solid rgba(255,255,255,0.03); padding: 6px 14px; border-bottom: 0.5px solid var(--df-hover);
cursor: pointer; transition: background 0.1s; cursor: pointer; transition: background 0.1s;
} }
.gc-commit-row:hover { background: rgba(255,255,255,0.04); } .gc-commit-row:hover { background: var(--df-hover); }
.gc-commit-row.selected { background: rgba(255,255,255,0.06); } .gc-commit-row.selected { background: rgba(255,255,255,0.06); }
.gc-commit-line1 { .gc-commit-line1 {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
@@ -696,7 +694,7 @@ watch(() => props.refreshKey, () => {
} }
.gc-commit-detail-header { .gc-commit-detail-header {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap; display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 8px 14px; background: rgba(255,255,255,0.03); padding: 8px 14px; background: var(--df-hover);
border-bottom: 0.5px solid var(--df-border); border-bottom: 0.5px solid var(--df-border);
} }
.gc-commit-detail-hash { .gc-commit-detail-hash {
@@ -720,14 +718,14 @@ watch(() => props.refreshKey, () => {
/* 提交的文件列表 */ /* 提交的文件列表 */
.gc-commit-files { .gc-commit-files {
border-bottom: 0.5px solid var(--df-border); border-bottom: 0.5px solid var(--df-border);
overflow-y: auto; max-height: 140px; scrollbar-width: thin; flex-shrink: 0; overflow-y: auto; max-height: 140px; flex-shrink: 0;
} }
.gc-commit-file-row { .gc-commit-file-row {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
padding: 5px 14px 5px 28px; cursor: pointer; transition: background 0.1s; padding: 5px 14px 5px 28px; cursor: pointer; transition: background 0.1s;
border-bottom: 0.5px solid rgba(255,255,255,0.02); border-bottom: 0.5px solid var(--df-hover);
} }
.gc-commit-file-row:hover { background: rgba(255,255,255,0.04); } .gc-commit-file-row:hover { background: var(--df-hover); }
.gc-commit-file-row.active { background: rgba(255,255,255,0.06); } .gc-commit-file-row.active { background: rgba(255,255,255,0.06); }
/* 滑出动画 */ /* 滑出动画 */
@@ -757,7 +755,7 @@ watch(() => props.refreshKey, () => {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
cursor: pointer; transition: background 0.1s; cursor: pointer; transition: background 0.1s;
} }
.gc-file-row:hover { background: rgba(255,255,255,0.04); } .gc-file-row:hover { background: var(--df-hover); }
.gc-file-row.selected { background: rgba(255,255,255,0.06); } .gc-file-row.selected { background: rgba(255,255,255,0.06); }
/* 加载更多 */ /* 加载更多 */
@@ -766,7 +764,7 @@ watch(() => props.refreshKey, () => {
color: var(--df-accent); font-size: 12px; color: var(--df-accent); font-size: 12px;
transition: background 0.1s; flex-shrink: 0; transition: background 0.1s; flex-shrink: 0;
} }
.gc-load-more:hover { background: rgba(255,255,255,0.04); } .gc-load-more:hover { background: var(--df-hover); }
/* 分支列表弹窗 */ /* 分支列表弹窗 */
.gc-branch-picker { .gc-branch-picker {
@@ -790,7 +788,6 @@ watch(() => props.refreshKey, () => {
.gc-branch-list { .gc-branch-list {
max-height: 200px; max-height: 200px;
overflow-y: auto; overflow-y: auto;
scrollbar-width: thin;
} }
.gc-branch-item { .gc-branch-item {
display: flex; display: flex;
@@ -827,13 +824,9 @@ watch(() => props.refreshKey, () => {
.gc-skeleton-row { .gc-skeleton-row {
height: 24px; height: 24px;
border-radius: 4px; border-radius: 4px;
background: linear-gradient(90deg, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 100%); background: linear-gradient(90deg, var(--df-hover) 0%, rgba(255,255,255,0.08) 50%, var(--df-hover) 100%);
background-size: 200% 100%; background-size: 200% 100%;
animation: gc-shimmer 1.5s infinite linear; animation: df-shimmer 1.5s infinite linear; /* 消费 global.css df-shimmer(原本地 gc-shimmer 重复定义已删) */
}
@keyframes gc-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
} }
/* 通用 */ /* 通用 */
@@ -853,24 +846,5 @@ watch(() => props.refreshKey, () => {
border: 2px solid var(--df-border); border-top-color: var(--df-accent); border: 2px solid var(--df-border); border-top-color: var(--df-accent);
border-radius: 50%; animation: df-spin 0.8s linear infinite; display: inline-block; border-radius: 50%; animation: df-spin 0.8s linear infinite; display: inline-block;
} }
@keyframes df-spin { to { transform: rotate(360deg); } } /* df-spin keyframes 消费 global.css 全局定义(原本地同名重复定义已删,2026-10-18 整洁波) */
/* 滚动条对齐全局(变更/历史提交列表与全局统一样式) */
.git-changes ::-webkit-scrollbar {
width: 5px;
height: 5px;
}
.git-changes ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: var(--df-radius-xs);
}
.git-changes ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.14);
}
.git-changes ::-webkit-scrollbar-track {
background: transparent;
}
</style> </style>
+2 -6
View File
@@ -242,13 +242,9 @@ onBeforeUnmount(() => window.removeEventListener('resize', onWinResize))
width: 18px; width: 18px;
height: 12px; height: 12px;
border-radius: 3px; border-radius: 3px;
background: linear-gradient(90deg, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 100%); background: linear-gradient(90deg, var(--df-hover) 0%, rgba(255,255,255,0.08) 50%, var(--df-hover) 100%);
background-size: 200% 100%; background-size: 200% 100%;
animation: pc-shimmer 1.5s infinite linear; animation: df-shimmer 1.5s infinite linear; /* 消费 global.css df-shimmer(原本地 pc-shimmer 重复定义已删) */
}
@keyframes pc-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
} }
.card-top-actions { display: flex; align-items: center; gap: 8px; } .card-top-actions { display: flex; align-items: center; gap: 8px; }
+5 -9
View File
@@ -91,8 +91,8 @@
<div class="update-progress-text">{{ progressText }}</div> <div class="update-progress-text">{{ progressText }}</div>
</div> </div>
<!-- 错误提示 --> <!-- 错误提示:视觉走全局 .df-note--danger -->
<div v-if="installError" class="update-error">{{ installError }}</div> <div v-if="installError" class="df-note df-note--danger update-error">{{ installError }}</div>
<div class="update-card-actions"> <div class="update-card-actions">
<button <button
@@ -373,11 +373,12 @@ async function onIntervalChange() {
.update-progress { .update-progress {
margin-bottom: 12px; margin-bottom: 12px;
} }
/* 进度条轨道:原 --df-bg-hover 为未定义幽灵 token(靠回退侥幸渲染),改中性底 */
.update-progress-bar { .update-progress-bar {
width: 100%; width: 100%;
height: 6px; height: 6px;
border-radius: 3px; border-radius: 3px;
background: var(--df-bg-hover, rgba(128, 128, 128, 0.2)); background: var(--df-neutral-bg);
overflow: hidden; overflow: hidden;
} }
.update-progress-fill { .update-progress-fill {
@@ -391,14 +392,9 @@ async function onIntervalChange() {
font-family: var(--df-font-mono, monospace); font-family: var(--df-font-mono, monospace);
color: var(--df-text-dim); color: var(--df-text-dim);
} }
/* 安装错误提示 */ /* 安装错误提示:底色/边/圆角/字号走全局 .df-note--danger(token 直引无回退),此处仅留排版 */
.update-error { .update-error {
margin-bottom: 12px; margin-bottom: 12px;
padding: 8px 10px;
border-radius: var(--df-radius-md, 6px);
background: rgba(220, 53, 69, 0.12);
font-size: 12px;
color: var(--df-danger, #dc3545);
line-height: 1.5; line-height: 1.5;
word-break: break-all; word-break: break-all;
} }
+7 -12
View File
@@ -86,11 +86,12 @@
</div> </div>
<!-- all 二次确认弹层(独立,不与 Settings 壳层共用:壳层按钮写死「删除」不适合「确认接管」) --> <!-- all 二次确认弹层(独立,不与 Settings 壳层共用:壳层按钮写死「删除」不适合「确认接管」) -->
<Transition name="confirm"> <!-- 按钮行/过渡消费 global.css .df-confirm-actions + df-confirm(2026-10-18 整洁波并轨) -->
<Transition name="df-confirm">
<div v-if="confirmState.visible" class="confirm-mask" @click.self="answerConfirm(false)"> <div v-if="confirmState.visible" class="confirm-mask" @click.self="answerConfirm(false)">
<div class="confirm-box"> <div class="confirm-box">
<div class="confirm-msg">{{ confirmState.msg }}</div> <div class="confirm-msg">{{ confirmState.msg }}</div>
<div class="confirm-actions"> <div class="df-confirm-actions">
<button class="btn btn-ghost btn-sm" @click="answerConfirm(false)">{{ $t('common.cancel') }}</button> <button class="btn btn-ghost btn-sm" @click="answerConfirm(false)">{{ $t('common.cancel') }}</button>
<button class="btn btn-danger btn-sm" @click="answerConfirm(true)">{{ confirmState.dangerLabel || $t('common.confirm') }}</button> <button class="btn btn-danger btn-sm" @click="answerConfirm(true)">{{ confirmState.dangerLabel || $t('common.confirm') }}</button>
</div> </div>
@@ -252,8 +253,9 @@ function onApprovalTimeoutChange() {
.setting-select/.setting-textarea + SettingRow(封装 .setting-row/.setting-info/.setting-label/ .setting-select/.setting-textarea + SettingRow(封装 .setting-row/.setting-info/.setting-label/
.setting-desc/.setting-control)。本 Section 仅保留 all 二次确认弹层特有 scoped 样式。 .setting-desc/.setting-control)。本 Section 仅保留 all 二次确认弹层特有 scoped 样式。
F-#97 all 二次确认弹层:复用 Settings.vue 壳层 confirm 弹层同款样式(mask/box/msg/ F-#97 all 二次确认弹层:mask/box/msg 本地特有样式;actions 按钮行与过渡已并轨
actions/transition),此处独立持有 useConfirm 状态(不与壳层共用,壳层按钮写死「删除」)。 */ global.css .df-confirm-actions + df-confirm 过渡(2026-10-18 整洁波双轨合一)。
此处独立持有 useConfirm 状态(不与壳层共用,壳层按钮写死「删除」)。 */
.data-dir-path { .data-dir-path {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -284,8 +286,6 @@ function onApprovalTimeoutChange() {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
.prompt-row .setting-info { align-items: flex-start; }
.prompt-row .setting-control { align-items: flex-start; }
/* select 类设置行(自定义布局,不复用 SettingRow):label 与 select 同一行靠左顶对齐, /* select 类设置行(自定义布局,不复用 SettingRow):label 与 select 同一行靠左顶对齐,
desc 换行到整行下方。 desc 换行到整行下方。
┌───────────────────────────────┐ ┌───────────────────────────────┐
@@ -326,12 +326,7 @@ function onApprovalTimeoutChange() {
padding: 20px; padding: 20px;
min-width: 280px; min-width: 280px;
max-width: 420px; max-width: 420px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4); box-shadow: var(--df-shadow-md);
} }
.confirm-msg { font-size: 13px; color: var(--df-text); line-height: 1.5; margin-bottom: 16px; white-space: pre-line; } .confirm-msg { font-size: 13px; color: var(--df-text); line-height: 1.5; margin-bottom: 16px; white-space: pre-line; }
.confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
.btn-danger { background: var(--df-danger); color: #fff; }
.btn-danger:hover { filter: brightness(1.1); }
.confirm-enter-active, .confirm-leave-active { transition: opacity 0.15s; }
.confirm-enter-from, .confirm-leave-to { opacity: 0; }
</style> </style>
+8 -10
View File
@@ -87,12 +87,13 @@
</section> </section>
<!-- 「信任所有目录」二次确认弹层(同 AdvancedSection 的 all 接管确认:自渲染独立弹层, <!-- 「信任所有目录」二次确认弹层(同 AdvancedSection 的 all 接管确认:自渲染独立弹层,
msg 拼标题+正文,confirm-msg 用 pre-line 保留换行) --> msg 拼标题+正文,confirm-msg 用 pre-line 保留换行;
<Transition name="confirm"> 按钮行/过渡消费 global.css .df-confirm-actions + df-confirm,2026-10-18 整洁波并轨) -->
<Transition name="df-confirm">
<div v-if="confirmState.visible" class="confirm-mask" @click.self="answerConfirm(false)"> <div v-if="confirmState.visible" class="confirm-mask" @click.self="answerConfirm(false)">
<div class="confirm-box"> <div class="confirm-box">
<div class="confirm-msg">{{ confirmState.msg }}</div> <div class="confirm-msg">{{ confirmState.msg }}</div>
<div class="confirm-actions"> <div class="df-confirm-actions">
<button class="btn btn-ghost btn-sm" @click="answerConfirm(false)">{{ $t('common.cancel') }}</button> <button class="btn btn-ghost btn-sm" @click="answerConfirm(false)">{{ $t('common.cancel') }}</button>
<button class="btn btn-danger btn-sm" @click="answerConfirm(true)">{{ confirmState.dangerLabel || $t('common.confirm') }}</button> <button class="btn btn-danger btn-sm" @click="answerConfirm(true)">{{ confirmState.dangerLabel || $t('common.confirm') }}</button>
</div> </div>
@@ -296,8 +297,10 @@ onMounted(load)
.script-safety-desc { .script-safety-desc {
font-size: 12px; color: var(--df-text-dim); margin: 0 0 4px; line-height: 1.5; font-size: 12px; color: var(--df-text-dim); margin: 0 0 4px; line-height: 1.5;
} }
/* 名单输入定宽 280px + max-width 窄窗收缩(原 min-width:280px 在窄列不可收缩致横向溢出) */
.script-safety-input { .script-safety-input {
min-width: 280px; width: 280px;
max-width: 100%;
font-family: var(--df-font-mono, monospace); font-family: var(--df-font-mono, monospace);
} }
.script-safety-note { .script-safety-note {
@@ -321,12 +324,7 @@ onMounted(load)
padding: 20px; padding: 20px;
min-width: 280px; min-width: 280px;
max-width: 420px; max-width: 420px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4); box-shadow: var(--df-shadow-md);
} }
.confirm-msg { font-size: 13px; color: var(--df-text); line-height: 1.5; margin-bottom: 16px; white-space: pre-line; } .confirm-msg { font-size: 13px; color: var(--df-text); line-height: 1.5; margin-bottom: 16px; white-space: pre-line; }
.confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
.btn-danger { background: var(--df-danger); color: #fff; }
.btn-danger:hover { filter: brightness(1.1); }
.confirm-enter-active, .confirm-leave-active { transition: opacity 0.15s; }
.confirm-enter-from, .confirm-leave-to { opacity: 0; }
</style> </style>
+7 -10
View File
@@ -263,13 +263,10 @@ onMounted(loadConnections)
<style scoped> <style scoped>
/* 阶段1/3 UX 重构:panel/panel-header/empty-hint/btn 系列/setting-select/setting-input /* 阶段1/3 UX 重构:panel/panel-header/empty-hint/btn 系列/setting-select/setting-input
已迁移到 settings.css 全局。 已迁移到 settings.css 全局。
本 scoped 仅保留 ConnectionPanel 特有样式:connection-card/conn 系列/type 系列/form 系列/ 本 scoped 仅保留 ConnectionPanel 特有样式:connection-card/conn 系列/type 系列/form 系列
btn-link 及 form-field 内输入控件布局覆盖等。 */ 及 form-field 内输入控件布局覆盖等。 */
/* ===== 链接按钮(本面板特有,未进全局) ===== */ /* 链接按钮 .btn-link/.btn-link-danger 已并轨 global.css(样式大重构去重) */
.btn-link { background: none; border: none; color: var(--df-accent); font-size: 12px; cursor: pointer; padding: 2px 6px; }
.btn-link:hover { text-decoration: underline; }
.btn-link-danger { color: var(--df-danger); }
/* ===== 标题组(panel-header 内 标题+badge 一组,与右侧按钮 space-between) ===== */ /* ===== 标题组(panel-header 内 标题+badge 一组,与右侧按钮 space-between) ===== */
.panel-title-group { display: flex; align-items: center; gap: 8px; } .panel-title-group { display: flex; align-items: center; gap: 8px; }
@@ -307,10 +304,10 @@ onMounted(loadConnections)
border-radius: var(--df-radius-sm); border-radius: var(--df-radius-sm);
font-weight: 500; font-weight: 500;
} }
.type-mysql { background: rgba(100,181,246,0.15); color: var(--df-info); } .type-mysql { background: var(--df-info-bg); color: var(--df-info); }
.type-ssh { background: var(--df-accent-soft); color: var(--df-accent); } .type-ssh { background: var(--df-accent-soft); color: var(--df-accent); }
.type-redis { background: rgba(255,107,107,0.15); color: var(--df-danger); } .type-redis { background: var(--df-danger-bg); color: var(--df-danger); }
.type-mongo { background: rgba(100,255,218,0.15); color: var(--df-success); } .type-mongo { background: var(--df-success-bg); color: var(--df-success); }
.conn-host, .conn-user { .conn-host, .conn-user {
font-size: 12px; font-size: 12px;
@@ -318,7 +315,7 @@ onMounted(loadConnections)
font-family: var(--df-font-mono); font-family: var(--df-font-mono);
} }
.conn-user { .conn-user {
background: rgba(90,99,128,0.2); background: var(--df-neutral-bg);
padding: 1px 6px; padding: 1px 6px;
border-radius: var(--df-radius-sm); border-radius: var(--df-radius-sm);
} }
+4 -2
View File
@@ -142,9 +142,11 @@ async function save() {
<style scoped> <style scoped>
/* panel/panel-header/btn 系列/setting-input 均在 settings.css 全局,此处仅本面板特有样式 */ /* panel/panel-header/btn 系列/setting-input 均在 settings.css 全局,此处仅本面板特有样式 */
.feishu-settings { display: flex; flex-direction: column; gap: 4px; } .feishu-settings { display: flex; flex-direction: column; gap: 4px; }
.feishu-input { width: 280px; } /* 定宽输入补 max-width,窄窗收缩防横向溢出 */
.feishu-input { width: 280px; max-width: 100%; }
.feishu-whitelist { .feishu-whitelist {
width: 320px; width: 320px;
max-width: 100%;
min-height: 64px; min-height: 64px;
resize: vertical; resize: vertical;
font-family: var(--df-font-mono, monospace); font-family: var(--df-font-mono, monospace);
@@ -153,7 +155,7 @@ async function save() {
margin: 0 0 4px; margin: 0 0 4px;
font-size: 12px; font-size: 12px;
line-height: 1.5; line-height: 1.5;
color: var(--df-text-tertiary, #888); color: var(--df-text-dim);
} }
.actions { display: flex; gap: 8px; margin-top: 8px; } .actions { display: flex; gap: 8px; margin-top: 8px; }
</style> </style>
+1 -1
View File
@@ -38,7 +38,7 @@
</select> </select>
</SettingRow> </SettingRow>
<SettingRow v-if="knowledgeConfig.vector_enabled" ref="rowEmbeddingModel" :label="$t('settings.labelEmbeddingModel')" :desc="$t('settings.descEmbeddingModel')"> <SettingRow v-if="knowledgeConfig.vector_enabled" ref="rowEmbeddingModel" :label="$t('settings.labelEmbeddingModel')" :desc="$t('settings.descEmbeddingModel')">
<input type="text" :value="knowledgeConfig.embedding_model ?? ''" @input="knowledgeConfig.embedding_model = ($event.target as HTMLInputElement).value || null" @change="markSaved('embeddingModel')" class="setting-input" :placeholder="$t('settings.phEmbeddingModel')" style="width:220px" /> <input type="text" :value="knowledgeConfig.embedding_model ?? ''" @input="knowledgeConfig.embedding_model = ($event.target as HTMLInputElement).value || null" @change="markSaved('embeddingModel')" class="setting-input" :placeholder="$t('settings.phEmbeddingModel')" style="width:220px; max-width:100%" />
</SettingRow> </SettingRow>
</div> </div>
</section> </section>
+35 -409
View File
@@ -5,55 +5,9 @@
<h2>{{ $t('settings.panelAiProvider') }}</h2> <h2>{{ $t('settings.panelAiProvider') }}</h2>
<button class="btn btn-ghost btn-sm" @click="openProviderForm()">{{ $t('settings.addProvider') }}</button> <button class="btn btn-ghost btn-sm" @click="openProviderForm()">{{ $t('settings.addProvider') }}</button>
</div> </div>
<div class="provider-list" v-if="aiProviders.length > 0"> <!-- 卡片列表 + 负载均衡池控件拆至 provider-panel/provider-list.vue(C4c) -->
<div class="provider-card" :class="{ 'provider-card--default': p.is_default, 'provider-card--disabled': p.enabled === false }" v-for="p in aiProviders" :key="p.id"> <ProviderList v-if="aiProviders.length > 0" ref="providerListRef" :providers="aiProviders"
<div class="provider-top"> @edit="openProviderForm" @delete="deleteProvider" @set-default="setDefaultProvider" @toast="relayToast" />
<div class="provider-info">
<span class="provider-name">{{ p.name }}</span>
<span v-if="p.is_default" class="default-badge">{{ $t('settings.badgeDefault') }}</span>
<span v-if="p.enabled === false" class="pool-badge">{{ $t('settings.badgePoolDisabled') }}</span>
</div>
<div class="provider-actions">
<button class="btn-link" @click="setDefaultProvider(p.id)" v-if="!p.is_default">{{ $t('settings.actionSetDefault') }}</button>
<button class="btn-link" @click="openProviderForm(p)">{{ $t('common.edit') }}</button>
<button class="btn-link btn-link-danger" @click="deleteProvider(p.id)">{{ $t('common.delete') }}</button>
</div>
</div>
<div class="provider-detail">
<div class="detail-row">
<span class="detail-label">{{ $t('settings.detailBaseUrl') }}</span>
<span class="detail-value">{{ p.base_url }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ $t('settings.detailModel') }}</span>
<span class="detail-value">{{ p.default_model }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ $t('settings.detailApiKey') }}</span>
<span class="detail-value mask">{{ maskKey(p.api_key) }}</span>
</div>
<!-- F-260614-04c 负载均衡池:enabled toggle + weight 即时调 IPC,落库即重建 caps -->
<div class="detail-row detail-row--pool">
<span class="detail-label" :title="$t('settings.descPoolEnabled')">{{ $t('settings.detailPoolEnabled') }}</span>
<div class="detail-value detail-value--control">
<label class="toggle toggle--sm" :title="$t('settings.descPoolEnabled')">
<input type="checkbox" :checked="p.enabled !== false" @change="onPoolToggle(p, ($event.target as HTMLInputElement).checked)" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="detail-row detail-row--pool" v-if="p.enabled !== false">
<span class="detail-label" :title="$t('settings.descPoolWeight')">{{ $t('settings.detailPoolWeight') }}</span>
<div class="detail-value detail-value--control">
<input type="number" class="setting-input setting-input--sm" min="0" max="100"
:value="p.weight ?? 50"
@change="onPoolWeightChange(p, ($event.target as HTMLInputElement).valueAsNumber)" />
<span class="pool-weight-hint">0-100</span>
</div>
</div>
</div>
</div>
</div>
<div class="empty-hint" v-else>{{ $t('settings.emptyProvider') }}</div> <div class="empty-hint" v-else>{{ $t('settings.emptyProvider') }}</div>
</section> </section>
@@ -127,8 +81,8 @@
</div> </div>
<!-- P0-2: 保存失败持久错误横幅(常驻表单内,非自隐 toast)。 <!-- P0-2: 保存失败持久错误横幅(常驻表单内,非自隐 toast)。
含分类建议:keyring/迁移失败 → 查系统钥匙串权限;其它 → 通用重试。 含分类建议:keyring/迁移失败 → 查系统钥匙串权限;其它 → 通用重试。
用户看到错误同时表单保持编辑态,改完点保存即可重试。 --> 用户看到错误同时表单保持编辑态,改完点保存即可重试。视觉走全局 .df-note--danger。 -->
<div v-if="providerForm.saveError" class="save-error-banner"> <div v-if="providerForm.saveError" class="df-note df-note--danger">
<pre class="save-error-msg">{{ providerForm.saveError }}</pre> <pre class="save-error-msg">{{ providerForm.saveError }}</pre>
</div> </div>
<div class="form-actions"> <div class="form-actions">
@@ -140,59 +94,10 @@
</button> </button>
</div> </div>
<!-- ═══ 拉取到的模型列表(F-01 阶段6)═══ --> <!-- ═══ 拉取到的模型列表(F-01 阶段6;表格+标签标注拆至 provider-panel/model-table.vue)═══ -->
<div class="form-field form-field--full" v-if="providerForm.models.length > 0"> <div class="form-field form-field--full" v-if="providerForm.models.length > 0">
<label class="form-label">{{ $t('settings.modelListTitle', { count: providerForm.models.length }) }}</label> <label class="form-label">{{ $t('settings.modelListTitle', { count: providerForm.models.length }) }}</label>
<table class="model-table"> <ModelTable :models="providerForm.models" />
<thead>
<tr>
<th class="col-enabled">{{ $t('settings.colEnabled') }}</th>
<th class="col-model">{{ $t('settings.colModel') }}</th>
<th class="col-ability">{{ $t('settings.colAbility') }}</th>
<th class="col-weight">{{ $t('settings.labelWeight') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="m in providerForm.models" :key="m.model_id">
<td class="col-enabled">
<label class="toggle toggle--sm">
<input type="checkbox" v-model="m.enabled" />
<span class="toggle-slider"></span>
</label>
</td>
<td class="col-model">
<span class="model-id">{{ m.model_id }}</span>
</td>
<td class="col-ability">
<!-- 能力/模态标签(可编辑:点 × 删除;点 + 添加;手动改后标为「手动」) -->
<div class="model-tags">
<span v-for="md in m.modalities" v-show="!redundantTags.modalities.has(md)" :key="'md-' + md"
class="tag tag-modality" :title="$t('settings.tagTooltipModality')">
{{ $t('settings.tagModality.' + md) }}<span class="tag-remove" role="button" tabindex="0" :aria-label="$t('common.delete')" @click.stop="removeModality(m, md)" @keydown.enter.prevent="removeModality(m, md)" @keydown.space.prevent="removeModality(m, md)">×</span>
</span>
<span v-for="cap in m.capabilities" v-show="!redundantTags.capabilities.has(cap)" :key="'cap-' + cap"
class="tag tag-capability" :title="$t('settings.tagTooltipCapability')">
{{ $t('settings.tagCapability.' + cap) }}<span class="tag-remove" role="button" tabindex="0" :aria-label="$t('common.delete')" @click.stop="removeCapability(m, cap)" @keydown.enter.prevent="removeCapability(m, cap)" @keydown.space.prevent="removeCapability(m, cap)">×</span>
</span>
<span v-if="m.probe_source && m.probe_source !== 'user_set'" v-show="!redundantTags.sources.has(m.probe_source)"
class="tag tag-source" :title="$t('settings.tagTooltipSource')">{{ $t('settings.tagSource.' + m.probe_source) }}</span>
<span v-if="m.probe_source === 'user_set'" class="tag tag-source" :title="$t('settings.tagTooltipSource')">{{ $t('settings.tagSource.user_set') }}</span>
<span class="tag-add" role="button" tabindex="0" :title="$t('settings.addTagTitle')" :aria-label="$t('settings.addTagTitle')" @click.stop="startAddTag(m)" @keydown.enter.prevent="startAddTag(m)" @keydown.space.prevent="startAddTag(m)">+</span>
</div>
<!-- 添加能力内联菜单(点+展开;选未有的能力/模态即添加) -->
<div v-if="addingTagModel === m.model_id" class="add-tag-menu">
<span v-for="opt in addableTags(m)" :key="opt.kind + '-' + opt.key"
class="tag" :class="opt.kind === 'modality' ? 'tag-modality' : 'tag-capability'"
@click.stop="addTag(m, opt.key, opt.kind)">{{ opt.label }}</span>
<span v-if="addableTags(m).length === 0" class="add-tag-empty">{{ $t('settings.addTagNone') }}</span>
</div>
</td>
<td class="col-weight">
<input type="number" class="setting-input setting-input--sm" min="0" max="100" v-model.number="m.weight" />
</td>
</tr>
</tbody>
</table>
<div class="model-list-hint">{{ $t('settings.modelListHint') }}</div> <div class="model-list-hint">{{ $t('settings.modelListHint') }}</div>
<!-- M32: 模型列表 enabled/weight 改了未保存时显提示(关闭/切走将丢弃) --> <!-- M32: 模型列表 enabled/weight 改了未保存时显提示(关闭/切走将丢弃) -->
<div v-if="isFormDirty" class="model-list-unsaved-hint">{{ $t('settings.modelListUnsavedHint') }}</div> <div v-if="isFormDirty" class="model-list-unsaved-hint">{{ $t('settings.modelListUnsavedHint') }}</div>
@@ -205,11 +110,17 @@
import { reactive, ref, computed, onMounted, useTemplateRef } from 'vue' import { reactive, ref, computed, onMounted, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { aiApi } from '@/api' import { aiApi } from '@/api'
import type { AiProviderConfig, ModelConfig, Modality, Capability, ProbeSource } from '@/api/types' import type { AiProviderConfig, ModelConfig } from '@/api/types'
import { PROVIDER_PRESETS, CUSTOM_PRESET_ID, findPreset } from './providerPresets' import { PROVIDER_PRESETS, CUSTOM_PRESET_ID, findPreset } from './providerPresets'
import ProviderList from './provider-panel/provider-list.vue'
import ModelTable from './provider-panel/model-table.vue'
// ============================================================ // ============================================================
// AI 提供商域 — 真实后端 CRUD + c 负载均衡池(enabled / weight) // AI 提供商域 — 真实后端 CRUD + c 负载均衡池(enabled / weight)
// C4c 拆分:卡片列表+池控件 → provider-panel/provider-list.vue;
// 模型表格+能力标签标注 → provider-panel/model-table.vue。
// 本件保留:表单状态机(校验红框/温度与权重钳制/快照脏判)、保存/测试连接/预设回填、
// 列表 CRUD 与 loadProviders 闭环、对壳 defineExpose 契约。
// ============================================================ // ============================================================
const { t } = useI18n() const { t } = useI18n()
@@ -286,37 +197,7 @@ const providerForm = reactive({
savedSnapshot: '' as string, savedSnapshot: '' as string,
}) })
/** // (冗余标签判定 redundantTags 随模型表格拆至 provider-panel/model-table.vue)
* 冗余标签判定:全模型共有的 modality/capability/probe_source 无区分度,
* 每行都渲染会刷屏(如全部命中预设表 → 全是「预设」;对话模型默认 ToolUse → 全是「工具调用」)。
* 计算「所有模型都含」的值集合,模板渲染时跳过 → 只显示有区分度的标签。
*/
const redundantTags = computed(() => {
const models = providerForm.models
// SA-6: 去冗余的前提是「模型间对比」——仅 1 个模型时它的所有标签都满足「全模型共有」,
// 无脑隐藏会把唯一模型的能力标签全抹掉(行内空白,用户看不到该模型能力)。
// 故 ≥2 个模型起才去冗余;0/1 个一律全显(sources 分支本就 <2 直接放行,此处对齐)。
if (models.length < 2) return { modalities: new Set<string>(), capabilities: new Set<string>(), sources: new Set<string>() }
const common = (pick: (m: ModelConfig) => string[]) => {
const sets = models.map(m => new Set(pick(m)))
const first = sets[0]
const result = new Set<string>()
for (const v of first) {
if (sets.every(s => s.has(v))) result.add(v)
}
return result
}
return {
modalities: common(m => m.modalities),
capabilities: common(m => m.capabilities),
// 探测来源:全模型相同的值也冗余(如全 PresetTable)
sources: (() => {
const vals = models.map(m => m.probe_source).filter((v): v is ProbeSource => !!v)
if (vals.length < 2) return new Set<string>()
return new Set([...new Set(vals)].filter(v => vals.every(x => x === v)))
})(),
}
})
/** /**
* L19/M32: 表单脏判定——当前表单状态与已落库快照不一致即脏。 * L19/M32: 表单脏判定——当前表单状态与已落库快照不一致即脏。
@@ -349,59 +230,7 @@ function formBusinessSnapshot(): string {
}) })
} }
// ============================================================ // (模型能力手动标注 tag 编辑块随模型表格拆至 provider-panel/model-table.vue)
// 模型能力手动标注(表格内编辑 tags;改动即标 UserSet,手动优先)
// ============================================================
/** 当前展开「添加能力」菜单的模型名;null=无展开 */
const addingTagModel = ref<string | null>(null)
/** 可添加的模态(与 tagModality 对齐;text 是对话基础模态,允许删但一般不主动加) */
const ADDABLE_MODALITIES: Modality[] = ['text', 'vision', 'audio', 'video']
/** 可添加的能力 */
const ADDABLE_CAPABILITIES: Capability[] = ['tool_use', 'embedding', 'code_gen']
/** 某模型可添加的可选 tag(模态 + 能力,排除已选的) */
function addableTags(m: ModelConfig): { key: string; kind: 'modality' | 'capability'; label: string }[] {
const mods = ADDABLE_MODALITIES
.filter(x => !m.modalities.includes(x))
.map(x => ({ key: x, kind: 'modality' as const, label: t('settings.tagModality.' + x) }))
const caps = ADDABLE_CAPABILITIES
.filter(x => !m.capabilities.includes(x))
.map(x => ({ key: x, kind: 'capability' as const, label: t('settings.tagCapability.' + x) }))
return [...mods, ...caps]
}
/** 标记模型为「手动标注」(probe_source=user_set),下次探测不覆盖 */
function markManual(m: ModelConfig) {
m.probe_source = 'user_set'
addingTagModel.value = null
}
function removeModality(m: ModelConfig, md: Modality) {
m.modalities = m.modalities.filter(x => x !== md)
markManual(m)
}
function removeCapability(m: ModelConfig, cap: Capability) {
m.capabilities = m.capabilities.filter(x => x !== cap)
markManual(m)
}
function addTag(m: ModelConfig, key: string, kind: 'modality' | 'capability') {
if (kind === 'modality') {
const md = key as Modality
if (!m.modalities.includes(md)) m.modalities = [...m.modalities, md]
} else {
const cap = key as Capability
if (!m.capabilities.includes(cap)) m.capabilities = [...m.capabilities, cap]
}
markManual(m)
}
function startAddTag(m: ModelConfig) {
addingTagModel.value = addingTagModel.value === m.model_id ? null : m.model_id
}
/** /**
* L19: 关闭表单入口——脏表单二次确认,清表单后关闭。 * L19: 关闭表单入口——脏表单二次确认,清表单后关闭。
@@ -677,64 +506,15 @@ async function setDefaultProvider(id: string) {
} }
} }
// ============================================================ // (负载均衡池 enabled/weight 即时 IPC 块随卡片列表拆至 provider-panel/provider-list.vue)
// c: 负载均衡池可编辑层(enabled / weight)
// 卡片即时调 IPC(对齐 syncConcurrencyConfig 模式);落库后后端 reload_provider_caps
// 重建 per_provider caps,下条消息即按新配置 acquire。
// 不走 saveProvider(全量 INSERT OR REPLACE 会触发 R-PD-1 密钥迁移分支,与 c 轻量 UPDATE 路径冲突)。
// ============================================================
// 计时器按 provider id 分键:单例计时器下 300ms 内先后改两家权重,B 的变更会清掉
// A 尚未落库的保存,先改的一方静默丢失(走查 P2-8)。
const _poolTimers = new Map<string, ReturnType<typeof setTimeout>>()
/** /** 子件 toast 上抛中继(池控件 IPC 结果反馈) */
* toggle 即时生效(开关语义=立即入/出池,无 debounce 必要;但失败需回滚视图)。 function relayToast(msg: string, type: 'error' | 'warning' | 'info' | 'success') {
* 乐观更新本地数组,失败 revert + toast。 emit('toast', msg, type)
*/
async function onPoolToggle(p: AiProviderConfig, enabled: boolean) {
const prev = p.enabled
p.enabled = enabled
try {
// 防御钳制:p.weight 理论上已被 onPoolWeightChange 钳过,此处兜底防脏数据直传 IPC
const w = Math.min(100, Math.max(0, p.weight ?? 50))
await aiApi.updateProviderPool(p.id, enabled, w)
emit('toast', t('settings.toastPoolUpdateOk'), 'info')
} catch (e) {
p.enabled = prev // revert
console.error(t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), e)
emit('toast', t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), 'error')
}
} }
/** /** 卡片列表子件引用(池 debounce 计时器下沉子件,卸载清理经此委托) */
* weight 变更 debounce 300ms(防快速连续输入触发频繁 IPC),clamp [0,100] 对齐后端。 const providerListRef = useTemplateRef<InstanceType<typeof ProviderList>>('providerListRef')
* 双 clamp:前端 clamp 视觉输入 + 后端 command 再 clamp(防御性)。
*/
function onPoolWeightChange(p: AiProviderConfig, raw: number) {
// NaN 兜底(清空输入):回退 50,防 IPC 传 NaN 后端 serde u32 失败
const clamped = Math.min(100, Math.max(0, Number.isFinite(raw) ? Math.trunc(raw) : 50))
const prev = p.weight
p.weight = clamped
// 仅重置同一 provider 的待发计时;跨 provider 互不干扰
const prevTimer = _poolTimers.get(p.id)
if (prevTimer) clearTimeout(prevTimer)
_poolTimers.set(p.id, setTimeout(async () => {
_poolTimers.delete(p.id)
try {
await aiApi.updateProviderPool(p.id, p.enabled !== false, clamped)
emit('toast', t('settings.toastPoolUpdateOk'), 'info')
} catch (e) {
p.weight = prev // revert
console.error(t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), e)
emit('toast', t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), 'error')
}
}, 300))
}
function maskKey(key: string): string {
if (!key || key.length < 8) return '••••••••'
return key.slice(0, 4) + '••••••••' + key.slice(-4)
}
/** 暴露给 shell:loadProviders 供外部主动刷新 / Knowledge 读 aiProviders / 卸载清 timer。 /** 暴露给 shell:loadProviders 供外部主动刷新 / Knowledge 读 aiProviders / 卸载清 timer。
* L19: isFormDirty + closeProviderForm 暴露,供外部(如导航切走前)查询/触发拦截。 * L19: isFormDirty + closeProviderForm 暴露,供外部(如导航切走前)查询/触发拦截。
@@ -744,7 +524,7 @@ defineExpose({
loadProviders, loadProviders,
isFormDirty, isFormDirty,
closeProviderForm, closeProviderForm,
clearPoolTimer() { for (const t of _poolTimers.values()) clearTimeout(t); _poolTimers.clear() }, clearPoolTimer() { providerListRef.value?.clearPoolTimer() },
}) })
// 阶段3 UX 重构:本面板改为 ai-model 类懒挂载,不再由 Settings.vue onMounted 调用, // 阶段3 UX 重构:本面板改为 ai-model 类懒挂载,不再由 Settings.vue onMounted 调用,
@@ -754,92 +534,24 @@ onMounted(loadProviders)
<style scoped> <style scoped>
/* 阶段1/3 UX 重构:panel/panel-header/empty-hint/btn 系列/setting-select/ /* 阶段1/3 UX 重构:panel/panel-header/empty-hint/btn 系列/setting-select/
setting-input/toggle/toggle-slider 已迁移到 settings.css 全局。 setting-input/toggle/toggle-slider 已迁移到 settings.css 全局,btn-link 已并轨 global.css。
本 scoped 仅保留 ProviderPanel 特有样式:provider-card/default-badge/ C4c 拆分:provider 卡片/池控件样式随 provider-list.vue、模型表格/标签样式随
pool-badge/detail 系列/form 系列/btn-link/toggle--sm 及输入控件布局覆盖等。 model-table.vue 下沉子件;本 scoped 仅保留表单区样式(form 系列/红框/错误横幅/
模型列表区容器与提示行)。.setting-input--sm/.toggle--sm 为两子件共用,各持一份。
注:注释列举类名时勿写星斜杠序列(会提前闭合 CSS 注释)。 */ 注:注释列举类名时勿写星斜杠序列(会提前闭合 CSS 注释)。 */
/* ===== 链接按钮(本面板特有,未进全局) ===== */ /* 表单(本面板特有输入控件样式;.setting-select 走全局)。
.btn-link { background: none; border: none; color: var(--df-accent); font-size: 12px; cursor: pointer; padding: 2px 6px; } --form-label-w 为标签列宽单一来源:hint 缩进用 calc(label 列宽+行 gap) 对齐 input 位,
.btn-link:hover { text-decoration: underline; } 免手算魔法数。 */
.btn-link-danger { color: var(--df-danger); } .form-grid { --form-label-w: 72px; display: flex; flex-direction: column; gap: 14px; }
/* ===== Provider 卡片 ===== */
.provider-list { display: flex; flex-direction: column; gap: 12px; }
.provider-card {
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
padding: 14px 16px;
}
.provider-card--default {
border-color: var(--df-accent);
box-shadow: inset 3px 0 0 var(--df-accent);
}
.provider-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.provider-info { display: flex; align-items: center; gap: 8px; }
.provider-name { font-size: 14px; font-weight: 500; color: var(--df-text); }
.default-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: var(--df-radius-xs);
background: rgba(100,255,218,0.15);
color: var(--df-success);
font-weight: 500;
}
.provider-actions { display: flex; gap: 4px; }
.provider-detail { display: flex; flex-direction: column; gap: 6px; }
.detail-row { display: flex; align-items: flex-start; gap: 12px; }
.detail-label {
font-size: 12px;
color: var(--df-text-dim);
min-width: 60px;
}
.detail-value {
font-size: 12px;
color: var(--df-text-secondary);
font-family: var(--df-font-mono);
}
.detail-value.mask { color: var(--df-text-dim); letter-spacing: 1px; }
/* Provider 卡片 disabled 态(c:enabled=false 视觉弱化) */
.provider-card--disabled { opacity: 0.6; }
.pool-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: var(--df-radius-xs);
background: rgba(255,107,107,0.15);
color: var(--df-danger);
font-weight: 500;
}
/* 负载均衡池行(toggle / weight 控件) */
.detail-row--pool { align-items: center; }
.detail-value--control { display: flex; align-items: center; gap: 8px; font-family: var(--df-font-mono); }
.pool-weight-hint { font-size: 11px; color: var(--df-text-dim); }
/* 表单(本面板特有输入控件样式;.setting-select 走全局) */
.form-grid { display: flex; flex-direction: column; gap: 14px; }
.form-field { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; } .form-field { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.form-label { flex: 0 0 72px; font-size: 12px; font-weight: 500; color: var(--df-text-secondary); text-align: right; } .form-label { flex: 0 0 var(--form-label-w); font-size: 12px; font-weight: 500; color: var(--df-text-secondary); text-align: right; }
/* 字段级辅助说明(默认模型提示等):弱化色 + 小字,与 label/input 区分层级。 /* 字段级辅助说明(默认模型提示等):弱化色 + 小字,与 label/input 区分层级。
横向布局下占满整行换到 label+input 下方。 */ 横向布局下占满整行换到 label+input 下方,缩进与 input 左缘结构对齐。 */
.form-field-hint { flex-basis: 100%; margin-left: 82px; font-size: 11px; color: var(--df-text-dim); line-height: 1.5; } .form-field-hint { flex-basis: 100%; margin-left: calc(var(--form-label-w) + 10px); font-size: 11px; color: var(--df-text-dim); line-height: 1.5; }
.form-actions { display: flex; justify-content: flex-end; padding-top: 4px; } .form-actions { display: flex; justify-content: flex-end; padding-top: 4px; }
/* 保存失败持久错误横幅(常驻 form-actions 上方,醒目于自隐 toast) */ /* 保存失败持久错误横幅:视觉(底色/边/圆角/内距)走全局 .df-note--danger,此处仅留内部排版 */
.save-error-banner {
background: var(--df-danger-bg);
border: 0.5px solid var(--df-danger);
border-radius: var(--df-radius-sm);
padding: 10px 12px;
}
.save-error-msg { .save-error-msg {
margin: 0; margin: 0;
font-family: var(--df-font-sans); font-family: var(--df-font-sans);
@@ -860,12 +572,6 @@ onMounted(loadProviders)
/* 走查 P3-10:校验缺失字段红框(@input 即清,用户开始修正就不再红) */ /* 走查 P3-10:校验缺失字段红框(@input 即清,用户开始修正就不再红) */
.setting-input--invalid { border-color: var(--df-danger) !important; } .setting-input--invalid { border-color: var(--df-danger) !important; }
.setting-input--invalid:focus { border-color: var(--df-danger) !important; box-shadow: 0 0 0 1px var(--df-danger); } .setting-input--invalid:focus { border-color: var(--df-danger) !important; box-shadow: 0 0 0 1px var(--df-danger); }
.setting-input--sm { flex: 0 0 auto; min-width: 0; width: 70px; padding: 4px 8px; font-size: 12px; }
/* Toggle 小号变体(本面板负载均衡池用;.toggle 基础走全局) */
.toggle--sm { width: 32px; height: 18px; }
.toggle--sm .toggle-slider::before { width: 12px; height: 12px; left: 3px; bottom: 3px; }
.toggle--sm input:checked + .toggle-slider::before { transform: translateX(14px); }
/* M32: 模型列表有未保存调整时的提示(弱 warning 色,贴合 model-list-hint 之下) */ /* M32: 模型列表有未保存调整时的提示(弱 warning 色,贴合 model-list-hint 之下) */
.model-list-unsaved-hint { .model-list-unsaved-hint {
@@ -874,86 +580,6 @@ onMounted(loadProviders)
line-height: 1.5; line-height: 1.5;
color: var(--df-warning); color: var(--df-warning);
} }
/* ===== 模型列表(表格形式:表头 + 数据行) =====
原 model-list/model-row 等无任何样式定义(裸渲染),本批补齐并改为表格。
风格对齐 provider-card(浅底 / 圆角 / 0.5px 边框)。 */
.model-table {
width: 100%;
border-collapse: collapse;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
overflow: hidden;
font-size: 12px;
}
.model-table th,
.model-table td {
padding: 8px 12px;
text-align: left;
vertical-align: middle;
}
.model-table thead th {
background: var(--df-bg);
font-weight: 500;
color: var(--df-text-dim);
font-size: 11px;
border-bottom: 0.5px solid var(--df-border);
white-space: nowrap;
}
.model-table tbody tr { border-bottom: 0.5px solid var(--df-border); }
.model-table tbody tr:last-child { border-bottom: none; }
.model-table tbody tr:hover { background: var(--df-accent-bg); }
/* 列宽:启用窄、模型/能力弹性、权重窄 */
.model-table .col-enabled { width: 56px; text-align: center; }
.model-table .col-weight { width: 110px; }
.model-table .col-model { min-width: 140px; }
.col-enabled .toggle--sm { display: inline-block; }
.model-id {
font-size: 13px;
font-family: var(--df-font-mono);
color: var(--df-text);
font-weight: 500;
word-break: break-all;
}
/* 标签列:小圆角 chip,三类语义色区分 */
.model-tags { display: flex; flex-wrap: wrap; gap: 4px; }
.tag {
font-size: 10px;
padding: 1px 6px;
border-radius: var(--df-radius-xs);
font-weight: 500;
white-space: nowrap;
}
.tag-modality { background: rgba(100,181,246,0.15); color: var(--df-info); }
.tag-capability { background: rgba(108,99,255,0.15); color: var(--df-accent); }
.tag-source { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
/* 可编辑 tag:×删除按钮(悬停显红)+ 添加按钮 */
.tag-remove { margin-left: 3px; cursor: pointer; opacity: 0.6; font-weight: 700; }
.tag-remove:hover { opacity: 1; color: var(--df-danger); }
.tag-add {
cursor: pointer;
background: transparent;
border: 1px dashed var(--df-border);
color: var(--df-text-dim);
font-weight: 700;
}
.tag-add:hover { border-color: var(--df-accent); color: var(--df-accent); }
/* 添加能力内联菜单(点+展开):浅底容器包可选 tag */
.add-tag-menu {
margin-top: 4px;
padding: 4px;
display: flex;
flex-wrap: wrap;
gap: 4px;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
}
.add-tag-menu .tag { cursor: pointer; }
.add-tag-menu .tag:hover { filter: brightness(1.2); }
.add-tag-empty { font-size: 10px; color: var(--df-text-dim); padding: 2px 4px; }
/* 权重输入框:表格内固定窄宽 */
.model-table .setting-input--sm { width: 64px; }
.model-list-hint { .model-list-hint {
margin-top: 8px; margin-top: 8px;
font-size: 11px; font-size: 11px;

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