Compare commits

58 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
lxy 3dcb3c6fea 新增: 截断标记事件契约字段(953 后端配套)
AiChatEvent 补 truncated_by_max_tokens, 与前端提示条消费配套
2026-10-11 07:38:04 +08:00
lxy f894196924 优化: 换装脚本本地模式适配与版本号
- 未设签名私钥时自动关 updater 产物, 免 tauri build 中断
- 发版(需签名)分流 publish-release.ps1, 本地换装/正式发布分离
2026-10-11 07:38:03 +08:00
lxy 78dd079344 文档: 签名发布SOP与两份走查报告落档
- 签名与发布 SOP: 签名→打包→发布清单→自动升级全链可重复执行
- 上下文管理五维走查全文(瓶颈清单已登记 939)
- 版本升级链走查报告
2026-10-11 07:38:02 +08:00
lxy cd77c1d6c7 优化: Anthropic prompt 缓存默认开启
- 默认开(原默认关): 长会话显式缓存断点省 token, 按端点类型自动判定
- 仅支持缓存的端点生效, 其余零影响; 网关异常可手动关闭回退
2026-10-11 07:38:01 +08:00
lxy 3818a63522 新增: 待发箱抽屉与对话体验前端批
- 待发箱抽屉: ChatInput ⏰ 入口 + OutboxDrawer + useAiOutbox IPC 封装
- U2 进度语义三层: 内容层(最近动作) + 静默三档降速脉冲, 永不自动判死
- 流式心跳自愈: 竞态窗口旧任务迟到完成事件误收敛的自愈判定(949 前端兜底)
- 953 截断提示: 末条气泡渲染截断标记 + 继续输出按钮, 事件契约透传
2026-10-11 07:38:00 +08:00
lxy 31510d3555 新增: 工具结果落盘瘦身与空轮自愈
- 超阈值工具结果正文落盘+句柄可读回(治 read_file 29.6% 体量裸奔)
- read_file 改小文件豁免(8KB), 超阈值压缩+落盘句柄, 无损可找回
- 空轮判据自愈: 连续空轮先 nudge 续轮, 达阈值停靠止损(开关可回退)
- 零 token 收敛信号透传(非截断语义分离)
- 上下文清单描述按字符截断, 防清单体量无限膨胀
2026-10-11 07:33:27 +08:00
lxy 18e50c5af7 新增: 媒体引用化存储与消息溯源
- media_store 模块: sha256 去重落盘 + 阿里云 OSS 双写(签名手写), OSS 失败降级本机引用
- 飞书图片入站: image 消息下载装配 vision 片, 3 次退避+120s 超时+残件清理防线
- 出站回传: 报告 [交付] 路径行自动上传飞书原生附件, 凭据/密钥名拦截兜底
- 溯源 V52: ai_messages.source 列, 飞书 msg_id/chat_id/发送者随消息落库
- spawn offload: 桌面粘贴图 base64 统一转落盘引用, SQLite 不进大字符串
- 引用统一 url 字段(https/file://)不自造 scheme, image_ref 发送前按需展开
2026-10-11 07:33:18 +08:00
lxy 8aefe3254c 修复: df-im-gateway Hello 握手补 proto 字段
- Wave A 7b 给 df-tunnel 的 Hello 结构体新增必填 proto 字段,但 df-im-gateway(commander.rs:84)的构造点漏同步 → cargo check --workspace 挂 E0063
- 补 proto: 1(serde 缺省 v1,与旧服务端兼容);教训:跨 crate 结构体改动自证必须 cargo check --workspace(-p 单 crate 不覆盖下游 bin)
2026-10-10 22:32:48 +08:00
lxy 39e0eac39d 优化: AI Chat 渲染与无障碍 + 文件查看 + 同步 IO 收口
- 渲染: 结果/过程分离(气泡只放最终结果,过程轮叙述+工具卡收进轨迹卡默认收起可展开——治『流式后正文消失』且不再 N 气泡打扰)
- 渲染 P0: v-memo 定向 dep(只有真正渲染流式块的 item 失效,其余取占位) + renderItems 桶 memo(签名覆盖全消费输入,item 引用稳定) + 指纹 watch 降为 O(1) toolEpoch
- TopBar 三处 O(n) messages computed 合并单次遍历;md 缓存改有界 LRU(除满即全清抖动);useTurnTrace 补 never 穷尽 + default;MessageItem 模板去重
- 无障碍(四批累计): 可点容器键盘化(含 role/tabindex/keydown/aria-expanded) + icon-only aria-label 约 60 处 + 装饰图标 aria-hidden + 52 个滚动容器 overscroll-behavior:contain + 设置开关聚焦可见环 + outline 零补偿站点补 :focus-visible
- UX 走查项: 灵感 IDEA-10/11、知识 KNOW-6/7/9、项目详情 O8、审计 SA-4、设置 SA-6/26/27/28(脏表单/冗余 save/clamp/删除二次确认)
- 文件查看专项: 切工程状态残留 F5/F6、FileTree 去重与展开反馈 F7/F15/F17、切文件头部残留 F18、R 状态着色 F19;FilePreview diff 文件头(---/+++)误当增删渲染修复 + 大纲行高亮死样式修复 + 图片失败文案 i18n 化
- 同步 IO 收口(异步上下文内阻塞): unattended 看板读写改 tokio::fs、fetch_url 探活改 spawn_blocking、http/generate_image 清理改 tokio::fs
- 子代理 P3 批量: 任务书钉扎上限/截断文案区分父子语境/环境画像日期/temperature 可覆盖/model 回退上报/lang 透传
- crate 清理: df-execute·df-types 注释与格式、df-ideas 过时注释、df-nodes subflow 夸大描述、df-project 死变量
2026-10-10 08:09:45 +08:00
lxy 8df4e33656 文档: 看板销账与登记 + 设计档六份 + 索引补录
- 看板: 907 补注/909~941 登记与销账(含 929 模型停用根因与 4 处次生缺口、931 队列丢失、933/935/937 新需求、939 上下文瓶颈清单、941 C-r0 行为变更待拍板)
- 归档: 全量完成态条目/段落 + 只读核验销账 4 段迁 todo归档/2026-10-10.md(机械搬运零措辞改动,未完成项零丢失)
- 设计档: 输入框列表预览与自动编号 / 定时消息与待发箱持久化(统一 ai_outbox 底座) / 对话内定时延时任务 / 消息渲染管线重构(P0-P2 分期) / 上下文分层组装器落地方案(记账基点单一真值,与并行写者职责划清) / FileTree 大目录方案(推荐后端分页,否决虚拟滚动) / u-relay 运维手册
- 索引与事实修正: docs/INDEX crate 数 8→13 + 四子树补录 25 条 + 专项设计补录 27 条;纠正「项目无独立 ARCHITECTURE.md」等过时声明;统一 bun 口径
- CLAUDE.md: 新增「命名与代码风格」节(变量/方法名精简+准确;DRY·简洁·扩展性·高内聚低耦合·单一真值来源)
2026-10-10 08:09:25 +08:00
lxy b37279f3a2 新增: 待发箱/唤醒原语/预算随窗口 + 治理门禁与场景护栏
- 待发箱地基: V51 ai_outbox 表 + 手写 OutboxRepo(claim CAS/崩溃残留复位/瞬时失败保留) + outbox.rs 调度器(KV 默认关/Notify 唤醒/会话忙跳过不丢)
- Wake 异步唤醒原语: 三条件(delay/file-appear/process-exit)+纯判定+注入走正典 spawn_agentic_session,默认关
- 上下文预算随模型窗口: model_context.rs 三级优先(池显式>预设表>回退 128K);上界取 1M 而非 128K——max_tokens 仅裁剪预算,卡 128K 等于不治根因
- 文件租约补纯函数与单测(未另造双轨:LEASE 早随 cb260ab 落地) / 跨会话召回补纯函数单测(主体早已落地)
- 子代理: 完整卡已消化 + 子会话压缩层 + P3 批量
- 隧道/中继: 指令离线不丢 + Hello proto 版本 + 心跳与 runbook
- df-mcp: 写工具后落 KV 变更标记(供 GUI 轮询兜底,补独立 stdio 进程无感知)
- 迁移链顶点改单一真值常量 LATEST_SCHEMA_VERSION(修 2 处因 V51 失效的断言)
- 治理: i18n 特殊字符提交门禁(含反引号覆盖 + 10 自测) / 根目录卫生门禁 / 看板归档器 / 单一 lockfile(bun) / swap-install 落 temp/ 且兼容无 Get-FileHash
- 场景护栏: 流式正文可见性 e2e(playwright 免真机事件回放) + 列表续写/外置句柄 node 测试
2026-10-10 08:09:17 +08:00
lxy 9c68f98d89 修复: 模型停用无效根因 + 上下文归位/压缩守卫
- 模型停用无效根因: get_active_provider 两条路径均不校验 enabled(显式活跃直返/默认只挑 is_default) → 收敛为启用集合解析,无启用 provider 明确报错
- 压缩/标题兜底模型不校验 enabled → 新增 resolve_fallback_model 纯函数(title 复用同一实现,单一源无双轨)
- 上下文多夹层归位修复: gather_tool_responses 聚拢该头全部响应(此前第二个夹层之后的响应仍被判未闭合剥除)
- 压缩后台任务加内容版本哨兵(取首条消息 id,否掉 len:压缩期间 loop 正常 append 会致守卫永不生效) + panic 兜底复位标志
- 902 超时类失败与其它 Fatal 区分停靠文案(点继续可续跑)
- 子代理事件按 conv 改挂(route_scope) + 运行期事件推父会话视图
- 飞书 13a 真根因: run_stream_refresher 死代码(提前 drop)+静默钟被心跳重置 → 帧通道+仅内容事件重置
- 项目详情文件查看: F22 目录不存在误显非 Git 仓库 / F24 模块为仓库子目录时 git 徽标全失效(scope_git_path 按 show-prefix 剥前缀)
- 更新徽章通道错配(Tauri 事件 vs window CustomEvent 同名不同通道,徽章此前永不触发)
- ProjectCard 卡片根 keydown 顶替内嵌按钮(补 e.target 守卫)
- i18n 坏键 ai.aiTool.rejectedHint → aiTool.rejectedHint(双缺会渲染裸 key)
- miniapp 三处: 会话列表刷新漏网/看门狗节流致新一轮无兜底/newConversation 漏清看门狗
2026-10-10 08:08:48 +08:00
lxy 2a3a6cd25b 优化: 无障碍第二批(icon-only aria-label + 容器键盘化 20 处)
- TopBar 10 处 icon-only 按钮补 aria-label(复用同 :title 表达式,含三态 title)
- ConversationSidebar: 4 icon-only aria-label + 2 分组标题键盘化(role/tabindex/aria-expanded/keydown)
- MessageList 2 icon-only; KnowledgeDetail 3 章节折叠键盘化; dashboard 两列表项键盘化
- FileExplorer 面包屑/模块下拉项键盘化; ConflictResolver 冲突文件折叠键盘化
- 约定: 遮罩/点外关闭类(@click.self)非键盘缺口,跳过;零新增 i18n 键
- 验收: bunx vue-tsc --noEmit EXIT 0;看板 P3-2 更新(两批共 23 处)
2026-10-10 01:04:09 +08:00
lxy 9d83a06300 优化: 无障碍批次(可点容器键盘化 + icon-only aria-label)
- P2-2 命名 4 处可点容器键盘可达: ToolCard 卡头 / MessageList 轨迹卡头 / AuditLog audit-row(tr) / conv-list-item —— tabindex+keydown 复用原 handler, div 类加 role=button+aria-expanded; tr 因 role=button 会抹掉行语义故不加 role
- P3-2 3 处 icon-only 按钮补 aria-label(EnrichmentPanel 关闭 / ProjectDetail 编辑·删除),复用同 :title 的 i18n key,零新增键
- 硬约束执行: 4 个并行子代理按文件分区,零跨文件改动;主代理独立核查 diff 后修正一处语义缺陷(tr role)
- 看板同步: P2-2 销账 / P3-2 部分完成(~12 处待续) / 残留诊断代码 authz_debug 已清销账 / G6 标题泄漏(title.rs sanitize_title_source 双路径)销账
- 验收: bunx vue-tsc --noEmit EXIT 0 + node scripts/check-i18n.cjs 0 失败
2026-10-10 01:00:28 +08:00
lxy bcb0b82f33 修复: 流式正文被轮次折叠吞掉(真根因)
- 根因: renderItems 完成轮分桶把整轮含正文折进轨迹卡且默认收起,thinkPlainText 又 reasoning_content||content 把正文当思考塞单行 pill → 流式期间可见、轮末随卡消失(现象=气泡清空回到等待)
- 修复: 过程轮有正文的各成常显气泡(剥 toolCalls,工具卡仍归轨迹卡不重复);思考 pill 只认 reasoning_content
- 历次修补(40c4a21/4652112/524120b/BUG-260624-01/flush guard)都在状态清空层,未碰渲染折叠层 → 故无效;看板 911 留痕教训
- 验收: vue-tsc --noEmit EXIT 0
2026-10-10 00:51:50 +08:00
lxy e61fcdea79 新增: 看板归档器 + 已完成内容再分离
- scripts/archive-todo.cjs: 把看板已完成内容分离到归档(条目块 [x] 自动移出;历史/导航段走标题白名单整段移出,不靠启发式猜--本板存在大量无勾选的行内待办,启发式会误移)
- 安全闸: 未完成项零丢失断言(失败即中止不写);默认 dry-run;--list 从 UTF-8 文件读白名单(规避 Windows CLI 中文编码);容器标题(下挂更深子节)不当作空标题删
- 本次移出 6 段(统一规划 2026-06-26 全景/交接状态 2026-06-14/编排推进总览 2026-06-18)+ 909 条目;主板 847→736 行
- 核对: 未完成项 109 项零丢失,主板 [x] 清零,归档收 909 及其验收块
2026-10-10 00:47:10 +08:00
lxy 73dec866a4 修复: 压缩重入标志复位与兜底提交收口单锁
- keyword_fallback_commit 内单锁一并 set_compressing(false) + 兜底提交,消「锁外复位→再取锁提交」并发二次压缩窗口(放大楔入概率)
- 复位置于 KEYWORD_FALLBACK_ENABLED 门控之前(关开关同样复位,防标志永真致压缩停摆)
- 两处调用点(provider 重建失败/LLM 失败)独立复位块删除
- 验收: cargo check -p devflow EXIT 0 + cargo test -p devflow --lib 749 passed/0 failed;看板 909 收口
2026-10-10 00:42:48 +08:00
lxy bdd723f62c 修复: swap-install 兼容无 Get-FileHash 的环境
- 本机 Windows PowerShell 5.1 实测缺 Get-FileHash(v3 脚本 3 处调用直接报错中断,换装不执行)
- 新增 Get-Sha256 辅助:优先 Get-FileHash,异常兜底系统 certutil;两法实测同值
- 收口 3 处调用(baseline/before/after)
2026-10-10 00:42:47 +08:00
lxy da036db1f2 清理: 根目录散乱文件归位
- 删根 9 个构建/换装日志(*.log 本已 gitignore,属磁盘残留)+ 根 todo.md(陈旧副本,真板为 docs/todo.md)+ .dockerignore(无 Dockerfile)
- 根 swap-install.ps1(v3 加固版)迁 scripts/,替换其中陈旧 43 行版(句柄探测/SHA256 校验/重试/自清理)
- dev-tools.bat / dev-server.bat 迁 scripts/
- Batch.md / PROGRESS.md 迁 docs/07-项目管理/,同步修正 5 处指向根的文档链接
- .gitignore 补 /swap-install.lock(v3 运行期锁)
2026-10-10 00:37:19 +08:00
lxy 58834c9443 新增: i18n 消息特殊字符提交门禁
- 字面 @ / | / 裸 {} 未转义只在 prod runtime 抛 Invalid linked format(dev 宽松回退、vue-tsc 捕获不到),已两次致生产回归(白屏/输入框消失)
- scripts/check-i18n.cjs:用 @intlify/message-compiler 编译全部含特殊字符的 i18n 值,npm run check:i18n 可手动跑
- commit-msg 规则 i18n-special-chars.sh:暂存区含 src/i18n/ 即跑预检,失败拒提交;双向测试过(干净放行/坏值 exit1)
2026-10-10 00:28:40 +08:00
lxy a95e205eb6 修复: 三元组相邻性归位保住工具轮
- df-ai sanitize 新增 step 0.5 reorder_interlopers_after_tool_run: 楔入 head 与 tool 响应间的异类消息(压缩摘要/用户插话/警示)移到该组响应之后,只重排不删内容,工具轮不再被剥除
- 幂等;仅处理单头+夹层+紧随连续 tool 段,其余畸形仍走剥除兜底
- 单测: 旧剥除用例改为归位保轮 + 新增幂等用例;cargo test -p df-ai --lib context 156 passed
- 实证更正: 2026-08-04 报告「实证4」被推翻——已定位原始会话 b4d6b4e0,首轮裸问候即发两个 tool_calls;同步更新看板 909 状态
2026-10-10 00:27:47 +08:00
lxy d918237327 修复: i18n 裸 @ 致生产环境对话输入框消失
- 905 将 @ 改为裸 @,而 vue-i18n 的 @ 是 linked-message 语法字符,未转义只在 prod runtime 抛 Invalid linked format(dev 宽松回退/vue-tsc 捕获不到)
- zh/en aiChat 8 处(placeholderDefault/mentionHint/searchFilesBlocked/projectLabel)按规范转义为 {'@'}
- 输入框 placeholder 消息编译抛错→ChatInput 渲染失败→输入框消失,即本次生产回归根因
- 验证: 全库 462 个含 @/{/| 的 i18n 值经 message-compiler 编译 0 失败 + vue-tsc EXIT 0
2026-10-10 00:27:43 +08:00
lxy 1f5e6cce90 修复: system 锚点避让悬空 tool 调用头防 400
- df-ai 新增 ContextManager::push_system_anchor 统一出口:末条为悬空 ToolCallHead 时插其前,否则尾部追加(不位移前缀)
- 5 处 system 尾部 push 全量收口(压缩成功/兜底提交、警示单点、主题标记、续轮 nudge),从源头杜绝后台压缩摘要/警示楔入工具三元组致上游 400 insufficient tool messages
- 单测 push_system_anchor 两态锁定;cargo test -p df-ai --lib context 155 passed / cargo check -p devflow EXIT 0
- 看板归档:全量完成态条目与段落迁 docs/07-项目管理/todo归档/2026-10-10.md(零措辞改动机械搬运,未完成项110条零丢失/完成项37条全入归档/行数守恒)
2026-10-10 00:21:13 +08:00
lxy c71d0c4657 修复: AI上下文邻接性收口 + 设置页i18n缺失键
- df-ai 三元组邻接性收口: sanitize/openai_compat 闭合判定由「id 全局存在」收紧为「紧后 tool 连续段覆盖」,夹层序列(后台压缩/用户插话楔入)不再直送 provider 致 400 insufficient tool messages
- 设置页补 settings.loading 缺失键(zh/en),修版本信息返回前渲染字面串
- 看板: 907 接手核查补注(实现口径=剥除非归位,有损)+ 遗留登记 909 + 908 销账
2026-10-10 00:08:35 +08:00
lxy f4ca01f2f2 优化: df-ai/df-storage/df-nodes 底层加固: 上下文/模型/节点/仓储
df-ai:
- context/mod.rs + manager_tests.rs: 上下文管理调整 + 测试同步(48 行)
- openai_compat.rs: OpenAI 兼容层增强(+51)
- presets/models.json: 模型预设 +18

df-storage:
- crud/conversation_repo.rs: 会话仓储(loop_state 相关)
- crud/task_repo.rs: 任务仓储
- crud/mod.rs: 导出调整

df-nodes:
- task_advance_node.rs: 任务推进节点

验收: cargo check -p devflow EXIT 0。
2026-10-09 22:54:25 +08:00
lxy d7e626a813 修复: 飞书走查六项 + 停靠状态写侧根因解 + agentic/audit 加固
飞书集成走查六项(feishu_channel.rs +420):
1. 审批卡按钮化: on_card_action + handle_card_action(解析 {approval_id,approved}
   → ai_approve) + send_approval_button_card(Card JSON 1.0 双按钮, 失败降级文本卡)
2. 首帧流式提速: AiTextDelta 首个 delta 立即刷占位卡, 不等 3s ticker
3. 断线重连通知: 按 run_loop 时长判据(≥30s 推断开 / <30s 累计 3 次推不稳定),
   新增 notify_monitor()
4. 占位卡带任务原文回显(truncate 60)
5. 白名单配置引导: 后端分步自助引导 + FeishuPanel hintFeishuWhitelist + i18n
6. 审批决议后追加执行结果: spawn_approval_result_watcher 订阅 AiToolCallCompleted
   (匹配 tool_call_id, 60s 超时兜底); 订阅须在 ai_approve 前完成(无订阅者跳过序列化)

停靠(parked)写侧根因解(904):
emit_task_parked(ai/mod.rs) 升级为「写 parked + 发事件 + 通知」三件套——
新增 set_loop_state(conv_id, Some("parked")) fire-and-forget; 8 个业务调用点
(H2 三闸/熔断两路/挂起取消/max_rounds/midstream/fatal + 审批超时) 全经单点。
治「停靠卡继续必报 AI 未在暂停态」(全仓原无写 parked 路径)。

agentic/audit 加固: context_lifecycle/stream_relay/mod 调整 + audit record 完善 +
tool_failure_memory/tool_result_offload/tools/task/settings 同步。

验收: cargo check -p devflow EXIT 0。
2026-10-09 22:54:19 +08:00
lxy b283622c64 文档: 看板销账: 🔴 清零(187 路径误弹窗/214 401/229 架构扫描/457 AI自审/提交后跟进)
G2 门禁前置清理——5 条历史 🔴 逐条实证后销账:
1. 187 BUG-260620-05 工程内路径误弹窗: 方案已演进为动态推导(优于原「无条件插入
   workspace_root」), state.rs:579 实证「不再硬塞编译期 workspace_root」, 工程根
   授权改由 bind_directory + Settings.allowed_dirs 两途径, 黑名单兜底。已修销账。
2. 214 aichat API Key 401: 排查结论登记(非 devflow bug, 401 来自服务端), 非代码
   缺陷, 降级销账。
3. 229 架构坏味道扫描: 6 路 Explore agent 报告类归档(非缺陷), P1 项已各自销账,
   降级非 🔴。
4. 457 AI 自审/工作流推进链路: 标题即「P0×3 已修, 2026-10-05 核验销账」。
5. 提交后跟进: 10/11/12 三项已修销账。

标题 🔴 → ✅, 复核 `### 🔴` 余量 = 0。.gitignore 补规则。

验收: Select-String '^### 🔴' docs/todo.md → (无), G2 门禁通过。
2026-10-09 22:54:07 +08:00
lxy 40c4a21a7e 优化: AI Chat 消息视图: 闪烁修复+骨架统一+过程卡降级+任务关联 chip
MessageList.vue:
1. 流式闪烁修复: _mdRenderKey 稳定化 + key 对齐, 消除流式重渲染抖动
2. 骨架统一: 过程步骤卡改用 trace 卡骨架(头部一行摘要 + 正文增量)
3. 过程卡视觉降级: 缩进收窄 + 紧贴结果, 弱化过程突出结论
4. 消息任务关联: TASK_REF_TOOLS 映射(advance_task/run_workflow) 从
   toolCalls[].args 派生任务 chip, 零数据模型改动(单一事实源), 无任务工具
   的消息不渲染 chip; 新增 .ai-trace-task-chip 样式 + go-to-task emit

AiChat.vue: goToTask(taskId) 接参, router.push('/tasks?taskId=xxx')
Tasks.vue: applyRouteTaskId 消费 query.taskId 自动开抽屉(onMounted 跨路由 +
  watch 同路由), 消费后 router.replace 清 query 保持 URL 干净
i18n zh/en: aiChat.goToTaskHint + ai.trace.taskRef 双语补键

验收: vue-tsc --noEmit EXIT 0 + build EXIT 0。
2026-10-09 22:53:22 +08:00
lxy a8c92e9619 新增: 应用内版本更新与自动升级全链路
后端 update.rs: app_check_update(拉 latest.json+ed25519 签名校验) /
app_download_install_update / prefs 读写 / mark_checked / mark_notified 五命令,
lib.rs 注册;tauri.conf updater 段(endpoints=app.1216.top/download/devflow/latest.json
+ pubkey + createUpdaterArtifacts) + tauri-plugin-updater 依赖 + capabilities 权限。

前端 UpdateBadge.vue(顶栏徽章+下拉) / useUpdateCheck.ts(检查/下载/安装状态机) /
AboutPanel.vue(设置页关于面板) + AppLayout 接线 + SettingsNav/searchIndex 索引 +
i18n zh/en settings 键。

运维 scripts/publish-release.ps1(发版) + swap-install.ps1(换装校验) +
设计文档 版本更新与自动升级-设计-2026-10-09.md。

验收: cargo check -p devflow EXIT 0 + vue-tsc EXIT 0 + build EXIT 0。
2026-10-09 22:53:14 +08:00
266 changed files with 27158 additions and 7490 deletions
-11
View File
@@ -1,11 +0,0 @@
# docker build 排除(docker context = repo 根,仅 df-relay 编译需 workspace 源码)
target
node_modules
dist
dist-ssr
.git
*.db
*.log
apps/df-miniapp/node_modules
apps/df-miniapp/dist
src-tauri/target
+11
View File
@@ -3,6 +3,9 @@ logs
*.log *.log
npm-debug.log* npm-debug.log*
# 换装脚本(scripts/swap-install.ps1)运行期锁文件
/swap-install.lock
# Dependencies # Dependencies
node_modules/ node_modules/
@@ -32,6 +35,10 @@ target-musl/ # df-relay 交叉编译产物隔离目录(见 .cargo/config.toml)
src-tauri/target/ src-tauri/target/
src-tauri/Cargo.lock src-tauri/Cargo.lock
# 签名私钥(应用内升级,设计 §七 高风险项:私钥绝不入仓)
*.key
*.key.pub
# Tauri 自动生成的 schema(每次构建变,可重建) # Tauri 自动生成的 schema(每次构建变,可重建)
src-tauri/gen/schemas/ src-tauri/gen/schemas/
@@ -100,3 +107,7 @@ temp/
# df-im-gateway 运行时数据(冒烟测试残留) # df-im-gateway 运行时数据(冒烟测试残留)
crates/df-im-gateway/data/ crates/df-im-gateway/data/
# Playwright 运行产物
test-results/
playwright-report/
+5
View File
@@ -16,5 +16,10 @@
- 边界对齐意志卡 forbidden:发版/部署/重启线上、git push、生产数据操作、对外发消息,攒齐等拍板。 - 边界对齐意志卡 forbidden:发版/部署/重启线上、git push、生产数据操作、对外发消息,攒齐等拍板。
- 本机工具/脚本的行为变更属 report_after 级(干完通报、可回滚),不属确认级。 - 本机工具/脚本的行为变更属 report_after 级(干完通报、可回滚),不属确认级。
## 命名与代码风格(2026-10-10 用户重申)
- **变量/方法名:精简 + 准确**——一眼看出「是什么/做什么」且不超必要长度。忌把实现细节写进名字(`..._after_tool_run`)、忌无信息缩写(`tmp2`/`h`)、忌同概念多叫法。名字过长通常说明该拆函数或把说明下沉到注释(注释承载「为什么」,名字只承载「是什么」)。
- **DRY · 简洁 · 扩展性 · 高内聚低耦合**(同期重申):新增能力先找共用底座(如定时消息与定时任务共用一张 `ai_outbox` + 一个调度器),不造双轨;同一事实只留**单一真值来源**(如 token 估值口径)。
## 其他规范 ## 其他规范
- 架构设计文档在 `docs/02-架构设计/`;当前活跃专项:无人值守可靠性(B1-B4)+ 异步任务会话(13-17),设计见 `temp/` 与 docs。 - 架构设计文档在 `docs/02-架构设计/`;当前活跃专项:无人值守可靠性(B1-B4)+ 异步任务会话(13-17),设计见 `temp/` 与 docs。
Generated
+249 -6
View File
@@ -104,6 +104,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
dependencies = [
"derive_arbitrary",
]
[[package]] [[package]]
name = "asn1-rs" name = "asn1-rs"
version = "0.7.2" version = "0.7.2"
@@ -996,6 +1005,17 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "derive_arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "2.1.1" version = "2.1.1"
@@ -1019,7 +1039,7 @@ dependencies = [
[[package]] [[package]]
name = "devflow" name = "devflow"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -1039,7 +1059,10 @@ dependencies = [
"df-workflow", "df-workflow",
"futures", "futures",
"futures-util", "futures-util",
"hex",
"hmac",
"htmd", "htmd",
"httpdate",
"keyring", "keyring",
"larksuite-oapi-sdk-rs", "larksuite-oapi-sdk-rs",
"percent-encoding", "percent-encoding",
@@ -1047,6 +1070,8 @@ dependencies = [
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
"sha1 0.10.6",
"sha2 0.10.9",
"sys-locale", "sys-locale",
"tauri", "tauri",
"tauri-build", "tauri-build",
@@ -1054,6 +1079,7 @@ dependencies = [
"tauri-plugin-notification", "tauri-plugin-notification",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-single-instance", "tauri-plugin-single-instance",
"tauri-plugin-updater",
"tauri-plugin-window-state", "tauri-plugin-window-state",
"tokio", "tokio",
"tokio-tungstenite 0.23.1", "tokio-tungstenite 0.23.1",
@@ -1076,6 +1102,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"base64 0.22.1",
"bytes", "bytes",
"df-ai-core", "df-ai-core",
"df-types", "df-types",
@@ -1278,6 +1305,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer 0.10.4", "block-buffer 0.10.4",
"crypto-common 0.1.7", "crypto-common 0.1.7",
"subtle",
] ]
[[package]] [[package]]
@@ -1571,6 +1599,16 @@ dependencies = [
"rustc_version", "rustc_version",
] ]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@@ -2142,6 +2180,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest 0.10.7",
]
[[package]] [[package]]
name = "htmd" name = "htmd"
version = "0.5.5" version = "0.5.5"
@@ -2572,6 +2619,36 @@ dependencies = [
"windows-sys 0.45.0", "windows-sys 0.45.0",
] ]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.18",
"walkdir",
"windows-link 0.2.1",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.117",
]
[[package]] [[package]]
name = "jni-sys" name = "jni-sys"
version = "0.3.1" version = "0.3.1"
@@ -2896,6 +2973,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -3277,6 +3360,18 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
] ]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.13.0",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]] [[package]]
name = "objc2-quartz-core" name = "objc2-quartz-core"
version = "0.3.2" version = "0.3.2"
@@ -3420,6 +3515,20 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "pango" name = "pango"
version = "0.18.3" version = "0.18.3"
@@ -4059,15 +4168,20 @@ dependencies = [
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde", "serde",
"serde_json", "serde_json",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http",
@@ -4213,6 +4327,33 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni 0.22.4",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.13" version = "0.103.13"
@@ -4661,6 +4802,22 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.3" version = "1.0.3"
@@ -4830,6 +4987,17 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]] [[package]]
name = "sync_wrapper" name = "sync_wrapper"
version = "1.0.2" version = "1.0.2"
@@ -4911,7 +5079,7 @@ dependencies = [
"gdkwayland-sys", "gdkwayland-sys",
"gdkx11-sys", "gdkx11-sys",
"gtk", "gtk",
"jni", "jni 0.21.1",
"libc", "libc",
"log", "log",
"ndk", "ndk",
@@ -4944,6 +5112,17 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]] [[package]]
name = "target-lexicon" name = "target-lexicon"
version = "0.12.16" version = "0.12.16"
@@ -4968,7 +5147,7 @@ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"http", "http",
"http-range", "http-range",
"jni", "jni 0.21.1",
"libc", "libc",
"log", "log",
"mime", "mime",
@@ -5179,6 +5358,39 @@ dependencies = [
"zbus 5.16.0", "zbus 5.16.0",
] ]
[[package]]
name = "tauri-plugin-updater"
version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5cad8ed5948d988e1018ecd31e27cacedbf72ce3fb972940c3e4bf51639e4b"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest 0.13.4",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.18",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]] [[package]]
name = "tauri-plugin-window-state" name = "tauri-plugin-window-state"
version = "2.4.1" version = "2.4.1"
@@ -5204,7 +5416,7 @@ dependencies = [
"dpi", "dpi",
"gtk", "gtk",
"http", "http",
"jni", "jni 0.21.1",
"objc2", "objc2",
"objc2-ui-kit", "objc2-ui-kit",
"objc2-web-kit", "objc2-web-kit",
@@ -5227,7 +5439,7 @@ checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9"
dependencies = [ dependencies = [
"gtk", "gtk",
"http", "http",
"jni", "jni 0.21.1",
"log", "log",
"objc2", "objc2",
"objc2-app-kit", "objc2-app-kit",
@@ -6376,6 +6588,15 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "1.0.8" version = "1.0.8"
@@ -7069,7 +7290,7 @@ dependencies = [
"gtk", "gtk",
"http", "http",
"javascriptcore-rs", "javascriptcore-rs",
"jni", "jni 0.21.1",
"libc", "libc",
"ndk", "ndk",
"objc2", "objc2",
@@ -7133,6 +7354,16 @@ dependencies = [
"time", "time",
] ]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]] [[package]]
name = "xdg-home" name = "xdg-home"
version = "1.3.0" version = "1.3.0"
@@ -7379,6 +7610,18 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.14.0",
"memchr",
]
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"
+6 -3
View File
@@ -28,6 +28,9 @@ keyring = { version = "3", features = ["windows-native", "apple-native", "linux-
dashmap = "6" dashmap = "6"
# release 增量编译:小改动重打包显著提速(2026-10-04 打包提速项); # release 增量编译:小改动重打包显著提速(2026-10-04 打包提速项);
# 代价为产物略大,正式发版如在意体积可临时注释此段 # ⚠️ 2026-10-09 回退:release + incremental 触发链接期匿名符号丢失
[profile.release] # (anon.*.llvm.* undefined symbol,rust-lld 与 MSVC link.exe 均复现),
incremental = true # 根因是增量编译 CGU 划分与跨 crate 内联冲突。cargo check 不链接故不暴露,
# cargo build/tauri build 链接期必挂。release 禁用 incremental 为 Rust 官方推荐。
# [profile.release]
# incremental = true
@@ -348,6 +348,9 @@ function clearAllApprovalTimers(): void {
function clearWatchdog(): void { function clearWatchdog(): void {
if (streamTimer) clearTimeout(streamTimer) if (streamTimer) clearTimeout(streamTimer)
streamTimer = null streamTimer = null
// 复位节流时间戳:resetWatchdog 的 5s 节流假定「已有 timer 在跑」,而此处刚清空 timer。
// 不复位则「停止/报错后 5s 内重发」会因节流跳过 arm → 新一轮无看门狗(中途断则 generating 永卡)。
lastWatchdogReset = 0
} }
/** 已注册事件监听标志(防多次订阅 wsClient) */ /** 已注册事件监听标志(防多次订阅 wsClient) */
@@ -1374,6 +1377,7 @@ function newConversation(): void {
tokenUsage.value = null // 新会话清 token(对齐 switchConversation) tokenUsage.value = null // 新会话清 token(对齐 switchConversation)
activeModel.value = null // 新会话复位模型选择(新会话无 per_conv 模型,走默认路由) activeModel.value = null // 新会话复位模型选择(新会话无 per_conv 模型,走默认路由)
pendingApprovals.value.splice(0, pendingApprovals.value.length); clearAllApprovalTimers() pendingApprovals.value.splice(0, pendingApprovals.value.length); clearAllApprovalTimers()
clearWatchdog() // 与 switchConversation 对齐:清残留看门狗,防新一轮 send 沿用旧 timer(超时点偏移)
} }
/** /**
+5 -2
View File
@@ -7,6 +7,9 @@ import { useAiChat } from '@/composables/useAiChat'
import type { MentionSpan, MiniContentPart } from '@/types/relay' import type { MentionSpan, MiniContentPart } from '@/types/relay'
import type { ProjectRecord, TaskRecord, IdeaRecord, AiToolCallInfo, ChatMessage, TokenUsage } from '@/types/events' import type { ProjectRecord, TaskRecord, IdeaRecord, AiToolCallInfo, ChatMessage, TokenUsage } from '@/types/events'
/** 调试日志门控(对齐 useAiChat/ws.ts:import.meta.env.DEV,生产零 bridge 成本) */
const DEBUG = import.meta.env.DEV
// 组件内联(绕过微信工具 3.15.2 组件解析缓存 bug):独立组件恒报 Component not found, // 组件内联(绕过微信工具 3.15.2 组件解析缓存 bug):独立组件恒报 Component not found,
// inline 进页面后 chat/index.json usingComponents 空,工具无需解析 components/,问题消失。 // inline 进页面后 chat/index.json usingComponents 空,工具无需解析 components/,问题消失。
// MdView/MentionInput 源保留(src/components/ 下,工具 bug 修复后可拆回)。 // MdView/MentionInput 源保留(src/components/ 下,工具 bug 修复后可拆回)。
@@ -512,11 +515,11 @@ function onMentionClose(): void {
* 保留原有守卫:空文本忽略 / generating 中忽略 / 关闭联想浮层。 * 保留原有守卫:空文本忽略 / generating 中忽略 / 关闭联想浮层。
*/ */
function handleSend(): void { function handleSend(): void {
console.log('[v2:send] enter text=', JSON.stringify(inputText.value), 'gen=', generating.value, 'imgs=', pendingImages.value.length) if (DEBUG) console.log('[v2:send] enter text=', JSON.stringify(inputText.value), 'gen=', generating.value, 'imgs=', pendingImages.value.length)
const text = inputText.value.trim() const text = inputText.value.trim()
const hasImage = pendingImages.value.length > 0 const hasImage = pendingImages.value.length > 0
if (!text && !hasImage) { if (!text && !hasImage) {
console.log('[v2:send] 空文本且无图片,忽略') if (DEBUG) console.log('[v2:send] 空文本且无图片,忽略')
return return
} }
if (generating.value) { if (generating.value) {
@@ -12,11 +12,15 @@ const ai = useAiChat()
*/ */
const conversations = ref<Conversation[]>([]) const conversations = ref<Conversation[]>([])
/** /**
* 监听 ai.conversations.length 变化(比 deep watch 更可靠)。 * 监听 ai.conversations 整体替换(比 deep watch 更可靠,也比只盯 length 完整)。
* 用 `[...ai.conversations]` 创建普通数组赋值给 ref,确保每次变化都触发模板更新。 * 用 `[...ai.conversations]` 创建普通数组赋值给 ref,确保每次变化都触发模板更新。
*
* 为什么监听 ref 而非 length:device 回推 AiConversationList 是「整体替换」且条数常不变
* (改名后条数不变),只盯 length 时 watch 回调不触发 → 本地副本滞留旧标题。
* 监听 ref 则在赋值(含同长度替换)时即触发刷新。
*/ */
watch(() => ai.conversations.value.length, () => { watch(ai.conversations, (list) => {
conversations.value = [...ai.conversations.value] conversations.value = [...(list || [])]
}, { immediate: true }) }, { immediate: true })
/** 点击会话项切换 */ /** 点击会话项切换 */
+9 -6
View File
@@ -42,25 +42,25 @@ impl ContentPart {
impl ChatMessage { impl ChatMessage {
pub fn system(content: impl Into<String>) -> Self { pub fn system(content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::System, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::System, content: content.into(), parts: None, source: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
} }
pub fn user(content: impl Into<String>) -> Self { pub fn user(content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: None, source: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
} }
pub fn assistant(content: impl Into<String>) -> Self { pub fn assistant(content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, source: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
} }
pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self { pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, source: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
} }
pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self { pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::Tool, content: content.into(), parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::Tool, content: content.into(), source: None, parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
} }
/// 多模态 user 消息:content 文本 + parts(含 Image 片)。 /// 多模态 user 消息:content 文本 + parts(含 Image 片)。
/// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。 /// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。
pub fn user_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self { pub fn user_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: Some(parts), tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: Some(parts), source: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
} }
/// 是否含图片片(供 provider 判定走多模态分支)。 /// 是否含图片片(供 provider 判定走多模态分支)。
@@ -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,
+21
View File
@@ -68,6 +68,22 @@ pub enum ContentPart {
}, },
} }
/// 消息来源(#958 溯源):外部渠道进入会话的原始消息定位。序列化为 JSON 随消息
/// 落库(ai_messages.source 列);桌面本端消息恒 None 不落
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MessageSource {
/// 渠道标识:"feishu"(后续可扩 openapi/webhook 等)
pub channel: String,
/// 渠道侧原始消息 ID(飞书 om_/oc_ 前缀,可跳原消息追溯)
pub message_id: String,
/// 渠道侧会话/群 ID(可选,群聊定位)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chat_id: Option<String>,
/// 发送者标识(飞书 open_id,可选)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sender: Option<String>,
}
/// 聊天消息 /// 聊天消息
/// ///
/// ⚠️ **迁移耦合点**:下方各字段名(`role`/`content`/`parts`/`tool_call_id`/ /// ⚠️ **迁移耦合点**:下方各字段名(`role`/`content`/`parts`/`tool_call_id`/
@@ -85,6 +101,11 @@ pub struct ChatMessage {
pub id: Option<String>, pub id: Option<String>,
pub role: MessageRole, pub role: MessageRole,
pub content: String, pub content: String,
/// 消息来源(#958 溯源):消息从哪个渠道/端点进入会话。None=桌面本端(既有语义,
/// 老数据反序列化为 None 零回归);Some=外部渠道(飞书 message_id 可跳原消息追溯,
/// 多端混发可区分来源端)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<MessageSource>,
/// 多模态内容片。 /// 多模态内容片。
/// ///
/// `None`/空 → 纯文本消息(绝大多数场景,content 即全部载荷)。 /// `None`/空 → 纯文本消息(绝大多数场景,content 即全部载荷)。
+1
View File
@@ -12,6 +12,7 @@ tokio = { workspace = true, features = ["sync", "time"] }
async-trait = { workspace = true } async-trait = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
base64 = "0.22"
# HTTP + 流式 # HTTP + 流式
reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"] } reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"] }
+18
View File
@@ -136,6 +136,24 @@
"context_window": 262144, "context_window": 262144,
"temperature_supported": false "temperature_supported": false
}, },
{
"model_id": "sensenova-6.8-flash-lite",
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"weight": 85,
"context_window": 262144,
"temperature_supported": false
},
{
"model_id": "sn2-kimi",
"enabled": true,
"modalities": ["text"],
"capabilities": ["tool_use"],
"weight": 80,
"context_window": 131072,
"temperature_supported": false
},
{ {
"model_id": "deepseek-v4-flash", "model_id": "deepseek-v4-flash",
"enabled": true, "enabled": true,
+312 -19
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| {
@@ -288,9 +347,11 @@ impl AnthropicCompatProvider {
/// 单个 ContentPart → Anthropic content block(text/image)。 /// 单个 ContentPart → Anthropic content block(text/image)。
/// - Text 片 → {type:text, text} /// - Text 片 → {type:text, text}
/// - Image 片 → {type:image, source:{type:base64, media_type, data}} /// - Image 片 → {type:image, source:{type:base64, media_type, data}}
/// base64 内嵌;url 模式当前不可达,兜底发空 data + warn(保留原行为)。 /// base64 内嵌;本地 file:// 引用(#958)先展开为 base64,http(s) url 模式
/// 当前不可达,兜底发空 data + warn(保留原行为)。
/// match 取得 p 所有权后直接 move media_type/base64,避免大 base64 clone。 /// match 取得 p 所有权后直接 move media_type/base64,避免大 base64 clone。
fn content_part_to_block(p: crate::provider::ContentPart) -> serde_json::Value { fn content_part_to_block(p: crate::provider::ContentPart) -> serde_json::Value {
let p = crate::image_ref::inline_local_image_part(p);
match p { match p {
crate::provider::ContentPart::Text { text } => serde_json::json!({ crate::provider::ContentPart::Text { text } => serde_json::json!({
"type": "text", "type": "text",
@@ -326,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)) => {
@@ -427,6 +515,22 @@ impl AnthropicCompatProvider {
.and_then(|m| m.get("role").and_then(|r| r.as_str())) .and_then(|m| m.get("role").and_then(|r| r.as_str()))
.unwrap_or(""); .unwrap_or("");
if first_role == "user" { if first_role == "user" {
// 首条 user 但 content 为空串(会话压缩/续发残留,典型 text(0B)):原样发出会被
// precheck "user content 空" 拦下而整请求失败。与上方补占位同思路——就地填
// 占位文案(空串无信息可丢),上游实测接受非空占位,precheck 亦放行。
let empty_str = messages[0]
.get("content")
.and_then(|c| c.as_str())
.map(|s| s.is_empty())
.unwrap_or(false);
if empty_str {
warn!(
msg_count = messages.len(),
"ensure_leading_user: 首条 user content 为空串,填占位(防 precheck 拦截)"
);
messages[0]["content"] =
serde_json::json!("(continued from previous context)");
}
return; return;
} }
// 空 Vec(异常会话经 sanitize 清空)或首条非 user → 补 user 占位: // 空 Vec(异常会话经 sanitize 清空)或首条非 user → 补 user 占位:
@@ -711,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,
@@ -856,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));
} }
} }
@@ -1163,6 +1274,46 @@ mod tests {
assert_eq!(content[1]["source"]["data"], "iVBOR"); assert_eq!(content[1]["source"]["data"], "iVBOR");
} }
/// 本地 file:// 引用(#958)在转换层展开为 base64 source;缺失降级 text 块不炸请求
#[test]
fn anthropic_convert_expands_local_file_ref() {
use base64::Engine as _;
let _convert = convert_lock();
let dir = std::env::temp_dir().join("df-ai-anthropic-ref-test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("ref.png");
std::fs::write(&path, b"\x89PNG ok").unwrap();
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages: vec![ChatMessage::user_parts(
"看图",
vec![crate::provider::ContentPart::Image {
url: Some(format!("file://{}", path.display())),
base64: None,
media_type: Some("image/png".into()),
alt: None,
}],
)],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let body = provider.convert_request(req);
let user_msg = body
.messages
.iter()
.find(|m| m.get("role").and_then(|r| r.as_str()) == Some("user"))
.expect("应有 user 消息");
let content = user_msg.get("content").and_then(|c| c.as_array()).expect("user content 应为数组");
assert_eq!(content[1]["source"]["data"], base64::engine::general_purpose::STANDARD.encode(b"\x89PNG ok"));
std::fs::remove_file(&path).ok();
}
/// 纯文本 user 消息 → content 仍是字符串简写(无图不数组化) /// 纯文本 user 消息 → content 仍是字符串简写(无图不数组化)
#[test] #[test]
fn anthropic_convert_text_only_user_remains_string() { fn anthropic_convert_text_only_user_remains_string() {
@@ -1200,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();
@@ -1217,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");
@@ -1231,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");
} }
@@ -1240,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("第二批回复"), "第二批原文已追加");
}
}
+308 -27
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 {
@@ -1045,9 +1047,12 @@ fn phase1_append_after_dirty_stays_dirty_until_save() {
// ============================================================ // ============================================================
#[test] #[test]
fn eviction_inserts_summary_message_at_view_head() { fn eviction_inserts_summary_message_at_view_tail() {
// 触发淘汰 → 视图头部应有一条 "[早期对话概要]" user 摘要(补偿无声丢弃的早期主题), // 触发淘汰 → 视图尾部应有一条 "[早期对话概要]" user 摘要(补偿无声丢弃的早期主题),
// 概要 ≤ EVICTION_SUMMARY_MAX_CHARS,保护消息完好,且纯视图(不污染内存全量/不落库)。 // 概要 ≤ EVICTION_SUMMARY_MAX_CHARS,保护消息完好,且纯视图(不污染内存全量/不落库)。
// 缓存友好(2026-10):摘要改尾部追加(push)而非头部插入(insert(0))——头部插入位移
// 整条前缀字节,使 DeepSeek 等自动前缀缓存 provider 全部缓存失效(命中率掉到个位数)。
// 尾部追加让稳定前缀(system + 早期 history)逐轮不变,命中率最大化。
let mut mgr = ContextManager::new(cfg(200)); let mut mgr = ContextManager::new(cfg(200));
for i in 0..20 { for i in 0..20 {
if i % 2 == 0 { if i % 2 == 0 {
@@ -1063,46 +1068,47 @@ fn eviction_inserts_summary_message_at_view_head() {
let (msgs, trimmed) = mgr.build_for_request(0); let (msgs, trimmed) = mgr.build_for_request(0);
assert!(trimmed, "超预算应触发裁剪"); assert!(trimmed, "超预算应触发裁剪");
// 视图头部 = 补偿摘要(role=user,"[早期对话概要]" 前缀 + 高频主题) // 视图尾部 = 补偿摘要(role=user,"[早期对话概要]" 前缀 + 高频主题)
let head = msgs.first().expect("视图不应为空"); let tail = msgs.last().expect("视图不应为空");
assert!( assert!(
matches!(head.role, MessageRole::User), matches!(tail.role, MessageRole::User),
"视图头部应为 user 摘要, 实际 {:?}", "视图尾部应为 user 摘要, 实际 {:?}",
head.role tail.role
); );
assert!( assert!(
head.content.starts_with("[早期对话概要] 高频主题: "), tail.content.starts_with("[早期对话概要] 高频主题: "),
"摘要格式不符, 实际 {}", "摘要格式不符, 实际 {}",
head.content tail.content
); );
assert!( assert!(
head.content.contains("审批"), tail.content.contains("审批"),
"摘要应含被淘汰区高频主题词'审批', 实际 {}", "摘要应含被淘汰区高频主题词'审批', 实际 {}",
head.content tail.content
); );
assert!( assert!(
head.content.chars().count() <= EVICTION_SUMMARY_MAX_CHARS, tail.content.chars().count() <= EVICTION_SUMMARY_MAX_CHARS,
"摘要应 ≤ {} 字符, 实际 {}", "摘要应 ≤ {} 字符, 实际 {}",
EVICTION_SUMMARY_MAX_CHARS, EVICTION_SUMMARY_MAX_CHARS,
head.content.chars().count() tail.content.chars().count()
); );
// 保护消息完好:末条(i=19 assistant)保留;摘要 user 与首轮真实 user 相邻,由 // 保护消息完好:末条(i=19 assistant)保留;摘要 user 追加在尾部,与末尾 assistant
// sanitize 的 ensure_sequence_legal 合并(摘要在前,换行接原文),故 msgs[1] 为 i=1 的 assistant // 不同 role 不合并,故 msgs[倒数第2] 仍为 i=19 的 assistant
assert_eq!( assert_eq!(
msgs.last().unwrap().content, msgs[msgs.len() - 2].content,
"第19条回复内容同样较长用于撑爆上下文预算触发视图裁剪逻辑", "第19条回复内容同样较长用于撑爆上下文预算触发视图裁剪逻辑",
"保护区最新消息被误裁" "保护区最新消息被误裁"
); );
assert!( assert!(
matches!(msgs[1].role, MessageRole::Assistant), matches!(msgs[msgs.len() - 2].role, MessageRole::Assistant),
"摘要与首轮 user 合并后,第二条应为 assistant, 实际 {:?}", "摘要前一条应为 assistant(i=19), 实际 {:?}",
msgs[1].role msgs[msgs.len() - 2].role
); );
// 首条仍是保留区真实首条 user(摘要不再抢占头部,不位移前缀)
assert!( assert!(
msgs[0].content.contains("审批流程与审批门禁需要适配"), matches!(msgs[0].role, MessageRole::User),
"合并不应丢失被保留的首轮真实 user 文本, 实际 {}", "视图头部应为真实首条 user(摘要已移尾部), 实际 {:?}",
msgs[0].content msgs[0].role
); );
// 纯视图:摘要不进内存全量,被淘汰原文存储态不变 // 纯视图:摘要不进内存全量,被淘汰原文存储态不变
@@ -1196,3 +1202,278 @@ fn eviction_no_user_text_no_summary() {
); );
assert_eq!(mgr.all_messages_clone().len(), 16, "裁剪污染了内存全量"); assert_eq!(mgr.all_messages_clone().len(), 16, "裁剪污染了内存全量");
} }
// ── 源头守静(909):push_system_anchor 避开悬空 tool_call 头 ──
#[test]
fn push_system_anchor_appends_on_normal_tail() {
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问"));
mgr.push(ChatMessage::assistant("答"));
let avoided = mgr.push_system_anchor(ChatMessage::system("警示"));
assert!(!avoided, "常规尾部应走追加(不避让)");
assert_eq!(mgr.len(), 3);
assert_eq!(mgr.messages_mut()[2].message.content, "警示", "system 应在尾部");
}
#[test]
fn push_system_anchor_avoids_dangling_tool_call_head() {
// [user] [assistant(tool_calls)] ← 末条为悬空头(其 tool 响应尚未回填)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("跑工具"));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("c1".to_string(), "t", "{}")],
));
let avoided = mgr.push_system_anchor(ChatMessage::system("## 摘要"));
assert!(avoided, "末条为悬空 tool_call 头应避让插入");
// 序列应为 [user, system(摘要), assistant(tool_calls)] —— 头仍是末条,三元组不被劈开
let msgs = mgr.all_messages_clone();
assert_eq!(msgs.len(), 3);
assert_eq!(msgs[1].content, "## 摘要", "摘要应插在悬空头之前");
assert!(
msgs[2].tool_calls.as_ref().is_some_and(|c| !c.is_empty()),
"悬空头应保持末条,其 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 锚点应原样进视图"
);
}
+185 -24
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,
); );
} }
@@ -246,30 +260,111 @@ impl ContextManager {
.collect(); .collect();
// 淘汰补偿摘要:视图裁剪无声丢弃被淘汰内容,LLM 从此看不到早期主题,与压缩 // 淘汰补偿摘要:视图裁剪无声丢弃被淘汰内容,LLM 从此看不到早期主题,与压缩
// 路径(有摘要补偿)行为不一致。此处把被淘汰文本提炼成一短段摘要插回发送 // 路径(有摘要补偿)行为不一致。此处把被淘汰文本提炼成一短段摘要插回发送视图。
// 视图最前部(淘汰区头部、保护消息之前)。角色必须 user:role=system 会被 // **缓存友好修正(2026-10)**:原 insert(0) 头部插入会位移整条前缀字节,使
// anthropic_compat 折叠进带 cache_control 的顶层 system 段,而本摘要纯视图 // DeepSeek 等自动前缀缓存 provider 的稳定前缀(顶层 system + 早期 history)
// 每轮即时再生成(淘汰区单调增长致内容逐轮漂移),落 system 即逐轮改写缓存 // 全部缓存失效——命中率掉到个位数。改尾部追加(push):摘要本身每轮即时再生成
// 指纹使全部前缀缓存失效;连续 role 由既有 sanitize/协议层自愈兜底。摘要只进 // (淘汰区单调增长致内容逐轮漂移)本就无法命中缓存,但放尾部不再拖累稳定前缀,
// messages 易变区,严禁拼进 system_prompt/任何稳定段;纯视图语义:不落库、 // 命中率最大化。角色必须 user:role=system 会被 anthropic_compat 折叠进带
// 不改 conv.messages 存储、不标记 compressed,被淘汰原文存储态不变。 // cache_control 的顶层 system 段,落 system 即逐轮改写缓存指纹;user 放尾部
// 极短/空淘汰文本提炼为空 → 跳过(不值得插)。 // 由 sanitize/协议层自愈兜底。摘要只进 messages 易变区,严禁拼进 system_prompt/
// 任何稳定段;纯视图语义:不落库、不改 conv.messages 存储、不标记 compressed,
// 被淘汰原文存储态不变。极短/空淘汰文本提炼为空 → 跳过(不值得插)。
if let Some(text) = build_eviction_summary_text(&evicted) { if let Some(text) = build_eviction_summary_text(&evicted) {
msgs.insert(0, ChatMessage::user(text)); msgs.push(ChatMessage::user(text));
} }
tracing::info!( tracing::info!(
"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`]。
@@ -565,6 +660,41 @@ impl ContextManager {
self.needs_full_rewrite = true; self.needs_full_rewrite = true;
} }
/// 追加一条 system 锚点消息(警示文本/压缩摘要),自动避开**悬空 tool_call 头**。
///
/// **源头守静(909)**:OpenAI/内部协议不变式是「assistant(tool_calls) 之后必须紧随
/// 其 tool 响应」。主 loop 的顺序是 push 头(`finalize_round_output`)→ 释放锁 →
/// 执行工具 → 再取锁 push 结果;若在「头已入、结果未回」的窗口内从尾部追加 system
/// (后台压缩摘要/警示/nudge/主题标记),system 就楔进 head 与其响应之间 → 上游报
/// 400「insufficient tool messages following tool_calls message」。
///
/// 本出口统一处置:末条非悬空头 → 常规尾部追加(不位移前缀,保自动前缀缓存,行为与
/// 原 `push` 一致);末条是悬空头 → 改插在该头**之前**(index = len-1),不劈三元组,
/// 摘要/警示仍紧邻本轮内容对 LLM 可见。返回 `true` 表示走了避让插入(会置
/// `needs_full_rewrite`,需 DB 全量重写),供调用方记日志。
pub fn push_system_anchor(&mut self, message: ChatMessage) -> bool {
if self.tail_is_dangling_head() {
let idx = self.messages.len() - 1;
self.insert_at(idx, message);
tracing::debug!(
idx,
"system 锚点避让悬空 tool_call 头插入(防楔入三元组致 400)"
);
true
} else {
self.push(message);
false
}
}
/// 末条是否为「悬空 tool_call 头」(assistant 带 tool_calls 且其 tool 响应尚未回填)。
/// 供 [`Self::push_system_anchor`] 判定避让位置:末条即头,说明头已入而结果未回。
fn tail_is_dangling_head(&self) -> bool {
self.messages
.last()
.is_some_and(|m| m.group == MessageGroup::ToolCallHead)
}
/// 按淘汰单元分组消息范围(三元组原子性),供压缩定位/分段标记复用同一分组逻辑。 /// 按淘汰单元分组消息范围(三元组原子性),供压缩定位/分段标记复用同一分组逻辑。
/// ///
/// 返回每个单元的右开区间 end + token 总和,保证: /// 返回每个单元的右开区间 end + token 总和,保证:
@@ -631,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 每请求告警的确定性供给。按全长分组取「完整单元边界中
@@ -657,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
+231 -7
View File
@@ -42,12 +42,35 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
.filter(|m| m.is_active()) .filter(|m| m.is_active())
.collect(); .collect();
// step 1:已闭合的 tool_call_id 集合(role=Tool 消息全部提供过的 id) // step 0.5(相邻性归位,909②):把楔入 assistant(tool_calls) 头与其 tool 响应之间的异类消息
let resolved_ids: HashSet<&str> = messages // (后台压缩摘要 / 用户插话 / 警示 system)整体移到该组响应**之后**——只重排不删内容。
.iter() // 归位后三元组恢复相邻,step1 的邻接判定即可正确识别为已闭合,不再走「未邻接即剥除」而**保住
.filter(|m| matches!(m.role, MessageRole::Tool)) // 工具轮**(避免丢结果致模型重跑/误判)。对同一头的多段夹层同样生效(全部响应聚拢到头后紧邻)。
.filter_map(|m| m.tool_call_id.as_deref()) // 真正无法归位者(头无任何响应)仍由 step2/3 剥除兜底。
.collect(); let messages = gather_tool_responses(messages);
// step 1:相邻性判定的依据——每个 assistant 头「紧后 tool 连续段」覆盖的 id 集合。
// 口径与 openai_compat::sanitize_openai_triplets 一致(2026-10-09 收紧):
// 协议要求 tool 响应必须紧随其 assistant(tool_calls);原实现按「id 全局存在」判定
// (位置无关),会把被 system/user 隔开的游离响应误判为已闭合,放行畸形序列致 400
// 「insufficient tool messages」。故判定改为「紧后连续段覆盖」——未覆盖的 call 视同
// 未闭合处理(剥除),其游离响应随后由 step3 按 orphaned_ids 丢弃,自动闭环。
let mut adjacency: std::collections::HashMap<usize, HashSet<String>> =
std::collections::HashMap::new();
for i in 0..messages.len() {
if !matches!(messages[i].role, MessageRole::Assistant) {
continue;
}
let mut seg: HashSet<String> = HashSet::new();
let mut j = i + 1;
while j < messages.len() && matches!(messages[j].role, MessageRole::Tool) {
if let Some(id) = messages[j].tool_call_id.as_deref() {
seg.insert(id.to_string());
}
j += 1;
}
adjacency.insert(i, seg);
}
// step 2:判定每个 assistant 头的闭合状态,产出需丢弃的 id 集合 // step 2:判定每个 assistant 头的闭合状态,产出需丢弃的 id 集合
// orphaned_ids:未闭合的 tool_call.id(含占位 id),其 tool_result 要丢 // orphaned_ids:未闭合的 tool_call.id(含占位 id),其 tool_result 要丢
@@ -72,7 +95,7 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
.iter() .iter()
.partition(|c| { .partition(|c| {
!c.id.starts_with(TOOL_MISSING_PREFIX) !c.id.starts_with(TOOL_MISSING_PREFIX)
&& resolved_ids.contains(c.id.as_str()) && adjacency.get(&i).is_some_and(|s| s.contains(&c.id))
}); });
match (resolved.is_empty(), unresolved.is_empty()) { match (resolved.is_empty(), unresolved.is_empty()) {
@@ -181,6 +204,78 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
ensure_sequence_legal(after_reverse_orphan) ensure_sequence_legal(after_reverse_orphan)
} }
/// 相邻性归位(909②,view-only):把 assistant 头的**全部** tool 响应聚拢到头后紧邻,夹层整体后移。
///
/// 根因:协议不变式要求「tool 响应必须紧随其 assistant(tool_calls)」。后台压缩摘要在 head 已入、
/// 结果未回的窗口内楔入,或用户在执行期插话,都会产生 `[head, 异类, tool…]`。原逻辑对此按「未邻接
/// = 未闭合」剥除头与其响应,**丢掉整轮工具交互**(有损)。本步改为**归位**——夹层后移,头与其响应
/// 恢复相邻,工具轮保留;夹层内容亦不丢。
///
/// 覆盖**多夹层**:同一头的响应之间又插了第二个夹层时(如 `[head(c1,c2), 异类, tool(c1), 异类,
/// tool(c2)]`),只后移首夹层会让后段 tool 仍不与头相邻 → step1 邻接判定只认前段 → 后段 call 被
/// 判未闭合而剥除(tool 一并丢)。本实现把「响应集合耗尽前出现的**所有**非 Tool 消息」整体移到
/// **该头最后一个响应之后**,头后紧邻其全部响应(保序),夹层随后。
///
/// 边界:遇下一个 tool_call 头即停(不跨单元搬运);该头响应集合(其 tool_calls 的 id)全部命中
/// 即停(不牵动响应之后的无关消息)。无法归位者(头无响应)仍由 step2/3 剥除兜底。
///
/// 幂等:归位后序列已相邻,再跑本函数无夹层可移,输出不变。
fn gather_tool_responses(msgs: Vec<ChatMessage>) -> Vec<ChatMessage> {
use std::collections::HashSet;
fn is_tool_call_head(m: &ChatMessage) -> bool {
matches!(m.role, MessageRole::Assistant)
&& m.tool_calls.as_ref().is_some_and(|c| !c.is_empty())
}
fn is_tool(m: &ChatMessage) -> bool {
matches!(m.role, MessageRole::Tool)
}
let mut out: Vec<ChatMessage> = Vec::with_capacity(msgs.len());
let mut i = 0usize;
while i < msgs.len() {
if is_tool_call_head(&msgs[i]) {
// 该头全部 call id —— 用于判定它的响应集合是否已耗尽
let call_ids: HashSet<String> = msgs[i]
.tool_calls
.as_ref()
.map(|c| c.iter().map(|c| c.id.clone()).collect())
.unwrap_or_default();
// 从头后扫到「下一个 tool_call 头」或「该头响应集合耗尽」为止,分流响应与夹层
let mut j = i + 1;
let mut responses: Vec<ChatMessage> = Vec::new();
let mut interlopers: Vec<ChatMessage> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
while j < msgs.len() && !is_tool_call_head(&msgs[j]) {
if is_tool(&msgs[j]) {
if let Some(id) = msgs[j].tool_call_id.as_deref() {
seen.insert(id.to_string());
}
responses.push(msgs[j].clone());
} else {
interlopers.push(msgs[j].clone());
}
j += 1;
// 该头响应集合已全部命中 → 不再牵动响应之后的无关消息(保持边界语义)
if !call_ids.is_empty() && call_ids.iter().all(|id| seen.contains(id)) {
break;
}
}
// 有夹层且确有响应 → 头 + 全部响应在前,夹层整体后移(只重排)
if !interlopers.is_empty() && !responses.is_empty() {
out.push(msgs[i].clone());
out.extend(responses);
out.extend(interlopers);
i = j;
continue;
}
}
out.push(msgs[i].clone());
i += 1;
}
out
}
/// step 3.5:反向 orphan 检测(view-only)—— **补头自愈**(对 orphan tool_result 就地补占位 /// step 3.5:反向 orphan 检测(view-only)—— **补头自愈**(对 orphan tool_result 就地补占位
/// assistant tool_call 头,而非丢弃 tool_result)。 /// assistant tool_call 头,而非丢弃 tool_result)。
/// ///
@@ -554,6 +649,135 @@ mod tests {
// ── 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ── // ── 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ──
/// 相邻性归位(909②):夹层(system/user)隔开 head 与其 tool 响应时,把夹层移到响应之后
/// (只重排不删),保住整轮工具交互;同时保证发送序列满足「head 后紧邻其响应」。
/// 旧行为(逐字剥除工具轮)已被归位取代——本用例即旧行为的有损对照。
#[test]
fn sanitize_reorders_interloper_to_keep_tool_round() {
// [user] [assistant(c1,c2)] [system 夹层] [tool c1] [tool c2]
let msgs = vec![
ChatMessage::user("go"),
ChatMessage::assistant_with_tools(
"",
vec![
ToolCall::new("c1".to_string(), "t", "{}"),
ToolCall::new("c2".to_string(), "t", "{}"),
],
),
ChatMessage::system("夹层提示"),
ChatMessage::tool_result("c1", "r1"),
ChatMessage::tool_result("c2", "r2"),
];
let out = sanitize_messages(msgs);
// 零丢失:5 条全在(归位只重排,不删内容)
assert_eq!(out.len(), 5, "归位不得丢内容, 实际 {:?}", out);
// 头保留其两个 tool_call(工具轮不丢)
let head_idx = out
.iter()
.position(|m| matches!(m.role, MessageRole::Assistant))
.expect("head 应保留");
assert_eq!(
out[head_idx].tool_calls.as_ref().map(|c| c.len()),
Some(2),
"头应保留两个 tool_call, 实际 {:?}",
out
);
// 头后紧邻两条 tool 响应(协议不变式成立)
assert!(
matches!(out[head_idx + 1].role, MessageRole::Tool)
&& matches!(out[head_idx + 2].role, MessageRole::Tool),
"head 后应紧邻两条 tool 响应, 实际 {:?}",
out
);
// 夹层内容保留(后移到响应之后)
assert!(
out.iter()
.any(|m| matches!(m.role, MessageRole::System) && m.content == "夹层提示"),
"夹层内容不得丢, 实际 {:?}",
out
);
}
#[test]
fn sanitize_reorder_is_idempotent() {
let msgs = vec![
ChatMessage::user("go"),
ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("c1".to_string(), "t", "{}")],
),
ChatMessage::system("夹层提示"),
ChatMessage::tool_result("c1", "r1"),
];
let once = sanitize_messages(msgs);
let twice = sanitize_messages(once.clone());
// MessageRole 未实现 PartialEq → 用 Debug 签名比较序列(角色+内容)是否一致
let sig = |v: &[ChatMessage]| -> Vec<String> {
v.iter().map(|m| format!("{:?}|{}", m.role, m.content)).collect()
};
assert_eq!(sig(&once), sig(&twice), "归位应幂等(角色序与内容序不变)");
}
/// 多夹层归位:同一头的响应之间又插了第二个夹层时,头与该头的**全部**响应必须聚拢相邻
/// (旧实现只后移首夹层,后段 tool 仍不与头相邻 → step1 判后段 call 未闭合 → 剥除并丢其 tool)。
#[test]
fn sanitize_reorders_multiple_interlopers_to_keep_tool_round() {
// [user] [assistant(c1,c2)] [system 夹层X] [tool c1] [system 夹层Y] [tool c2]
let msgs = vec![
ChatMessage::user("go"),
ChatMessage::assistant_with_tools(
"",
vec![
ToolCall::new("c1".to_string(), "t", "{}"),
ToolCall::new("c2".to_string(), "t", "{}"),
],
),
ChatMessage::system("夹层X"),
ChatMessage::tool_result("c1", "r1"),
ChatMessage::system("夹层Y"),
ChatMessage::tool_result("c2", "r2"),
];
let out = sanitize_messages(msgs);
// 零丢失:6 条全在(归位只重排,不删内容)
assert_eq!(out.len(), 6, "多夹层归位不得丢内容, 实际 {:?}", out);
// 头保留其两个 tool_call(工具轮不丢)
let head_idx = out
.iter()
.position(|m| matches!(m.role, MessageRole::Assistant))
.expect("head 应保留");
assert_eq!(
out[head_idx].tool_calls.as_ref().map(|c| c.len()),
Some(2),
"头应保留两个 tool_call, 实际 {:?}",
out
);
// 头后紧邻其全部响应(保序 c1,c2),协议不变式成立
assert!(
matches!(out[head_idx + 1].role, MessageRole::Tool)
&& out[head_idx + 1].tool_call_id.as_deref() == Some("c1")
&& matches!(out[head_idx + 2].role, MessageRole::Tool)
&& out[head_idx + 2].tool_call_id.as_deref() == Some("c2"),
"head 后应紧邻其全部响应 c1,c2, 实际 {:?}",
out
);
// 两条夹层内容都保留(后移到响应之后)
assert!(
out.iter()
.any(|m| matches!(m.role, MessageRole::System) && m.content == "夹层X")
&& out.iter()
.any(|m| matches!(m.role, MessageRole::System) && m.content == "夹层Y"),
"两条夹层内容不得丢, 实际 {:?}",
out
);
// 幂等:再跑一次结果不变
let twice = sanitize_messages(out.clone());
let sig = |v: &[ChatMessage]| -> Vec<String> {
v.iter().map(|m| format!("{:?}|{}", m.role, m.content)).collect()
};
assert_eq!(sig(&out), sig(&twice), "多夹层归位应幂等(角色序与内容序不变)");
}
#[test] #[test]
fn sanitize_heals_reverse_orphan_tool_result_with_placeholder_head() { fn sanitize_heals_reverse_orphan_tool_result_with_placeholder_head() {
// 三元组完整性 P0:反向 orphan(tool_result 无对应 tool_call 头)→ 补占位头自愈(不丢)。 // 三元组完整性 P0:反向 orphan(tool_result 无对应 tool_call 头)→ 补占位头自愈(不丢)。
+149 -46
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)
} }
} }
@@ -423,6 +458,17 @@ pub const TOOL_RESULT_CHAR_LIMIT: usize = 1_024;
/// JSON 对象中字符串字段值的最大字符数(超过则截断)。独立于行数截断, /// JSON 对象中字符串字段值的最大字符数(超过则截断)。独立于行数截断,
/// 解决 `{"content":"大段文字(无换行)"}` 类 JSON 逃逸行级截断的问题。 /// 解决 `{"content":"大段文字(无换行)"}` 类 JSON 逃逸行级截断的问题。
pub const TOOL_RESULT_JSON_STR_FIELD_MAX: usize = 512; pub const TOOL_RESULT_JSON_STR_FIELD_MAX: usize = 512;
/// read_file 豁免上限(P1 工具结果瘦身 2026-10-10):≤此值原样返回,超过则走压缩+落盘。
///
/// 依据实测:全库工具结果体量前二为 patch_file 34.6% / read_file 29.6%(单条 max 150KB),
/// 原「read_file 一律豁免」让 29.6% 体量完全裸奔(65K 均值 prompt 压不下来的直接原因之一)。
/// 保留小文件豁免(AI 定向读代码需完整语义——历史有「限读 28KB 被折叠致能力受损」实证),
/// 阈值取 8KB 与 D2a 审计侧同构;超过则摘要+落盘句柄(可无损读回)。
pub const READ_FILE_OFFLOAD_BYTES: usize = 8 * 1024;
/// 追加落盘句柄的最小原文字节数(与 D2a 审计侧 8KB 阈值对齐)。
/// 小于此值的压缩结果**不加句柄**:有损摘要的丢失量小,而句柄行(约 150 字符)会净增 token;
/// ≥此值才值得(丢失量大、句柄占比可忽略),兼顾「无损可读回」与「不反噬瘦身目标」。
pub const TOOL_OFFLOAD_HANDLE_MIN_BYTES: usize = 8 * 1024;
/// 判断 tool_result 是否需摘要压缩:content >2KB 或 占比 >40%。 /// 判断 tool_result 是否需摘要压缩:content >2KB 或 占比 >40%。
/// ///
@@ -454,14 +500,30 @@ pub fn should_summarize_tool_result(
/// - 错误行(在头尾区间外的)额外插入,标注位置。 /// - 错误行(在头尾区间外的)额外插入,标注位置。
/// - 中间大段省略为 `... (省略 N 行) ...`。 /// - 中间大段省略为 `... (省略 N 行) ...`。
/// ///
/// `tool_name` 参与内容判断:`read_file` 是 AI 定向读代码的工具,压缩其 content /// `tool_name` 参与内容判断:`read_file` 定向读代码,压缩其 content 会削弱代码分析能力,
/// 等于直接阉割 AI 的代码分析能力(实测 limit=100 读 28KB,被折叠为首尾各 5 行), /// 故保留**小文件豁免**(≤ READ_FILE_OFFLOAD_BYTES 原样返回);超阈值仍走压缩并落盘
/// 故对 `read_file` 一律豁免压缩(原样返回)。其余工具仍走压缩。 /// (P1 2026-10-10,句柄可读回,无损)。其余工具仍走压缩。
/// 空内容一律返回空字符串。 /// 空内容一律返回空字符串。
pub fn extract_key_info(content: &str, tool_name: &str) -> String { pub fn extract_key_info(content: &str, tool_name: &str) -> String {
// read_file 豁免:AI 定向读代码,压缩 content 违背用户/AI 意图。 let compressed = extract_key_info_inner(content, tool_name);
// read_file handler 自带 limit 硬上限 2000 行(file.rs:156/168),无爆 prompt 风险。 // P1 无损化(2026-10-10):确实发生压缩 → 原文落盘 + 追加句柄行,被省略的中段可 read_file 读回。
if tool_name == "read_file" { // 句柄**只对 ≥ TOOL_OFFLOAD_HANDLE_MIN_BYTES 的大结果**追加——小结果有损摘要丢得少,
// 加句柄行反而净增 token(反噬瘦身目标);大结果丢失量大,句柄占比可忽略,才值得。
// base 未注入(单测/未初始化)→ 不加句柄,输出与改造前逐字节一致(零行为变化)。
if content.len() >= TOOL_OFFLOAD_HANDLE_MIN_BYTES && compressed.len() < content.len() {
if let Some(handle) = crate::tool_offload::offload_with_handle(content, tool_name) {
return format!("{compressed}\n{handle}");
}
}
compressed
}
/// extract_key_info 的纯压缩实现(零 IO,便于单测与复用)。
fn extract_key_info_inner(content: &str, tool_name: &str) -> String {
// read_file 高阈值豁免(P1 2026-10-10):实测任务结果体量 read_file 占 29.6%(单条 max 150KB),
// 原「一律豁免」致最大头裸奔(65K 均值 prompt 压不下来的直接原因之一)。
// 改为「小文件豁免 + 超阈值压缩并落盘」:既保 AI 定向读代码语义,又让超大读取可瘦身。
if tool_name == "read_file" && content.len() <= READ_FILE_OFFLOAD_BYTES {
return content.to_string(); return content.to_string();
} }
// JSON 感知压缩:识别对象中的大数组/大字符串并截断 // JSON 感知压缩:识别对象中的大数组/大字符串并截断
@@ -620,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 时据此剔除畸形三元组。
@@ -1032,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()
); );
} }
@@ -1717,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",
@@ -1726,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})");
}
} }
+122
View File
@@ -0,0 +1,122 @@
//! 本地图片引用展开(#958 图片入站引用化)
//!
//! 消息持久化只存引用(ContentPart::Image.url = `file://<绝对路径>`,base64 空),
//! 发送请求前在此统一展开为 base64 内嵌——存储面瘦 referencing(一张 5MB 图不再以
//! 6.7MB base64 字符串进 SQLite 随历史全量重放),转换面按需读盘(SSD 毫秒级,
//! 对比 base64 内嵌的每请求全量携带,磁盘读便宜得多)。
//!
//! 展开点是 provider 转换层的入口:主会话/子代理/标题/压缩等全部请求路径在此
//! 单点覆盖,调用方(飞书入站/桌面粘贴图)只管落盘产引用,不关心 provider 差异。
//!
//! 失败语义:引用文件丢失/不可读时降级为 Text 片「[图片文件缺失: path]」——
//! 诚实告知模型图不可用,不让请求 400(空 base64 会被两个协议端点拒绝)。
use crate::provider::ContentPart;
use base64::Engine as _;
/// `file://` 引用前缀。内部引用标识(两端自洽即可,不进端点):落盘方拼
/// `file://<原生绝对路径>`,展开方 strip 后按原生路径读(Windows 盘符路径直接可用)。
pub const LOCAL_FILE_SCHEME: &str = "file://";
/// 判定 Image 片是否为本地引用(base64 空 + url 带本地 scheme)
fn is_local_ref(url: Option<&str>, base64: Option<&str>) -> bool {
base64.is_none() && url.is_some_and(|u| u.starts_with(LOCAL_FILE_SCHEME))
}
/// 把消息内的本地图片引用展开为 base64 内嵌片;非本地片原样透传。
/// 文件缺失/不可读时该 Image 片降级为 Text 片(请求不炸,模型得到可读的缺失说明)。
pub fn inline_local_image_parts(parts: Vec<ContentPart>) -> Vec<ContentPart> {
parts.into_iter().map(inline_local_image_part).collect()
}
/// 单片版(provider 转换臂逐片 map 用)
pub fn inline_local_image_part(p: ContentPart) -> ContentPart {
match p {
ContentPart::Image { url, base64, media_type, alt }
if is_local_ref(url.as_deref(), base64.as_deref()) =>
{
let path = url.unwrap();
match std::fs::read(&path[LOCAL_FILE_SCHEME.len()..]) {
Ok(bytes) => ContentPart::Image {
url: None,
base64: Some(base64::engine::general_purpose::STANDARD.encode(bytes)),
media_type,
alt,
},
Err(_) => ContentPart::Text {
text: format!("[图片文件缺失: {}]", &path[LOCAL_FILE_SCHEME.len()..]),
},
}
}
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_ref_expands_to_base64() {
let dir = std::env::temp_dir().join("df-ai-image-ref-test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("expands.png");
std::fs::write(&path, b"\x89PNG fake-bytes").unwrap();
let out = inline_local_image_parts(vec![ContentPart::Image {
url: Some(format!("file://{}", path.display())),
base64: None,
media_type: Some("image/png".into()),
alt: None,
}]);
match &out[0] {
ContentPart::Image { url, base64, media_type, .. } => {
assert!(url.is_none());
let b = base64.as_deref().expect("base64 应已填充");
assert_eq!(b, base64::engine::general_purpose::STANDARD.encode(b"\x89PNG fake-bytes"));
assert_eq!(media_type.as_deref(), Some("image/png"));
}
other => panic!("应保持 Image 片, 实际 {other:?}"),
}
std::fs::remove_file(&path).ok();
}
#[test]
fn missing_file_degrades_to_text_not_empty_base64() {
let out = inline_local_image_parts(vec![ContentPart::Image {
url: Some("file:///nonexistent/df-ai-no-such.png".into()),
base64: None,
media_type: Some("image/png".into()),
alt: None,
}]);
match &out[0] {
ContentPart::Text { text } => {
assert!(text.contains("[图片文件缺失"), "应降级为缺失说明, 实际 {text}");
assert!(text.contains("df-ai-no-such.png"));
}
other => panic!("缺失文件应降级 Text 片, 实际 {other:?}"),
}
}
#[test]
fn non_local_parts_pass_through() {
let passthrough = vec![
ContentPart::text("hello"),
ContentPart::image_base64("image/png", "aGVsbG8="),
ContentPart::image_url("https://x/a.png"),
// base64 非空的 file:// 不是引用(优先内嵌),原样透传
ContentPart::Image {
url: Some("file:///x.png".into()),
base64: Some("aGVsbG8=".into()),
media_type: Some("image/png".into()),
alt: None,
},
];
let out = inline_local_image_parts(passthrough);
assert_eq!(out.len(), 4);
assert!(matches!(&out[0], ContentPart::Text { .. }));
assert!(matches!(&out[1], ContentPart::Image { base64: Some(_), .. }));
assert!(matches!(&out[2], ContentPart::Image { url: Some(u), .. } if u.starts_with("https://")));
assert!(matches!(&out[3], ContentPart::Image { base64: Some(_), .. }));
}
}
+5
View File
@@ -33,6 +33,8 @@ mod model_probe_helpers;
pub mod plan_hint; pub mod plan_hint;
pub mod openai_compat; pub mod openai_compat;
pub mod openai_helpers; pub mod openai_helpers;
// 本地图片引用展开(#958 图片入站引用化):消息存 file:// 引用,发送前单点展开 base64
pub mod image_ref;
// Phase 0 (B 路线): Plan-driven 规划骨架(维度 0.8 Plan/SubTask 结构 + 0.7 规划校验/Kahn 分层)。 // Phase 0 (B 路线): Plan-driven 规划骨架(维度 0.8 Plan/SubTask 结构 + 0.7 规划校验/Kahn 分层)。
// 纯函数模块(零 IO 零状态)。依据 docs/02-架构设计/单对话并行多轮-{设计,Phase0落地路线图}-2026-06-20.md。 // 纯函数模块(零 IO 零状态)。依据 docs/02-架构设计/单对话并行多轮-{设计,Phase0落地路线图}-2026-06-20.md。
// Phase 1(plan_hint 接入主 loop)/Phase 2(planning)/Phase 3(并行 execution) 接入后续。 // Phase 1(plan_hint 接入主 loop)/Phase 2(planning)/Phase 3(并行 execution) 接入后续。
@@ -50,6 +52,9 @@ pub mod router;
pub mod retry; pub mod retry;
pub mod sse_parser; pub mod sse_parser;
pub mod namespace_store; pub mod namespace_store;
// P1 工具结果瘦身(上下文根治专项 2026-10-06 设计 §二):超阈值工具结果正文落盘 + 句柄。
// 与 src-tauri 审计侧 D2a(tool_result_offload) 同构;base 未注入时降级为纯摘要(零行为变化)。
pub mod tool_offload;
use provider::LlmProvider; use provider::LlmProvider;
use reqwest::Client; use reqwest::Client;
+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);
}
} }
+130 -9
View File
@@ -118,10 +118,12 @@ impl OpenAICompatProvider {
// 多模态 content(须在 move m.tool_calls 之前算,借用 m)。 // 多模态 content(须在 move m.tool_calls 之前算,借用 m)。
// 含图消息走 content 数组(text/image_url);纯文本走字符串简写 // 含图消息走 content 数组(text/image_url);纯文本走字符串简写
// (保持与现有纯文本端点零回归)。image_url 支持 data URI(base64)与 http(s) URL。 // (保持与现有纯文本端点零回归)。image_url 支持 data URI(base64)与 http(s) URL。
// 本地 file:// 引用(#958)先展开为 base64 内嵌——OpenAI 端点不认本地路径
let content = if m.has_image() { let content = if m.has_image() {
let parts: Vec<serde_json::Value> = m let parts: Vec<serde_json::Value> = m
.flattened_parts() .flattened_parts()
.into_iter() .into_iter()
.map(crate::image_ref::inline_local_image_part)
.map(|p| match p { .map(|p| match p {
crate::provider::ContentPart::Text { text } => serde_json::json!({ crate::provider::ContentPart::Text { text } => serde_json::json!({
"type": "text", "type": "text",
@@ -211,6 +213,10 @@ impl OpenAICompatProvider {
// 模型不支持 temperature(如商汤 sensenova 仅接受 1.0)→ 省略该字段,避免上游 HTTP 400。 // 模型不支持 temperature(如商汤 sensenova 仅接受 1.0)→ 省略该字段,避免上游 HTTP 400。
// 判定走 model_probe(命中 presets 表;未命中默认支持,保持原样透传零回归)。 // 判定走 model_probe(命中 presets 表;未命中默认支持,保持原样透传零回归)。
// 商汤网关(token.sensenova.cn)下温度支持**按模型各异**:sensenova-6.x 与
// sn2-kimi 实测仅接受 1(传其它值 400),部分新模型支持常规温度。
// 故不做 base_url 一刀切,改由 presets 逐模型精确维护 temperature_supported:
// 受限模型(含实测 sn2-kimi)标 false 省略;支持温度变化的模型标 true 正常透传。
let temperature = if req.temperature.is_some() let temperature = if req.temperature.is_some()
&& !crate::model_probe::probe(&model).temperature_supported && !crate::model_probe::probe(&model).temperature_supported
{ {
@@ -368,18 +374,34 @@ fn extract_tool_call_ids(msg: &OpenAiMessage) -> Vec<String> {
fn sanitize_openai_triplets(messages: &mut Vec<OpenAiMessage>) { fn sanitize_openai_triplets(messages: &mut Vec<OpenAiMessage>) {
use std::collections::HashSet; use std::collections::HashSet;
// step 1:resolved_ids = 所有 tool 消息提供的 tool_call_id(有 result 响应的 id)。 // step 1:相邻性判定的依据 = 每个 assistant 头「紧后 tool 连续段」覆盖的 id 集合。
let resolved_ids: HashSet<String> = messages // 根因(2026-10-09):原实现按「id 全局存在」判闭合(位置无关),而 OpenAI 协议要求
.iter() // tool 响应必须**紧随**其 assistant(tool_calls)。后台压缩摘要在 head 已 push、tool
.filter(|m| m.role == "tool") // 结果未 push 的窗口内可楔入 system(context_lifecycle),被隔开的响应仍被判"已闭合"
.filter_map(|m| m.tool_call_id.clone()) // → 畸形序列放行 → 上游 400「insufficient tool messages」。故判定口径由「id 全局存在」
.collect(); // 收紧为「紧后连续段覆盖」:未覆盖的 call 视同未闭合剥除,其游离响应随后由 step3 丢弃。
let mut adjacency: std::collections::HashMap<usize, HashSet<String>> =
std::collections::HashMap::new();
for i in 0..messages.len() {
if messages[i].role != "assistant" {
continue;
}
let mut seg: HashSet<String> = HashSet::new();
let mut j = i + 1;
while j < messages.len() && messages[j].role == "tool" {
if let Some(id) = messages[j].tool_call_id.as_deref() {
seg.insert(id.to_string());
}
j += 1;
}
adjacency.insert(i, seg);
}
let mut stripped_heads = 0u32; let mut stripped_heads = 0u32;
let mut total_stripped = 0u32; let mut total_stripped = 0u32;
// step 2:assistant 剥未闭合 tool_call(无对应 tool result 响应)。 // step 2:assistant 剥未闭合 tool_call(其响应不在紧后连续段内)。
for m in messages.iter_mut() { for (i, m) in messages.iter_mut().enumerate() {
if m.role != "assistant" { if m.role != "assistant" {
continue; continue;
} }
@@ -389,12 +411,13 @@ fn sanitize_openai_triplets(messages: &mut Vec<OpenAiMessage>) {
if calls.is_empty() { if calls.is_empty() {
continue; continue;
} }
let seg = adjacency.get(&i);
let kept: Vec<serde_json::Value> = calls let kept: Vec<serde_json::Value> = calls
.iter() .iter()
.filter(|tc| { .filter(|tc| {
tc.get("id") tc.get("id")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.is_some_and(|id| resolved_ids.contains(id)) .is_some_and(|id| seg.is_some_and(|s| s.contains(id)))
}) })
.cloned() .cloned()
.collect(); .collect();
@@ -999,6 +1022,57 @@ mod tests {
assert_eq!(arr[2]["image_url"]["url"], "https://x/a.png"); assert_eq!(arr[2]["image_url"]["url"], "https://x/a.png");
} }
/// 本地 file:// 引用(#958)在转换层展开为 data URI;引用缺失降级 Text 片不炸请求
#[test]
fn openai_convert_expands_local_file_ref() {
use base64::Engine as _;
let dir = std::env::temp_dir().join("df-ai-openai-ref-test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("ref.png");
std::fs::write(&path, b"\x89PNG ok").unwrap();
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
let req = CompletionRequest {
model: "gpt-4o".into(),
messages: vec![
ChatMessage::user_parts(
"看图",
vec![crate::provider::ContentPart::Image {
url: Some(format!("file://{}", path.display())),
base64: None,
media_type: Some("image/png".into()),
alt: None,
}],
),
ChatMessage::user_parts(
"坏引用",
vec![crate::provider::ContentPart::Image {
url: Some("file:///nonexistent/ref-missing.png".into()),
base64: None,
media_type: Some("image/png".into()),
alt: None,
}],
),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
let good = &out.messages[0];
let arr = good.content.as_array().expect("含图 → 数组");
assert_eq!(arr[1]["image_url"]["url"], format!("data:image/png;base64,{}", base64::engine::general_purpose::STANDARD.encode(b"\x89PNG ok")));
let bad = &out.messages[1];
let arr = bad.content.as_array().expect("降级片仍在数组");
assert_eq!(arr[1]["type"], "text");
assert!(arr[1]["text"].as_str().unwrap().contains("[图片文件缺失"));
std::fs::remove_file(&path).ok();
}
/// 纯文本消息 → content 仍是字符串简写(无图不数组化,对齐纯文本端点兼容) /// 纯文本消息 → content 仍是字符串简写(无图不数组化,对齐纯文本端点兼容)
#[test] #[test]
fn openai_convert_text_only_remains_string() { fn openai_convert_text_only_remains_string() {
@@ -1018,6 +1092,53 @@ mod tests {
assert_eq!(msg.content, serde_json::Value::String("hello".into())); assert_eq!(msg.content, serde_json::Value::String("hello".into()));
} }
/// 温度支持按模型精确判定(presets 表,probe 命中),不按 base_url 一刀切——
/// 商汤网关下温度支持各异:sn2-kimi 实测仅接受 temperature=1(传 0.7 → 上游 400),
/// presets 已标 temperature_supported=false,任何 provider 下都省略。
/// 回归 B5 挂机 sn2-kimi 传 0.7 → 400。省略交上游默认,宁默认不 400。
#[test]
fn openai_temperature_omitted_for_sn2_kimi_via_presets() {
let provider =
OpenAICompatProvider::new("https://token.sensenova.cn", "k", "sensenova-6.8-flash-lite");
let req = CompletionRequest {
model: "sn2-kimi".into(), // presets 精确标记受限,probe 命中 false
messages: vec![ChatMessage::user("hi")],
temperature: Some(0.7),
max_tokens: None,
stream: true,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
assert_eq!(out.temperature, None, "sn2-kimi presets 标 false,应省略 temperature");
}
/// 反向保证:支持常规温度的模型(presets 未标 false,如 deepseek-v4-flash)
/// 即使挂在商汤 provider 池也按模型判定透传,不因 base_url 误杀——商汤新模型
/// 支持温度变化,一刀切会错误降级。
#[test]
fn openai_temperature_passed_for_supported_model_on_sensenova_base() {
let provider =
OpenAICompatProvider::new("https://token.sensenova.cn", "k", "sensenova-6.8-flash-lite");
let req = CompletionRequest {
model: "deepseek-v4-flash".into(), // presets 未标受限 → 默认支持温度
messages: vec![ChatMessage::user("hi")],
temperature: Some(0.7),
max_tokens: None,
stream: true,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
assert_eq!(
out.temperature,
Some(0.7),
"支持温度的模型应透传,不因 base_url 是商汤而省略"
);
}
// ---------- ensure_leading_user(首条非 user/system → 补 user 占位,OpenAI 对称 Anthropic)---------- // ---------- ensure_leading_user(首条非 user/system → 补 user 占位,OpenAI 对称 Anthropic)----------
/// 首条 assistant → 补 user 占位(对齐 Anthropic)。上游绕过 sanitize 的 /// 首条 assistant → 补 user 占位(对齐 Anthropic)。上游绕过 sanitize 的
+117
View File
@@ -0,0 +1,117 @@
//! 上下文侧工具结果正文外置(P1 工具结果瘦身)。
//!
//! 依据 docs/02-架构设计/上下文根治设计-20261006.md §二「工具结果瘦身(治最大头,先行)」:
//! 超阈值工具结果**正文落盘**,上下文只留「摘要 + 句柄路径」;句柄可被 agent 主动展开
//! (read_file 读句柄路径)——需要时找得回,不需要时不占位。与 src-tauri 审计侧 D2a 同构。
//!
//! 与 namespace_store 的区别(两者互补,不冲突):
//! - namespace_store:内存态引用化(省进程内存),发 LLM 前展开为全文 → token 零收益。
//! - 本模块:落盘 + 句柄,被省略的中段**不进 prompt 但可读回** → 真正减 token 且不丢信息。
//!
//! 降级契约:base 未注入(单测/未初始化)→ 不落盘返回 None,调用方退化为纯摘要(改造前行为)。
//! 幂等:文件名取内容稳定哈希,同内容重复压缩只落盘一次。
//! 保留策略:与全局「历史数据默认保留」一致,本模块自产文件只增不减(无清理调用点)。
//!
//! 测试隔离:核心逻辑 `offload_to(base, ...)` 显式传 base(零全局态),单测只测它;
//! `init_base` 的 OnceLock 全局态一次性不可重置,故测试**绝不调用**它(避免污染同进程
//! 其他用例——如 extract_key_info 系列断言会被意外注入的句柄行破坏)。
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
/// 外置根目录(进程级一次注入;src-tauri lib.rs setup 调 init_base)。
/// 与审计侧共用 {app_data},文件名前缀 `ctx-` 区分(tool-results/ 目录共存)。
static BASE: OnceLock<PathBuf> = OnceLock::new();
/// 注入外置根目录(app data dir)。幂等,首次生效。
pub fn init_base(base: PathBuf) {
let _ = BASE.set(base);
}
/// base 是否已注入(降级判定用)。
pub fn is_enabled() -> bool {
BASE.get().is_some()
}
/// 内容稳定哈希(非密码学用途;碰撞后果仅同名文件互相覆盖,概率可忽略)。
fn content_id(content: &str) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
content.hash(&mut h);
h.finish()
}
/// 落盘工具结果正文,返回可读句柄行(供摘要末尾追加)。base 未注入 → None。
pub fn offload_with_handle(content: &str, tool_name: &str) -> Option<String> {
offload_to(BASE.get()?, content, tool_name)
}
/// 核心实现(显式 base,零全局态,可测)。
///
/// 建目录失败 / 写文件失败 → None(降级:调用方仅用摘要,不丢数据)。
pub fn offload_to(base: &Path, content: &str, tool_name: &str) -> Option<String> {
let dir = base.join("tool-results");
if std::fs::create_dir_all(&dir).is_err() {
return None;
}
let file = dir.join(format!("ctx-{:016x}.txt", content_id(content)));
if !file.exists() && std::fs::write(&file, content).is_err() {
return None;
}
Some(format!(
"[工具 {} 原文 {} 字符已外置: {} —— 需完整内容用 read_file 读该路径]",
tool_name,
content.chars().count(),
file.display()
))
}
/// 读回外置文件(查询侧/测试用)。文件缺失返回带语义错误(降级不 panic)。
pub fn read_offloaded(path: &str) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|e| format!("外置结果读取失败: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_base(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("df-ai-offload-{tag}-{}", std::process::id()))
}
#[test]
fn offload_to_writes_readable_file_and_is_idempotent() {
let base = tmp_base("ok");
let _ = std::fs::remove_dir_all(&base);
let payload = "large tool output ".repeat(1000);
let handle = offload_to(&base, &payload, "grep").expect("显式 base 应落盘");
assert!(handle.contains("已外置"), "句柄应含提示文案: {handle}");
assert!(handle.contains("read_file"), "句柄应指引读回方式: {handle}");
// 幂等:同内容二次调用返回同一文件名
let handle2 = offload_to(&base, &payload, "grep").expect("二次调用仍返回句柄");
assert_eq!(handle, handle2, "同内容应幂等复用同一文件");
// 落盘文件可读回原文(无损)
let path = std::fs::read_dir(base.join("tool-results"))
.expect("目录应存在")
.flatten()
.map(|e| e.path())
.find(|p| p.extension().map(|e| e == "txt").unwrap_or(false))
.expect("应有落盘文件");
assert_eq!(
read_offloaded(path.to_string_lossy().as_ref()).expect("应可读回"),
payload
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn different_content_gets_different_file() {
let base = tmp_base("diff");
let _ = std::fs::remove_dir_all(&base);
let h1 = offload_to(&base, &"a".repeat(3000), "grep").unwrap();
let h2 = offload_to(&base, &"b".repeat(3000), "grep").unwrap();
assert_ne!(h1, h2, "不同内容应落不同文件");
let _ = std::fs::remove_dir_all(&base);
}
}
+85
View File
@@ -0,0 +1,85 @@
//! P1 工具结果瘦身 — 端到端集成验证。
//!
//! 独立测试二进制(独立进程):`tool_offload::init_base` 的 OnceLock 全局态不外泄,
//! 不污染 lib 单测(反之亦然)。这是验证「超阈值 → 落盘句柄 → 可无损读回」全链的唯一安全方式。
use df_ai::context_helpers::{extract_key_info, READ_FILE_OFFLOAD_BYTES};
use df_ai::tool_offload;
fn tmp_base(tag: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("df-ai-ctx-offload-{tag}-{}", std::process::id()))
}
/// 从句柄行里粗取落盘路径(格式:`[...已外置: <path> —— ...]`)。
fn path_of(handle_line: &str) -> String {
handle_line
.split("已外置: ")
.nth(1)
.and_then(|s| s.split(" ——").next())
.expect("句柄行应含路径")
.trim()
.to_string()
}
#[test]
fn large_read_file_summarized_with_lossless_handle() {
let base = tmp_base("large");
let _ = std::fs::remove_dir_all(&base);
tool_offload::init_base(base.clone());
// 造一个超阈值的 read_file 结果(代码风格多行文本)
let body: String = (1..=800)
.map(|i| format!("line {i}: fn sample_{i}() {{ let x = {i}; }}\n"))
.collect();
assert!(body.len() > READ_FILE_OFFLOAD_BYTES, "夹具须超阈值");
let out = extract_key_info(&body, "read_file");
assert!(out.len() < body.len(), "超阈值 read_file 应被压缩");
assert!(out.contains("已外置"), "应追加落盘句柄:\n{out}");
assert!(out.contains("read_file 读该路径"), "句柄应指引读回方式");
// 无损性:按句柄路径读回 = 原文逐字节一致
let line = out.lines().find(|l| l.contains("已外置")).expect("应有句柄行");
let back = tool_offload::read_offloaded(&path_of(line)).expect("句柄路径应可读回");
assert_eq!(back, body, "落盘原文须与输入逐字节一致(无损)");
// 摘要确实变小(瘦身生效)
let ratio = out.len() as f64 / body.len() as f64;
assert!(ratio < 0.2, "压缩比应显著(<0.2),实际 {ratio:.3}");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn small_read_file_still_exempt() {
let base = tmp_base("small");
tool_offload::init_base(base);
let small = "line1: fn a() {}\nline2: fn b() {}\n";
assert_eq!(
extract_key_info(small, "read_file"),
small,
"阈值内 read_file 应原样返回(保 AI 定向读代码语义)"
);
}
#[test]
fn non_read_file_large_result_also_gets_handle() {
let base = tmp_base("other");
let _ = std::fs::remove_dir_all(&base);
tool_offload::init_base(base.clone());
// 中性行夹具:不得含 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(句柄追加下限)");
let out = extract_key_info(&body, "grep");
assert!(out.len() < body.len(), "应被压缩");
assert!(out.contains("已外置"), "≥8KB 结果应带落盘句柄");
let line = out.lines().find(|l| l.contains("已外置")).expect("应有句柄行");
assert_eq!(
tool_offload::read_offloaded(&path_of(line)).expect("应可读回"),
body
);
let _ = std::fs::remove_dir_all(&base);
}
+8 -2
View File
@@ -102,7 +102,9 @@ impl EnvSnapshot {
} }
} }
/// 兜底:spawn_blocking panic/join 失败时返回最小可用快照(全 None,字段不空)。 /// 兜底:spawn_blocking panic/join 失败或外层 5s 超时时返回最小可用快照(全 None,字段不空)。
/// 注:异常/超时均**冻结**为 fallback 写入 OnceLock(EX-3)——本进程后续 detect() 不再重探,
/// 瞬时挂起(如 Store Alias)会被固化为整进程降级快照;解冻需重启进程。
fn fallback() -> EnvSnapshot { fn fallback() -> EnvSnapshot {
EnvSnapshot { EnvSnapshot {
os: std::env::consts::OS.to_string(), os: std::env::consts::OS.to_string(),
@@ -310,7 +312,11 @@ fn extract_codepage(text: &str) -> Option<String> {
} }
} }
/// 执行 `tool --version`,解析首行返回版本串。失败/超时返回 None,不阻塞调用方。 /// 执行 `tool --version`,解析首行返回版本串。失败(非零退出/无首行)返回 None。
///
/// 注:单条命令本身**无独立超时**(EX-4)——若 `tool --version` 挂起(如 Windows Store Alias),
/// 本函数会一直阻塞,由外层 [`EnvSnapshot::detect`] 的 5s spawn_blocking 超时兜底整批(届时
/// 全部工具版本一起回退 fallback),而非逐命令超时。「不阻塞调用方」靠的是外层超时,非本函数。
/// ///
/// 例:python --version 输出 "Python 3.11.5" → 返回 "3.11.5";git --version 输出 /// 例:python --version 输出 "Python 3.11.5" → 返回 "3.11.5";git --version 输出
/// "git version 2.41.0" → 返回 "2.41.0"。统一抽掉工具名前缀,只保留版本号本身。 /// "git version 2.41.0" → 返回 "2.41.0"。统一抽掉工具名前缀,只保留版本号本身。
+7 -3
View File
@@ -320,7 +320,7 @@ pub struct ShellRequest {
pub env: std::collections::HashMap<String, String>, pub env: std::collections::HashMap<String, String>,
/// 超时时间(秒),None 表示不超时 /// 超时时间(秒),None 表示不超时
pub timeout_secs: Option<u64>, pub timeout_secs: Option<u64>,
/// Shell 类型(默认:Windows→Cmd, 非Windows→Sh) /// Shell 类型(None 时取 [`ShellType::default`]:Windows→pwsh 可用则 pwsh 否则 PowerShell,非 Windows→Sh)
#[serde(default)] #[serde(default)]
pub shell_type: Option<ShellType>, pub shell_type: Option<ShellType>,
} }
@@ -419,7 +419,7 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
// 提交冻结机械闸(39 治理):冻结在场且命令为 git commit → spawn 前拒绝,改动留工作区 // 提交冻结机械闸(39 治理):冻结在场且命令为 git commit → spawn 前拒绝,改动留工作区
crate::commit_freeze::check(&request).map_err(anyhow::Error::msg)?; crate::commit_freeze::check(&request).map_err(anyhow::Error::msg)?;
// 探测 pwsh(惰性 + OnceLock 全局缓存,只探一次),使后续 ShellType::default() 可读取缓存 // 探测 pwsh(惰性 + 模块级 PWSH_CACHE,成功才写缓存),使后续 ShellType::default() 可读取缓存
#[cfg(windows)] #[cfg(windows)]
let _ = probe_pwsh().await; let _ = probe_pwsh().await;
@@ -453,7 +453,11 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
/// 治 run_command 执行中黑盒:execute() 等 exit 才返回整块 stdout/stderr,长命令(cargo/npm 构建) /// 治 run_command 执行中黑盒:execute() 等 exit 才返回整块 stdout/stderr,长命令(cargo/npm 构建)
/// 期间前端只看 Started→Completed,中间进度不可见。本函数 spawn 子进程后并发逐行读两条流, /// 期间前端只看 Started→Completed,中间进度不可见。本函数 spawn 子进程后并发逐行读两条流,
/// 每读到一行回调 `on_output(kind, line)`(调用方可 emit 事件给前端实时展示),仍等进程 exit /// 每读到一行回调 `on_output(kind, line)`(调用方可 emit 事件给前端实时展示),仍等进程 exit
/// 后返回完整 ShellResult(与 execute() 同形,调用方无需感知差异)。 /// 后返回完整 ShellResult。
///
/// 与 [`execute`] 的输出差异(EX-2,调用方须知):本函数 stdout/stderr 按行重组——经
/// `BufReader::lines()` 逐行拼接,行尾统一为 `\n`(剥 \r\n)且补末尾换行;execute 则保留原始字节
/// (lossy 解码,含 \r\n,无补换行)。二者 ShellResult 字段同形,仅换行形态不同;需字节级一致时用 execute。
/// ///
/// 4性: /// 4性:
/// - 合理机制:spawn + BufReader::lines() 逐行,不丢未换行结尾的末段(read_to_end 兜底) /// - 合理机制:spawn + BufReader::lines() 逐行,不丢未换行结尾的末段(read_to_end 兜底)
-2
View File
@@ -125,8 +125,6 @@ impl ScoringEngine {
} }
} }
/// 统计 text 中命中任一关键词的数量(小写匹配,兼顾中英文)
/// 局限:纯子串匹配,不识别"不复用""无用户增长"等否定前缀,接 LLM 后由语义层修正
/// 单字否定前缀:关键词紧邻这些字时该命中不计(「不复用」不计入「复用」正向命中)。 /// 单字否定前缀:关键词紧邻这些字时该命中不计(「不复用」不计入「复用」正向命中)。
/// ///
/// 注意「别」单字歧义大(别具/别的/特别 均非否定),故不进单字清单—— /// 注意「别」单字歧义大(别具/别的/特别 均非否定),故不进单字清单——
+1
View File
@@ -85,6 +85,7 @@ async fn connect_once(
kind: HelloKind::Commander, kind: HelloKind::Commander,
channel_id: channel_id.to_string(), channel_id: channel_id.to_string(),
token: token.to_string(), token: token.to_string(),
proto: 1,
}; };
sink.send(Message::Text(serde_json::to_string(&hello)?)).await?; sink.send(Message::Text(serde_json::to_string(&hello)?)).await?;
+1 -1
View File
@@ -17,4 +17,4 @@ pub mod server;
pub mod server_http; pub mod server_http;
pub mod tools; pub mod tools;
pub use server::{run_server, ServerConfig}; pub use server::{run_server, ServerConfig, MCP_DATA_CHANGED_KEY};
+103 -10
View File
@@ -14,6 +14,7 @@ use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use df_storage::crud::SettingsRepo;
use df_storage::db::Database; use df_storage::db::Database;
use serde_json::{json, Value}; use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
@@ -29,6 +30,15 @@ pub(crate) const PROTOCOL_VERSION: &str = "2025-06-18";
pub(crate) const SERVER_NAME: &str = "devflow-mcp"; pub(crate) const SERVER_NAME: &str = "devflow-mcp";
pub(crate) const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION"); pub(crate) const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
/// app_settings KV key:stdio MCP 写库后落的变更标记,供 GUI 侧轮询兜底消费。
///
/// 独立进程形态的 stdio MCP 无 AppHandle(`on_write_call=None`),写库后 GUI 无从经
/// `df-data-changed` 事件感知,造成双端数据断层(P0-2)。此标记是跨进程的落库信令:
/// 每次成功写工具覆盖写入,值为 `{"at": <毫秒时间戳>, "tool": "<工具名>"}`。
/// GUI 侧轮询读此 key,`at` 变化即知库被外部改动;再按 `tool` 复用 df-data-changed
/// 的 entity 映射刷新对应列表(entity 映射留在 GUI 侧,避免本 crate 引入 df-ai 依赖)。
pub const MCP_DATA_CHANGED_KEY: &str = "mcp.data_changed";
/// MCP server 运行配置。 /// MCP server 运行配置。
/// ///
/// 聚合 stdio 形态的运行参数,避免 `run_server` 参数膨胀。 /// 聚合 stdio 形态的运行参数,避免 `run_server` 参数膨胀。
@@ -41,7 +51,8 @@ pub struct ServerConfig {
pub idle_timeout: Option<Duration>, pub idle_timeout: Option<Duration>,
/// 写操作回调:成功执行(未被 read-only/High 拒绝)的写工具(risk != Low)触发。 /// 写操作回调:成功执行(未被 read-only/High 拒绝)的写工具(risk != Low)触发。
/// 桌面内嵌形态经 server_http → on_tool_call → emit df-data-changed 刷新 GUI; /// 桌面内嵌形态经 server_http → on_tool_call → emit df-data-changed 刷新 GUI;
/// stdio 独立进程暂无 AppHandle,传 None 预留接线点,后续可注入。 /// stdio 独立进程暂无 AppHandle,传 None 预留接线点,后续可注入。传 None 时由
/// 落库变更标记([`MCP_DATA_CHANGED_KEY`])兜底,供 GUI 侧轮询消费。
pub on_write_call: Option<Arc<dyn Fn(&str) + Send + Sync>>, pub on_write_call: Option<Arc<dyn Fn(&str) + Send + Sync>>,
} }
@@ -154,8 +165,13 @@ where
if let Some(name) = tool_name { if let Some(name) = tool_name {
// MC-5仅业务成功才触发写回调——防业务失败(如 update_project 非法 status、 // MC-5仅业务成功才触发写回调——防业务失败(如 update_project 非法 status、
// create_project 空名等 result.isError=true)仍假触发 df-data-changed。 // create_project 空名等 result.isError=true)仍假触发 df-data-changed。
if is_success_tool_call(&resp) { if is_success_tool_call(&resp) && is_executed_write(&name, config.read_only) {
fire_write_hook(config, &name); // 多进程兜底:stdio 形态无 AppHandle,先落库内变更标记,供 GUI 侧轮询消费
// (即便未注入回调也不丢变更感知);再触发进程内回调(HTTP 内嵌形态走事件)。
record_data_change(ctx, &name).await;
if let Some(cb) = &config.on_write_call {
cb(&name);
}
} }
} }
} }
@@ -293,13 +309,30 @@ fn required_names(schema: &Value) -> Vec<String> {
} }
} }
/// 触发写操作回调(若有)。仅当工具为写操作(risk != Low)且未被 read-only/High 拒绝时触发, /// 判定工具是否属于「会执行的成功写操作」:risk != Low 且未被 read-only/High 拒绝。
/// 与 dispatch 的执行判定一致。回调仅作通知(如 GUI 刷新),不承载返回结果。 ///
fn fire_write_hook(config: &ServerConfig, name: &str) { /// 变更标记与进程内写回调共用此判定,保证两通道对「哪些写操作需通知 GUI」语义一致;
let Some(cb) = &config.on_write_call else { return }; /// 与 dispatch 的执行判定(`should_execute`)同源,避免逻辑漂移。
let Some(spec) = tools::find(name) else { return }; fn is_executed_write(name: &str, read_only: bool) -> bool {
if spec.risk != RiskLevel::Low && should_execute(config.read_only, spec.risk) { match tools::find(name) {
cb(name); Some(spec) => spec.risk != RiskLevel::Low && should_execute(read_only, spec.risk),
None => false,
}
}
/// 写成功后向 [`MCP_DATA_CHANGED_KEY`] 覆盖写入一条变更标记(多进程兜底信令)。
///
/// 独立进程形态完成写库后,GUI 无法经事件感知;此标记是落库的持久信令,GUI 轮询读
/// 该 key、`at` 变化即知库被外部改动。写失败仅告警不阻断(工具调用已成功,下次成功
/// 写会再次覆盖,标记只作「有变更」的粗粒度提示,非精确事件队列)。
async fn record_data_change(ctx: &Ctx, tool_name: &str) {
let at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let value = json!({ "at": at, "tool": tool_name }).to_string();
if let Err(e) = SettingsRepo::new(&ctx.db).set(MCP_DATA_CHANGED_KEY, &value).await {
tracing::warn!(target: "df_mcp", tool = tool_name, err = %e, "写变更标记失败(不改工具调用结果)");
} }
} }
@@ -675,4 +708,64 @@ mod tests {
*calls.lock().unwrap() *calls.lock().unwrap()
); );
} }
// ── 多进程兜底:stdio 写库后落变更标记 ────────────────────────────
#[tokio::test]
async fn main_loop_records_data_change_marker_for_write_tool() {
// stdio 形态(on_write_call=None)成功写工具后应向 KV 落变更标记,
// 供 GUI 轮询消费——即便无任何回调注入也不丢变更感知(P0-2 兜底)。
let ctx = test_ctx().await;
let (mut tx, rx) = tokio::io::duplex(1024);
let line = r#"{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"MarkProj","description":"d"}}}"#;
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
drop(tx);
main_loop(rx, tokio::io::sink(), &ctx, &test_config(false).await)
.await
.unwrap();
let v = SettingsRepo::new(&ctx.db)
.get(MCP_DATA_CHANGED_KEY)
.await
.unwrap()
.expect("写工具成功后应落变更标记");
let parsed: Value = serde_json::from_str(&v).unwrap();
assert_eq!(parsed["tool"], "create_project");
assert!(parsed["at"].as_u64().unwrap() > 0, "标记应含毫秒时间戳: {v}");
}
#[tokio::test]
async fn main_loop_no_data_change_marker_for_read_tool() {
// 只读工具无副作用,不应落变更标记(否则 GUI 无谓刷新)。
let ctx = test_ctx().await;
let (mut tx, rx) = tokio::io::duplex(1024);
let line = r#"{"jsonrpc":"2.0","id":12,"method":"tools/call","params":{"name":"list_projects","arguments":{}}}"#;
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
drop(tx);
main_loop(rx, tokio::io::sink(), &ctx, &test_config(false).await)
.await
.unwrap();
let v = SettingsRepo::new(&ctx.db)
.get(MCP_DATA_CHANGED_KEY)
.await
.unwrap();
assert!(v.is_none(), "只读工具不应落变更标记,实际: {v:?}");
}
#[tokio::test]
async fn main_loop_no_data_change_marker_when_read_only_denies_write() {
// read-only 下写被拒(未真正写库),不应落变更标记。
let ctx = test_ctx().await;
let (mut tx, rx) = tokio::io::duplex(1024);
let line = r#"{"jsonrpc":"2.0","id":13,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"X","description":"d"}}}"#;
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
drop(tx);
main_loop(rx, tokio::io::sink(), &ctx, &test_config(true).await)
.await
.unwrap();
let v = SettingsRepo::new(&ctx.db)
.get(MCP_DATA_CHANGED_KEY)
.await
.unwrap();
assert!(v.is_none(), "read-only 下写被拒不应落变更标记,实际: {v:?}");
}
} }
+7 -3
View File
@@ -70,10 +70,12 @@ impl Node for HumanNode {
// 3. select! 循环:Response / 超时 / 取消 // 3. select! 循环:Response / 超时 / 取消
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs); let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
let mut cancel_tick = tokio::time::interval(Duration::from_millis(500)); // FR-P4:取消检测由 500ms interval 轮询改为 Notify 被动唤醒(set_cancelled 触发)。
cancel_tick.tick().await; // 丢弃首个立即触发的 tick // 先建 notified future 再进 loop,避免 notify 早于 await 的竞态窗口;
// 每轮 select! 后重建(Notified 单次消费),配合 is_cancelled 复查兜底。
loop { loop {
let cancel_wait = ctx.node_status.cancel_notified();
tokio::select! { tokio::select! {
recv = rx.recv() => match recv { recv = rx.recv() => match recv {
Ok(WorkflowEvent::HumanApprovalResponse { Ok(WorkflowEvent::HumanApprovalResponse {
@@ -154,7 +156,9 @@ impl Node for HumanNode {
_ = tokio::time::sleep_until(deadline) => { _ = tokio::time::sleep_until(deadline) => {
return Err(anyhow::anyhow!("人工审批超时({}s)", timeout_secs)); return Err(anyhow::anyhow!("人工审批超时({}s)", timeout_secs));
} }
_ = cancel_tick.tick() => { _ = cancel_wait => {
// FR-P4:Notify 唤醒后复查 is_cancelled(Notify 无状态,
// 可能因其他节点取消而误唤醒,须确认本节点确被取消)。
if ctx.node_status.is_cancelled(&ctx.node_id) { if ctx.node_status.is_cancelled(&ctx.node_id) {
return Err(anyhow::anyhow!("人工审批被取消")); return Err(anyhow::anyhow!("人工审批被取消"));
} }
+3 -2
View File
@@ -1,7 +1,8 @@
//! 子工作流节点 — 加载一个子 DAG 并在当前执行上下文中递归执行 //! 子工作流节点 — 加载并校验一个子 DAG,返回其 JSON 快照
//! //!
//! 用于复用通用流程(如"代码审查"可作为任意主流程的子步骤)。 //! 用于复用通用流程(如"代码审查"可作为任意主流程的子步骤)。
//! 执行结果不直接展开节点,而是返回子 DAG 的 JSON 快照,由 DagExecutor 消费展开。 //! 注意:本节点不递归执行子 DAG,只做配置/深度校验后返回子 DAG 快照;
//! 快照的展开执行需调用方自行处理(DagExecutor 当前不会自动执行返回的 subflow)。
use async_trait::async_trait; use async_trait::async_trait;
use df_workflow::dag_def::DagDef; use df_workflow::dag_def::DagDef;
+121 -4
View File
@@ -67,6 +67,26 @@ pub async fn advance_task_atomic(
.await? .await?
.ok_or_else(|| df_types::error::Error::NotFound(format!("任务 {} 不存在", id)))?; .ok_or_else(|| df_types::error::Error::NotFound(format!("任务 {} 不存在", id)))?;
// 2.5 前置校验:归档任务冻结,不可推进(与 CAS SQL 的 archived=0 条件同源)。
//
// 背景:advance_status_atomic 的 CAS SQL 带 `AND archived = 0`,归档任务必然
// affected==0 → 返回 None → 下游误报「已变更(并发推进或旁路修改)」,诊断信息
// 完全误导(真实原因是归档,非并发)。此处前置拦截,给出精确可操作错误。
//
// 软删(deleted_at IS NOT NULL)任务 get_by_id 仍能读到(TASK_COLS 不 SELECT
// deleted_at,TaskRecord 无该字段),但 CAS 的 `deleted_at IS NULL` 同样必败。
// 因 TaskRecord 不带 deleted_at,此处无法直接判定;由 advance_status_atomic
// 的 CAS 失败回查区分(见该函数注释)。
if current.archived {
return Err(df_types::error::Error::InvalidState {
current: format!(
"任务 {} 已归档(archived=true),归档任务冻结不可推进;如需推进请先取消归档",
id
),
expected: target_status.to_string(),
});
}
// 3. 状态机校验(三类拒绝,错误类型区分供前端分辨): // 3. 状态机校验(三类拒绝,错误类型区分供前端分辨):
// - 同态(from==to):Validation「相同状态,无需推进」(空操作,非状态机违例) // - 同态(from==to):Validation「相同状态,无需推进」(空操作,非状态机违例)
// - 非法转换(跳态/终态无后继等):InvalidState「非法状态转换 X→Y」 // - 非法转换(跳态/终态无后继等):InvalidState「非法状态转换 X→Y」
@@ -112,11 +132,45 @@ pub async fn advance_task_atomic(
.advance_status_atomic(id, from, target_status, bump) .advance_status_atomic(id, from, target_status, bump)
.await?; .await?;
// 5. CAS 失败:并发已改动 status(或任务被删),拒绝静默成功。 // 5. CAS 失败:affected==0 有四种可能,回查区分,给出精确错误(不再一律误报「并发」)。
updated.ok_or_else(|| df_types::error::Error::InvalidState { //
current: "已变更(并发推进或旁路修改)".to_string(), // CAS SQL 条件:`WHERE id=? AND status=? AND deleted_at IS NULL AND archived=0`。
// affected==0 时逐一排查,避免「已变更(并发推进或旁路修改)」这个误导性文案:
// a) deleted_at 非 NULL → 任务在回收站(回查 is_deleted)
// b) archived=1 → 已归档(前置 2.5 已拦,此处兜底防 TOCTOU)
// c) status 真被并发改动 → 回读当前 status 对比 from
// d) 任务被删(id 不存在)→ get_by_id 返 None
let Some(updated) = updated else {
// a) 回收站任务(最易被误报为「并发」的一类,优先排查)
if repo.is_deleted(id).await? {
return Err(df_types::error::Error::InvalidState {
current: format!(
"任务 {} 在回收站(deleted_at 非 NULL),回收站任务不可推进;如需推进请先恢复",
id
),
expected: from.to_string(), expected: from.to_string(),
}) });
}
// b/c/d) 回读最新记录,区分归档 / 真并发 / 已删除
let latest = repo.get_by_id(id).await?;
return Err(match latest {
None => df_types::error::Error::NotFound(format!("任务 {} 不存在", id)),
Some(rec) if rec.archived => df_types::error::Error::InvalidState {
current: format!("任务 {} 已归档(archived=true),不可推进;如需推进请先取消归档", id),
expected: from.to_string(),
},
Some(rec) => df_types::error::Error::InvalidState {
current: format!(
"状态已变更(并发推进或旁路修改):期望 {:?},实际 {:?}",
from,
rec.status.as_str()
),
expected: from.to_string(),
},
});
};
Ok(updated)
} }
// ============================================================ // ============================================================
@@ -646,6 +700,69 @@ mod tests {
assert!(r.is_none(), "expected 与库内不符应 CAS 失败"); assert!(r.is_none(), "expected 与库内不符应 CAS 失败");
} }
// ---------- 归档/软删任务的精确错误(治「已变更(并发推进或旁路修改)」误导) ----------
#[tokio::test]
async fn archived_task_rejected_with_precise_error() {
// 归档任务(archived=true)推进应被前置校验拦截,给出「已归档」精确错误,
// 而非落到 CAS 失败报「已变更(并发推进或旁路修改)」误导文案。
let repo = setup().await;
let mut r = rec("t1", TaskStatus::Todo);
r.archived = true;
repo.insert(r).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap_err();
match err {
df_types::error::Error::InvalidState { current, .. } => {
assert!(
current.contains("已归档"),
"归档任务错误应含「已归档」,实际: {current}"
);
assert!(
!current.contains("并发推进或旁路修改"),
"归档任务不应误报并发,实际: {current}"
);
}
other => panic!("归档任务拒绝应是 InvalidState,实际: {other:?}"),
}
}
#[tokio::test]
async fn soft_deleted_task_rejected_with_precise_error() {
// 回收站任务(deleted_at 非 NULL)推进:get_by_id 仍能读到(不过滤软删),
// 状态机校验通过,但 CAS SQL 带 deleted_at IS NULL 必败 → 回查 is_deleted
// 区分,给出「在回收站」精确错误,而非误报「已变更(并发推进或旁路修改)」。
let repo = setup().await;
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
// 软删该任务(走 soft_delete,设 deleted_at)
let ok = repo.soft_delete("t1").await.unwrap();
assert!(ok, "soft_delete 应命中");
assert!(repo.is_deleted("t1").await.unwrap(), "软删后 is_deleted 应为 true");
let err = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap_err();
match err {
df_types::error::Error::InvalidState { current, .. } => {
assert!(
current.contains("回收站"),
"软删任务错误应含「回收站」,实际: {current}"
);
assert!(
!current.contains("并发推进或旁路修改"),
"软删任务不应误报并发,实际: {current}"
);
}
other => panic!("软删任务拒绝应是 InvalidState,实际: {other:?}"),
}
}
#[tokio::test]
async fn is_deleted_false_for_active_task() {
// 活跃任务 is_deleted 应为 false(不误判),保证回查区分逻辑不误伤正常任务。
let repo = setup().await;
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
assert!(!repo.is_deleted("t1").await.unwrap(), "活跃任务 is_deleted 应为 false");
// 不存在的任务:is_deleted 返回 false(不存在 ≠ 软删)
assert!(!repo.is_deleted("nope").await.unwrap(), "不存在任务 is_deleted 应为 false");
}
#[tokio::test] #[tokio::test]
async fn blocked_round_trip_does_not_bump() { async fn blocked_round_trip_does_not_bump() {
let repo = setup().await; let repo = setup().await;
-3
View File
@@ -114,14 +114,11 @@ pub(crate) fn strip_readme_noise(content: &str) -> String {
if trimmed == "---" || trimmed == "+++" { if trimmed == "---" || trimmed == "+++" {
// 开头处的 frontmatter:跳到下一个匹配分隔符 // 开头处的 frontmatter:跳到下一个匹配分隔符
let delim = trimmed; let delim = trimmed;
let mut consumed_any = false;
for next in lines.by_ref() { for next in lines.by_ref() {
consumed_any = true;
if next.trim() == delim { if next.trim() == delim {
break; break;
} }
} }
let _ = consumed_any;
continue; continue;
} }
// ── HTML 注释块(<!-- ... -->,可能跨行)── // ── HTML 注释块(<!-- ... -->,可能跨行)──
+40
View File
@@ -75,6 +75,17 @@ fn validate_device_id(id: &str) -> bool {
!id.is_empty() && id.len() <= 128 && id.chars().all(|c| !c.is_control()) !id.is_empty() && id.len() <= 128 && id.chars().all(|c| !c.is_control())
} }
/// 当前 Hello 握手协议版本。
///
/// 协商规则:旧客户端 Hello 缺 `proto` 字段 → serde default 视作 v1(向后兼容);
/// 高于本端支持版本时记录告警,为后续破坏性变更预留通知位(当前仅日志,不拒绝握手)。
pub const HELLO_PROTO_VERSION: u32 = 1;
/// `proto` 缺省值:旧客户端不带该字段时按 v1 处理(向后兼容)。
fn default_proto() -> u32 {
HELLO_PROTO_VERSION
}
/// 客户端首消息:身份宣告(简单协议) /// 客户端首消息:身份宣告(简单协议)
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hello { pub struct Hello {
@@ -84,6 +95,9 @@ pub struct Hello {
pub device_id: String, pub device_id: String,
/// 配对 token /// 配对 token
pub token: String, pub token: String,
/// 协议版本(缺省按 v1,兼容旧客户端)
#[serde(default = "default_proto")]
pub proto: u32,
} }
/// Hello.kind 的传输表示(serde 字符串,与 ClientKind 解耦避免 rename 歧义) /// Hello.kind 的传输表示(serde 字符串,与 ClientKind 解耦避免 rename 歧义)
@@ -298,6 +312,16 @@ async fn handle_connection(socket: WebSocket, state: RelayState, expected: Clien
return; return;
} }
// 版本协商:proto 缺省按 v1(旧客户端不带字段,向后兼容)。
// 高于本端支持版本时告警 —— 为后续破坏性变更预留的通知位(当前仅日志,不拒绝握手)。
if hello.proto > HELLO_PROTO_VERSION {
tracing::warn!(
client_proto = hello.proto,
server_proto = HELLO_PROTO_VERSION,
"客户端协议版本高于本端,可能存在不兼容(预留通知位)"
);
}
let conn_id = next_conn_id(); let conn_id = next_conn_id();
let kind: ClientKind = hello.kind.into(); let kind: ClientKind = hello.kind.into();
let device_id = hello.device_id.clone(); let device_id = hello.device_id.clone();
@@ -305,6 +329,7 @@ async fn handle_connection(socket: WebSocket, state: RelayState, expected: Clien
conn_id = conn_id.0, conn_id = conn_id.0,
?kind, ?kind,
device_id = %device_id, device_id = %device_id,
proto = hello.proto,
"连接握手通过,进入收发循环" "连接握手通过,进入收发循环"
); );
@@ -545,4 +570,19 @@ mod tests {
fn max_frame_is_one_mib() { fn max_frame_is_one_mib() {
assert_eq!(MAX_FRAME_BYTES, 1024 * 1024); assert_eq!(MAX_FRAME_BYTES, 1024 * 1024);
} }
#[test]
fn hello_proto_defaults_to_v1_when_absent() {
// 旧客户端不发 proto → 反序列化按 v1,保证向后兼容
let json = r#"{"kind":"device","device_id":"dev-1","token":"t"}"#;
let h: Hello = serde_json::from_str(json).expect("缺 proto 应可解析");
assert_eq!(h.proto, HELLO_PROTO_VERSION);
}
#[test]
fn hello_proto_parsed_when_present() {
let json = r#"{"kind":"device","device_id":"dev-1","token":"t","proto":2}"#;
let h: Hello = serde_json::from_str(json).unwrap();
assert_eq!(h.proto, 2);
}
} }
+220 -16
View File
@@ -112,6 +112,10 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
boundary_json: row.get("boundary_json").unwrap_or(None), boundary_json: row.get("boundary_json").unwrap_or(None),
// loop_state 列老库经 v49 迁移补建。unwrap_or(None) 兜底(NULL=非任务会话)。 // loop_state 列老库经 v49 迁移补建。unwrap_or(None) 兜底(NULL=非任务会话)。
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: 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),
}) })
} }
@@ -133,6 +137,8 @@ fn ai_tool_execution_from_row(row: &Row<'_>) -> std::result::Result<AiToolExecut
requested_at: row.get("requested_at")?, requested_at: row.get("requested_at")?,
executed_at: row.get("executed_at")?, executed_at: row.get("executed_at")?,
decided_by: row.get("decided_by")?, decided_by: row.get("decided_by")?,
// duration_ms 列老库经 v50 迁移补建。unwrap_or(None) 兜底(NULL=老数据/未测路径)。
duration_ms: row.get("duration_ms").unwrap_or(None),
}) })
} }
@@ -187,29 +193,32 @@ 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) "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)", 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.boundary_json, rec.loop_state, rec.status, rec.working_context_json
], ],
) )
}, },
update => |conn, rec| { update => |conn, rec| {
// loop_state 有意不在 SET 列(V49 生命周期标记独立更新,对齐 boundary 早期模式): // loop_state/status 有意不在 SET 列(V49/V50 生命周期标记独立更新,对齐 boundary 早期模式):
// update_full 的调用方(save_conversation 等)从 get_by_id 读旧记录再改元数据写回, // update_full 的调用方(save_conversation 等)从 get_by_id 读旧记录再改元数据写回,
// 读改写窗口内 loop 生命周期可变更(spawn 置 running/收敛清 None)——若 SET 含 // 读改写窗口内 loop 生命周期/终局态可变更——若 SET 含二者,旧值会覆盖新值(lost-update)。
// loop_state,旧值会覆盖新值(lost-update)。生命周期只经 set_loop_state 单点写。 // 生命周期只经 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
], ],
) )
} }
@@ -249,22 +258,22 @@ impl_repo!(
from_row => |row| ai_tool_execution_from_row(row), from_row => |row| ai_tool_execution_from_row(row),
insert => |conn, rec| { insert => |conn, rec| {
conn.execute( conn.execute(
"INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by) "INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by, duration_ms)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
params![ params![
rec.id, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name, rec.id, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name,
rec.arguments, rec.result, rec.status, rec.risk_level, rec.arguments, rec.result, rec.status, rec.risk_level,
rec.requested_at, rec.executed_at, rec.decided_by rec.requested_at, rec.executed_at, rec.decided_by, rec.duration_ms
], ],
) )
}, },
update => |conn, rec| { update => |conn, rec| {
conn.execute( conn.execute(
"UPDATE ai_tool_executions SET conversation_id = ?1, message_id = ?2, tool_call_id = ?3, tool_name = ?4, arguments = ?5, result = ?6, status = ?7, risk_level = ?8, requested_at = ?9, executed_at = ?10, decided_by = ?11 WHERE id = ?12", "UPDATE ai_tool_executions SET conversation_id = ?1, message_id = ?2, tool_call_id = ?3, tool_name = ?4, arguments = ?5, result = ?6, status = ?7, risk_level = ?8, requested_at = ?9, executed_at = ?10, decided_by = ?11, duration_ms = ?12 WHERE id = ?13",
params![ params![
rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name,
rec.arguments, rec.result, rec.status, rec.risk_level, rec.arguments, rec.result, rec.status, rec.risk_level,
rec.requested_at, rec.executed_at, rec.decided_by, rec.id rec.requested_at, rec.executed_at, rec.decided_by, rec.duration_ms, rec.id
], ],
) )
} }
@@ -298,15 +307,15 @@ impl AiToolExecutionRepo {
let tx = guard.transaction().map_err(storage_err)?; let tx = guard.transaction().map_err(storage_err)?;
{ {
let mut stmt = tx.prepare( let mut stmt = tx.prepare(
"INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by) "INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by, duration_ms)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
) )
.map_err(storage_err)?; .map_err(storage_err)?;
for rec in &records { for rec in &records {
stmt.execute(params![ stmt.execute(params![
rec.id, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name, rec.id, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name,
rec.arguments, rec.result, rec.status, rec.risk_level, rec.arguments, rec.result, rec.status, rec.risk_level,
rec.requested_at, rec.executed_at, rec.decided_by rec.requested_at, rec.executed_at, rec.decided_by, rec.duration_ms
]) ])
.map_err(storage_err)?; .map_err(storage_err)?;
} }
@@ -839,6 +848,29 @@ impl AiConversationRepo {
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 会话终局态写入(V50 status)。
///
/// status: "active"(默认/进行中/正常完成)、"incomplete"(停靠/断流等未完成终局)、
/// "error"(循环错误终局)。纯标记不进 update_full(防 save_conversation 高频全量写覆盖),
/// 对齐 set_loop_state 模式;不动 updated_at。对话未落库(懒创建)时影响 0 行,调用方忽略。
pub async fn set_status(&self, id: &str, status: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let status = status.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_conversations SET status = ?1 WHERE id = ?2",
params![status, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 重启扫描:把所有 loop_state='running' 残留改写为 'parked'。 /// 重启扫描:把所有 loop_state='running' 残留改写为 'parked'。
/// ///
/// 进程启动时调用一次:上次运行中 loop_state=running 的会话,进程重启后 loop 必死 /// 进程启动时调用一次:上次运行中 loop_state=running 的会话,进程重启后 loop 必死
@@ -1020,6 +1052,7 @@ mod tests {
requested_at: "0".into(), requested_at: "0".into(),
executed_at: None, executed_at: None,
decided_by: None, decided_by: None,
duration_ms: None,
} }
} }
@@ -1068,6 +1101,43 @@ mod tests {
assert_eq!(got2.unwrap().id, "a2", "同三元组应返回最新落定记录"); assert_eq!(got2.unwrap().id, "a2", "同三元组应返回最新落定记录");
} }
/// D2a 结果外置的读写兼容契约:result 列由外置层(audit 侧)决定写内联还是写句柄,
/// 存储层既不解释也不改写——老行(外置落地前写入)是内联全文,读回必须原样;新行的
/// 外置句柄 JSON 同样原样存取。锁死「旧行仍读表内全文」:存储层若对 result 做截断/
/// 外置/解析等 mutation,会同时破坏两侧兼容(旧行读残缺 + 句柄被误当正文)。
#[tokio::test]
async fn tool_execution_result_stored_verbatim_old_and_new() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiToolExecutionRepo::new(&db);
// 旧数据:内联全文。超大值也要能原样读回(D2a 落地前的历史大结果就长这样,存储层不得追改)
let legacy_big = "A".repeat(10 * 1024);
let mut old_big = audit_rec("old_big", "conv1", "call_old_big", "read_file", "{}", "completed");
old_big.result = Some(legacy_big.clone());
repo.insert(old_big).await.expect("insert old big");
let mut old_small = audit_rec("old_small", "conv2", "call_old_small", "write_file", "{}", "completed");
old_small.result = Some("{\"ok\":true}".to_string());
repo.insert(old_small).await.expect("insert old small");
// 新数据:外置句柄 JSON(存储层不解析,当普通 TEXT 存取)
let handle = r#"{"__offloaded__":true,"path":"/tmp/x.txt","size_bytes":20480}"#;
let mut new_off = audit_rec("new_off", "conv3", "call_new", "run_command", "{}", "completed");
new_off.result = Some(handle.to_string());
repo.insert(new_off).await.expect("insert new offloaded");
// 读回:三条各自原样(旧行全文;句柄不被误解析/清空)
let got_old_big = repo.get_by_id("old_big").await.expect("get").expect("row");
assert_eq!(got_old_big.result.as_deref(), Some(legacy_big.as_str()), "老行超大内联 result 须原样读回");
let got_old_small = repo.get_by_id("old_small").await.expect("get").expect("row");
assert_eq!(got_old_small.result.as_deref(), Some("{\"ok\":true}"));
let got_new = repo.get_by_id("new_off").await.expect("get").expect("row");
assert_eq!(got_new.result.as_deref(), Some(handle), "外置句柄 JSON 须原样存取");
// 专用查询路径(按 tool_call_id 定位)同样原样,与通用 get_by_id 口径一致
let by_tc = repo.find_by_tool_call_id("call_old_big").await.expect("query").expect("row");
assert_eq!(by_tc.result.as_deref(), Some(legacy_big.as_str()));
}
/// insert_checkpoint 冲突语义:同 id 二次写入 Ok 不报错(幂等跳过,affected=0 走 warn), /// insert_checkpoint 冲突语义:同 id 二次写入 Ok 不报错(幂等跳过,affected=0 走 warn),
/// 保留首次 snapshot。锁死 OR IGNORE 行为,防未来误改成 REPLACE 静默覆盖快照。 /// 保留首次 snapshot。锁死 OR IGNORE 行为,防未来误改成 REPLACE 静默覆盖快照。
#[tokio::test] #[tokio::test]
@@ -1094,4 +1164,138 @@ mod tests {
.expect("snapshot"); .expect("snapshot");
assert_eq!(snapshot, r#"[{"seq":0}]"#, "冲突时应保留首次快照"); assert_eq!(snapshot, r#"[{"seq":0}]"#, "冲突时应保留首次快照");
} }
/// #904 契约:set_loop_state 写 "parked" 后 get_by_id 可读回(停靠出口写侧单点)。
/// 锁死「停靠状态写入收敛进 emit_task_parked」依赖的 repo 能力——若 set_loop_state
/// 或 from_row 的 loop_state 映射回归,停靠卡「继续」将再次撞「AI 未在暂停态」。
#[tokio::test]
async fn set_loop_state_parked_roundtrip() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiConversationRepo::new(&db);
repo.insert(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: None,
})
.await
.expect("insert");
assert!(repo.set_loop_state("c1", Some("parked")).await.expect("set"));
let got = repo.get_by_id("c1").await.expect("get").expect("row");
assert_eq!(got.loop_state.as_deref(), Some("parked"), "写 parked 应可读回");
// 清态(running 收敛出口)可读回 None
assert!(repo.set_loop_state("c1", None).await.expect("clear"));
let cleared = repo.get_by_id("c1").await.expect("get").expect("row");
assert_eq!(cleared.loop_state, None, "清 loop_state 应可读回 None");
}
/// V50 契约:set_status 写终局态后可读回,且 insert 默认 active。
/// 锁死「会话终局态」建模能力,防 from_row 映射或 insert 列回归致默认值丢失。
#[tokio::test]
async fn set_status_roundtrip_and_default_active() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiConversationRepo::new(&db);
repo.insert(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: None,
})
.await
.expect("insert");
let got = repo.get_by_id("c1").await.expect("get").expect("row");
assert_eq!(got.status, "active", "默认终局态应为 active");
assert!(repo.set_status("c1", "incomplete").await.expect("set"));
let got2 = repo.get_by_id("c1").await.expect("get").expect("row");
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]
+101 -11
View File
@@ -47,6 +47,7 @@ fn ai_message_from_row(row: &Row<'_>) -> std::result::Result<AiMessageRecord, ru
prompt_cache_miss_tokens: row.get("prompt_cache_miss_tokens")?, prompt_cache_miss_tokens: row.get("prompt_cache_miss_tokens")?,
reasoning_tokens: row.get("reasoning_tokens")?, reasoning_tokens: row.get("reasoning_tokens")?,
is_estimated: row.get("is_estimated")?, is_estimated: row.get("is_estimated")?,
source: row.get("source")?,
}) })
} }
@@ -87,8 +88,8 @@ impl AiMessageRepo {
(id, conversation_id, seq, role, content, parts, tool_call_id, (id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens, prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated) prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated, source)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
) )
.map_err(storage_err)?; .map_err(storage_err)?;
for rec in &records { for rec in &records {
@@ -97,7 +98,7 @@ impl AiMessageRepo {
rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status, rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status,
rec.reasoning_content, rec.timestamp, rec.created_at, rec.reasoning_content, rec.timestamp, rec.created_at,
rec.prompt_tokens, rec.completion_tokens, rec.prompt_tokens, rec.completion_tokens,
rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens, rec.is_estimated rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens, rec.is_estimated, rec.source
]) ])
.map_err(storage_err)?; .map_err(storage_err)?;
} }
@@ -124,7 +125,7 @@ impl AiMessageRepo {
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens, prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens,
is_estimated is_estimated, source
FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq ASC", FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq ASC",
) )
.map_err(storage_err)?; .map_err(storage_err)?;
@@ -171,13 +172,13 @@ impl AiMessageRepo {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id, "SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens, prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated, source
FROM ai_messages WHERE conversation_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3" FROM ai_messages WHERE conversation_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3"
} else { } else {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id, "SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens, prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated, source
FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq DESC LIMIT ?2" FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq DESC LIMIT ?2"
}; };
let mut stmt = guard.prepare(sql).map_err(storage_err)?; let mut stmt = guard.prepare(sql).map_err(storage_err)?;
@@ -264,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)冲突跳过;
@@ -273,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();
@@ -281,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],
@@ -296,8 +325,8 @@ impl AiMessageRepo {
(id, conversation_id, seq, role, content, parts, tool_call_id, (id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens, prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated) prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated, source)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
) )
.map_err(storage_err)?; .map_err(storage_err)?;
for rec in &records { for rec in &records {
@@ -306,7 +335,7 @@ impl AiMessageRepo {
rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status, rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status,
rec.reasoning_content, rec.timestamp, rec.created_at, rec.reasoning_content, rec.timestamp, rec.created_at,
rec.prompt_tokens, rec.completion_tokens, rec.prompt_tokens, rec.completion_tokens,
rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens, rec.is_estimated rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens, rec.is_estimated, rec.source
]) ])
.map_err(storage_err)?; .map_err(storage_err)?;
} }
@@ -381,6 +410,7 @@ mod tests {
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None, is_estimated: None,
source: None,
} }
} }
@@ -544,6 +574,7 @@ mod tests {
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None, is_estimated: None,
source: None,
}, },
AiMessageRecord { AiMessageRecord {
id: "new_1".into(), id: "new_1".into(),
@@ -565,6 +596,7 @@ mod tests {
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None, is_estimated: None,
source: None,
}, },
]; ];
repo.replace_conversation("conv", records).await.expect("replace"); repo.replace_conversation("conv", records).await.expect("replace");
@@ -638,6 +670,7 @@ mod tests {
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None, is_estimated: None,
source: None,
}], }],
) )
.await .await
@@ -680,6 +713,7 @@ mod tests {
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None, is_estimated: None,
source: None,
}; };
repo.replace_conversation("c", vec![rec()]).await.expect("1st"); repo.replace_conversation("c", vec![rec()]).await.expect("1st");
repo.replace_conversation("c", vec![rec()]).await.expect("2nd"); repo.replace_conversation("c", vec![rec()]).await.expect("2nd");
@@ -687,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]
+4
View File
@@ -14,6 +14,7 @@
//! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具 //! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具
//! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22) //! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22)
//! - [`mod@message_repo`]:AiMessageRepo(消息拆分存储,全专用方法不走宏) //! - [`mod@message_repo`]:AiMessageRepo(消息拆分存储,全专用方法不走宏)
//! - [`mod@outbox_repo`]:OutboxRepo(待发箱 ai_outbox,V51,含 claim CAS/reset_stale_sending,手写不走宏)
//! //!
//! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` / //! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` /
//! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。 //! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。
@@ -25,6 +26,7 @@ mod conversation_repo;
mod idea_eval_repo; mod idea_eval_repo;
mod idea_repo; mod idea_repo;
mod message_repo; mod message_repo;
mod outbox_repo;
mod project_event_repo; mod project_event_repo;
mod project_module_repo; mod project_module_repo;
mod project_repo; mod project_repo;
@@ -38,6 +40,7 @@ pub use conversation_repo::*;
pub use idea_eval_repo::*; pub use idea_eval_repo::*;
pub use idea_repo::*; pub use idea_repo::*;
pub use message_repo::*; pub use message_repo::*;
pub use outbox_repo::*;
pub use project_event_repo::*; pub use project_event_repo::*;
pub use project_module_repo::*; pub use project_module_repo::*;
pub use project_repo::*; pub use project_repo::*;
@@ -72,6 +75,7 @@ macro_rules! impl_repo {
update => |$u_conn:ident, $u_rec:ident| $update_body:expr update => |$u_conn:ident, $u_rec:ident| $update_body:expr
) => { ) => {
$(#[$meta])* $(#[$meta])*
#[derive(Clone)]
pub struct $name { pub struct $name {
conn: Arc<Mutex<Connection>>, conn: Arc<Mutex<Connection>>,
} }
+619
View File
@@ -0,0 +1,619 @@
//! 待发箱 Repo:OutboxRepo(ai_outbox 表,V51)
//!
//! 队列与定时消息统一实体的持久层。因需按 id 逐条 CAS 更新(claim)与状态条件扫描,
//! 不走 `impl_repo!` 宏,手写(同 `SettingsRepo` 范式)。
//!
//! 设计见 docs/02-架构设计/专项设计/定时消息与待发箱持久化-设计-2026-10-10.md §3.2。
//!
//! 状态落点纪律(V21 教训):每项状态由调度器按项逐条 CAS 写,不依赖全局内存推算;
//! `reset_stale_sending` 在启动时把崩溃残留的 `sending` 复位为 `pending`,镜像无人值守引擎的
//! `in_flight` 泄漏处置。
use std::sync::Arc;
use rusqlite::{params, Connection, OptionalExtension, Row};
use tokio::sync::Mutex;
use df_types::error::Result;
use crate::db::Database;
use crate::models::AiOutboxRecord;
use super::storage_err;
// ============================================================
// 状态 / 类型字面量(单一真值来源,避免调度器与 Repo 各写一份裸串漂移)
// ============================================================
/// 待派发(初始态)。
pub const STATUS_PENDING: &str = "pending";
/// 已被调度器 CAS 占取、正在派发。
pub const STATUS_SENDING: &str = "sending";
/// 受理成功(已交给正典发送链)。
pub const STATUS_SENT: &str = "sent";
/// 受理失败且不再自动重试。
pub const STATUS_FAILED: &str = "failed";
/// 用户取消 / 清空。
pub const STATUS_CANCELED: &str = "canceled";
/// 尽快发(kind=queue,due_at 为空)。
pub const KIND_QUEUE: &str = "queue";
/// 定时发(kind=scheduled,due_at 为 epoch 毫秒)。
pub const KIND_SCHEDULED: &str = "scheduled";
/// 终态集合(不再被调度器扫描)。
const DONE_STATUSES: &str = "'sent','failed','canceled'";
fn outbox_from_row(row: &Row<'_>) -> std::result::Result<AiOutboxRecord, rusqlite::Error> {
Ok(AiOutboxRecord {
id: row.get("id")?,
conversation_id: row.get("conversation_id")?,
kind: row.get("kind")?,
text: row.get("text")?,
skill: row.get("skill")?,
model_override: row.get("model_override")?,
parts_json: row.get("parts_json")?,
spans_json: row.get("spans_json")?,
due_at: row.get("due_at")?,
status: row.get("status")?,
attempts: row.get("attempts")?,
last_error: row.get("last_error")?,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
sent_at: row.get("sent_at")?,
})
}
/// 待发箱 Repo(表 `ai_outbox`)。
pub struct OutboxRepo {
conn: Arc<Mutex<Connection>>,
}
impl OutboxRepo {
pub fn new(db: &Database) -> Self {
Self { conn: db.conn() }
}
/// 插入一条待发项,返回 id。created_at/updated_at 由本方法取当前毫秒覆盖
/// (写入时机即落库时机,调用方传入的不可信)。
pub async fn insert(&self, record: AiOutboxRecord) -> Result<String> {
let conn = self.conn.clone();
let now = df_types::now_millis();
let mut rec = record;
rec.created_at = now;
rec.updated_at = now;
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let id = rec.id.clone();
guard
.execute(
"INSERT INTO ai_outbox
(id, conversation_id, kind, text, skill, model_override, parts_json,
spans_json, due_at, status, attempts, last_error, created_at, updated_at, sent_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
params![
rec.id, rec.conversation_id, rec.kind, rec.text, rec.skill,
rec.model_override, rec.parts_json, rec.spans_json, rec.due_at,
rec.status, rec.attempts, rec.last_error, rec.created_at, rec.updated_at,
rec.sent_at
],
)
.map_err(storage_err)?;
Ok(id)
})
.await
.map_err(storage_err)?
}
/// 按 id 取单条(IPC 回显 / 测试核验用)。
pub async fn get_by_id(&self, id: &str) -> Result<Option<AiOutboxRecord>> {
let conn = self.conn.clone();
let id = id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let rec = guard
.query_row("SELECT * FROM ai_outbox WHERE id = ?1", params![id], outbox_from_row)
.optional()
.map_err(storage_err)?;
Ok(rec)
})
.await
.map_err(storage_err)?
}
/// 按会话取待发视图(前端 `state.queue` 的来源)。`include_done=false` 时排除终态,
/// 按 created_at 升序(先入先出,保序)。
pub async fn list_by_conv(
&self,
conversation_id: &str,
include_done: bool,
) -> Result<Vec<AiOutboxRecord>> {
let conn = self.conn.clone();
let conversation_id = conversation_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let sql = if include_done {
"SELECT * FROM ai_outbox WHERE conversation_id = ?1 ORDER BY created_at ASC"
.to_string()
} else {
format!(
"SELECT * FROM ai_outbox WHERE conversation_id = ?1 \
AND status NOT IN ({DONE_STATUSES}) ORDER BY created_at ASC"
)
};
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
let rows = stmt
.query_map(params![conversation_id], outbox_from_row)
.map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
/// 全量列表(跨会话,「定时列表」抽屉数据源)。`include_done=false` 时排除终态,
/// 按 created_at 升序(与 list_by_conv 同序语义)。
pub async fn list_all(&self, include_done: bool) -> Result<Vec<AiOutboxRecord>> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let sql = if include_done {
"SELECT * FROM ai_outbox ORDER BY created_at ASC".to_string()
} else {
format!(
"SELECT * FROM ai_outbox \
WHERE status NOT IN ({DONE_STATUSES}) ORDER BY created_at ASC"
)
};
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
let rows = stmt.query_map([], outbox_from_row).map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
/// 取到点的待派发项(status='pending' 且 due_at 为空或已到),按 created_at 升序,至多 limit 条。
/// 命中 idx_ai_outbox_due。
pub async fn list_due(&self, now_ms: i64, limit: i64) -> Result<Vec<AiOutboxRecord>> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
"SELECT * FROM ai_outbox \
WHERE status = 'pending' AND (due_at IS NULL OR due_at <= ?1) \
ORDER BY created_at ASC LIMIT ?2",
)
.map_err(storage_err)?;
let rows = stmt
.query_map(params![now_ms, limit], outbox_from_row)
.map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
/// CAS 占取:仅当项仍为 pending 时置 sending。changes==1 才返回 true,
/// 防并发双取(多轮/多消费方同取同一条时只有一个成功)。
pub async fn claim(&self, id: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_outbox SET status = 'sending', updated_at = ?1 \
WHERE id = ?2 AND status = 'pending'",
params![now, id],
)
.map_err(storage_err)?;
Ok(affected == 1)
})
.await
.map_err(storage_err)?
}
/// 标记受理成功(终态)。清空 last_error。
pub async fn mark_sent(&self, id: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_outbox SET status = 'sent', sent_at = ?1, updated_at = ?1, \
last_error = NULL WHERE id = ?2",
params![now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 标记永久失败(终态,不再自动重试)。
pub async fn mark_failed(&self, id: &str, err: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let err = err.to_owned();
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_outbox SET status = 'failed', last_error = ?1, updated_at = ?2 \
WHERE id = ?3",
params![err, now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 瞬时失败回退:attempts+1、状态回 pending、记 last_error,返回新的 attempts。
/// 保留下轮再试(网络超时/会话忙场景天然重试),不删项——对齐「错误不清队列」纪律。
pub async fn revert_pending(&self, id: &str, err: Option<&str>) -> Result<i64> {
let conn = self.conn.clone();
let id = id.to_owned();
let err = err.map(|s| s.to_owned());
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
guard
.execute(
"UPDATE ai_outbox SET status = 'pending', attempts = attempts + 1, \
last_error = ?1, updated_at = ?2 WHERE id = ?3",
params![err, now, id],
)
.map_err(storage_err)?;
let attempts: i64 = guard
.query_row(
"SELECT attempts FROM ai_outbox WHERE id = ?1",
params![id],
|r| r.get(0),
)
.map_err(storage_err)?;
Ok(attempts)
})
.await
.map_err(storage_err)?
}
/// 改期:更新 due_at(NULL=改为尽快)。
pub async fn reschedule(&self, id: &str, due_at: Option<i64>) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_outbox SET due_at = ?1, updated_at = ?2 WHERE id = ?3",
params![due_at, now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 手动重试入口:仅 failed → pending(attempts 归零=手动重试给全新预算,清 last_error)。
/// 其余状态不动返回 false——调度器自动重试走 revert_pending(瞬时失败保序),两者互补。
pub async fn requeue(&self, id: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_outbox SET status = 'pending', attempts = 0, last_error = NULL, \
updated_at = ?1 WHERE id = ?2 AND status = 'failed'",
params![now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 取消单项。
pub async fn cancel(&self, id: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_outbox SET status = 'canceled', updated_at = ?1 WHERE id = ?2",
params![now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 编辑待发文本。
pub async fn update_text(&self, id: &str, text: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let text = text.to_owned();
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_outbox SET text = ?1, updated_at = ?2 WHERE id = ?3",
params![text, now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 清空待发(pending→canceled,不物理删——保留行便于审计)。`conv_id=None` 时清全部会话。
/// 返回被取消的行数。
pub async fn clear(&self, conv_id: Option<&str>) -> Result<usize> {
let conn = self.conn.clone();
let conv_id = conv_id.map(|s| s.to_owned());
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = match conv_id {
Some(id) => guard.execute(
"UPDATE ai_outbox SET status = 'canceled', updated_at = ?1 \
WHERE status = 'pending' AND conversation_id = ?2",
params![now, id],
),
None => guard.execute(
"UPDATE ai_outbox SET status = 'canceled', updated_at = ?1 \
WHERE status = 'pending'",
params![now],
),
}
.map_err(storage_err)?;
Ok(affected)
})
.await
.map_err(storage_err)?
}
/// 启动复位:把崩溃半途残留的 `sending` 全部复位为 `pending`(进程重启时前者必为陈旧值)。
/// 返回复位的行数。镜像无人值守引擎的 in_flight 泄漏处置。
pub async fn reset_stale_sending(&self) -> Result<usize> {
let conn = self.conn.clone();
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_outbox SET status = 'pending', updated_at = ?1 WHERE status = 'sending'",
params![now],
)
.map_err(storage_err)?;
Ok(affected)
})
.await
.map_err(storage_err)?
}
}
// ============================================================
// 单元测试 — 迁移可重入 / CAS 互斥 / sender 残留复位(内存 DB)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
/// 构造一条 pending 待发项(due_at=None=尽快)。
fn rec(id: &str, conv: &str) -> AiOutboxRecord {
AiOutboxRecord {
id: id.to_string(),
conversation_id: conv.to_string(),
kind: KIND_QUEUE.to_string(),
text: format!("msg-{id}"),
skill: None,
model_override: None,
parts_json: None,
spans_json: None,
due_at: None,
status: STATUS_PENDING.to_string(),
attempts: 0,
last_error: None,
created_at: 0,
updated_at: 0,
sent_at: None,
}
}
async fn setup() -> (Database, OutboxRepo) {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = OutboxRepo::new(&db);
(db, repo)
}
/// V51 迁移可重入:全链在已建表库上重跑 again 不报错,ai_outbox 表与两索引仍在。
#[tokio::test]
async fn migration_v51_is_reentrant() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let conn = db.conn();
let guard = conn.lock().await;
// 首次已由 open_in_memory 跑过;再显式重跑一遍全链(模拟版本号回退/崩溃重跑)。
crate::migrations::run(&guard).expect("重跑迁移不应失败(幂等)");
crate::migrations::run(&guard).expect("二次重跑仍应幂等");
// 表存在
let has_table: bool = guard
.query_row(
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='ai_outbox'",
[],
|r| r.get(0),
)
.unwrap();
assert!(has_table, "ai_outbox 表应存在");
// 两索引存在
for idx in ["idx_ai_outbox_due", "idx_ai_outbox_conv"] {
let has_idx: bool = guard
.query_row(
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='index' AND name=?1",
params![idx],
|r| r.get(0),
)
.unwrap();
assert!(has_idx, "索引 {idx} 应存在");
}
}
/// claim CAS 互斥:同一条只被占取一次,二次 claim 返回 false。
#[tokio::test]
async fn claim_is_mutually_exclusive() {
let (_db, repo) = setup().await;
repo.insert(rec("o1", "conv-1")).await.unwrap();
assert!(repo.claim("o1").await.unwrap(), "首次 claim 应成功");
assert!(!repo.claim("o1").await.unwrap(), "二次 claim 应失败(已非 pending)");
let got = repo.get_by_id("o1").await.unwrap().unwrap();
assert_eq!(got.status, STATUS_SENDING);
}
/// reset_stale_sending 复位崩溃残留:sending → pending,计数正确。
#[tokio::test]
async fn reset_stale_sending_recovers_crash_residue() {
let (_db, repo) = setup().await;
repo.insert(rec("o1", "conv-1")).await.unwrap();
repo.insert(rec("o2", "conv-1")).await.unwrap();
// 模拟两条被占取后进程崩溃(残留 sending)
assert!(repo.claim("o1").await.unwrap());
assert!(repo.claim("o2").await.unwrap());
assert_eq!(repo.get_by_id("o1").await.unwrap().unwrap().status, STATUS_SENDING);
let recovered = repo.reset_stale_sending().await.unwrap();
assert_eq!(recovered, 2, "两条残留 sending 应被复位");
assert_eq!(repo.get_by_id("o1").await.unwrap().unwrap().status, STATUS_PENDING);
assert_eq!(repo.get_by_id("o2").await.unwrap().unwrap().status, STATUS_PENDING);
}
/// list_due:只取到点的 pending(尽快项恒命中,未到点定时项不取),按 created_at 升序。
#[tokio::test]
async fn list_due_respects_due_and_order() {
let (_db, repo) = setup().await;
repo.insert(rec("o1", "conv-1")).await.unwrap();
// 未到点定时项(远未来)
let mut future = rec("o2", "conv-1");
future.kind = KIND_SCHEDULED.to_string();
future.due_at = Some(9_999_999_999_999);
repo.insert(future).await.unwrap();
// 已到点定时项
let mut due = rec("o3", "conv-1");
due.kind = KIND_SCHEDULED.to_string();
due.due_at = Some(0);
repo.insert(due).await.unwrap();
let got = repo.list_due(df_types::now_millis(), 32).await.unwrap();
let ids: Vec<&str> = got.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, vec!["o1", "o3"], "仅到点的 pending,按 created_at 升序");
}
/// revert_pending:瞬时失败回退 pending + attempts 自增 + 记 last_error,项不丢。
#[tokio::test]
async fn revert_pending_keeps_item_and_bumps_attempts() {
let (_db, repo) = setup().await;
repo.insert(rec("o1", "conv-1")).await.unwrap();
repo.claim("o1").await.unwrap();
let n = repo.revert_pending("o1", Some("boom")).await.unwrap();
assert_eq!(n, 1);
let got = repo.get_by_id("o1").await.unwrap().unwrap();
assert_eq!(got.status, STATUS_PENDING, "回退后应可再次被调度");
assert_eq!(got.attempts, 1);
assert_eq!(got.last_error.as_deref(), Some("boom"));
}
/// cancel/clear 只动 pending:已终态项不受影响,clear 按会话范围限定。
#[tokio::test]
async fn clear_cancels_only_pending_in_scope() {
let (_db, repo) = setup().await;
repo.insert(rec("o1", "conv-1")).await.unwrap();
repo.insert(rec("o2", "conv-2")).await.unwrap();
repo.mark_sent("o1").await.unwrap();
let n = repo.clear(Some("conv-1")).await.unwrap();
assert_eq!(n, 0, "conv-1 已无 pending(sent 不算),不误改");
assert_eq!(repo.get_by_id("o1").await.unwrap().unwrap().status, STATUS_SENT);
let n = repo.clear(Some("conv-2")).await.unwrap();
assert_eq!(n, 1);
assert_eq!(repo.get_by_id("o2").await.unwrap().unwrap().status, STATUS_CANCELED);
}
/// list_all:跨会话全量返回;include_done=false 排除终态,created_at 升序。
#[tokio::test]
async fn list_all_covers_convs_and_filters_done() {
let (_db, repo) = setup().await;
repo.insert(rec("o1", "conv-1")).await.unwrap();
repo.insert(rec("o2", "conv-2")).await.unwrap();
let mut done = rec("o3", "conv-1");
done.status = STATUS_SENT.to_string();
repo.insert(done).await.unwrap();
let all = repo.list_all(true).await.unwrap();
assert_eq!(all.len(), 3, "include_done=true 全量(含跨会话)");
let active = repo.list_all(false).await.unwrap();
let ids: Vec<&str> = active.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, vec!["o1", "o2"], "排除终态后仅两条 pending,升序");
}
/// requeue:仅 failed → pending 且 attempts 归零清错;pending/sent 不动返 false。
#[tokio::test]
async fn requeue_only_resets_failed() {
let (_db, repo) = setup().await;
// o1:pending → claim(占取)→ revert(attempts=1)→ mark_failed(failed,attempts 保留 1)
repo.insert(rec("o1", "conv-1")).await.unwrap();
repo.claim("o1").await.unwrap();
let attempts = repo.revert_pending("o1", Some("boom")).await.unwrap();
assert_eq!(attempts, 1);
repo.mark_failed("o1", "boom").await.unwrap();
assert!(repo.requeue("o1").await.unwrap(), "failed 应可重入队");
let got = repo.get_by_id("o1").await.unwrap().unwrap();
assert_eq!(got.status, STATUS_PENDING);
assert_eq!(got.attempts, 0, "手动重试给全新预算");
assert_eq!(got.last_error, None, "重入队清上次错误");
// pending 项:requeue 不动(返回 false)
repo.insert(rec("o2", "conv-1")).await.unwrap();
assert!(!repo.requeue("o2").await.unwrap(), "pending 非 failed,requeue 不动");
// sent 终态:requeue 不动
repo.insert(rec("o3", "conv-1")).await.unwrap();
repo.mark_sent("o3").await.unwrap();
assert!(!repo.requeue("o3").await.unwrap(), "sent 终态不可重入队");
}
}
+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 最新值"
);
}
} }
+28
View File
@@ -249,6 +249,34 @@ impl TaskRepo {
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 判定任务是否在回收站(soft_delete 已设 deleted_at)。
///
/// 专供推进链 CAS 失败后的**原因区分**:`advance_status_atomic` 的 CAS SQL 带
/// `deleted_at IS NULL` 条件,软删任务必然 affected==0 返回 None;调用方据此
/// 回查本方法,把「已变更(并发推进或旁路修改)」这个误导性错误,精确改写为
/// 「任务在回收站」,让 LLM/用户知道该先恢复而非重试。
///
/// 返回 true = 任务存在且 deleted_at 非 NULL(在回收站);false = 不存在或未删。
/// 单列轻查询(SELECT 1),不拉全记录,失败回查开销可忽略。
pub async fn is_deleted(&self, id: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare("SELECT 1 FROM tasks WHERE id = ?1 AND deleted_at IS NOT NULL")
.map_err(storage_err)?;
let found = stmt
.query_row(params![id], |_| Ok(()))
.optional()
.map_err(storage_err)?
.is_some();
Ok(found)
})
.await
.map_err(storage_err)?
}
/// 按 id 查未删除任务(`WHERE id AND deleted_at IS NULL`)。 /// 按 id 查未删除任务(`WHERE id AND deleted_at IS NULL`)。
/// ///
/// 专供 [`validate_parent_id`](super::task_validation) 的父任务存在性检查: /// 专供 [`validate_parent_id`](super::task_validation) 的父任务存在性检查:
+356 -7
View File
@@ -1,7 +1,7 @@
//! 数据库迁移 — 建表 SQL 与版本管理 //! 数据库迁移 — 建表 SQL 与版本管理
//! 数据库迁移 — 建表 SQL 与版本管理 //! 数据库迁移 — 建表 SQL 与版本管理
use crate::migrations_helpers::column_exists; use crate::migrations_helpers::{column_exists, table_exists};
use anyhow::Result; use anyhow::Result;
use rusqlite::Connection; use rusqlite::Connection;
@@ -59,7 +59,15 @@ pub fn run(conn: &Connection) -> Result<()> {
// V48 = 项目归档(projects.archived 列,归档功能 P2,同款三方对齐)。 // V48 = 项目归档(projects.archived 列,归档功能 P2,同款三方对齐)。
// V49 = loop 生命周期标记(ai_conversations.loop_state TEXT,断点续跑底座: // V49 = loop 生命周期标记(ai_conversations.loop_state TEXT,断点续跑底座:
// running/parked,重启扫描 running→parked 改写,侧栏「可恢复」标记)。 // running/parked,重启扫描 running→parked 改写,侧栏「可恢复」标记)。
let steps: [(i32, fn(&Connection) -> Result<()>); 49] = [ // V50 = 使用数据分析 schema 债补全(三项,详见 migrate_v50 文档):
// ai_conversations.status 会话终局态 / ai_tool_executions.duration_ms 纯执行耗时 /
// conversation_checkpoints.token_total 语义回填。
// V51 = 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基):队列/定时统一实体,
// 调度器按 status+due_at 索引到点派发。纯新增表,可逆=DROP TABLE ai_outbox。
// V52 = ai_messages.source 列(#958 消息溯源:外部渠道原始消息 ID JSON,NULL=桌面本端)。
// 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),
@@ -113,6 +121,14 @@ pub fn run(conn: &Connection) -> Result<()> {
(48, migrate_v48), (48, migrate_v48),
// V49 = loop 生命周期标记(ai_conversations.loop_state,断点续跑底座)。 // V49 = loop 生命周期标记(ai_conversations.loop_state,断点续跑底座)。
(49, migrate_v49), (49, migrate_v49),
// V50 = 使用数据分析 schema 债补全(status/duration_ms/token_total 三项)。
(50, migrate_v50),
// V51 = 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基)。
(51, migrate_v51),
// V52 = ai_messages 加 source 列(#958 消息溯源:飞书等外部渠道原始消息 ID)。
(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 {
@@ -1450,6 +1466,205 @@ fn migrate_v49(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
/// V50: 使用数据分析 schema 债补全(2026-10-04 实证三笔,归「V49 schema 迁移批」)。
///
/// 一次迁移承载三项相关改动,均为纯粹加列/回填,不改既有列语义:
///
/// ① ai_conversations.status TEXT NOT NULL DEFAULT 'active' —— 会话终局态。
/// 原仅靠侧栏内消息尾部推断「是否收尾」(17/89 会话停在 tool 结果无 assistant 收尾),
/// 无法直接统计未完成率。新增列建模终局:
/// active=进行中/默认(含正常完成,完成态精确写入待收尾单点接驳),incomplete=停靠/
/// 断流等未完成终局,error=循环错误终局。与 loop_state(运行期 running/parked)正交:
/// loop_state 管「能否续跑」,status 管「会话如何结束」。
/// ② ai_tool_executions.duration_ms INTEGER —— 纯执行耗时(毫秒)。
/// 原审计只有 requested_at→executed_at 区间,该区间在审批路径混入「等待人工批准」
/// 时长,耗时统计不可信。本列专记工具真正执行的墙钟毫秒(执行前→执行后),与审批
/// 等待解耦;NULL=老数据/未测路径(向后兼容)。
/// ③ conversation_checkpoints.token_total 回填 —— 原写入侧误用「消息条数」当 token 总量
/// (p50=120 vs 真实数万级,差 3 个量级)。写入侧已修为真实 token 累加(见 save 侧);
/// 本迁移对历史行按 snapshot 内逐条 prompt_tokens+completion_tokens 重算回填,使存量
/// 可比。仅当重算结果 > 0 才写(N 老快照无消息级 token 时保留原值不误清零)。
///
/// - **幂等**:①②`column_exists` 守卫,重复执行不报 duplicate column;③重算确定(同输入同输出),
/// 反复执行结果一致,且经 schema_version 版本号闸只跑一次。
/// - **可逆**:①`ALTER TABLE ai_conversations DROP COLUMN status`;
/// ②`ALTER TABLE ai_tool_executions DROP COLUMN duration_ms`;
/// ③无回退(DROP COLUMN 不会还原被覆盖的 token_total 旧值)——如需回退重建快照表
/// (老值本就失真无保留价值)。
fn migrate_v50(conn: &Connection) -> Result<()> {
// ① 会话终局态列(NOT NULL 常量默认,SQLite ALTER 合法)。
if table_exists(conn, "ai_conversations") && !column_exists(conn, "ai_conversations", "status") {
conn.execute(
"ALTER TABLE ai_conversations ADD COLUMN status TEXT NOT NULL DEFAULT 'active'",
[],
)?;
tracing::info!("v50: 补建 ai_conversations.status 列(会话终局态)");
}
// ② 纯执行耗时列(NULL=老数据/未测路径)。
if table_exists(conn, "ai_tool_executions")
&& !column_exists(conn, "ai_tool_executions", "duration_ms")
{
conn.execute(
"ALTER TABLE ai_tool_executions ADD COLUMN duration_ms INTEGER",
[],
)?;
tracing::info!("v50: 补建 ai_tool_executions.duration_ms 列(纯执行耗时)");
}
// ③ 存量 checkpoint token_total 按 snapshot 重算回填。
backfill_checkpoint_token_total(conn)?;
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [50])?;
tracing::info!("迁移 v50 完成: status/duration_ms 加列 + checkpoint token_total 回填");
Ok(())
}
/// V51: 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基)。
///
/// 对标 docs/02-架构设计/专项设计/定时消息与待发箱持久化-设计-2026-10-10.md §3.1。
/// 队列(931)与定时消息(935)本质是同一实体、不同触发条件,统一落本表:
/// 队列项 due_at=NULL(会话空闲即发),定时项 due_at=epoch_ms(到点且空闲即发)。
///
/// 选独立表而非 app_settings KV:outbox 需按 status+due_at 索引扫描、按 id 逐条 CAS 更新,
/// KV 整块 JSON 读改写有并发改期/取消互相覆盖的风险且不可索引。
///
/// - **幂等**:table_exists 守卫建表(同 V29/V30/V31 模式),索引 CREATE INDEX IF NOT EXISTS
/// 原生幂等;版本号 INSERT OR IGNORE 防崩溃重跑 PK 冲突。全链重跑/断点续跑均安全。
/// - **可逆**(回退方式,当前不执行):本表纯新增、不触碰旧数据,回退即
/// `DROP TABLE ai_outbox`(同时关调度器开关 df-outbox-enabled=false),无需数据搬迁。
/// - **状态落点**:pending|sending|sent|failed|canceled 由调度器按项逐条 CAS 写,不依赖全局
/// 内存推算(V21 教训:全局 COUNT 幂等窗口中途崩溃致永久残缺)。崩溃残留的 sending 由
/// 调度器启动时 reset_stale_sending 复位为 pending。
/// 迁移链顶点版本(单一真值来源):**新增迁移时必须同步 +1**,否则下方迁移测试会失败
/// (测试用它断言「全量迁移后 schema_version == 顶点」「重跑仍到顶点」——既防链中断,也免多处写死)。
pub const LATEST_SCHEMA_VERSION: i32 = 53;
fn migrate_v51(conn: &Connection) -> Result<()> {
if !table_exists(conn, "ai_outbox") {
conn.execute(
"CREATE TABLE ai_outbox (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'queue',
text TEXT NOT NULL,
skill TEXT,
model_override TEXT,
parts_json TEXT,
spans_json TEXT,
due_at INTEGER,
status TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
sent_at INTEGER
)",
[],
)?;
tracing::info!("v51: 建 ai_outbox 表(待发箱)");
}
// 索引覆盖两条最高频扫描:调度器 list_due(status+due_at)/ 前端按会话取视图(conversation_id+status)。
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_ai_outbox_due ON ai_outbox(status, due_at)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_ai_outbox_conv ON ai_outbox(conversation_id, status)",
[],
)?;
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [51])?;
tracing::info!("迁移 v51 完成: ai_outbox 表 + 两索引(定时消息与待发箱持久化)");
Ok(())
}
/// V52: ai_messages 加 source 列(#958 消息溯源)。TEXT JSON(MessageSource 序列化),
/// NULL=桌面本端。纯加列零回退风险(老数据恒 NULL=既有语义)。
fn migrate_v52(conn: &Connection) -> Result<()> {
if !column_exists(conn, "ai_messages", "source") {
conn.execute("ALTER TABLE ai_messages ADD COLUMN source TEXT", [])?;
tracing::info!("v52: ai_messages 加 source 列(消息溯源)");
}
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [52])?;
tracing::info!("迁移 v52 完成");
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 内逐条消息的
/// prompt_tokens+completion_tokens 求真实总量。
///
/// 幂等:重算确定,重复执行相同结果;仅当重算值 > 0 且与现值不同才 UPDATE
/// (老快照无消息级 token → 重算 0 → 保留原值,不误清零)。表/列不存在时静默跳过
/// (极老库未跑到 V37,或手工删表)。
fn backfill_checkpoint_token_total(conn: &Connection) -> Result<()> {
// 表存在性探测:未跑到 V37 的极老库无此表,PRAGMA 返回空即跳过。
if !column_exists(conn, "conversation_checkpoints", "token_total") {
return Ok(());
}
// 收集待回填行到内存再逐条 UPDATE:避免同一连接上「先 prepare 读游标、再同连接 execute 写」
// 造成的隐式锁/游标失效,分批小表可接受。
let rows: Vec<(String, String, i64)> = {
let mut stmt = conn.prepare(
"SELECT id, snapshot, token_total FROM conversation_checkpoints",
)?;
let mapped = stmt.query_map([], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, i64>(2)?))
})?;
mapped.filter_map(|r| r.ok()).collect()
};
let mut updated = 0usize;
for (id, snapshot, old_total) in rows {
let recomputed = sum_snapshot_tokens(&snapshot);
if recomputed > 0 && recomputed != old_total {
conn.execute(
"UPDATE conversation_checkpoints SET token_total = ?1 WHERE id = ?2",
rusqlite::params![recomputed, id],
)?;
updated += 1;
}
}
if updated > 0 {
tracing::info!("v50: checkpoint token_total 回填 {updated} 行(消息条数→真实 token)");
}
Ok(())
}
/// 从消息 JSON 数组求 token 总量:逐条取 prompt_tokens + completion_tokens(UInt64)
/// 求和。字段缺失/类型不符按 0 计(老消息无消息级 token → 贡献 0)。解析失败返回 0。
///
/// 用 `serde_json::Value` 裸取(df-storage 不依赖 df-ai-core 的 ChatMessage,同 V21 迁移模式)。
fn sum_snapshot_tokens(snapshot: &str) -> i64 {
let Ok(msgs) = serde_json::from_str::<Vec<serde_json::Value>>(snapshot) else {
return 0;
};
msgs.iter()
.map(|m| {
let p = m.get("prompt_tokens").and_then(|v| v.as_i64()).unwrap_or(0);
let c = m.get("completion_tokens").and_then(|v| v.as_i64()).unwrap_or(0);
p + c
})
.sum()
}
/// V21 建表 SQL — 消息拆分存储 ai_messages 表 /// V21 建表 SQL — 消息拆分存储 ai_messages 表
/// ///
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。 /// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
@@ -2478,9 +2693,9 @@ mod tests {
) )
.expect("查 schema_version 应成功"); .expect("查 schema_version 应成功");
assert_eq!( assert_eq!(
max_version, 49, max_version, LATEST_SCHEMA_VERSION as i64,
"全量迁移后 schema_version 应为 49(实际 {}),说明某条 migrate_vN 链路断在中间", "全量迁移后 schema_version 应为顶点 {}(实际 {}),说明某条 migrate_vN 链路断在中间",
max_version LATEST_SCHEMA_VERSION, max_version
); );
// 4. V40 抽查:project_modules 表 description / status 列存在(老库 ALTER 补,新库 V40 也跑) // 4. V40 抽查:project_modules 表 description / status 列存在(老库 ALTER 补,新库 V40 也跑)
@@ -2556,7 +2771,7 @@ mod tests {
|r| r.get(0), |r| r.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(max_v, 49, "首轮应推进到 49"); assert_eq!(max_v, LATEST_SCHEMA_VERSION as i64, "首轮应推进到 {}", LATEST_SCHEMA_VERSION);
// 清空版本表强制全链第二遍(每步 execute 第二次) // 清空版本表强制全链第二遍(每步 execute 第二次)
conn.execute("DELETE FROM schema_version", []).unwrap(); conn.execute("DELETE FROM schema_version", []).unwrap();
@@ -2568,7 +2783,7 @@ mod tests {
|r| r.get(0), |r| r.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(max_v2, 49, "重跑后应重新推进到 49"); assert_eq!(max_v2, LATEST_SCHEMA_VERSION as i64, "重跑后应重新推进到 {}", LATEST_SCHEMA_VERSION);
} }
// ============================================================ // ============================================================
@@ -2674,4 +2889,138 @@ mod tests {
.unwrap(); .unwrap();
assert!(got.is_some(), "boundary_json 应可写可读"); assert!(got.is_some(), "boundary_json 应可写可读");
} }
/// V50: 三列/回填一次承载,幂等重跑不炸。
/// ① ai_conversations.status 补建默认 'active';
/// ② ai_tool_executions.duration_ms 补建(可空);
/// ③ conversation_checkpoints.token_total 按 snapshot 重算(消息条数→真实 token)。
#[test]
fn v50_adds_columns_and_backfills_token_total() {
let conn = setup_legacy_db();
// 前置:最小库无 status/duration_ms 列(V45 之后建,老库缺)
assert!(!column_exists(&conn, "ai_conversations", "status"));
// V50 需要 checkpoint / tool_executions 表(正常由 V1/V9 建;此处手工建以隔离本迁移被测量)
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS conversation_checkpoints (\
id TEXT PRIMARY KEY, conv_id TEXT NOT NULL, snapshot TEXT NOT NULL,\
token_total INTEGER NOT NULL, label TEXT, created_at TEXT NOT NULL);
INSERT INTO conversation_checkpoints (id, conv_id, snapshot, token_total, created_at)\
VALUES ('ck1','c1',\
'[{\"role\":\"assistant\",\"prompt_tokens\":150000,\"completion_tokens\":20000}]',\
1,'100');
CREATE TABLE IF NOT EXISTS ai_tool_executions (\
id TEXT PRIMARY KEY, tool_call_id TEXT NOT NULL, tool_name TEXT NOT NULL,\
arguments TEXT NOT NULL, requested_at TEXT NOT NULL);",
)
.expect("建 checkpoint/tool_executions 表 + 塞一条失真行");
migrate_v50(&conn).expect("v50 首次迁移应成功");
assert!(column_exists(&conn, "ai_conversations", "status"), "应有 status 列");
assert!(column_exists(&conn, "ai_tool_executions", "duration_ms"), "应有 duration_ms 列");
// ① 默认值 active 生效
conn.execute(
"INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES ('c1','[]','0','0')",
[],
)
.unwrap();
let status: String = conn
.query_row("SELECT status FROM ai_conversations WHERE id = 'c1'", [], |r| r.get(0))
.unwrap();
assert_eq!(status, "active", "新列默认值应为 active");
// ③ 失真 token_total(1)被回填为真实和(150000+20000)
let tt: i64 = conn
.query_row("SELECT token_total FROM conversation_checkpoints WHERE id = 'ck1'", [], |r| r.get(0))
.unwrap();
assert_eq!(tt, 170_000, "应回填为 snapshot 内 token 真实和");
let v: i64 = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, 50, "应写入版本号 50");
// 幂等:列已存在时重跑走 column_exists 短路;回填重算确定(值不变)
migrate_v50(&conn).expect("列已存在时重跑应幂等不报错");
let tt2: i64 = conn
.query_row("SELECT token_total FROM conversation_checkpoints WHERE id = 'ck1'", [], |r| r.get(0))
.unwrap();
assert_eq!(tt2, 170_000, "重跑回填结果应一致");
}
/// V50 回填不误清零:老快照无消息级 token(重算=0)时保留原值(不 UPDATE)。
#[test]
fn v50_backfill_keeps_legacy_when_no_message_tokens() {
let conn = setup_legacy_db();
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS conversation_checkpoints (\
id TEXT PRIMARY KEY, conv_id TEXT NOT NULL, snapshot TEXT NOT NULL,\
token_total INTEGER NOT NULL, label TEXT, created_at TEXT NOT NULL);
INSERT INTO conversation_checkpoints (id, conv_id, snapshot, token_total, created_at)\
VALUES ('ck2','c2','[{\"role\":\"user\",\"content\":\"hi\"}]', 42, '100');",
)
.expect("建表塞老快照");
migrate_v50(&conn).expect("v50 应成功");
let tt: i64 = conn
.query_row("SELECT token_total FROM conversation_checkpoints WHERE id = 'ck2'", [], |r| r.get(0))
.unwrap();
assert_eq!(tt, 42, "无消息级 token 的老快照应保留原值不误清零");
}
/// sum_snapshot_tokens 纯函数:字段缺失/非法 JSON 按 0 计。
#[test]
fn sum_snapshot_tokens_handles_missing_and_bad_json() {
assert_eq!(sum_snapshot_tokens("not json"), 0, "非法 JSON 应返回 0");
assert_eq!(sum_snapshot_tokens("[]"), 0, "空数组应为 0");
assert_eq!(
sum_snapshot_tokens(r#"[{"prompt_tokens":10},{"completion_tokens":5},{"foo":1}]"#),
15,
"逐条相加,缺字段按 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)");
}
} }
@@ -14,6 +14,19 @@ pub(crate) fn column_exists(conn: &Connection, table: &str, col: &str) -> bool {
false false
} }
/// 检测表是否存在(幂等迁移辅助)。
///
/// [`column_exists`] 对不存在的表与缺列都返回 false,无法区分——迁移里「加列」需先确认
/// 表在(否则 ALTER 报 no such table)。sqlite_master 精确查名(非 LIKE,避免前缀误命中)。
pub(crate) fn table_exists(conn: &Connection, table: &str) -> bool {
conn.query_row(
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type = 'table' AND name = ?1",
[table],
|r| r.get::<_, bool>(0),
)
.unwrap_or(false)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -74,4 +87,16 @@ mod tests {
assert!(!column_exists(&conn, "probe_t", "prompt")); assert!(!column_exists(&conn, "probe_t", "prompt"));
assert!(!column_exists(&conn, "probe_t", "model_configs")); assert!(!column_exists(&conn, "probe_t", "model_configs"));
} }
/// table_exists 精确匹配表名,不误命中前缀/近似名(与 column_exists 区分表缺失与缺列)。
#[test]
fn table_exists_matches_exact_name() {
let conn = Connection::open_in_memory().expect("open in-memory db");
conn.execute_batch("CREATE TABLE probe_t (id TEXT);")
.expect("create probe table");
assert!(table_exists(&conn, "probe_t"));
assert!(!table_exists(&conn, "probe"), "不误命中前缀");
assert!(!table_exists(&conn, "probe_t2"), "不误命中扩展名");
assert!(!table_exists(&conn, "ghost"), "未建表返回 false");
}
} }
+45
View File
@@ -396,6 +396,15 @@ pub struct AiConversationRecord {
/// 纯元数据标记不进 update_full 通用 UPDATE,走专用 set_loop_state。 /// 纯元数据标记不进 update_full 通用 UPDATE,走专用 set_loop_state。
#[serde(default)] #[serde(default)]
pub loop_state: Option<String>, pub loop_state: Option<String>,
/// 会话终局态(V50):active=进行中/默认(含正常完成),incomplete=停靠/断流等未完成终局,
/// error=循环错误终局。与 loop_state(运行期 running/parked,管「能否续跑」)正交——
/// 本列回答「会话如何结束」,供未完成率等统计。不进 update_full,走专用 set_status 单点写。
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 工具执行审计记录
@@ -419,6 +428,11 @@ pub struct AiToolExecutionRecord {
pub requested_at: String, pub requested_at: String,
pub executed_at: Option<String>, pub executed_at: Option<String>,
pub decided_by: Option<String>, // human/auto pub decided_by: Option<String>, // human/auto
/// 纯执行耗时(毫秒,V50):工具真正执行的墙钟时长(执行前→执行后),与审批等待解耦。
/// None=老数据 / 未测路径(缓存命中/未真执行)。审计面板耗时统计用本列,替代
/// requested_at→executed_at(后者在审批路径混入等待人工批准时长)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<i64>,
} }
/// 消息记录(ai_messages 表,消息拆分存储)。 /// 消息记录(ai_messages 表,消息拆分存储)。
@@ -470,6 +484,37 @@ pub struct AiMessageRecord {
/// 本轮 prompt 是否估算值(round_usage.prompt_tokens==0 → estimated_prompt 兜底打标)。 /// 本轮 prompt 是否估算值(round_usage.prompt_tokens==0 → estimated_prompt 兜底打标)。
/// reload 逐条回显「估算」标注;老消息 NULL → None(向前兼容)。 /// reload 逐条回显「估算」标注;老消息 NULL → None(向前兼容)。
pub is_estimated: Option<bool>, pub is_estimated: Option<bool>,
/// 消息来源 JSON(#958 溯源,MessageSource 序列化;V52 加列)。
/// NULL=桌面本端(既有语义);非空=外部渠道(飞书 message_id 等,可跳原消息追溯)
pub source: Option<String>,
}
/// 待发箱记录(ai_outbox 表,V51)。
///
/// 队列与定时消息统一实体:队列项 due_at=NULL(会话空闲即发),定时项 due_at=epoch_ms(到点且空闲即发)。
/// 表是唯一真相源(前端内存队列退化为该会话 outbox 的只读视图)。设计见
/// docs/02-架构设计/专项设计/定时消息与待发箱持久化-设计-2026-10-10.md §3.1。
///
/// kind:queue(尽快)| scheduled(定时);预留 task(937 对话内定时任务)。
/// status:pending|sending|sent|failed|canceled。时间戳均 epoch 毫秒(存储层不转字符串本表特例:
/// due_at 需按数值比较排序,created_at/updated_at/sent_at 同为毫秒整数便于调度器直接比较)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AiOutboxRecord {
pub id: String,
pub conversation_id: String,
pub kind: String,
pub text: String,
pub skill: Option<String>,
pub model_override: Option<String>,
pub parts_json: Option<String>,
pub spans_json: Option<String>,
pub due_at: Option<i64>,
pub status: String,
pub attempts: i64,
pub last_error: Option<String>,
pub created_at: i64,
pub updated_at: i64,
pub sent_at: Option<i64>,
} }
// ============================================================ // ============================================================
+41
View File
@@ -11,12 +11,25 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// 当前 Hello 握手协议版本。
///
/// 协商规则:旧客户端 Hello 缺 `proto` 字段 → 视为 v1(向后兼容);
/// 高于对端支持版本时由 peer 记录告警,为后续破坏性变更预留通知位。
pub const HELLO_PROTO_VERSION: u32 = 1;
/// `proto` 缺省值:旧客户端不带该字段时按 v1 处理(向后兼容)。
fn default_proto() -> u32 {
HELLO_PROTO_VERSION
}
/// 握手身份宣告帧(对齐 u-relay::relay::Hello) /// 握手身份宣告帧(对齐 u-relay::relay::Hello)
/// ///
/// 桌面端 WS 连 `/ws/agent` 后,首条消息发此 JSON,relay 校验 kind 与 token。 /// 桌面端 WS 连 `/ws/agent` 后,首条消息发此 JSON,relay 校验 kind 与 token。
/// 字段名/serde rename 与 u-relay 严格一致(`#[serde(rename_all="snake_case")]`), /// 字段名/serde rename 与 u-relay 严格一致(`#[serde(rename_all="snake_case")]`),
/// 否则 relay 反序列化失败 → 握手被拒。 /// 否则 relay 反序列化失败 → 握手被拒。
/// ///
/// `proto` 为协议版本,供 relay 端做版本协商(缺字段按 v1,新增破坏性变更时递增)。
///
/// 注意:本 crate 不依赖 u-relay(避免 lib→server 反向依赖),自行镜像协议结构。 /// 注意:本 crate 不依赖 u-relay(避免 lib→server 反向依赖),自行镜像协议结构。
/// 协议字段如有变更须同步 u-relay::relay::Hello 与本类型。 /// 协议字段如有变更须同步 u-relay::relay::Hello 与本类型。
/// ///
@@ -30,6 +43,9 @@ pub struct Hello {
pub channel_id: String, pub channel_id: String,
/// 配对 token(env U_RELAY_TOKEN 同源) /// 配对 token(env U_RELAY_TOKEN 同源)
pub token: String, pub token: String,
/// 协议版本(缺省按 v1,兼容旧客户端)
#[serde(default = "default_proto")]
pub proto: u32,
} }
/// Hello.kind 传输表示(serde 字符串,与 u-relay::ClientKindWire 一致) /// Hello.kind 传输表示(serde 字符串,与 u-relay::ClientKindWire 一致)
@@ -155,3 +171,28 @@ pub enum TunnelMessage {
/// 云后端转发的指令(上行,发往桌面端) /// 云后端转发的指令(上行,发往桌面端)
Command(TunnelCommand), Command(TunnelCommand),
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hello_proto_defaults_to_v1_when_absent() {
// 旧客户端不发 proto → 反序列化按 v1,保证向后兼容
let json = r#"{"kind":"agent","channel_id":"c1","token":"t"}"#;
let h: Hello = serde_json::from_str(json).expect("缺 proto 应可解析");
assert_eq!(h.proto, HELLO_PROTO_VERSION);
}
#[test]
fn hello_serializes_proto_field() {
let h = Hello {
kind: HelloKind::Agent,
channel_id: "c1".into(),
token: "t".into(),
proto: HELLO_PROTO_VERSION,
};
let v: serde_json::Value = serde_json::to_value(&h).unwrap();
assert_eq!(v["proto"], HELLO_PROTO_VERSION);
}
}
+11 -5
View File
@@ -38,7 +38,7 @@ use tokio_tungstenite::tungstenite::protocol::CloseFrame;
use tokio_tungstenite::tungstenite::Message as WsMessage; use tokio_tungstenite::tungstenite::Message as WsMessage;
use crate::error::{Result, TunnelError}; use crate::error::{Result, TunnelError};
use crate::events::{Hello, HelloKind, RelayControlError, TunnelEvent}; use crate::events::{Hello, HelloKind, RelayControlError, TunnelEvent, HELLO_PROTO_VERSION};
/// 收到 commander→agent 指令 payload 时的回调类型(Boxed Future,在收发循环 task 内 await) /// 收到 commander→agent 指令 payload 时的回调类型(Boxed Future,在收发循环 task 内 await)
/// ///
@@ -105,9 +105,13 @@ const HANDSHAKE_PROBE: Duration = Duration::from_secs(3);
/// 包 `tokio::time::timeout` 在此窗口内未完成 → 返 TunnelError::Connect 显式错误。 /// 包 `tokio::time::timeout` 在此窗口内未完成 → 返 TunnelError::Connect 显式错误。
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
/// 心跳间隔(应用层 Ping,补协议层 keepalive,防 NAT 连接表超时回收) /// 心跳间隔(WS 协议层 Ping,补 TCP keepalive,防 NAT/反代空闲回收连接)。
/// u-relay 心跳走应用层 control 帧 ping→pong,relay 本地直回不转对端 ///
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(25); /// 上界约束:链路最短空闲超时在反代侧(生产 nginx `proxy_read_timeout`,配置样例见
/// u-relay wire 协议文档 §5.2,最小 300s),间隔须显著小于它并留余量——容忍数次心跳
/// 丢失或网络抖动仍不被判定空闲断链。取 30s:满足约束,且与 commander 参考实现
/// (u-ppt 30s ping)节拍一致,避免两端看门狗口径不一导致误重连。
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
/// 重连初始退避 /// 重连初始退避
const RECONNECT_BASE: Duration = Duration::from_secs(1); const RECONNECT_BASE: Duration = Duration::from_secs(1);
@@ -201,7 +205,8 @@ impl TunnelClient for WsTunnelClient {
))); )));
} }
}; };
tracing::info!(%url, "WS 连接已建立,开始 Hello 握手"); // 版本协商(本端宣告):Hello 携带 proto,供 relay 侧比对决定兼容性
tracing::info!(%url, proto = HELLO_PROTO_VERSION, "WS 连接已建立,开始 Hello 握手");
let (mut sink, mut stream) = ws_stream.split(); let (mut sink, mut stream) = ws_stream.split();
@@ -210,6 +215,7 @@ impl TunnelClient for WsTunnelClient {
kind: HelloKind::Agent, kind: HelloKind::Agent,
channel_id: channel_id.to_string(), channel_id: channel_id.to_string(),
token: token.to_string(), token: token.to_string(),
proto: HELLO_PROTO_VERSION,
}; };
let hello_json = serde_json::to_string(&hello)?; let hello_json = serde_json::to_string(&hello)?;
sink.send(WsMessage::Text(hello_json)).await.map_err(|e| { sink.send(WsMessage::Text(hello_json)).await.map_err(|e| {
+4
View File
@@ -315,6 +315,10 @@ impl ProjectStatus {
/// ///
/// Input Augmentation 层 ProjectResolver/TaskResolver 把记录的 String status 投影成 /// Input Augmentation 层 ProjectResolver/TaskResolver 把记录的 String status 投影成
/// 类型化枚举用。未知字符串返 None。 /// 类型化枚举用。未知字符串返 None。
///
/// ⚠️ TYPES-2:本函数返 None 是显式信号,但若调用方用 `unwrap_or_default()` 兜底,
/// 未知值会**静默归 Planning**(ProjectStatus::default),掩盖数据异常。建议调用方对
/// None 分支记 warn 审计(而非静默兜底),便于发现脏 status。
pub fn from_db_str(s: &str) -> Option<Self> { pub fn from_db_str(s: &str) -> Option<Self> {
Some(match s { Some(match s {
"planning" => ProjectStatus::Planning, "planning" => ProjectStatus::Planning,
+18
View File
@@ -30,6 +30,10 @@ use df_types::types::{NodeId, NodeStatus};
pub struct StateMachine { pub struct StateMachine {
/// 节点状态映射(Arc 共享,clone 浅拷贝同一 HashMap) /// 节点状态映射(Arc 共享,clone 浅拷贝同一 HashMap)
states: Arc<Mutex<HashMap<NodeId, NodeStatus>>>, states: Arc<Mutex<HashMap<NodeId, NodeStatus>>>,
/// 取消信号唤醒器(FR-P4):set_cancelled 时 notify_waiters,
/// 阻塞节点(HumanNode)用 notified() 被动等待,替代 500ms 轮询。
/// 与 states 同 Arc 共享,clone 浅拷贝同一 Notify。
cancel_notify: Arc<tokio::sync::Notify>,
} }
impl StateMachine { impl StateMachine {
@@ -37,9 +41,19 @@ impl StateMachine {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
states: Arc::new(Mutex::new(HashMap::new())), states: Arc::new(Mutex::new(HashMap::new())),
cancel_notify: Arc::new(tokio::sync::Notify::new()),
} }
} }
/// 获取取消信号等待器(FR-P4)
///
/// 返回的 `Notified` future 在 `set_cancelled` 被调用时 resolve。
/// 调用方应配合 `is_cancelled` 做条件判断(Notify 无状态,可能错过
/// 早于 await 的 notify,故 select! 分支 resolve 后仍须复查 is_cancelled)。
pub fn cancel_notified(&self) -> tokio::sync::futures::Notified<'_> {
self.cancel_notify.notified()
}
/// 获取节点状态 /// 获取节点状态
/// ///
/// 锁中毒时降级返回 `NodeStatus::Pending`(保守默认:视为未启动, /// 锁中毒时降级返回 `NodeStatus::Pending`(保守默认:视为未启动,
@@ -139,6 +153,10 @@ impl StateMachine {
); );
} }
} }
// FR-P4:唤醒所有等待取消信号的阻塞节点(HumanNode notified 分支)。
// notify_waiters 不持锁调用,避免与 states 锁耦合;Notify 无状态,
// 等待方 resolve 后仍复查 is_cancelled 兜底(防 notify 早于 await 丢失)。
self.cancel_notify.notify_waiters();
} }
/// 获取所有状态快照(clone 返回,调用方持独立副本) /// 获取所有状态快照(clone 返回,调用方持独立副本)
@@ -0,0 +1,246 @@
# DevFlow 签名与发布 SOP
> 2026-10-10 制定。适用对象:上游发布者(你)。目标:让「签名 → 打包 → 发布清单 → 客户端自动升级」这条链**可重复执行、可验证、密钥可控**。
> 相关文档:《版本更新与自动升级-设计-2026-10-09.md》(链路设计)、《团队级基建-部署与运维-2026-10-05.md》(分发设施)。
---
## 一、密钥体系一句话
DevFlow 用 **minisign / ed25519** 非对称签名:
| 角色 | 文件 | 位置 | 作用 |
|---|---|---|---|
| 私钥(绝密) | `devflow.key` | `%USERPROFILE%\.tauri\` | 签名安装包,**泄露=可伪造升级** |
| 公钥(公开) | `devflow.key.pub` | 同上 | 验签,**同时硬编码进客户端** |
| 公钥副本 | `pubkey` 字段 | `src-tauri/tauri.conf.json` `plugins.updater.pubkey` | 编译进 exe,客户端用它验签 |
**核心约束**:客户端 exe 里的公钥是**编译期固化**的。换密钥 = 换公钥 = 已分发的老客户端**永远无法自动升级到新密钥签的包**(只能手动重装)。所以密钥轮换是**断链级操作**,非必要不做。
---
## 二、密钥文件格式(排障必知)
Tauri 私钥文件是 **base64 文本包 base64** 的两层结构:
```
外层 base64 → 解码得文本,两行:
L1 untrusted comment: ...
L2 <内层 base64> → 解码得 payload 字节
```
payload 布局:
| 长度 | 类型 | 含义 |
|---|---|---|
| 106 B | 明文(未加密) | `sig_alg(2)+keynum(8)+sk(64)+ck(32)` |
| 158 B | 加密(Scrypt KDF 段) | `sig_alg(2)+kdf_alg(2)+ck_alg(2)+salt(32)+opslimit(8)+memlimit(8)+keynum(8)+sk(64)+ck(32)` |
> ⚠️ **`cargo tauri signer generate` 产出的都是 158 B(带 KDF 段)**——即使你「没设密码」,结构上仍是加密布局,只是口令为空。所以**不能用文件大小判断是否设了密码**,必须用「空密码能否签名」实测(见 §六 自检)。
**公钥** payload 固定 42 B:`sig_alg(2)+keynum(8)+key(32)`。`keynum = payload[2:10]`,是**密钥指纹**,用于快速比对「配置公钥 ↔ 私钥 ↔ 签名」三者是否同一把钥匙。
---
## 三、密钥生命周期操作
### 3.1 生成(首次或轮换)
```powershell
# 无密码(不推荐,当前生产就是这个状态)
cargo tauri signer generate -w "$env:USERPROFILE\.tauri\devflow.key"
# 带密码(推荐)
cargo tauri signer generate -p "<强口令>" -w "$env:USERPROFILE\.tauri\devflow.key"
```
生成后把 `.pub` 的内容填进 `src-tauri/tauri.conf.json` 的 `plugins.updater.pubkey`,然后**重新编译客户端**才生效。
### 3.2 备份(强制)
私钥丢失 = 无法再签发升级包 = 只能原地冻版。**生成后立刻**:
```
1. 复制 devflow.key / devflow.key.pub 到离线介质(U 盘 / 加密卷)
2. 记录密钥指纹(keynum),见 §六
3. 关键:口令单独记录(不要和私钥同处存储)
```
### 3.3 轮换(断链级,慎用)
```
代价:已分发的所有客户端自动升级链断裂 → 必须手动重装
步骤:
1. 生成新密钥对 → 旧密钥改名归档(不要删,历史包验签要用)
2. 更新 tauri.conf.json pubkey → 重新编译 → 发布「桥接版」
(桥接版仍用旧私钥签,老客户端能升上来;桥接版内含新公钥,之后再用新私钥签)
3. 桥接版铺开 ≥1 个版本周期后,才切新私钥签发
```
> 单管理员自用场景可简化:接受「一次手动重装」,直接换。对外分发则**必须走桥接版**。
---
## 四、发布流程(一键脚本)
```powershell
cd E:\wk-lab\devflow
.\scripts\publish-release.ps1 # 完整发布(含 tauri build)
.\scripts\publish-release.ps1 -Notes "修复xx" # 手动指定更新说明
.\scripts\publish-release.ps1 -SkipBuild # 复用已有产物,只生成清单+上传
```
脚本六步(每步失败即 `exit 1`,不静默):
| 步 | 动作 | 失败含义 |
|---|---|---|
| 1 | **版本三源校验**(Cargo.toml / tauri.conf.json / package.json) | 三处版本号不一致,先对齐(见 §七 坑 2) |
| 2 | 签名私钥就位检查(env 优先,回退 `~/.tauri/devflow.key`) | 私钥缺失 |
| 3 | `cargo tauri build`(产出 `devflow_<ver>_x64-setup.exe` + `.exe.sig`) | 编译/打包失败 |
| 4 | 生成 `latest.json`(**UTF-8 无 BOM**) | — |
| 5 | 上传 `ssh-proxy` → `suke-dev:/var/www/app-site/download/devflow/` | SSH/上传失败 |
| 6 | 远端 `ls` + `cat latest.json` 回显验证 | 上传未落盘 |
**发布清单契约**(`latest.json`):
```json
{
"version": "0.1.2",
"notes": "...",
"pub_date": "2026-10-10T14:00:00Z",
"platforms": {
"windows-x86_64": {
"signature": "<.exe.sig 全文>",
"url": "https://app.1216.top/download/devflow/devflow_0.1.2_x64-setup.exe"
}
}
}
```
`app.1216.top` 已配 `no-cache`,改完即时生效,无需刷 CDN。
---
## 五、手工签名(脚本之外,补签/演练用)
```powershell
# 无密码密钥
cargo tauri signer sign -f "$env:USERPROFILE\.tauri\devflow.key" -p "" --app-version 0.1.2 <file>
# 带密码密钥(口令走参数或环境变量,勿写进脚本)
cargo tauri signer sign -f "$env:USERPROFILE\.tauri\devflow.key" -p "<口令>" --app-version 0.1.2 <file>
# 环境变量方式(CI 友好)
$env:TAURI_SIGNING_PRIVATE_KEY = "$env:USERPROFILE\.tauri\devflow.key"
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = "<口令>"
```
> **务必带 `--app-version`**:它把版本号写进签名的 trusted comment 并被签名覆盖。若客户端/服务端开启 `requireSignedVersion`,清单里改版本号就会被拒——这是防「篡改清单把新版号配旧包」的关键绑定。`cargo tauri build` 会自动带上,手工签要自己带。
---
## 六、验证(发布后必做)
### 6.1 一键验签(推荐)
```powershell
node temp/verify_sig.mjs
```
输出:配置公钥 keynum / 每个 exe 的 keynum 匹配性 / **ed25519 实际验签结果** / `latest.json` 与 `.sig` 内容一致性。
验签数学:payload 首 2 字节 `ED` 表示 **blake2b512 预哈希**模式,即 `verify(blake2b512(file), sig)`;`Ed` 表示直接对原文。
### 6.2 密钥指纹自检
```powershell
python temp/sign_state_check.py # 三源 keynum 对照
python temp/key_layout_final.py # 私钥格式(106/158B)判定
```
判据:**配置公钥 keynum == 预期私钥对应 keynum**。不等 = 拿错钥匙,签出来的包客户端一律拒。
### 6.3 「私钥是否设了密码」实测(唯一可信方法)
```powershell
$probe = "$env:TEMP\pw_probe.txt"; "x" | Set-Content $probe -NoNewline
cargo tauri signer sign -f "$env:USERPROFILE\.tauri\devflow.key" -p "" "$probe" ; $LASTEXITCODE
# exit=0 → 无密码保护;exit≠0 且报 incorrect password → 有密码
```
---
## 七、踩坑记录(实证)
**坑 1 — `latest.json` 的 UTF-8 BOM**
Windows PowerShell 5.1 的 `Out-File -Encoding utf8` / `Set-Content -Encoding utf8` **会写 BOM**(`EF BB BF`)。JSON 解析器遇到 BOM 报 `expected value at line 1 column 1`,表现为「签名没错、包没错,updater 就是拉不到清单」。
✅ 修正:`[System.IO.File]::WriteAllText($path, $json, (New-Object System.Text.UTF8Encoding($false)))`。已固化进 `publish-release.ps1`。
**坑 2 — 版本号三源分叉**
版本号存在于三处:`src-tauri/Cargo.toml`(Rust crate / `env!("CARGO_PKG_VERSION")`)、`src-tauri/tauri.conf.json`(打包器实际使用)、`package.json`(前端)。
任一处漏改 → 设置页显示与实际安装版本不符(灵异现象)。
✅ 修正:`publish-release.ps1` 第 1 步**硬校验三源一致**,不一致直接拒发。2026-10-10 已修 `package.json` 0.1.1 → 0.1.2。
**坑 3 — 发布脚本硬编码版本号**
`swap-install.ps1` 曾硬编码 `devflow_0.1.1_x64-setup.exe`。升版忘改 → 若旧包残留同目录,**静默装回旧版**(伪成功,最危险)。
✅ 修正:改 glob 探测 + `Sort-Object LastWriteTime -Descending | Select-Object -First 1`。
**坑 4 — 用文件大小判密码**
`cargo tauri signer generate` 产出的私钥**一律 158 B**(带 Scrypt KDF 段),无密码时只是口令为空。按大小判断会误判。
✅ 修正:用 §6.3 空密码实签判定。
**坑 5 — 密钥文件被误覆盖**
2026-10-10 曾发生野密钥覆盖生产密钥,靠备份 `*.bak-20261010-213046` 恢复。
✅ 教训:**动密钥前先归档**(`restore-key.ps1` 的归档-恢复-校验三步式可复用);密钥目录加 ACL 防误改(见 §八)。
---
## 八、安全加固(已实施 2026-10-10)
**威胁模型**:任何能读到 `devflow.key` 的本地进程,都能签发被客户端信任的更新包 → 推恶意升级。
已实施的缓解(见 `temp/harden_key_acl.ps1`):
```
%USERPROFILE%\.tauri\ 阻断继承,仅 (当前用户 / SYSTEM)
%USERPROFILE%\.tauri\devflow.key* 阻断继承,仅 (当前用户 读写删 / SYSTEM 全控)
```
**仍未消除的风险(重要)**:私钥当前**无口令保护**,文件本身即等价于签名能力;ACL 只挡了「非授权本地用户」,挡不住「以当前用户身份运行的程序」。
**根治方案(需拍板,有代价)**:
| 方案 | 强度 | 代价 |
|---|---|---|
| A. 私钥加口令 | 中 | 必须**重新生成密钥对**(`signer` 无 reencrypt 子命令)→ 公钥变 → **换公钥** → 见 §3.3 断链(老客户端需手动重装) |
| B. 私钥迁离线介质 | 高 | 每次发布需插盘;CI 不可用 |
| C. A+B 组合 | 最高 | 同上,推荐对外分发场景 |
| D. 仅 ACL(现状) | 低 | 零代价,只防误改/误读 |
> 现状为 D。若 DevFlow 仅自用,D 可接受;一旦对外分发,建议升到 C。
---
## 九、应急:私钥泄露怎么办
```
1. 立即停发:删除远端 latest.json(客户端拉不到清单即停止升级链)
2. 评估:泄露时间窗内是否有未授权包被签(检查远端目录文件时间戳)
3. 轮换:按 §3.3 生成新密钥对 → 发布桥接版(旧私钥签)→ 铺开后切新私钥
4. 通告:对外分发场景需告知用户手动重装
```
**注意:`删除 latest.json` 是唯一的「远端急停」手段**——客户端只在 check 时拉清单,清单不在 = 无法升级。
---
## 十、日常检查清单
发布前:
- [ ] 版本三源一致(脚本第 1 步自动校验)
- [ ] 私钥就位且指纹与配置公钥一致(`node temp/verify_sig.mjs` 的 keynum 对照)
- [ ] 密钥已备份(首次/轮换后)
发布后:
- [ ] `node temp/verify_sig.mjs` 全绿(验签 + latest.json 对账)
- [ ] 远端 `curl https://app.1216.top/download/devflow/latest.json` 可取且 version 正确
- [ ] 老客户端实测一次「检查更新 → 下载 → 安装 → 重启后版本号变新」(B3 端到端)
+29 -2
View File
@@ -4,7 +4,7 @@
> 上级索引:[../INDEX.md](../INDEX.md) > 上级索引:[../INDEX.md](../INDEX.md)
> 命名规范:见本目录 [文档记录规范-2026-06-14.md](./滚动规范/文档记录规范-2026-06-14.md) §九「文档命名规范」 > 命名规范:见本目录 [文档记录规范-2026-06-14.md](./滚动规范/文档记录规范-2026-06-14.md) §九「文档命名规范」
本目录是 DevFlow 的**架构设计 / 设计决策 / 设计方案 / 构想**文档集合(不含实现流水,流水走 `PROGRESS.md`;不含审查报告,审查走 `05-代码审查/`)。 本目录是 DevFlow 的**架构设计 / 设计决策 / 设计方案 / 构想**文档集合(不含实现流水,流水走 [`../07-项目管理/PROGRESS.md`](../07-项目管理/PROGRESS.md);不含审查报告,审查走 `05-代码审查/`)。
按**性质**分四类,每类一个子目录: 按**性质**分四类,每类一个子目录:
@@ -23,7 +23,7 @@
| 文档 | 状态 | 核心内容 | | 文档 | 状态 | 核心内容 |
|---|---|---| |---|---|---|
| [业务系统设计-2026-06-12.md](./滚动规范/业务系统设计-2026-06-12.md) | 🏗 ARCHITECTURE 载体 | 项目无独立 ARCHITECTURE.md,本文档为实质载体:产品定位 / crate 结构 / 数据模型 / Phase 规划 | | [业务系统设计-2026-06-12.md](./滚动规范/业务系统设计-2026-06-12.md) | 📐 设计 | 产品定位 / crate 结构 / 数据模型 / Phase 规划。项目架构总纲见根 [ARCHITECTURE.md](../../ARCHITECTURE.md) |
| [Phase1架构决策-2026-06-12.md](./滚动规范/Phase1架构决策-2026-06-12.md) | 📐 ADR | Phase 1 引擎骨架阶段架构级选型(ADR-001 引擎不绑定业务 等) | | [Phase1架构决策-2026-06-12.md](./滚动规范/Phase1架构决策-2026-06-12.md) | 📐 ADR | Phase 1 引擎骨架阶段架构级选型(ADR-001 引擎不绑定业务 等) |
| [功能决策记录-2026-06-14.md](./滚动规范/功能决策记录-2026-06-14.md) | 📋 滚动 | 功能**需求规格 + 设计决策规格**(为什么这么定 / 要做什么)。最高频引用(67 入链) | | [功能决策记录-2026-06-14.md](./滚动规范/功能决策记录-2026-06-14.md) | 📋 滚动 | 功能**需求规格 + 设计决策规格**(为什么这么定 / 要做什么)。最高频引用(67 入链) |
| [功能决策记录-归档-2026-06-14.md](./滚动规范/功能决策记录-归档-2026-06-14.md) | 🗄 归档只读 | 从主文档归档:纯流水 / 老 Sprint 决策 / UX 微调 / 已被取代细节 | | [功能决策记录-归档-2026-06-14.md](./滚动规范/功能决策记录-归档-2026-06-14.md) | 🗄 归档只读 | 从主文档归档:纯流水 / 老 Sprint 决策 / UX 微调 / 已被取代细节 |
@@ -92,6 +92,33 @@
| [测试落地实施方案-2026-07-01.md](./专项设计/测试落地实施方案-2026-07-01.md) | 📐 测试 | 各测试用例的落地方式(Rust 单元/集成/vitest/E2E 手动)、文件位置、Mock 策略、CI 自动化路径 | | [测试落地实施方案-2026-07-01.md](./专项设计/测试落地实施方案-2026-07-01.md) | 📐 测试 | 各测试用例的落地方式(Rust 单元/集成/vitest/E2E 手动)、文件位置、Mock 策略、CI 自动化路径 |
| [devflow集成三方IM-飞书企微钉钉Web-规划设计-2026-10-04.md](./专项设计/devflow集成三方IM-飞书企微钉钉Web-规划设计-2026-10-04.md) | 📐 规划(待评审) | 飞书/企微/钉钉/自定义Web 远程接入 AI Chat:新增 df-im-gateway(WS Commander)复用 u-relay+df-tunnel,桌面端零改动。含必要性分析(§三A)、破坏性分析(§三B)、演进预留(§十 S1-S4:多人/引擎上云/新端)。P0网关→P1 Web→P2飞书→P3钉钉→P4企微 | | [devflow集成三方IM-飞书企微钉钉Web-规划设计-2026-10-04.md](./专项设计/devflow集成三方IM-飞书企微钉钉Web-规划设计-2026-10-04.md) | 📐 规划(待评审) | 飞书/企微/钉钉/自定义Web 远程接入 AI Chat:新增 df-im-gateway(WS Commander)复用 u-relay+df-tunnel,桌面端零改动。含必要性分析(§三A)、破坏性分析(§三B)、演进预留(§十 S1-S4:多人/引擎上云/新端)。P0网关→P1 Web→P2飞书→P3钉钉→P4企微 |
| [三方IM-Rust接入与SDK调研-2026-10-04.md](./专项设计/三方IM-Rust接入与SDK调研-2026-10-04.md) | ✅ 调研完成 | 三平台 Rust SDK 选型:飞书 larksuite-oapi-sdk-rs / 钉钉 dingtalk-stream / 企微 wecom-aibot-rust-sdk。**重大发现:企微智能机器人走 WS 长连接免公网回调,三平台统一形态**。含统一 adapter 映射表与 5 项落地风险清单 | | [三方IM-Rust接入与SDK调研-2026-10-04.md](./专项设计/三方IM-Rust接入与SDK调研-2026-10-04.md) | ✅ 调研完成 | 三平台 Rust SDK 选型:飞书 larksuite-oapi-sdk-rs / 钉钉 dingtalk-stream / 企微 wecom-aibot-rust-sdk。**重大发现:企微智能机器人走 WS 长连接免公网回调,三平台统一形态**。含统一 adapter 映射表与 5 项落地风险清单 |
| [上下文管理演进-任务技术设计-2026-07-20.md](./专项设计/上下文管理演进-任务技术设计-2026-07-20.md) | 📐 设计 | 上下文管理演进任务技术设计 |
| [上下文管理演进-执行计划-2026-07-20.md](./专项设计/上下文管理演进-执行计划-2026-07-20.md) | 📐 计划 | 上下文管理演进执行计划 |
| [上下文管理演进与发散思考-2026-07-20.md](./专项设计/上下文管理演进与发散思考-2026-07-20.md) | 📐 思考 | 上下文管理演进与发散思考 |
| [aichat-plan-driven-设计-2026-08-01.md](./专项设计/aichat-plan-driven-设计-2026-08-01.md) | 📐 设计 | AI Chat plan-driven 模式设计 |
| [devflow集成u-claw-可行性研究-2026-09-28.md](./专项设计/devflow集成u-claw-可行性研究-2026-09-28.md) | 📐 研究 | devflow 集成 u-claw 可行性研究 |
| [跨会话记忆四层架构-设计-2026-09-29.md](./专项设计/跨会话记忆四层架构-设计-2026-09-29.md) | 📐 设计 | 跨会话记忆四层架构 |
| [aichat上下文管理-派工与决策单-2026-09-29.md](./专项设计/aichat上下文管理-派工与决策单-2026-09-29.md) | 📋 决策单 | aichat 上下文管理派工与决策 |
| [DRY技术债走查报告-2026-10-01.md](./专项设计/DRY技术债走查报告-2026-10-01.md) | 📋 报告 | 全库 DRY 技术债走查 |
| [并行化与异步唤醒-设计-2026-10-04.md](./专项设计/并行化与异步唤醒-设计-2026-10-04.md) | 📐 设计 | 并行化与异步唤醒 |
| [无人值守长任务-设计-2026-10-04.md](./专项设计/无人值守长任务-设计-2026-10-04.md) | 📐 设计 | 无人值守长任务 |
| [上下文三层记忆-设计-2026-10-04.md](./专项设计/上下文三层记忆-设计-2026-10-04.md) | 📐 设计 | 上下文三层记忆 |
| [跨会话召回最小闭环-设计-2026-10-04.md](./专项设计/跨会话召回最小闭环-设计-2026-10-04.md) | 📐 设计 | 跨会话召回最小闭环 |
| [context-helpers拆分备勘-设计-2026-10-04.md](./专项设计/context-helpers拆分备勘-设计-2026-10-04.md) | 📐 设计 | context-helpers 拆分备勘 |
| [namespace淘汰治理-设计-2026-10-04.md](./专项设计/namespace淘汰治理-设计-2026-10-04.md) | 📐 设计 | namespace 淘汰治理 |
| [子代理完成卡已消化降级-设计-2026-10-04.md](./专项设计/子代理完成卡已消化降级-设计-2026-10-04.md) | 📐 设计 | 子代理完成卡消化降级 |
| [uclaw两侧勘察补全-2026-10-04.md](./专项设计/uclaw两侧勘察补全-2026-10-04.md) | 📋 勘察 | u-claw 两侧勘察补全 |
| [u-relay-wire-protocol-2026-10-04.md](./专项设计/u-relay-wire-protocol-2026-10-04.md) | 📐 协议 | u-relay 线协议规范 |
| [数据存储架构-定稿-2026-10-05.md](./专项设计/数据存储架构-定稿-2026-10-05.md) | 📐 定稿 | 数据存储架构定稿 |
| [知识库模型重审-精细化设计-2026-10-05.md](./专项设计/知识库模型重审-精细化设计-2026-10-05.md) | 📐 设计 | 知识库模型精细化重审 |
| [飞书直连接入-形态修正与问题记录-2026-10-05.md](./专项设计/飞书直连接入-形态修正与问题记录-2026-10-05.md) | 📋 记录 | 飞书直连接入形态修正与问题记录 |
| [版本更新与自动升级-设计-2026-10-09.md](./专项设计/版本更新与自动升级-设计-2026-10-09.md) | 📐 设计 | 版本更新与自动升级 |
| [u-relay-运维手册-2026-10-10.md](./专项设计/u-relay-运维手册-2026-10-10.md) | 📖 手册 | u-relay 运维手册 |
| [上下文分层组装器-落地方案-2026-10-10.md](./专项设计/上下文分层组装器-落地方案-2026-10-10.md) | 📐 方案 | 上下文分层组装器落地方案 |
| [定时消息与待发箱持久化-设计-2026-10-10.md](./专项设计/定时消息与待发箱持久化-设计-2026-10-10.md) | 📐 设计 | 定时消息与待发箱持久化 |
| [对话内定时延时任务-设计-2026-10-10.md](./专项设计/对话内定时延时任务-设计-2026-10-10.md) | 📐 设计 | 对话内定时延时任务 |
| [消息渲染管线重构-设计-2026-10-10.md](./专项设计/消息渲染管线重构-设计-2026-10-10.md) | 📐 设计 | 消息渲染管线重构 |
| [输入框列表预览与自动编号-设计-2026-10-10.md](./专项设计/输入框列表预览与自动编号-设计-2026-10-10.md) | 📐 设计 | 输入框列表预览与自动编号 |
--- ---
@@ -0,0 +1,131 @@
# FileTree 大目录卡顿 — 可实施方案
> 日期: 2026-10-10 | 状态: 设计稿(待拍板) | 勘察: 只读实测锚点(Wave Q)
> 定位: 「单个目录上千条合法条目」的卡顿风险可实施方案。本文只写方案,不改码。
> 触发: Wave Q 实测 — 后端 `get_module_file_tree` 不封顶、前端 FileTree 全量 `v-for`,当前靠三重缓解兜底。
> 结论预告: 推荐 **方案 B(后端 offset/limit 分页 + 前端「加载更多」)**,先做期 1 最小可用;虚拟滚动本期否决(见 §二·A 的实证疤痕)。
---
## 一、现状与证据(带行号)
### 1.1 后端:列目录不封顶
`src-tauri/src/commands/module.rs` 命令 `get_module_file_tree`(707-834):
- 774-777 `std::fs::read_dir(&target_dir)` 遍历目标目录**全部**条目;
- 781-791 过滤噪音目录(`is_noise_entry`,FileTree 档)+ 隐藏文件(`.` 开头);
- 810-818 每条 push 进 `items`;
- 820-824 排序(目录优先 + 字母序,稳定可预期);
- 830-833 一次性返回 `{ path, entries }`。
**无任何条数上限**。对照同语义的另一档位:AI 侧 `list_dir_recursive`(`src-tauri/src/commands/ai/tool_registry.rs:923-965`)有 `max_entries` 封顶(调用处 `tools/file.rs:347` 传 `1000`,达上限返 `truncated`)。即"列目录"这一语义,**AI 上下文档有封顶、文件浏览器档没有** —— 这是 Wave Q 要补的缺口。
### 1.2 前端:单层全量 v-for
`src/components/project/FileTree.vue`:
- 25-33 `<li v-for="entry in entries" :key="entry.path">` —— 当前层全部条目**一次性渲染**;
- 95-106 `FileTree` 自引用递归:展开的子目录再加一层同构 `<ul>`;
- 157-205 `entries` 为该层全量(命中 `loadedChildren` 缓存则整层直赋)。
滚动容器是**共享**的 `.explorer-tree`(`FileExplorer.vue:806-813`,`overflow-y: auto`),**不是每层各自滚动** —— 这决定了虚拟滚动若要落地,需先把递归树拍平成"单一带缩进的可见行列表"(见 §二·A)。
### 1.3 三重缓解(既定兜底,也是本次要复用的底座)
1. **按层懒加载**:FileTree 仅在某目录被展开时才拉该层(`loadEntries` 174-205;`toggleDir` 208-237),从不整树加载。
2. **噪音 / 隐藏过滤**:后端 `is_noise_entry`(FileTree 档,`tool_registry.rs:1045-1056`)+ `.` 隐藏文件(`module.rs:789`)剔除最大宗条目(`.git` 数千项、`node_modules`/`target`/`dist`/`__pycache__` 等)。
3. **KeepAlive 保活**:`src/views/ProjectDetail.vue:83-85` `<KeepAlive>` 包 `FileExplorer`,切 tab 不销毁、不重挂载、不重拉。
附随机制:`loadedChildren` 缓存(`FileExplorer.vue:280`)+ 在途去重 / 请求序号(`loadSeq`/`inflightEntries`,`FileTree.vue:170-171`)使重复展开秒开、并发请求去重。
**残留风险**:三重缓解把"上千条"压缩成"**单层**上千条",但对**一个真实含上千条且非噪音**的目录(如日志目录、生成代码目录、扁平资源目录),后端仍全量返回 + 前端仍全量 DOM。
---
## 二、方案对比
| 维度 | A 前端虚拟滚动 | B 后端 offset/limit + 前端「加载更多」 | C 两者结合 |
|------|---------------|--------------------------------------|-----------|
| 机制 | 只渲染可视窗口(+ overscan) | 后端分页切片,前端累积 | B 分页 + A 窗口化 |
| 后端改动 | 无 | `get_module_file_tree` 加 offset/limit/total | 同 B |
| 前端改动 | FileTree 重构为扁平窗口化列表 | FileTree 累积 entries + 尾部按钮 | B + 窗口化 |
| 依赖 | 需新库(项目现有依赖无)或自研 | 无 | 需新库 |
| 与现有架构 | **冲突**(递归树需拍平;共享滚动容器需重排) | **天然契合**(每层本就独立请求) | 中 |
| 风险 | 高(高度测量/展开态/选中态同步;项目有自研虚拟滚动翻车疤痕) | 低(改动集中、可回退) | 高 |
| 适配量级 | 上万条 | **上千条(本场景)** | 上万条 |
### A 的风险:项目实证疤痕
自研虚拟滚动曾落地于消息列表(自研 IntersectionObserver + sentinel + ResizeObserver),因 **IO/RO 时序竞态致消息重叠**,2026-06-18 **彻底移除**(见 `docs/待决策.md:132-136`、`docs/02-架构设计/滚动规范/功能决策记录-2026-06-14.md:362-363`;`MessageList.vue` 至今标注"虚拟滚动已移除,恒渲染")。
并且 `package.json` 现有依赖**无任何虚拟滚动库**(`@vueuse/core` 也未安装)。再自研 = 重蹈覆辙;引库 = 新增依赖 + 仍需适配递归树拍平 —— 成本与"上千条"的收益不成比例。
### 推荐:B(最小可用)
理由:
1. **零架构冲突**:现有模型就是"每层一个独立请求 + `loadedChildren` 缓存",把"整层"切片成"整层分批"是该模型的自然延伸,不引入新范式。
2. **最小 diff、低风险、可回退**(见 §三)。
3. **量级匹配**:上千条 DOM 在现代浏览器渲染在数十 ms 量级;首屏限 200~300 条后交互即流畅,足以消除卡顿。无需为"上千条"上虚拟滚动的复杂度(A/C 属 YAGNI,仅当实测 >5000 条再评估)。
4. **给用户明确信号**:「加载更多 (已加载/总数)」比"悄悄截断"更符合用户预期。
---
## 三、方案 B 改动面 / 风险 / 回退 / 验收
### 3.1 改动面(期 1 最小可用)
| 层 | 文件 | 改动 |
|----|------|------|
| 后端 | `src-tauri/src/commands/module.rs` | `get_module_file_tree` 加 `offset: Option<usize>`、`limit: Option<usize>` 参数;返回加 `total`。**先完整收集 + 过滤 + 排序,再切片**(切片点放在 820-824 排序之后),保证分页稳定。`limit` 缺省 = `None` → 返回全量(保持旧行为)。 |
| 前端 API | `src/api/module.ts` | `getModuleFileTree` 加 `offset`/`limit` 参数;`FileTreeResult` 加 `total?: number`。 |
| 前端组件 | `src/components/project/FileTree.vue` | `entries` 改为"累积注入":首屏拉 `limit` 条,`loadMore()` 按 `offset=已加载数` 追加,直到 `total`;层尾渲染「加载更多 (N/M)」;分页状态并入 `loadedChildren` 缓存(**建议其值由 `FileTreeEntry[]` 扩展为 `{ entries, total }` 或新增 `loadedTotals` map**,否则重展开丢失"还有更多"状态)。 |
| i18n | `src/i18n/zh-CN/fileExplorer.ts`、`src/i18n/en/fileExplorer.ts` | 新增 `loadMore`(「加载更多」/「Load more」)、可选 `allLoaded`。 |
不改 `FileExplorer.vue`(缓存语义、展开 / 选中逻辑不变)。改动集中、职责单一。
### 3.2 风险
- **offset 错位**:目录内容在分页期间变化 → 可能重复 / 漏项。自用场景 + `refresh` 清缓存重拉(`resetTreeState`,`FileExplorer.vue:330-336`)已覆盖,可接受。
- **多次请求 git status**:每个分片请求都重跑 `git_status_map_cached`,但有缓存(10s TTL),开销可控。
- **`has_children` 开销**:现每目录一次 `read_dir` 短路统计;分页后只对返回的前 N 条目录计算,反而**降**开销。
- **`loadMore` 与首屏请求竞争**:需让 `loadMore` 复用同一 inflight 去重键或使用独立键,避免与 `onMounted` 首屏请求互相覆盖(机制见 `FileTree.vue:185-188`)。
### 3.3 回退
- 后端 `limit`/`offset` 缺省 = `None` = 全量;前端不传 `limit` 即旧行为。
- 或前端恒定不渲染"加载更多"(首屏即全量)。
- 改动集中在 3 个源文件 + 2 个 i18n 文件,单次 revert 即可回滚。
### 3.4 验收口径
- **造数**:批量子项造 1500 条目录(如 1500 个空文件)。
- 首屏 DOM 行数 ≤ `limit`(如 200);层尾显「加载更多 (200/1500)」;点击逐次追加 200 条直到 1500;加载完按钮隐藏。
- **回归**:选中文件高亮、git 状态标、空目录标、展开子目录、切工程、刷新,均正常。
- **命令**:`cargo check -p devflow` EXIT 0;分页切片纯函数加单测(`module.rs:1574` 已有 `mod tests` 模块可承接);`bunx vue-tsc --noEmit` EXIT 0;`node scripts/check-i18n.cjs` 通过。
---
## 四、分期(最小可用优先)
- **期 1(MVP,必做)**:后端点分页 + 前端「加载更多」按钮。
- **期 2(体验,可选)**:触底自动加载(IO sentinel)。**注意**:滚动容器是共享 `.explorer-tree`(非每层独立滚动),触底自动加载需定位"当前层尾部的 sentinel",实现比按钮复杂;且只做"触底追加"、**不做"卸载裁剪"**,以规避历史 IO 竞态。可延后。
- **期 3(仅当实测触发)**:若实测 >5000 条仍卡,评估虚拟滚动,且**用成熟库**(如 `@vueuse/core` `useVirtualList`)而非自研,并先把递归树拍平为单一带缩进列表。
---
## 五、与既有能力的复用
- **懒加载缓存**:`loadedChildren`(`FileExplorer.vue:280`,`FileTree.vue:180/197/228`)→ 分页后仍存"累积 entries",展开秒开语义不变。
- **噪音过滤**:`is_noise_entry` FileTree 档(`tool_registry.rs:1045-1056`)→ 分页在**过滤之后**切片,过滤语义不变(不会因分页漏滤)。
- **KeepAlive**:`ProjectDetail.vue:83-85` → 分页游标随组件状态在 tab 切换间保留,回来不重拉。
- **在途去重 / 序号守卫**:`FileTree.vue:170-171/187` → `loadMore` 复用,避免并发覆盖。
---
## 六、拍板点
1. **分页粒度**:首屏 `limit` 与每次追加步长各取多少?(建议各 200 —— 兼顾首屏速度与点击次数)
2. **交互形态**:MVP 用「加载更多」按钮(推荐:简单、可测、与共享滚动容器不冲突)vs 触底自动加载(期 2 再评估)?
3. **是否本期引入虚拟滚动**:建议否决(YAGNI + 自研疤痕),仅当实测 >5000 条再以成熟库评估 —— 是否认可?
@@ -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 惯例)。
@@ -170,6 +170,24 @@ Commander(控制端) ──wss──> nginx(:443 TLS 终结) ──ws──> u-r
| **Commander** | 用 pong 更新 `lastPongTime`,看门狗超时则重连 | | **Commander** | 用 pong 更新 `lastPongTime`,看门狗超时则重连 |
| **Agent**(跑桌面/移动网络) | 用 pong 重置读死线,死线超时走既有重连 | | **Agent**(跑桌面/移动网络) | 用 pong 重置读死线,死线超时走既有重连 |
### 4.4 心跳间隔约束
心跳间隔不是自由值,**链路各层空闲超时中最小的一层决定其上界**:
| 层 | 空闲超时 | 说明 |
|---|---|---|
| nginx 反代 | `proxy_read_timeout`(样例 300s;生产实时配置可能更大) | 超时即静默断开 UPSTREAM WS,无任何错误帧 |
| NAT / 防火墙 | 数十秒 ~ 数分钟(设备相关) | 空闲连接被回收,后续帧永久不可达 |
**约定**:间隔须**显著小于最短超时**并留足余量(容忍连续数次心跳丢失 / 网络抖动仍不断链),**推荐 30–60s**。低于 30s 徒增无效流量,超过 60s 在 300s 反代下余量偏薄。
| 端 | 保活帧 | 间隔 |
|---|---|---|
| Commander 参考(u-ppt) | 应用层 `{control_kind:"ping"}` | 30s |
| Agent(df-tunnel) | WS 协议层 Ping 帧(对端协议栈自动回 Pong) | 30s |
> Agent 侧用协议层 Ping 即可保活——WS Ping/Pong 由对端协议栈(tungstenite/axum)自动应答,同样刷新反代空闲计时;应用层 `control_kind:"ping"` 主要用于需要显式 pong 观测的控制端看门狗。两端节拍取同值,避免一方看门狗口径与另一方保活周期错配导致误重连。
--- ---
## 5. 部署配置 ## 5. 部署配置
@@ -0,0 +1,122 @@
# u-relay 运维手册(token 轮换 · 一通道一 agent · 心跳约束)
> 适用:u-relay 跨端中继的日常运维纪律。协议细节见
> [u-relay Wire 协议规格](./u-relay-wire-protocol-2026-10-04.md),本手册只讲**运维动作与约束**。
> 来源:协议评估遗留三项(单共享 token 可冒充 / 心跳间隔未定值 / 同 channel 多 agent 全投递)。
> 本手册**不含**任何 token 值、主机地址等敏感信息——真值只在部署机 `/etc/u-relay.env` 与各客户端本地配置里。
---
## 1. 心跳间隔约束
心跳间隔须显著小于**链路最短空闲超时**(反代 `proxy_read_timeout`,配置样例 300s;生产实时值可能更大),
以容忍连续数次心跳丢失仍不断链。**约定 30–60s**,各方实现见协议规格 §4.4:
- Agent(df-tunnel):`HEARTBEAT_INTERVAL` 常量,30s(WS 协议层 Ping)
- Commander 参考(u-ppt):30s(应用层 `control_kind:"ping"`)
> 改反代 `proxy_read_timeout` 时须同步评估心跳间隔:若把超时压到 300s 以下,间隔也要相应收窄并保持「超时 / 间隔 ≥ 5」量级的余量。
---
## 2. 单共享 token 轮换 Runbook
### 2.1 现状与风险
u-relay 鉴权是**单一共享 token**(env `U_RELAY_TOKEN`,服务端常量时间比较),**不做 per-channel 隔离**:
任何持有该 token 的客户端可声明**任意 `channel_id`** 冒充任一通道。故 token 属敏感凭据,
泄露即等于整条中继失守;轮换是唯一的止损手段。
### 2.2 涉及方与各自持有位置
token 值散落在「服务端 + 多个客户端」,**没有集中下发通道**:
| 方 | 角色 | token 持有位置 |
|---|---|---|
| u-relay 服务端 | 鉴权源 | 部署机 `/etc/u-relay.env`(`U_RELAY_TOKEN`,权限 600)或 systemd unit 的 `Environment=` |
| devflow 桌面端 | Agent | `relay_token` 设置项(运行时读,缺省回退硬编码默认值) |
| devflow 小程序 | Commander | `apps/df-miniapp/src/config.ts` 缺省 + 设置页可改 |
| devflow ask_uclaw | Commander | 设置页 `uclaw_token`(与 relay token 同源) |
| u-claw | Agent/Runner | u-claw 侧配置 |
| u-ppt | Commander | `relay.ts` 配置 |
> 新增任何接入端都要登记到本表——漏一端的后果见下节。
### 2.3 锁步成本(务必先读)
因为**服务端只认一个 token、客户端各自缓存**,轮换天然是一个**锁步切换**:
- **无重叠窗口**:服务端不能同时接受新旧两个 token,故不存在「灰度换端」的过渡期。
- **顺序无解**:先改服务端 → 全部旧客户端立即 `auth_failed` 掉线;先改客户端 → 全部客户端在服务端切换前拿新 token 一样 `auth_failed`。两条路都有中断窗口。
- **漏端即静默失联**:任一客户端未同步换 token,会持续 `auth_failed` 被拒连,表现为**该端静默离线**(无业务错误、仅 relay 日志有 `auth_failed`),排查成本高。
- **不可逐端滚动**:无法像多 token 系统那样「先切一半、观察、再切一半」。
结论:**轮换是一次计划内的协调停机**,中断窗口 = 从「首个客户端改值」到「服务端重启加载新值」的间隔。执行要点是**把客户端改值的动作压到最短、紧接着重启服务端**。
### 2.4 执行步骤
前置:准备好新 token,确认上表**每一端**都有人能改、改完可验证。
1. **写服务端新值(先不重启)**:在部署机把 `/etc/u-relay.env`(或 unit `Environment=`)的 `U_RELAY_TOKEN` 改为新值,保持服务仍在跑旧值。此时新值尚未生效。
2. **集中改用各客户端**:将上表每一端改到新值。此步开始后各端会 `auth_failed`,**中断窗口从这里起算**。
3. **重启服务端加载新值**:`systemctl restart u-relay`。重启后已改端的客户端重连即通。
4. **验证**:见 2.5。
5. **未改端收尾**:对仍 `auth_failed` 的端补改(此时只有漏改者受影响,不再影响大局)。
> 若某些端的 token 是**编译期硬编码**(无运行时配置项),须提前把「新 token + 重新打包」的产物备好,否则第 2 步无法即时完成,窗口会被拉长。
### 2.5 验证
- 服务端:`systemctl status u-relay` 正常,`ss -tlnp | grep 9180` 在听。
- 连接:拿新 token 走一次握手冒烟(`/ws/agent` 或 `/ws/commander`,收 `hello_ack` 即通)。
- 日志:确认无 `auth_failed` 残留——有则说明还有端没换到新 token。
### 2.6 回滚
轮换失败(如某端无法改)时,**把服务端 env 回退到旧 token 并再重启一次**,回到轮换前状态;客户端已改的端随之 `auth_failed`,需一并回退。回滚与轮换同样是锁步中断。
### 2.7 根治方向
根治是**去掉单共享 token**:服务端改为 per-channel / per-client token(客户端拿自己的凭据只能绑自己的 channel),
届时任一端轮换互不影响,锁步成本消失。在此之前,本 runbook 的锁步纪律是必须遵守的现状约束。
---
## 3. 运维纪律:一通道一 agent
### 3.1 现象与根因
u-relay 路由按 `channel_id` 配对,且**同 channel_id 允许多个 agent 并存、命令全投递**(Vec 索引,无去重、无选举)。
即:同 `channel_id` 上跑两个 agent 实例(如两个 devflow 桌面端、或 devflow + u-claw runner 用了同一通道),
一条 commander 指令会被**两端各执行一次**——写文件、跑命令、调外部接口全部重复,且两端事件回传互相交错。
### 3.2 危害
- 指令重复执行:副作用翻倍(重复落库 / 重复发消息 / 重复改文件),且难以察觉。
- 事件串台:commander 收到两份进度/结果,流式 UI 错乱;两 agent 的上下文互相污染。
- 隐性化:两端都「看起来正常工作」,问题只在副作用层面暴露,事后才追溯得到。
### 3.3 纪律
- **一通道一 agent**:每个 `channel_id` 在任一时刻**只允许一个 active agent**。通道不是「实例池」。
- **启动前先查**:起第二个 agent 前,确认目标 `channel_id` 上没有在跑的 agent(换一个独立 `channel_id`,或用不同通道前缀)。
- **换装/重启要收尾**:旧 agent 若未干净退出(进程被杀但连接残留),WS 连接可能存活到对端超时,期间新旧两连接同通道并存 = 短暂双投递。换装脚本须确认旧连接已断、或等旧连接超时后再起新实例。
- **命名隔离**:不同用途用不同 `channel_id`(如按机器 / 按项目 / 按用户拆通道),从命名上杜绝复用。
### 3.4 排查手法
怀疑重复执行时:
- 看 relay 日志:同一 `channel_id` 是否同时有 >1 条 agent 连接。
- 看 commander 侧:同一 `request_id` 是否收到两份 `result` / 事件重复。
- 看副作用:文件 mtime、DB 记录、外部调用日志是否成对出现。
确认后,断开多余 agent 连接(停掉重复实例)即可恢复单投递。
---
## 附:关联
- 协议全貌:[u-relay Wire 协议规格](./u-relay-wire-protocol-2026-10-04.md)(§4 心跳、§5 部署、§3.3 路由)
- u-relay 仓 README / 部署技能:中继二进制与 systemd 发布流程
- 看板登记:`docs/todo.md`「新能力引入评估纪律」下三项 P3
@@ -0,0 +1,265 @@
# 上下文分层组装器 — 落地方案
> 日期: 2026-10-10 | 状态: 设计稿(待拍板) | 勘察: 只读实测锚点
> 定位: **《上下文根治设计-20261006.md》§一(L1/L2/L3)/§二(P1 瘦身)/§九(v1 锁定)/§十(检索面统筹)的落地细化**,不另立设计。
> 覆盖 Wave 上下文走查实测五病灶:① `history_tokens` 与真实体量脱钩;② 每轮全量深克隆+展开 O(n²);③ 预算恒 128K;④ 裁剪后前缀缓存整体失配;⑤ L1/L2/L3 未落地、窗口外历史无召回。
> 前置提醒: `crates/df-ai/**` 正由另一写者改动(P1 工具结果外置 WIP,见 §六),本方案明确与其重叠面与对接协议。
---
## 一、现状勘察(锚点实测)
### 1.1 发送链路的真实数据流(五病灶的公共底座)
每一轮 LLM 请求的消息来源分四步,散落在两处:
| 步 | 锚点 | 动作 |
|----|------|------|
| ① 裁剪 | `crates/df-ai/src/context/mod.rs:185-273` `build_for_request(sys_tokens)` | 按 `history_tokens` 判预算,超则从**头部**淘汰轮次单元 |
| ② 展开 | `src-tauri/src/commands/ai/agentic/mod.rs:1927-1944` | 逐条把 `namespace://` 引用**展开回真实内容** |
| ③ 拼装 | `agentic/mod.rs:1945-1952` | 前置 system_prompt,尾部 `merge_volatile_tail`(`agentic/stream_relay.rs:789`) |
| ④ 瘦身 | `agentic/mod.rs:1968-1988` → `stall_detector.rs:284-385` `summarize_tool_results` | view-only 摘要压缩大 tool_result(`extract_key_info`) |
### 1.2 五病灶逐条锚点
**① `history_tokens` 与真实体量脱钩(根因,最重)**
- 工具结果在 push 前先被引用化:`audit/mod.rs:1184-1191`,`should_use_namespace`(`namespace_store.rs:47-53`)命中 → `store()`(`namespace_store.rs:124-131`)入内存,**消息里只留短串 `namespace://tool/{hash}`**。
- `ContextManager::push`(`context/mod.rs:101-143`)对这条短串跑 `estimate_message`(`context_helpers.rs:62-105`),`history_tokens += tokens` 在 `:108-110`。
- 但**发送时**才在 `agentic/mod.rs:1927-1944` 展开成真实体量(单条 max 150KB,见 P1 注释 `context_helpers.rs:427-432`)。
- 结果:`history_tokens` 记的是引用串(~30 字符),真实发送是全文。预算门 `context/mod.rs:198`(`history_tokens <= available`)与压缩门 `context_lifecycle.rs:133-135`(`available*6/10 < history_tokens`)**双双恒不触发** → 每轮全量重发 → 223:1。
**② 每轮 O(n) 全量克隆+展开+压缩 → 会话累计 O(n²)**
- 未超预算分支 `context/mod.rs:198-205` 走 `all_messages_clone()`(`:294-296`,逐条深克隆);
- 展开 `agentic/mod.rs:1927-1944` 对**全部**历史逐条 `read_only` + `String` 克隆;
- 压缩 `summarize_tool_results`(`stall_detector.rs:313-385`)再遍历全部消息做内容变换。
- 三者每轮各 O(n),会话累计 O(n²)。**根因与①同源**:门失效 → 窗口永不收敛 → n 随会话单调涨。
**③ 预算恒 128K,与模型真实窗口无关**
- 主会话 `PerConvState::new`(`commands/ai/mod.rs:1241`)= `ContextManager::new(ContextConfig::default())`,`max_tokens=128_000`(`context_helpers.rs:128-136`),`budget_limit` 由它算(`:138-143`)。
- 真实窗口在 `ModelConfig.context_window`(`crates/df-ai-core/src/model.rs:138-139`),但只在 router 用,而代码自陈该维度"恒过、失效"(`agentic/mod.rs:2012` 注释)。
- 子代理同病:`sub_agent.rs:119` 也是固定 128_000。
**④ 裁剪后前缀缓存整体失配**
- 超预算时 `context/mod.rs:239-246` 切 `self.messages[trim_end..]`——头部被切,整个请求前缀位移,provider 自动前缀缓存(DeepSeek/Anthropic)整段失效。
- 已有部分缓解:淘汰摘要改尾部追加(`context/mod.rs:259-261`)、`push_system_anchor` 避让悬空头(`:582-595`)、`tool_view_decisions` 冻结压缩决策防跨轮翻转(`stall_detector.rs:352-363`)。但**基础裁剪本身**仍位移前缀,且**淘汰摘要内容逐轮漂移**(被淘汰区单调增长,`:259` 每轮即时重生成)。
**⑤ L1/L2/L3 未落地 + 窗口外历史无召回**
- 组装是隐式的:system(L1 内容)由 `build_runtime_prompt`(`agentic/mod.rs:3042`)拼,历史由 `build_for_request` 拼,易变尾由 `merge_volatile_tail` 拼,无统一"组装器"对象、无可观测统计。
- 检索仅覆盖知识库:`hybrid_search`(`knowledge_inject.rs:421`)→ `build_knowledge_context`(`:508`)→ `build_knowledge_context_for_conv`(`:587`);**窗口外对话历史与压缩摘要无处可召回**。
### 1.3 与既有设计的衔接
- 本方案 = 根治设计 §一 的接口化落地;`上下文三层记忆-设计-2026-10-04.md` 的"记忆卡"(C-m1)是 L3 的**记忆卡来源**,本方案不重复其产出逻辑,只提供召回入口。
- §九 v1 锁定范围(P1 瘦身+P2 页池+P3 压缩常规化+P4 评估)不变;本方案细化 **P2**,并把 §九"唯一存储/唯一调度器/唯一召回入口"三收敛落到具体接口。
---
## 二、目标与验收口径
**目标**
1. **账实相符**:预算/压缩门依据的 token 数 = 实际发送视图的体量(单一真值来源)。
2. **组装显式化**:L1 常驻 / L2 滑动窗口 / L3 检索召回 三段各有明确产出与稳定合并顺序;附带可观测统计。
3. **前缀稳定**:稳态下请求前缀只追加、不位移;裁剪以批量滞后发生。
4. **预算随模型**:上下文预算取自当前模型的 `context_window`,非恒定 128K。
5. **可召回**:窗口外历史/记忆卡经统一检索面按需注入,复用 `knowledge_inject` 管线。
**验收口径**
- 账实相符:`history_tokens` 与"发送视图 `estimate_message` 求和"偏差 ≤ 5%(构造含大 `namespace://` 引用的会话实测)。
- 门生效:构造超预算会话,`context_trimmed`(`context/mod.rs:263`)按真实体量触发;压缩门 `context_lifecycle.rs:133` 在正确水位触发。
- 前缀稳定:稳态连续 N 轮,请求消息数组的公共前缀长度 = 全量(无裁剪)或 = system 段(裁剪后仅在再裁剪时才变);provider 缓存命中率不再掉到个位数。
- 预算随模型:`context_window=256K` 的模型下 `budget_limit` = 256K×0.85−8K 量级,不再恒 128K。
- 可召回:窗口外某历史事实,仅凭关键词在下一轮被召回注入(带来源标注)。
- 不倒退:单轮/短会话(未超预算)行为与改造前逐字节一致;`df-ctx-*` 开关全关 = 旧路径。
---
## 三、设计方案
### 3.0 核心判断
五病灶**同源于一个断裂**:"保留形态"与"发送形态"两套尺寸,门只看了前者。因此**不新增存储、不另立调度**——本方案只做三件事:
1. 把"门所依据的尺寸"与"发送形态"对齐(**记账基点 = 发送视图尺寸**);
2. 把隐式拼装收敛为一个显式**组装器**(三段产出 + 稳定顺序 + 统计);
3. 把检索面从"仅知识库"扩为**注册表**(知识库 + 历史页 + 记忆卡同入口),落到 `build_knowledge_context_for_conv` 单点。
### 3.1 单一真值来源的 token 估值(治 ①,兼解 ②)
**两法对比**
| 法 | 做法 | 评价 |
|----|------|------|
| **A 记账点算"发送视图尺寸"** | 在内容产生点(及 reload)按 `view_form(msg)` 估 token——大工具结果按 `extract_key_info` 后的摘要+句柄尺寸入账 | O(1)/条、纯函数、与发送形态**同函数**(单一真值)、不依赖 namespace LRU 状态 |
| B 估值器识别 ref 查真实大小 | `estimate_message` 遇 `namespace://` 前缀 → 查 store 取真实内容再估 | 破坏 `TokenEstimator` 纯函数性(需注入 store)、每轮 O(n) 查+扫(加重②)、引用被淘汰 / 重启后查不到 → 退回串长仍脱钩 |
**取 A**。理由:A 让"记账"与"发送"共用同一个纯函数,是真值来源唯一化的最短路径;B 把复杂度塞进每轮热路径且对淘汰态脆弱。A 单点落在估值函数,正是 `context_helpers.rs` 的职责域(与既有 `should_summarize_tool_result`/`extract_key_info` 同居一文件,DRY)。
**接口形态(新增,minimal)**
```rust
// crates/df-ai/src/context_helpers.rs — 与 extract_key_info 同域,共享同一压缩逻辑
/// 发送视图尺寸(记账唯一口径):与发送路径"展开→summarize_tool_results"后的形态对齐。
/// 大 tool_result → extract_key_info 结果的 token;其余 → 内容 token。
pub fn view_form_tokens(estimator: &TokenEstimator, content: &str, tool_name: Option<&str>) -> u32;
// crates/df-ai/src/context/mod.rs — push 的带尺寸覆盖入口(记账用)
impl ContextManager {
/// 同 push,但 token_count 用调用方给定的发送视图尺寸(工具名在产生点可得)。
pub fn push_with_tokens(&mut self, message: ChatMessage, tokens: u32);
}
```
**改动点**
- 产生点 `audit/mod.rs:1184-1191`:此处已握有真实 `content` 与 `draft.name`,算 `view_form_tokens(...)` 后走 `push_with_tokens`,取代 `push` 的 content 估值。删掉 push 的隐式 ref 估值这条唯一失真源。
- 其余重估入口(`replace_tool_result_content` `context/mod.rs:332`、`replace_last_active_user_content` `:418`)同改走 `view_form_tokens`,保证单一口径。
- **reload 一致性**:DB 存的是展开后的真实内容(`namespace_store.rs:3-4` 注释 / `agentic/mod.rs:1922`),故 `restore_from_messages`(`context/mod.rs:310`)须同样按 `view_form_tokens` 记账,否则"新会话(视图尺寸)≠ 重载(真实尺寸)"两态不一致。工具名从历史重建(`build_id_to_name`,`stall_detector.rs:300`)取得(见拍板点 2)。
- **② 的化解**:① 修好后门下移到位 → 裁剪按真实体量发生 → 发送窗口收敛为常数 W → 每轮克隆/展开/压缩从 O(n) 降到 O(W),会话累计从 O(n²) 降到 O(n·W)。②无需单独"优化",随①自愈。
### 3.2 分层组装的接口形态(治 ②④⑤)
**现状**:拼接散在 `context/mod.rs:185-273`(窗口裁剪)与 `agentic/mod.rs:1945-1952`(system+尾)。改为一个**显式组装器**,产出结构化的三段 + 易变尾 + 统计:
```rust
// src-tauri/src/commands/ai/agentic/assembler.rs (新文件;或收敛进现有 helpers.rs)
/// 组装器产出:有序三段 + 易变尾 + 可观测统计。
pub struct AssembledContext {
pub resident: Vec<ChatMessage>, // L1:system 段(见 3.2.1)
pub window: Vec<ChatMessage>, // L2:滑动窗口(三元组对齐的发送视图)
pub recall: Vec<ChatMessage>, // L3:检索召回(user 角色,易变,固定置尾)
pub volatile: Option<String>, // 既有易变尾(日期/清单/knowledge)
pub trimmed: bool,
pub stats: AssemblyStats, // 页命中/召回数/换出量/system 估算
}
/// 合并顺序固定:resident → window → volatile → recall,全部只追加不位移前缀。
pub fn assemble(ctx: &ContextManager, sys_tokens: u32, recall: Vec<ChatMessage>, volatile: Option<String>)
-> AssembledContext;
```
**合并顺序与缓存友好的三条不变式**
1. **L1 resident 最前且为 loop 不变量**:`build_runtime_prompt`(`agentic/mod.rs:3042`)产出,pinned_goals/工作上下文/技能/DAG 均在此,整个 loop 文本不变 → 稳定缓存前缀最大块。
2. **L2 window 追加为主**:`build_for_request` 的裁剪保留区(`context/mod.rs:243-246`)即窗口。三元组原子性沿用 `build_eviction_units`(`:612-642`)的单元边界,窗口切片必落单元边界。稳态只 push 追加,前缀不变。
3. **L3 recall + volatile 严格置尾(role=user)**:召回复用 `merge_volatile_tail`(`stream_relay.rs:789`)的"末尾 user 承载"范式——易变内容不进缓存前缀。**召回绝不插进 window**(避免劈三元组、位移前缀)。
**与 `build_for_request` 的关系**:`build_for_request` 保留为 L2 的**纯函数内核**(签名与三元组逻辑不动),组装器在其上"包一层":调 `build_for_request(sys_tokens)` 取窗口 → 前置 resident → 追尾 volatile/recall → 填 `AssembledContext`。即"内核不改行为,组装器负责编排"。调用点 `agentic/mod.rs:1910-1953` 从内联拼装改为 `assemble(...)` 单调用。
**前缀稳定性(治④)**
- **滞后水位批量裁剪**:trim 触发用高水位(`high = available`),一次裁到低水位(`low = available × k`,建议 k≈0.6),使裁剪**稀疏发生**;两次裁剪之间窗口 append-only,前缀稳定。替代现行"每轮才够就裁一单元"(`context/mod.rs:211-219`)。
- **淘汰摘要冻结**:现行摘要每轮重生成致漂移(`:259`)。改为与 `tool_view_decisions` 同款**按内容哈希冻结**(首次生成后沿用),前缀稳定。
- **随①自愈**:① 使门在正确水位触发,裁剪频次本就下降。
### 3.3 检索召回复用 knowledge_inject 管线(治⑤,兼§十统筹)
**现状单入口**:两个调用点(`agentic/helpers.rs:150`、`commands/chat.rs:398`)都经 `build_knowledge_context_for_conv`(`knowledge_inject.rs:587`),内部 `hybrid_search`(`:421`)+ `merge_hybrid_results`(`:468`)排序。
**改为注册表(§十"召回入口统一"的落地)**
```rust
// src-tauri/src/commands/ai/knowledge_inject.rs — 扩为召回面
type RecallItem = (df_storage::models::KnowledgeRecord, RecallKind); // 复用既有 record 形态
enum RecallKind { Knowledge, HistoryPage, MemoryCard }
/// 各源实现同一检索签名;合并复用 merge_hybrid_results 的排序/去重/limit 语义。
async fn recall(query, limit, ...) -> Vec<RecallItem> // 内部:知识库(既有) + 历史页(新) + 记忆卡(新)
```
- **入口不动**:`build_knowledge_context_for_conv`(`:587`)与 `inject_knowledge_into_prompt`(`:633`)保持签名,内部从"只查知识库"扩为"查注册表全部源",输出**按源标注**(`[知识]`/`[历史]`/`[记忆卡]`)。调用点零变更。
- **HistoryPageSource(v1 当前会话)**:query 复用末条 active user 文本(`knowledge_inject.rs:592-601` 已取);扫描当前 `conv.messages` 中 **!is_active(compressed/archived)** 与**窗口外**消息,关键词重叠排序取 top-K。**零新存储**——数据已在 `ContextManager.messages` 全量保留(`context/mod.rs:104-107`)。
- **MemoryCardSource**:压缩摘要(`ChatMessage::system(&summary.nl_summary)`,`context_lifecycle.rs:294` 尾部追加)与 `上下文三层记忆` C-m1 的记忆卡,作为可召回条目注册。v1 可与 HistoryPageSource 合并实现。
- **配额闸(§十原则4)**:召回页每轮 ≤K(建议 5),配额独立于知识注入既有上限,防信息炸弹。
### 3.4 预算随模型窗口(治③)
- 预算来源改为当前模型的 `ModelConfig.context_window`(`df-ai-core/src/model.rs:138-139`)——该值已在 `AiProviderRecord.model_configs`(`df-storage/src/models.rs:306+`,经 `deserialize_model_configs` 解析)可得,loop 顶部已持 `provider_config`。
- 落地:`ContextManager` 增 `set_max_tokens` 或在派发前用模型窗口重建 `ContextConfig`;`sys_tokens` 估算点(`agentic/mod.rs:1115`)不变。
- 缺省回退:当前模型无 `context_window`/为默认值时,沿用 128K(零回归)。
### 3.5 边界总览(三段职责)
```
L1 resident ← build_runtime_prompt (agentic/mod.rs:3042) 常驻,loop 不变量,缓存前缀
L2 window ← build_for_request (context/mod.rs:185) 滑动,三元组对齐,记账=view_form
L3 recall ← recall registry (knowledge_inject.rs:587 扩) 按需,置尾,配额闸
```
---
## 四、分期(每期独立可上线、可回退)
### P2.1 — 真值 token 估值(治①,兼解②)【S,最先】
- 新增 `view_form_tokens`(`context_helpers.rs`)+ `push_with_tokens`(`context/mod.rs`)。
- 改 `audit/mod.rs:1191` push 站点与重估入口(`context/mod.rs:332`/`:418`)、reload 路径(`:310`)。
- 开关 `df-ctx-accurate-tokens`(默认 **true**;false = 旧 content-based 记账,逐字节回退)。
- 验收:账实相符 + 门生效两条。**此期不动发送内容,零行为变化。**
### P2.2 — 组装器结构化(治④,为 P2.3 铺路)【M】
- 新增 `AssembledContext` + `assemble()`;`agentic/mod.rs:1910-1953` 内联拼装改单调用;加 `AssemblyStats` 日志/事件。
- trim 滞后水位 + 淘汰摘要冻结。
- 行为等价重构(除 trim 时机稀疏化,可单开关 `df-ctx-trim-hysteresis` 默认 true)。
- 验收:前缀稳定 + 不倒退两条。
### P2.3 — L3 检索召回(治⑤)【M】
- `knowledge_inject.rs` 扩为召回注册表 + HistoryPageSource(v1 当前会话)+ 源标注;配额闸 ≤K。
- 开关 `df-ctx-recall`(默认 **off**,择机灰度开)。
- 验收:可召回一条。
### P2.4 — 预算随模型窗口(治③)【S】
- 接 `ModelConfig.context_window`;缺省回退 128K。开关 `df-ctx-model-window`(默认 true)。
- 验收:预算随模型一条。
### P2.5 — 页池统一【北极星,触发制,不在本方案实施】
- 触发条件:单会话版实测后仍有跨会话/内存痛点(§九问题2);或将 namespace_store(内存)与 tool_offload(磁盘)合流为唯一内容寻址页池(§九问题1 收敛)。
- 本方案只**预留接口边界**(见 §六),不实施。
**顺序**:P2.1(最大杠杆、最小改动)→ P2.2(结构)→ P2.4(廉、高值,可与 P2.2 并行)→ P2.3。
---
## 五、影响面(五维)
- **兼容**:P2.1 只改记账,不改发送内容;老会话 reload 后按 `view_form_tokens` 重算(与新建会话口径一致,依赖 reload 路径同改)。P2.2 行为等价重构。P2.3/P2.4 均带开关,关=旧路径。无 schema 变更。
- **破坏**:裁剪切点由"即时"变"滞后批量"(P2.2)——行为变更但语义等价(仍保三元组、仍有淘汰摘要);开关可退。无不可逆操作。
- **安全**:检索召回仅扩"只读召回面",注入内容受既有配额闸约束;不引入新命令执行面。召回的历史页来自本会话已保留内容,无跨权限面(v1 单会话)。
- **资源**:① 修正后 `history_tokens` 上升到真实量级,裁剪更积极 → **发送 token 下降**(目标 §一)。召回新增每轮 ≤K 页注入(可控)。无新持久化。
- **回退开关**:`df-ctx-accurate-tokens` / `df-ctx-trim-hysteresis` / `df-ctx-recall` / `df-ctx-model-window`(均 KV 热读,默认值见各期)。观测:`AssemblyStats`(召回命中/换出量/window token/trim 次数)+ 既有 `context_trimmed` 日志。
---
## 六、与 P1 工具结果瘦身(另一写者)的边界与对接
**P1 现状(WIP,`git diff`)**:`crates/df-ai/src/lib.rs` 加 `pub mod tool_offload;`;`context_helpers.rs` 加 `READ_FILE_OFFLOAD_BYTES:432` / `TOOL_OFFLOAD_HANDLE_MIN_BYTES:436`,重构 `extract_key_info`(`:472`) 为"小文件豁免 + 超阈值压缩并落盘句柄"。消费点 `stall_detector.rs:342-351`、`sub_agent.rs`。
**职责二分(不双实现)**
| 归属 | 内容 |
|------|------|
| **P1(内容变换)** | 大工具结果**如何变小**:`extract_key_info` / `offload_with_handle`(`tool_offload.rs:46`)/ `namespace_store` 引用化 |
| **本方案(记账+组装)** | 变小后**占多少**、**发什么**、**如何召回**:`view_form_tokens` 记账基点、组装器、召回面 |
**对接协议(冻结接口)**
1. **单一压缩函数**:本方案的 `view_form_tokens` 内部**复用 P1 的 `extract_key_info`**(同域同 crate),不另写压缩逻辑。P1 若改阈值/句柄格式,本方案自动跟随——**一处定义**。
2. **单一存储形态(§九问题1)**:`namespace_store`(内存)与 `tool_offload`(磁盘)是**同一原文的两种介质**,构成双实现隐患。**本方案 v1 不碰二者**,只在 §3.3 召回面与 §二验收中把"结果页"抽象为内容哈希寻址(handle 与 ref key 均已是内容哈希:`namespace_store.rs:353` / `tool_offload.rs:37`)——为 P2.5 合流预留,不在 v1 制造第三套。
3. **文件重叠**:`context_helpers.rs` 双方都动(P1 改 `extract_key_info`/常量;本方案加 `view_form_tokens`)。约定:本方案新增函数**不改 P1 既有函数签名**;`view_form_tokens` 以只读方式调用 `extract_key_info`。**串行合并**:P1 先合(其 `extract_key_info` 形态是本方案记账的输入),本方案在 P1 之上加记账点。
4. **执行序**:P2.2/P2.3/P2.4 与 P1 无重叠文件(改 src-tauri,除消费 P1 常量),可并行;P2.1 与 P1 同文件,**待 P1 合入后落**。
---
## 七、拍板点清单
| # | 决策 | 选项 | 建议 |
|---|------|------|------|
| 1 | 记账基点(单一真值来源) | A 发送视图尺寸(view_form,与发送同函数);B 保留内容/引用串尺寸 | **A**。B 无法消除账实脱钩;B 是当前病灶本身 |
| 2 | reload 路径口径 | A 重建 id→name,reload 亦按 view_form(精确一致);B reload 按真实内容保守估(实现简,与新建会话口径不完全一致) | **A**。一致性是"单一真值"的前提;`build_id_to_name` 已存在可复用 |
| 3 | 组装器落地形态 | A 新增 `AssembledContext` 结构 + `assemble()` 单入口;B 保留 `build_for_request` 返回 tuple,拼接仍分散 | **A**。B 延续"拼接分散",统计/顺序无处安放 |
| 4 | L3 召回 v1 半径 | A 仅当前会话窗口外页(in-memory 关键词,零新存储);B 纳入跨会话/向量检索 | **A**。跨会话/向量是 §九 v2+ 北极星(触发制),v1 零新存储先证价值 |
---
## 八、与既有文档关系
- 本方案 = 《上下文根治设计-20261006.md》§一/§二/§九/§十的**接口细化与实施序**,不改其 v1 范围与北极星定位。
- `上下文三层记忆-设计-2026-10-04.md`:其 C-m1 记忆卡是 L3 的 `MemoryCardSource` 数据源,本方案只提召回入口(§3.3),不重复卡片产出。
- `namespace淘汰治理-设计-2026-10-04.md`:namespace_store 的 pin/EvictionLog/owner 治理不变;P2.5 页池统一时作为 RAM 层纳入。
@@ -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,284 @@
# 定时消息与待发箱持久化 — 设计
> 日期: 2026-10-10 | 状态: 设计稿(待拍板) | 勘察: 只读实测锚点
> 覆盖需求: todo **935**(定时消息多条 + 统一「定时列表」)与 todo **931**(发送队列网络超时/重启不丢)。
> 关联: todo **937**(对话内定时/延时任务)共用本设计调度器,仅预留扩展点,不在本设计实施。
> 目标(用户原话转译): 消息可设为定时发送、可设多条,有一个统一「定时列表」可查看/改期/取消;发队列遇网络超时、杀进程重启都不能丢。
---
## 一、现状勘察(锚点实测)
### 1.1 发送队列(931 病灶)
| 件 | 锚点 | 现状 |
|----|------|------|
| 队列数据结构 | `src/stores/ai.ts:104`(类型)、`:136`(`queue: []` 初始化) | **纯内存模块级数组**,字段 `{ text, skill?, enqueuedAt, parts?, spans?, conversationId? }`,无任何持久化 |
| 入队 | `src/composables/ai/useAiSend.ts:392-404` | `sendMessage` 查 `ai_is_generating` 得 `backendGenerating` → 忙则 `state.queue.push(...)`;上限 `QUEUE_LIMIT=10`(`:53`),超时 `QUEUE_TIMEOUT_MS=30_000`(`:56`) |
| 续发派发 | `useAiSend.ts:308-353` `drainQueue`;监听器 `:607-612` `initDrainQueueListener` | **前端自驱**:`AiCompleted` → `useAiEvents` 发 `ai-drain-queue` → `drainQueue` 取出队首 `sendMessage` |
| 显式清队 | `useAiSend.ts:193-196`(regenerate)、`:269-272`(editMessage)、`:488-495` `clearQueue` | 重生成/编辑属用户显式动作,清本会话队列合理;`notifyQueueCleared`(`aiShared.ts:772`)toast 提示 |
| 队列 UI | `src/components/AiChat.vue:78-113`;`activeQueue` 计算 `:489` | 按 `activeConversationId` 过滤渲染当前会话队列,含 强制发送/清空/编辑/回填/立即发送/取消 |
**病灶(与 todo 931 勘察一致)**:①队列内存态,杀进程即丢;②后端 busy 判定 + 后续 `AiCompleted` 续发全依赖前端活着并收到事件——网络超时/事件竞态丢失则队列永久卡住或静默丢;③`state.queue` 无启动恢复入口。修法须从「内存 + 前端自驱」下沉到「持久化 + 后端自驱」。
### 1.2 后端发送受理链(定时复用的正典入口)
| 件 | 锚点 | 语义 |
|----|------|------|
| `ai_chat_send` IPC | `src-tauri/src/commands/ai/commands/chat.rs:636` | 拦截 `/status` → 解析前导技能 → 委派 `spawn_agentic_session` |
| `spawn_agentic_session` | `chat.rs:796`(`pub(crate)`) | **发送正典核心**:解析 provider → 目标 conv 入参优先/缺省 active/都无则懒建 → `can_accept_request` 门禁(仅 Idle\|Error 可接,拒 Generating/Stopping/Compressed)→ 写 stop_flag/language/iteration/model_override → spawn `run_agentic_loop`。`pin_active` 决定是否劫持用户活跃面板 |
| generating 判定 | `chat.rs:471` `ai_is_generating` | 读无锁 `state.conv_states.is_active(conv)` |
| 完成出口(单点) | `src-tauri/src/commands/ai/agentic/mod.rs:2882` `emit_ai_completed_once` → 调 `unattended::notify_batch_done(conv_id)`(`:2917`) | **全部正常收尾汇聚于此单点**,是「会话转空闲」的唯一可靠信号 |
结论:`spawn_agentic_session` 是后端可无前端直接调用的发送入口(无人值守引擎已这么用),定时到点可直接走它,无需前端在场。
### 1.3 后端常驻调度器先例(可直接抄形态)
`src-tauri/src/commands/ai/unattended.rs`:
- `start(app)`(`:505` 起)用 `tauri::async_runtime::spawn` + `tokio::time::interval` + `tokio::select!{ interval.tick(), notify.notified() }` 起常驻循环;周期/开关/范围全部**从 `Settings KV` 每轮热读**(`df-unattended-poll-secs` 等)。
- 派发走 `spawn_agentic_session(..., Some(new_id()), ..., false)`(`:847-857`):**独立后台会话 + `pin_active=false`**,不劫持用户活跃面板。
- `notify_batch_done`(`:483`)在完成出口清在途标志并 `notify_one` 唤醒轮询器。
- 单会话单在途双保险:内部 `in_flight` 标志 + `can_accept_request` 兜底清残留(异常退出漏钩子时不永久卡死)。
- 挂载点 `src-tauri/src/lib.rs:84` `commands::ai::unattended::start(app.handle().clone())`。
- 通知出口:托盘 `notify_session_event`(`commands/ai/notify.rs:47`,`NotifyKind::Completed/Failed/NeedAction`);飞书 `feishu_notify::push_card/push_monitor_alert`,目标 KV `df-monitor-chat-id`(`unattended.rs:52`)。
### 1.4 生命周期(「应用关闭后是否生效」的事实)
- `lib.rs:51` `.setup()`:开库+迁移(`AppState::init`)→ `app.manage` → 各 `spawn`(feishu、`unattended::start`、mcp、proxy gateway、tray)。
- `lib.rs:614-658` `on_window_event`:`CloseRequested` → **`api.prevent_close()` + `window.hide()` 托盘化,进程不退出**;真正退出只走托盘菜单「退出」(或系统关机)。
- 无 `RunEvent::Exit` 级关闭钩子(默认 `.run()`)。
**关键事实**:窗口关闭 → 托盘常驻 → **进程仍在,后端 tokio 调度器照常跑**;只有进程真退出(托盘退出/关机)调度器才停。故「应用关闭后是否生效」的精确答案是:**关窗(托盘化)期间生效;进程退出期间不生效,重启后按追赶策略补发**——这是必须写进验收口径的边界。
### 1.5 存储与迁移底座
- 迁移版本链 `crates/df-storage/src/migrations.rs`,当前 **V50**;`migrations_helpers::{column_exists, table_exists}` 提供幂等探测。
- Repo 两种范式:`impl_repo!` 宏(`crates/df-storage/src/crud/mod.rs`,生成 insert/get_by_id/list_all/update_full,适合固定 CRUD);`SettingsRepo`(`crud/settings.rs`)手写 KV(`app_settings` 表,key/value JSON),适合标量偏好。
- 会话记录 `AiConversationRecord`(`crates/df-storage/src/models.rs:372`)已有 `loop_state`(V49)/`status`(V50)等生命周期列先例。
- 前端会话 CRUD:`ai_conversation_create`(`src/api/ai.ts:318`)、`ai_conversation_list`(`:323`)——定时目标会话可在 composer 处先建。
---
## 二、目标与验收口径
**功能目标**
1. Composer 旁「定时」入口:把当前输入(文本/技能/图片)设定为**某绝对时间**发送;可连续设多条。
2. 统一「定时列表」:跨会话查看全部定时项,支持 查看/改期/取消/编辑文本;到点自动发送。
3. 待发队列**持久化**:入队/出队/取消即落库;网络超时不清队列(仅标记该条可重试);杀进程重启后队列仍在且可续发。
4. 定时到点时会话忙 → 顺延到会话空闲再发(不丢、不打断当前生成)。
5. 失败可见:连续失败到上限 → 标失败 + 托盘通知(可选飞书),用户可在列表重试/取消。
**验收口径**
- 断网/超时:构造后端生成中入队 → 断网触发错误 → 队列项仍在(标记可重试),恢复网络后续发。
- 重启:入队 N 条 → 杀进程 → 重启 → 队列恢复 N 条且自动续发;`sending` 残留被复位为 `pending`。
- 定时:设 1 分钟后发送 → 关窗托盘化 → 到点仍在后台发出(session 落库可见);设 1 分钟后发送 → 立刻进程退出 → 重启 → 按追赶策略补发或标记过期。
- 多会话:定时项按目标会话独立排队,不误发到当前活跃会话;到点会话忙则顺延。
- 不倒退:无定时/无队列时,普通发送链路(L0/L1/L2)行为与改造前逐字节一致。
---
## 三、设计方案
### 3.0 核心判断:统一为后端「待发箱(outbox)」
931 队列与 935 定时消息本质是**同一实体、不同触发条件**:队列 = 「会话空闲即发」,定时 = 「到点且空闲即发」。因此不建两套调度,统一落一张表 + 一个后端调度器:
- **触发条件统一为 `due_at`**:队列项 `due_at = NULL`(尽快);定时项 `due_at = epoch_ms`。调度器每次取「`due_at` 已到 且 目标会话空闲」的项派发。
- **单一真相源**:表是真相,前端 `state.queue` 退化为该会话 outbox 的**只读视图**,前后端各一套派发逻辑的「草率聚合」不再存在。
- **调度落点 = 后端 tokio 常驻**(抄 unattended 形态),复用 `spawn_agentic_session` 正典链 + `emit_ai_completed_once` 空闲钩子。
### 3.1 数据结构(新表 `ai_outbox`,迁移 V51)
选**独立表**而非 KV:`app_settings` 是标量偏好,读改写整块 JSON 无索引、并发改期/取消会互相覆盖;outbox 需按 `status+due_at` 索引扫描、按 id 逐条 CAS 更新,表是对的解。
```sql
CREATE TABLE IF NOT EXISTS ai_outbox (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL, -- 目标会话(composer 处确定;无则前端先建)
kind TEXT NOT NULL DEFAULT 'queue', -- 'queue'(尽快) | 'scheduled'(定时);预留 'task'(937)
text TEXT NOT NULL,
skill TEXT, -- /技能名
model_override TEXT, -- 用户指定模型(null=路由)
parts_json TEXT, -- ContentPart[] JSON(图片;空/NULL=纯文本)
spans_json TEXT, -- MentionSpan[] JSON
due_at INTEGER, -- epoch ms;NULL=尽快(queue)
status TEXT NOT NULL DEFAULT 'pending', -- pending|sending|sent|failed|canceled
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
sent_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_ai_outbox_due ON ai_outbox(status, due_at);
CREATE INDEX IF NOT EXISTS idx_ai_outbox_conv ON ai_outbox(conversation_id, status);
```
迁移:V51 = 建表 + 两索引(`table_exists` 幂等守卫)。**可逆**:DOWN 等价 = `DROP TABLE ai_outbox`(纯新增表,不影响旧数据;回退即弃表 + 关调度器开关)。**务必按项逐条落状态标记,禁依赖全局内存推算**(V21 教训:`docs/todo.md:860` 全局 COUNT 幂等窗口中途崩溃致永久残缺)。
Rust 记录(加 `crates/df-storage/src/models.rs`):
```rust
pub struct AiOutboxRecord {
pub id: String,
pub conversation_id: String,
pub kind: String,
pub text: String,
pub skill: Option<String>,
pub model_override: Option<String>,
pub parts_json: Option<String>,
pub spans_json: Option<String>,
pub due_at: Option<i64>,
pub status: String,
pub attempts: i64,
pub last_error: Option<String>,
pub created_at: i64,
pub updated_at: i64,
pub sent_at: Option<i64>,
}
```
### 3.2 存储层 Repo(手写,不走 `impl_repo!`)
`crates/df-storage/src/crud/outbox_repo.rs`(新)。因需状态 CAS 与条件更新,手写(同 `SettingsRepo` 范式):
- `insert(&record) -> id`
- `list_by_conv(conv_id, include_done: bool) -> Vec<AiOutboxRecord>`
- `list_due(now_ms, limit) -> Vec<AiOutboxRecord>`(status='pending' AND (due_at IS NULL OR due_at<=now) ORDER BY created_at)
- `claim(id) -> bool`:CAS `UPDATE ... SET status='sending', updated_at=? WHERE id=? AND status='pending'`(changes==1 才 true,防并发双取)
- `mark_sent(id)` / `mark_failed(id, err)` / `revert_pending(id, attempts, err)`
- `reschedule(id, due_at)` / `cancel(id)` / `update_text(id, text)` / `clear(conv_id?)`
- `reset_stale_sending()`:启动把残留 `sending` 复位为 `pending`(崩溃半途,镜像 unattended 的 `in_flight` 泄漏处置)
AppState 增 `pub outbox: OutboxRepo`(`state.rs`,`AppState::init` 内 `OutboxRepo::new(&db)`),与 `settings`/`conv_states` 并列。
### 3.3 后端调度器 + IPC(新文件 `src-tauri/src/commands/ai/outbox.rs`)
**挂载**:`lib.rs` setup 增 `commands::ai::outbox::start(app.handle().clone())`(与 `unattended::start` 并列);启动即先 `outbox.reset_stale_sending()`。
**循环**(抄 unattended 形态):
```
start(app): spawn loop {
select!{ interval.tick(), notify.notified() } // 周期兜底 + 事件即时唤醒
if !enabled(KV) continue
for item in list_due(now, 32) { // 按 created_at 排序
if dispatched_this_tick(conv) continue // 同会话同轮只派一条(串行保序)
if !conv_states.can_accept_request(conv) continue // 忙 → 本轮跳过,下次再取(不丢)
if !claim(item.id) continue // CAS 失败(他轮已取)
match spawn_agentic_session(app, state, item.text, None, item.skill,
item.model_override, Some(conv), parts, spans, false) {
Ok(_) => { mark_sent(item.id); emit("ai-outbox-sent", {id, conv}); mark_dispatched(conv) }
Err(e) => { // 仅 spawn 前置失败(provider未配/会话忙竞态)
if permanent(e) { mark_failed(item.id, e); notify(...) }
else { revert_pending(item.id); emit("ai-outbox-changed"); }
}
}
}
}
```
**关键语义**
- `pin_active=false`:定时/队列的自动发送不劫持用户活跃面板(同无人值守);用户切到该会话仍能看到实时流(AiTextDelta 已按 conv 路由)。
- **「完成即唤醒」钩子**:`agentic/mod.rs:2917` 现有 `unattended::notify_batch_done(conv_id)` 旁并列加 `outbox::notify_conv_idle(conv_id)`——会话一空闲就唤醒调度器续发,替代前端 `ai-drain-queue` 自驱。
- **失败面界定**:`spawn_agentic_session` 返回 `Ok` 即「受理成功」,outbox 立即标 `sent`;LLM 轮次内的失败(流断/工具错)由既有 loop 自愈+错误气泡+看门狗处置,**不回流 outbox**(避免与既有韧性机制重叠)。outbox 的 `failed` 只覆盖**受理失败**(provider 未配、永久 4xx 特征串如 404/额度耗尽——复用 `classifySendError` 同源判据的 Rust 版)。
- 重试:`revert_pending` 保留下轮再试(忙场景天然重试);`attempts` 达 `df-outbox-max-attempts`(默认 3)记 `failed` + 通知。
- **过期追赶**:调度器只认「`due_at <= now`」,故进程退出期间到期的项在重启后立即命中(补发)。是否对「迟到过久」的项改标过期见拍板点 4。
**IPC 面**(注册进 `lib.rs` `invoke_handler`):
- `ai_outbox_enqueue(conversation_id, text, skill, model_override, parts, spans, due_at?) -> id`(`due_at` 空=队列项)
- `ai_outbox_list(conversation_id?, include_done?) -> Vec<AiOutboxItem>`
- `ai_outbox_reschedule(id, due_at)` / `ai_outbox_cancel(id)` / `ai_outbox_update_text(id, text)`
- `ai_outbox_send_now(id)`(插队=force;后端 CAS claim 后立即派发)
- `ai_outbox_clear(conversation_id?)`
**事件面**:`ai-outbox-changed`(任何变更后前端刷新视图)、`ai-outbox-sent { id, conversation_id }`、`ai-outbox-failed { id, conversation_id, error, permanent }`。
**通知出口**:失败经 `notify_session_event(app, NotifyKind::Failed/NeedAction, conv, ...)` 托盘补位;需要飞书时复用 `feishu_notify::push_card` + KV `df-monitor-chat-id`(与 unattended 同款,零新通道)。
**KV 配置**(`app_settings`,热读):`df-outbox-enabled`(默认 true,回退开关)、`df-outbox-poll-secs`(默认 10,下限钳 5)、`df-outbox-max-attempts`(默认 3)、`df-outbox-notify-feishu`(默认 false)。调度器另用 `Notify` 由 enqueue/完成钩子即时唤醒,周期 tick 仅兜底 `due_at` 到点。
### 3.4 前端改造(队列视图化 + 定时入口)
**`src/composables/ai/useAiSend.ts`**
- `sendMessage` L1 分支(`:392-404`):`state.queue.push(...)` → `await aiApi.outboxEnqueue(...)`,随后刷新本会话视图。
- 删前端派发:`drainQueue`(`:308`)、`initDrainQueueListener`(`:607`)、`ai-drain-queue` 监听改由 `ai-outbox-changed`/`ai-outbox-sent` 驱动的**视图刷新**;`useAiEvents` 的 `AiCompleted` 不再发 `ai-drain-queue`。
- 保留 `cancelQueued/clearQueue/editQueued/sendQueuedNow/tryForceSend`,后端代理为对应 IPC。
- regenerate/editMessage 的「清本会话队列」改为 `ai_outbox_clear(convId)`(语义不变:用户显式动作允许清)。
**新增 `src/composables/ai/useAiOutbox.ts`**:`state.queue` 保持为「本会话待发视图」数组(shape 兼容 `AiChat.vue`),由 `ai_outbox_list` 拉取 + 事件增量刷新;提供 `scheduleMessage(convId, text, ..., dueAt)`、`listAll()`、`reschedule/cancel` 等。
**`src/stores/ai.ts`**:`queue` 字段类型不变(视图契约零破裂),来源改为 composable 写入。
**Composer 入口 `src/components/ai/ChatInput.vue`**:发送按钮区(`:107-108`)旁加「定时」图标按钮 → 弹轻量时间选择(预设 5/30/60 分钟 + 自选时间)→ 调 `scheduleMessage`。空文本仅选时间时提示需输入内容。
**列表 UI `src/components/AiChat.vue`**:
- 现有内联队列条(`:78-113`)保留渲染本会话待发(含定时项可加 `🕐 时间` 角标 + 「改期」按钮)。
- 新增**「定时列表」抽屉**:跨会话展示全部 `scheduled`(+可选 queue)项,列 时间/会话/摘要/状态,操作 查看/改期/取消/编辑。可复用 TaskDrawer 抽屉范式(`temp/S4-TaskDrawer跳转勘察-2026-10-03.md` 已有跳转勘察)。
**启动恢复**:`useAiOutbox` 初始化时拉 `ai_outbox_list`;若存在恢复项,`ai-toast` 提示「已恢复 N 条待发」(参考 `notifyQueueCleared` 文案范式)。
### 3.5 UI 形态示意(文字稿)
```
┌ ChatInput ─────────────────────────────┐
│ [图片预览行] │
│ ┌──────────────────────────┐ ⏰ ➤ │ ⏰=定时;➤=立即
│ └──────────────────────────┘ │
└────────────────────────────────────────┘
点 ⏰ → 小面板: [5分钟] [30分钟] [1小时] [自选时间 ▾] (确认后入 outbox kind=scheduled)
┌ 待发/定时列表(抽屉) ───────────────────┐
│ 定时 (2) │
│ 🕐 14:30 会话A 「跑一下构建…」 改期 ✕ │
│ 🕐 明天9:00 会话B 「/status」 改期 ✕ │
│ 待发 (1) │
│ ⏳ 会话A 「继续」 立即 ✕ │
└─────────────────────────────────────────┘
```
---
## 四、与 todo 931 的共用关系(同源方案)
- **同一持久层**:931 队列项与 935 定时项**同表 `ai_outbox`**,仅 `kind`/`due_at` 区分。931 不再单独造 KV/表。
- **同一调度器**:队列=「`due_at` 为空、空闲即发」,定时=「`due_at` 到时、空闲即发」,共用 `outbox.rs` 派发循环与 `spawn_agentic_session`。
- **共用修复点**:网络超时/重启不丢(§3.3 reconcile + §3.4 视图化)同时治 931;「错误不清队列」由「outbox 项仅 `sending→pending` 回退,不删」天然满足。
- 分期上允许 **P0 先落 `scheduled`(935)**,`queue`(931 迁移)随后并入同表——同表即刻共用,队列侧前端派发下线放在 P1,降低单批爆炸半径。
---
## 五、分期与最小可用
### P0 — 后端待发箱 + 定时(最小可用,独立可验收)
- 新增:表 V51 + `models::AiOutboxRecord` + `crud/outbox_repo.rs`;`commands/ai/outbox.rs`(调度器+IPC+事件);`lib.rs` 挂载 `outbox::start` + `agentic/mod.rs:2917` 加 `outbox::notify_conv_idle` 钩子;`state.rs` 加 `outbox`。
- 前端:`useAiOutbox.ts` + ChatInput ⏰ 入口 + 定时列表抽屉 + `api/ai.ts` 包装 + i18n。
- 验收:§二「定时」两条 + 「多会话」+「不倒退」。此期**只服务 `kind='scheduled'`**;`queue` 仍走旧内存路径(未有回归)。
### P1 — 队列并入 outbox(治 931)
- 前端 `sendMessage` L1 改走 outbox;下线 `drainQueue`/`initDrainQueueListener`/`ai-drain-queue`;regenerate/edit/clear 接 IPC 清队。
- 验收:§二「断网/超时」「重启」两条 + 既有队列 UI 交互(编辑/回填/立即发送/取消/超时强发)零弱化。
### P2 — 进阶
- 飞书通知(`df-outbox-notify-feishu`);过期窗口策略(拍板点 4);`kind='task'` 扩展位预留供 937(定时任务/工作流经同一调度器,派发目标改 task/workflow 而非 `spawn_agentic_session`)。
---
## 六、影响面(新能力引入评估纪律五维,`docs/todo.md:446`)
- **兼容性**:纯新增表 + 新增 IPC/事件,旧库由 V51 幂等建表向上迁移,老数据零触碰;前端 `state.queue` shape 不变(视图契约)。迁移可断点续跑(逐条状态标记,无全局 COUNT 推算)、可逆(`DROP TABLE`+关开关)。旧 `ai-drain-queue` 事件在 P1 下线时保留一个版本的空发兜底,防旧前端混跑。
- **破坏性**:不可逆操作仅「迁移 V51 建表」(可 `DROP` 回退);P1 下线上前端派发属行为变更,以开关 `df-outbox-enabled=false` 可回退旧路径(过渡期保留旧内存队列代码)。
- **安全面**:无新端点/命令执行面。outbox 项若触发工具调用,仍走既有 `audit`/`approval`/边界模型闸门,**定时不等于放行**(与无人值守 L1 同级,不自动升档)。落盘内容(文本/图片 base64)属本地库既有信任域(与 `ai_messages` 同级)。改期/取消为本地 IPC。
- **资源面**:DB 增长——`sent/canceled` 行建议保留(胜于删除,便于审计)但**须有上限**:图片 base64 项加体积上限(沿用前端 `PARTS_SIZE_BUDGET` 50MB 量级口径,超限入队即拒并提示);可加「已完成项保留 N 天」的清理点(默认可不做,登记为观测)。调度器 tick 默认 10s,配合 `Notify` 即时唤醒,常驻开销可忽略(低于 unattended 300s 轮询)。
- **回退开关**:`df-outbox-enabled`(默认 true,关=调度停=项只入不发,可观测);`df-outbox-poll-secs`/`df-outbox-max-attempts` 热读。灰度顺序:P0 scheduled 先在本机自用验证 → P1 队列迁移 → P2 通知/937。观测指标:outbox 积压条数、`sending` 残留数、失败率、`due→sent` 延迟。
---
## 七、拍板点清单
| # | 决策 | 选项 | 建议 |
|---|------|------|------|
| 1 | 调度落点与「进程退出」语义 | A 后端 tokio 常驻(关窗托盘化期间生效;进程退出期间不生效,重启追赶补发);B 前端定时器(仅界面活着时生效);C 注册 OS 级计划任务(进程死也触发) | **A**。与无人值守同形态、复用 `spawn_agentic_session`;明确「进程退出=不触发,重启补发」为本设计边界,C 作远期非目标(跨平台成本高,偏移本设计) |
| 2 | 存储形态 | A 新表 `ai_outbox`;B 复用 `app_settings` KV | **A**。需按 `status+due_at` 索引扫描 + 逐条 CAS 改期/取消,KV 整块 JSON 读改写有并发覆盖风险且不可索引 |
| 3 | 队列与定时是否统一实体 | A 统一并入 outbox(`kind`/`due_at` 区分),前端队列视图化; B 队列保前端内存 + 独立后端定时器 | **A**。931 根因就是内存+前端自驱,B 会留双调度(草率聚合);A 一次治两需求 |
| 4 | 进程退出期间到期项的追赶策略 | A 全部到点即补发(迟到多久都补);B 迟到超阈值(如 >12h)标「已过期」+通知不补发;A+B 折衷 | **B(阈值建议 12h,值可调)**。补发全部易在长关机后制造批量突发发送;过期标记保证「不静默丢」(用户可见可手动重发),符合不静默丢失纪律 |
| 5 | 定时消息是否允许携带图片 | A 允许(限体积);B 定时项禁图片仅文本 | **A(B 作兜底)**。图片 base64 落库有膨胀,但用户「设好图片定时发」是合理诉求;用体积上限而非一刀切禁用 |
@@ -0,0 +1,254 @@
# 对话内定时/延时任务 — 设计
> 日期: 2026-10-10 | 状态: 设计稿(待拍板) | 需求锚点: `docs/todo.md:703` 条目 937 | 勘察: 只读实测(文件:行已核)
> 目标一句话: 在对话里直接说「1 小时后跑 X / 明早 9 点把任务 A 推进到审查」,系统登记一条**持久化的 `kind='task'` 待发箱项**,到点自动执行(注入发起会话续跑),任务卡显示「定时中/已触发」,并可在统一「定时列表」里查看/改期/取消。
> 边界: 仅设计,不改码。
> **与 935 的关系(要害)**: 937 **不新造调度底座**。表 / 调度器 / IPC / 事件 / KV / 「定时列表」抽屉**全部复用** [`定时消息与待发箱持久化-设计-2026-10-10.md`](./定时消息与待发箱持久化-设计-2026-10-10.md)(该设计已覆盖 todo 935 定时消息 + 931 队列,并在其 §3.1 明确预留 `kind='task'` 给 937、§五 P2 交棒)。**本设计只定义 `kind='task'` 这一分支的语义 + AI 工具面 + 触发执行 + 权限边界**。
> 关联: 并行化与异步唤醒-设计(§二 wake delay 型 / §6.3 挂机)/无人值守长任务-设计(TaskBoundary 边界)。
---
## 一、现状勘察(锚点实测)
### 1.1 AI 工具面(新工具的正确落点)
| 件 | 锚点 | 现状 |
|----|------|------|
| 注册宏 | `crates/df-ai/src/ai_tools_decl.rs` `declare_tool!` | 纯 `macro_rules!`,零新依赖;新工具**只加一个 `tools/<域>.rs` 文件** + 在对应 `register_*` 接一行 |
| 注册总入口 | `src-tauri/src/commands/ai/tool_registry.rs:541` `build_ai_tool_registry` | `register_data_tools`(:735) 内 `register_task_tools`(:737)/`register_workflow_tools`(:739);二者已 thin 委托 `tools::task::register`(:767)/`tools::workflow::register`(:813) |
| 任务工具 | `src-tauri/src/commands/ai/tools/task.rs` | 6 个:list_tasks(:40)/create_task(:72)/advance_task(:185,状态机推进唯一合法路径)/delete_task/get_task_count。**无任何「延后」维度** |
| 工作流工具 | `src-tauri/src/commands/ai/tools/workflow.rs:35` `run_workflow` | High 风险;handler 是防御兜底(返 Err),**真正执行经 `ai_approve` → `execute_run_workflow_for_tool`**(`src-tauri/src/commands/ai/mod.rs:1367`)→ `run_workflow_inner`(`src-tauri/src/commands/workflow.rs:127`);空 dag + target_status 时按 `template_for`(`crates/df-nodes/src/task_workflow_templates.rs:28`)自动选模板 |
| conv_id 透传先例 | `execute_run_workflow_for_tool`(`mod.rs:1367`) | AI 工具 handler 无 `State`,**发起会话 id 经调用方(task-local/参数)注入**——`schedule_task` 记录发起会话照此 |
| 基线守卫 | `tool_registry.rs` 测试 `test_build_ai_tool_registry_baseline_tool_count` | 钉死工具名集合;**新增工具须同步该断言(+1)** |
### 1.2 任务体系与推进链(被定时触发的目标能力,均已就位)
| 件 | 锚点 | 现状 |
|----|------|------|
| 状态机 | `crates/df-nodes/src/task_state_machine.rs` | 8 态合法转换纯函数;done 唯一终态 |
| 原子推进 | `crates/df-nodes/src/task_advance_node.rs:50` `advance_task_atomic` / `:335` `advance_task_with_parent` | 状态机校验 + CAS + review_rounds + 父聚合,已与 IPC/AI 工具三方同源 |
结论: 「到点后要做的事」(推进任务/跑工作流/执行指令)**能力齐备**,缺的只是**外部时间驱动源**与**工具面的延时表达**。
### 1.3 后端常驻调度器先例(unattended;935 已定 outbox 抄其形态)
| 件 | 锚点 | 现状 |
|----|------|------|
| 常驻轮询 | `src-tauri/src/commands/ai/unattended.rs:508` `start`;`lib.rs:84` 挂载 | `async_runtime::spawn` + `interval.tick()` / `notify.notified()` `select!` |
| 共享态/唤醒 | `unattended.rs:459` `EngineShared{notify: Notify, in_flight,…}`;`:483` `notify_batch_done` | 单在途 + 完成钩子即时唤醒 |
| 开工链路 | `unattended.rs:847` `spawn_agentic_session`(`chat.rs:796`,10 参,`pin_active=false`) | 后台会话派发正典入口 |
| 生命周期事实 | `lib.rs:614` `on_window_event` `prevent_close`+`hide` 托盘化 | **关窗=进程仍在=调度器照跑;进程退出=停,重启补发** |
**关键缺口(937 视角)**: 引擎作业源是看板文件,**不落库、不看时间点**;真正解决「持久化 + 到点」的是 935 设计的 `ai_outbox`(见下节),937 直接搭车。
### 1.4 存储 / 恢复 / 事件 / UI 样板(937 复用链)
| 件 | 锚点 | 现状 / 复用点 |
|----|------|--------------|
| 迁移框架 | `crates/df-storage/src/migrations.rs:9` `run`;调度数组 `:108-121`(止于 `(50, migrate_v50)` @ `:1483`) | 追加 `(N, migrate_vN)` 即挂;**935 占 V51(建 `ai_outbox`),937 用 V52 加列** |
| 加列样板 | `migrations.rs:1366` `migrate_v45` | `column_exists` 守卫 + `ALTER TABLE ADD COLUMN`,幂等可重跑;可 `DROP COLUMN` 回退 |
| 启动恢复样板 | `audit/restore.rs:26` `restore_pending_approvals` | 从表重建内存态;outbox 的 `reset_stale_sending` 同款 |
| 会话来源 | `models.rs:389` `AiConversationRecord.origin`(现仅 `"sub_agent"`) | 定时触发落后台会话时可用新值 `"schedule"` |
| 事件 | `mod.rs:134` `AiChatEvent`(`#[serde(tag="type")]`) | 935 定的 `ai-outbox-changed/sent/failed` 直接复用 |
| 卡片范式 | `src/components/ai/TaskParkedCard.vue` | 「定时任务卡」可照此(状态徽标 + 行为后更新) |
### 1.5 缺口清单(937 专属,底座缺口由 935 覆盖)
1. **工具面无延时维度**:LLM 无法表达「1 小时后」(`advance_task`/`run_workflow`/`create_task` 均即时)。
2. **无 `kind='task'` 分支**:935 的 `ai_outbox` 表/调度器只服务消息派发,`kind='task'` 的**载荷字段**(结构化指令/任务关联/边界快照)与**派发目标**(注入会话 or 直推任务)**尚未定义**。
3. **无触发执行的落点语义**:到点「谁来跑、跑哪个会话、结果给谁看」未定。
4. **无对话内定时卡片**:工具卡如何呈现「定时中/已触发」未定。
---
## 二、目标与验收口径
| 目标 | 验收口径(可实测) |
|------|-------------------|
| G1 对话内创建 | LLM 调 `schedule_task(instruction, delay_secs)`,对话出现**定时任务卡**(指令摘要 + 触发时刻/倒计时 + 「定时中」徽标);`ai_outbox` 落一行 `kind='task'`, status=pending |
| G2 到点触发 | 到 `due_at` → 调度器把指令注入**发起会话**续跑(等价一条 user 消息注入),卡片变「已触发」,新 assistant 回复出现在原会话 |
| G3 持久存活 | **应用关闭跨过 due_at**:关窗托盘化期间照常触发;进程真退出则重启后按 935 追赶策略(§四)补触发或标过期;卡片状态经重启不丢 |
| G4 管理面 | 统一「定时列表」(935 抽屉,937 项并列显示,类型标「任务」)可查看/改期/取消;改期/取消即时写库刷新卡片 |
| G5 边界合规 | 创建 = Medium 风险(走既有审批);**触发执行零旁路**——写/High 风险仍在触发会话内走既有 `audit`/`path_auth`;黑名单永不放行 |
| G6 不打架 | 触发时发起会话正生成 → 轮界排队(不打断);会话已删/不可用 → 回落独立后台会话(origin=`schedule`) |
| G7 零回归 | 既有 advance_task/run_workflow/create_task 行为不变;无 task 作业时调度器空转零副作用;935 的 `queue`/`scheduled` 分支不受影响 |
---
## 三、设计方案
### 3.1 总体判断(先说结论)
1. **新工具 `schedule_task`,不复用 `advance_task` 加调度字段。** `advance_task` 的契约是「此刻」推进状态机(CAS + `applied.matched_target` 断言,`tools/task.rs:185`),loop 收敛判定依赖它;改成「可能延后」会污染该断言并让「任务状态」与「调度时点」耦合。调度是正交能力:指令里可以*提到*任务,但调度本身不是状态迁移。
2. **底座全复用 935 的 `ai_outbox` + `outbox.rs` 调度器,937 只加 `kind='task'` 分支。** 不新造表/不新造 loop,与需求「与 935 共用调度器」一致;亦避免「双调度器」的草率聚合。
3. **触发执行的通用形态 = 注入发起会话(把指令当一条用户消息续跑)**,由触发后的 agentic loop 统一消化(它本就会 `advance_task`/`run_workflow`);确定性「直推任务状态」作 P2 快路径(§3.6、拍板点 4)。
### 3.2 数据形态:复用 `ai_outbox` + 迁移 V52 增列
935 已定义 `ai_outbox`(`定时消息…设计` §3.1,V51),其列 `text / skill / model_override / parts_json / spans_json / due_at / status / attempts` 面向消息。937 复用其 `text`(= 指令原文,供统一列表渲染摘要)、`due_at`(= 触发时刻)、`status`、`attempts`,并**新增三列**承载 task 专属载荷:
```sql
-- 迁移 V52(937):幂等 ALTER(照 migrate_v45 样板),可 DROP COLUMN 回退
ALTER TABLE ai_outbox ADD COLUMN title TEXT; -- 卡片/列表标题(缺省取指令前 N 字)
ALTER TABLE ai_outbox ADD COLUMN payload_json TEXT; -- 结构化载荷 {instruction, task_id?, target_status?}
ALTER TABLE ai_outbox ADD COLUMN boundary_json TEXT; -- 触发执行继承的边界快照(可空=审批模式)
```
- `kind='task'` 行:`text`=指令人读文本(列表摘要用);`payload_json`=`{instruction, task_id?, target_status?}`(instruction 冗余存一份便于派发时直接取;task_id/target_status 供 P2 直推快路径)。
- **不新增独立表**。`AiOutboxRecord`(`定时消息…设计` §3.1)对应加三个 `Option` 字段;`outbox_repo.rs` 的 insert/读方法带这三列(其余复用)。
- 迁移编号:935 占 V51,937 占 V52;若两需求同批落地,可合入一个迁移函数一次到位(建表 + 增列),但保持「V51 建表 / V52 增列」两段亦可(幂等,顺序无关)。
### 3.3 工具面:新增 `schedule_task`(`declare_tool!`,Medium)
新文件 `src-tauri/src/commands/ai/tools/schedule.rs`,仅一个工具;`tool_registry.rs:737` 旁接线(`register_schedule_tools`),基线测试 +1。
```rust
declare_tool!(
registry, db: Arc<Database>,
"schedule_task",
"登记一个定时/延时执行的任务作业:到点后在发起会话里自动执行 instruction(等价到点时我把这句话重新对你说一遍)。\
用 delay_secs 表达相对延时(如「1 小时后」=3600),或用 at 表达绝对时刻(RFC3339 或 epoch ms)。二者至少给一个,同时给以 at 为准。\
到点执行受既有审批/路径授权约束,不绕过任何安全闸。返回 job_id 与触发时刻。",
RiskLevel::Medium, // 登记未来自动执行,须人工知情(对齐 advance_task 的 Medium)
meta: ToolMeta {
entity_action: Some(("schedule", "create")),
approval_hint: Some(("创建定时任务:{}", &["instruction"])),
display_verb: Some("创建定时任务"),
..Default::default()
},
schema: object_schema(vec![
("instruction", "string", true), // 到点要执行的自然语言任务(必填)
("delay_secs", "integer", false), // 相对延时(秒),与 at 至少一个
("at", "string", false), // 绝对时刻 RFC3339;优先于 delay_secs
("title", "string", false), // 卡片标题(缺省取 instruction 前 N 字)
]),
args => {
// 1. 解析 instruction(必填)/delay_secs/at/title → 折算 due_at(epoch ms)
// 2. 发起 conv_id:handler 无 State → 经调用方注入(task-local,对齐 execute_run_workflow_for_tool mod.rs:1367)
// 3. outbox_repo::insert(kind="task", text=instruction, payload_json={instruction}, title, due_at, status=pending)
// 4. 唤醒调度器 notify → emit("ai-outbox-changed") 前端插卡
// 5. 返回 { job_id, due_at }
}
);
```
- **相对 `delay_secs` 为主**(免时区/解析歧义);`at` 为便利补充(LLM 依 system prompt 里的当前时间折算)。
- **conv_id**:AI 工具 handler 是无 `State` 闭包;发起会话 id 沿用 workflow 工具先例经 task-local 注入(`execute_run_workflow_for_tool` 由 `ai_approve` 带 `conv_id` 调,`mod.rs:1367`)。`ai_outbox.conversation_id` 为 `NOT NULL`,而 AI 工具场景必在会话内,故恒可填;极少数拿不到 conv_id 的路径退回「前端/列表入口创建」(935 §3.4 composer 入口)。
### 3.4 调度器:复用 935 `outbox.rs`,加 `kind='task'` 派发分支
935 的调度循环(`定时消息…设计` §3.3)按 `kind` 分派。937 **只加一个分支**:
```
// outbox.rs list_due 取项后的派发(schema 由 935 定义,此处补 kind 分支)
match item.kind.as_str() {
"queue" | "scheduled" => { /* 935 既有:spawn_agentic_session(text,...) */ }
"task" => fire_task_job(app, state, &item), // ← 937 新增(§3.5)
other => { warn!(unknown kind); }
}
```
- **CAS/并发/在途/唤醒钩子全部复用** 935:同会话忙(`can_accept_request=false`)→ 本轮跳过下次再取(不丢);`notify_conv_idle` 完成钩子即时续发;`claim` CAS 防双取;`reset_stale_sending` 启动复位。
- **`df-outbox-enabled` 复用**:关 = 所有 kind 冻结(937 的 task 也冻结),一致。
### 3.5 触发执行语义(`fire_task_job`,937 核心)
1. **注入文本**:`[定时任务触发] {instruction}`(固定前缀族,便于前端识别 + 完成卡归属)。
2. **落点选择**:
- 发起会话存在且 `can_accept_request` → **注入发起会话**:走 `spawn_agentic_session(text=<前缀+instruction>, …, Some(conv), …, pin_active=false)`(与 935 派发同源,`chat.rs:796`)。
- 发起会话正生成 → **轮界排队**:`revert_pending`(复用 935 的忙回退),下一 tick 重试(不打断流)。
- 发起会话已删/不存在 → **回落独立后台会话**:`Some(new_id())` + `origin="schedule"`,不静默丢。
3. **落库**:`mark_sent`(复用 935 语义:`spawn` 返回 Ok 即受理成功)+ `emit("ai-outbox-sent", {id, conv})`。
4. **失败**:spawn 前置失败(provider 未配/永久 4xx)→ `mark_failed`(`permament(error)` 复用 935 判据)+ 通知;非永久 → `revert_pending` 下轮重试。**LLM 轮次内的失败不回流**(与 935 同界定,交既有 loop 自愈)。
### 3.6 关闭/重启行为(复用 935 追赶策略,937 不另立规则)
- 迁移/恢复:复用 935 的 `reset_stale_sending`(启动把残留 `sending` → `pending`);调度器只认 `due_at <= now`,故**进程退出期间到期的 task 项重启后立即命中**。
- **迟到追赶策略 = 复用 935 拍板点 4(阈值 12h,建议值)**:迟到 ≤ 阈值 → 补触发;迟到 > 阈值 → 标过期(`status='expired'`? 935 用 `failed`/`canceled`,建议新增 `expired` 或用 `failed`+`last_error="missed"`)+ 通知,列表可见可手动重排。**937 不引入 24h 另一套阈值**,统一走 `df-outbox-*` 配置(如需 task 专属阈值,加 KV `df-outbox-task-missed-grace-secs`,默认同 12h)。
- 有界性:任何 task 作业按 `due_at + 宽限` 有界,不无限滞留。
### 3.7 权限与边界(与既有 audit/boundary 链的关系)
- **创建**:`schedule_task` Medium → 走既有 `audit/approval`(三档/边界会话按现行规则);**创建 ≠ 授权执行**。
- **触发执行**:走**同一** pipeline。`fire_task_job` 只把一句话送进会话,此后 agentic loop 里每个工具照常过 `audit`(写/High 审批)、`path_auth`(路径授权)、黑名单拒绝。**不新增任何旁路,不预授权**(红线)。
- **边界快照 `boundary_json`(可选)**:若发起会话已声明 `TaskBoundary`(`boundary.rs`,risk_ceiling≤Medium,落 `ai_conversations.boundary_json` V45),创建时可快照;触发时注入触发会话 → 边界内自动放行、边界外照旧拒绝。未声明 → 不写快照 → 触发走普通审批(首个写操作停靠等人工 + 通知)。
- **黑名单/破坏性命令**:永不放行(同 all 档/子代理先例)。
- **MCP 面(独立表面,首期不接)**:`crates/df-mcp/src/tools.rs:101` `all_tools()` 是**独立硬编码清单**(非 AI registry 派生);首期不把 `schedule_task` 加入 MCP(定时依赖本机 GUI 进程 + 调度 loop,远程场景语义不闭合),MCP 侧零改动。
### 3.8 UI 形态(复用 935 抽屉,937 只加卡片)
- **对话内定时任务卡(工具卡变体)**:`useToolCard.ts:513` 登记 `schedule_task` formatter;卡面 = 标题 + 指令摘要 + 「⏰ 触发于 MM-DD HH:mm(还有 X 分钟)」+ 状态徽标(`定时中`/`已触发`/`已取消`/`已过期`/`失败`)。**状态随 `ai-outbox-changed/sent/failed` 事件更新**(到点晚于工具返回,不能靠静态 tool_result)。形态照 `TaskParkedCard.vue`(per-conv 卡,行为后摘/更新)。
- **统一「定时列表」抽屉 = 复用 935**(`定时消息…设计` §3.4):937 的 task 项与 935 的 scheduled/queue 项**同列同抽屉**,仅多一列「类型=任务/消息」+ task 项显示关联任务(若有)。改期/取消复用 935 的 `ai_outbox_reschedule/cancel` IPC。
- **可选 task 专属入口**:ChatInput ⏰ 入口(935 已加,`ChatInput.vue:107`)+ 若要在列表里「手动新增定时任务」,复用同一 IPC(`ai_outbox_enqueue` 加 `kind='task'`+payload,或 937 加 `ai_schedule_task` 薄封装,见拍板点)。
- 设置页/TopBar 徽标:复用 935 的 `df-outbox-*` 区块与状态查询,不另开。
### 3.9 与既有链路的关系
- **worktree / 评审**:触发执行 = 一次普通 agentic run,worktree/review/run_workflow 全照旧;指令若含「推进任务/跑工作流」,由触发后的 loop 调 `advance_task`/`run_workflow`(工作流仍走 High 审批)。
- **无人值守引擎**:两者独立 loop、独立作业源;挂机在途(`in_flight`)与 outbox 派发各管各 conv,不冲突。
- **P2 快路径(可选)**:确定性作业「到点直推任务状态」(`payload_json.task_id`+`target_status`)可**不起 LLM**,直接 `advance_task_with_parent`(`task_advance_node.rs:335`)——省 token、免 LLM(状态机推进本身非破坏);须在创建时校验状态机合法性。见拍板点 4。
---
## 四、分期与最小可用
| 期 | 范围 | 交付 | 依赖 |
|----|------|------|------|
| **P1(最小可用)** | 工具面 + task 分支闭环 | V52 增列 + `AiOutboxRecord` 三字段 + repo + `schedule_task` 工具 + `fire_task_job` 注入发起会话 + 启动追赶快照 + 工具卡徽标 | **935 P0 底座(`ai_outbox` 表 + `outbox.rs` 调度器 + 列表抽屉)** |
| **P2(管理/快路径)** | 列表类型区分 + 直推 | 列表 task 项类型标 + 关联任务显示;可选确定性直推快路径 + KV 阈值 | P1 稳定 |
| **P3** | 边界快照 / 与挂机联动 | `boundary_json` 快照注入触发执行;挂机自领定时任务(可选) | P2 + 边界模型落地 |
**最小可用定义**: P1 完成即达标——「对话里说 1 小时后跑 X → 卡片定时中 → 到点(含重启补触发)在原会话自动执行」。
**关键依赖**: 937 的 `kind='task'` 分支**不能脱离 935 的 `ai_outbox` 底座独立交付**;两需求应**同批或先后紧邻落地**,先 935 P0(表+调度器)后接 937 分支。这是本设计对编排的硬约束。
---
## 五、影响面(新能力引入评估纪律 · 五维)
| 维度 | 判定 |
|------|------|
| **兼容性** | 复用 `ai_outbox`(V51)+ 937 加三列(V52,`column_exists` 幂等,可 `DROP` 回退);既有工具/任务/工作流零改动。**基线测试 `test_build_ai_tool_registry_baseline_tool_count` 须 +1(schedule_task)**。935 的 `queue`/`scheduled` 分支不受影响(仅多一个 `match` 分支)。**PASS(带迁移)** |
| **破坏性** | 无破坏性数据操作;不改 advance_task/run_workflow 语义;触发=发消息/起会话,可逆(取消/停止)。**PASS(低)** |
| **安全面** | 新攻击面 = 「未来自动执行 LLM/用户给定指令」。缓解:①创建 Medium 须知情审批 ②触发执行**零旁路**,写/High/路径/黑名单全走既有闸(G5/G6)③边界快照仅在会话已声明边界时生效(不放大权限)④可随时取消。**红线**:不得为「定时」开任何 approval/path 旁路。**PASS(受控)** |
| **资源面** | 复用 935 表与 10s tick loop;937 只多三列 + 一个 match 分支,资源增量≈0。task 项随 outbox 统一设上限(935 已定 sent/canceled 有界策略)。**PASS** |
| **回退开关** | 复用 `df-outbox-enabled`(置 false → task 项只入不发,冻结);V52 可 `DROP COLUMN` 回退。KV 热读免重启。**PASS(带开关)** |
---
## 六、拍板点(需人定,附建议)
1. **触发执行落点**:注入**发起会话**(结果可见、上下文连续,贴「对话内创建」语义)【建议默认】 vs 独立后台会话(不打扰但结果另起)。建议前者,发起会话忙则轮界排队、不可用则回落后台会话(§3.5 两全)。
2. **与 935 的落地顺序**:937 的 `kind='task'` 分支**依附 935 的 `ai_outbox` 底座**——先 935 P0 后接 937【建议】 vs 937 自建一套表/调度器(违反「共用调度器」且制造双调度,否决) vs 两设计合并为一次实施(978/935/937 同批)。建议前者,把「共用底座」写死为编排约束。
3. **触发执行授权**:默认**普通审批模式**(未声明边界的发起会话 → 触发后首个写操作停靠等人工并通知)【建议,最安全】 vs 创建即固化「完全信任」(攻击面大) vs 仅当发起会话已声明 `TaskBoundary` 时携带边界快照(边界内免打扰)。建议:默认普通审批 + 「有边界则快照」并存。
4. **快路径**:起步只做 `instruction`(通用,触发后 loop 消化一切)【建议】 vs 起步即带 `task_id`+`target_status` 确定性直推(省 LLM/免审批,但限死用途、需额外状态机校验)。建议 instruction 起步,直推作 P2(§3.9)。
5. **迟到追赶阈值**:复用 935 拍板点 4 的统一阈值(建议 12h)【建议】 vs 为 task 单设更短阈值(定时任务含副作用,可能希望更保守)。建议统一阈值起步,观测后如需再拆 KV。
---
## 七、明确不做
- 不做 cron 表达式/周期重复任务(首期只做**一次性**定时;周期任务=挂机模式职责,另议)。
- 不做跨设备/云端定时(复用 935 边界:关窗托盘化期间生效,进程退出期间不触发,重启补发)。
- 不做定时执行结果的独立推送通道(复用 `notify_session_event` + 飞书,与 935 同出口)。
- 不为定时开任何 approval/path 旁路(红线)。
- 不新造调度底座(表/循环/IPC/事件/抽屉全复用 935)。
---
## 附:实施落点清单(供实施会话直接取用;底座项见 935 设计,此处只列 937 增量)
| 文件 | 改动 |
|------|------|
| `crates/df-storage/src/migrations.rs` | 增 `migrate_v52`(3 个 `ALTER TABLE ai_outbox ADD COLUMN`,幂等);调度数组追加 `(52, migrate_v52)`;补幂等单测。**注意:V51 由 935 占,顺序不得换** |
| `crates/df-storage/src/models.rs` | `AiOutboxRecord`(**935 已加**)再补 `title/payload_json/boundary_json: Option<String>` |
| `crates/df-storage/src/crud/outbox_repo.rs`(**935 已建**) | insert/读方法带三新列;`list_by_status`/`list_due` 复用 |
| `src-tauri/src/commands/ai/tools/schedule.rs`(新) | `schedule_task` 工具(`declare_tool!`,Medium);`tool_registry.rs:737` 旁接线 + 基线测试 +1 |
| `src-tauri/src/commands/ai/outbox.rs`(**935 已建**) | 调度派发 `match kind` 加 `"task" => fire_task_job(...)`;`fire_task_job`(§3.5)+ 追赶快照 |
| `src-tauri/src/commands/ai/mod.rs` | 事件复用 `ai-outbox-changed/sent/failed`(935 已定);如卡片需状态细分可加可选字段,不新事件名 |
| `src-tauri/src/lib.rs` | `outbox::start` 由 935 挂载;937 无需另挂 |
| `src/composables/ai/useToolCard.ts` | `:513` 映射增 `schedule_task` formatter(定时任务卡) |
| `src/components/ai/` | 定时任务卡(仿 `TaskParkedCard.vue`);列表抽屉 task 项类型标(复用 935 抽屉) |
| `src/api/ai.ts` | 复用 935 `ai_outbox_*` 封装;若新增「手动建定时任务」入口再加薄封装 |
| `src/i18n/zh-CN/*` + `en/*` | 卡片状态词(`定时中`/`已触发`/`已过期`…)/类型「任务」 |
> 参照档: `定时消息与待发箱持久化-设计-2026-10-10.md`(935/931,**共用底座,必读**)· `无人值守长任务-设计-2026-10-04.md`(结构/详略)· `并行化与异步唤醒-设计-2026-10-04.md`(§二 wake delay 型/§6.3 挂机)· `输入框列表预览与自动编号-设计-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,245 @@
# 消息渲染管线重构 — 设计
> 日期: 2026-10-10 | 状态: 设计稿(待拍板) | 勘察: 只读实测锚点(全仓零 `v-memo`,实测 `grep -c v-memo = 0`)
> 对象: `src/components/ai/MessageList.vue`(3041→实测 **3054 行** God 组件)
> 目标(用户原话转译): 长会话流式掉帧根治 —— 拆分 God 组件、渲染记忆化、引入 `v-memo`、去 `JSON.stringify` 指纹、流式帧降载。
> 说明: 本专题为**只读走查 + 设计**,不含代码实施;所有行号均为实测锚点(文件以 `E:/wk-lab/devflow` 为根)。
---
## 一、现状勘察(锚点实测)
### 1.1 文件构成(3054 行 = script 1476 + template 460 + style 1117)
| 段 | 行区间 | 行数 | 备注 |
|----|--------|------|------|
| `<script setup>` | `1–1476` | 1476 | 逻辑 + 组合式调用 + 大量内联渲染辅助 |
| `<template>` | `1477–1936` | 460 | 单一 `v-for` 扁平渲染 + 6 个 `v-if/v-else-if` 分支 |
| `<style scoped>` | `1938–3054` | 1117 | 全组件样式集中于此(拆分后随子组件 scoped 自然分流) |
职责分类(**>6 类**,单一组件内并存):
1. 列表编排/分段 —— `messageSegments`(`:837`)、`renderItems`(`:1067`)、时间线 `needsTimeSep`(`:1049`);
2. 轨迹卡/步骤 —— trace 分支(`:1510`)/step 分支(`:1587`)+ 聚合助手 `:993–1246` + 轮次导航 `traceNav`(`:1386`);
3. 子代理完成卡 + peek 浮层 —— agentFin 分支(`:1601`)+ `openAgentTrace`(`:1280`)+ `agentPeek*`(`:1297–1384`);
4. 消息气泡 —— msg 分支(`:1644–1784`),user 走子组件 `MessageItem.vue`,assistant/system/json/长文折叠/错误操作条内联;
5. token 用量 —— 派生 `:172–224` + 模板块 `:1792+`;
6. 复制/选区 —— `copyMsgContent`(`:603`)、`copyMsgAsImage`(`:640`)、`saveSelection`/`restoreSelection`(`:501/:545`)、`offsetToDOMPosition`(`:481`)、代码块复制(`:728`);
7. 滚动/折叠联动 —— `useMessageScroll`(`:21/:230–238`)、`collapseAllToolLists`(`:395`)、`anchorCollapseScroll`(`:354`)、`buildActiveToolIds`(`:380`);
8. 展开状态集合 5 组 —— `useToggleSet`(`:869–880`)+ `expanded*`(`:883/:891/:944/:949/:953`)。
流式渲染(`useStreamRenderer.ts`)与 Markdown(`useMarkdown.ts`)此前已抽出;**本专题针对剩余编排层**。
### 1.2 热路径触发链(谁在每帧/每次事件重算)
| 触发源 | 频率 | 影响 | 锚点 |
|--------|------|------|------|
| `streamingBlocks` 每帧更新(rAF) | **每帧** | 组件 render 函数整体重跑 → `v-for` 对全部 item **逐条新建 vnode**(全仓零 `v-memo`) | `useStreamRenderer.ts:232`(rAF 内赋值)、模板 `:1727–1728` |
| 工具事件(status 翻转) | 每次工具事件 | `renderItems` 全量 O(n) 重算(读 `m.toolCalls`/`tc.status`/`tc.completedAt`) | `renderItems :1067`;status 就地改 `useAiToolEvents.ts:51/101/119`、`useAiApproval.ts:87/109`、`useAiConversations.ts:501`、`useAiStreamingEvents.ts:123` |
| 指纹 watch | 每次工具事件 / 消息数变 | `map(m=>toolCalls→{id,s})` + `JSON.stringify` 全量 O(n) | **`:429–442`**(source `:430–433`) |
| 展开/折叠 toggle | 用户点击 | `renderItems` 重算(trace/seg 集合在 dep 内)+ 全组件 render | `:1116`(expandedTraceIds)、`:1152`(expandedSegmentIds) |
**关键链路细节**:
- `renderItems`(`:1067–1189`)读 `store.state.messages`(数组)、`shouldRenderMsg`(`:159–167`,读 `msg.content?.trim()`)与 `isViewingGenerating`(`:126`)。**流式 delta 期间末条 AI 的 `msg.content` 不被改写**(正文回填在收尾单点 `aiShared.ts:544`),故 `renderItems` 本身**不逐帧重算**;逐帧的开销来自 render 函数里 `v-for` 对全部 item 重建 vnode(无 `v-memo` 跳过)。工具事件则确会触发 `renderItems` 全量重算。
- `flushBucket`(`:1075`)对每个桶 `collectTurnStats`(`useTurnTrace.ts:90`,遍历全部 toolCalls)+ trace 桶 `sumTurnTokens`(`useTurnTrace.ts:134`,遍历全部消息 token 字段)+ 模板逐条 `traceTaskRefs(item.traceMsgs)`(`:88`,遍历 toolCalls 提取任务引用)。
### 1.3 已有的内容级缓存(部分缓解,非本次病灶)
| 设施 | 锚点 | 粒度 |
|------|------|------|
| `renderMd` 整段缓存 LRU | `useMarkdown.ts:79`(`_MD_CACHE_LIMIT=200`)、`:195–207` | 文本→HTML |
| 代码块装饰缓存 LRU | `MessageList.vue:681–711`(`DECO_CACHE_LIMIT=800`) | 文本→装饰 HTML |
| 流式块级 memo + 增量切块 | `useStreamRenderer.ts:59`(`BLOCK_CACHE_LIMIT=300`)、`:67–79` | 单块→HTML |
| `MessageItem` 派生 computed | `MessageItem.vue:99–101` | 单条 user 派生值 |
**缺口(本次目标)**:内容级缓存已就绪,但**列表结构级(renderItems)与 vnode 级(v-memo)缺优化** —— 前者在工具事件时全量重算,后者在每帧重建全列表 vnode。
---
## 二、目标与验收口径
**功能目标**
1. `MessageList.vue` 拆分至 **单文件 <400 行**(script+template;style 随子组件 scoped 分流)。
2. `renderItems` **记忆化**:按桶缓存,桶内容未变即复用同一 item 对象(稳定引用),不再 O(n) 重建。
3. 引入 **`v-memo`**:流式每帧仅重渲"真正变化"的 item,其余 item 跳过 vnode 新建/补丁。
4. 指纹 watch **去 `JSON.stringify`**,改 O(1) 版本计数。
5. 流式帧渲染**降载**:rAF 合并(已有)+ 离屏段落跳过。
**验收口径**
- 长会话(≥200 条)+ 长回复流式:每帧 patch 的 item 数从"全列表"降到"1(末条)"——Vue DevTools render 计数或 dev 打点可观测;人眼无掉帧、无卡顿。
- 工具事件(审批/完成/拒绝)后:轨迹卡统计(`collectTurnStats`)与聚合行正确刷新;`renderItems` 重算次数 ≤ 变更桶数。
- 展开/折叠(轨迹卡/思考 pill/JSON/长文/折叠段)即时生效,无"点了没反应"。
- 复制/选文字保持/错误重试/子代理 peek/轮次导航跳转等既有交互逐条回归无弱化。
- 不倒退:普通单轮问答、历史会话滚动、切换会话行为与改造前逐字节一致。
- `vue-tsc --noEmit` 与 `pnpm test:e2e`(Playwright,`tests/e2e/smoke.spec.ts`)全绿。
---
## 三、设计方案
### 3.1 核心判断:三件事正交,可分笔落地
病灶分处三层,互不耦合,可分笔提交、独立回退:
- **vnode 层**(每帧) —— `v-memo` 缺 → 补 `v-memo`(不改结构)。
- **列表结构层**(每工具事件) —— `renderItems` 全量重算 → 桶 memo 缓存(不改结构)。
- **代码组织层**(可维护性) —— God 组件 → 拆 composable / 子组件(行为零变)。
设计原则:`v-memo` 与 memo 缓存**不改 `renderItems` 输出契约**(仍返回 `RenderItem[]`,`:1018–1028` 类型不变),模板 6 分支结构不变 → 回归面收敛在"渲染策略"而非"渲染语义"。
### 3.2 `renderItems` 记忆化(稳定 item 对象)
**输入指纹(桶签名 `sig`)**:仅取 `renderItems` 真正消费的字段(与 `:429` 指纹同源思路,但**按桶**):
```
sig(bucket) =
bucket.map(m => m.id + ':' + m.status + ':' + (m.toolCalls ?? [])
.map(t => t.id + ':' + t.status + ':' + (t.completedAt ?? 0) + ':' + (t.argsHash ?? '')).join(','))
.join('|')
+ '#' + finalIndex // 完成态桶的 finalMsg 下标
+ '#' + (expandedTraceIds.has(traceKey) ? 1 : 0)
```
**缓存**:组件级 `Map<string, RenderItem[]>`(LRU 有界,如 128,沿用 `_decoCache:681` 同款范式)。命中即回**同一 item 对象数组**(引用稳定);未命中才 `collectTurnStats`/`sumTurnTokens`/`traceTaskRefs` 并写缓存。顶层 `items` 数组仍每次重组(桶数 ≪ 消息数,成本可忽略)。
**收益**:①工具事件时 O(n)→O(变更桶);②item 引用稳定 → `v-memo` 的 dep 可用 `[item]` 表达(见 3.3)。
**风险**:签名漏字段 → 命中陈旧 item → 界面不刷新。故签名须覆盖所有影响输出的字段;此风险由 3.3 的保守 dep 兜底(primitive dep 即使签名漏,值变仍会重渲)。
> 备选(更保守):不引入桶 memo,P0 仅 v-memo。但工具事件时的 O(n) 重算仍在 —— 建议两步都做、分两笔提交。
### 3.3 `v-memo` 依赖数组口径
**核心原理**:`renderItems` 是 computed,跨帧被缓存(流式帧不重算)→ item 对象跨帧引用稳定;任其**已读取**的响应式源变化 → 重算 → 新 item 对象 → `[item]` dep 变化 → 该 item 重渲。因此 **dep 只需补"`renderItems` 未读、但模板渲染该 item 时读了"的兄弟响应式源**。
**已读(自动失效,无需列入)**:`store.state.messages`、`m.status`/`m.role`/`m.content`(经 `shouldRenderMsg`)、`expandedTraceIds`(`:1116`)、`expandedSegmentIds`(`:1152`)、`isViewingGenerating`(读 `streaming`)。
**未读(必须补入 dep,否则 v-memo 跳过致不刷新)**:
| 源 | 影响 | 锚点 |
|----|------|------|
| `expandedThinkIds` | 轨迹卡内思考 pill 展开 | `:953/:1559` |
| `expandedJsonIds` | JSON 工具结果展开 | `:944/:1711` |
| `expandedLongIds` | 长文折叠/展开 | `:891/:898/:1714` |
| `tokenPopoverMsgId` | token 详情面板显隐 | `:180` |
| `editingMsgId` | user 编辑按钮显隐 | `:1691` |
| `agentPeekLoadingId` | 子代理 peek 转圈 | `:1625` |
| `_mdRenderKey` | marked 就绪后首屏重渲 | `:457–458/:718` |
| 任务标题源 | trace 任务 chip 文案 | `taskTitleOf :83` |
**推荐写法**(DRY 单出口,补 dep 一处维护):
```ts
// template: <template v-for="item in renderItems" :key="item.key" v-memo="itemDeps(item)">
function itemDeps(it: RenderItem): unknown[] {
return [it, expandedThinkIds.value, expandedJsonIds.value, expandedLongIds.value,
tokenPopoverMsgId.value, editingMsgId.value, agentPeekLoadingId.value,
mdRenderVersion.value, taskTitleVersion.value]
}
```
- `[it,...]` 依赖"renderItems 重算即新对象"这一既定事实(无需 primitive 枚举,简洁);兄弟集合用**整体引用**(`useToggleSet` 每次 toggle 替换 Set → 引用变 → 全部 item 重渲)。toggle 为用户点击(低频),全量重渲可接受,换取 dep 永不过期。
- `taskTitleVersion`/`mdRenderVersion` 用 O(1) 版本号(见 3.4)表达异步就绪源,避免把整个 `skills`/`tasks` 数组塞进 dep。
**风险与兜底**:若 Vite/Vue 编译器不支持在 `<template v-for>` 上挂 `v-memo`(与内部 `v-if` 链组合),则**回退方案**=为每个 item 包一层 keyed 子组件(即 P2 的 `<AiMessageBubble :item>` 提前),`v-memo` 落在子组件根 —— 属结构性,故列为风险项而非默认路径。
### 3.4 指纹 watch 改 O(1) 版本计数
**现状**(`:429–442`):source(`:430–433`)每次工具状态变动作 `messages.map(...)` + `JSON.stringify`,O(n) 分配 + 序列化。
**改法**:引入 `toolEpoch`(数字 ref,建议落 `aiShared.ts` 与 `convStreamStates` 同层),在**唯一入口** `bumpToolEpoch()` 自增;所有工具状态变更点调用:
- `useAiToolEvents.ts:39`(push 新 toolCall)、`:51`(completed)、`:101`(pending_approval)、`:119`(rejected);
- `useAiApproval.ts:87`(running/rejected)、`:109`(completed);
- `useAiConversations.ts:501`(待审批回填)、`useAiStreamingEvents.ts:123`(待审批)。
watch 改:
```ts
watch([() => store.state.messages.length, () => toolEpoch.value], () => {
if (isFollowingBottom.value) collapseAllToolLists(buildActiveToolIds(store.state.messages))
})
```
**收益**:每次工具事件从 O(n) map+stringify 降为 O(1) 计数;`lastMsgSnapshot` 去重串可删(watch 已按值变化触发)。**代价**:需在 ~7 处 mutation 点补 `bumpToolEpoch()` —— 建议收敛到一个 `markToolChanged()` 助手,避免散点漏调(漏调 = 折叠联动不触发,非渲染错误,风险低)。
### 3.5 流式帧渲染降载
1. **rAF 合并** —— 已有(`useStreamRenderer.scheduleStreamParse:225`),每帧仅一次 parse;保持不动。
2. **v-memo**(3.3)—— 每帧仅末条 item 重建 vnode,是**最主要的降载**(治"逐帧全列表 vnode 重建")。
3. **离屏段落跳过** —— 对 `.ai-msg-slot` 加 CSS `content-visibility: auto` + `contain-intrinsic-size`(浏览器级跳过离屏布局/绘制,零 JS)。**风险**:与 `scrollToBottom`(`anchorCollapseScroll:354` 读 `getBoundingClientRect`)可能因离屏高度估算产生滚动抖动 → 建议 P1 引入并实测,回退=去该 CSS 类。
4. **renderItems 不读流式正文** —— 现状已满足(正文回填在 `aiShared.ts:544` 收尾单点,delta 期间不改 `msg.content`);设计上**明确此约束不得破坏**(勿在 `renderItems` 内引入 `store.state.currentText` 读取),否则 computed 每 delta 重算,前功尽弃。
### 3.6 拆分方案(职责切分 + 数据流)
**数据流不变**:`store.state.messages`(真相)→ `messageSegments` → `renderItems`(`RenderItem[]`)→ 模板分支渲染。拆分仅重排"谁来持有逻辑"。
**P1 — 抽出 composable(模板不动,纯脚本迁移 + 依赖注入)**:参照已抽的 `useStreamRenderer`(回调注入)范式:
| 新 composable | 迁出内容 | 入参(注入) | 出参 |
|---------------|----------|-----------|------|
| `useRenderItems` | `messageSegments`/`renderItems`/时间线/traceNav + 桶 memo | messages、`isViewingGenerating`、各 expanded 集合 | `renderItems`、`traceNav`、`jumpToTrace` |
| `useTracePanel` | trace 展开/task refs/轮次导航 + agent peek 浮层 | store、`expandedTraceIds` | `toggleTrace`、`traceTaskRefs`、`openAgentTrace`/`closeAgentTrace`、`agentPeek*` |
| `useMessageActions` | 复制(文本/图片)/重试/装 obscura/去设置 | store、`emit`、`scrollToBottom` | `copyMsgContent`、`handleRetry`、… |
| `useTextSelection` | 保存/恢复选区 + 偏移定位 + 代码块复制委托 | `messagesContainer` | `saveSelection`/`restoreSelection`、`onMdCodeCopyClick` |
| `useTokenUsage` | token 派生 + popover 状态 | — | `token*Of`、`tokenPopoverMsgId`、`toggleTokenPopover` |
**P2 — 抽子组件(模板分流 + style scoped 随迁)**:
| 子组件 | 迁出模板 | 目标 |
|--------|----------|------|
| `TraceCard.vue` | trace(`:1510–1583`)+ step(`:1587–1596`)同构两态 + rail 面板 | 含内部 `v-memo`/computed,思考 pill 展开自治 |
| `AgentFinishedCard.vue` | agentFin(`:1601–1627`) | peek 点击 emit 上抛 |
| `AiMessageBubble.vue` | msg 的 assistant/system/json/长文折叠/错误操作(`:1697–1784`)+ 流式块(`:1727–1730`) | 单条气泡自治,`item` 作 prop |
| `TimeSeparator.vue` / `SegmentSeparator.vue` | timesep(`:1629`)/sep(`:1650–1671`) | 小件,可留 MessageList |
拆完后 `MessageList.vue` 余:容器 + 滚动 + 编排 `v-for` + 事件委托(目标 <400 行)。
**高内聚低耦合要点**:子组件只收 `item` 数据 + 经 `emit` 上抛动作(不直接持 `store` 写);`ToolCardList`/`MessageItem` 已是此模式,新组件对齐。
---
## 四、分期(每期可独立上线、可回退)
### P0 — 零风险渲染层(v-memo + 桶 memo + 版本计数)**不改结构**
- 补 `v-memo`(`itemDeps`,3.3);`renderItems` 桶 memo(3.2);指纹 watch 改 `toolEpoch`(3.4)。
- **不改**模板分支、DOM 契约(`data-msg-id`/role class)、`RenderItem` 类型。
- 建议拆 **两笔**提交:`v-memo` 一笔、桶 memo+版本计数一笔 → 可分笔 revert。
- 验收:§二"每帧仅末条 patch"人眼/打点;工具事件统计刷新正确;toggle 即时生效;`vue-tsc` + e2e 全绿。
### P1 — 拆 composable + 离屏降载(模板不动)
- 抽 `useRenderItems`/`useTracePanel`/`useMessageActions`/`useTextSelection`/`useTokenUsage`(3.6);`.ai-msg-slot` 加 `content-visibility`(3.5-3)。
- 验收:`MessageList.vue` script 显著下降;所有交互回归;滚动锚点无抖动;e2e 全绿。
### P2 — 拆子组件(结构 + style 分流)
- 抽 `TraceCard`/`AgentFinishedCard`/`AiMessageBubble`(3.6);style scoped 随迁。
- 验收:单文件 **<400 行**;全交互 + e2e 全绿;`v-memo` 下沉各子组件(可改用 `[item]` 简化)。
---
## 五、影响面(五维)
- **兼容性**:纯前端渲染层重构,不改 IPC / 事件 / DB / 后端协议;`RenderItem[]` 输出契约与模板分支不变;DOM 语义属性保留 → 既有 e2e 选择器/`getBoundingClientRect` 锚点不破坏;`MessageItem`/`ToolCardList` 对外 props/emit 不变。
- **破坏性**:无不可逆操作(纯代码,git 可回退)。**行为风险**=`v-memo` dep 遗漏(局部不刷新,可见回归)、桶 memo 签名冲突(串内容)。缓解:dep 保守(3.3)+ 签名含 `id` 唯一性 + LRU 有界。
- **安全面**:无新端点/命令执行面;渲染 HTML 仍走既有 `DOMPurify`(`useMarkdown.renderMd`/`parseBlockNoCache`)链,`v-memo` 只跳过补丁、不改变 `v-html` 内容来源,无新 XSS 面。
- **资源面**:内存 —— 新增桶 memo Map + dep 数组,有界 LRU(128);CPU —— 每帧 vnode 从 O(全列表) 降 O(1~少数);工具事件 O(n) 重算 → O(变更桶);`content-visibility` 降离屏布局/绘制。工具事件 O(1) 版本计数替代 O(n) stringify。
- **回退开关**:`v-memo`/结构拆分属**编译期**,无运行时开关 → 回退粒度=**逐笔 git revert**(故 P0 拆两笔、P1/P2 各自独立)。`toolEpoch` 为本地计数,回退即恢复旧 watch。`content-visibility` 可用 CSS 类开关运行时回退。
---
## 六、拍板点清单
| # | 决策 | 选项 | 建议 |
|---|------|------|------|
| 1 | `v-memo` 依赖口径 | A 逐 kind primitive 枚举;B `[item, ...兄弟引用]`(依赖 renderItems 缓存保证 item 稳定);C 仅对 msg 分支上 v-memo | **B**。简洁且覆盖充分;前提=3.2 桶 memo 与 3.3 的 sibling 清单正确;A 作理论最保守备选(枚举成本高、易漏) |
| 2 | P0 是否同时做桶 memo | A 仅 v-memo(结构零改,工具事件仍 O(n));B v-memo + 桶 memo(拆两笔) | **B**。两者正交、各自可测;桶 memo 治"工具事件级"重算,仅 v-memo 治不了 |
| 3 | 离屏 `content-visibility` 时机 | A P0 一起;B P1 引入;C 不做 | **B**。收益大但需实测滚动锚点抖动,单列为 P1 可回退项,风险隔离 |
| 4 | P2 子组件边界 | A 抽 TraceCard/AgentFinishedCard/AiMessageBubble;小件(时间线/折叠条)留父;B 仅抽最大件 AiMessageBubble | **A**。目标是 <400 行,需 style scoped 随迁才达标;B 达标不足 |
---
## 附:实施纪律
- `renderItems` 是全仓最热路径,**回归面大** → 遵循"优先级 Bug→重构→新功能、单次 1-2 文件、原子操作"策略(`memory large-refactor-strategy`);每期独立提交 + 独立 e2e。
- 改代码一律用 Edit 工具(禁 sed/perl);验证阶段收官统一跑 `vue-tsc` + e2e,不逐文件全量 build(`CLAUDE.md` 编排纪律)。
- 存疑处**源码形态优先于文档描述**;`v-memo` dep 清单以本设计 3.3 表为准,实施时逐条 grep 模板引用核对,勿凭空增删。
@@ -0,0 +1,361 @@
# DevFlow 版本更新与自动升级 — 专项设计(2026-10-09)
> 对象:应用内「版本可见 → 检查更新 → 下载 → 安装重启」全链路。
> 标准:合理 · 兼容 · 优雅 · 可扩张。
> 现状勘察:`src-tauri/tauri.conf.json` / `src-tauri/Cargo.toml` / `src-tauri/capabilities/default.json` / `src-tauri/src/lib.rs` / `src/views/Settings.vue` / `src/components/settings/SettingsNav.vue` / `scripts/package-devflow.ps1` / `swap-install.ps1` / u-desk 先例。
---
## 一、本质定性
### 1.1 两条"升级"通道必须分开命名
现状只有一个动作叫「换装」,但它其实是两个语义完全不同的事情,混称导致讨论失真:
| 通道 | 触发源 | 使用者 | 目标 | 现载体 |
|---|---|---|---|---|
| **本地换装** | 本机源码改动 | 开发者(你) | 把刚编译的产物装到自己机器 | `scripts/package-devflow.ps1 -Mode full` / `swap-install.ps1` |
| **应用内升级** | 远端新版本 | 任何使用者 | 把手上的旧版升到新版 | **不存在** |
**本设计只做第二条**。第一条保持不动(且是本设计的发布端依赖)。
### 1.2 为什么这件事值得做(而非"自用工具不需要")
现状的隐性成本:
1. **用户无法得知自己用的是哪版** —— 设置页无版本号展示,issue 反馈时只能靠猜("你那个缓存修复装上了吗?"——本次对话里就真实发生过:凌晨 02:50 装了包,13:02 又改了码,靠猜时间戳判断哪个生效)。
2. **升级不可达** —— 未来若分发给他人(u-desk 已有对外分发先例),对方拿到 v0.1.1 后永远停在 v0.1.1,只能等新包重装。
3. **升版本号是手工雷区** —— `swap-install.ps1` 硬编码 `devflow_0.1.1_x64-setup.exe`,改版本号忘改脚本 = 静默装回旧包(**静默伪成功**,最危险的一类故障)。
结论:**先做 L0(版本单一源 + 可见),再做 L1(检查→提示),最后 L2(下载安装)**。L0 交付即消除 1、3 两项,成本极低。
---
## 二、现状勘察(全部实证)
### 2.1 三元缺口
| 层 | 现状 | 证据 |
|---|---|---|
| 插件层 | 无 `tauri-plugin-updater` | `src-tauri/Cargo.toml:18-25` 仅 dialog/opener/window-state/notification/single-instance |
| 配置层 | 无 `plugins.updater` 段 | `tauri.conf.json` 仅 `bundle.targets: ["nsis"]` |
| 逻辑层 | 无版本比对 IPC | `grep check_update\|latest_version\|auto_update` 于 `src-tauri/src` → 0 命中 |
| UI 层 | 无版本号/检查更新入口 | `grep 检查更新\|getVersion\|appVersion` 于 `src/` → 0 命中;SettingsNav 仅 7 分类 |
### 2.2 现存缺陷(本设计顺带治理)
**D1 — 版本双源不一致**
```
src-tauri/tauri.conf.json:3 "version": "0.1.1" ← 打包器实际用这个
src-tauri/Cargo.toml:3 version = "0.1.0" ← Rust crate 用这个
```
Tauri 2 中 `bundle` 版本取 `tauri.conf.json`,`CARGO_PKG_VERSION` 取 Cargo.toml。当前两者已经分叉(0.1.1 vs 0.1.0),未来必然出「设置页显示 0.1.0,实际装的是 0.1.1」这类灵异现象。
**D2 — 换装脚本硬编码版本号**
```powershell
# swap-install.ps1:8
$installer = 'E:\wk-lab\devflow\target\release\bundle\nsis\devflow_0.1.1_x64-setup.exe'
```
版本号进入文件名,升版忘改 → 找不到文件 → `Start-Process` 抛错(尚好);若旧版包残留同目录,则**装回旧版**(伪成功)。必须改为 glob 探测(`package-devflow.ps1:44` 已用 `Sort-Object LastWriteTime -Descending | Select-Object -First 1` 的正确姿势,swap-install 未对齐)。
### 2.3 可复用的成熟资产
**u-desk(同作者的 Wails 应用)已完成全套更新链路**,虽技术栈不同(Go+Wails vs Rust+Tauri),但**方案骨架可直接借鉴**:
- 清单托管:`https://img.1216.top/u-desk/last-version.json`(七牛 CDN)
- 后端:`internal/api/update_api.go` + `internal/service/update_service.go`
- `CheckUpdate()` / `GetCurrentVersion()` / `GetUpdateConfig()` / `SetUpdateConfig()`
- `DownloadUpdate()` 异步 + `download-progress` 事件流
- `InstallUpdateWithHash()` 哈希校验后安装
- 自动检查定时器(`startAutoUpdateCheck` 可重入,配置变更 `resetAutoUpdateCheck` 免重启生效)
- 前端:`update-available` 事件驱动弹窗
**关键差异**:u-desk 是自研(无签名体系);Tauri 2 官方 updater **内建 ed25519 签名校验**,安全性更好,应优先采用官方插件而非照搬自研。
**另有可复用的健壮性经验**:`swap-install.ps1` 里已验证有效的三件套——
1. `Wait-HandleReleased()`:独占打开目标 exe 探测句柄释放(治 Windows 文件锁延迟释放致 NSIS 静默伪成功)
2. SHA256 三级判据(等磁盘快照 / 两次确定性输出 / 实变)
3. SYSTEM 上下文转投交互用户会话(GUI 才可见)
这三条应在应用内升级的「安装前等待」阶段复刻(Rust 侧实现等价逻辑)。
---
## 三、方案选型
### 方案 A:Tauri 官方 `tauri-plugin-updater`(推荐)
**能力**:`check()` → `Update` 对象(version/notes/date)→ `download_and_install(on_chunk)` → 自动重启。
**优点**
- 内建 **ed25519 签名校验**(防篡改,安全基线)
- 跨平台统一(NSIS / MSI / AppImage / dmg)
- 与 Tauri 窗口生命周期原生协同(安装前自动关窗)
- 官方维护,随 Tauri 2 演进
**代价**
- 需引入签名密钥(`tauri signer generate`,公钥入配置,私钥入打包环境)
- 打包需开 `bundle.createUpdaterArtifacts: true`(产出 `.nsis.zip` + `.nsis.zip.sig`)
- 端点必须 HTTPS + 清单格式固定(`latest.json`)
### 方案 B:自研(照搬 u-desk 模式)
**优点**:零新依赖,可完全复用 `swap-install` 的静默安装动作。
**缺点**:签名/校验/回滚/进度全自研;`latest.json` 契约要自己定;安全基线靠自己守。
### 选型结论:**A 为基座,B 的本地动作经验作为"安装前等待"的健壮性补丁喂给 A**
理由:
- 官方插件把「签名校验」这件最容易做错的事做对了,没有理由重造。
- devflow 是**联网分发**场景(未来对外),签名是刚需;u-desk 的裸下载在自用场景够,对外不够。
- 本地换装通道(脚本)**不废弃**,继续服务开发期高频迭代(它比走远端快得多)。
---
## 四、分层设计
```
┌─ L3 发布侧(脚本增强)────────────────────────────┐
│ tauri build → 产出 .nsis.zip + .sig │
│ → 生成 latest.json(version/notes/pub_date/urls/sig)│
│ → 上传 app.1216.top/download/devflow/ │
└────────────────────────────────────────────────────┘
↑ 拉取
┌─ L2 前端 ──────────────────────────────────────────┐
│ SettingsNav 新增「关于」分类(第 8 类) │
│ AboutPanel.vue:版本号 / 检查更新 / 更新弹窗 / 进度 │
└────────────────────────────────────────────────────┘
↑ IPC
┌─ L1 后端(Rust)───────────────────────────────────┐
│ tauri-plugin-updater 注册 + capabilities 授权 │
│ IPC: app_get_version / app_check_update / │
│ app_download_install_update │
│ 本地 KV: df-update-auto-check / -interval-minutes / │
│ -last-check / -notified-version │
└────────────────────────────────────────────────────┘
↑ 单一源
┌─ L0 版本治理 ──────────────────────────────────────┐
│ 单一版本源 + 设置页展示版本号(零风险,先落) │
└────────────────────────────────────────────────────┘
```
### L0:版本单一源治理
**决策**:以 `tauri.conf.json` 的 `version` 为**唯一权威源**(打包器实际使用它)。
**动作**
1. `src-tauri/Cargo.toml` 的 `version` 对齐同名值(人工同步,或加校验脚本);**§2.2-D1 双源问题在此关闭**。
2. 新增 IPC `app_get_version`:返回 `env!("CARGO_PKG_VERSION")` + 构建时间 + git short hash(option_env! 注入,未注入显示 `dev`)。
3. `swap-install.ps1:8` 改为 glob 探测(**§2.2-D2 在此关闭**):
```powershell
$installer = (Get-ChildItem 'E:\wk-lab\devflow\target\release\bundle\nsis\devflow_*_x64-setup.exe' |
Sort-Object LastWriteTime -Descending | Select-Object -First 1).FullName
```
4. 设置页「关于」展示版本号(L0 即可交付的可见价值)。
**扩张性**:未来 `df-im-gateway` 等独立服务走各自版本线(在其自身发布 skill 内治理),本设计不扩范围,但 `app_get_version` 的返回结构预留 `components: []` 数组位。
### L1:后端接入(插件注册 + 检查)
**Cargo.toml**
```toml
tauri-plugin-updater = "2"
```
**lib.rs**(`Builder` 链,插在 notification 之后)
```rust
.plugin(tauri_plugin_updater::Builder::new().build())
```
**capabilities/default.json** 补
```json
"updater:default"
```
**tauri.conf.json**
```json
"bundle": {
"active": true,
"targets": ["nsis"],
"createUpdaterArtifacts": true
},
"plugins": {
"updater": {
"endpoints": ["https://app.1216.top/download/devflow/latest.json"],
"pubkey": "<tauri signer generate 产出的公钥>",
"windows": { "installMode": "passive" }
}
}
```
> `installMode: passive` —— **不是静默**:弹进度条窗口,用户看得见进度,但无需点击「下一步」,装完自动往下走。
> 三档对比:`basic`(完整向导,需点下一步)/ `passive`(进度条,无需点击)/ `quiet`(完全无 UI,**失败不可见**,不推荐)。
> 注:`swap-install.ps1` 用的 NSIS `/S` 是真静默(等价 `quiet`),应用内升级刻意不采用——面向用户自己机器的升级需要失败可见性。
**IPC 契约**(新增 3 个,命名沿用 `app_*` 前缀)
| 命令 | 入参 | 返回 | 风险档 |
|---|---|---|---|
| `app_get_version` | — | `{version, build_time, git_hash}` | 只读 |
| `app_check_update` | — | `{has_update, version?, notes?, date?}` | 只读(外发网络) |
| `app_download_install_update` | — | 流式事件 + 成功即重启 | **高风险(需审批)** |
**配置 KV**(复用既有 appSettings 体系,零新表)
- `df-update-auto-check`:bool,默认 `true`
- `df-update-check-interval-minutes`:int,默认 `15`
- `df-update-last-check`:ISO 时间戳(节流用,防每次启动都拉)
- `df-update-notified-version`:string(**同版本只提示一次**,防骚扰)
**自动检查策略**:启动后延迟 **3s** → 拉清单 → 有更新则发事件 `app-update-available` 到主窗;此后**每 15 分钟**检查一次,但**同一版本号只提示一次**(用户点「稍后」后不再重复弹)。**只提示不自动装**(安装是高风险动作,必须人点头)。
### L2:前端(设置页「关于」分类)
**SettingsNav** 加第 8 类:
```ts
{ value: 'about', icon: 'ℹ️', labelKey: 'settings.navAbout' },
```
`searchIndex.ts` 补对应条目(`SettingsCategory` 联合类型同步扩 `'about'`)。
**新增 `src/components/settings/AboutPanel.vue`**(对齐既有 panel 视觉语言)
```
┌─ 关于 DevFlow ─────────────────────────┐
│ 当前版本 v0.1.2 (a1b2c3d) [检查更新] │
│ 构建时间 2026-10-09 14:30 │
│ ─────────────────────────────────────── │
│ ☑ 启动时自动检查更新(启动后 3 秒) │
│ 检查间隔 [15 分钟 ▾] │
│ ─────────────────────────────────────── │
│ 上次检查 2026-10-09 14:35 │
└─────────────────────────────────────────┘
```
**更新弹窗**(复用既有 ConfirmDialog 视觉,含:新版本号 / 更新说明 markdown / 下载进度条 / 「立即更新并重启」「稍后」)
**事件订阅**(对齐既有 `useAiEvents` 模式)
- `app-update-available` → 顶栏小红点 + 弹窗
- `app-update-progress` → 进度条(`downloaded` / `total` / 百分比)
**i18n**:zh-CN / en 双语同步补 `settings.navAbout` / `settings.about*` / `update.*` 系列键。
### L3:发布侧脚本增强
新增 `scripts/publish-release.ps1`(或扩展 `package-devflow.ps1` 加 `-Mode release` 通道):
1. 前置:校验 `tauri.conf.json.version` 与 `Cargo.toml.version` 一致(防 D1 复发)
2. tauri build(含 createUpdaterArtifacts → .nsis.zip + .sig)
3. 计算 sha256 + 读取 .sig 内容
4. 生成 latest.json:
{
"version": "0.1.2",
"notes": "<从 docs/CHANGELOG.md 提取本节>",
"pub_date": "2026-10-09T14:30:00Z",
"platforms": {
"windows-x86_64": {
"signature": "<.sig 内容>",
"url": "https://app.1216.top/download/devflow/devflow_0.1.2_x64-setup.nsis.zip"
}
}
}
5. 上传 latest.json + .nsis.zip + .sig + 裸 exe 到 **app.1216.top** `download/devflow/`(走 publish-app-site 通道:suke-dev / `/var/www/app-site/`)
6. `app.1216.top` 的 JSON 已配 `Cache-Control: no-cache` → **无需刷 CDN**,发布即生效
```
**密钥管理**
- 一次性:`tauri signer generate -w ~/.tauri/devflow.key` → 公钥入 `tauri.conf.json` 的 `pubkey`
- 打包时设环境变量 `TAURI_SIGNING_PRIVATE_KEY`(路径)+ `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`
- **私钥绝不入仓**(`.gitignore` 补 `*.key`)
---
## 五、兼容性核验
| 既有资产 | 影响 | 结论 |
|---|---|---|
| `scripts/package-devflow.ps1` | 不动 verify/full 两通道,L3 新增独立通道 | ✔ 零回归 |
| `swap-install.ps1` | 仅改第 8 行硬编码 → glob(D2 修复) | ✔ 行为等价,更健壮 |
| `lib.rs` Builder 链 | 追加一个 `.plugin()`,不触碰既有 5 个插件与 68 IPC | ✔ 零侵入 |
| `capabilities/default.json` | 追加一行 `updater:default` | ✔ 纯增量 |
| SettingsNav 7 类 | 追加第 8 类,搜索/防抖/持久化逻辑自动覆盖(`SETTINGS_INDEX` 驱动) | ✔ 架构自洽 |
| appSettings KV 体系 | 新增 4 个 key,复用既有读写 | ✔ 零新表零迁移 |
| `tauri.conf.json` targets | 保持 `["nsis"]`,只加 `createUpdaterArtifacts` | ✔ 不扩大构建目标(对齐知识库「targets 收窄需还原」的教训:本项是新增字段非收窄,安全) |
| devflow.db / devflow-dev.db | L1/L2 只读 KV,不涉 schema | ✔ 零库变更 |
**dex 依赖**:无。**P0 依赖**:无(可立即开工)。
---
## 六、实施批次(全部可独立交付)
| 批次 | 规模 | 内容 | 验收 |
|---|---|---|---|
| **B1** | S | L0 版本治理:Cargo.toml 对齐 0.1.1 / `app_get_version` IPC / swap-install glob 修复 / 设置页版本号展示 | `cargo check -p devflow` 0 error;设置页可见 `v0.1.1`;换装脚本升版后仍能正确取包 |
| **B2** | M | L1 插件接入 + `app_check_update` IPC + 自动检查节流 + `app-update-available` 事件 | 手动改远端 `latest.json` 版本号 → 应用内提示可用 |
| **B3** | M | L2 下载安装:进度事件 + 更新弹窗 + `app_download_install_update` + 重启 | 端到端:旧版 → 提示 → 下载 → 装 → 重启后版本号变新 |
| **B4** | S | L3 发布脚本 + 密钥生成 + latest.json 生成上传 | 一条命令产出并上传可分发的更新清单(app.1216.top 无需刷缓存) |
**建议序**:B1 立即做(零风险、当天可交付、消除 D1/D2 两个真实缺陷);B2→B3 一起做(拆开无独立价值);B4 在首次真实发版前完成即可。
**总估算**:B1 0.5 天 / B2+B3 1.5-2 天 / B4 0.5 天 ≈ **2.5-3 天**。
---
## 七、风险与边界
| 风险 | 等级 | 对策 |
|---|---|---|
| 私钥泄露 | **高** | `.gitignore` 拦 `*.key`;仅打包机存私钥;公钥入仓无妨 |
| ~~CDN 缓存陈旧致拉了旧清单~~ | ~~中~~ **已消解** | C1 改走 `app.1216.top`(已配 `Cache-Control: no-cache`),无缓存陈旧问题;可选 `?t=<timestamp>` 兜底 |
| 更新包下载中断 | 中 | 插件内建重试;失败保留旧版不破坏现状 |
| 装完起不来(坏包) | 中 | `installMode: passive` 失败可见;保留上一版安装包以备手回滚 |
| 自动检查打扰 | 低 | 15 分钟周期 + **同版本只提示一次** + 只提示不自动装 + 可关 |
| 版本双源复发 | 低 | B1 加校验(发布脚本第 1 步)+ 可选 `build.rs` 断言 |
**明确不做(本设计范围外)**
- 增量更新(delta update)——体量收益不值复杂度
- 灰度/分渠道发布
- 非主应用组件(`df-im-gateway` 等)的版本线
- 回滚 UI(保留手动重装路径即可)
---
## 八、决策点(✅ 2026-10-09 拍板定稿)
| # | 议题 | ✅ 定稿 | 依据 |
|---|---|---|---|
| **C1** | 清单托管位 | **`app.1216.top/download/devflow/`** | 产品门户语义正确;该站已配 `Cache-Control: no-cache`,发布即生效,无需刷 CDN;通道(suke-dev / `/var/www/app-site/`)现成 |
| **C2** | 安装模式 | **`passive`**(进度条窗口,无需点击) | 非静默;`quiet` 完全无感但失败不可见(静默伪成功风险) |
| **C3** | B1 是否先行 | **立即开工** | 零风险、当天交付,消 D1/D2 两个真实缺陷 |
| **C4** | 自动检查节奏 | **启动后 3s → 周期 15 分钟** + 同版本单次提示 + 关于页手动按钮 | 用户定:3s / 15min |
### C1 补充决策:双产物并存(站点规范 vs Tauri 强制格式)
app-site 站规为「不打 zip,直接裸 exe」,而 Tauri updater **强制** `.nsis.zip` + `.sig`。故 `download/devflow/` 并存四项:
| 文件 | 给谁 | 合规 |
|---|---|---|
| `devflow_<ver>_x64-setup.exe` | 手动下载的用户 | ✅ 站点「裸 exe」规范 |
| `devflow_<ver>_x64-setup.nsis.zip` | updater 拉取 | ✅ Tauri 格式强制 |
| `devflow_<ver>_x64-setup.nsis.zip.sig` | updater 验签 | ✅ |
| `latest.json` | updater 清单 | ✅ |
### C4 补充决策:体验设计「环境式更新」
**核心原则**:检查频率 ≠ 体验。体验由「提醒方式 / 去重 / 过程可见 / 时机可控」四件事决定。
| 触点 | ✅ 定稿 |
|---|---|
| 平时 | 完全安静,零存在感 |
| 发现新版 | **顶栏非模态徽章**(不弹窗打断) |
| 查看详情 | 点徽章展开:版本号 + 更新说明 + 包大小 |
| 决定 | 「立即升级」/「稍后」双选(**不自动下载安装**) |
| 下载 | 进度条 + 可取消 |
| 安装 | `passive` 进度窗口,无需点击 |
| 完成 | 重启后 toast「已升级到 vX.Y.Z」 |
| 反复 | **同版本只提示一次**(KV `df-update-notified-version`) |
**为什么必须是「环境式」而非「隐形型(全自动)」**:DevFlow 是**有状态工作工具**(可能正跑长任务/开着会话),任何自动重启都会打断工作。时机控制权必须交给用户。
@@ -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,230 @@
# 输入框列表预览与自动编号 — 交互设计
> 日期: 2026-10-10 | 状态: 设计稿(待拍板) | 需求锚点: `docs/todo.md:701` 条目 933 | 勘察: 只读实测(文件:行已核)
> 目标一句话: ChatInput 输入 `1. ` / `- ` 后,输入框内呈现"列表已就绪"的反馈(高亮/预览)并支持回车续写补号,对齐飞书消息输入框的顺手感,而当前是纯文本无反馈。
> 边界: 仅设计,不改码;不涉及后端/协议/落库。
---
## 一、现状勘察(锚点实测)
### 1.1 输入框本体
| 件 | 锚点 | 现状 |
|----|------|------|
| 编辑器 | `src/components/ai/ChatInput.vue:20-35` | 单个纯 `<textarea class="ai-input" v-model="inputText">`,`rows="1"`,无富文本、无 overlay、无 mirror。事件: `@keydown/@beforeinput/@input/@paste/@dragover/@drop` |
| 键处理 | `ChatInput.vue:814-910` | `handleKeydown` 一条优先级长链:IME 守卫(817)→ Ctrl+R(822)→ 编辑态 Esc(829)→ 技能 Esc(836)→ chip Esc(843)→ 技能导航 ↑↓/Enter/Tab(850-872)→ 技能无匹配 Enter 拦截(875)→ mentions 导航 ↑↓/Enter/Tab(882-905)→ **Enter 发送(906-909)** |
| 输入态互斥 | `ChatInput.vue:418-432` | `watch(inputText)`: 以 `/` 开头 → `skillOpen`;否则 `detectMentionTrigger()`。二者互斥(427-431) |
| `/` 判据 | `ChatInput.vue:408` | `skillQuery = inputText.startsWith('/') ? slice(1) : ''` —— **仅识别"整串以 `/` 开头"**,行首列表标记 `1. ` 天然不与之撞 |
| `@` 判据 | `ChatInput.vue:439-467` | 从光标向前找最近 `@`,要求其前一字符为空/空白;遇空白即停 |
| 程序化改文 | `ChatInput.vue:509-532` | 已有成熟范式: `beforeinput` 快照旧文 + `input` 时公共前后缀差分定位编辑区间 → `shiftMentionSpans` 平移/丢弃 `pendingMentionSpans`。注释明确"程序赋值不派发 input 事件"(508-509) |
| 高度自适应 | `ChatInput.vue:916-922` | `autoResize()`: `height='auto'` → `min(scrollHeight, INPUT_MAX_HEIGHT=200)` |
| 光标定位 | `ChatInput.vue:801-811` | `selectMention` 里 `setSelectionRange` + 同步重算高度,是既有的"程序化插字+回光标"样板 |
| 样式 | `ChatInput.vue:1201-1215` | `.ai-input { flex:1; font-size:13px; line-height:1.5; font-family:var(--df-font-sans); resize:vertical; height:20px; max-height:200px; overflow-y:auto }`。外层 `.ai-input-wrap`(1177) `position:relative` + `display:flex; flex-wrap:wrap` |
### 1.2 既有浮层/提及基础设施(可复用)
| 件 | 锚点 | 复用点 |
|----|------|--------|
| 技能浮层 | `SkillMention.vue:43-62` | `.ai-skill-popover` 绝对定位浮层 |
| `@` 浮层 | `MentionPopover.vue:39-64` | 同款 `.ai-skill-popover`,仅加组标题 |
| 浮层定位 | `src/styles/components.css:254` | `.ai-skill-popover { position:absolute; bottom:calc(100% + 4px); left:0; right:0; max-height:240px; overflow-y:auto; z-index:20 }` —— 浮层锚在输入框**上方**,输入框整体透明底(`.ai-input { background:transparent }`) |
| 已选技能/图片预览 | `ChatInput.vue:16-19` | 输入框上方已有 chip/预览条先例(ImageInput),说明"输入框上方加一条反馈带"是既有形态 |
### 1.3 发送与渲染链(证明"发送侧零改动")
| 件 | 锚点 | 现状 |
|----|------|------|
| 发送 | `ChatInput.vue:924-1046` | `handleSend` 取 `inputText.trim()` 原样透传 `store.sendMessage(text, ...)`,不做任何 markdown 预处理 |
| 渲染器 | `src/composables/useMarkdown.ts:115-132` | `marked.setOptions({ breaks:true, gfm:true })` —— **有序/无序列表本就渲染为 `<ol>/<ul>`** |
| 列表样式 | `src/styles/ai-md.css:8-9` | `.ai-md ul, .ai-md ol { margin:4px 0; padding-left:20px }` / `.ai-md li { margin:2px 0; line-height:1.5 }` |
| 文本预处理先例 | `useMarkdown.ts:239-299` `wrapNakedDiff` | marked.parse 前做文本预处理的既有范式;其守卫1(纯 `-` 列表不误包成 diff)说明**已考虑"`- ` 是普通 markdown 列表"的语义** |
**结论**: 消息落地后列表渲染链完整,本需求是**纯输入侧体验缺口**——输入阶段无任何"这是列表"的反馈、无回车续写、无自动补号。
### 1.4 缺口清单
1. 输入 `1. ` / `- ` / `* ` / `+ ` 后无任何视觉反馈(纯文本),与飞书"行首即成型"的手感差距明显。
2. 回车续写不补号:按 Shift+Enter 换行后,下一行不会自动带 `2. ` / `- `。
3. 空列表项无退出:清空某行内容后标记 `1. ` 残留,需手工删。
4. 更深度的是"自动重排号"(中间插一项后续号重排)完全缺失。
---
## 二、目标与验收口径
| 目标 | 验收口径(可实测) |
|------|-------------------|
| G1 触发识别 | 行首匹配 `^(≤3 空格)(\d+\. )` / `^(≤3 空格)([-*+] )` 即判定为列表项;` ``` ` 围栏内不触发 |
| G2 回车续写补号 | 在非空列表项行末按 **Shift+Enter** → 新行自动前缀正确序号(`n` → `n+1`)或同型符号(`- `→`- `),光标落新标记后 |
| G3 空项退出 | 空列表项行(仅标记+空格)按 **Shift+Enter** 或 **末位退格** → 标记被移除,退出列表态,不产生新项 |
| G4 视觉反馈 | 列表行标记(`1.` / `- `)在输入框内获得区别于正文的样式(高亮/等宽/色块);无法在纯 textarea 内着色时,以"输入框上方列表预览条"替代(见 §3.3) |
| G5 不打架 | `/` 技能联想、`@` 提及、编辑态、IME 组词、代码围栏优先级正确;开启本能力后既有行为零回归 |
| G6 发送不变 | 发送文本仍为含字面标记的原文,消息气泡经 marked 渲染为列表——发送链/后端/落库零改动 |
---
## 三、设计方案
### 3.1 总体判断(先说结论)
**纯 `<textarea>` 无法在"框内"渲染真正的列表项**(圆点/编号缩进)——textarea 只能显示等宽字符流,无法对子串应用 bullet + hanging indent。因此"框内所见即所得"只有两条路:
| 档 | 实现 | 能力 | 成本/风险 |
|----|------|------|-----------|
| **档一 · 轻量** | 保留 textarea + 逻辑层(识别/补号/退出)+ 可选 mirror 高亮 | 补号、退出、标记高亮(字面仍为 `1.`,但高亮显示) | 低-中;mirror 需 textarea 文字透明化,有选区可见性坑 |
| **档二 · 完整** | contenteditable 富文本编辑器(真 `<ol>/<ul>`) | 真 WYSIWYG、可嵌套/拖动 | 高;替换 textarea,击穿 IME/`@`span/粘贴图/autoResize 全部既有机制 |
**建议先做档一**,档二单独立项(见 §6 拍板点 2)。理由:档一达成飞书体验的 ~90%(续写补号是主力价值),且完全不碰已验证多年的输入链;档二是输入层重写,风险与工作量不成比例。
### 3.2 逻辑层设计(档一核心,必做)
抽 `src/composables/ai/useListContinuation.ts`(纯函数,可单测),不改 textarea 渲染。
**数据结构**(仅内存,不落库):
```ts
export interface ListContext {
kind: 'ordered' | 'unordered'
indent: string // 行首缩进(≤3 空格,保留)
marker: string // 字面标记,如 "3. " / "- "
number?: number // ordered 的序号(含 . 或 ) 后解析)
bullet?: '-' | '*' | '+'// unordered 的符号字符
content: string // 标记后的正文(可空)
lineStart: number // 该行在 inputText 中的起始偏移
markerEnd: number // 标记结束偏移(标记后第一个正文字符)
}
/** 判定给定文本 + 光标所处行的列表语境;围栏内/无匹配返回 null */
export function analyzeListContext(text: string, caret: number): ListContext | null
/** 续写:返回应插入的标记串(ordered → `${n+1}. `;unordered → 同型) */
export function nextMarker(ctx: ListContext): string
/** (可选增强)重排:给定文本,把连续 ordered 段重编为 1,2,3…;返回新文本 + 需平移偏移 */
export function renumberOrdered(text: string): { text: string; renumbered: boolean }
```
**触发判据(实现细节)**:
- 有序: `/^(\s{0,3})(\d{1,9})[.)]\s(.*)$/`(兼容 `.` 与 `)`;markdown 允许 1-9 数字 + `.`/`)`)
- 无序: `/^(\s{0,3})([-*+])\s(.*)$/`
- **围栏守卫**: 从文本第 0 行扫到当前行前,统计 `^\s{0,3}(```|~~~)` 出现次数,奇数 → 处于代码围栏内 → 返回 `null`(不在围栏内把 `- 1.` 之类误判为列表)。复用 `useMarkdown.wrapNakedDiff` 同款 `_FENCE_OPEN_RE` 思路。
- 光标必须落在当前行(用 `\n` 计数定位行号),避免"光标在别行却对当前行操作"。
**续写流程(Shift+Enter)**: 在 `handleKeydown` 里新增分支(位置见 §3.4):
```
Shift+Enter 且非 skillOpen/mentionOpen/编辑态:
ctx = analyzeListContext(inputText, caret)
if !ctx: return(交默认换行)
if ctx.content.trim() === '':
// 空项 → 退出列表:删除整行标记,留下空行
preventDefault; 删除 [lineStart, markerEnd) 区间;
shiftMentionSpans(旧, 新); 回光标; return
preventDefault
insert = '\n' + ctx.indent + nextMarker(ctx)
在 caret 处插入 insert; 光标置插入末
shiftMentionSpans(旧, 新) // 复用 509-532,列表插字同样会平移其后的 @span
autoResize(); updateMirror()
```
> 注意: 程序化赋值不触发 `input` 事件(`ChatInput.vue:508-509` 已注明),故 `shiftMentionSpans` 必须**显式手动调用**(不能依赖 `onInputText`),与 `selectMention` 的处理保持一致。
**退格退出(B3)**: 在 `handleKeydown` 增 `Backspace` 分支:光标在行末且当前行为空列表项(`^(\s*)([-*+]|\d+[.)])\s$`)→ preventDefault,删标记,退出列表。
**自动重排号(G-增强,可选)**: 默认**不做**。true "自动编号" 需在中间插入/删除项后重写后续行号(`1. 2. 3.` → 插一行后 `1. 2. 3. 4.`),会改动用户已输入文本 → 触碰光标/`@`span 偏移。若做,须走完整"旧文差分 → 重排 → 重新定位光标与 span",成本与回归面显著上升,建议单独开关(见 §6 拍板点 3)。
### 3.3 视觉反馈设计(三选一,见拍板点 4)
| 形态 | 实现 | 优 | 劣 |
|------|------|----|----|
| **V0 零视觉** | 只做补号/退出 | 零渲染风险,一文件 | 不满足"而非纯文本"的观感诉求 |
| **V1 mirror 高亮(推荐目标)** | 在 textarea 下垫一层等宽 mirror div,渲染原文(转义),把列表标记包成 `<span class="ai-list-marker">` 高亮;textarea 文字设 `color:transparent; caret-color:var(--df-text)`,可见字符来自 mirror;同步 `scrollTop`/高度 | 字面仍为 `1.` 但**框内可见高亮 + 悬挂缩进**,最接近飞书 | 需精确对齐 font/line-height(13px/1.5)、尾行高度、`resize:vertical`、滚动;**选区可见性坑**:透明文字下 `::selection` 只剩底色、字形不可见,需 `textarea::selection{ background:… }` 兜且体验打折 |
| **V2 预览条** | 输入框上方(ImageInput 同位置范式)一条折叠/淡显预览,复用 `useMarkdown.renderMd(inputText)`(已 DOMPurify 净化)渲染当前输入 | 复用既有渲染单例,零新解析器;能看到真实序号/圆点 | 非"框内",占垂直空间,与飞书形态有差 |
**建议**: 先 V0 落地 G1-G3/G5/G6(最小可用,零渲染风险),再把 **V1 作为独立增强步**单独评估(透明化对选区/无障碍的影响需实测);V2 作为可选辅助保留。不建议一步到位 V1,因其 textarea 透明化是全局输入体验改动,面比"列表"本身大。
若采用 V1,样式落 `src/styles/components.css`(与 `.ai-skill-popover` 同处,因 scoped 不渗透):
```css
.ai-input-field { position: relative; flex: 1; display: flex; } /* 新包裹层,仅包 textarea */
.ai-input-mirror {
position: absolute; inset: 0; overflow: hidden; pointer-events: none;
font: inherit; font-size: 13px; line-height: 1.5; font-family: var(--df-font-sans);
white-space: pre-wrap; word-break: break-word; color: var(--df-text);
padding: 0; /* 与 textarea 完全一致,靠 .ai-input-wrap 的 6px 8px 内边距 */
}
.ai-list-marker { color: var(--df-accent); font-weight: 600; }
```
> mirror 文本必须用 `document.createTextNode` 逐段拼接(**禁用 v-html 直插原始输入**,防 XSS);仅对已确定的标记区间包 span。若日后想直接渲染整段 markdown 预览,只能走 V2 的 `renderMd`(已 `_purify.sanitize`,189-208)。
### 3.4 与既有机制的优先级(冲突落地)
新增分支在 `handleKeydown`(814-910)的**精确插入点**:
```
IME 守卫(817) → Ctrl+R(822) → 编辑态 Esc(829) → 技能 Esc/chip(836-848)
→ 技能导航(850-872)/技能无匹配 Enter(875)
→ mention 导航(882-905)
→ 【新增】Backspace 空项退出 · 【新增】Shift+Enter 列表续写 ← 放在此处
→ Enter 发送(906-909)
```
- **`/` 技能**: `skillOpen` 仅当"整串以 `/` 开头"(408/423),列表标记在行首,首行不可能是 `/`;续行也不影响 `startsWith` 判定 → **不撞**。守卫:续写分支前置 `!skillOpen`。
- **`@` 提及**: 续写时若光标处 popover 未开,insert 后调 `shiftMentionSpans` 平移其后的 span;若 popover 开着(`mentionOpen`),用户意图是选实体,续写分支前置 `!mentionOpen`。
- **代码围栏 ` ``` `**: 由 `analyzeListContext` 的围栏守卫直接排除(§3.2),`- 1.` 在围栏内不触发,与 `wrapNakedDiff` 守卫3一致。
- **IME/isComposing(817)**: 续写分支位于其后,组词中的 Shift+Enter 不误触。
- **Enter 发送不变**: 本设计**不改 Enter 发送语义**——续写绑定 Shift+Enter(换行链),Enter 仍发送。为最小改动。若要与飞书"Enter 续行"完全一致需改语义,见拍板点 1。
### 3.5 UI 形态与文案
- 无新浮层;V1 高亮走 mirror,V2 预览条若采用则置 `.ai-input-wrap` 之后、`EnrichmentPanel`(`ChatInput.vue:113-121`)之前。
- 提示语: 可选在 `.ai-input-hint`(122,`aiChat.inputHint` zh:159)追加"列表续行 Shift+Enter",或单独加一行淡提示。新增 i18n key 落 `src/i18n/zh-CN/aiChat.ts` + `src/i18n/en/aiChat.ts`(现有 key 区见 130-165)。
---
## 四、分期与最小可用
| 期 | 范围 | 交付 | 依赖 |
|----|------|------|------|
| **P1(最小可用)** | 逻辑层 + V0 | `useListContinuation.ts`(analyze/nextMarker)+ ChatInput 接线(Shift+Enter 续写、Backspace 空项退出、围栏守卫)+ 开关 | 单测覆盖判定/续写/围栏 |
| **P2(视觉)** | V1 mirror 高亮(或 V2 预览条) | 框内标记高亮 + 悬挂缩进;样式入 components.css | P1 稳定 + 选区/无障碍实测通过 |
| **P3(增强,可选)** | 自动重排号(renumber) | 中间插/删项后重编序号;独立开关 | P1/P2 无回归 |
**最小可用定义**: P1 完成即视为需求达标可发布——触发识别 + 续写补号 + 空项退出三件事齐,发送侧零改动。视觉(V1/V2)与重排(P3)为增量。
---
## 五、影响面(新能力引入评估纪律 · 五维)
| 维度 | 判定 |
|------|------|
| **兼容性** | 纯前端输入侧行为,**无协议/落库/迁移**。既有草稿(`convDrafts`)、编辑态回填、队列回填文本语义不变(仍是含字面标记的 markdown 原文)。旧会话/历史消息不受影响(渲染链本就支持列表)。**PASS** |
| **破坏性** | 增分支只拦 Shift+Enter/Backspace 的**特定空项/列表场景**,其余键行为不变;Enter 发送语义**不变**(拍板点 1 若选改才涉及行为变更,且可逆)。无不可逆操作。**PASS(低)** |
| **安全面** | 无新端点/命令执行/路径。**关键**: mirror/预览渲染用户输入时必须转义——mirror 用 textNode 逐段拼接;若用 v-html 走 `renderMd`(已 `DOMPurify.sanitize`,`useMarkdown.ts:201`)。禁裸 `v-html=原始输入`。新增攻击面为零,仅为既有 XSS 防护纪律的又一次适用。**PASS** |
| **资源面** | 每次键入一次纯字符串扫描(O(行数),典型 <100 行)一次可选 mirror DOM 更新。无需 rAF/防抖(P3 重排号若开,是 O(n) 文本改写,仍可控)。无磁盘/日志/内存增量。**PASS** |
| **回退开关** | 设 appSettings 键(沿用现网 `df-ai-streaming-md` / `df-ai-auto-execute-mode` 范式,`useAppSettingsStore().get<boolean>('df-ai-list-input', false)`),默认**关**再灰度;单点置 false 即完全回退到现状 textarea 行为,零残留。**PASS(带开关)** |
---
## 六、拍板点(需人定,附建议)
1. **Enter 语义**: 保留"Enter=发送,Shift+Enter=换行并续列表"(**建议**,零回归、不违 `aiChat.inputHint` 与 `handleKeydown:906` 既有契约) vs 改为"列表态下 Enter 续行、Cmd/Ctrl+Enter 才发送"(更贴飞书,但变更已固化的发送习惯,置信成本高)。
2. **渲染档位**: 先做**档一 轻量**、档二 WYSIWYG 单独立项(**建议**) vs 直接上档二 contenteditable 富文本。档二会击穿 IME/`@`span/粘贴图/autoResize 全链,工作量与回归面远超本需求。
3. **范围边界**: 标记仅 `\d+. ` + `- `(**建议**起步) vs 含 `)`、`*`、`+`、嵌套缩进、以及是否做**自动重排号**(renumber 改用户文本,单独开关)。
4. **视觉形态**: V0 零视觉先行 → V1 mirror 框内高亮(**建议路径**,但需实测 textarea 透明化的选区/无障碍影响) vs 直接 V1 vs 用 V2 输入框上方预览条替代。(此项与拍板点 2 联动:若否决档二,则 V1 是"框内感"的唯一可达形态。)
---
## 附:实施落点清单(供实施会话直接取用)
| 文件 | 改动 |
|------|------|
| `src/composables/ai/useListContinuation.ts` | 新增:`analyzeListContext` / `nextMarker` / 围栏守卫 /(可选)`renumberOrdered` + 单测 |
| `src/components/ai/ChatInput.vue` | `handleKeydown`(814-910)插入 Shift+Enter/Backspace 分支;`<script>` 引入 composable;V1 时包一层 `.ai-input-field` + `<div class="ai-input-mirror">` 并同步 scroll/height;读开关 `df-ai-list-input` |
| `src/styles/components.css` | V1:`.ai-input-field` / `.ai-input-mirror` / `.ai-list-marker` |
| `src/i18n/zh-CN/aiChat.ts` + `src/i18n/en/aiChat.ts` | 可选:续行提示文案 |
> 参照档: `无人值守长任务-设计-2026-10-04.md`(结构/详略) · `上下文三层记忆-设计-2026-10-04.md` · `跨会话召回最小闭环-设计-2026-10-04.md`(同一目录写法)。
@@ -2,7 +2,7 @@
> 创建: 2026-06-10 | 状态: 设计中 | 最后重写: 2026-06-15 (DOC-01 硬伤修复) > 创建: 2026-06-10 | 状态: 设计中 | 最后重写: 2026-06-15 (DOC-01 硬伤修复)
> >
> **本文档为 ARCHITECTURE.md 的实质载体**(项目无独立 ARCHITECTURE.md 文件)。所有数据模型、设计决策均以源码为基准,已剔除虚构内容。 > **项目架构总纲见根 [ARCHITECTURE.md](../../../ARCHITECTURE.md)**,本文档为其补充细节。所有数据模型、设计决策均以源码为基准,已剔除虚构内容。
--- ---
@@ -16,6 +16,15 @@
> 注:用户贴的"你好"会话原始调用(`"project_id":"DevFlow"` + `pattern:"scan"`)在三个库中均**未检索到**, > 注:用户贴的"你好"会话原始调用(`"project_id":"DevFlow"` + `pattern:"scan"`)在三个库中均**未检索到**,
> 疑未落库或已清理。但同类行为在库中有大量实证,结论不依赖该单例。 > 疑未落库或已清理。但同类行为在库中有大量实证,结论不依赖该单例。
>
> **✅ 2026-10-10 定位恢复(推翻上文"未检索到")**:该会话已实证定位——`devflow-dev.db` 会话
> `b4d6b4e0-6901-4937-af47-d3202678406b`(标题「打招呼」,模型 `sensenova-6.7-flash-lite`,创建
> 2026-08-04 04:00:46 UTC)。`ai_conversations.messages` blob 恰为该轮:`[user] "你好"` +
> `[assistant] 空文本 + tool_calls=[list_project_modules, search_files]`;`ai_tool_executions`
> 两行逐字对上截图(`{"project_id": "DevFlow"}`→`{"modules":[],"project_id":"DevFlow","total":0}`;
> `{"pattern":"scan","path":"E:/wk-lab/devflow/src-tauri","recursive":true}`→`{"has_more":false,...,"total":0}`),
> tool_call_id 为弱模型特征 `gen_stream_4`/`gen_stream_23`,message_id `msg_1785816059122_5`。
> 原检索为空的可能原因:该轮未落 `ai_messages`(该会话 ai_messages 最早为 2026-08-06),仅存于 blob+executions 表。
## 实证 1:重复探索是系统性现象(非偶发) ## 实证 1:重复探索是系统性现象(非偶发)
@@ -50,6 +59,12 @@
## 实证 4:问候本身不触发工具,但弱模型任务中失控 ## 实证 4:问候本身不触发工具,但弱模型任务中失控
- 修正:纯"你好"第一轮模型会正常回复能力介绍(09e7abfa/1982ff08 均如此),**0 例**"问候后紧邻调工具" - 修正:纯"你好"第一轮模型会正常回复能力介绍(09e7abfa/1982ff08 均如此),**0 例**"问候后紧邻调工具"
> **⚠️ 2026-10-10 更正(实证推翻本条)**:会话 `b4d6b4e0`(标题「打招呼」)的**首轮**即是裸问候
> 「你好」→ assistant **空文本 + 直接发 2 个 tool_calls**,无任何前置上下文。故"问候本身不触发工具"
> 不成立——至少对 `sensenova-6.7-flash-lite` 存在**首轮问候即开工具**的实例。原"0 例"结论源于当时未
> 检索到该会话(其轮次未落 ai_messages),属样本缺失导致的误判。据此,「工具全量暴露 + 弱模型自控弱」
> 的诱因比原判更严重:**无需任务上下文,裸问候即可触发工具探索**。
- 用户贴的"你好犯傻"更可能是:会话有历史上下文(scan/工程模块任务残留),模型把"你好"当继续信号,或 system prompt 全量 55 工具 + "优先使用工具"引导诱使弱模型"准备上下文" - 用户贴的"你好犯傻"更可能是:会话有历史上下文(scan/工程模块任务残留),模型把"你好"当继续信号,或 system prompt 全量 55 工具 + "优先使用工具"引导诱使弱模型"准备上下文"
- 一旦进入实质任务,弱模型即进入重复探索/绕行/参数错误循环 - 一旦进入实质任务,弱模型即进入重复探索/绕行/参数错误循环
@@ -0,0 +1,143 @@
# 上下文管理五维走查(2026-10-10)
> 走查方式:五路只读代理并行(组装/预算 · 压缩 · 自愈清洗 · 记忆召回 · 工具结果),全程源码取证;瓶颈清单摘要登记看板 `docs/todo.md` #939,本文为全文落档。
> ⚠️ 行号快照:`crates/df-ai/**` 与 `src-tauri` 部分文件当日有并行改动(工具结果外置 P1 WIP / Wave J 修复),行号为 2026-10-10 走查时点,批次开工以现场源码为准再核对。
> 关联设计:`docs/02-架构设计/上下文根治设计-20261006.md`(223:1 根治总纲)· `docs/02-架构设计/专项设计/上下文分层组装器-落地方案-2026-10-10.md`(五病灶锚点实测 + 接口化落地方案)。
> 状态标注:【已修-工作区】= 修复已落工作区待提交(Wave J/守夜期产出);【待解禁】= 修复点在 `crates/df-ai`,需并行写者完工后接手;★ = 可先在 src-tauri 侧落。
---
## 零、背景与铁证
- **223:1 出处**:84cb104c 会话 2.2 天烧 4.3 亿 tokens,prompt:completion = **223:1**,日均 1.9 亿(`上下文根治设计-20261006.md` §量化预期)。
- 走查定位出该结构性浪费的共同根因:**记账(history_tokens)与真实发送体量脱钩 → 三重门槛(预算门/裁剪门/压缩门)恒不触发 → 窗口永不收敛 → 每轮全量重发**。
---
## 一、组装/预算维度
### HIGH① history_tokens 与真实请求体量脱钩(根因,最重)【待解禁】
机制链四步,环环实证:
1. **引用化发生在 push 前**:工具结果在审计层先经 `should_use_namespace` 判定(`audit/mod.rs:1184-1191`;`namespace_store.rs:47-53`),`ALWAYS_LARGE_TOOLS = read_file/list_directory/grep/diff_files`(`namespace_store.rs:38`)**命中即引用、与实际体积无关** → `store()`(`namespace_store.rs:124-131`)入内存池,消息里只留短串 `namespace://tool/{hash}`。
2. **记账只数短串**:`ContextManager::push`(`context/mod.rs:101-143`)对这条短串跑 `estimate_message`(`context_helpers.rs:62-105`),`history_tokens += tokens` 在 `:108-110` → **单条上万 token 的大结果只记 ~15 token(~30 字符)**。
3. **展开发生在发送时**:`agentic/mod.rs:1927-1944` 才把引用展开为真实体量(单条 max 150KB,见 P1 注释 `context_helpers.rs:427-432`)。
4. **三重门槛双双恒不触发**:
- 预算门 `context/mod.rs:198`:`history_tokens <= available` 恒真 → 直接返回全量;
- 裁剪门 `context/mod.rs:239-246`:永不达阈值;
- 压缩门 `context_lifecycle.rs:133-135`:`available*6/10 < history_tokens` 恒假。
→ 每轮实际重发展开后的全量历史 → 223:1。
- **修**(二选一或组合):push/外置时按**原文体量**入账(记账基点 = 发送视图尺寸单一真值,见组装器方案 §3.1,兼解 HIGH② 的自愈重算);或估算器识别 namespace ref 取池内真实大小。修复点在 `crates/df-ai`(估算器)+ `src-tauri`(入账点),df-ai 侧待解禁。
- **本质澄清**:namespace_store 是**内存态引用化**,只省进程内存,发 LLM 前展开全文 → **token 零收益**(与 P1 落盘外置的对比见维度五)。
### HIGH② 每轮 O(n) 全量克隆 + sanitize + 展开 + 摘要 → 会话累计 O(n²)【待解禁】
- 未超预算分支 `context/mod.rs:198-205` 走 `all_messages_clone()`(`:294-296`,逐条深克隆)+ sanitize + 出口断言;
- 展开 `agentic/mod.rs:1927-1944` 对**全部**历史逐条 `read_only` + String 克隆;
- 摘要 `summarize_tool_results`(`stall_detector.rs:313-385`)再遍历全部消息做内容变换。
- 三者每轮各 O(n)(调度点 `agentic/mod.rs:1904-1921/1945-1957/1981-1984`),会话累计 **O(n²) = 全量重发本体**。根因与①同源:门失效 → 窗口永不收敛 → n 随会话单调涨。**治本 = 组装器方案 P2 分层组装**(接口化,见关联设计)。
### ★HIGH③ 预算上限恒 128K,不随模型真实窗口【已修-工作区(主会话);子代理残留】
- 原状:主会话构造点 `commands/ai/mod.rs`(走查时点 :1239)`ContextManager::new(ContextConfig::default())`;`context_helpers.rs:128-136` `max_tokens=128_000`,`budget_limit` 由它算(`:138-143`);子代理同病 `sub_agent.rs:117-127`。真实窗口在 `ModelConfig.context_window`(`df-ai-core/src/model.rs:138-139`)却只被 router 使用,代码自陈该维度「恒过、失效」(`agentic/mod.rs:2012` 注释)。
- 影响:1M 窗口模型被当 128K 裁(**能力降级**);小窗口模型(如 deepseek-chat 64K)预算高于实际窗口(**超发风险**)。上界取 1M 而非 128K 的理由:`max_tokens` 只是**裁剪预算**——历史不足则不裁、不会撑大实际发送,卡 128K 等于不治根因。
- 修复(Wave J,工作区):`commands/ai/model_context.rs` 新增 `context_config_for_model` 三级优先(预设表/用户手填可信窗口/未知回退 128K)+ 仅按窗口下调或持平、绝不放大 + 安全顶挡脏预设值;主会话收口单一入口 `apply_model_window`(`commands/ai/mod.rs:1274-1284`,构造仍 default、发送入口按 resolved_model 重设)+ 7 单测。
- **残留缺口**:子代理未接——`sub_agent.rs:119`(`SUB_AGENT_CONTEXT_MAX_TOKENS=128_000`)、`:123-126`、`:761` 仍固定 128K,待改走 `context_config_for_model` 统一口径。
### MED⑤ 估算器 chars_ratio=0.35 系统性偏差【待解禁】
`context_helpers.rs:29-44/73-74`:中文低估 2-3 倍、base64 高估(注释自陈 OpenAI 按像素计而非字节,0.35 偏保守)。多模态回归修正已并入(parts 文本/base64 计入 + url 图 1600 token 地板折算在 ×ratio 之后),但系数本身未分语种/分载荷。方向:分系数校准,需独立评估裁剪边界。
### MED⑥ 保护区固定 6 条 + 淘汰补偿仅 300 字【待解禁】
`context/mod.rs:648/:655`:保护区条数硬编码;被淘汰内容的补偿摘要极短,早期主题对 LLM 快速失忆。与⑦(无召回)叠加后**窗口外内容实质永久丢失**。
### LOW⑬ 估值双轨从不互相校准
`estimated_prompt`(路由用,较准)与 `history_tokens`(预算用,失真)两轨并存,无任何校准/对账点——①的失真因此长期无告警。修①时可顺带加「双轨偏差超阈即 warn」的可观测。
### LOW⑭ sys_tokens 未计工具 schema
system 侧估算不含工具 schema 体积,预算口径偏乐观。
### 前缀缓存两处失效源(组装侧)
- **⑨ 滑动裁剪位移前缀【待解禁】**:超预算时 `context/mod.rs:239-246` 切 `messages[trim_end..]`——头部被切,整个请求前缀位移,provider 自动前缀缓存(DeepSeek/Anthropic)整段失效。已有部分缓解:淘汰摘要改尾部追加(`:259-261`)、`push_system_anchor` 避让悬空头(`:582-595`)、`tool_view_decisions` 冻结压缩决策防跨轮翻转(`stall_detector.rs:352-363`);但**基础裁剪本身仍位移前缀**,且淘汰摘要内容逐轮漂移(`:259` 每轮即时重生成,被淘汰区单调增长)。
- **⑧ system 可变段内嵌稳定段【已修-工作区】**:可变段(日期 + 项目/任务清单)混在稳定前缀内致前缀缓存失效;清单描述不截断(走查时点 `prompt.rs:565/:584`)加剧体量。修复(Wave J):`df-ai-anthropic-cache` 拆分开关**默认开**(`prompt.rs:653-687`,键名保留历史名、语义扩展覆盖 openai_compat 自动前缀缓存——易变段挪到消息流末尾,开关关时非官方网关零风险)+ 清单描述截断 `CONTEXT_DESC_MAX_CHARS=120`(`prompt.rs:562/:604/:629`)。
---
## 二、压缩维度
### HIGH④ 压缩单向不可逆,被压原文对 LLM 永久不可见【待解禁】
- `compress_old_messages`(`context/mod.rs:664-699`)标记 Compressed(幂等:已压跳过、token 只扣首次)→ 发送视图 sanitize step0 按 `is_active` 过滤出局(`context/mod.rs:107-108` 统一剔除)。
- 后果:压缩后 LLM 对早期细节**不可回读**;窗口外内容(见⑥⑦叠加)实质永久丢失。设计 C-r1「压缩不丢/可召回」未落地。
- 方向:复用 P1 句柄模式——压缩写 pointer 可回读(压缩摘要 + 「原文句柄路径」,agent 需要时 read_file 找得回),与工具结果外置同构。
### MED⑩ 一次压尽,无分批【待解禁】
触发压缩时一次压完可压区,无 windows 式分批(分批可平摊摘要质量波动与 token 峰值)。
### HIGH⑪ 后台压缩无 epoch/内容版本守卫【已修-工作区】
- 原状(`context_lifecycle.rs:206-323` 走查时点):后台 task 无任何守卫——`clear()` 保留 per_conv 条目且复位 is_compressing → **压缩窗口内旧摘要可注入已清空的新上下文**(跨代污染)。loop 侧有 F1 epoch,task 侧没有。
- 修复(Wave J,工作区):**内容版本哨兵 = 首条消息全局唯一 id**(`context_lifecycle.rs:63-68`)——否掉 len 方案:压缩期间 loop 正常 append 会使 len 增长,以 len 相等为守卫永不生效;重建后首条必换新 id,是「上下文被整体替换」的准确且零成本哨兵。快照触发时首条 id,后台 task 提交前比对(`:74/:153`),不匹配即丢弃本次摘要。
### HIGH⑫ 后台压缩无 panic 兜底【已修-工作区】
- 原状:后台 task panic → is_compressing 永真 → **该会话压缩永久停摆**。
- 修复(Wave J,工作区):整个后台 body 包 `catch_unwind`(复用 `mod.rs::unwind_to_result` 薄包装,`context_lifecycle.rs:236-244`、复位句柄 `:79-82`),panic 以 Err 返回 → 复位 is_compressing + warn。依赖 panic=unwind(tokio 默认);panic=abort 构建下退化为进程终止(尽力兜底,已在注释声明)。
---
## 三、自愈清洗维度(sanitize)
本维整体结论**良好**,一处缺陷已修:
- **已核良好**:裁剪三元组原子性正确(`context/mod.rs:612-642`);909① 两处修复现状有效——`push_system_anchor` 锚点避让悬空头(`:582-595`)、压缩重入单锁复位,未见旧窗口复现。
- **出口防御在位**:畸形配对自愈后 `assert_placeholder_pairing` 出口断言(`context/mod.rs:198-205` 路径内),失败降级 `TOOL_MISSING_PREFIX` 自愈,防 400 orphan 复发。
- **已修【已修-工作区】多夹层归位有损**(对抗审查③):原 `reorder_interlopers_after_tool_run` 只把头与「首个连续 tool 段」间的夹层后移 → `[head(c1,c2), X, tool(c1), Y, tool(c2)]` 时 c2 仍被判未闭合剥除(非回归,但「无损」宣称不成立)。守夜期重写为**聚拢该头全部响应(保序)到夹层之前**,边界语义不变(遇下一个头即停/响应集合耗尽即停),更名 `gather_tool_responses`(`sanitize.rs:223`,调用点 `:50`,描述做的事、短一半);新增多夹层 + 幂等单测。「无损归位」至此对多夹层成立。复验:`cargo test -p df-ai --lib context` 157 passed / 0 failed。
---
## 四、记忆召回维度
- **⑦ 窗口外历史零召回【待解禁(属 P2 分层组装)】**:组装是隐式的——system 由 `build_runtime_prompt`(`agentic/mod.rs:3042`)拼、历史由 `build_for_request` 拼、易变尾由 `merge_volatile_tail` 拼,无统一组装器对象、无可观测统计;检索**仅覆盖知识库**:`hybrid_search`(`knowledge_inject.rs:421`)→ `build_knowledge_context`(`:508`)→ `build_knowledge_context_for_conv`(`:587`),**窗口外对话历史与压缩摘要无处可召回**。与⑥(补偿仅 300 字)叠加 = 「裁掉即失忆」。
- **方向(组装器方案 §3.3 已定)**:检索召回复用 `knowledge_inject` 单入口——把窗口外历史/压缩摘要作为第三类召回源挂进既有管线,不另造第二套检索;L3 记忆卡(`上下文三层记忆-设计-2026-10-04.md` C-m1)是召回的内容来源,组装器只提供入口。
- **跨会话召回底座查证(Wave L)**:文件租约与跨会话召回 **HEAD 已实现**(`src-tauri/src/commands/ai/lease.rs`,cb260ab;召回链 cb260ab+9e81182)——未另造双轨,只补纯函数与单测(lease 11 / knowledge_inject 29 通过),W-h 已销账。新需求只接既有底座,勿重建。
---
## 五、工具结果维度
- **namespace 引用化机制(HIGH① 的机制载体)**:判定 `should_use_namespace`(`namespace_store.rs:47-53`)两条路——`ALWAYS_LARGE_TOOLS` 四工具命中即引用(与体积无关)或超 `NAMESPACE_BYTE_THRESHOLD`/`NAMESPACE_LINE_THRESHOLD`;入池 `store()`(`:124-131`),pin 占比上限 0.5(`:44`),EvictionLog 容量 50 条(`:41`)。**定位澄清**:它治的是进程内存,不治 token——发送时展开回全文。
- **P1 工具结果外置(真正治 token)【并行写者 WIP,未提交】**:`crates/df-ai/src/tool_offload.rs`(新,~117 行)+ `context_helpers.rs`(`READ_FILE_OFFLOAD_BYTES=8KB` / `extract_key_info` 句柄追加)+ `lib.rs`(pub mod)+ `tests/**`——超阈值结果**正文落盘**、上下文只留「摘要 + 句柄路径」,agent 可 read_file 展开回读:被省略的中段不进 prompt 但找得回 → 真正减 token 且不丢信息。与 namespace_store 互补不冲突;降级契约(base 未注入返回 None)+ 幂等(内容稳定哈希只落盘一次)+ 只增不减保留策略齐备。该批静置归属待用户拍板(含 `src-tauri/src/lib.rs`、`stall_detector.rs` 挂点)。
- **HIGH① 的入账修复与外置的衔接**:外置落地后,push 侧按**落盘前原文体量**入账即可同源治掉①(记账基点 = 发送视图尺寸);两者是同一根因(记账失真)的内容侧与账本侧修复,须协同验收。
---
## 六、修复状态总表(截至 2026-10-10 收盘)
| # | 瓶颈 | 级别 | 状态 | 落点 |
|---|------|------|------|------|
| ① | history_tokens 与真实体量脱钩 | HIGH | 待解禁(依赖 df-ai) | 入账点 src-tauri + 估算器 df-ai |
| ② | 每轮 O(n) 全量重发 → O(n²) | HIGH | 待解禁(P2 分层组装) | 组装器方案 |
| ③ | 预算恒 128K | ★HIGH | **主会话已修-工作区**;子代理残留 | `model_context.rs` + `mod.rs:1274`;子代理 `sub_agent.rs:761` 待接 |
| ④ | 压缩不可逆无重载 | HIGH | 待解禁(C-r1 句柄化) | df-ai |
| ⑤ | chars_ratio 偏差 | MED | 待解禁 | df-ai |
| ⑥ | 保护区 6 条 + 补偿 300 字 | MED | 待解禁 | df-ai |
| ⑦ | 窗口外零召回 | MED | 待解禁(复用 knowledge_inject 入口) | src-tauri |
| ⑧ | system 可变段毁前缀缓存 | ★ | **已修-工作区**(开关默认开 + 描述截断 120) | `prompt.rs` |
| ⑨ | 滑动裁剪位移前缀 | MED | 待解禁 | df-ai |
| ⑩ | 一次压尽无分批 | MED | 待解禁 | df-ai |
| ⑪ | 后台压缩无版本守卫 | HIGH | **已修-工作区**(首条消息 id 哨兵) | `context_lifecycle.rs:63-74` |
| ⑫ | 后台压缩无 panic 兜底 | HIGH | **已修-工作区**(catch_unwind 复位) | `context_lifecycle.rs:79-82,236-244` |
| ⑬ | 估值双轨无校准 | LOW | 随①顺带 | — |
| ⑭ | sys_tokens 无工具 schema | LOW | 待解禁 | df-ai |
| — | 多夹层归位有损(自愈维) | 缺陷 | **已修-工作区**(`gather_tool_responses`) | `sanitize.rs:223` |
> 收敛路线:★ 项与守卫类已落;①④⑨ 等 df-ai 侧修复待并行写者(工具结果外置 P1)完工解禁后接手,届时以「记账基点 = 发送视图尺寸单一真值」一条主线串起 ①→②自愈,量化目标 223:1 → ≤50:1(根治设计 §四)。
@@ -259,7 +259,7 @@
> **已完成**:`SELECT *` 改为显式 14 列(id, kind, title, content, tags, status, confidence, reuse_count, verified, source_project, source_ref, reasoning, created_at, updated_at),`embedding` 单独另取。消除隐式依赖,字段级精简待单独立项。commit 4a95f6a(该 commit 内编号为 FR-D2)。 > **已完成**:`SELECT *` 改为显式 14 列(id, kind, title, content, tags, status, confidence, reuse_count, verified, source_project, source_ref, reasoning, created_at, updated_at),`embedding` 单独另取。消除隐式依赖,字段级精简待单独立项。commit 4a95f6a(该 commit 内编号为 FR-D2)。
### 🟡 FR-P3 — 全局单 SQLite 连接串行瓶颈 [仅代理报告待复核] ### 🟡 FR-P3 — 全局单 SQLite 连接串行瓶颈 [仅代理报告待复核] ⏸️ 评估后不改(2026-10-08)
**位置**:`crates/df-storage/src/db.rs:13-60` + `crud.rs` 宏内 `spawn_blocking`+`blocking_lock` **位置**:`crates/df-storage/src/db.rs:13-60` + `crud.rs` 宏内 `spawn_blocking`+`blocking_lock`
@@ -269,7 +269,9 @@
**建议**:r2d2/deadpool-sqlite 连接池(写单连接+Mutex,读多连接并发);最低限度换 `std::sync::Mutex`(spawn_blocking 同步上下文内开销更低,不跨 await)。 **建议**:r2d2/deadpool-sqlite 连接池(写单连接+Mutex,读多连接并发);最低限度换 `std::sync::Mutex`(spawn_blocking 同步上下文内开销更低,不跨 await)。
### 🟡 FR-P4 — `human_node` 500ms 忙轮询取消状态 [已核实] > **⏸️ 2026-10-08 评估后判定不改**:① 架构约束——`conn()` 返 `Arc<Mutex<Connection>>`,全仓数十处调用点按此签名取锁,改连接池需重构全部调用点 + 迁移逻辑(`run_migrations` 亦持单锁),改动面大风险高;② 收益极低——SQLite 写操作本就串行(WAL 下写写互斥),连接池对写无收益,读并发在桌面单用户场景无实际压力;③ 现状已缓解——`busy_timeout(5000)` 已解 GUI+MCP server 多进程写冲突(真实痛点)且已落地;④ 收益/风险比不划算,动 DB 核心层高危。保留 `db.rs:60` TODO 注释备忘。
### 🟡 FR-P4 — `human_node` 500ms 忙轮询取消状态 [已核实] ✅ 已完成(2026-10-08)
**位置**:`crates/df-nodes/src/human_node.rs:54` **位置**:`crates/df-nodes/src/human_node.rs:54`
@@ -279,7 +281,9 @@
**建议**:取消走独立 `tokio::sync::Notify`/oneshot,HumanNode `notify.notified().await` 零轮询。注:todo.md:142 已记"停止生成 idle 用 Notify 替代轮询",此为同类不同位置(审批节点 vs stopChat)。 **建议**:取消走独立 `tokio::sync::Notify`/oneshot,HumanNode `notify.notified().await` 零轮询。注:todo.md:142 已记"停止生成 idle 用 Notify 替代轮询",此为同类不同位置(审批节点 vs stopChat)。
### 🟡 FR-P5 — `build_system_prompt` 每次发消息全表扫项目 [仅代理报告待复核] > **✅ 2026-10-08 已完成**:`StateMachine` 加 `cancel_notify: Arc<tokio::sync::Notify>` 字段(与 states 同 Arc 共享,clone 浅拷贝同一 Notify);`set_cancelled` 置位后 `notify_waiters()` 唤醒;新增 `cancel_notified()` 暴露 `Notified` future。`human_node.rs` select! 循环内 `let cancel_wait = ctx.node_status.cancel_notified()` 替代 `cancel_tick.tick()`,唤醒后仍复查 `is_cancelled` 兜底(Notify 无状态,可能因他节点取消误唤醒)。`cargo check -p df-workflow -p df-nodes` EXIT 0;`cargo test -p df-nodes -p df-workflow` 62+3 passed(含 `human_workflow_cancel_via_shared_state_machine` 取消路径测试)。
### 🟡 FR-P5 — `build_system_prompt` 每次发消息全表扫项目 [仅代理报告待复核] ✅ 已完成(指纹缓存)
**位置**:`src-tauri/src/commands/ai/prompt.rs:70-86` **位置**:`src-tauri/src/commands/ai/prompt.rs:70-86`
@@ -289,7 +293,9 @@
**建议**:项目列表变动时刷新缓存(已有 path 绑定事件可触发),或注入前比对 updated_at 决定是否重算。 **建议**:项目列表变动时刷新缓存(已有 path 绑定事件可触发),或注入前比对 updated_at 决定是否重算。
### 🟡 FR-P6 — `AiChat.vue` watch messages 用 JSON.stringify 全量快照 [仅代理报告待复核] > **✅ 已完成(指纹缓存)**:`prompt.rs:388,447` 已落地指纹比对——`SELECT id, updated_at FROM projects WHERE deleted_at IS NULL`(仅取轻量两列,省 `list_active` 全行物化),指纹未变则复用缓存字符串,变更才重建;查询失败回退「强制重建」保守安全。原「全表扫 + 每次重拼」病灶已消除。
### 🟡 FR-P6 — `AiChat.vue` watch messages 用 JSON.stringify 全量快照 [仅代理报告待复核] ✅ 已完成(已消除)
**位置**:`src/components/AiChat.vue:670-678` **位置**:`src/components/AiChat.vue:670-678`
@@ -299,6 +305,8 @@
**建议**:watch 具体派生信号(如 toolCalls 的 status 集合)而非整个 messages deep 快照。 **建议**:watch 具体派生信号(如 toolCalls 的 status 集合)而非整个 messages deep 快照。
> **✅ 已完成(已消除)**:`AiChat.vue` 全仓 grep `JSON.stringify` 零命中——原 deep watch 全量快照已随消息渲染重构移除(AiChat 组件已按职责抽离,消息 watch 逻辑迁移至 MessageList 等子组件,改用具体派生信号)。病灶不复存在。
--- ---
## 6. 可读性 / DRY(重构项) ## 6. 可读性 / DRY(重构项)
@@ -0,0 +1,84 @@
# 版本升级链走查(2026-10-10)
> 走查方式:用户要求的只读走查 + 本地全链演练(零线上影响),源码与现场日志取证;本文为全文落档(看板/汇总仅摘要)。
> 铁证来源:应用 trace(`%TEMP%/devflow-trace.log`)· 演练服务访问日志(`temp/upgrade-drill/access.log`)· 脚本与配置现场(`scripts/publish-release.ps1` / `scripts/package-devflow.ps1` / `src-tauri/tauri.conf.json` / `src-tauri/src/commands/update.rs`)。
> 结论先行:**升级核心机制本身零缺陷**——四发现全在链路外围(端点发布/脚本产物格式/传输协议/本地打包拆分),这也是本地演练能全链闭环的前提。
---
## 一、链路全景(走查基线:代码链本身完整 ✓)
- **后端**:`app_check_update` / `app_download_install_update`(`src-tauri/src/commands/update.rs`)——走 Tauri updater `check()` 拉 `tauri.conf.json` 的 `plugins.updater.endpoints` 配置的 latest.json,**内建 ed25519 签名校验**(公钥 `tauri.conf.json:47`);无更新返回 `has_update=false`,网络/解析失败返回 Err(前端据此提示,不谎报「已是最新」)。
- **前端**:`useUpdateCheck` → 徽章 / 设置页(徽章事件通道错配——Tauri 事件 vs window CustomEvent 同名不同通道致徽章永不触发——已于前批修复并登记看板 925)。
- **发布侧**:`publish-release.ps1` 六步(版本双源校验 → 构建 → 定位产物 → 生成 latest.json → 上传 app.1216.top);版本双源校验 `:28-38`(tauri.conf.json vs Cargo.toml 必须同值,防 D1 复发);私钥已就位 `~/.tauri/devflow.key`。
- **版本一致性闸**:`update.rs:89-104` 用 `CARGO_PKG_VERSION` 与 latest.json 的 `version` 比较,非更新返回 `has_update=false`(演练中验证其正确拦截,见 §三)。
---
## 二、四发现(按严重度)
### 发现 1 🔴 线上端点从未发布——应用内升级从未真正可用【已修复:v0.1.2 发布上线】
- 事实:`https://app.1216.top/download/devflow/latest.json` **404**;目录 403(存在但空)——从未有产物上传过。
- 铁证:应用 trace 每 15 分钟报 `update endpoint did not respond with a successful status code`,**07:33 / 07:48 / 08:03 / 08:18 / 08:23 连续五次**——自动检查一直在跑、一直失败。
- 影响:发布流程「能构建能上传产物」但 latest.json 这最后一环缺失,应用内升级通道自上线以来形同虚设。
### 发现 2 🟠 发布脚本产物名与 Tauri v2 不匹配——跑通也会 404【已修复:脚本 v2 适配】
- 事实:`publish-release.ps1` 期待 v1 格式 `...setup.nsis.zip` + `.nsis.zip.sig`;而 `createUpdaterArtifacts: true`(`tauri.conf.json:33`)在 **Tauri v2 实际产出 `...setup.exe.sig`**(更新包即安装器本身)。
- 两个后果:① 脚本停在「未找到 .sig 签名文件」直接 exit;② 即使产物名对上,它写进 latest.json 的 url 也指向不存在的 `.nsis.zip` → 客户端下载 404。
- 修法二选一(当时列出):脚本改读 `.exe.sig` + url 指 exe(v2 格式,**已采纳**);或配置改 `createUpdaterArtifacts: "v1Compatible"`。
### 发现 3 🟡 updater 强制 https——自建 http 更新源不可行【机制确认,线上无碍】
- 事实:本地 http 端点被 updater 拒绝,实测报错原文 `The configured updater endpoint must use a secure protocol like https`。
- 判定:线上是 https 无碍;但**演练/私有 http 更新源**必须二选一——真证书,或构建期 `--config` 注入 `dangerousInsecureTransportProtocol: true`(演练采纳后者,见 §三,仅演练专用构建携带、不改生产配置语义)。
### 发现 4 🟡 本地 `package-devflow -Mode full` 缺密钥必报「失败」并跳过安装段【已修复:换装与签名拆开】
- 事实:本机未设 `TAURI_SIGNING_PRIVATE_KEY` 时,产包 updater 签名段必报失败并**跳过安装段**——这正是此前每次都要手工跑 `swap-install.ps1` 的根因(2026-10-10 实录:`package-devflow -Mode full` 产包后因缺密钥报失败跳过安装)。
- 本质:本地换装(自用安装最新构建)与发版签名(updater 产物)是两类需求被一个开关捆死。修法:本地换装不需要签名产物,两者拆开。
---
## 三、本地全链演练(零线上影响)
### 演练设施
| 件 | 位置 | 说明 |
|---|---|---|
| 端点覆盖 | `update.rs:63-84` `build_updater` | env `DEVFLOW_UPDATER_ENDPOINT` 覆盖端点,**不设即走 tauri.conf 配置**(生产零影响);覆盖时 warn 日志「更新端点被环境变量覆盖(本地演练模式)」 |
| 传输放开 | `temp/drill-tauri-override.json` | 仅 `dangerousInsecureTransportProtocol: true`,演练专用构建以 `--config` 注入(治发现 3 的演练侧) |
| 真 v2 产物 | `temp/upgrade-drill/` | `devflow_0.1.2_x64-setup.exe`(13.4MB)+ `.exe.sig`(436B,base64 minisign 格式,与 `tauri.conf.json:47` pubkey 配对)+ `latest.json`(version/notes/pub_date/platforms.windows-x86_64.{signature,url},url 指 `http://127.0.0.1:8765/...exe`) |
| 带日志本地源 | 同目录 `access.log` | 8765 端口静态服务,全量记录请求 |
### 演练链与现场证据(access.log 时间线)
1. **端点覆盖生效 → 检查 200**:`08:47:51 GET /latest.json 200`——env 覆盖后的首次检查即通(对照发现 1 的 trace 连续失败,同一检查链路换个可用端点立即恢复)。
2. **发现更新 → 下载 200**:`09:31:36-09:32:16` latest.json 200 → `GET /devflow_0.1.2_x64-setup.exe 200`(完整下载 13.4MB)。
3. **验签通过**:latest.json 的 signature(ed25519)经 `tauri.conf.json:47` 公钥校验通过——签名/公钥/私钥三件套自洽(此后下载才允许进入安装)。
4. **版本一致性闸正确拦截**:`update.rs:89-104` 以 `CARGO_PKG_VERSION` 与 latest.json `version` 比较——当本机版本 ≥ 通告版本时返回 `has_update=false` 拒绝安装(闸本身行为正确);演练初期版本差未成立即被此闸拦下,**0.1.2 产物重建(本机保持 0.1.1 演练版)后版本差成立**。
5. **闭环复现**:`12:43:03-12:43:50` 第二轮完整通过(latest.json 200 → exe 200 → 回查 latest.json)——检查→下载→验签→版本判定全链跑通;且 `09:43:43→11:46:37` 每 ~15 分钟一次的周期自动检查连续 9 次全部 200——自动检查定时器在演练端点下工作正常(对照发现 1:同一台机器同一定时器,此前对线上端点连续失败)。
> 演练结论:**升级核心链(检查→下载→ed25519 验签→版本一致性闸→安装)在本机全链可用**,四发现均为外围问题且逐一有解。
---
## 四、已落地修复清单(截至当日收盘,均在盘待提交)
| # | 修复 | 落点 | 治哪个发现 |
|---|---|---|---|
| 1 | env 端点覆盖 `DEVFLOW_UPDATER_ENDPOINT`(不设即走配置 + warn 日志) | `src-tauri/src/commands/update.rs:63-84` | 演练设施(兼私有源能力) |
| 2 | 脚本 v2 产物适配:`.exe.sig`/`.nsis.zip.sig` 双格式探测,与更新产物正确配对(v2 签名配 setup.exe,v1 配 zip);url 指向真实产物 | `scripts/publish-release.ps1:65-71` | 发现 2 |
| 3 | SHA256 计算兜底:本机 PowerShell 5.1 缺 `Get-FileHash`,异常走 `certutil -hashfile`(同 swap-install 兜底口径) | `scripts/publish-release.ps1:84-87` | 发布机环境鲁棒性 |
| 4 | **v0.1.2 正式发布上线**:`publish-release.ps1 -SkipBuild` 上传成功,线上 latest.json 实测 **200**(version 0.1.2)——应用内升级通道正式激活 | 线上 `app.1216.top/download/devflow/` | **发现 1(🔴 404 清零)** |
| 5 | 本地换装与发版签名拆开:未设 `TAURI_SIGNING_PRIVATE_KEY` 时以 `--config` 关掉 `createUpdaterArtifacts`,不再假失败、不再跳过安装段;发版(需签名)走 `publish-release.ps1` | `scripts/package-devflow.ps1:39-44` | 发现 4 |
| 6 | (前批)UpdateBadge 通道错配修复:补 window CustomEvent 监听 + 卸载清理 | `src/components/layout/UpdateBadge.vue` | 前端侧「有更新不亮徽章」 |
---
## 五、待办与纪律留痕
- **正式验收未走完**:本机装的仍是 **0.1.1 演练版**(带 env 覆盖构建)。验收动线:正常重启应用(**不设 env**)→ 检查更新 → 从**线上**发现 0.1.2 → 升级 → 关于页显示 v0.1.2 → 再查一次显「已是最新」。
- **纪律事件**:当日 e296c84 提交经用户令「撤」已回退,升级链 4 文件(update.rs / publish-release.ps1 / package-devflow.ps1 / tauri.conf.json)回工作区零丢失;纪律强化为**一切提交攒批、最后由用户显式发令**。
- **遗留提示**:发现 3 的 `dangerousInsecureTransportProtocol` 仅存在于演练 override 配置,生产 `tauri.conf.json` 未含、也不应含;若未来要私有 http 源,正路是证书而非放开传输。
@@ -63,5 +63,5 @@ Phase 1 目标:**引擎骨架**,打通 `df-core → df-workflow → df-stora
## 参考文档 ## 参考文档
- [ARCHITECTURE.md](../../ARCHITECTURE.md) — 完整架构设计 - [ARCHITECTURE.md](../../ARCHITECTURE.md) — 完整架构设计
- [PROGRESS.md](../../PROGRESS.md) — 工作进展与交接 - [PROGRESS.md](./PROGRESS.md) — 工作进展与交接
- [Phase1 架构决策](../02-架构设计/滚动规范/Phase1架构决策-2026-06-12.md) - [Phase1 架构决策](../02-架构设计/滚动规范/Phase1架构决策-2026-06-12.md)
File diff suppressed because one or more lines are too long
@@ -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),非回复消息,属正常
+1 -1
View File
@@ -1,6 +1,6 @@
# DevFlow 批次可视化总览 # DevFlow 批次可视化总览
> 基于 [Batch.md](../Batch.md) 生成 | 更新: 2026-07-01 > 基于 [Batch.md](./07-项目管理/Batch.md) 生成 | 更新: 2026-07-01
--- ---
+47 -59
View File
@@ -2,7 +2,7 @@
> 创建: 2026-06-10 | 当前阶段: Phase 2 本地优先开发流程验证 > 创建: 2026-06-10 | 当前阶段: Phase 2 本地优先开发流程验证
> 更新: 2026-08-02 | 05-代码审查 补录 2026-08-02 走查批次:DevFlow AI 全库走查(00-05,12 crate/78 问题)、claude 多代理走查(汇总+nodes/ai-storage/ideas-前端/基建 5 文档)、本次修复摘要(5 根因 + 13 agent P0/P1/P2 + 架构专项 3,提交 fc249ad/f719546) > 更新: 2026-08-02 | 05-代码审查 补录 2026-08-02 走查批次:DevFlow AI 全库走查(00-05,12 crate/78 问题)、claude 多代理走查(汇总+nodes/ai-storage/ideas-前端/基建 5 文档)、本次修复摘要(5 根因 + 13 agent P0/P1/P2 + 架构专项 3,提交 fc249ad/f719546)
> 历次: 2026-06-19 02-架构设计 核对优化(新增分类索引/补录缺失文档/命名规范);06-17 文档缺陷扫描通过(零死链) > 历次: 2026-10-10 按 ls 对齐现存文件(crate 数 8→13;01/04/05/06 子树补录漏登文档);06-19 02-架构设计 核对优化(新增分类索引/补录缺失文档/命名规范);06-17 文档缺陷扫描通过(零死链)
--- ---
@@ -15,57 +15,23 @@ docs/
├── todo.md # 工作看板(任务/bug 跟踪,~1200 行含历史) ├── todo.md # 工作看板(任务/bug 跟踪,~1200 行含历史)
├── 待审查.md # 代码审查队列(CR 登记+回填,67 条已审) ├── 待审查.md # 代码审查队列(CR 登记+回填,67 条已审)
├── 待决策.md # 需人工决策项(产品取舍/方向选型/排期) ├── 待决策.md # 需人工决策项(产品取舍/方向选型/排期)
├── 待决策论证-2026-06-16.md # (归档) 7 项决策论证(D-01~04 已定 / DEC-01~03 已闭环)
├── 01-技术文档/ ├── 01-技术文档/
│ ├── SQLite-CRUD模式-2026-06-12.md │ ├── SQLite-CRUD模式-2026-06-12.md
│ ├── Tauri-IPC模式-2026-06-12.md │ ├── Tauri-IPC模式-2026-06-12.md
│ └── AntV-X6调研报告与引入计划-2026-06-30.md # 图可视化层选型(v3.1.7)+ 依赖图落地计划 │ ├── AntV-X6调研报告与引入计划-2026-06-30.md # 图可视化层选型(v3.1.7)+ 依赖图落地计划
│ ├── 多代理流水线实战经验-2026-10-03.md # 多代理流水线实战经验
│ ├── 团队级基建-部署与运维-2026-10-05.md # 团队级基建部署与运维
│ └── 签名与发布SOP-2026-10-10.md # 更新签名密钥体系/发布流程/验签/踩坑/应急吊销
├── 02-架构设计/ ├── 02-架构设计/
│ ├── INDEX.md # 本目录分类索引(按性质四类 + 命名规范摘要) │ ├── INDEX.md # 本目录分类索引(按性质四类 + 命名规范摘要)
│ ├── Phase1架构决策-2026-06-12.md │ ├── 滚动规范/ # 长期维护的真相源 / 规范 / 决策记录
│ ├── 业务系统设计-2026-06-12.md │ ├── 已编号方案/ # 带 F/B/CR 编号的方案文档
│ ├── 产品定位调整-2026-06-12.md │ ├── 专项设计/ # 无编号的主题设计 / 审查派生方案
│ ├── 前后端类型对齐-2026-06-12.md │ ├── 构想审查/ # 构想 / UX 改进 / 模块审查 / 论证
│ ├── 对抗论证裁决报告-2026-06-12.md │ ├── 单对话并行多轮-设计-2026-06-20.md
│ ├── 功能决策记录-2026-06-14.md # 需求规格 + 设计决策规格 │ ├── 单对话并行多轮-Phase0落地路线图-2026-06-20.md
│ ├── 功能决策记录-归档-2026-06-14.md │ ├── 上下文根治设计-20261006.md
│ ├── 功能创意池-2026-06-14.md # 5 架构创意(演化/时效/回溯/债务/契约) │ └── 无人值守可靠性专项设计-20261006.md
│ ├── 经验记录-2026-06-14.md
│ ├── 文档记录规范-2026-06-14.md # 含 §八 文档命名规范
│ ├── 规格契约自检机制-2026-06-14.md
│ ├── Agent架构说明-2026-06-14.md
│ ├── B-03-人工审批响应机制-2026-06-14.md
│ ├── aichat审查报告-2026-06-14.md # AI Chat 模块代码审查
│ ├── aichat异步审批构想-2026-06-14.md
│ ├── aichat交互体验改进方案-2026-06-14.md
│ ├── aichat授权体验改进方案-2026-06-14.md
│ ├── aichat信息密度构想-2026-06-14.md
│ ├── 任务推进构想-2026-06-14.md
│ ├── 任务推进链实施路径-2026-06-16.md # 4 阶段实施路径(阶段1决策已定)
│ ├── aichat流式Markdown渲染调研-2026-06-15.md # 流式渲染优化方案(rAF节流/块级diff/换库)
│ ├── patch_file工具设计-2026-06-15.md # patch_file 完整设计(API/三层防御/边界情况)
│ ├── 工作流审批审查报告-2026-06-14.md
│ ├── F-07-df-ai-core-trait下沉设计-2026-06-14.md
│ ├── 密钥迁移健壮性-2026-06-15.md
│ ├── 工作流脚本执行边界-2026-06-15.md
│ ├── 条件表达式引擎-2026-06-15.md
│ ├── generating状态机加固-2026-06-15.md
│ ├── AiNode自审实施方案-2026-06-16.md # AiNode 自审闸门+verdict DAG 阻断
│ ├── B-260616-21排查方案-2026-06-16.md # 工具卡重复渲染排查(audit去重+前端幂等)
│ ├── F-01-模型能力系统与智能路由设计-2026-06-16.md # 模型能力 Phase 3 设计
│ ├── F-02-技能联想使用-实施机制设计-2026-06-16.md # / 联想技能 → AI 调用执行机制
│ ├── F-05-多模态实施方案设计-2026-06-16.md # 多模态 Phase2a/2b/2c 完整方案
│ ├── F-09-多会话并发架构设计-2026-06-19.md # AiSession 多会话并发 B 阶段(当前真相源)
│ ├── F-09B-多会话并发设计-2026-06-16.md # (过时·被 F-09 取代) 多会话 A/B 路线架构设计
│ ├── F-15-上下文管理增强设计-2026-06-16.md # 会话分段/压缩/裁剪设计
│ ├── secret下沉与provider注入方案-2026-06-16.md # secret 纯密钥下沉 df-storage 方案
│ ├── 推进链阶段2实施路径-2026-06-16.md # advance_task 走 df-nodes 实施路径
│ ├── 意图识别层论证-2026-06-19.md # 通用前置意图识别层 8 维度论证
│ ├── 上下文管理演进与发散思考-2026-07-20.md # 上下文管理现状评估、业界对标、7个发散方向与组合分析
│ ├── 上下文管理演进-任务技术设计-2026-07-20.md # T2命名空间/T4工作流DAG/T3结构化摘要技术设计与推演
│ ├── 上下文管理演进-执行计划-2026-07-20.md # Wave 划分、agent 并行策略、接口契约、验证 gate、回滚策略
│ ├── 多主题上下文管理愿景-2026-06-19.md # 多主题多摘要愿景(远期方向,关联 F-15/意图识别)
│ └── 查询效率优化方案-2026-06-19.md # 查询链路优化(SQL 下推/精确拉取/缓存/字段投影)
├── 03-模块文档/ ├── 03-模块文档/
│ ├── df-storage-存储层-2026-06-12.md │ ├── df-storage-存储层-2026-06-12.md
│ ├── df-workflow-工作流引擎-2026-06-12.md │ ├── df-workflow-工作流引擎-2026-06-12.md
@@ -79,7 +45,9 @@ docs/
│ ├── DEVFLOW-1.CRUD层实施-2026-06-12.md │ ├── DEVFLOW-1.CRUD层实施-2026-06-12.md
│ ├── DEVFLOW-2.IPC桥接实施-2026-06-12.md │ ├── DEVFLOW-2.IPC桥接实施-2026-06-12.md
│ ├── DEVFLOW-3.Store对接实施-2026-06-12.md │ ├── DEVFLOW-3.Store对接实施-2026-06-12.md
│ └── DEVFLOW-4.端到端验证-2026-06-12.md │ ├── DEVFLOW-4.端到端验证-2026-06-12.md
│ ├── 父子任务支持设计-2026-08-04.md # 父子任务支持设计
│ └── u-switch接入分析-2026-09-28.md # u-switch 接入分析
├── 05-代码审查/ ├── 05-代码审查/
│ ├── 00-全库代码走查汇总-2026-08-02.md # 全库走查汇总(12 crate, 78 问题, 11 P0 / 30 P1 / 37 P2) │ ├── 00-全库代码走查汇总-2026-08-02.md # 全库走查汇总(12 crate, 78 问题, 11 P0 / 30 P1 / 37 P2)
│ ├── 01-df-nodes-走查-2026-08-02.md # 17 节点走查(20 问题) │ ├── 01-df-nodes-走查-2026-08-02.md # 17 节点走查(20 问题)
@@ -112,15 +80,35 @@ docs/
│ ├── 任务模块问题分析-2026-06-16.md # 任务模块18项核对(真bug 7+增强5+假3+去重3) │ ├── 任务模块问题分析-2026-06-16.md # 任务模块18项核对(真bug 7+增强5+假3+去重3)
│ ├── 任务执行与推进能力分析-2026-06-16.md # 推进能力实现度0%,4阶段路径+决策点 │ ├── 任务执行与推进能力分析-2026-06-16.md # 推进能力实现度0%,4阶段路径+决策点
│ ├── AI聊天组件极端数据场景分析-2026-06-17.md # aichat 交互层 20 项缺陷(HIGH×2/MED×8/LOW×8) │ ├── AI聊天组件极端数据场景分析-2026-06-17.md # aichat 交互层 20 项缺陷(HIGH×2/MED×8/LOW×8)
│ ├── 工作流审批审查报告-2026-06-14.md # (归档)工作流审批链路审查 │ ├── aichat-技术债审查-2026-06-21.md # aichat 技术债审查(10 模块 0 P0 / 7 P1 / 25 P2 / 40 P3)
│ ├── 前后端类型对齐-2026-06-12.md # (归档)Rust↔TS 类型契约核对 │ ├── aichat-会话实测分析-2026-06-22.md # aichat 会话实测分析
│ ├── 对抗论证裁决报告-2026-06-12.md # (归档)df-ideas 对抗评估裁决 │ ├── 全量走查报告-2026-06-28.md # 全量走查报告
│ └── 架构与缺陷复核报告-2026-06-14.md # (归档)复核已修项+回归审计 │ ├── aichat-会话实测分析-2026-07-07.md # aichat 会话实测分析
│ ├── aichat-对话功能走查-2026-07-17.md # aichat 对话功能走查
│ ├── UIUX扩展审查-2026-08-02.md # UI/UX 扩展审查
│ ├── aichat历史会话实证诊断-2026-08-04.md # aichat 历史会话实证诊断
│ ├── AI工具失败画像复盘-2026-08-08.md # AI 工具失败画像复盘
│ ├── aichat效率走查-2026-08-09.md # aichat 效率走查
│ ├── df-mcp+工具crate走查-2026-08-09.md # df-mcp + 工具 crate 走查
│ ├── settings+审计+布局后端走查-2026-08-09.md # settings/审计/布局后端走查
│ ├── 任务+仪表盘+灵感知识走查-2026-08-09.md # 任务/仪表盘/灵感知识走查
│ ├── 项目详情页走查-2026-08-09.md # 项目详情页走查
│ ├── aichat交互走查-2026-09-28.md # aichat 交互走查
│ ├── aichat可用性与交互成本研究-2026-09-29.md # aichat 可用性与交互成本研究
│ ├── aichat知识库与记忆走查-2026-09-29.md # aichat 知识库与记忆走查
│ ├── 整体UIUX三维走查-2026-09-29.md # 整体 UI/UX 三维走查
│ ├── aichat三路走查报告-2026-10-02.md # aichat 三路走查报告
│ ├── UX走查-Web规范合规-2026-10-05.md # UX 走查 Web 规范合规
│ └── 全系统模块走查索引.md # 全系统模块走查索引
├── 06-前端开发/ ├── 06-前端开发/
│ └── View改造指南-2026-06-12.md │ ├── View改造指南-2026-06-12.md
│ └── 前端架构技术债-2026-06-19.md # 前端架构技术债盘点
├── 07-项目管理/ ├── 07-项目管理/
│ ├── PROGRESS.md # 项目进展与交接记录
│ ├── Batch.md # 批次执行记录
│ ├── Phase1任务清单-2026-06-12.md │ ├── Phase1任务清单-2026-06-12.md
│ └── Phase2计划-2026-06-12.md │ ├── Phase2计划-2026-06-12.md
│ └── 用户主张思想总纲-2026-10.md # 跨会话用户主张蒸馏(注入用)
├── 08-用户指南/ ├── 08-用户指南/
│ ├── 使用手册-2026-06-12.md # 用户手册(对齐 06-15 代码基线) │ ├── 使用手册-2026-06-12.md # 用户手册(对齐 06-15 代码基线)
│ ├── AI文件操作工具手册.md # AI 工具 API 全量参考(10 工具,2026-06-16 更新) │ ├── AI文件操作工具手册.md # AI 工具 API 全量参考(10 工具,2026-06-16 更新)
@@ -138,7 +126,7 @@ docs/
| 🚀 快速开始 | [README.md](./README.md) | 安装、启动、快速上手 | | 🚀 快速开始 | [README.md](./README.md) | 安装、启动、快速上手 |
| 📖 使用手册 | [使用手册-2026-06-12.md](./08-用户指南/使用手册-2026-06-12.md) | 用户手册 | | 📖 使用手册 | [使用手册-2026-06-12.md](./08-用户指南/使用手册-2026-06-12.md) | 用户手册 |
| 🔧 AI 工具手册 | [AI文件操作工具手册.md](./08-用户指南/AI文件操作工具手册.md) | 10 个文件操作工具 API 参考 | | 🔧 AI 工具手册 | [AI文件操作工具手册.md](./08-用户指南/AI文件操作工具手册.md) | 10 个文件操作工具 API 参考 |
| 📋 项目进度 | [PROGRESS.md](../PROGRESS.md) | 实时项目进展与状态 | | 📋 项目进度 | [PROGRESS.md](./07-项目管理/PROGRESS.md) | 实时项目进展与状态 |
| 📝 工作看板 | [todo.md](./todo.md) | 任务/bug 路踪(P0~P3 + aichat 交互专区) | | 📝 工作看板 | [todo.md](./todo.md) | 任务/bug 路踪(P0~P3 + aichat 交互专区) |
| 🔍 审查队列 | [待审查.md](./待审查.md) | 代码审查登记+回填(67 条已审) | | 🔍 审查队列 | [待审查.md](./待审查.md) | 代码审查登记+回填(67 条已审) |
| ⚖️ 决策项 | [待决策.md](./待决策.md) | 需人工决策的产品/设计/排期 | | ⚖️ 决策项 | [待决策.md](./待决策.md) | 需人工决策的产品/设计/排期 |
@@ -174,9 +162,9 @@ docs/
| 文档 | 路径 | 说明 | | 文档 | 路径 | 说明 |
|------|------|------| |------|------|------|
| 架构设计 | `../ARCHITECTURE.md` | 完整架构文档 | | 架构设计 | `../ARCHITECTURE.md` | 完整架构文档 |
| 项目进展 | `../PROGRESS.md` | 工作进展与交接 | | 项目进展 | `./07-项目管理/PROGRESS.md` | 工作进展与交接 |
| 工作看板 | `./todo.md` | 任务/bug 跟踪(含决策记录) | | 工作看板 | `./todo.md` | 任务/bug 跟踪(含决策记录) |
| Crate 结构 | `../ARCHITECTURE.md#四crate-结构` | 8 个 Crate 概览 | | Crate 结构 | `../ARCHITECTURE.md#四crate-结构` | 13 个 Crate 概览 |
| 数据模型 | `../ARCHITECTURE.md#六数据模型` | SQLite 表结构定义 | | 数据模型 | `../ARCHITECTURE.md#六数据模型` | SQLite 表结构定义 |
| Phase 规划 | `../ARCHITECTURE.md#八phase-规划` | 5 个 Phase 路线图 | | Phase 规划 | `../ARCHITECTURE.md#八phase-规划` | 5 个 Phase 路线图 |
@@ -188,7 +176,7 @@ docs/
|----|------|------| |----|------|------|
| Desktop | Tauri v2 | Rust 后端 + WebView 前端 | | Desktop | Tauri v2 | Rust 后端 + WebView 前端 |
| Frontend | Vue 3 + TypeScript + Pinia | Arco Design 组件库 | | Frontend | Vue 3 + TypeScript + Pinia | Arco Design 组件库 |
| Engine | Rust Workspace (8 crate) | 多 crate 架构 | | Engine | Rust Workspace (13 crate) | 多 crate 架构 |
| Storage | SQLite (rusqlite) | 本地优先,零运维 | | Storage | SQLite (rusqlite) | 本地优先,零运维 |
| AI | Multi-Provider | Claude/GLM/DeepSeek/OpenAI 兼容 | | AI | Multi-Provider | Claude/GLM/DeepSeek/OpenAI 兼容 |
| Build | npm + Vite | 前端构建(不要使用 bun) | | Build | bun + Vite | 前端构建(`tauri.conf.json` 的 beforeDev/BuildCommand 即 `bun dev` / `bun run build`;单一 lockfile=bun.lock) |
+1 -1
View File
@@ -9,7 +9,7 @@
- [DevFlow 使用手册](./08-用户指南/使用手册-2026-06-12.md) - 完整的使用指南,包含功能介绍、操作流程和最佳实践 - [DevFlow 使用手册](./08-用户指南/使用手册-2026-06-12.md) - 完整的使用指南,包含功能介绍、操作流程和最佳实践
### 📋 项目进度 ### 📋 项目进度
- [PROGRESS.md](../PROGRESS.md) - 项目开发进展与交接记录 - [PROGRESS.md](./07-项目管理/PROGRESS.md) - 项目开发进展与交接记录
## 🎯 核心理念 ## 🎯 核心理念
+123 -300
View File
@@ -14,6 +14,12 @@
> **2026-06-18 归档**: 已完成 `[x]` 与历史分析段已迁至 [07-项目管理/todo归档/2026-06-18.md](./07-项目管理/todo归档/2026-06-18.md)。 > **2026-06-18 归档**: 已完成 `[x]` 与历史分析段已迁至 [07-项目管理/todo归档/2026-06-18.md](./07-项目管理/todo归档/2026-06-18.md)。
> **2026-06-27 归档**: 已完成 `[x]`/`✅` 项已迁至 [07-项目管理/todo归档/2026-06-27.md](./07-项目管理/todo归档/2026-06-27.md)。 > **2026-06-27 归档**: 已完成 `[x]`/`✅` 项已迁至 [07-项目管理/todo归档/2026-06-27.md](./07-项目管理/todo归档/2026-06-27.md)。
> **2026-10-05 归档**: 7 个实测核验全完成的历史走查段(父子任务支持/对话上下文透明化/文件拆分升级/ToolCard UI 合并/灵感模块诊断/aichat 深度走查/TaskDetail 按钮走查)迁至 [07-项目管理/todo归档/2026-10-05.md](./07-项目管理/todo归档/2026-10-05.md),销账批注与滞后勘误见该文件。 > **2026-10-05 归档**: 7 个实测核验全完成的历史走查段(父子任务支持/对话上下文透明化/文件拆分升级/ToolCard UI 合并/灵感模块诊断/aichat 深度走查/TaskDetail 按钮走查)迁至 [07-项目管理/todo归档/2026-10-05.md](./07-项目管理/todo归档/2026-10-05.md),销账批注与滞后勘误见该文件。
> **2026-10-10 归档**: 全量完成态条目/段落(编号 [x] 32 条 + 勾选 [x] + 标题带 ✅ 且段内零未完成项的历史段,含 907/908 与飞书交互面全量盘查批)迁至 [07-项目管理/todo归档/2026-10-10.md](./07-项目管理/todo归档/2026-10-10.md)。迁移为机械行搬运(零措辞改动),核对:未完成项 110 条零丢失、完成项 37 条全入归档、行数守恒。
> **2026-10-10 只读核验销账(源码取证,非文档自述)**: 挂机期派 4 路只读核查子代理逐项 grep/read 源码核验历史待办,结论如下(证据见核验记录/归档)。
> - **2026-06-21 技术债审查段**: MED-1(bind_directory 白名单刷新)/REFACTOR-260619-08(tool_registry 拆分组注册)/REFACTOR-260619-09(context.rs 已拆 context/ 子模块)/UX-260617-28(双监听器·已决策现状够用) **均已落地可销账**;SMELL-P1-6(executed/completed 双轨已修,newtype 父项待推进)/ ARC-260619-05(run_agentic_loop 仍约 1162 行未拆) **保持未完成**。
> - **其余**: WC-技能注入-trace定位(4b445e9 已根治)/ miniapp P3-B appid(已真实值)/ u-claw 集成代码面(ask_uclaw+UclawPanel 齐备,剩真机冒烟) **已落地**。
> - **2026-08-09 三域走查 P1**: 27 项核验 **25 done / 2 partial**(partial=PD-P1-3 事件协议无项目维度已降级标注、PRJ-1 normalize_path 小写化)。
> - 已核销的四个走查段整段迁归档:`2026-08-01 走查剩余` / `2026-06-22 miniapp 走查` / `2026-08-09 任务·仪表盘·灵感知识走查` / `2026-08-09 Settings·审计·布局·后端命令走查`;**含活口子的段保留主板**(项目详情页走查=PD-P1-3 / df-mcp 走查=PRJ-1 / 技术债审查=SMELL-P1-6·ARC-260619-05 / u-claw=剩真机冒烟)。
> >
> **2026-08-05 编号碰撞登记**: `F-260619-03` 被两个特性复用(消息拆分存储=文档本体 + AI工具路径授权=复用)。子代理审查确认:改 18 处编号无实际价值有风险,**暂不改代码**,后续新增注释编号统一走日期+序号体系、避免复用已有编号。路径授权若后续单独立项再独立编号。 > **2026-08-05 编号碰撞登记**: `F-260619-03` 被两个特性复用(消息拆分存储=文档本体 + AI工具路径授权=复用)。子代理审查确认:改 18 处编号无实际价值有风险,**暂不改代码**,后续新增注释编号统一走日期+序号体系、避免复用已有编号。路径授权若后续单独立项再独立编号。
> **2026-08-15 编号碰撞登记**: `REFACTOR-260619-04` 被复用(父③③.2 审批状态机拆分=待办 vs ToolCard 拆分=已完成)。后续引用审批状态机拆分时用「审批状态机拆分」描述,不再沿用该编号。 > **2026-08-15 编号碰撞登记**: `REFACTOR-260619-04` 被复用(父③③.2 审批状态机拆分=待办 vs ToolCard 拆分=已完成)。后续引用审批状态机拆分时用「审批状态机拆分」描述,不再沿用该编号。
@@ -23,8 +29,7 @@
> 背景:src 43 个 .vue 文件 UI 规范缺口全量合规走查(Vercel Web Interface Guidelines 适用子集)+ 审批/aichat/项目知识审计三域子代理分组审。质量中上无阻断,缺口集中 4 类系统性模式,其中 2 类可全局样式一刀切收敛。 > 背景:src 43 个 .vue 文件 UI 规范缺口全量合规走查(Vercel Web Interface Guidelines 适用子集)+ 审批/aichat/项目知识审计三域子代理分组审。质量中上无阻断,缺口集中 4 类系统性模式,其中 2 类可全局样式一刀切收敛。
> 详情:[05-代码审查/UX走查-Web规范合规-2026-10-05.md](./05-代码审查/UX走查-Web规范合规-2026-10-05.md) > 详情:[05-代码审查/UX走查-Web规范合规-2026-10-05.md](./05-代码审查/UX走查-Web规范合规-2026-10-05.md)
- [ ] P2-2 高频可点 div 键盘化:ToolCard:11 卡头/MessageList:1423 轨迹头/AuditLog:67,119 审计行(@click div 全仓 56 处,去 mask 合规项约 20 真缺口,高频改 button 其余 tabindex+keydown) - [~] P3-2 icon-only 按钮缺 aria-label ~15 处(title 非等价读屏补偿;EnrichmentPanel 关闭钮/ProjectDetail:185,189 编辑删除钮等)+剩余 div 键盘化 —— ✅ **2026-10-10 两批共 23 处**:①批次一 3 处(EnrichmentPanel 关闭 / ProjectDetail 模块编辑·删除);②批次二 20 处(TopBar 10 icon-only 按钮 aria-label / ConversationSidebar 4 aria-label + 2 分组标题键盘化 / MessageList 2 icon-only / KnowledgeDetail 3 章节折叠键盘化 / dashboard 两列表项键盘化 / FileExplorer 面包屑·模块下拉项键盘化 / ConflictResolver 冲突文件折叠键盘化)。全部复用已有 `:title`/i18n key,**零新增键**;遮罩类(@click.self)按约定跳过。验收:`bunx vue-tsc --noEmit` EXIT 0。**剩余**少数(会话项 space 键、dblclick 重命名入口等)+「存量 vs 仅本批」拍板点仍在。
- [ ] P3-2 icon-only 按钮缺 aria-label ~15 处(title 非等价读屏补偿;EnrichmentPanel 关闭钮/ProjectDetail:185,189 编辑删除钮等)+剩余 div 键盘化
- 拍板点:修复范围「存量 vs 仅本批」——transition:all/outline:none 大半为存量;本批新代码 a11y 意识已好(svg aria-hidden 批补/MessageList role=log+aria-live/hover-only focus-within 补偿),仅清本批则工作量降 60%+ - 拍板点:修复范围「存量 vs 仅本批」——transition:all/outline:none 大半为存量;本批新代码 a11y 意识已好(svg aria-hidden 批补/MessageList role=log+aria-live/hover-only focus-within 补偿),仅清本批则工作量降 60%+
### 💡 2026-09-29 aichat 可用性与交互成本研究(三动线走查+异常循环截图实证,39 项新发现) ### 💡 2026-09-29 aichat 可用性与交互成本研究(三动线走查+异常循环截图实证,39 项新发现)
@@ -64,85 +69,6 @@
> 审批留在对话内(需要即时上下文),知识/灵感/通知放 Inbox。 > 审批留在对话内(需要即时上下文),知识/灵感/通知放 Inbox。
> 后续做详细设计方案。 > 后续做详细设计方案。
## 🎯 统一规划(2026-06-26 全景)
> 整合知识图谱设计 + 22 条未完成项 + G1 目标钉扎(✅已落地) + 多工程方案。
> **核心主线:AI Working** — AI 拥有项目知识图谱,自主分解/编排/执行/自检,人只决策。
> **执行策略(两阶段)**:① 先攒批无冲突小 bug/小调整 → 提交;② workflow 编排相关任务批量推进大重构(减少文件读取/LLM 交互,提高上下文效率)。
### 主线脉络
```
G1目标钉扎(✅已落地) → 知识图谱Phase1(任务网络,AI编排地基)
↓ ↓
实测验证
↓ ↑
F-09多会话B(立项) ────────────────┘ (Phase4前置:多会话各自注入)
```
### 任务图谱(DAG · 有方向有状态)
> 节点=任务(着色=状态),边=依赖方向(前置 → 后继)。**推进时更新状态**(CLI/edit 同步此表 + Mermaid)。
> 图例:✅完成 / 🔨进行中 / 📋待办 / 🚫阻塞
```mermaid
graph TD
classDef done fill:#bfb,stroke:#393,stroke-width:2px
classDef doing fill:#ffd,stroke:#993,stroke-width:2px
classDef todo fill:#eef,stroke:#99c
classDef blocked fill:#fcc,stroke:#c33
G1["G1 目标钉扎<br/>✅代码 8ce18cb"]:::done
P1["父① 小bug攒批<br/>✅①.2/①.3(①.1过时·①.4归⑤)"]:::done
P2["父② 知识图谱Phase1<br/>✅Phase1(数据层+业务层)"]:::done
P3["父③ AI对话体验<br/>📋待办"]:::todo
P4["父④ F-09 per-conv<br/>✅前端per-conv(accessor)"]:::done
P5["父⑤ 灵感模块<br/>✅完成(⑤.1/①.4/⑤.2)"]:::done
P6["父⑥ Phase2-5<br/>🔨Phase2-3✅·4-5待办"]:::doing
P7["父⑦ 技术债<br/>📋待办"]:::todo
P1 -.先行.-> P2
G1 ==基建==> P6
P2 ==> P6
P4 ==> P6
P2 -.task_links.-> P5
```
| 任务 | 状态 | 子项 | 依赖(前置) |
|---|---|---|---|
| **G1** 目标钉扎 | ✅ 代码已落地 | G1/G2/G4 落地 | — |
| **父①** 小bug攒批 | ✅ 完成 | ①.2 白名单✅(settings.rs) / ①.3 priority✅(idea.rs) / ①.1 BUG层1❌过时(F-260619-03 方案①取代,层2待决策) / ①.4 雷达图→归父⑤ | — |
| **父②** 知识图谱Phase1 | ✅ Phase1完成 | ②.1 V29迁移✅ / ②.2 TaskRecord+TaskLinkRepo✅ / ②.3 IPC(create_task扩展+task_link CRUD+move_queue+get_tree)✅ / ②.4 父聚合✅(set_status_for_aggregation绕status收口) / ②.5 AI工具6✅(基线38) | G1(弱) |
| **父③** AI对话体验 | 🔨 ③.1✅ / ③.2待办 | ③.1 B-260619-04 ToolCard✅(跨轮收起已有+标题增强) / ③.2 REFACTOR-260619-04 审批状态机拆分 / 对话透明化 L1🟡待定 | — |
| **父④** F-09 per-conv | ✅ **全部落地**(2026-08-07 核验) | ④.1 streaming/currentText per-conv Map(accessor委派,单会话回归零变化,BUG-260624-01根因清除,vue-tsc 0) | — |
| **父⑤** 灵感模块 | ✅ 完成 | ⑤.1 软删除✅ / ①.4 雷达图✅ / ⑤.2 #05✅/#06拆const✅/#09/#10表单(逗号tags)✅ / #07 DEC-02保留purge(不改) / 附:priority_from_i32跨层映射修复(对齐前端0=critical) | #07→②.1 |
| **父⑥** Phase2-5 | 🔨 Phase4 注入进行中 | ⑥.1事件流✅(V30) / ⑥.2基础设施✅(V31) / ⑥.3注入✅(resolver 增强+extra 渲染) / ⑥.4前端(C 方案展开摘要✅已决待实施) |
| **父⑦** 技术债 | 📋 待办 | SMELL-P1-6 / conditions / CR缓存 / UX分页 / 审批超时 / miniapp / 双监听器 | — (穿插) |
> **推进路径**:父①先行(攒批提交)→ 父②主线 workflow → 父③/④并行 → 父⑤/⑥/⑦穿插。每父任务一个 workflow 批,子任务相关文件批量读改减少交互。**状态更新约定**:子项完成→父状态 🔨;全子完成→父 ✅;每批提交后同步此表。
### 协同点(跨主题复用)
1. **IDEA-FIX-07 关联单向 ↔ 知识图谱 `task_links`**:并入 Phase 1 用独立表(§2.2 论证 JSON 缺陷),别补 JSON 双向同步
2. **G1 system_prompt 注入基建 ↔ Phase 4 L0 注入**:复用 `pinned_goal` 拼接链路
3. **F-09 per-conv ↔ Phase 4 多会话注入**:F-09 前置
4. **REFACTOR-260619-04 审批状态机 ↔ `queue=decision` 待决策池**:审批对齐
## 交接状态(2026-06-14)
**代码健康度**:`cargo test --workspace` 全过、`npx vue-tsc --noEmit` 0 error(主代理独立验证,非 mission 自报)。
**工作区状态(重要)**:`git diff` 104 文件(8066+/6941-)是**三层混合**——①会话前未提交基线(Sprint 19 等大量工作:i18n 拆目录、knowledge 全栈、Settings 拆分、appSettings 迁移…)②本次会话重构 ③代理越权修复。**接手前务必 `git diff` 通览区分**,勿整体当作单一改动提交。
**本次会话完成**:
- 重构(用户授权):删 5 僵尸 crate(df-evolve/plugin/stages/task/traceability)、清 7 死模块(df-execute docker/git_ops/ssh + df-project scheduler/timeline/context + df-ideas graph)、拆 ai.rs→`commands/ai/` 11 文件、拆 ai.ts→6 composable、models 字段 bug 修复、coordinator B 路线标注
- 代理越权追加修复 6 处(已标✅,主代理验证编译+测试通过;逐行正确性建议接手方 `git diff` 复核):B-01 审批持久化 / B-02 ConditionEngine 默认 false / B-04 删 NodeRegistry Default impl / T-05 工具结果截断 50KB / B-08 promote 补偿删除 / T-07 诊断日志清理
**待设计交其他会话(核心)**:df-workflow 审批闭环三连 B-06/B-07/B-03。**✅ B-03 设计已完成**(接手会话,2026-06-14):见 [B-03-人工审批响应机制-2026-06-14.md](./02-架构设计/已编号方案/B-03-人工审批响应机制-2026-06-14.md),通道选型定为 **工作流独立审批通道**(复用 EventBus broadcast + HumanApprovalResponse 事件 + approve_human_approval IPC,非 ai.rs AiApprovalRequired——后者是 AI Chat 工具审批路径,与工作流节点审批是两条独立链路)。拆 B-03a(响应等待 + 超时,不依赖 B-07)/ B-03b(取消机制)。**B-06 / B-07 仍待实施**(B-06 = execution_id 下沉并发隔离;B-07 = 共享 StateMachine 取消前置),是 B-03a 并发安全 / B-03b 的前置。
**失控代理教训**:本次会话派的后台拆分代理在 stop hook 循环里失控,越权改代码/文档(先斩后奏)。接手方若再派 agent,注意约束其不碰决策记录(用户已要求手动触发)+ 限定单任务不自主续推。
---
## 待办 ## 待办
### 🔍 2026-06-21 AI Chat 技术债审查(workflow wf_92b61c35-4d2 · 10 模块串行 grep 核验) ### 🔍 2026-06-21 AI Chat 技术债审查(workflow wf_92b61c35-4d2 · 10 模块串行 grep 核验)
@@ -153,7 +79,7 @@ graph TD
**销账核对(已登记 8 复核)**:SMELL-P1-6(line221·#5 覆盖 executed/completed 双轨)/ MED-1(tool_registry:546 bind_directory)/ REFACTOR-260619-08(tool_registry:881 拆)/ REFACTOR-260619-09(context.rs:1 God 1552行)/ ARC-260619-05(agentic:318 超长)/ UX-260617-28(双监听器 INFO)。**均仍在未完成,审查复核确认,保留**。 **销账核对(已登记 8 复核)**:SMELL-P1-6(line221·#5 覆盖 executed/completed 双轨)/ MED-1(tool_registry:546 bind_directory)/ REFACTOR-260619-08(tool_registry:881 拆)/ REFACTOR-260619-09(context.rs:1 God 1552行)/ ARC-260619-05(agentic:318 超长)/ UX-260617-28(双监听器 INFO)。**均仍在未完成,审查复核确认,保留**。
**残留诊断代码(待清)**:chat.rs `authz_debug`/`authz-debug.log`(L2 调试遗留,P2 报 chat.rs:548 硬编码绝对路径),L2 完成后可清。 **残留诊断代码(待清)**:chat.rs `authz_debug`/`authz-debug.log`(L2 调试遗留,P2 报 chat.rs:548 硬编码绝对路径),L2 完成后可清。✅ **2026-10-10 核验销账**:`grep -rn "authz_debug|authz-debug.log" src-tauri/src/` 零命中,残留已清。
--- ---
@@ -163,46 +89,6 @@ graph TD
--- ---
### 📋 编排推进总览(2026-06-18 更新)
未完成待办按可执行性分 8 组(详细条目见下方各分类,勿重复记录):
| 组 | 说明 | 代表项 |
|---|---|---|
| **A todo 卫生** | 矛盾清理/被取代退役标注 | ✅ 本批已完成 |
| **B 零风险减法** | 死链清/空壳合并 | ✅ 本批已完成 |
| **C 需用户输入** | 阻塞,无法代办 | —(已归档)
| **D 功能增强** | 设计清晰可推进(P1) | F-15-01 审批选项 / F-15-02 task 详情 / F-15-03 分页⚠️breaking / F-15-04 卡片折叠 |
| **E 架构重投入** | 需设计,非小改 | F-14-01 模型能力 / F-14-07 trait 下沉 / ARC-05·06 store 拆·循环依赖 |
| **F 全局 review P2 需设计** | 安全/竞态收口 | R-PD-4/5/6/8/9/12/13 + R-PD-10 .map_err 85 处 |
| **H 长期功能池** | 不进主线 | F-14-02~10 / T-14-06 Settings 拆 / T-14-11 条件引擎 / B-14-05 / B-03b-R9 |
**推进原则**:能并行不串行(独立子任务 8-12 并发);每批重汇总+全优先级重排+销账核对 ✅;C 组阻塞项不代办等用户。**本会话首批**:A 卫生 + B 清死链。
**③④C类已迁**(2026-06-16):需人工决策项(③产品取舍 7 / ④设计方向 7 / 重投入排期 9 / C用户实测 5)见 [待决策.md](./待决策.md),从本文件抽离职责分离。**batch54 可自主批进展(2026-06-16·workflow wsfz7zl8i + 主代核查)**:复核-新⑪ 看门狗幽灵 ✅ 销账(清主窗口 state 视觉残留;**核验更正**:原 watchdog 幽灵已被 CR-260615-24 stopListener:371 clearStreamWatchdog 兜底);AE-03 diff预览 ⚠️ 降级阻塞(后端 write_file 未返 diff,待后端补,见 line 538);UX-04 断线保文 待 DEC-260616-03 授权(见待决策.md 🟡,本批不含)。
> **📦 已完成项归档**: [07-项目管理/todo归档/2026-06.md](./07-项目管理/todo归档/2026-06.md) — 2026-06-18 拆分, 已完成 `[x]` 与历史分析段迁此。另有 [2026-06-18.md](./07-项目管理/todo归档/2026-06-18.md) / [2026-06-27.md](./07-项目管理/todo归档/2026-06-27.md) — 本次归档。
### 🔴 2026-06-20 BUG-260620-05 F-260619-03 工程内路径误弹窗(reload_allowed_dirs 丢 workspace_root·b22e9ae 回归)
**现象**:AI Chat file_info 等文件工具访问工程内路径(如 docs/02-架构设计/单对话并行多轮-设计-2026-06-20.md)触发授权弹窗,用户称"本来就有访问权限,为什么还来申请"。
**根因**:`state.rs:586-619 reload_allowed_dirs` 合并 `kv_dirs(KV allowed_dirs)+ project_dirs(projects.bind_directory)` 为 `all_dirs`,**仅当 `all_dirs.is_empty()` 时(:618)才插入 workspace_root**。KV 配过 allowed_dirs 或有绑定项目 → all_dirs 非空 → **workspace_root 不入 persistent** → 工程内路径(在 workspace_root 下)`is_authorized`(:356 starts_with)失败 → `check_path_authorization` 返 `NeedsAuthorization` → 弹窗。
**注释承诺失配**::346-348 / :583 / :617 注释均称"KV 未配时保持 workspace_root 免授权(向后兼容)",但 :618 条件用 `all_dirs`(含 project_dirs),project_dirs 非空也丢 root。b22e9ae "reload 尊重用户 persistent" 引入的回归。
**用户政策指令**(2026-06-20):"默认直接访问,明确知道无权限才申请"。指向工程内(workspace_root 下)默认免授权,仅 workspace_root 外非白名单路径才弹窗。
**修法(层1·1 行确定性 bug)**:`state.rs:618` 去掉 `if all_dirs.is_empty()` 条件包裹,**无条件** `set.insert(workspace_root_path())`(工程内始终免授权,对齐用户政策 + 注释承诺)。不破坏现有能力:workspace_root 经 `set/get_allowed_dirs`(:664/680 filter root)本就不可见不可删,"用户删 root"语义未落地,保 root 无冲突。
**层2 待决策(政策范围)**:用户"默认直接访问"是否要求更宽语义——workspace_root 外的非黑名单路径也默认放行(翻转 `is_authorized` 为黑名单制,实质去白名单)?涉 P0 去固定根决策方向,见待决策.md。dev 自用场景层1 即满足。
**关联**:F-260619-03(todo :528 b22e9ae 回归源)/ 待决策 workspace_root 分发适配(方案b 用户项目绑定)/ memory [[devflow-project-path-binding]]
**状态**:✅ 已实施(层1 无条件插入 workspace_root + 层2 白名单制 persistent/session/once + 黑名单兼底,对齐 2026-06-28 用户决策)
---
### 💡 2026-06-16 新需求(已分析·待实施) ### 💡 2026-06-16 新需求(已分析·待实施)
> 功能需求,已走查定位链路 + 方案记录,待用户确认后实施。 > 功能需求,已走查定位链路 + 方案记录,待用户确认后实施。
@@ -211,11 +97,6 @@ graph TD
> **✅ 2026-08-07 核验销账(F-09 B 阶段全部落地)**:三方独立核验(2 agent + 主代 grep 抽查)确认 06-19 草案所有改造点(§4.1-4.7)已落地,**且代码超出草案**:① AiSession 去单例 → `per_conv: HashMap<String, PerConvState>`(mod.rs:593,顶层会话级字段全删)② ConvStateStore DashMap 无锁(治 guard.reset 锁竞争卡死连环)③ 三处退出校验改 conv 存在性(agentic/mod.rs 7 处,零旧 `active_conversation_id != conv_id` 残留)④ GeneratingGuard per-conv + loop_epoch owner token(F1 治同 conv 双 loop 竞态)⑤ last_heartbeat 心跳(F2 治卡死兜底误判)⑥ IPC 全加 conv_id + ConvStateStore 零锁读(switch 删 readonly / create 不杀旧 loop)⑦ 前端 convStates Map(enum 轨替代 bool Set)+ d2 多窗口(useAiWindow.ts `ai-detached-${convId}`)已落地。**剩余仅 B 路线 Phase3 单对话并行多轮**(`per_sub_flow` 占位未接,另线 [[aichat-b-route-parallel-multiround]])。c-1 决策已被用户「不设并发会话上限」推翻作废。 > **✅ 2026-08-07 核验销账(F-09 B 阶段全部落地)**:三方独立核验(2 agent + 主代 grep 抽查)确认 06-19 草案所有改造点(§4.1-4.7)已落地,**且代码超出草案**:① AiSession 去单例 → `per_conv: HashMap<String, PerConvState>`(mod.rs:593,顶层会话级字段全删)② ConvStateStore DashMap 无锁(治 guard.reset 锁竞争卡死连环)③ 三处退出校验改 conv 存在性(agentic/mod.rs 7 处,零旧 `active_conversation_id != conv_id` 残留)④ GeneratingGuard per-conv + loop_epoch owner token(F1 治同 conv 双 loop 竞态)⑤ last_heartbeat 心跳(F2 治卡死兜底误判)⑥ IPC 全加 conv_id + ConvStateStore 零锁读(switch 删 readonly / create 不杀旧 loop)⑦ 前端 convStates Map(enum 轨替代 bool Set)+ d2 多窗口(useAiWindow.ts `ai-detached-${convId}`)已落地。**剩余仅 B 路线 Phase3 单对话并行多轮**(`per_sub_flow` 占位未接,另线 [[aichat-b-route-parallel-multiround]])。c-1 决策已被用户「不设并发会话上限」推翻作废。
### 🔴 aichat API Key 401 排查(2026-06-16)
> 用户报对话失败「调用失败: API Key 无效或无权限」+ 重设 key 无效。**排查结论:代码链路全对(保存 keyring✓ / 读取 resolve_provider_secret✓ / 鉴权头 openai=Bearer·anthropic=x-api-key✓ / URL 智能拼接✓),401 来自服务端,非 devflow bug**。根因四选一(key 无效 / provider_type 不匹配 / base_url 错 / model 名错)。详见 [aichat-apikey-401排查-2026-06-15.md](./09-问题排查/aichat-apikey-401排查-2026-06-15.md)。
### 🟦 架构审查待修项(2026-06-16) ### 🟦 架构审查待修项(2026-06-16)
> 纯架构层评估(边界/依赖/抽象/扩展性/状态管理),详见 [架构审查-2026-06-15.md](./05-代码审查/架构审查-2026-06-15.md)。共 🔴6 🟡6 ⚪4 + 亮点 6。与 06-14 三份报告去重(不重复 bug/性能 FR-*、aichat AR-*)。 > 纯架构层评估(边界/依赖/抽象/扩展性/状态管理),详见 [架构审查-2026-06-15.md](./05-代码审查/架构审查-2026-06-15.md)。共 🔴6 🟡6 ⚪4 + 亮点 6。与 06-14 三份报告去重(不重复 bug/性能 FR-*、aichat AR-*)。
@@ -223,21 +104,8 @@ graph TD
**中期(技术债)** **中期(技术债)**
✅ ARC-260615-07 已决策:缓做(d归F-09B,其余ROI低不实施) ✅ ARC-260615-07 已决策:缓做(d归F-09B,其余ROI低不实施)
### P1 — 重要缺陷
### 🔴 架构坏味道全面扫描(2026-06-18·6路并行 Explore agent·57K行代码)
> 来源: God文件/长函数 + 循环依赖/耦合 + DRY违反 + 错误处理不一致 + 命名API/类型安全 + 测试覆盖/死代码。6 维度全覆盖。
**🟡 P1 应该改进 (9项)**:
✅ **SMELL-P1-6** — **[P1🟢 已决·待推进]** String 类型滥用替 newtype/enum —— execution_id/status/tool_type 等 5+ 处改强类型(b 路线:仅 Rust 内部 newtype)
**🆕 sweep 派生登记(2026-06-18·workflow w2xkw4ybh data 抽出 + wo7d6zdqm 多维度 sweep)**:
- ✅ **conditions 条件引擎** — ✅ 全部完成:引擎完备+feature flag 默认开+executor 集成+前端 UI(WorkflowDagDisplay.vue 可编辑)+i18n 补齐
### 🟡 AI Chat 交互体验改进(2026-06-14 方案 → 待办化) ### 🟡 AI Chat 交互体验改进(2026-06-14 方案 → 待办化)
> 来源:[aichat交互体验改进方案-2026-06-14.md](./02-架构设计/构想审查/aichat交互体验改进方案-2026-06-14.md)。7 大类 20 个改进项,按原文优先级分 4 批。**去重**:§1.3 复制 ⊂ F-260615-13/14(已写)、§3.4 新建中断 ⊂ B-260615-10(已做软复位,本文档加 confirm 增强)、§6.2 分离窗口同步 ⊂ CR-24/25(已写)。**状态: 待讨论→看板跟踪**。 > 来源:[aichat交互体验改进方案-2026-06-14.md](./02-架构设计/构想审查/aichat交互体验改进方案-2026-06-14.md)。7 大类 20 个改进项,按原文优先级分 4 批。**去重**:§1.3 复制 ⊂ F-260615-13/14(已写)、§3.4 新建中断 ⊂ B-260615-10(已做软复位,本文档加 confirm 增强)、§6.2 分离窗口同步 ⊂ CR-24/25(已写)。**状态: 待讨论→看板跟踪**。
@@ -278,52 +146,6 @@ graph TD
**Phase3 联调待办**(D1-D6 决策点推荐:A纯透传 / 全19变体透传 / EventBus汇聚 / switch不处理 / 强类型保留 / 桥接层R1兜底): **Phase3 联调待办**(D1-D6 决策点推荐:A纯透传 / 全19变体透传 / EventBus汇聚 / switch不处理 / 强类型保留 / 桥接层R1兜底):
### 🔍 2026-06-22 miniapp 功能走查(workflow wf_ddd49ac8 · 5 域并行核验 + 主代终态抽查)
> 用户 /review miniapp + workflow 汇总安排。5 agent 独立 grep/read 核验源码(不信注释/文档) + 主代终态核验(ws.ts 真路径 `src/api/ws.ts`、`relay.rs:255/355`、remote_bridge 命令数 10 非 11)。**18 findings → 去重 P1×5 / P2×3 / P3×1**,2 项 refuted。关联 F-260620-01 阶段4(真机联调前置)。**守 session-role-diagnose-only:本会话仅走查 + 登记,未实施代码。**
**P1(阻塞真机/可靠性·5 项)**:
**用户补充核验(2026-06-22 用户详读源码+设计文档,主代逐项核源码判真伪)**:
> ⚠️ 用户分析 P0 三项(协议三重不匹配 / EventBus 未接 / 桥接层占位)**经源码核验全部失实**,系基于 Phase3 阶段1-3 落地前的设计文档「计划态」描述,未核当前代码。证据:
> - **P0-1 协议**:`crates/df-tunnel/src/tunnel.rs:457/327/424` tunnel 纯透传 payload `Value` 上抛 on_command,**不**做 `from_value::<TunnelCommand>` 强类型反序列化(方案A 阶段1 已落地 commit 0fb5af5)→ miniapp `{cmd,args}` 反序列化在桥接层 `remote_bridge.rs:96 from_payload` 成功,非阻断。tunnel.rs:457 注释自证「不反序列化为 TunnelCommand 强类型」。
> - **P0-2 EventBus**:`src-tauri/src/lib.rs:128` `state.ai_event_bus.subscribe()`(tunnel subscriber 已接)+ `remote_bridge.rs:303/332/358/388` + `commands/ai/mod.rs:209/225` `publish_event` 跨端透传。**已双写接入**(todo line 760 commit 0fb5af5/612de4c emit 双写 chat19/audit12/guard3/stream_recv2/agentic18)。`event_bus.rs:53/115` 的 `#![allow(dead_code)]`+「骨架阶段」注释是**过时历史标注**(publish_event 现有调用方 remote_bridge/mod),非未接入。
> - **P0-3 桥接层**:`remote_bridge.rs` 完整实装(`handle_remote_command`:121 + `MiniCommand`:83 + `from_payload`:96 + match 路由 10 条 + `route_list/load_messages` publish_event)+ **已 wire** `lib.rs:150-154` `on_command = Arc::new(...handle_remote_command...)` + `:171` `.connect(&relay_url,&device_id,&token,on_command.clone())`。非占位/设计态。`remote_bridge.rs:40-42` 的 `#![allow(dead_code)]`+「零调用方」注释写于联调前,现 lib.rs 已调(过时标注,待清)。
**剩余开放项**:P3-B-260622 [P3·真机前置] — **manifest appid 占位**。`PLACEHOLDER` 无法真机调试,需微信公众平台注册的真 appid。— `apps/df-miniapp/src/manifest.json`
### ✅ 2026-06-24 aichat 可靠性修复(消息重叠×5 + 授权弹窗卡死 + 工具执行心跳误报 · 诊断workflow→论证workflow)
> 剩余低优 [ ](非阻断):
> - [x] BUG-260624-02 残留 ✅ 已销账(2026-08-15):ToolCard pending 态倒计时落地(`_approvalTimers` 升级携带 startedAt/timeoutMs + `getApprovalRemainingMs` 真值起点,expireSecs/expireLevel computed,≤60s `.is-urgent` 红;不限时回退正计时;i18n expireIn zh/en)+ detached 窗口核验无缺口(AiDetached→AiChat 独立 startListener,ai-chat-event app 级广播,主窗口 detach 时 stopListener 不竞态,结论注释 useAiWindow:107-115)
> - [x] F-09 根因:streaming/currentText 全局单例改 per-conv ✅ **2026-08-07 核验销账**(per_conv HashMap + 前端 convStates Map enum 轨 + AiConvStateChanged 事件 per-conv 路由,根治多会话串扰)(根治多会话串扰,见 docs/待决策.md)
> - [ ] CR-260622-01-P2-2 [P2低优·评估降级] — **HTML/markdown/JSON 等非代码文件无 session 级缓存**。**2026-06-24 评估**:已有 `TOOL_RESULT_COMPRESS_ENABLED`(mod.rs:98/1029-1078 view-only 摘要,>2KB tool_result 压缩,LLM 视图摘要非全文回灌 prompt)部分缓解;read_symbol 治代码文件(主场景,降 24.4x)。非代码文件完整 session 缓存(path→hash+content+patch失效)设计复杂 + LLM patch 后重读确认行为不确定(缓存命中提示可能不够 LLM 仍重读),归 B 路线 prompt 策略(约束 patch 后不重读)更合适,工具侧完整缓存低优暂缓。
---
### 🔍 2026-08-01 走查剩余待办(P2/P3/专项·本会话登记)
> 本次走查剩余项登记,未实施代码(守 session-role-diagnose-only)。专项已落 memory,此处留指针。
**P2(中期改进)**:
**P3(风格/可读·低优)**:
**专项(已登记 memory,留指针)**:
- 📌 **WC-专项-AI-router-sanitize** — AI router + sanitize 链路梳理重构 — 详见 memory(已登记,本会话不实施)
- 📌 **WC-专项-run_agentic_loop-重构** — `run_agentic_loop` 重构(ARC-260619-05 agentic:318 超长,历史登记) — 详见 memory(已登记,本会话不实施)
**技能注入(待定位)**:
- [ ] **WC-技能注入-trace定位** — 技能/skill 注入链路待 trace 定位(注入时机、上下文来源、是否重复注入),定位后再决定是否待办化
---
### 🔍 2026-08-02 UI/UX 扩展审查(wf_wynylx23h · 10 路并行 · 基于任务 0115aea6 原 12 问题角度) ### 🔍 2026-08-02 UI/UX 扩展审查(wf_wynylx23h · 10 路并行 · 基于任务 0115aea6 原 12 问题角度)
> 详单:[UIUX扩展审查-2026-08-02.md](./05-代码审查/UIUX扩展审查-2026-08-02.md)。80 条发现(8 高/38 中/34 低;38 全新/29+ 延伸),高严重度主代理 grep 核验**全属实**。完整数据 `~/.claude/jobs/8ff8ccb7/tmp/audit_summary.json`。 > 详单:[UIUX扩展审查-2026-08-02.md](./05-代码审查/UIUX扩展审查-2026-08-02.md)。80 条发现(8 高/38 中/34 低;38 全新/29+ 延伸),高严重度主代理 grep 核验**全属实**。完整数据 `~/.claude/jobs/8ff8ccb7/tmp/audit_summary.json`。
@@ -421,30 +243,6 @@ graph TD
> **优先级建议**:① C0-1+C0-2 缓存命中率 → ② L0-1 save + L0-2 system 缓存 + L0-3 压缩后台化 → ③ R1-1 全量重放 + S1-1 流式增量 → ④ T1-1 审计批量 + F1-1/2/3 列表刷新收敛。 > **优先级建议**:① C0-1+C0-2 缓存命中率 → ② L0-1 save + L0-2 system 缓存 + L0-3 压缩后台化 → ③ R1-1 全量重放 + S1-1 流式增量 → ④ T1-1 审计批量 + F1-1/2/3 列表刷新收敛。
> **memory 指针**:[[aichat-efficiency-walkthrough-2026-08-09]] > **memory 指针**:[[aichat-efficiency-walkthrough-2026-08-09]]
### 🔍 2026-08-09 任务/仪表盘/灵感知识走查(3 路并行 · 阶段②第一批)
> 详单见 [任务+仪表盘+灵感知识走查-2026-08-09.md](./05-代码审查/任务+仪表盘+灵感知识走查-2026-08-09.md)。共 **59 项**(任务19/Dashboard+Projects17/Ideas+Knowledge23,P0=0/P1=7/P2=25/P3=27)。守 session-role-diagnose-only:仅走查登记,未实施代码。
**P1(确定性,优先修)**:
**P2(体验/数据)**: WK-8~13 ✅ 已修(0554cc1):快捷菜单状态机+补cancelled / overflow裁切根治(顶底角裁剪下放) / loadTasks seq守卫+keyword / 搜索态隐藏进度徽章 / 分页越界钳制 / 失败统一toast。剩余 WK-14~18 🟡 待后续
**P3(27 项)**: 见详单文档(TSK-10~19 / DBP-10~17 / IDEA-10~12 / KNOW-6~10 / C-1~3)
> **优先级**:WK-1 Dashboard 状态映射 → WK-2/3 Knowledge 确定性 → WK-4/5 Ideas → WK-6 状态徽章 → WK-7 错误条统一。
### 🔍 2026-08-09 Settings/审计/布局/后端命令走查(3 路并行 · 阶段②第二批)
> 详单见 [settings+审计+布局后端走查-2026-08-09.md](./05-代码审查/settings+审计+布局后端走查-2026-08-09.md)。共 **95 项**(Settings+AuditLog29 / layout+sidebar21 / 后端命令45,P0=0/P1=9/P2=32/P3=54)。守 session-role-diagnose-only:仅走查登记,未实施代码。
**P1(确定性/安全,优先修)**:
**P2(体验/数据·32 项)**: 见详单文档(SA-1~8 审计 i18n/脏表单/竞态、LS-2~10 侧栏偏好/小屏布局/审批浮窗/Paginator、BE-CMD-9~23 后端校验/聚合)
**P3(54 项)**: 见详单文档(SA-9~31 / LS-11~21 / BE-CMD-24~45)
> **优先级**:LW-1 ConfirmDialog 安全 → LW-2/3 update 裸奔 → LW-4 queue → LW-5 count/list → LW-6 软删 → LW-8 父聚合。
> **全系统索引**:前端 13 + 后端 9 命令全走查完成 ✅,剩 crate df-mcp/df-execute/df-project/df-ideas/df-tunnel/df-relay/df-types。
### 🔍 2026-08-09 df-mcp + 工具 crate 走查(阶段②最后一批) ### 🔍 2026-08-09 df-mcp + 工具 crate 走查(阶段②最后一批)
> 详单见 [df-mcp+工具crate走查-2026-08-09.md](./05-代码审查/df-mcp+工具crate走查-2026-08-09.md)。共 **35 项**(P0=0/P1=2/P2=8/P3=25)。守 session-role-diagnose-only:仅走查登记,未实施代码。 > 详单见 [df-mcp+工具crate走查-2026-08-09.md](./05-代码审查/df-mcp+工具crate走查-2026-08-09.md)。共 **35 项**(P0=0/P1=2/P2=8/P3=25)。守 session-role-diagnose-only:仅走查登记,未实施代码。
@@ -454,19 +252,6 @@ graph TD
**P3(25 项)**: 见详单文档(MCP-7~16 / EX-1~4 / PRJ-2~4 / TUN-2~4 / RLY-2~5) **P3(25 项)**: 见详单文档(MCP-7~16 / EX-1~4 / PRJ-2~4 / TUN-2~4 / RLY-2~5)
> **全系统走查(阶段②)完成 ✅**——前端 13 模块 + 后端 9 命令 + 全部 crate 已走查登记。进入**阶段③批量打包推进 /advance**。 > **全系统走查(阶段②)完成 ✅**——前端 13 模块 + 后端 9 命令 + 全部 crate 已走查登记。进入**阶段③批量打包推进 /advance**。
### 🔴 2026-08-09 AI 自审/工作流推进链路缺陷(✅ P0×3 已修,2026-10-05 核验销账)
> 用户「检查 AI self-review 遇到的问题」触发对工作流推进链路的深度审查。**原结论「三条推进链全部不可用」已过时——P0×3 全部已修**(task_id 注入 workflow.rs:340 + AiNode 自审落库含 agent 模式 + TaskDetail 审批 UI 随 2eea011 审批重设计批落地),核验详录迁 [07-项目管理/todo归档/2026-10-05.md](./07-项目管理/todo归档/2026-10-05.md)。以下原始诊断保留作档案,修复状态见归档勘误。
**涉及文件**:
- `src-tauri/src/commands/workflow.rs`(run_workflow_inner 核心)
- `crates/df-nodes/src/task_workflow_templates.rs`(三模板)
- `crates/df-nodes/src/ai_node.rs` / `ai_self_review_node.rs` / `ai_node_helpers.rs` / `human_node.rs`
- `crates/df-workflow/src/executor.rs` / `dag.rs` / `registry.rs` / `dag_def.rs`
- `src/views/TaskDetail.vue` / `src/stores/project/workflow.ts` / `src/views/ProjectDetail.vue`
---
## 🔴 P0 缺陷(3 项,工作流推进 100% 失败) ## 🔴 P0 缺陷(3 项,工作流推进 100% 失败)
### P0-A task_id 断裂:AiSelfReviewNode 必定失败 ### P0-A task_id 断裂:AiSelfReviewNode 必定失败
@@ -555,11 +340,6 @@ graph TD
> 来源:中断根治批次全量走查(正常设计路径无漏 emit,风险集中四类)。P1-2/P0-1/P2-1 本批已修,以下为登记项。 > 来源:中断根治批次全量走查(正常设计路径无漏 emit,风险集中四类)。P1-2/P0-1/P2-1 本批已修,以下为登记项。
### P0 run_agentic_loop 包 catch_unwind ✅已修(2026-10-05 核验:loop 主体 unwind_to_result 包裹,panic 转 teardown_round 补终态,5 单测锁定 mod.rs:3135-3165)
- **位置**:`agentic/mod.rs` run_agentic_loop 主体(无 catch_unwind)
- **现状**:任一轮 prepare/stream/finalize/tools 段 panic → task 死亡 → GeneratingGuard::drop 只迁 Idle+emit AiConvStateChanged,**不 emit AiError/AiCompleted**,heartbeat 随作用域死亡——「工具完成后流断+无终止事件」的精确根因形状(前端探测式续跑已兜住后果,此为后端根治)。
- **修复**:整体包 `std::panic::catch_unwind(AssertUnwindSafe(...))`,unwind 时 emit AiError{error_type:Internal} 再收敛;guard Drop 兜底保留。核心 loop 改动,需谨慎设计+场景回放测试。
### P1 conv 删除类静默退出族 6 处补终态事件 ### P1 conv 删除类静默退出族 6 处补终态事件
- **位置**:`agentic/mod.rs:417/:1316/:1379/:1437/:1593/:1423`、`stream_relay.rs:520-525`(MidStream 保文时 conv-deleted 跳过 teardown emit,最刺眼) - **位置**:`agentic/mod.rs:417/:1316/:1379/:1437/:1593/:1423`、`stream_relay.rs:520-525`(MidStream 保文时 conv-deleted 跳过 teardown emit,最刺眼)
- **现状**:生成期间会话被删,裸 return 仅 guard Drop 迁 Idle,无终止事件。 - **现状**:生成期间会话被删,裸 return 仅 guard Drop 迁 Idle,无终止事件。
@@ -573,17 +353,18 @@ graph TD
### 附: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`**。以下为可执行登记,开工以现场源码为准。
### 批次2(后端高危)并入项
### 批次3(前端体验)并入项
### 机械批(可独立小批)
### 登记观察(定级前先核)
# 子代理调度走查(2026-10-03 两路,详情 temp/走查-子代理{UX,调度后端}-2026-10-03.md) # 子代理调度走查(2026-10-03 两路,详情 temp/走查-子代理{UX,调度后端}-2026-10-03.md)
@@ -595,9 +376,6 @@ graph TD
> 与上节 25 项零重叠(报告尾附排除对账)。修复批:四路走查修复收口后,确定性项先行,需拍板项进拍板集。 > 与上节 25 项零重叠(报告尾附排除对账)。修复批:四路走查修复收口后,确定性项先行,需拍板项进拍板集。
- [x] P1[需拍板→已实施] 熔断无收尾轮 ✅ 2026-10-06:max_rounds 打满不再直接截断,补一次无工具(tools=None)收尾 LLM 调用——system 指令「输出最终任务总结报告」,成功则 push 报告+落库后走原收尾;失败(全候选耗尽/MidStream)静默落回原截断路径(报告是增益非闸门,零新增停靠)。预算:仅 1 轮 max_retries=0,无放大(agentic/mod.rs 达上限分支)
- [x] P1[需拍板→已实施] 报告被视图压缩吞掉 ✅ 2026-10-06:summarize_tool_results 加新近度豁免(RECENCY_EXEMPT_TOOL_RESULTS=2)——末尾 2 条 tool_result 恒不压缩(豁免在冻结决策之前,不进决策表),委托报告/末轮工具结果全文可见;单测 summarize_recency_exempt_keeps_last_two_tool_results 锁定行为(stall_detector.rs)
- [x] P2 namespace 10MB 全局 LRU 淘汰:报告蒸发为「重新调用」死路+多代理共池互踩 ✅设计完成(2026-10-04 [设计文档](./02-架构设计/专项设计/namespace淘汰治理-设计-2026-10-04.md)):pin 机制+EvictionLog 富提示(替代裸 EVICTED_PLACEHOLDER)+owner 软隔离三招,零 schema 变更;2 拍板点(pin 上限保护/报告外默认 pin 范围),约 0.5-1 天 ✅ 2026-10-06 实施完成 e0b29b4(pin+EvictionLog+owner 软隔离三招全落地,子代理报告出口 pin 收编;验收 cargo check 零 error)
- [ ] P2 被拒事实父上下文仅报告尾段单副本,LLM 压缩 prompt 无保留指令[需拍板] - [ ] P2 被拒事实父上下文仅报告尾段单副本,LLM 压缩 prompt 无保留指令[需拍板]
- [ ] P2 完成卡(前端全文)与父上下文(压缩)双轨无「已消化」降级机制 ✅设计完成(2026-10-04 [设计文档](./02-架构设计/专项设计/子代理完成卡已消化降级-设计-2026-10-04.md)):勘察修正——重启回补已存在(aiShared.ts:677 ai_sub_agent_list 反查重建),设计聚焦 digested 标记(KV 持久)+卡降级交互+ai_sub_agent_trace 只读工具(LLM 追问自助通道,兼为 namespace 蒸发兜底);2 拍板点,约 1-1.5 天 - [ ] P2 完成卡(前端全文)与父上下文(压缩)双轨无「已消化」降级机制 ✅设计完成(2026-10-04 [设计文档](./02-架构设计/专项设计/子代理完成卡已消化降级-设计-2026-10-04.md)):勘察修正——重启回补已存在(aiShared.ts:677 ai_sub_agent_list 反查重建),设计聚焦 digested 标记(KV 持久)+卡降级交互+ai_sub_agent_trace 只读工具(LLM 追问自助通道,兼为 namespace 蒸发兜底);2 拍板点,约 1-1.5 天
- [ ] P2 子会话缺 maybe_auto_compress 层+预算恒 128k 默认[需拍板] - [ ] P2 子会话缺 maybe_auto_compress 层+预算恒 128k 默认[需拍板]
@@ -637,10 +415,15 @@ graph TD
- [ ] P2 审计表无执行耗时字段(requested→executed 混入审批等待,耗时统计不可信)【部分缓解:AuditLog.vue:71 ⏱ durationLabel 已显示且诚实标注「审批→执行」;纯执行耗时独立字段归 V49 schema 迁移批】 - [ ] P2 审计表无执行耗时字段(requested→executed 混入审批等待,耗时统计不可信)【部分缓解:AuditLog.vue:71 ⏱ durationLabel 已显示且诚实标注「审批→执行」;纯执行耗时独立字段归 V49 schema 迁移批】
- [ ] P2 messages blob 双存储冗余(89/89,单会话最大 1.7MB)【归 V49 schema 迁移批】 - [ ] P2 messages blob 双存储冗余(89/89,单会话最大 1.7MB)【归 V49 schema 迁移批】
- [ ] 观察自动执行模式架空审批(auto 系 81%/人工 15%,auto_takeover_all 含 run_command 886 次)——产品语义与 S2/信任分级联动 - [ ] 观察自动执行模式架空审批(auto 系 81%/人工 15%,auto_takeover_all 含 run_command 886 次)——产品语义与 S2/信任分级联动
- [ ] 913 ✅转待办 验收:auto 模式审批架空语义收敛——auto_takeover_all 触发高危工具(run_command 等)须按 S2/信任分级处置(拦截或旁路留痕);审计表可查 auto 会话审批触发/旁路记录,产品语义写入设计档。
- [ ] 观察进化系统只写不读:knowledges 1033 条/embedding 全 NULL/83.6% 零复用/knowledge_search 仅 5 次 - [ ] 观察进化系统只写不读:knowledges 1033 条/embedding 全 NULL/83.6% 零复用/knowledge_search 仅 5 次
- [ ] 915 ✅转待办 验收:知识库由「只写不读」转读写闭环——embedding_status 回填(或明确弃用向量路径),≥半数知识进检索候选,knowledge_search 调用与复用率实测上升(复用率 >50%、检索调用非个位数);口径=同库 DB 查询核验。
- [ ] 观察多模态未走通:贴图落"[图片已忽略]"占位,实际靠 run_command 外部 vision CLI 绕路 - [ ] 观察多模态未走通:贴图落"[图片已忽略]"占位,实际靠 run_command 外部 vision CLI 绕路
- [ ] 917 ✅转待办 验收:贴图走原生视觉路径不落占位——非视觉模型收图时明确拒绝或切视觉模型,视觉模型直接出识别结果,不再靠 run_command 调外部 vision CLI 绕路;实测贴图会话零"[图片已忽略]"占位。
- [ ] 观察编排面零使用:workflow/node_executions/ai_plans 等 9 张表全 0 行;四会话分工 origin 89/89 全 NULL - [ ] 观察编排面零使用:workflow/node_executions/ai_plans 等 9 张表全 0 行;四会话分工 origin 89/89 全 NULL
- [ ] 919 ✅转待办 验收:编排面明确使用或弃用——e2e 跑通一个 workflow 使 node_executions 非空,或判定降级为预留并销账;会话 origin 回填(不再 89/89 NULL);口径=DB 查各表行数与 origin 非空计数。
- [ ] 观察数据目录 ~640MB(主库 140.5MB+dev 库 71.2MB+4 份手工备份 346.9MB 散落) - [ ] 观察数据目录 ~640MB(主库 140.5MB+dev 库 71.2MB+4 份手工备份 346.9MB 散落)
- [ ] 921 ✅转待办 验收:数据目录有保留策略——手工备份纳入轮转(定份数与周期),dev 库与主库职责明确(合并或清理);口径=%APPDATA%/top.1216.devflow 目录大小回落且备份数受控。
# ai-chat UX 走查第五轮(2026-10-05,可访问性视角,详情 temp/UX走查-aiChat缺陷-2026-10-05.md) # ai-chat UX 走查第五轮(2026-10-05,可访问性视角,详情 temp/UX走查-aiChat缺陷-2026-10-05.md)
@@ -675,6 +458,7 @@ graph TD
- [ ] **资源面**:token/磁盘/内存/进程开销评估;日志与数据膨胀上限(借鉴日志无轮转教训) - [ ] **资源面**:token/磁盘/内存/进程开销评估;日志与数据膨胀上限(借鉴日志无轮转教训)
- [ ] **回退开关**:新能力默认关或带单点回退(借鉴 GOAL_PIN 系开关范式);灰度顺序与观测指标 - [ ] **回退开关**:新能力默认关或带单点回退(借鉴 GOAL_PIN 系开关范式);灰度顺序与观测指标
- [ ] P2 指令离线即丢(§7 不缓存):commander 发指令时 agent 离线=静默丢失,移动端控制场景用户无感知——补业务层送达回执或短缓冲 - [ ] P2 指令离线即丢(§7 不缓存):commander 发指令时 agent 离线=静默丢失,移动端控制场景用户无感知——补业务层送达回执或短缓冲
- [ ] 923 ✅转待办 验收:离线指令不静默丢——补业务层送达回执(发送方可见成功/失败)或短缓冲(重连后补投);口径=断网/离线场景实测指令有回执或重连后送达,无静默丢失。
- [ ] P2 Hello 无协议版本字段:下次破坏性变更仍是无通知硬切换——加 proto 版本字段(现在加便宜,以后加贵) - [ ] P2 Hello 无协议版本字段:下次破坏性变更仍是无通知硬切换——加 proto 版本字段(现在加便宜,以后加贵)
- [ ] P3 单共享 token 可冒充任意 channel_id(§8 自述):补 token 轮换 runbook(/etc/u-relay.env 改+重启+四方客户端同步换,锁步成本需写明) - [ ] P3 单共享 token 可冒充任意 channel_id(§8 自述):补 token 轮换 runbook(/etc/u-relay.env 改+重启+四方客户端同步换,锁步成本需写明)
- [ ] P3 心跳间隔未定值:nginx proxy_read_timeout=300s,间隔须 <300s,文档补建议值(30-60s) - [ ] P3 心跳间隔未定值:nginx proxy_read_timeout=300s,间隔须 <300s,文档补建议值(30-60s)
@@ -731,7 +515,7 @@ graph TD
- [ ] W-g 状态即时同步与通知(设计 §九):W-g1 出口扇出+即时级(依赖飞书通道)/W-g2 摘要级+远控查询命令族/W-g3 每日报告(挂机联动) - [ ] W-g 状态即时同步与通知(设计 §九):W-g1 出口扇出+即时级(依赖飞书通道)/W-g2 摘要级+远控查询命令族/W-g3 每日报告(挂机联动)
- [ ] W-h 会话间/代理间感知(FileLeaseRegistry:写前登记+冲突指路/query_leases 工具/兄弟感知/知识联动;与 W-a 组合成完整 lease)——设计 §十;根治今日全部互踩 - [x] W-h 会话间/代理间感知(FileLeaseRegistry:写前登记+冲突指路/query_leases 工具/兄弟感知/知识联动;与 W-a 组合成完整 lease) ✅ **2026-10-10 核验销账(源码取证,发现早已落地)**:实现在 `src-tauri/src/commands/ai/lease.rs`(345 行,commit cb260ab「断点续跑与日志租约底座」);接线三处齐备——`commands/ai/mod.rs:48 pub mod lease;` / `tool_registry.rs:561 register_lease_tool`(query_leases 只读 Low,工具基线 80 含 lease 且已有断言) / 写类工具前置校验+RAII 释放 `audit/mod.rs:274-297`。本轮增量=把「冲突检测/过期清理」抽为纯函数 `try_register`/`purge_expired` + 补 3 单测(过期回占/清理计数/异路径并存),`lease::` 11 passed;**未新建 file_lease.rs 造双轨**。⚠️ 并行坑留痕:任何「注入未来时间查全局 REGISTRY」的测试会因 query 的 retain 误清他测活跃租约致偶发失败,新增时间敏感测试须走纯函数+独立表。——设计 §十;根治今日全部互踩
- [ ] W-e2 全局并发池(小:子代理并发上限+预算联动) - [ ] W-e2 全局并发池(小:子代理并发上限+预算联动)
- [ ] W-d1 挂机最小闭环(领单→执行→销账→循环+轻量续接;W-b wake 随其绑定实施) - [ ] W-d1 挂机最小闭环(领单→执行→销账→循环+轻量续接;W-b wake 随其绑定实施)
@@ -811,7 +595,7 @@ graph TD
### 待办(按优先级) ### 待办(按优先级)
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,新会话确认铁律段带出
@@ -824,32 +608,11 @@ graph TD
- 飞书四修+流式(占位卡片 edit_card 原地更新/表格降级/clip_for_card 超长裁剪/启动日志)——已上线 - 飞书四修+流式(占位卡片 edit_card 原地更新/表格降级/clip_for_card 超长裁剪/启动日志)——已上线
- 知识库 691b351d/c142e6b2 已 published+high(c142e6b2 含权威源声明);9c848ab 确认零丢失(rescue 分支锚定) - 知识库 691b351d/c142e6b2 已 published+high(c142e6b2 含权威源声明);9c848ab 确认零丢失(rescue 分支锚定)
### 新缺陷(2026-10-05 22:2x 用户截图实证,与 N/D 清单同级)
8. [x] **工具输出 ANSI 转义裸露**(乱码):run_command 类工具输出含 ANSI 码(\x1b[7m 等)原样透传前端显示为 ␛[7m。修:执行器统一 strip_ansi(或注入 TERM=dumb/--color=never)+前端渲染层兜底过滤;验收:cargo test 输出在面板无 ␛ 残留。 ✅ 2026-10-06 补销: 已随 70d6e98 修复(ANSI 源头抑制+strip 兜底), 当时漏销
9. [x] **会话标题 content 泄漏**:会话标题显示原始正文片段(如 `<details> <summa…`,84cb104c 会话同症)——标题生成输入未清洗或兜底直接取 content 头部。修:title 生成失败/超时兜底改为清洗后的首行摘要,含 HTML/Markdown 标签时剥离;验收:长会话标题无标签裸露。 ✅ 2026-10-06 补销: 已随 70d6e98 修复(标题剥标签 sanitize(title.rs)), 当时漏销
> 附:截图实证单会话 206 轮/1500 万 tokens——C 批次(上下文管理)紧迫性最强活证,随 C-r0 优先消化。
### 🔴 提交后跟进(2026-10-05 23:0x 走查发现,cb260ab 已入库但未修)
10. [x] **loop_state 对新会话/编排会话静默失效(高)** ✅修复(2026-10-06,提交「修复: loop_state 懒创建继承与入口早退清理」):懒创建继承 running(conversation.rs)+入口早退清残留(agentic/mod.rs)+list_parked 消费方=TopBar「可恢复」分区(V49 读侧)。验收:cargo check EXIT 0+vue-tsc EXIT 0。
11. [x] 【中】conversation_repo.rs:817 注释矛盾 ✅修复(2026-10-06 同批):update_full 剥离 loop_state 列,封 lost-update 窗口,注释与实现一致。
12. [x] 【中】前端 i18n 双侧同缺 aiChat.reattachToMain 键 ✅修复(2026-10-06 同批):zh/en 补键。
> 以上为 72 文件走查结论(报告要点 23:0x 已交群),提交规范 18 笔全合规,质量项按本清单跟进。
## 流畅性根本解三件(2026-10-05 深夜用户立项:1+2+3,④双通道搁置) ## 流畅性根本解三件(2026-10-05 深夜用户立项:1+2+3,④双通道搁置)
13. [x] **①过程流式** ✅ 2026-10-06 实施:run_stream_refresher 消费面扩容(AiTextDelta + AiAgentRound + AiToolCallStarted/Completed 四事件,conv 路由过滤共用)——过程流不刷屏:卡片改「头部一行摘要 + 正文增量」结构,头行=轮次 + ⚙当前工具执行中/已完成 N 步(历史步骤折叠为计数,决策价值过滤);过程事件标脏强制重刷(纯工具轮无文本也有"活着"信号,治 206 轮长任务卡片静止猜卡死);签名去重防同 tick 事件风暴空刷;超限保尾截断口径不变。4 单测锁格式契约(首轮无头行/工具执行中/轮间空档/截断)。验收:cargo check EXIT 0 + feishu 7 passed。参照 u-claw 进度卡治理;桌面过程视图同步对齐见「桌面端能力同步清单」。
14. [ ] **②任务式委托**:重任务(agentic loop 任务)受理即回任务卡(「已受理·预计 N 分钟」),用户可离开;完成后卡片原地更新+结果在卡。底座已有(30min 超时任务后台不丢),重构 handle_message 等待形态为受理/完成两段式。验收:发「走查代码」3s 内收到受理卡,完成后卡片变结果,全程无需等待。 14. [ ] **②任务式委托**:重任务(agentic loop 任务)受理即回任务卡(「已受理·预计 N 分钟」),用户可离开;完成后卡片原地更新+结果在卡。底座已有(30min 超时任务后台不丢),重构 handle_message 等待形态为受理/完成两段式。验收:发「走查代码」3s 内收到受理卡,完成后卡片变结果,全程无需等待。
15. [ ] **③C 批次关联**:本组两件的提速收益依赖 C 批次(prompt 71K→按需)——C-m1/m2 优先级顺位提升(单轮推理是 80% 耗时大头,见流畅性分析 2026-10-05)。 15. [ ] **③C 批次关联**:本组两件的提速收益依赖 C 批次(prompt 71K→按需)——C-m1/m2 优先级顺位提升(单轮推理是 80% 耗时大头,见流畅性分析 2026-10-05)。
16. [x] **纠偏注入通道**(用户 2026-10-05 深夜点名,飞书对话补充消息目前只能排队不能注入运行中任务)✅ 2026-10-06 实施:引擎层 `inject_runtime_message`(chat.rs,活跃守卫 conv_states.is_active → 短锁 push per_conv.messages → save_conversation 增量落库,loop 下轮 build_for_request 自然带上,u-claw 补拉同构);飞书侧 handle_message 白名单后加注入旁路(chat_map 命中且 conv 活跃 → 注入 + 卡片确认「下一轮生效」,非活跃 Err 落回常规 spawn 路径零行为变更)。不改锁语义/不打断 loop;桌面任务卡补充入口待 14 异步委托批配。验收:cargo check EXIT 0 + chat 17 passed + feishu 3 passed(含注入前缀格式契约单测)。
> **14 升级(用户拍板 2026-10-06 凌晨):桌面也要异步能力,不做双端分叉**——异步委托是引擎层能力,桌面与飞书都是它的前端视图。实现要点:①引擎层 spawn 即受理返回,完成走通知桥广播(oneshot 改事件+等待者订阅) ②桌面任务卡 UI:受理/进行中 N 轮/完成+系统通知,发消息后可切会话/最小化 ③飞书为同一语义投影(受理卡/完成原地更新) ④锁语义保守起步=单会话单在途+排队,多并发看用量再说 ⑤yml 开关灰度。依赖链:16 纠偏通道(受理后补充入口)→13 过程流式(进行中视图)→本项。验收:桌面发任务→切走→完成收系统通知;飞书同构;双端行为一致。
> **设计约束(用户拍板 2026-10-06 凌晨):任务会话高度复杂,代码设计一定要优雅**——①状态机显式化:受理/运行/暂停/完成/失败用枚举统一,禁散落 bool 拼凑 ②单一事实源:任务状态只在引擎层,桌面/飞书 UI 都是投影(订阅不轮询) ③事件驱动贯通:13/14/16 共用同一事件总线,不做渠道私有旁路 ④优雅优先于速度:宁可工期换结构,不做特判堆补丁(优雅架构铁律)。
> **桌面端能力同步清单**(与渠道对齐,2026-10-06):16 纠偏注入做在 AiSession 会话层桌面天然受益;13 过程流式桌面过程视图同步对齐;14 任务卡+系统通知已在升级注记;另两件桌面专属:①V49 可恢复入口——走查实锤 list_parked 读侧零消费,桌面侧栏补「可恢复」列表消费它(断点续跑闭环最后一块)✅已做 e0b29b4(分组展示+点击恢复) ②缺陷9 会话标题泄漏桌面同症随批修。
17. [ ] **桌面会话内容同步飞书**(同会话,2026-10-06 凌晨拍板):绑定飞书 chat 的 conv(chat_map 映射既有,恢复机制在)——桌面端该 conv 的新消息(user/assistant)实时投影飞书卡片,用户离机后手机飞书可跟任务全程。实现:订阅 ai_event_bus 的 conv 内容事件→飞书投影,与 13 过程流式/14 任务卡共用单写卡者;按决策价值过滤防刷屏(完整历史在桌面,飞书给关键节点)。与 14 组合即完整闭环:桌面发任务→切走→手机看过程→收结果。验收:桌面 conv 发消息/收回复,绑定飞书 3s 内卡片更新关键节点。 17. [ ] **桌面会话内容同步飞书**(同会话,2026-10-06 凌晨拍板):绑定飞书 chat 的 conv(chat_map 映射既有,恢复机制在)——桌面端该 conv 的新消息(user/assistant)实时投影飞书卡片,用户离机后手机飞书可跟任务全程。实现:订阅 ai_event_bus 的 conv 内容事件→飞书投影,与 13 过程流式/14 任务卡共用单写卡者;按决策价值过滤防刷屏(完整历史在桌面,飞书给关键节点)。与 14 组合即完整闭环:桌面发任务→切走→手机看过程→收结果。验收:桌面 conv 发消息/收回复,绑定飞书 3s 内卡片更新关键节点。
18. [x] **渠道错误话术吞因**(09:51 实证)✅ 2026-10-06 修复:①spawn Err 分类话术——并发护栏拒绝(「正在生成」)→「⏳ 该会话已有任务进行中,本条将在任务完成后处理;可稍后重发,或先 /status 查看进度」;其余失败→附截断原始原因(不吞因)。②探活告警口径修正——inflight 计数点从 spawn 前提前到 handle_message 白名单通过处(RAII 覆盖占位卡 HTTP+per-chat 锁排队窗口,原该窗口任务在跑却报「无在途会话」);旧计数点删除防双重计数。验收:cargo check EXIT 0 + feishu 7 passed。
> 附:09:48 桌面纠偏「不是 haogame 是 redtimer」被运行中 loop 下一轮直接接住——16 纠偏注入的桌面形态天然存在,飞书侧对齐即可。
## 工作流治理升级(2026-10-06 用户拍板:根本性增强,源自提交质量检查三不达标) ## 工作流治理升级(2026-10-06 用户拍板:根本性增强,源自提交质量检查三不达标)
> 根因:任务传递靠软机制(看板摘要+群消息阅读),无结构化任务单与销账门禁——🔴被静默跳过/i18n 描述失实/描述与内容偏差,三层同源。 > 根因:任务传递靠软机制(看板摘要+群消息阅读),无结构化任务单与销账门禁——🔴被静默跳过/i18n 描述失实/描述与内容偏差,三层同源。
@@ -860,17 +623,6 @@ graph TD
- [ ] **G3 描述反向核对**:提交前 diff 关键词与描述机械比对,不符打回(cm 技能增强) - [ ] **G3 描述反向核对**:提交前 diff 关键词与描述机械比对,不符打回(cm 技能增强)
- [ ] **G4 销账带证据**:勾选 [x] 须附验收命令输出行,无证据视同未完成 - [ ] **G4 销账带证据**:勾选 [x] 须附验收命令输出行,无证据视同未完成
### 桌面能力同步(随本批)
- [x] G5 V49「可恢复」入口(桌面侧栏消费 list_parked,断点续跑闭环最后一块)✅ e0b29b4 可恢复分组+点击恢复已落地
- [ ] G6 缺陷9 标题泄漏桌面同症随批修
### ⚠️ 退回重处理(2026-10-06 用户令,G2/G3 治理标准首次执行)
- 三笔提交(cec4d11 委托引擎/84df9b5 租约/622507c UI)已 reset --mixed 退回(内容全在工作区,零丢失,未推远端无需处理远端)
- **重做要求**:①先修 🔴10 loop_state 失效(G2 门禁:🔴不清零不得提交新功能)②描述按 G3 如实:实际干了 14+16+事件广播三件须逐件列全 ③i18n reattachToMain 键随批补上 ④修完按主题重新分组提交,销账 [x] 附验收输出(G4)
19. [x] **飞书 outbound 统一出口**(11:23 实证)✅ 2026-10-06 收口:勘察确认对话/受理/完成/注入回执已统一走 deliver_result(降级/裁剪/卡片链内聚);病灶「挂机进度汇总独立发送路径」在当前 main 不存在(属退回的挂机实验代码)。本批防复发收口:①新增 push_to_chat pub(crate) 正典出口(主动推送形态——挂机进度汇总/巡检通知将来落地时禁再开独立路径,强制走降级/裁剪/卡片链) ②ACTIVE_CLIENT OnceLock 全局句柄(渠道启动就绪,推送复用长连接 client 不临时建连,未启动静默跳过)。验收:cargo check EXIT 0 + feishu 7 passed。
> 附:挂机汇报自报 🔴10 已修验证通过+43 文件产出——挂机模式推进有效,路径修复别误伤它。
## 无人值守可靠性专项(2026-10-06 swarm 立项,设计档:docs/02-架构设计/无人值守可靠性专项设计-20261006.md+temp/V49断点续跑设计-20261006.md)
## UI/UX 体验设计实施项(2026-10-06 设计档:temp/异步任务会话UIUX设计-20261006.md,依赖 13/14/16/17 机制先行) ## UI/UX 体验设计实施项(2026-10-06 设计档:temp/异步任务会话UIUX设计-20261006.md,依赖 13/14/16/17 机制先行)
- [ ] U1 任务卡生命周期组件(双端):桌面 TaskCard 八态投影枚举(全部由既有事件派生,零引擎新状态)+飞书 header 状态色结构卡(JSON 元素级规格已给,动作 v1 走文本命令) - [ ] U1 任务卡生命周期组件(双端):桌面 TaskCard 八态投影枚举(全部由既有事件派生,零引擎新状态)+飞书 header 状态色结构卡(JSON 元素级规格已给,动作 v1 走文本命令)
@@ -879,24 +631,6 @@ graph TD
- [ ] U4 纠偏交互:桌面直发默认注入·下一轮生效(排队可切),回执+前缀逃生;飞书回复即注入保持 - [ ] U4 纠偏交互:桌面直发默认注入·下一轮生效(排队可切),回执+前缀逃生;飞书回复即注入保持
- [ ] U5 异常态话术公式:状态+真实原因+已保留什么+≥2条出路;禁自动判死静默任务(15min 重入退避是合法等待),给判断材料不替用户决策 - [ ] U5 异常态话术公式:状态+真实原因+已保留什么+≥2条出路;禁自动判死静默任务(15min 重入退避是合法等待),给判断材料不替用户决策
- [ ] U6 跨端一致性:引擎唯一写者,双端不直同步,"实时"=秒级最终一致;断线恢复桌面走 DB 回放/飞书走 /status 回查 - [ ] U6 跨端一致性:引擎唯一写者,双端不直同步,"实时"=秒级最终一致;断线恢复桌面走 DB 回放/飞书走 /status 回查
20. [x] **委托制伪受理断层**(13:38 实证):受理卡在引擎确认可启动之前发出——12:57 任务停滞 Generating 时,「进度」spawn 被引擎拒但受理卡已发,之后零通知零进度(比 09:51 立即报错更隐蔽)。修:受理前过 can_accept_request,不可启动→直接回「前任务在跑,已排队/稍后」;可启动→才发受理卡+watcher 接力。附带:委托制下 inflight 生命周期改跟任务走(handle_message 提前返回致 18 修正再次失效,13:18 误报复现)。验收:同场景复测,占用期发消息得到明确排队提示而非静默受理。 ✅ 2026-10-06 c91b993(交互批八项合提,验收: cargo check 零 error+feishu 13/df-im-gateway 19/chat 17 passed+vue-tsc 0)
> **符号语义规范(2026-10-06 用户反馈:受理卡「✅ 已受理」歧义,随 19 统一出口一并落地)**:
> - 🚀 受理/开工(任务开始才用,禁 ✅)|⏳ 进行中/排队/等待|✅ 仅限完成且成功|❌ 失败(必须附原因)|⚠️ 警告/需注意(附数据)|📎 附录/附加信息
> - 原则:符号=状态的一眼判据,同一符号跨场景语义必须一致;受理卡改为「🚀 已受理,后台执行中」;完成推送才允许 ✅ 开头。
## 飞书交互面全量盘查(2026-10-06,11 条缺陷按恼人度降序,修复前话术避免引用 /status)
34. [x] 🔴 **过程推送散乱刷屏**(14:06 用户截图反馈「信息太散乱爆炸了」):push_to_chat(:797)每调用必新开独立卡片,通道无聚合语义——昨日调试把链路测试/修复上线/加固明细等 L3 过程逐条直推 4+ 条进群,与 U3 分级相悖。修双层:①机制=push_to_chat 增滚动汇总卡——同 chat 维护在途事务卡 message_id(内存即可,重启另起),进度用已有 update_card(:730)原地追加;首推需 send_markdown_card 返回新卡 id(现 :683 is_ok() 丢弃,比照 reply_markdown_card);文本以 ✅/❌ 开头=终态,更新后清句柄下条另起新卡;更新失败回退新发并重绑(比照 deliver_result 兜底)。②纪律=L3 过程默认不推群,落日志+进度文件;push 收敛「需决策/完成✅」两时机,调试确需的验证推也进汇总卡不新开。验收:同事务多步进度群时间线只占 1 张卡,过程类推送零直达。 ✅ 2026-10-06 c91b993(交互批八项合提,验收: cargo check 零 error+feishu 13/df-im-gateway 19/chat 17 passed+vue-tsc 0)
21. [x] 🔴 **/status 被话术推荐但不存在**(chat.rs:387/:486 两处):用户发了被当任务烧一轮 LLM。修:实现 /status(读 conv 状态返回进度摘要,配合委托制价值高)或先改话术删除引用。验收:发 /status 得进度卡而非 spawn。 ✅ 2026-10-06 c91b993(交互批八项合提,验收: cargo check 零 error+feishu 13/df-im-gateway 19/chat 17 passed+vue-tsc 0)
22. [x] 🔴 **35min 兜底回查会投出上一任务旧回答冒充本次结果**(feishu_channel.rs:483/:939):回查整 conv 最后 assistant 文本无任务边界。修:回查限定受理时间戳之后的 assistant,无则给真实状态话术。 ✅ 2026-10-06 c91b993(交互批八项合提,验收: cargo check 零 error+feishu 13/df-im-gateway 19/chat 17 passed+vue-tsc 0)
23. [x] 🔴 **watcher 首超时后永不弃守**(:486 continue 与注释"二次超时弃守"相反)+错误路径只发 AiError 无 AiAgentFinished→每 35min 重刷过期文案+任务泄漏。修:二次超时真弃守+AiError 也广播 Finished(status=error)。 ✅ 2026-10-06 c91b993(交互批八项合提,验收: cargo check 零 error+feishu 13/df-im-gateway 19/chat 17 passed+vue-tsc 0)
24. [x] **无取消/审批通路**:跑偏无法叫停(飞书零 stop 通道);审批挂起对飞书用户不可见(流式卡冻结无解释)。修:/cancel 对接 ai_stop_loop;审批事件上卡片(「需审批:xxx,回复 批准/拒绝」)。 /cancel 停止通路+watcher 收口+审批推送与批准/拒绝决议(路径类授权留原语差异), ✅ 2026-10-07 细项批(待提交)
25. [x] **非文本消息静默丢**(:243):发图/文件零反馈。修:回「暂仅支持文本,媒体能力规划中」(D7 前的兜底)。 非文本兜底已存在于基线(:255 回「暂仅支持文本」), ✅ 2026-10-07 核验补销
26. [x] **⏳话术承诺排队实际不入队**——话术与行为不符。修:改话术为实况(「可稍后重发」)或实现真排队(与 20 受理断层同修)。 ✅ 2026-10-06 c91b993(交互批八项合提,验收: cargo check 零 error+feishu 13/df-im-gateway 19/chat 17 passed+vue-tsc 0)
27. [x] **快任务兜底死分支**(:449 elapsed>=3s 入场即查恒假,「秒级任务直投终版」不可达)。修:受理卡发送后计时或事件驱动判定。 ✅ 2026-10-06 c91b993(交互批八项合提,验收: cargo check 零 error+feishu 13/df-im-gateway 19/chat 17 passed+vue-tsc 0)
28. [x] **注入末轮竞态**:loop 已在末轮则补充指令无人消费静默丢失。修:注入后检查 loop 剩余轮次,末轮则自动续一轮。 注入末轮顺延轮次预算(共享余量+轮顶动态判定+封顶 8), 5 单测锁定, ✅ 2026-10-07 细项批(待提交)
29. [x] **push_to_chat 取 KV map 首条**(:792):多 chat 绑定可能发错会话。修:按 conv 反查 chat。 push_to_chat 按 conv 反查+多绑定取最近活跃(签名加 conv_hint 零涟漪), ✅ 2026-10-07 细项批(待提交)
30. [x] 细节批::389 反引号不闭合;/new 大小写与附言容错;/new 时旧任务在跑无提示;标点半全角混排。 反引号闭合//new 容错与旧任务提示/半全角归一(parse_command 3 单测), ✅ 2026-10-07 细项批(待提交)
## 上下文根治专项(2026-10-06 设计定稿:docs/02-架构设计/上下文根治设计-20261006.md,取代旧 C 发散条目) ## 上下文根治专项(2026-10-06 设计定稿:docs/02-架构设计/上下文根治设计-20261006.md,取代旧 C 发散条目)
- [ ] P1(S):工具结果瘦身——超 2K 结果正文落盘+句柄,上下文留摘要+路径(与 D2a 同构,最大头先行) - [ ] P1(S):工具结果瘦身——超 2K 结果正文落盘+句柄,上下文留摘要+路径(与 D2a 同构,最大头先行)
- [ ] P2(M):L1/L2/L3 分层组装器(build_for_request 改造:常驻+滑动窗口+检索召回) - [ ] P2(M):L1/L2/L3 分层组装器(build_for_request 改造:常驻+滑动窗口+检索召回)
@@ -911,6 +645,11 @@ graph TD
### 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 连续推进模拟(确定性批次队列),零人工干预零停顿;超大任务挂钟闸到点走检查点续跑。
@@ -925,20 +664,17 @@ graph TD
**补齐优先序:B5d 工具重试策略 → B5a 挂钟定级 → B5b 自动续作 → B5c 审批策略 → B2 预算闸 → 13 过程流式** **补齐优先序:B5d 工具重试策略 → B5a 挂钟定级 → B5b 自动续作 → B5c 审批策略 → B2 预算闸 → 13 过程流式**
### 13a 飞书过程可见性加固(2026-10-06 用户实证"卡点到结束才回复",13 的最小件提前独立做,零新机制) ### 13a 飞书过程可见性加固(2026-10-06 用户实证"卡点到结束才回复",13 的最小件提前独立做,零新机制)
- [x] 13a-1 刷新可观测:refresher 的 update_card 失败从"静默跳过"改为 feishu_log 记录+连续失败计数(≥3 连败告警)——当前刷了还是没刷、刷败全不可知,排查无从下手。 ✅ 2026-10-07 实施(feishu_channel.rs 单文件):刷失败 feishu_log 落盘(连败次数/chat/msg/chars)+连败 ≥3 告警一次(REFRESH_FAIL_ALERT_THRESHOLD=3,== 阈值触发后继续累计不重复)+刷新恢复记「此前连败 N 次」+成功归零。验收:cargo check -p devflow EXIT 0 + cargo test --lib feishu 16 passed 0 failed。
- [ ] 13a-2 静默期心跳汇报:AiHeartbeat(20s 既有事件)接入 refresher——超 N 分钟无内容刷新→主动刷一行「仍在跑:第 X 轮 · 最近动作 yyy」(数据源 AiAgentRound/最后工具名),防静默期卡片冻结。 - [ ] 13a-2 静默期心跳汇报:AiHeartbeat(20s 既有事件)接入 refresher——超 N 分钟无内容刷新→主动刷一行「仍在跑:第 X 轮 · 最近动作 yyy」(数据源 AiAgentRound/最后工具名),防静默期卡片冻结。
- [ ] 13a-3 阶段汇报:AiTaskProgress(每 5 轮,mod.rs:1119 已 emit)接入 refresher——长任务的阶段性进展对飞书用户可见(当前只有桌面消费)。 - [ ] 13a-3 阶段汇报:AiTaskProgress(每 5 轮,mod.rs:1119 已 emit)接入 refresher——长任务的阶段性进展对飞书用户可见(当前只有桌面消费)。
- [ ] 13a-4 受理→首刷衔接:受理卡到首个 delta/心跳之间的静默窗口检查(>60s 无首刷则心跳兜底)。 - [ ] 13a-4 受理→首刷衔接:受理卡到首个 delta/心跳之间的静默窗口检查(>60s 无首刷则心跳兜底)。
验收:206 轮级长任务期间,占位卡至少每 3-5 分钟有一次真实更新(心跳或阶段),失败可见于 feishu-channel.log。 验收:206 轮级长任务期间,占位卡至少每 3-5 分钟有一次真实更新(心跳或阶段),失败可见于 feishu-channel.log。
31. [x] (自动续作) **AI-Ops→devflow 派工通道**(多 bot 编排正统通道,2026-10-06 缺口实证):AI-Ops 无法直接触发 devflow 开工(飞书 bot 消息不互触发/WebChat 无/remote_bridge 走 tunnel 不外用)。方案二选一:①u-claw 侧新增 ask_devflow(与既有 ask_uclaw 对等,经 remote_bridge 协议下发启动指令) ②devflow 侧看板轮询引擎(定时扫 todo.md 新🔴/高优条目自动开工,CLAUDE.md 领单纪律的运行时化=B5b 同源)。建议 ② 与 B5b 合并实现(同一套自动续作引擎)。验收:AI-Ops 写看板后 15min 内 devflow 自主开工,零人工中转。 ✅ 2026-10-07 方案②实施并与 B5b 合并落地(unattended.rs 636 行,B5b+31 同一引擎):看板解析器(编号条目四态+🔴 判据=标记后首字符防误判)/G2 门禁领单选择器(🔴 未清零只领红 fail-closed)/轮询调度器(tokio 常驻,KV 热读配置 df-unattended-{enabled,poll-secs,claim-scope,todo-path},默认关+300s)/收敛回读钩子 notify_batch_done 接 agentic emit_ai_completed_once 单点/parked 自动恢复优先/连续 3 次 spawn 失败熔断/领单原子写+spawn 失败回滚/引擎独立后台会话 pin_active=false。验收证据:①接线 rg 实测 lib.rs:77 unattended::start + agentic/mod.rs 收敛钩子调用 ②`cargo test --lib unattended` = 9 passed 0 failed(本轮实测修正 parse_statuses_and_red_marker 断言:🔴 判据仅认标记后首字符,正文提及不误判,与实现注释语义对齐) ③**活证**=本条目自身即引擎产出:AI-Ops 写板 → 引擎改写 `31. [→](自动续作)` → 派发本会话执行,零人工中转,15min 上限实测远低于下限。
### 31 泛化路线:「看板即队列」模板资产化(2026-10-06 swarm 设计定稿) ### 31 泛化路线:「看板即队列」模板资产化(2026-10-06 swarm 设计定稿)
- **抽象**:看板=任务队列(优先级/状态三态/验收口径)+ 领单纪律=消费者协议 + 治理门禁=质量闸。任何 Claude Code 实例 + 一个看板 = 一个自主工作单元。 - **抽象**:看板=任务队列(优先级/状态三态/验收口径)+ 领单纪律=消费者协议 + 治理门禁=质量闸。任何 Claude Code 实例 + 一个看板 = 一个自主工作单元。
- **组件三件套模板**(提炼自 devflow 今日实践,新场景开箱即用): 1.看板格式规范 2.领单纪律 CLAUDE.md 模板 3.治理门禁包(cm v1.3.0+pre-commit hook) - **组件三件套模板**(提炼自 devflow 今日实践,新场景开箱即用): 1.看板格式规范 2.领单纪律 CLAUDE.md 模板 3.治理门禁包(cm v1.3.0+pre-commit hook)
- **推广路线**: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 不可见——机制防护优先于行为归因。
@@ -948,16 +684,103 @@ graph TD
35. [~] ~~H3 teardown 疑似过重~~ **真相改写(验装日志实锤)**:17:47 进程消失=agent 自杀式换装脚本杀宿主(swap-install.log:17:48:14 old process stopped),非 H3——H3 嫌疑解除。真正缺陷转 36。 35. [~] ~~H3 teardown 疑似过重~~ **真相改写(验装日志实锤)**:17:47 进程消失=agent 自杀式换装脚本杀宿主(swap-install.log:17:48:14 old process stopped),非 H3——H3 嫌疑解除。真正缺陷转 36。
> 关联:13a 过程可见性四件**置顶催办**——受理后静默的体感投诉今天第三次(09:51/13:38/17:47+),13a-1 刷新可观测是排查此类问题的前提。 > 关联:13a 过程可见性四件**置顶催办**——受理后静默的体感投诉今天第三次(09:51/13:38/17:47+),13a-1 刷新可观测是排查此类问题的前提。
36. [x] **换装脚本失败:杀进程后 3s 即安装,Windows 文件锁延迟释放致 NSIS 静默伪成功**(17:48 实证 FAIL: exe not updated,installer exit=0)。✅ 2026-10-06 晚全链闭环:**脚本加固**(swap-install.ps1 v3,语法 0 错实测):①句柄释放探测(独占打开循环,30s) ②校验升级为构建产物 SHA256 强基准(mtime 判据作废,NSIS 保留源文件 mtime 必误报) ③校验失败自动重试一次 ④SYSTEM 触发时转投用户会话重入,四级 fallback,最坏落 swap-need-start.flag。**上线验证过**(18:44 版手动换装成功):运行 exe SHA256=构建产物=0C45683B,进程 12764 运行中,H1 行为实测通过(19:13 DevFlow 回复带「📎 git 实测」段)。
37. [ ] **监控轮询机制缺失(1004/1006 两次要求均未落地,全周响应性投诉的伴生缺口)**:10-04「持续不间断监控每 1 小时」+10-06 18:45「持续监控 10 分钟一轮」,两次均未建机制;18:45 断链实锤=传承摘要未继承该指令。根因:无头会话无定时能力(网关禁用 CronCreate)。方案二选一:①网关 cron(运维配 yml crons,周期拉 git/进程/日志快照+异常告警) ②devflow 侧自监控引擎(与 B5/31 看板轮询同源,顺带监控自身进程/会话活性+静默告警)。验收:监控自动轮询真实运转,devflow 静默/进程消失 1 个周期内被发现并通报。 37. [ ] **监控轮询机制缺失(1004/1006 两次要求均未落地,全周响应性投诉的伴生缺口)**:10-04「持续不间断监控每 1 小时」+10-06 18:45「持续监控 10 分钟一轮」,两次均未建机制;18:45 断链实锤=传承摘要未继承该指令。根因:无头会话无定时能力(网关禁用 CronCreate)。方案二选一:①网关 cron(运维配 yml crons,周期拉 git/进程/日志快照+异常告警) ②devflow 侧自监控引擎(与 B5/31 看板轮询同源,顺带监控自身进程/会话活性+静默告警)。验收:监控自动轮询真实运转,devflow 静默/进程消失 1 个周期内被发现并通报。
38. [x] **飞书出站 md 语法裸露净化+交互样式对齐 u-claw**(AI-Ops 2026-10-06 晚实证落板):任务报告 `##`/`**` 等 md 标记在飞书裸露(20:53 分析报告截图实锤)。根因:飞书 lark_md 不支持标题/表格等全谱语法,LLM 产出未经出站转换;主链 send_markdown_card(feishu_channel.rs:716,注释自认「text 类型会符号裸露」)只保粗体/链接,im-gateway StreamText 链纯文本直出。修:①出站净化层——md 转飞书友好格式(标题行转粗体/表格转列表/代码块适配),双层防线=出站兜底转换+agent 产出 prompt 约束,移植 u-claw 投递链降级/裁剪经验(u-claw 仓为范本) ②首回受理卡样式对齐 u-claw 任务状态卡(计数 N/M+逐条状态符+当前动作一行,10-02 用户拍板「效果不错」);受理卡符号 🚀 已由 AI-Ops 修(449 行)。验收:同类分析报告在飞书零 md 裸露标记,受理卡样式与 u-claw 一致。 ✅ 2026-10-06 c91b993(交互批八项合提,验收: cargo check 零 error+feishu 13/df-im-gateway 19/chat 17 passed+vue-tsc 0)
39. [x] 🔴 **提交冻结令被无人值守链路击穿**(10-07 用户查问「最近提交不符合要求」实证):看板顶部 🔒 冻结令(10-06 深夜用户令,期间只改不提)生效后,挂机链路 01:56-57 仍连提 4 笔(c5ba755 续作引擎 811 行/d0281cb 飞书取消审批 345 行/7ab25e5 换装判据/73fa7c2 看板销账),且 73fa7c2 看板内容自书「细项批(待提交)」与提交事实自相矛盾——冻结仅是文本纪律,引擎与挂机会话的提交步无机械闸。修:①引擎自动提交路径与挂机会话提交步在 commit 前必查冻结标志(看板顶部 🔒 行视为生效,KV 标志更佳),在场即拒绝提交、改动留工作区;禁用全局 pre-commit 拦截(误伤用户本人提交) ②引擎派发的会话启动 prompt 注入「冻结期禁 commit」硬约束 ③解冻=用户「攒批提交通知」后由会话摘除 🔒 行再提。验收:冻结令在场时引擎/挂机会话 commit 步零执行(实测),摘令后恢复;随 31 引擎同测。 ✅ 2026-10-07 实施(commit_freeze 三层闸,零提交全在工作区):①机械闸=crates/df-execute/src/commit_freeze.rs 新模块——静态冻结位 set_frozen(引擎 unattended::run_tick 每轮按看板 🔒 行+KV df-commit-frozen 覆盖同步)+execute/execute_streaming 入口 check(),git commit 变体(git -C/复合命令 ;&&|/引号包裹/多空格)spawn 前拒绝,改动留工作区;未挂全局 pre-commit,用户本人终端零影响。②任务书注入:claim_and_spawn 冻结在场时追加「提交冻结令硬约束」段(禁 git commit/push,执行层闸兜底)。③解冻=用户摘 🔒 行或 KV df-commit-frozen=false,下一轮轮询(≤300s)自动同步放行。验收实测:cargo test -p df-execute 26 passed 0 failed(18 lib 含 6 冻结闸新用例:commit 变体识别/只读命令放行/冻结拦截/解冻恢复/静态位切换;8 shell 集成)+ cargo test -p devflow --lib unattended 10 passed 0 failed(含冻结判据新用例:🔒 行/blockquote `> 🔒` 形态/KV 双向覆盖/默认不冻结)+ cargo check -p devflow --lib EXIT 0。冻结在场实测=单测断言 check() 对 `git commit` 返回 Err 且 `git diff` 放行,摘令后恢复=static_flag_toggle 用例复位断言;真实看板形态(`> 🔒` 引用行)经 commit_freeze_board_and_kv_judge 实测命中。
40. [ ] **桌面侧 u-relay 常驻连接管理器(设计命题,待综合评估后再规划设计)**(2026-10-07 用户议定「登记后处理」):ask_uclaw 现为每请求独立 WS 短连接(connect→hello→探活→request→progress/result→close,任务执行期内本就实时);用户提议改「全程一条常驻连接中转所有上下行(zhub 式单连接多路复用)」。**本项仅登记命题,具体方案待后续综合评估**——评估须回答:①当前唯一用例(ask_uclaw 15min 级委托)下常驻是否真有必要(握手 ~0.1s 可忽略、u-claw 侧单 channel 串行 worker 锁死并发=1)②push 型需求(u-claw 主动推事件/高频短指令/多 channel 并行/跨链路统一观测)何时兑现 ③是否与 10-05「推翻网关链路、定稿桌面直连」决策冲突(飞书直连接入-形态修正与问题记录-2026-10-05.md)。命中上述 push 型触发条件之一即启动评估;同构先例可参 df-im-gateway(一条常驻 tunnel 连接中转四端全部上下行)与 feishu_channel.rs。 40. [ ] **桌面侧 u-relay 常驻连接管理器(设计命题,待综合评估后再规划设计)**(2026-10-07 用户议定「登记后处理」):ask_uclaw 现为每请求独立 WS 短连接(connect→hello→探活→request→progress/result→close,任务执行期内本就实时);用户提议改「全程一条常驻连接中转所有上下行(zhub 式单连接多路复用)」。**本项仅登记命题,具体方案待后续综合评估**——评估须回答:①当前唯一用例(ask_uclaw 15min 级委托)下常驻是否真有必要(握手 ~0.1s 可忽略、u-claw 侧单 channel 串行 worker 锁死并发=1)②push 型需求(u-claw 主动推事件/高频短指令/多 channel 并行/跨链路统一观测)何时兑现 ③是否与 10-05「推翻网关链路、定稿桌面直连」决策冲突(飞书直连接入-形态修正与问题记录-2026-10-05.md)。命中上述 push 型触发条件之一即启动评估;同构先例可参 df-im-gateway(一条常驻 tunnel 连接中转四端全部上下行)与 feishu_channel.rs。
900. [x](自动续作) 🔴 测试条目
901. [x](自动续作) 🔴 提交工作区改动(转交实战,2026-10-07):将当前工作区改动按主题分组提交并推送 origin main。(21:4x AI-Ops 重置回队:前两轮引擎会话被 20:38-21:02 u-switch 故障窗口「网络中断不完整」腰斩,重入预算耗尽停 [→],网络已恢复)要求:①提交前 G4 前置走查(风格/明显缺陷/调试残留/注释规范,发现问题先处理再提交) ②分组建议:挂机模式 P1 总装一笔(UnattendedPanel/Settings/TopBar/TaskBoundaryCard/i18n/unattended 两 IPC/lib.rs/feishu_notify)+看板销账一笔(todo.md) ③swap-install.ps1 若有改动先审 diff 再定去留;删除 scripts/check_plaintext_key.py(一次性诊断脚本已完成使命) ④提交信息中文类型开头逐件列全,commit-msg hook 会拦截违规 ⑤完成后本条改 [x] 并附 git log --oneline -3 输出。验收(10-08 AI-Ops 补录,引擎 22:06 完成):git log --oneline -3 → 0aac14e 文档: 看板销账: 挂机模式 P1 总装与换装校验修复入库,901 销账 / 6bfcf01 修复: 换装校验同版本重装误判不通过 / 2441101 新增: 挂机模式 P1 总装: 设置面板+顶栏徽章+边界卡精简+监控告警推送;main=origin/main 已推,工作区清零。
902. [ ] 超时失败后的一键继续入口: 流式首字节超时(120s,服务收了不响应,不可自动重试)报「响应超时,请重试」后流程中断——轮次重入预算耗尽(3/3)即彻底停,用户只见裸文案。修:超时失败落停靠语义(复用 emit_task_parked),停靠卡带「继续」(ai_continue_loop parked 分流已修好)——错误后一键续跑而非中断。体验缺口实证 2026-10-07 晚 u-switch 故障窗口。 902. [ ] 超时失败后的一键继续入口: 流式首字节超时(120s,服务收了不响应,不可自动重试)报「响应超时,请重试」后流程中断——轮次重入预算耗尽(3/3)即彻底停,用户只见裸文案。修:超时失败落停靠语义(复用 emit_task_parked),停靠卡带「继续」(ai_continue_loop parked 分流已修好)——错误后一键续跑而非中断。体验缺口实证 2026-10-07 晚 u-switch 故障窗口。
903. [ ] u-switch 20:38-21:02 故障窗口根因深查: 11 连败+120s 首字节超时风暴(多会话多 provider 同时),21:0x 自愈。查 moyu-ops 机 u-switch 日志定因(上游抖动/服务重启/连接池耗尽),根治或加监控告警。**【21:5x AI-Ops 定因已实锤,journalctl 实测】上游连环降级雪崩非服务故障:api.deepseek.com 402 余额不足(冷却10m)+enterprise.moyu.info 401 认证失效(冷却10m)+sensenova kimi-k3 429/SSE空响应体(30s冷却循环)+bigmodel glm 429/SSE截断缺 message_stop——多通道同时打光致无首字节,冷却走完自愈。剩余工作=根治项:①switch-gw 的 deepseek 系 4 通道确认摘除(用户指认已摘但配置仍在役)②429 风暴时客户端退避协同(30s 冷却对重试风暴无效)③故障窗口告警。** 903. [ ] u-switch 20:38-21:02 故障窗口根因深查: 11 连败+120s 首字节超时风暴(多会话多 provider 同时),21:0x 自愈。查 moyu-ops 机 u-switch 日志定因(上游抖动/服务重启/连接池耗尽),根治或加监控告警。**【21:5x AI-Ops 定因已实锤,journalctl 实测】上游连环降级雪崩非服务故障:api.deepseek.com 402 余额不足(冷却10m)+enterprise.moyu.info 401 认证失效(冷却10m)+sensenova kimi-k3 429/SSE空响应体(30s冷却循环)+bigmodel glm 429/SSE截断缺 message_stop——多通道同时打光致无首字节,冷却走完自愈。剩余工作=根治项:①switch-gw 的 deepseek 系 4 通道确认摘除(用户指认已摘但配置仍在役)②429 风暴时客户端退避协同(30s 冷却对重试风暴无效)③故障窗口告警。**(10-08 已处置:moyu 令牌过期续期+主链切 ds-flash,见 900 区段外 905)
925. [ ] **前后端事件契约与 i18n key 全量核查(2026-10-10 只读 Wave D,源码取证)**:全量抽取后端 emit 与前端 listen/分派做对照;另把 src 下 1282 个静态 i18n key 与 zh-CN/en 键集合(各 1569)比对。
- ✅ **已修两处**:①`ai.aiTool.rejectedHint` 前缀写错(多一层 `ai.`)→ 2 处调用(`composables/ai/useAiApproval.ts:90` / `useAiToolEvents.ts:120`)改为正确键 `aiTool.rejectedHint`(此前 zh/en 双缺 → UI 渲染裸 key,908 类复发);②`components/layout/UpdateBadge.vue` 监听 Tauri 事件 `app-update-available`,而生产端 `composables/useUpdateCheck.ts` 走 **window CustomEvent** 同名不同通道 → 徽章永不触发;已补 window 监听 + 卸载清理(保留 Tauri 监听以兼容后端将来改走 emit)。
- [ ] **待处理·孤儿事件 2**:`AiPlanCreated`(`agentic/mod.rs:1006` emit)/`AiConflictResolved`(`commands/settings.rs:259` emit)——前端零消费;补消费或停发(现状为白 emit + 双写总线开销)。
- [ ] **待处理·死变体 3**:`AiMaxRoundsReached`(`commands/ai/mod.rs:239`)/`AiSubTaskStatusChanged`(`:421`)/`AiMergeCompleted`(`:429`)——前后端零构造零消费(前端仅在 types 联合与 NO_RESET_WATCHDOG 名单留残影);清理或补 emit+消费。
- [ ] **潜在(低)**:`dashboard.ideaStatus.archived` 键缺失(值域含 archived,当前 list 恒过滤 archived → 不可达);补键或注明。
- 备注:跨端 miniapp-only 5 变体(ConversationList/MessageHistory/SkillList/ModelList/EntityList)只走 publish_event→总线→tunnel→miniapp,桌面端不监听属设计,非孤儿。
927. [ ] **V50 迁移回填的不可逆风险(2026-10-10 只读风险自查,待处置)**:`crates/df-storage/src/migrations.rs:1511-1545` `backfill_checkpoint_token_total` 直接 UPDATE 覆盖 `conversation_checkpoints.token_total` 旧值,**无备份表、无 DOWN**、函数注释自认「无回退」;虽迁移文档论证旧值(消息条数)失真无保留价值,仍属不可逆数据覆盖且无快照兜底。
- [ ] **处置(成本极低)**:回填前把 `(id, old_total → new_total)` dump 到 temp 备份文件或日志,给「后悔一次」的机会;顺带 `migrations.rs:1516-1527` 一次性 collect 全部 checkpoints(含 snapshot 全文字符串)进内存 → 改分批/游标,防大表峰值内存膨胀。
- 已核安全项:`migrate_v50`(:1483)幂等(column/table_exists 守卫 + 仅 recomputed>0 且 !=old 才 UPDATE + schema_version 闸 + 重跑单测);df-mcp 新增仅写一个 KV,无新端点/帧/权限放大。
928. [ ] **aichat 前端性能热点与测试覆盖缺口(2026-10-10 只读 Wave F,源码取证)**
- **性能(按收益排序)** — 高:①`MessageList.vue` 3041 行 God 组件(职责>6 类);②`renderItems`(:1058) 每次依赖变化全量 O(n) 重建(工具事件级触发);③**全仓零 `v-memo`**(:1498/:1715) → 流式 rAF 每帧重建整个 renderItems vnode,长会话掉帧最直接来源。中:`JSON.stringify` 指纹 watch(:429) 每次工具状态变动全量 map+stringify;每帧逐条 `renderMdDecorated`/`traceTaskRefs` 调用(:1548/1556/1672/1519/1722);`TopBar` 三处 O(n) messages computed(:619/:676/:707) 可合并单次遍历;`useAiSend.ts` 595 行可再拆 `useAiQueue`;`useTurnTrace` switch 缺 default(:59,union 非穷尽)。低:`ConversationSidebar` 分组排序、`ChatInput` status `as string` 拓宽(:601/605/606)、`MessageItem` 模板同函数重复调用(:110-122)、md 缓存「满即全清」悬崖(:699 / useMarkdown:195) → 改 LRU。
- **已核良好(非热点)**:`useAiEvents/useAiToolEvents/useAiStreamingEvents/useAiLifecycleEvents` 分派器拆分清晰(均<400 行、switch 带 default);`useStreamRenderer` 块级 memo + 增量 lexer + rAF 节流已完善。**关键澄清**:流式 delta 不写 `messages.content`(仅收尾/新轮/停止/超时 flush),故热点是「工具事件级 + 流式帧级」而非每 token。
- [ ] **测试覆盖缺口(建议补最小用例)**:结果/过程分离渲染(renderItems 无任何前端断言,仓库仅有 playwright e2e 两个 spec)、`keyword_fallback_commit` 单锁窗口(context_lifecycle.rs 无测试)、`check-i18n` 门禁自身无反引号用例、`route_conv_event`/`route_scope` 事件改挂(event_bus 测试未覆盖)、`relocate_project_path` 路径校验(`..` 拦截)无用例 —— 对「泄漏风险 × 改动复杂度」高者优先补。
929. [ ] **模型配置「停用」无效(2026-10-10 用户报,已定位待修)**:用户在设置里停用模型后仍被使用。 ✅ **2026-10-10 主根因已修**:`prompt.rs get_active_provider` 原两条路径均不看 `enabled`(显式活跃 provider 直接返回 / 默认 provider 只按 `is_default` 挑)→ 已收敛为「启用集合」解析(`resolve_enabled_provider`,无启用 provider 时明确报错);✅ **兜底路径已修**:`compress.rs`/`title.rs` 新增 `resolve_fallback_model`(纯函数,title 复用同一实现——单一实现无双轨)+ 4 单测,覆盖「默认模型被停用→回落池内任一启用模型/池内无启用→报错」。⚠️ **同类次生缺口待收口(4 处,均已定位未改)**:`commands/ai/knowledge_inject.rs:231` 与 `:1116`(embedding/extract 兜底)、`commands/project.rs:800`(scan_model 兜底)、`agentic/router_setup.rs:15-16`(`resolve_model_with_override` 的 resolved 兜底;override 本身已校验)——四处兜底均不校验 `enabled`,建议随本项一并收口。**勘察结论**:路由层**是**过滤 enabled 的(`crates/df-ai/src/router.rs:98 .filter(|m| m.enabled)` + 单测 `enabled_false_excluded_from_routing`),故病灶在别处,三处嫌疑按置信排序:①**已选中的 model_override 被无条件采纳**——`agentic/mod.rs:740 let mut resolved_model = match model_override.as_deref()` 优先用前端选中的模型,若该模型随后被停用则**不校验 enabled**,继续照用;②**兜底 `default_model` 不校验 enabled**——`compress.rs:82-83`(及 `title.rs` 同款) 在池内无可选时 `unwrap_or_else(|| provider_config.default_model.clone())`,若 default_model 正是被停用的模型 → 照用;③**provider 级池停用(`p.enabled`,ProviderPanel `onPoolToggle`)不参与路由**——路由只过滤 model.enabled,池禁用可能仅影响前端展示。修法:选中/兜底/池三条路径统一过「enabled 校验」,不可用则回落到池路由(已过滤)或明确报错。验收:停用某模型后,选择器不列出、既有 override 落到其它启用模型、压缩/标题兜底不选它。
931. [ ] **消息发送队列会丢失(2026-10-10 用户报,已定位)**:会话遇网络超时等错误后队列消失;重启同样丢。**勘察结论**:`state.queue` 是**纯内存**(`src/stores/ai.ts:136`),无持久化;错误/收尾路径会清或丢弃(`useAiSend.ts:180/256` 重生成/编辑时 drop、`aiShared.ts:773` clearQueue)。修法:①队列按会话**持久化**(KV 或独立表),入队/出队/清空即写,启动与会话加载即恢复;②**错误/超时不得清队列**(只标记该条失败可重试),重生成/编辑类**显式用户动作**才允许清;③恢复后给出「N 条待发已恢复」提示与手动清理入口。验收:断网超时后队列仍在;杀进程重启后队列仍在且可续发。
933. [ ] **输入框支持有序列表渲染(`1. ` 类飞书,2026-10-10 用户报)**:ChatInput 内输入 `1. `(或 `- `)后应有**列表预览/自动编号**效果(对齐飞书消息输入框),而非纯文本。**需设计**:触发判据(行首 `\d+. ` / `- ` / `* `)、渲染形态(实时预览块 or 仅高亮)、回车续写自动补序号、退格退出列表、与既有 `/` 技能联想、`@` 提及、代码围栏的优先级冲突处理。设计落 docs 后再实施。
935. [ ] **定时消息(多条 + 定时列表,2026-10-10 用户报)**:支持把消息设定为定时发送,可设多条,并有统一「定时列表」管理(查看/改期/取消)。**需设计**:存储(表 or KV)、调度器(前端定时器 or 后端 tokio 任务,需在应用关闭后仍生效→倾向后端)、与队列/生成中状态的关系、失败重试与通知、UI 入口(CHATIN 旁「定时」按钮 + 列表抽屉)。设计落 docs 后再实施。
939. [ ] **上下文管理瓶颈清单(2026-10-10 五维只读走查,源码取证)** — 按收益排序,⚠️ `crates/df-ai/**` 被并行写者占用(工具结果外置 WIP)→ 带 ★ 的可先在 src-tauri 侧落,其余待解禁:
- **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 ③ ✅ 已修(两批)**:主会话=model_context.rs 按 resolved_model 窗口构造(b37279f,未知窗口保守回退 128K 零回归);子代理=sub_agent_context_config 继承父会话预算(10-11,同模型同窗口单一事实来源,恒 128K 缺口收口)。
- **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「压缩不丢/可召回」落地。
- **④ 设计要点(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。
- **已核良好**:裁剪三元组原子性正确(`context/mod.rs:612-642`);909 两处修复(锚点避让/单锁复位)现状有效,未见旧窗口复现。
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)。
- **为何不能只改工具层**:轮次生命周期必须同步改写,否则态不自洽——① 心跳 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`。
- 拍板点:① 是否引入「非终态 tool_result」这一语义(默认关可回退);② grace 秒默认值;③ 后台期间会话态展示(Generating 还是「后台执行中」)。
937. [ ] **对话内创建定时/延时任务(2026-10-10 用户报)**:在对话中直接创建「定时执行/延时执行」的任务(如「1 小时后跑 X」)。**需设计**:与既有 Tasks/工作流(advance_task、run_workflow)的关系、调度落点(与 935 共用调度器)、无人值守引擎(unattended)是否复用、UI 呈现(任务卡带定时徽标)。设计落 docs 后再实施。
943. [ ] **ai-chat UI 检查:待发送队列横幅(2026-10-10 用户截图实证+代码核验)**——位置 `AiChat.vue:79-113`(样式 965-1050):
- **UI-1 全文不可辨**(中):`.ai-queue-text`(1005-1011)单行 ellipsis 且**无 title** → 长路径+指令完全无法看全;修:补 `:title="item.text"` 或两行 clamp。
- **UI-2 视觉层级倒挂**(低):横幅 accent 底色+多操作,比主输入区还醒目;待发是次要动作,建议降噪(中性底色/收起为一条计数)。
- **UI-3 操作语义重叠**(低):「回填(108)」与「编辑(110)」并存——回填=取出放回输入框,编辑=行内编辑,功能高度重叠;建议二合一。
- **UI-4 清空无确认**(中):`clearQueue`(84)直接清空全部待发,**无二次确认**;对比「强制发送」有 confirm(`tryForceSend`,533);误点即丢 10 条。修:加 confirmDialog。
- **UI-5 无上下文预期**(低):有等待时长(queueItemWait ✓)但无「预计还要等多久/当前生成还剩多少」;信息可后置,仅登记。
945. [ ] **ai-chat UI 检查:工具卡头部截断(2026-10-10 截图实证)**——`ToolCard.vue:27/:367`:头部摘要 `resultSummary` 有 `:title` 悬停,但 `summaryTitle` **只取 `args.path`**——`run_command` 类悬停看不到命令内容,头部命令文本又单行截断(446-450) → 截图实证「$ …命令成功 (19s)」完全不可辨。修:`summaryTitle` 按工具类型取关键参数(run_command→command;无 path 工具→首参)。
947. [ ] **ai-chat UI 检查:token 统计常驻每条消息(2026-10-10 截图实证)**——每条带 reasoning 的消息都挂思考 pill,右端 token 小字(`roundTokenText`,MessageList)逐条重复「55.4k in(估算) 974 c…」且截断显示不全 → 元信息噪音化。修(方向,需拍板):① pill 悬停才显 token;② 仅最新一显,历史轮收进轨迹卡统计;③ 截断点保底(最少显全「974 out」段)。
949. [ ] **强制发送后发送按钮失态(2026-10-10 用户实测报)**:队列「强制发送」后,后端在新 loop 正常生成(内容照常流出),但发送按钮回到普通「发送」态而非「停止」态。**根因方向(已定位待实施)**:force_send 复位旧 generating 时 emit 旧任务 AiCompleted(`chat.rs` 复位分支)——前端迟到收尾仲裁(useAiLifecycleEvents:60,IPC 查后端 generating)存在 ① 事件到达 vs 新 loop 占用 generating 的竞态窗口(查到 false 即误判真收尾);② AiTextDelta 无条件累加所以内容照流、streaming 却被翻 false,内容与按钮态脱钩正好吻合现象。修(三选一或组合,实施时细查 emit 顺序):① 后端把旧终态 emit 挪到新 loop 占用 generating 之后(消竞态);② 前端 AiConvStateChanged/相关收敛路径全纳入仲裁;③ 自愈兜底:AiHeartbeat 到达时若后端 generating=true 而前端 streaming=false → 置回 true。验收:强制发送后按钮全程「停止」态直至收尾。
951. [ ] **消息聚合形式优化(2026-10-10 用户提出)**:进一步聚合消息展示粒度——当前「结果/过程分离」是一桶一张轨迹卡(每轮一张);长任务(截图 20 轮)仍会有 N 张卡。方向(待拍板):① 任务级聚合=一次发送的全程收敛为**单张过程卡**(内部分轮导航/折叠),气泡只留最终结果;② 维持每轮一卡但加强轮间视觉折叠(连续轮默认收进「+N 轮」);③ 先上已实现的「结果/过程分离」新版实测再定(可能已够)。依赖:新版换装后实测再拍板粒度。
953. [ ] **主对话 max_tokens 截断无 UI 提示 + 续写整段重发(2026-10-10 用户新版实测截图,魔芋渠道接入会话)**:模型输出 921 out 戛然而止(finish_reason=length),气泡**无任何截断标记**——用户只能问「卡了吗」;追问后模型答「上一轮被截断,接着整理完整」却**从头发一遍**,同一方案在列表里重复两大段(token 翻倍+滚动负担)。代码核验:后端有专门截断标志(stream_recv.rs:105 truncated_by_max_tokens,openai_helpers.rs:341 finish_reason=length),但仅子代理完成卡消费(AgentFinishedStatus truncated);主对话气泡零消费。修(三层):①**UI 提示**:truncated_by_max_tokens 穿透到主对话气泡——尾部渲染「⚠ 输出因长度上限截断」弱提示条(附「继续输出」按钮=注入续写指令,复用 16 纠偏注入通道);②**行为**:续写注入让模型从断点续(上下文半截已在,提示「从中断处继续,勿重复已输出内容」),防整段重发;③**配置面**:921 out 即断疑似该渠道 max_tokens 上限小或默认 max_tokens 偏小,查 model_configs 该渠道 max_tokens 配置。验收:截断气泡有可见提示;继续输出不重复已发内容。
- **③ 配置面结论(2026-10-10 核验)**:`model_configs` 全部 `max_tokens=None`(ds-flash/glm/moyu 系 8 条)→ 请求不带输出上限,断点取决于上游默认;921 out 即断=该渠道模型输出上限小,且当前配置结构**无法干预**(model_configs 不支持 max_tokens 字段)→ 能力缺口:给 ModelConfig 增 max_tokens 可选字段+请求构造透传(涉 crates/df-ai,待解禁)。另:`agentic/mod.rs:2271` 截断后已有「以上内容可能不完整;可要求继续输出」system 提示(**只给模型,UI 不可见**)——模型能答「上轮被截断」正源于此,953① 的用户可见提示仍必要。旁证:DB 中 ctx=8192 哨兵大量存在,用户实测 56.7k in 未被卡 → J2 哨兵不采信守卫生效 ✓。
955. [ ] **DSML 工具调用标记泄漏为可见文本(2026-10-10 用户实测,魔芋渠道 ds-flash)**:模型输出 `<||DSML|| invoke name="run_command">`、`<||DSML|| parameter name="command" string="true">cd E:\wk-lab\devflow; npx vue-tsc...` 等 DeepSeek 文本协议工具调用标记,整段泄漏为可见文本(工具卡不渲染、命令裸露)。**根因(全仓 grep 取证)**:Rust/前端**均无 DSML 解析代码**——DevFlow 流解析只认原生 `tool_calls` delta;魔芋渠道的 DeepSeek v3.1+ 在该渠道不走原生 function calling 而是把工具调用以 DSML 特殊标记写进 content 文本(上游行为),无人解析即泄漏;且截断(921 out)发生在标记中间时,残缺 DSML 必然泄漏。**修(三层)**:①流解析层:openai_helpers/stream_recv 检测 content 中的 DSML invoke/parameter 标记 → 解析出 name/parameters 构造 ToolCallDraft(与原生 tool_calls 同路,工具卡正常渲染+真实执行);②渲染兜底:MessageItem/Markdown 渲染前剥离未闭合 DSML 残段(防截断泄漏);③**模型/渠道规避**:该行为是 DeepSeek v3.1+ 渠道侧特性,魔宇渠道换原生 function-calling 模型或升级渠道适配可根治。验收:DSML 工具调用正常出卡并执行;截断残段不泄漏。**注意**:全仓引入 DSML 解析是新能力,实施前按「新能力引入评估纪律」走设计档。
957. [ ] **审计决策者键补齐(2026-10-10 Wave G 发现)**:审计决策者列后端还有 5 个值渲染裸 key:`auto_retry_guard`(audit/approval.rs:185)、`auto_resolve_fail`(audit/mod.rs:750)、`boundary-dirs`(audit/mod.rs:909)、`boundary_auto`(audit/mod.rs:1449/1517)、`sub_agent_denied`(audit/mod.rs:116)——auditLog.ts 的 decided 段只有 8 键,缺这 5 个。修:i18n zh/en 补 5 键(值按语义直译);其中 boundary-dirs/sub_agent_denied 语义为拒绝,不要回退到既有 auto 键。
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 提示;空闲自动续投+结果回推。验收:任务占用期发消息不丢,任务完成自动处理并回推结果。
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/轮边界锚点绑定,不随视图压缩/折叠漂移)。
- ✅ **①已修(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 化/远程容器方向合流设计
-3438
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -1,15 +1,17 @@
{ {
"name": "devflow", "name": "devflow",
"private": true, "private": true,
"version": "0.1.1", "version": "0.1.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:stop": "taskkill /f /im vite.exe", "dev:stop": "taskkill /f /im vite.exe",
"dev:restart": "npm run dev:stop && npm run dev", "dev:restart": "bun run dev:stop && bun run dev",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"check:i18n": "node scripts/check-i18n.cjs",
"preview": "vite preview", "preview": "vite preview",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:scenario": "playwright test tests/e2e/stream-body-visibility.spec.ts",
"tauri": "tauri", "tauri": "tauri",
"build:dev": "tauri build --debug --no-bundle" "build:dev": "tauri build --debug --no-bundle"
}, },
+135
View File
@@ -0,0 +1,135 @@
// 看板归档器:把 docs/todo.md 中「已完成」内容分离到 docs/07-项目管理/todo归档/<date>.md。
//
// 移动规则(纯行搬运,零措辞改动):
// ① 条目块为 [x] → 自动移出(其后续非条目行为同块延续,含缩进证据/引用块);
// ② **白名单整段移出**:命令行给出的标题(用引号原样传入) → 该段连同其子段(级别更深的后续段)
// 一并移出。历史/导航/已完成段走此路——**不靠启发式猜「已完成」**(本板存在大量无勾选的
// 行内待办,启发式会误移)。
// ③ 移出后残留的空标题(段内仅空行)自动删除,防悬空标题。
//
// 安全闸:未完成项([ ]/[~]/[→]/[!])零丢失断言失败即中止不写;默认 dry-run。
//
// 用法:
// node scripts/archive-todo.cjs # dry-run
// node scripts/archive-todo.cjs "## 🎯 统一规划(2026-06-26 全景)" # dry-run + 试移该段
// node scripts/archive-todo.cjs --apply "## 交接状态(2026-06-14)" # 实际写入
// node scripts/archive-todo.cjs --apply --date 2026-10-11 ...
const fs = require('fs');
const argv = process.argv.slice(2);
const APPLY = argv.includes('--apply');
const di = argv.indexOf('--date');
const DATE = di >= 0 ? argv[di + 1] : new Date().toISOString().slice(0, 10);
const li = argv.indexOf('--list');
// 白名单来源:① 命令行位置参数(注意 Windows Git Bash 下中文会被编码破坏,慎用)
// ② --list <file>:UTF-8 文件,每行一个精确标题(推荐,避免编码问题)
const WHITELIST =
li >= 0
? fs.readFileSync(argv[li + 1], 'utf8').split('\n').map((s) => s.trim()).filter(Boolean)
: argv.filter((a, i) => !a.startsWith('--') && i !== di + 1 && i !== li + 1);
const SRC = 'docs/todo.md';
const ARC = `docs/07-项目管理/todo归档/${DATE}.md`;
const UNDONE = /^\s*- \[[ ~→!]\]|^\d+\. \[[ ~→!]\]/;
const DONE = /^\s*- \[x\]|^\d+\. \[x\]/i;
const BLOCK = /^(\s*- \[|\d+\. \[)/;
const HEAD = /^#{1,3} /;
const level = (h) => (h.match(/^#+/) || [''])[0].length;
// 行尾归一:工作区可能是 CRLF,按 \r?\n 切(否则标题比对带 \r 恒不等)。写回用 LF,git 提交时归一。
const lines = fs.readFileSync(SRC, 'utf8').split(/\r?\n/);
// 打标:``` 围栏内一律 text(防 mermaid/示例里的 ## 误导分段)
const kinds = [];
let inFence = false;
for (const ln of lines) {
if (ln.trim().startsWith('```')) { kinds.push('text'); inFence = !inFence; continue; }
kinds.push(inFence ? 'text' : HEAD.test(ln) ? 'head' : BLOCK.test(ln) ? 'block' : 'text');
}
// 分段(首个 head 前为 preamble,永留主板)
const segs = [];
let cur = { head: null, headLevel: 0, items: [] };
for (let i = 0; i < lines.length; i++) {
if (kinds[i] === 'head') { segs.push(cur); cur = { head: lines[i], headLevel: level(lines[i]), items: [] }; }
else cur.items.push(lines[i]);
}
segs.push(cur);
const preamble = segs[0].head === null ? segs.shift() : null;
// 块归并(块 = 条目起始行 + 其后非条目行)
function blocksOf(items) {
const out = [];
let curB = null;
for (const ln of items) {
if (BLOCK.test(ln)) {
if (curB) out.push(curB);
curB = { status: DONE.test(ln) ? 'done' : UNDONE.test(ln) ? 'undone' : 'other', lines: [ln] };
} else if (curB) curB.lines.push(ln);
else out.push({ status: 'text', lines: [ln] });
}
if (curB) out.push(curB);
return out;
}
const moved = [];
const kept = [];
if (preamble) kept.push(...preamble.items);
let treeLevel = null; // 白名单段的级别:更深的后续段随其移出
let movedSegs = 0, movedBlocks = 0;
for (const seg of segs) {
const whitelisted = seg.head !== null && WHITELIST.includes(seg.head);
const inTree = treeLevel !== null && seg.head !== null && seg.headLevel > treeLevel;
if (!seg.head) { /* 不可能:非 preamble 段必有 head */ }
if (whitelisted || inTree) {
if (whitelisted) treeLevel = seg.headLevel;
movedSegs++;
if (seg.head) moved.push(seg.head);
for (const b of blocksOf(seg.items)) moved.push(...b.lines);
continue;
}
treeLevel = null;
if (seg.head) kept.push(seg.head);
for (const b of blocksOf(seg.items)) {
if (b.status === 'done') { movedBlocks++; moved.push(...b.lines); }
else kept.push(...b.lines);
}
}
// 删残留空标题:仅当「段内只剩空行」且「下一个标题级别 ≤ 当前」才删——
// 若下一个标题更深,说明它是**容器标题**(如 `## 待办` 下挂 `### 子节`),必须保留。
const cleaned = [];
for (let i = 0; i < kept.length; i++) {
if (HEAD.test(kept[i])) {
let j = i + 1;
while (j < kept.length && kept[j].trim() === '') j++;
const nextIsDeeper = j < kept.length && HEAD.test(kept[j]) && level(kept[j]) > level(kept[i]);
if (j >= kept.length || (HEAD.test(kept[j]) && !nextIsDeeper)) continue;
}
cleaned.push(kept[i]);
}
// 零丢失断言
const origUndone = lines.filter((l) => UNDONE.test(l));
const newUndone = new Set(cleaned.filter((l) => UNDONE.test(l)));
const lost = origUndone.filter((l) => !newUndone.has(l));
console.log(`[archive-todo] ${APPLY ? 'APPLY' : 'DRY-RUN'} date=${DATE} whitelist=${WHITELIST.length}`);
console.log(` 整段移出 ${movedSegs} 段,条目块移出 ${movedBlocks} 个,移出 ${moved.length} 行,主板 ${cleaned.length} 行(原 ${lines.length})`);
console.log(` 未完成项 原 ${origUndone.length} → 主板 ${newUndone.size},丢失 ${lost.length}`);
if (lost.length) {
console.error('✗ 零丢失断言失败,不写入:');
for (const l of lost.slice(0, 10)) console.error(' ' + l.slice(0, 100));
process.exit(1);
}
console.log(' 移出段标题:');
for (const l of moved.filter((x) => HEAD.test(x))) console.log(' - ' + l.slice(0, 90));
if (!APPLY) { console.log('(dry-run,加 --apply 写入)'); process.exit(0); }
if (moved.length === 0) { console.log('无内容可移,跳过写入'); process.exit(0); }
let arc = fs.existsSync(ARC) ? fs.readFileSync(ARC, 'utf8').trimEnd() : `# todo 归档(${DATE})`;
fs.writeFileSync(ARC, `${arc}\n\n---\n\n## ${DATE} 分拆(已完成内容再分离)\n\n${moved.join('\n')}\n`, 'utf8');
fs.writeFileSync(SRC, cleaned.join('\n'), 'utf8');
console.log(`[archive-todo] 已写入 ${SRC} 与 ${ARC}`);
+111
View File
@@ -0,0 +1,111 @@
// vue-i18n 消息预检:揪出只在 **prod runtime** 抛错的未转义特殊字符(字面 @ / | / 裸 {})。
//
// 背景:vue-i18n 9 把 message 里的字面 `@` 当 linked-message 语法、`|` 当 plural 分隔符,
// 未转义会编译报 "Invalid linked format"。该错误 dev 宽松回退、`vue-tsc` 捕获不到(message 是
// 字符串值非类型),只在打包装出的应用里炸——已两次踩坑(2026-06-15 白屏 / 2026-10-10 输入框消失),
// 且两次静态检查全绿。故前置到提交门禁 + 可手动跑:`node scripts/check-i18n.cjs`
//
// 修法:字面量用 vue-i18n 的插值转义,如 `{'@'}`(渲染仍显示 @)。
//
// 依赖 @intlify/message-compiler(vue-i18n 的编译器);解析不到时只告警不拦截(避免门禁误伤)。
//
// 扫描/编译逻辑抽为可导出函数(scanText / scanDirectory / compileMessage),供
// scripts/check-i18n.test.cjs 自测;CLI 入口行为保持不变。
const fs = require('fs');
const path = require('path');
let baseCompile = null;
try {
({ baseCompile } = require('@intlify/message-compiler'));
} catch {
baseCompile = null;
}
const DIRS = ['src/i18n/zh-CN', 'src/i18n/en'];
// key: 'value' / "value" / `value`(带转义),三种字面量都取——
// 反引号模板字面量同样可能含未转义 @/|/裸{},原先只匹配引号 → 门禁假阴性(2026-10-10 对抗审查发现)。
const KV_RE = /(?:^|[\s,{])([A-Za-z0-9_$]+)\s*:\s*([`'"])((?:\\.|(?!\2).)*)\2/gm;
// 编译单个消息值,返回错误数组(@intlify 风格:{ message })
function compileMessage(val) {
const errs = [];
try {
baseCompile(val, { onError: (e) => errs.push(e), onWarn: () => {} });
} catch (e) {
errs.push(e);
}
return errs;
}
// 扫描一段源码文本,返回 { checked, violations }
// - checked: 含 vue-i18n 特殊字符(@ { |)并已送编译的值个数
// - violations: 编译报错的条目 [{ key, value, message }]
function scanText(txt) {
const violations = [];
let checked = 0;
KV_RE.lastIndex = 0;
let m;
while ((m = KV_RE.exec(txt))) {
const key = m[1];
// 模板字面量里的 ${...} 插值对 vue-i18n 无意义,编译前替换为占位符避免假报错
const val = m[2] === '`' ? m[3].replace(/\$\{[^}]*\}/g, 'X') : m[3];
// 只编译含 vue-i18n 特殊字符的值(@ { |),其余不可能因语法报错
if (!/[@{|]/.test(val)) continue;
checked++;
const errs = compileMessage(val);
if (errs.length) violations.push({ key, value: val, message: errs[0].message });
}
return { checked, violations };
}
// 扫描目录下所有 .ts,返回 { checked, violations }(violations 带 file 字段)
function scanDirectory(dir) {
const violations = [];
let checked = 0;
let files;
try {
files = fs.readdirSync(dir);
} catch {
return { checked, violations };
}
for (const f of files) {
if (!f.endsWith('.ts')) continue;
const res = scanText(fs.readFileSync(path.join(dir, f), 'utf8'));
checked += res.checked;
for (const v of res.violations) violations.push({ file: f, ...v });
}
return { checked, violations };
}
// CLI 主流程:返回进程退出码(0 通过 / 1 失败)
function run() {
if (!baseCompile) {
console.warn('[i18n-check] 未找到 @intlify/message-compiler,跳过消息编译预检(需 npm 依赖)');
return 0;
}
let checked = 0;
let failed = 0;
for (const dir of DIRS) {
const res = scanDirectory(dir);
checked += res.checked;
for (const v of res.violations) {
failed++;
console.error(`✗ ${dir}/${v.file} :: ${v.key} => ${v.message}`);
console.error(` 值: ${JSON.stringify(v.value)}`);
console.error(` 提示: 字面 @ 须转义为 {'@'}(同理 | 与裸 {})`);
}
}
if (failed > 0) {
console.error(`[i18n-check] 失败 ${failed} 处(共编译 ${checked} 个含特殊字符的值)——prod 运行时会抛 Invalid linked format`);
return 1;
}
console.log(`[i18n-check] 通过(编译 ${checked} 个含特殊字符的值,0 失败)`);
return 0;
}
if (require.main === module) {
process.exit(run());
}
module.exports = { compileMessage, scanText, scanDirectory, run, KV_RE, DIRS };
+101
View File
@@ -0,0 +1,101 @@
// check-i18n.cjs 的门禁自测:用 node 内置测试跑,不引第三方依赖。
// node --test scripts/check-i18n.test.cjs
//
// 覆盖(对应 2026-10-10 对抗审查发现的假阴性 + 常规门禁行为):
// ① 单引号值含裸 @ → 报错
// ② 双引号值含裸 @ → 报错
// ③ 反引号模板字面量含裸 @ → 报错(原实现只匹配引号,漏报)
// ④ 含 ${} 插值不误报(模板字面量里的插值需先占位再编译)
// ⑤ 转义写法 {'@'} → 通过
// ⑥ 含 | 与裸 {} 的用例
// 另附 scanDirectory 对临时目录样本的端到端断言。
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { scanText, scanDirectory } = require('./check-i18n.cjs');
let compilerAvailable = true;
try {
require('@intlify/message-compiler');
} catch {
compilerAvailable = false;
}
const opts = compilerAvailable ? {} : { skip: '@intlify/message-compiler 不可用,跳过编译相关用例' };
// 构造一份 TS 源样本:每行一个 `key: '<literal>',`
const src = (...lines) => `export default {\n${lines.map((l) => ` ${l},`).join('\n')}\n}\n`;
test('①单引号值含裸 @ → 报错', opts, () => {
const r = scanText(src("a: 'hello @ world'"));
assert.equal(r.checked, 1);
assert.equal(r.violations.length, 1);
assert.equal(r.violations[0].key, 'a');
});
test('②双引号值含裸 @ → 报错', opts, () => {
const r = scanText(src('b: "hello @ world"'));
assert.equal(r.checked, 1);
assert.equal(r.violations.length, 1);
assert.equal(r.violations[0].key, 'b');
});
test('③反引号模板字面量含裸 @ → 报错(假阴性回归)', opts, () => {
const r = scanText(src('c: `hello @ world`'));
assert.equal(r.checked, 1);
assert.equal(r.violations.length, 1);
assert.equal(r.violations[0].key, 'c');
});
test('④-1 模板字面量含 ${} 插值(无特殊字符)不误报', opts, () => {
const r = scanText(src('d: `hello ${name}`'));
// 插值被占位后不含 @ { |,不计入 checked,也无违规
assert.equal(r.violations.length, 0);
});
test('④-2 插值位于花括号内不误报(占位替代的关键场景)', opts, () => {
// 原值 `total {${count}}` 直接编译会报 Invalid token in placeholder: '$',
// 必须先替换 ${...} 为占位符成为 `total {X}`(合法具名插值)才不误报。
const r = scanText(src('e: `total {${count}}`'));
assert.equal(r.violations.length, 0);
assert.equal(r.checked, 1);
});
test('⑤转义写法 {\'@\'} → 通过', opts, () => {
const r = scanText(src("f: \"{'@'}\""));
assert.equal(r.checked, 1);
assert.equal(r.violations.length, 0);
});
test('⑥-1 值含 | 会进入编译且正常通过', opts, () => {
const r = scanText(src("g: 'one | two'"));
assert.equal(r.checked, 1);
assert.equal(r.violations.length, 0);
});
test('⑥-2 值以 | 结尾(plural 缺省) → 报错', opts, () => {
const r = scanText(src("h: 'one |'"));
assert.equal(r.checked, 1);
assert.equal(r.violations.length, 1);
});
test('⑥-3 裸 { 未闭合 → 报错', opts, () => {
const r = scanText(src("i: 'x { y'"));
assert.equal(r.checked, 1);
assert.equal(r.violations.length, 1);
});
test('scanDirectory 扫临时目录样本:定位到违规文件', opts, () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'i18n-check-'));
try {
fs.writeFileSync(path.join(dir, 'ok.ts'), src("ok: 'plain text'"));
fs.writeFileSync(path.join(dir, 'bad.ts'), src('bad: `oops @ here`'));
const r = scanDirectory(dir);
assert.equal(r.violations.length, 1);
assert.equal(r.violations[0].file, 'bad.ts');
assert.equal(r.violations[0].key, 'bad');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
+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"
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# i18n 消息特殊字符门禁(机制优先):暂存区含 src/i18n/ 改动时,跑 vue-i18n 消息编译预检。
# 背景:字面 @ / | / 裸 {} 未转义只在 prod runtime 抛 "Invalid linked format",dev 宽松、
# vue-tsc 捕获不到,已两次导致生产回归(2026-06-15 白屏 / 2026-10-10 输入框消失)。
# 修法:字面量转义为 {'@'} 等;详见 scripts/check-i18n.cjs
git diff --cached --name-only | grep -q '^src/i18n/' || exit 0
command -v node >/dev/null 2>&1 || exit 0
node scripts/check-i18n.cjs || {
echo "✗ [i18n] 消息含未转义特殊字符(prod 运行时会报 Invalid linked format)"
echo " 字面 @ 改写为 {'@'}(同理 | 与裸 {});本机可先跑 node scripts/check-i18n.cjs"
exit 1
}
exit 0
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# 根目录卫生门禁(2026-10-10 根治):根目录只放项目元文件,**新增**根文件不在白名单即拒。
# 背景:日志/一次性脚本/临时产物反复堆到仓库根,已两次人工清理(2026-10-10)——治本靠门禁而非再清一遍。
# 约定:日志与临时产物入 temp/(已 gitignore);脚本入 scripts/;文档入 docs/。
added=$(git diff --cached --name-only --diff-filter=A)
[ -z "$added" ] && exit 0
bad=$(printf '%s\n' "$added" | awk -F/ 'NF==1' | grep -vE '^(ARCHITECTURE\.md|CLAUDE\.md|README\.md|Cargo\.toml|Cargo\.lock|bun\.lock|index\.html|package\.json|playwright\.config\.ts|tsconfig\.json|tsconfig\.node\.json|vite\.config\.ts|\.gitignore|\.gitattributes|\.dockerignore)$')
if [ -n "$bad" ]; then
echo "✗ [根目录卫生] 新增根文件不在白名单: $(printf '%s ' $bad)"
echo " 路径约定:日志/临时产物→temp/(已 gitignore);脚本→scripts/;文档→docs/;确属项目元文件才放根"
echo " 如确需新增根文件,请同步更新本规则白名单(scripts/hooks/rules/root-hygiene.sh)"
exit 1
fi
exit 0
+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"
+11 -1
View File
@@ -36,7 +36,17 @@ if ($Mode -eq "verify") {
Write-Host "== 通道2: 全量发版换装(tauri build -> 静默安装 -> 启动验证) ==" Write-Host "== 通道2: 全量发版换装(tauri build -> 静默安装 -> 启动验证) =="
Write-Host "[1/4] tauri build(可能 10 分钟级)..." Write-Host "[1/4] tauri build(可能 10 分钟级)..."
cmd /c "cargo tauri build 2>&1" # 本地换装不需要 updater 签名产物:未设私钥时用 --config 关掉 createUpdaterArtifacts,
# 否则 tauri build 必因「A public key has been found, but no private key」报错中断(实测多次),
# 安装段被跳过,只能手工补 swap-install。发版(需签名)走 publish-release.ps1。
$tauriArgs = "cargo tauri build"
if (-not $env:TAURI_SIGNING_PRIVATE_KEY) {
Write-Host " [本地换装模式] 未设 TAURI_SIGNING_PRIVATE_KEY -> 关闭 updater 产物(发版走 publish-release.ps1)"
$override = "$repo\temp\local-no-updater.json"
@{ bundle = @{ createUpdaterArtifacts = $false } } | ConvertTo-Json | Set-Content $override -Encoding utf8
$tauriArgs = "cargo tauri build --config `"$override`""
}
cmd /c "$tauriArgs 2>&1"
if ($LASTEXITCODE -ne 0) { Write-Host "[失败] tauri build"; exit 1 } if ($LASTEXITCODE -ne 0) { Write-Host "[失败] tauri build"; exit 1 }
$bundle = Get-ChildItem "$repo\target\release\bundle\nsis\*.exe" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 $bundle = Get-ChildItem "$repo\target\release\bundle\nsis\*.exe" | Sort-Object LastWriteTime -Descending | Select-Object -First 1
+131
View File
@@ -0,0 +1,131 @@
# 用途: DevFlow 应用内升级发布通道(B4/L3)
# 构建 updater 产物(.nsis.zip + .sig) -> 生成 latest.json -> 上传 app.1216.top
# 设计: docs/02-架构设计/专项设计/版本更新与自动升级-设计-2026-10-09.md §四 L3
#
# 用法:
# .\scripts\publish-release.ps1 # 用 git 最近提交作 notes
# .\scripts\publish-release.ps1 -Notes "修复xx" # 手动指定更新说明
# .\scripts\publish-release.ps1 -SkipBuild # 复用已有产物(仅生成清单+上传)
#
# 前置:
# 1. tauri.conf.json 与 Cargo.toml 版本号一致(脚本第 1 步校验,防 D1 复发)
# 2. 签名私钥就位: $env:TAURI_SIGNING_PRIVATE_KEY = "$env:USERPROFILE\.tauri\devflow.key"
# (无密码密钥无需 TAURI_SIGNING_PRIVATE_KEY_PASSWORD)
# 3. ssh-proxy 已配 suke-dev(39.99.243.191),web 根 /var/www/app-site/
param(
[string]$Notes = "",
[switch]$SkipBuild
)
$ErrorActionPreference = "Stop"
$repo = "E:\wk-lab\devflow"
$site = "suke-dev"
$remoteDir = "/var/www/app-site/download/devflow"
$baseUrl = "https://app.1216.top/download/devflow"
Set-Location $repo
# ── 1. 版本双源校验(防 D1 复发)──
Write-Host "[1/6] 校验版本双源一致..."
$confVer = (Get-Content "$repo\src-tauri\tauri.conf.json" -Raw | ConvertFrom-Json).version
$cargoVer = (Select-String -Path "$repo\src-tauri\Cargo.toml" -Pattern '^version\s*=\s*"([^"]+)"').Matches[0].Groups[1].Value
if ($confVer -ne $cargoVer) {
Write-Host "[失败] 版本不一致: tauri.conf.json=$confVer, Cargo.toml=$cargoVer"
Write-Host " 请先对齐两处版本号再发布(设计 D1:双源必须同值)"
exit 1
}
$version = $confVer
Write-Host " 版本 v$version 双源一致,通过"
# ── 2. 签名私钥就位检查 ──
if (-not $env:TAURI_SIGNING_PRIVATE_KEY) {
$keyPath = "$env:USERPROFILE\.tauri\devflow.key"
if (Test-Path $keyPath) {
$env:TAURI_SIGNING_PRIVATE_KEY = $keyPath
Write-Host "[2/6] 签名私钥: $keyPath"
} else {
Write-Host "[失败] 未找到签名私钥($keyPath),且未设 TAURI_SIGNING_PRIVATE_KEY"
Write-Host " 先执行: cargo tauri signer generate -w `$env:USERPROFILE\.tauri\devflow.key"
exit 1
}
} else {
Write-Host "[2/6] 签名私钥: 取自环境变量"
}
# ── 3. 构建 updater 产物 ──
$nsisDir = "$repo\target\release\bundle\nsis"
if (-not $SkipBuild) {
Write-Host "[3/6] tauri build(含 createUpdaterArtifacts,可能 10 分钟级)..."
cmd /c "cargo tauri build 2>&1"
if ($LASTEXITCODE -ne 0) { Write-Host "[失败] tauri build"; exit 1 }
} else {
Write-Host "[3/6] 跳过构建(-SkipBuild),复用已有产物"
}
# 定位产物:Tauri v2 = setup.exe + .exe.sig(更新包即安装器本身);v1 遗留 = .nsis.zip + .nsis.zip.sig
$setupExe = Get-ChildItem "$nsisDir\devflow_${version}_x64-setup.exe" -ErrorAction SilentlyContinue | Select-Object -First 1
$sigFile = Get-ChildItem "$nsisDir\devflow_${version}_x64-setup.exe.sig","$nsisDir\devflow_${version}_x64-setup.nsis.zip.sig" -ErrorAction SilentlyContinue | Select-Object -First 1
$zipFile = Get-ChildItem "$nsisDir\devflow_${version}_x64-setup.nsis.zip" -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $sigFile) { Write-Host "[失败] 未找到 .sig 签名文件(确认 createUpdaterArtifacts=true 且 TAURI_SIGNING_PRIVATE_KEY 就位)"; exit 1 }
# 与 .sig 配对的更新产物:v2 签名(.exe.sig)配 setup.exe;v1 签名(.nsis.zip.sig)配 .nsis.zip
$artifact = if ($sigFile.Name.EndsWith('.exe.sig')) { $setupExe } else { $zipFile }
if (-not $artifact) { Write-Host "[失败] 未找到与 $($sigFile.Name) 配对的更新产物"; exit 1 }
Write-Host " 产物: $($artifact.Name) ($([math]::Round($artifact.Length/1MB,1))MB)"
# ── 4. 生成 latest.json ──
Write-Host "[4/6] 生成 latest.json..."
$sig = (Get-Content $sigFile.FullName -Raw).Trim()
if (-not $Notes) {
# 用 git 最近一条提交作 notes(无 CHANGELOG 时的兜底)
$Notes = (git log -1 --pretty=format:"%s" 2>&1) -join "`n"
if (-not $Notes) { $Notes = "DevFlow v$version" }
}
$pubDate = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
# SHA256:本机 PowerShell 5.1 实测缺 Get-FileHash(同 swap-install 兜底口径),异常走 certutil
function Get-Sha256Lower($path) {
try { return (Get-FileHash -Path $path -Algorithm SHA256 -ErrorAction Stop).Hash.ToLower() }
catch { return (((certutil -hashfile $path SHA256) | Select-Object -Index 1) -replace '\s', '').ToLower() }
}
$sha256 = Get-Sha256Lower $artifact.FullName
$latestJson = [ordered]@{
version = $version
notes = $Notes
pub_date = $pubDate
platforms = [ordered]@{
"windows-x86_64" = [ordered]@{
signature = $sig
url = "$baseUrl/$($artifact.Name)"
}
}
} | ConvertTo-Json -Depth 5
$latestPath = "$repo\target\release\bundle\nsis\latest.json"
# ⚠️ 必须写「UTF-8 无 BOM」:Windows PowerShell 5.1 的 `Out-File -Encoding utf8` 会写 BOM
# (EF BB BF),部分 JSON 解析路径会报 "expected value at line 1 column 1",
# 表现为 updater 拿到清单即失败(签名/包都没错,只多了头 3 字节)。2026-10-10 实证修正。
[System.IO.File]::WriteAllText($latestPath, $latestJson, (New-Object System.Text.UTF8Encoding($false)))
Write-Host " latest.json: version=$version, sha256=$($sha256.Substring(0,16))..."
# ── 5. 上传到 app.1216.top ──
Write-Host "[5/6] 上传到 $site`:$remoteDir ..."
$env:MSYS_NO_PATHCONV = "1"
# 确保远程目录存在
ssh-proxy exec -n $site -c "mkdir -p $remoteDir"
if ($LASTEXITCODE -ne 0) { Write-Host "[失败] 创建远程目录"; exit 1 }
$uploads = @($latestPath, $sigFile.FullName, $artifact.FullName)
if ($setupExe -and $setupExe.FullName -ne $artifact.FullName) { $uploads += $setupExe.FullName }
foreach ($f in $uploads) {
$name = Split-Path $f -Leaf
Write-Host " 上传 $name ..."
ssh-proxy upload --name $site --src $f --dst "$remoteDir/$name"
if ($LASTEXITCODE -ne 0) { Write-Host "[失败] 上传 $name"; exit 1 }
}
# ── 6. 验证 ──
Write-Host "[6/6] 验证远端清单..."
$remoteCheck = ssh-proxy exec -n $site -c "ls -la $remoteDir && echo '---' && cat $remoteDir/latest.json | head -5"
Write-Host $remoteCheck
Write-Host ""
Write-Host "[发布完成] v$version"
Write-Host " 清单地址: $baseUrl/latest.json"
Write-Host " 应用内检查更新即可发现新版本(app.1216.top 已配 no-cache,无需刷 CDN)"
+28 -7
View File
@@ -1,15 +1,36 @@
# DevFlow 自宿主换装脚本 v3 # DevFlow 自宿主换装脚本 v3
# 流程: 等旧进程退出并探测句柄释放, 静默安装, 按构建产物哈希校验且失败重试一次, 交互用户可见重启, 结束自清理 # 流程: 等旧进程退出并探测句柄释放, 静默安装, 按构建产物哈希校验且失败重试一次, 交互用户可见重启, 结束自清理
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$installer = 'E:\wk-lab\devflow\target\release\bundle\nsis\devflow_0.1.0_x64-setup.exe' # 安装包路径:按构建产物 glob 探测(不硬编码版本号,版本升级时脚本零改动)。
# C3 决议:脚本消费构建产物,版本号唯一源头是 Cargo.toml → tauri.conf.json 注入文件名。
$nsisDir = 'E:\wk-lab\devflow\target\release\bundle\nsis'
$installer = $null
if (Test-Path $nsisDir) {
$installer = Get-ChildItem -Path $nsisDir -Filter 'devflow_*_x64-setup.exe' -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName
}
if (-not $installer) {
# 兜底:目录缺失或无匹配产物时,保留旧硬编码路径以维持既有失败提示语义
$installer = 'E:\wk-lab\devflow\target\release\bundle\nsis\devflow_0.1.1_x64-setup.exe'
}
$target = 'C:\Users\23780\AppData\Local\devflow\devflow.exe' $target = 'C:\Users\23780\AppData\Local\devflow\devflow.exe'
$builtExe = 'E:\wk-lab\devflow\target\release\devflow.exe' $builtExe = 'E:\wk-lab\devflow\target\release\devflow.exe'
$log = 'E:\wk-lab\devflow\swap-install.log' $log = 'E:\wk-lab\devflow\temp\swap-install.log'
$lockFile = 'E:\wk-lab\devflow\swap-install.lock' $lockFile = 'E:\wk-lab\devflow\temp\swap-install.lock'
# SHA256 取值:Get-FileHash 在部分环境的本机 Windows PowerShell 5.1 上不可用(实测 2026-10-10 缺失),
# 兜底走系统自带的 certutil。返回大写十六进制串。
function Get-Sha256($path) {
try { return (Get-FileHash -Path $path -Algorithm SHA256 -ErrorAction Stop).Hash }
catch { return (((certutil -hashfile $path SHA256) | Select-Object -Index 1) -replace '\s', '').ToUpper() }
}
# 构建产物哈希是唯一强基准; 产物缺失时退化为与安装前快照比对的弱校验 # 构建产物哈希是唯一强基准; 产物缺失时退化为与安装前快照比对的弱校验
# 实测(2026-10-10):装机 exe 与本基准哈希不一致(与 target/release/devflow.exe 同尺寸但字节不同,
# 疑 NSIS 打包前打 bundle-type 补丁所致)→ 强基准恒不命中,实际退化为「两次安装字节一致 + 与
# 安装前不同」的校验。该退化仍能拦住「installer exit=0 但 exe 未更新」(before==after)。
$hashWant = $null $hashWant = $null
if (Test-Path $builtExe) { $hashWant = (Get-FileHash $builtExe -Algorithm SHA256).Hash } if (Test-Path $builtExe) { $hashWant = Get-Sha256 $builtExe }
# 0. SYSTEM 上下文转投用户会话 # 0. SYSTEM 上下文转投用户会话
$who = whoami $who = whoami
@@ -69,7 +90,7 @@ function Invoke-SwapInstall {
"[$(Get-Date -Format 'HH:mm:ss')] FAIL: target missing after install" | Add-Content $log "[$(Get-Date -Format 'HH:mm:ss')] FAIL: target missing after install" | Add-Content $log
return $null return $null
} }
$afterHash = (Get-FileHash $target -Algorithm SHA256).Hash $afterHash = Get-Sha256 $target
$afterSize = (Get-Item $target).Length $afterSize = (Get-Item $target).Length
"[$(Get-Date -Format 'HH:mm:ss')] installed size=$afterSize hash=$($afterHash.Substring(0, 12))" | Add-Content $log "[$(Get-Date -Format 'HH:mm:ss')] installed size=$afterSize hash=$($afterHash.Substring(0, 12))" | Add-Content $log
return $afterHash return $afterHash
@@ -99,7 +120,7 @@ try {
# 3. 安装前快照, 供弱校验与前后比对 # 3. 安装前快照, 供弱校验与前后比对
$before = @{ hash = 'absent'; size = 0 } $before = @{ hash = 'absent'; size = 0 }
if (Test-Path $target) { if (Test-Path $target) {
$before.hash = (Get-FileHash $target -Algorithm SHA256).Hash $before.hash = Get-Sha256 $target
$before.size = (Get-Item $target).Length $before.size = (Get-Item $target).Length
} }
$beforeHashShort = $before.hash $beforeHashShort = $before.hash
@@ -168,7 +189,7 @@ try {
} }
# 仍无进程, 落标记等人工拉起 # 仍无进程, 落标记等人工拉起
Set-Content -Path 'E:\wk-lab\devflow\swap-need-start.flag' -Value (Get-Date -Format o) Set-Content -Path 'E:\wk-lab\devflow\temp\swap-need-start.flag' -Value (Get-Date -Format o)
"[$(Get-Date -Format 'HH:mm:ss')] FAIL: need manual start" | Add-Content $log "[$(Get-Date -Format 'HH:mm:ss')] FAIL: need manual start" | Add-Content $log
exit 1 exit 1
} }
+9 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "devflow" name = "devflow"
version = "0.1.0" version = "0.1.3"
description = "DevFlow Desktop App" description = "DevFlow Desktop App"
authors = [""] authors = [""]
edition = "2021" edition = "2021"
@@ -23,6 +23,8 @@ tauri-plugin-window-state = "2"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
# Phase3 单实例:二次启动唤起已有进程主窗口,防多实例抢 18765 端口 # Phase3 单实例:二次启动唤起已有进程主窗口,防多实例抢 18765 端口
tauri-plugin-single-instance = "2" tauri-plugin-single-instance = "2"
# B2 应用内升级:L1 接入 tauri-plugin-updater(内建 ed25519 签名校验)
tauri-plugin-updater = "2"
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
tokio.workspace = true tokio.workspace = true
@@ -80,6 +82,12 @@ futures-util = "0.3"
# 使用 rustls-tls(非 native-tls),避免 Windows SChannel 同步阻塞 tokio 工作线程 # 使用 rustls-tls(非 native-tls),避免 Windows SChannel 同步阻塞 tokio 工作线程
# 致 BUG-2026-07-17(aichat 流式调用永久 hang,外层 tokio timeout 亦无法推进计时器)。 # 致 BUG-2026-07-17(aichat 流式调用永久 hang,外层 tokio timeout 亦无法推进计时器)。
reqwest = { version = "0.12", default-features = false, features = ["json", "gzip", "brotli", "rustls-tls", "stream"] } reqwest = { version = "0.12", default-features = false, features = ["json", "gzip", "brotli", "rustls-tls", "stream"] }
# media_store(#958 引用化):hash 去重文件名(sha2+hex)+ 阿里云 OSS PUT 上传签名(hmac+sha1)
sha2 = "0.10"
hex = "0.4"
hmac = "0.12"
sha1 = "0.10"
httpdate = "1"
# fetch_url AI 工具:URL → markdown 文档嗅探(GET HTML → htmd 转 markdown → 去噪音 + 截断)。 # fetch_url AI 工具:URL → markdown 文档嗅探(GET HTML → htmd 转 markdown → 去噪音 + 截断)。
# 替代 http_request 拿原始 HTML(噪声大、爆 token)。turndown.js 移植,只读 GET,与 http_request 共享 SSRF 防护。 # 替代 http_request 拿原始 HTML(噪声大、爆 token)。turndown.js 移植,只读 GET,与 http_request 共享 SSRF 防护。
htmd = "0.5" htmd = "0.5"
+2 -1
View File
@@ -24,6 +24,7 @@
"core:webview:allow-create-webview-window", "core:webview:allow-create-webview-window",
"dialog:default", "dialog:default",
"window-state:default", "window-state:default",
"notification:default" "notification:default",
"updater:default"
] ]
} }
@@ -98,6 +98,7 @@ pub async fn cleanup_expired_approvals(app: &AppHandle, state: &AppState, conv_i
&tc_id, &tc_id,
"rejected", "rejected",
Some(format!("审批超时({}分钟)自动取消", timeout_minutes)), Some(format!("审批超时({}分钟)自动取消", timeout_minutes)),
None,
) )
.await; .await;
} }
@@ -6,7 +6,7 @@
//! //!
//! 流程(对齐 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式): //! 流程(对齐 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式):
//! ① 读 active 克隆(不改 status / 不扣 token)→ 后台喂 LLM 出摘要; //! ① 读 active 克隆(不改 status / 不扣 token)→ 后台喂 LLM 出摘要;
//! ② LLM 成功 → compress_old_messages(标 compressed + 扣 token)+ insert_at(摘要 system); //! ② LLM 成功 → compress_old_messages(标 compressed + 扣 token)+ push(摘要 system 尾部追加);
//! ③ LLM 失败 → 消息状态完全不变(未改 status / 未扣 token),降级走原 build_for_request 裁剪。 //! ③ LLM 失败 → 消息状态完全不变(未改 status / 未扣 token),降级走原 build_for_request 裁剪。
//! //!
//! 口径决策(任务让"你判断"):选**延迟 mutate(成功才改)**而非"失败回滚 status"。 //! 口径决策(任务让"你判断"):选**延迟 mutate(成功才改)**而非"失败回滚 status"。
@@ -30,16 +30,18 @@
//! 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_helpers::{extract_keyword_summary, CompressedSummary}; use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary};
use df_ai::provider::ChatMessage; use df_ai::provider::ChatMessage;
use df_storage::models::AiProviderRecord; use df_storage::models::AiProviderRecord;
use crate::state::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;
@@ -54,11 +56,37 @@ const PROTECT_COUNT: usize = 6;
/// 改进3 B 常量开关:压缩失败时是否启用关键词摘要兜底(默认 true)。 /// 改进3 B 常量开关:压缩失败时是否启用关键词摘要兜底(默认 true)。
/// ///
/// true(默认):LLM 压缩失败 → compress_old_messages 标 compressed(释放 token) /// true(默认):LLM 压缩失败 → compress_old_messages 标 compressed(释放 token)
/// + insert_at(0, system 关键词摘要),保留用户反复提及的主题词作续接锚点。 /// + push(system 关键词摘要)尾部追加,保留用户反复提及的主题词作续接锚点。
/// false(回退):回退原裸裁剪行为(不插关键词摘要,仅 build_for_request 裁剪保最近 6 条)。 /// false(回退):回退原裸裁剪行为(不插关键词摘要,仅 build_for_request 裁剪保最近 6 条)。
/// 排障/对比用:置 false 即可观察无兜底时的裁剪效果。 /// 排障/对比用:置 false 即可观察无兜底时的裁剪效果。
const KEYWORD_FALLBACK_ENABLED: bool = true; const KEYWORD_FALLBACK_ENABLED: bool = true;
/// 会话消息列表的内容版本哨兵(939⑪):首条消息的全局唯一 id(空列表 = None)。
///
/// 压缩**触发时**快照本值,后台 task **提交前**重新读取比对:相等才允许提交。
/// 首条消息 id 由 `new_message_id()` 全局唯一——loop 只 append(不改首条),而 `clear()`
/// 重建后首条必换新 id,故它是「上下文是否被整体替换」的准确且零成本哨兵。
/// 不用 len:loop 后台压缩期间正常 append 会使 len 增长,以 len 相等为守卫会误判版本
/// 不符致后台压缩永不生效。
fn head_id_of(messages: &mut ContextManager) -> Option<String> {
messages.messages_mut().first().and_then(|t| t.message.id.clone())
}
/// 内容版本是否仍匹配(939⑪):快照与当前首条 id 相等才允许提交。
fn version_matches(snapshot: &Option<String>, current: &Option<String>) -> bool {
snapshot == current
}
/// panic 兜底复位(939⑫):清 is_compressing 防其永真致压缩永久停摆。
///
/// conv 已删(per_conv 无条目)跳过——标志随 conv 销毁无意义(对齐各正常出口语义)。
async fn reset_compressing_after_panic(session_arc: &Arc<Mutex<AiSession>>, conv_id: &str) {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
session.conv(conv_id).messages.set_compressing(false);
}
}
/// 自动压缩(智能裁剪)——在 build_for_request 之前预处理。 /// 自动压缩(智能裁剪)——在 build_for_request 之前预处理。
/// ///
/// **L0-3(2026-08-09)后台化**:压缩 LLM 调用不再同步阻塞本轮 loop(非流式 LLM 最长 60s)。 /// **L0-3(2026-08-09)后台化**:压缩 LLM 调用不再同步阻塞本轮 loop(非流式 LLM 最长 60s)。
@@ -114,7 +142,7 @@ pub(super) async fn maybe_auto_compress(
emit_ai_chat_event(app_handle, AiChatEvent::AiCompressing { emit_ai_chat_event(app_handle, AiChatEvent::AiCompressing {
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
}); });
let (mut active_msgs, lang) = { let (mut active_msgs, lang, head_id) = {
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
let conv = session.conv(&conv_id); let conv = session.conv(&conv_id);
conv.messages.set_compressing(true); conv.messages.set_compressing(true);
@@ -123,6 +151,9 @@ pub(super) async fn maybe_auto_compress(
// 切片前按当时实际长度钳制,防 index out of bounds panic // 切片前按当时实际长度钳制,防 index out of bounds panic
// (后续 compress_old_messages 内部自带 min(len) 钳制,口径一致)。 // (后续 compress_old_messages 内部自带 min(len) 钳制,口径一致)。
let msgs = conv.messages.messages_mut(); let msgs = conv.messages.messages_mut();
// 939⑪ 内容版本哨兵:快照触发时首条消息 id,后台 task 提交前比对
// (clear() 重建后闭包内提交会落到新上下文,靠此守卫丢弃)。
let head_id = msgs.first().and_then(|t| t.message.id.clone());
let cut = protect_start.min(msgs.len()); let cut = protect_start.min(msgs.len());
let active_msgs: Vec<ChatMessage> = msgs[..cut] let active_msgs: Vec<ChatMessage> = msgs[..cut]
.iter() .iter()
@@ -131,7 +162,7 @@ pub(super) async fn maybe_auto_compress(
.collect(); .collect();
let lang = conv.agent_language.clone() let lang = conv.agent_language.clone()
.unwrap_or_else(|| "zh-CN".to_string()); .unwrap_or_else(|| "zh-CN".to_string());
(active_msgs, lang) (active_msgs, lang, head_id)
}; };
// T2-修复(方案A):展开 namespace 引用为真实内容,再喂压缩 LLM/关键词摘要。 // T2-修复(方案A):展开 namespace 引用为真实内容,再喂压缩 LLM/关键词摘要。
@@ -177,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),
@@ -203,7 +245,15 @@ pub(super) async fn maybe_auto_compress(
let conv_id = conv_id.to_string(); let conv_id = conv_id.to_string();
let provider_config = provider_config.clone(); let provider_config = provider_config.clone();
let llm_concurrency = llm_concurrency.clone(); let llm_concurrency = llm_concurrency.clone();
// 939⑫ panic 兜底句柄:内层 async move 会消费 session_arc/conv_id,panic 分支
// 需另持一份用于复位 is_compressing(防其永真致压缩永久停摆)。
let panic_session = session_arc.clone();
let panic_conv_id = conv_id.clone();
tokio::spawn(async move { tokio::spawn(async move {
// 939⑫ 整个后台 body 包 catch_unwind(复用 mod.rs::unwind_to_result 薄包装):
// panic 以 Err 返回 → 复位 is_compressing + warn,防压缩永久停摆。依赖
// panic=unwind(tokio 默认);panic=abort 构建下退化为进程终止(尽力兜底)。
let outcome = super::unwind_to_result(async move {
// 后台重建 provider(secret::build_provider_for 三步打包:resolve→ensure→build)。 // 后台重建 provider(secret::build_provider_for 三步打包:resolve→ensure→build)。
// 失败 → 同 LLM 失败降级:复位 is_compressing + 关键词摘要兜底(标 compressed 释放 // 失败 → 同 LLM 失败降级:复位 is_compressing + 关键词摘要兜底(标 compressed 释放
// token + 插关键词),保持「失败→兜底→扣 token」语义——否则 history_tokens 未降, // token + 插关键词),保持「失败→兜底→扣 token」语义——否则 history_tokens 未降,
@@ -218,22 +268,29 @@ pub(super) async fn maybe_auto_compress(
"[ai] 后台压缩 provider 重建失败,降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})", "[ai] 后台压缩 provider 重建失败,降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
KEYWORD_FALLBACK_ENABLED, KEYWORD_FALLBACK_ENABLED,
); );
// 单次锁内 check+mutate:复位 is_compressing(防 conv 两次锁间被删惰性重建)。 // 复位 is_compressing 收口进 keyword_fallback_commit 单锁(909①-补:
{ // 原先此处单独取锁复位,与提交之间留并发二次压缩窗口)。
let mut session = session_arc.lock().await; let degraded = keyword_fallback_commit(
if session.per_conv.contains_key(&conv_id) {
session.conv(&conv_id).messages.set_compressing(false);
}
}
keyword_fallback_commit(
&session_arc, &session_arc,
&conv_id, &conv_id,
protect_start, protect_start,
&keyword_fallback, &keyword_fallback,
pre_compress_tokens, pre_compress_tokens,
"压缩 provider 重建失败", "压缩 provider 重建失败",
&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;
} }
}; };
@@ -252,7 +309,7 @@ pub(super) async fn maybe_auto_compress(
match compress_outcome { match compress_outcome {
Ok(Some(summary)) => { Ok(Some(summary)) => {
// LLM 成功 → 标 compressed(扣 token)+ 摘要 system 插首位 + set_compressing(false)。 // LLM 成功 → 标 compressed(扣 token)+ 摘要 system 尾部追加 + set_compressing(false)。
// conv 已删(per_conv 无条目)时丢弃压缩结果——惰性重建会污染新 conv, // conv 已删(per_conv 无条目)时丢弃压缩结果——惰性重建会污染新 conv,
// is_compressing 标志随 conv 销毁无意义,直接 return。 // is_compressing 标志随 conv 销毁无意义,直接 return。
{ {
@@ -261,9 +318,31 @@ pub(super) async fn maybe_auto_compress(
return; return;
} }
let conv = session.conv(&conv_id); let conv = session.conv(&conv_id);
// 939⑪ 内容版本守卫:提交前比对触发时快照,不符(期间 clear() 重建致
// 上下文整体替换)→ 丢弃压缩结果。不复位 is_compressing(clear 已复位;
// 若新会话已启新压缩,此处复位会误伤新压缩信令)。
if !version_matches(&head_id, &head_id_of(&mut conv.messages)) {
tracing::warn!(
conv_id = %conv_id,
"[ai] 压缩完成但上下文版本已变(疑似 clear 重建),丢弃旧摘要"
);
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);
// NL 摘要插首位 + JSON 卡片回填 WorkingContext,决策随压缩重锚定 // 召回句柄拼在摘要尾部(存档落盘成功才有):LLM 需要原文时 read_file
conv.messages.insert_at(0, ChatMessage::system(&summary.nl_summary)); // 回读;句柄行只进 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) 头部插入):头部插入会位移整条
// 前缀字节,使 DeepSeek 等自动前缀缓存的 provider 全部缓存失效
// (命中率掉到个位数)。尾部追加紧邻本轮内容 LLM 仍可见,前缀稳定
// 命中率最大化。JSON 卡片回填 WorkingContext,决策随压缩重锚定。
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);
} }
@@ -271,7 +350,7 @@ pub(super) async fn maybe_auto_compress(
conv_id = %conv_id, conv_id = %conv_id,
iteration, iteration,
pre_tokens = pre_compress_tokens, pre_tokens = pre_compress_tokens,
"[ai] 自动压缩成功(后台),摘要已插首位" "[ai] 自动压缩成功(后台),摘要已尾部追加"
); );
// 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体), // 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体),
// 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。 // 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。
@@ -286,29 +365,28 @@ pub(super) async fn maybe_auto_compress(
// 单次锁内 check+mutate(防 conv 在两次锁间被删致惰性重建污染新 conv)。 // 单次锁内 check+mutate(防 conv 在两次锁间被删致惰性重建污染新 conv)。
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) {
session.conv(&conv_id).messages.set_compressing(false); let conv = session.conv(&conv_id);
// 939⑪ 版本守卫:上下文已被整体替换 → 不复位(否则误伤新会话信令)。
if version_matches(&head_id, &head_id_of(&mut conv.messages)) {
conv.messages.set_compressing(false);
}
} }
} }
Err(e) => { Err(e) => {
// LLM 失败 → 改进3 B:仍标 compressed 释放 token + 关键词摘要塞回首条(非裸裁剪)。 // LLM 失败 → 改进3 B:仍标 compressed 释放 token + 关键词摘要尾部追加(非裸裁剪)。
// //
// 旧行为:消息状态完全不变,降级走 build_for_request 裁剪(丢主题)。 // 旧行为:消息状态完全不变,降级走 build_for_request 裁剪(丢主题)。
// 新行为(KEYWORD_FALLBACK_ENABLED=true 默认): // 新行为(KEYWORD_FALLBACK_ENABLED=true 默认):
// - compress_old_messages 标 [..protect_start] active 为 compressed(释放 token, // - compress_old_messages 标 [..protect_start] active 为 compressed(释放 token,
// 与成功路径一致,后续 build_for_request 不再把它们进 LLM 上下文); // 与成功路径一致,后续 build_for_request 不再把它们进 LLM 上下文);
// - keyword_fallback 非空 → insert_at(0, system 关键词摘要)作续接锚点; // - keyword_fallback 非空 → push(system 关键词摘要)尾部追加作续接锚点;
// - keyword_fallback 空(无 user 消息/无可提取词)→ 不插,等价旧裁剪(保底)。 // - keyword_fallback 空(无 user 消息/无可提取词)→ 不插,等价旧裁剪(保底)。
// 持久化语义不变:compressed 仍软删可追溯(DB 全量保留),与成功路径一致。 // 持久化语义不变:compressed 仍软删可追溯(DB 全量保留),与成功路径一致。
// 压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false) // 压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false)
// 误判生成结束(后端 loop 实际继续),致"压缩后停止"。降级为 warn + 兜底释放 // 误判生成结束(后端 loop 实际继续),致"压缩后停止"。降级为 warn + 兜底释放
// token + build_for_request 裁剪,loop 继续 stream_llm,用户自然看到后续回复。 // token + build_for_request 裁剪,loop 继续 stream_llm,用户自然看到后续回复。
// 单次锁内 check+mutate(防 conv 在两次锁间被删致惰性重建污染新 conv)。 // 复位 is_compressing 收口进 keyword_fallback_commit 单锁(909①-补,
{ // 同 provider 重建失败路径;消锁外复位→再取锁提交的并发窗口)。
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(&conv_id) {
session.conv(&conv_id).messages.set_compressing(false);
}
}
tracing::warn!( tracing::warn!(
conv_id = %conv_id, conv_id = %conv_id,
error = %e, error = %e,
@@ -316,17 +394,40 @@ 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,
&keyword_fallback, &keyword_fallback,
pre_compress_tokens, pre_compress_tokens,
"压缩失败", "压缩失败",
&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
.await;
if let Err(msg) = outcome {
reset_compressing_after_panic(&panic_session, &panic_conv_id).await;
tracing::warn!(
conv_id = %panic_conv_id,
error = %msg,
"[ai] 后台压缩 task panic,已复位 is_compressing(防压缩永久停摆)"
);
}
}); });
} }
} }
@@ -336,12 +437,25 @@ pub(super) async fn maybe_auto_compress(
/// 关键词兜底提交单点(DRY #8 收敛,2026-10-01)。 /// 关键词兜底提交单点(DRY #8 收敛,2026-10-01)。
/// ///
/// 抽自 [`maybe_auto_compress`] 后台 task 内的逐字双胞胎块:压缩 provider 重建失败路径 /// 抽自 [`maybe_auto_compress`] 后台 task 内的逐字双胞胎块:压缩 provider 重建失败路径
/// 与压缩 LLM 失败路径共用「KEYWORD_FALLBACK_ENABLED 门控 → 锁内 check+mutate /// 与压缩 LLM 失败路径共用「复位 is_compressing → KEYWORD_FALLBACK_ENABLED 门控 → 锁内
/// (标 compressed 释放 token + 关键词摘要插首位,非空才插)→ 成功插入打 info」。 /// check+mutate(标 compressed 释放 token + 关键词摘要尾部追加,非空才插)→ 成功插入打 info」。
/// 两条失败路径只差日志正文,经 `log_head` 参数化(拼为 /// 两条失败路径只差日志正文,经 `log_head` 参数化(拼为
/// "[ai] {log_head}兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)")。 /// "[ai] {log_head}兜底(后台):关键词摘要已尾部追加(compressed 标记已扣 token)")。
/// ///
/// conv 已删(per_conv 无条目)→ 直接返回不兜底(对齐原 return 语义:防惰性重建污染新 conv)。 /// **复位重入标志收口单锁(909①-补,2026-10-10)**:`set_compressing(false)` 与兜底提交在同一
/// 次锁内原子完成——原调用方在锁外单独取锁复位,与这里的提交之间留出窗口(期间的压缩触发读到
/// 的仍是 false,可并发二次压缩,放大楔入概率)。注意复位在 `KEYWORD_FALLBACK_ENABLED` 门控
/// **之前**:开关关闭时同样必须复位,否则 is_compressing 永真 → 压缩永久停摆。
///
/// conv 已删(per_conv 无条目)→ 直接返回(标志随 conv 销毁无需复位;对齐原 return 语义:
/// 防惰性重建污染新 conv)。
///
/// **内容版本守卫(939⑪)**:`head_id` 为触发时快照的首条消息 id;提交前比对不符(期间
/// `clear()` 重建致上下文整体替换)→ 直接返回:既不标 compressed / 不插旧关键词摘要污染
/// 新上下文,也不复位 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,
@@ -349,30 +463,178 @@ async fn keyword_fallback_commit(
keyword_fallback: &str, keyword_fallback: &str,
pre_tokens: u32, pre_tokens: u32,
log_head: &str, log_head: &str,
) { head_id: &Option<String>,
if !KEYWORD_FALLBACK_ENABLED { recall: Option<&Path>,
return; ) -> 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⑪ 内容版本守卫:上下文已被整体替换 → 丢弃兜底提交(标志亦不复位)。
if !version_matches(head_id, &head_id_of(&mut conv.messages)) {
tracing::warn!(
conv_id = %conv_id,
"[ai] {}兜底提交前上下文版本已变(疑似 clear 重建),丢弃",
log_head
);
return false;
}
// 复位与提交同锁原子(909①-补):消「锁外复位→再取锁提交」之间的并发二次压缩窗口。
conv.messages.set_compressing(false);
if !KEYWORD_FALLBACK_ENABLED {
// 开关关闭 → 回退原裸裁剪行为:不压缩不插摘要,仅完成复位。
false
} 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 {
conv.messages.insert_at(0, ChatMessage::system(keyword_fallback)); // 召回句柄拼在摘要尾部(存档落盘成功才有),只进 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) 头部插入):头部插入位移整条前缀字节,
// 使 DeepSeek 等自动前缀缓存 provider 全部缓存失效(命中率掉到个位数)。
// 尾部追加紧邻本轮内容 LLM 仍可见,前缀稳定命中率最大化。
conv.messages.push_system_anchor(ChatMessage::system(&anchor_text));
true true
} }
}
}; };
if inserted { if inserted {
tracing::info!( tracing::info!(
conv_id = %conv_id, conv_id = %conv_id,
pre_tokens, pre_tokens,
"[ai] {}兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)", "[ai] {}兜底(后台):关键词摘要已尾部追加(compressed 标记已扣 token)",
log_head log_head
); );
} }
inserted
}
#[cfg(test)]
mod tests {
use super::*;
/// 建 conv 并灌 n 条 active 消息,置 is_compressing(true)(模拟触发时已置位)。
/// 返回触发时的内容版本哨兵(首条消息 id),供 keyword_fallback_commit 的版本守卫比对。
async fn seed_compressing(arc: &Arc<Mutex<AiSession>>, conv_id: &str, n: usize) -> Option<String> {
let mut session = arc.lock().await;
let conv = session.conv(conv_id);
for i in 0..n {
conv.messages.push(if i % 2 == 0 {
ChatMessage::user(format!("用户消息{i}"))
} else {
ChatMessage::assistant(format!("助手回复{i}"))
});
}
conv.messages.set_compressing(true);
head_id_of(&mut conv.messages)
}
/// 非空关键词:复位 is_compressing 与兜底提交(标 compressed + 尾部插锚点)一并生效。
#[tokio::test]
async fn keyword_fallback_commit_resets_and_inserts() {
let arc = Arc::new(Mutex::new(AiSession::new()));
let head = seed_compressing(&arc, "c1", 4).await;
keyword_fallback_commit(&arc, "c1", 4, "关键词锚点", 0, "压缩失败", &head, None).await;
let session = arc.lock().await;
let conv = session.conv_read("c1").expect("conv 应存在");
assert!(!conv.messages.is_compressing(), "提交后 is_compressing 应复位 false");
let msgs = conv.messages.all_messages_clone();
assert_eq!(msgs.len(), 5, "压缩后应尾部追加 1 条关键词 system 锚点");
for (i, m) in msgs[..4].iter().enumerate() {
assert!(!m.is_active(), "第 {i} 条原消息应被标 compressed(释放 token)");
}
assert_eq!(msgs[4].content, "关键词锚点", "末条应为关键词锚点正文");
assert!(
matches!(msgs[4].role, df_ai::provider::MessageRole::System),
"锚点应为 system 角色"
);
}
/// 空关键词:复位不能丢(否则 is_compressing 永真 → 压缩永久停摆),但不插锚点。
#[tokio::test]
async fn keyword_fallback_commit_empty_keeps_reset() {
let arc = Arc::new(Mutex::new(AiSession::new()));
let head = seed_compressing(&arc, "c2", 4).await;
keyword_fallback_commit(&arc, "c2", 4, "", 0, "压缩 provider 重建失败", &head, None).await;
let session = arc.lock().await;
let conv = session.conv_read("c2").expect("conv 应存在");
assert!(!conv.messages.is_compressing(), "关键词为空仍应复位 is_compressing");
assert_eq!(conv.messages.len(), 4, "关键词为空不应插锚点,len 不变");
for m in conv.messages.all_messages_clone() {
assert!(!m.is_active(), "空关键词路径仍应标 compressed 扣 token");
}
assert_eq!(conv.messages.history_tokens(), 0, "无锚点插入,token 应清零");
}
/// conv 已删(per_conv 无条目):早返不惰性重建(防污染新 conv)。
#[tokio::test]
async fn keyword_fallback_commit_missing_conv_noop() {
let arc = Arc::new(Mutex::new(AiSession::new()));
keyword_fallback_commit(&arc, "gone", 4, "锚点", 0, "压缩失败", &None, None).await;
let session = arc.lock().await;
assert!(
session.per_conv.is_empty(),
"conv 已删不应惰性重建 per_conv 条目"
);
}
}
// 939⑪⑫ 后台压缩守卫契约:纯函数级断言(版本不符→丢弃 / panic→标志复位)。
#[cfg(test)]
mod guard_tests {
use super::{head_id_of, reset_compressing_after_panic, version_matches, AiSession};
use df_ai::context::{ContextConfig, ContextManager};
use std::sync::Arc;
use tokio::sync::Mutex;
// 939⑪:内容版本不符(首条 id 变化 / 列表被清空 / 空→非空)即判定丢弃。
#[test]
fn version_guard_discards_on_head_change() {
let a = Some("msg_a".to_string());
let b = Some("msg_b".to_string());
assert!(version_matches(&a, &a), "同版本必须放行");
assert!(!version_matches(&a, &b), "首条 id 变(clear 重建)必须丢弃");
assert!(!version_matches(&a, &None), "列表被清空必须丢弃");
assert!(!version_matches(&None, &a), "空→非空(新会话)必须丢弃");
}
// 939⑪:空列表的内容版本哨兵为 None。
#[test]
fn head_id_of_empty_list_is_none() {
let mut mgr = ContextManager::new(ContextConfig::default());
assert_eq!(head_id_of(&mut mgr), None);
}
// 939⑫:后台 body panic → is_compressing 被复位,压缩不停摆。
#[tokio::test]
async fn panic_resets_compressing_flag() {
let session = Arc::new(Mutex::new(AiSession::new()));
let conv_id = "conv-panic";
session.lock().await.conv(conv_id).messages.set_compressing(true);
// 模拟后台 body panic,经 catch_unwind 薄包装转受控 Err
let r = super::super::unwind_to_result(async { panic!("boom in compress task") }).await;
assert!(r.is_err(), "panic 应被捕获为 Err");
// panic 分支:复位 is_compressing
reset_compressing_after_panic(&session, conv_id).await;
assert!(
!session.lock().await.conv_read(conv_id).unwrap().messages.is_compressing(),
"panic 后 is_compressing 必须复位,否则压缩永久停摆"
);
}
} }
@@ -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>()

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