Compare commits

..
29 Commits
Author SHA1 Message Date
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
219 changed files with 16151 additions and 5024 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
npm-debug.log*
# 换装脚本(scripts/swap-install.ps1)运行期锁文件
/swap-install.lock
# Dependencies
node_modules/
@@ -32,6 +35,10 @@ target-musl/ # df-relay 交叉编译产物隔离目录(见 .cargo/config.toml)
src-tauri/target/
src-tauri/Cargo.lock
# 签名私钥(应用内升级,设计 §七 高风险项:私钥绝不入仓)
*.key
*.key.pub
# Tauri 自动生成的 schema(每次构建变,可重建)
src-tauri/gen/schemas/
@@ -100,3 +107,7 @@ temp/
# df-im-gateway 运行时数据(冒烟测试残留)
crates/df-im-gateway/data/
# Playwright 运行产物
test-results/
playwright-report/
+5
View File
@@ -16,5 +16,10 @@
- 边界对齐意志卡 forbidden:发版/部署/重启线上、git push、生产数据操作、对外发消息,攒齐等拍板。
- 本机工具/脚本的行为变更属 report_after 级(干完通报、可回滚),不属确认级。
## 命名与代码风格(2026-10-10 用户重申)
- **变量/方法名:精简 + 准确**——一眼看出「是什么/做什么」且不超必要长度。忌把实现细节写进名字(`..._after_tool_run`)、忌无信息缩写(`tmp2`/`h`)、忌同概念多叫法。名字过长通常说明该拆函数或把说明下沉到注释(注释承载「为什么」,名字只承载「是什么」)。
- **DRY · 简洁 · 扩展性 · 高内聚低耦合**(同期重申):新增能力先找共用底座(如定时消息与定时任务共用一张 `ai_outbox` + 一个调度器),不造双轨;同一事实只留**单一真值来源**(如 token 估值口径)。
## 其他规范
- 架构设计文档在 `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"
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]]
name = "asn1-rs"
version = "0.7.2"
@@ -996,6 +1005,17 @@ dependencies = [
"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]]
name = "derive_more"
version = "2.1.1"
@@ -1019,7 +1039,7 @@ dependencies = [
[[package]]
name = "devflow"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"anyhow",
"async-trait",
@@ -1039,7 +1059,10 @@ dependencies = [
"df-workflow",
"futures",
"futures-util",
"hex",
"hmac",
"htmd",
"httpdate",
"keyring",
"larksuite-oapi-sdk-rs",
"percent-encoding",
@@ -1047,6 +1070,8 @@ dependencies = [
"reqwest 0.12.28",
"serde",
"serde_json",
"sha1 0.10.6",
"sha2 0.10.9",
"sys-locale",
"tauri",
"tauri-build",
@@ -1054,6 +1079,7 @@ dependencies = [
"tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tauri-plugin-window-state",
"tokio",
"tokio-tungstenite 0.23.1",
@@ -1076,6 +1102,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"base64 0.22.1",
"bytes",
"df-ai-core",
"df-types",
@@ -1278,6 +1305,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"crypto-common 0.1.7",
"subtle",
]
[[package]]
@@ -1571,6 +1599,16 @@ dependencies = [
"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]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -2142,6 +2180,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "htmd"
version = "0.5.5"
@@ -2572,6 +2619,36 @@ dependencies = [
"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]]
name = "jni-sys"
version = "0.3.1"
@@ -2896,6 +2973,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -3277,6 +3360,18 @@ dependencies = [
"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]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -3420,6 +3515,20 @@ dependencies = [
"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]]
name = "pango"
version = "0.18.3"
@@ -4059,15 +4168,20 @@ dependencies = [
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -4213,6 +4327,33 @@ dependencies = [
"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]]
name = "rustls-webpki"
version = "0.103.13"
@@ -4661,6 +4802,22 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "siphasher"
version = "1.0.3"
@@ -4830,6 +4987,17 @@ dependencies = [
"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]]
name = "sync_wrapper"
version = "1.0.2"
@@ -4911,7 +5079,7 @@ dependencies = [
"gdkwayland-sys",
"gdkx11-sys",
"gtk",
"jni",
"jni 0.21.1",
"libc",
"log",
"ndk",
@@ -4944,6 +5112,17 @@ dependencies = [
"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]]
name = "target-lexicon"
version = "0.12.16"
@@ -4968,7 +5147,7 @@ dependencies = [
"heck 0.5.0",
"http",
"http-range",
"jni",
"jni 0.21.1",
"libc",
"log",
"mime",
@@ -5179,6 +5358,39 @@ dependencies = [
"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]]
name = "tauri-plugin-window-state"
version = "2.4.1"
@@ -5204,7 +5416,7 @@ dependencies = [
"dpi",
"gtk",
"http",
"jni",
"jni 0.21.1",
"objc2",
"objc2-ui-kit",
"objc2-web-kit",
@@ -5227,7 +5439,7 @@ checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9"
dependencies = [
"gtk",
"http",
"jni",
"jni 0.21.1",
"log",
"objc2",
"objc2-app-kit",
@@ -6376,6 +6588,15 @@ dependencies = [
"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]]
name = "webpki-roots"
version = "1.0.8"
@@ -7069,7 +7290,7 @@ dependencies = [
"gtk",
"http",
"javascriptcore-rs",
"jni",
"jni 0.21.1",
"libc",
"ndk",
"objc2",
@@ -7133,6 +7354,16 @@ dependencies = [
"time",
]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "xdg-home"
version = "1.3.0"
@@ -7379,6 +7610,18 @@ dependencies = [
"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]]
name = "zmij"
version = "1.0.21"
+6 -3
View File
@@ -28,6 +28,9 @@ keyring = { version = "3", features = ["windows-native", "apple-native", "linux-
dashmap = "6"
# release 增量编译:小改动重打包显著提速(2026-10-04 打包提速项);
# 代价为产物略大,正式发版如在意体积可临时注释此段
[profile.release]
incremental = true
# ⚠️ 2026-10-09 回退:release + incremental 触发链接期匿名符号丢失
# (anon.*.llvm.* undefined symbol,rust-lld 与 MSVC link.exe 均复现),
# 根因是增量编译 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 {
if (streamTimer) clearTimeout(streamTimer)
streamTimer = null
// 复位节流时间戳:resetWatchdog 的 5s 节流假定「已有 timer 在跑」,而此处刚清空 timer。
// 不复位则「停止/报错后 5s 内重发」会因节流跳过 arm → 新一轮无看门狗(中途断则 generating 永卡)。
lastWatchdogReset = 0
}
/** 已注册事件监听标志(防多次订阅 wsClient) */
@@ -1374,6 +1377,7 @@ function newConversation(): void {
tokenUsage.value = null // 新会话清 token(对齐 switchConversation)
activeModel.value = null // 新会话复位模型选择(新会话无 per_conv 模型,走默认路由)
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 { 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,
// inline 进页面后 chat/index.json usingComponents 空,工具无需解析 components/,问题消失。
// MdView/MentionInput 源保留(src/components/ 下,工具 bug 修复后可拆回)。
@@ -512,11 +515,11 @@ function onMentionClose(): void {
* 保留原有守卫:空文本忽略 / generating 中忽略 / 关闭联想浮层。
*/
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 hasImage = pendingImages.value.length > 0
if (!text && !hasImage) {
console.log('[v2:send] 空文本且无图片,忽略')
if (DEBUG) console.log('[v2:send] 空文本且无图片,忽略')
return
}
if (generating.value) {
@@ -12,11 +12,15 @@ const ai = useAiChat()
*/
const conversations = ref<Conversation[]>([])
/**
* 监听 ai.conversations.length 变化(比 deep watch 更可靠)。
* 监听 ai.conversations 整体替换(比 deep watch 更可靠,也比只盯 length 完整)。
* 用 `[...ai.conversations]` 创建普通数组赋值给 ref,确保每次变化都触发模板更新。
*
* 为什么监听 ref 而非 length:device 回推 AiConversationList 是「整体替换」且条数常不变
* (改名后条数不变),只盯 length 时 watch 回调不触发 → 本地副本滞留旧标题。
* 监听 ref 则在赋值(含同长度替换)时即触发刷新。
*/
watch(() => ai.conversations.value.length, () => {
conversations.value = [...ai.conversations.value]
watch(ai.conversations, (list) => {
conversations.value = [...(list || [])]
}, { immediate: true })
/** 点击会话项切换 */
+6 -6
View File
@@ -42,25 +42,25 @@ impl ContentPart {
impl ChatMessage {
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 {
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 {
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 {
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 {
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 片)。
/// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。
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 判定走多模态分支)。
+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`/
@@ -85,6 +101,11 @@ pub struct ChatMessage {
pub id: Option<String>,
pub role: MessageRole,
pub content: String,
/// 消息来源(#958 溯源):消息从哪个渠道/端点进入会话。None=桌面本端(既有语义,
/// 老数据反序列化为 None 零回归);Some=外部渠道(飞书 message_id 可跳原消息追溯,
/// 多端混发可区分来源端)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<MessageSource>,
/// 多模态内容片。
///
/// `None`/空 → 纯文本消息(绝大多数场景,content 即全部载荷)。
+1
View File
@@ -12,6 +12,7 @@ tokio = { workspace = true, features = ["sync", "time"] }
async-trait = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
base64 = "0.22"
# HTTP + 流式
reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"] }
+18
View File
@@ -136,6 +136,24 @@
"context_window": 262144,
"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",
"enabled": true,
+59 -1
View File
@@ -288,9 +288,11 @@ impl AnthropicCompatProvider {
/// 单个 ContentPart → Anthropic content block(text/image)。
/// - Text 片 → {type:text, text}
/// - 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。
fn content_part_to_block(p: crate::provider::ContentPart) -> serde_json::Value {
let p = crate::image_ref::inline_local_image_part(p);
match p {
crate::provider::ContentPart::Text { text } => serde_json::json!({
"type": "text",
@@ -427,6 +429,22 @@ impl AnthropicCompatProvider {
.and_then(|m| m.get("role").and_then(|r| r.as_str()))
.unwrap_or("");
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;
}
// 空 Vec(异常会话经 sanitize 清空)或首条非 user → 补 user 占位:
@@ -1163,6 +1181,46 @@ mod tests {
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 仍是字符串简写(无图不数组化)
#[test]
fn anthropic_convert_text_only_user_remains_string() {
+60 -22
View File
@@ -1045,9 +1045,12 @@ fn phase1_append_after_dirty_stays_dirty_until_save() {
// ============================================================
#[test]
fn eviction_inserts_summary_message_at_view_head() {
// 触发淘汰 → 视图头部应有一条 "[早期对话概要]" user 摘要(补偿无声丢弃的早期主题),
fn eviction_inserts_summary_message_at_view_tail() {
// 触发淘汰 → 视图尾部应有一条 "[早期对话概要]" user 摘要(补偿无声丢弃的早期主题),
// 概要 ≤ EVICTION_SUMMARY_MAX_CHARS,保护消息完好,且纯视图(不污染内存全量/不落库)。
// 缓存友好(2026-10):摘要改尾部追加(push)而非头部插入(insert(0))——头部插入位移
// 整条前缀字节,使 DeepSeek 等自动前缀缓存 provider 全部缓存失效(命中率掉到个位数)。
// 尾部追加让稳定前缀(system + 早期 history)逐轮不变,命中率最大化。
let mut mgr = ContextManager::new(cfg(200));
for i in 0..20 {
if i % 2 == 0 {
@@ -1063,46 +1066,47 @@ fn eviction_inserts_summary_message_at_view_head() {
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(trimmed, "超预算应触发裁剪");
// 视图头部 = 补偿摘要(role=user,"[早期对话概要]" 前缀 + 高频主题)
let head = msgs.first().expect("视图不应为空");
// 视图尾部 = 补偿摘要(role=user,"[早期对话概要]" 前缀 + 高频主题)
let tail = msgs.last().expect("视图不应为空");
assert!(
matches!(head.role, MessageRole::User),
"视图头部应为 user 摘要, 实际 {:?}",
head.role
matches!(tail.role, MessageRole::User),
"视图尾部应为 user 摘要, 实际 {:?}",
tail.role
);
assert!(
head.content.starts_with("[早期对话概要] 高频主题: "),
tail.content.starts_with("[早期对话概要] 高频主题: "),
"摘要格式不符, 实际 {}",
head.content
tail.content
);
assert!(
head.content.contains("审批"),
tail.content.contains("审批"),
"摘要应含被淘汰区高频主题词'审批', 实际 {}",
head.content
tail.content
);
assert!(
head.content.chars().count() <= EVICTION_SUMMARY_MAX_CHARS,
tail.content.chars().count() <= EVICTION_SUMMARY_MAX_CHARS,
"摘要应 ≤ {} 字符, 实际 {}",
EVICTION_SUMMARY_MAX_CHARS,
head.content.chars().count()
tail.content.chars().count()
);
// 保护消息完好:末条(i=19 assistant)保留;摘要 user 与首轮真实 user 相邻,由
// sanitize 的 ensure_sequence_legal 合并(摘要在前,换行接原文),故 msgs[1] 为 i=1 的 assistant
// 保护消息完好:末条(i=19 assistant)保留;摘要 user 追加在尾部,与末尾 assistant
// 不同 role 不合并,故 msgs[倒数第2] 仍为 i=19 的 assistant
assert_eq!(
msgs.last().unwrap().content,
msgs[msgs.len() - 2].content,
"第19条回复内容同样较长用于撑爆上下文预算触发视图裁剪逻辑",
"保护区最新消息被误裁"
);
assert!(
matches!(msgs[1].role, MessageRole::Assistant),
"摘要与首轮 user 合并后,第二条应为 assistant, 实际 {:?}",
msgs[1].role
matches!(msgs[msgs.len() - 2].role, MessageRole::Assistant),
"摘要前一条应为 assistant(i=19), 实际 {:?}",
msgs[msgs.len() - 2].role
);
// 首条仍是保留区真实首条 user(摘要不再抢占头部,不位移前缀)
assert!(
msgs[0].content.contains("审批流程与审批门禁需要适配"),
"合并不应丢失被保留的首轮真实 user 文本, 实际 {}",
msgs[0].content
matches!(msgs[0].role, MessageRole::User),
"视图头部应为真实首条 user(摘要已移尾部), 实际 {:?}",
msgs[0].role
);
// 纯视图:摘要不进内存全量,被淘汰原文存储态不变
@@ -1196,3 +1200,37 @@ fn eviction_no_user_text_no_summary() {
);
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 响应可紧随其后回填(协议不变式成立)"
);
}
+46 -9
View File
@@ -246,16 +246,18 @@ impl ContextManager {
.collect();
// 淘汰补偿摘要:视图裁剪无声丢弃被淘汰内容,LLM 从此看不到早期主题,与压缩
// 路径(有摘要补偿)行为不一致。此处把被淘汰文本提炼成一短段摘要插回发送
// 视图最前部(淘汰区头部、保护消息之前)。角色必须 user:role=system 会被
// anthropic_compat 折叠进带 cache_control 的顶层 system 段,而本摘要纯视图
// 每轮即时再生成(淘汰区单调增长致内容逐轮漂移),落 system 即逐轮改写缓存
// 指纹使全部前缀缓存失效;连续 role 由既有 sanitize/协议层自愈兜底。摘要只进
// messages 易变区,严禁拼进 system_prompt/任何稳定段;纯视图语义:不落库、
// 不改 conv.messages 存储、不标记 compressed,被淘汰原文存储态不变。
// 极短/空淘汰文本提炼为空 → 跳过(不值得插)。
// 路径(有摘要补偿)行为不一致。此处把被淘汰文本提炼成一短段摘要插回发送视图。
// **缓存友好修正(2026-10)**:原 insert(0) 头部插入会位移整条前缀字节,使
// DeepSeek 等自动前缀缓存 provider 的稳定前缀(顶层 system + 早期 history)
// 全部缓存失效——命中率掉到个位数。改尾部追加(push):摘要本身每轮即时再生成
// (淘汰区单调增长致内容逐轮漂移)本就无法命中缓存,但放尾部不再拖累稳定前缀,
// 命中率最大化。角色必须 user:role=system 会被 anthropic_compat 折叠进带
// cache_control 的顶层 system 段,落 system 即逐轮改写缓存指纹;user 放尾部
// 由 sanitize/协议层自愈兜底。摘要只进 messages 易变区,严禁拼进 system_prompt/
// 任何稳定段;纯视图语义:不落库、不改 conv.messages 存储、不标记 compressed,
// 被淘汰原文存储态不变。极短/空淘汰文本提炼为空 → 跳过(不值得插)。
if let Some(text) = build_eviction_summary_text(&evicted) {
msgs.insert(0, ChatMessage::user(text));
msgs.push(ChatMessage::user(text));
}
tracing::info!(
@@ -565,6 +567,41 @@ impl ContextManager {
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 总和,保证:
+231 -7
View File
@@ -42,12 +42,35 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
.filter(|m| m.is_active())
.collect();
// step 1:已闭合的 tool_call_id 集合(role=Tool 消息全部提供过的 id)
let resolved_ids: HashSet<&str> = messages
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.filter_map(|m| m.tool_call_id.as_deref())
.collect();
// step 0.5(相邻性归位,909②):把楔入 assistant(tool_calls) 头与其 tool 响应之间的异类消息
// (后台压缩摘要 / 用户插话 / 警示 system)整体移到该组响应**之后**——只重排不删内容。
// 归位后三元组恢复相邻,step1 的邻接判定即可正确识别为已闭合,不再走「未邻接即剥除」而**保住
// 工具轮**(避免丢结果致模型重跑/误判)。对同一头的多段夹层同样生效(全部响应聚拢到头后紧邻)。
// 真正无法归位者(头无任何响应)仍由 step2/3 剥除兜底。
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 集合
// orphaned_ids:未闭合的 tool_call.id(含占位 id),其 tool_result 要丢
@@ -72,7 +95,7 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
.iter()
.partition(|c| {
!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()) {
@@ -181,6 +204,78 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
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 就地补占位
/// assistant tool_call 头,而非丢弃 tool_result)。
///
@@ -554,6 +649,135 @@ mod tests {
// ── 占位配对完整性(解 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]
fn sanitize_heals_reverse_orphan_tool_result_with_placeholder_head() {
// 三元组完整性 P0:反向 orphan(tool_result 无对应 tool_call 头)→ 补占位头自愈(不丢)。
+33 -6
View File
@@ -423,6 +423,17 @@ pub const TOOL_RESULT_CHAR_LIMIT: usize = 1_024;
/// JSON 对象中字符串字段值的最大字符数(超过则截断)。独立于行数截断,
/// 解决 `{"content":"大段文字(无换行)"}` 类 JSON 逃逸行级截断的问题。
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%。
///
@@ -454,14 +465,30 @@ pub fn should_summarize_tool_result(
/// - 错误行(在头尾区间外的)额外插入,标注位置。
/// - 中间大段省略为 `... (省略 N 行) ...`。
///
/// `tool_name` 参与内容判断:`read_file` 是 AI 定向读代码的工具,压缩其 content
/// 等于直接阉割 AI 的代码分析能力(实测 limit=100 读 28KB,被折叠为首尾各 5 行),
/// 故对 `read_file` 一律豁免压缩(原样返回)。其余工具仍走压缩。
/// `tool_name` 参与内容判断:`read_file` 定向读代码,压缩其 content 会削弱代码分析能力,
/// 故保留**小文件豁免**(≤ READ_FILE_OFFLOAD_BYTES 原样返回);超阈值仍走压缩并落盘
/// (P1 2026-10-10,句柄可读回,无损)。其余工具仍走压缩。
/// 空内容一律返回空字符串。
pub fn extract_key_info(content: &str, tool_name: &str) -> String {
// read_file 豁免:AI 定向读代码,压缩 content 违背用户/AI 意图。
// read_file handler 自带 limit 硬上限 2000 行(file.rs:156/168),无爆 prompt 风险。
if tool_name == "read_file" {
let compressed = extract_key_info_inner(content, tool_name);
// P1 无损化(2026-10-10):确实发生压缩 → 原文落盘 + 追加句柄行,被省略的中段可 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();
}
// JSON 感知压缩:识别对象中的大数组/大字符串并截断
+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 openai_compat;
pub mod openai_helpers;
// 本地图片引用展开(#958 图片入站引用化):消息存 file:// 引用,发送前单点展开 base64
pub mod image_ref;
// Phase 0 (B 路线): Plan-driven 规划骨架(维度 0.8 Plan/SubTask 结构 + 0.7 规划校验/Kahn 分层)。
// 纯函数模块(零 IO 零状态)。依据 docs/02-架构设计/单对话并行多轮-{设计,Phase0落地路线图}-2026-06-20.md。
// Phase 1(plan_hint 接入主 loop)/Phase 2(planning)/Phase 3(并行 execution) 接入后续。
@@ -50,6 +52,9 @@ pub mod router;
pub mod retry;
pub mod sse_parser;
pub mod namespace_store;
// P1 工具结果瘦身(上下文根治专项 2026-10-06 设计 §二):超阈值工具结果正文落盘 + 句柄。
// 与 src-tauri 审计侧 D2a(tool_result_offload) 同构;base 未注入时降级为纯摘要(零行为变化)。
pub mod tool_offload;
use provider::LlmProvider;
use reqwest::Client;
+130 -9
View File
@@ -118,10 +118,12 @@ impl OpenAICompatProvider {
// 多模态 content(须在 move m.tool_calls 之前算,借用 m)。
// 含图消息走 content 数组(text/image_url);纯文本走字符串简写
// (保持与现有纯文本端点零回归)。image_url 支持 data URI(base64)与 http(s) URL。
// 本地 file:// 引用(#958)先展开为 base64 内嵌——OpenAI 端点不认本地路径
let content = if m.has_image() {
let parts: Vec<serde_json::Value> = m
.flattened_parts()
.into_iter()
.map(crate::image_ref::inline_local_image_part)
.map(|p| match p {
crate::provider::ContentPart::Text { text } => serde_json::json!({
"type": "text",
@@ -211,6 +213,10 @@ impl OpenAICompatProvider {
// 模型不支持 temperature(如商汤 sensenova 仅接受 1.0)→ 省略该字段,避免上游 HTTP 400。
// 判定走 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()
&& !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>) {
use std::collections::HashSet;
// step 1:resolved_ids = 所有 tool 消息提供的 tool_call_id(有 result 响应的 id)。
let resolved_ids: HashSet<String> = messages
.iter()
.filter(|m| m.role == "tool")
.filter_map(|m| m.tool_call_id.clone())
.collect();
// step 1:相邻性判定的依据 = 每个 assistant 头「紧后 tool 连续段」覆盖的 id 集合。
// 根因(2026-10-09):原实现按「id 全局存在」判闭合(位置无关),而 OpenAI 协议要求
// tool 响应必须**紧随**其 assistant(tool_calls)。后台压缩摘要在 head 已 push、tool
// 结果未 push 的窗口内可楔入 system(context_lifecycle),被隔开的响应仍被判"已闭合"
// → 畸形序列放行 → 上游 400「insufficient tool messages」。故判定口径由「id 全局存在」
// 收紧为「紧后连续段覆盖」:未覆盖的 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 total_stripped = 0u32;
// step 2:assistant 剥未闭合 tool_call(无对应 tool result 响应)。
for m in messages.iter_mut() {
// step 2:assistant 剥未闭合 tool_call(其响应不在紧后连续段内)。
for (i, m) in messages.iter_mut().enumerate() {
if m.role != "assistant" {
continue;
}
@@ -389,12 +411,13 @@ fn sanitize_openai_triplets(messages: &mut Vec<OpenAiMessage>) {
if calls.is_empty() {
continue;
}
let seg = adjacency.get(&i);
let kept: Vec<serde_json::Value> = calls
.iter()
.filter(|tc| {
tc.get("id")
.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()
.collect();
@@ -999,6 +1022,57 @@ mod tests {
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 仍是字符串简写(无图不数组化,对齐纯文本端点兼容)
#[test]
fn openai_convert_text_only_remains_string() {
@@ -1018,6 +1092,53 @@ mod tests {
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)----------
/// 首条 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);
}
}
+83
View File
@@ -0,0 +1,83 @@
//! 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());
let body: String = (1..=1200).map(|i| format!("match {i}: src/foo.rs:{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 {
EnvSnapshot {
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 输出
/// "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>,
/// 超时时间(秒),None 表示不超时
pub timeout_secs: Option<u64>,
/// Shell 类型(默认:Windows→Cmd, 非Windows→Sh)
/// Shell 类型(None 时取 [`ShellType::default`]:Windows→pwsh 可用则 pwsh 否则 PowerShell,非 Windows→Sh)
#[serde(default)]
pub shell_type: Option<ShellType>,
}
@@ -419,7 +419,7 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
// 提交冻结机械闸(39 治理):冻结在场且命令为 git commit → spawn 前拒绝,改动留工作区
crate::commit_freeze::check(&request).map_err(anyhow::Error::msg)?;
// 探测 pwsh(惰性 + OnceLock 全局缓存,只探一次),使后续 ShellType::default() 可读取缓存
// 探测 pwsh(惰性 + 模块级 PWSH_CACHE,成功才写缓存),使后续 ShellType::default() 可读取缓存
#[cfg(windows)]
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 构建)
/// 期间前端只看 Started→Completed,中间进度不可见。本函数 spawn 子进程后并发逐行读两条流,
/// 每读到一行回调 `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性:
/// - 合理机制: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,
channel_id: channel_id.to_string(),
token: token.to_string(),
proto: 1,
};
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 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::time::Duration;
use df_storage::crud::SettingsRepo;
use df_storage::db::Database;
use serde_json::{json, Value};
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_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 运行配置。
///
/// 聚合 stdio 形态的运行参数,避免 `run_server` 参数膨胀。
@@ -41,7 +51,8 @@ pub struct ServerConfig {
pub idle_timeout: Option<Duration>,
/// 写操作回调:成功执行(未被 read-only/High 拒绝)的写工具(risk != Low)触发。
/// 桌面内嵌形态经 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>>,
}
@@ -154,8 +165,13 @@ where
if let Some(name) = tool_name {
// MC-5仅业务成功才触发写回调——防业务失败(如 update_project 非法 status、
// create_project 空名等 result.isError=true)仍假触发 df-data-changed。
if is_success_tool_call(&resp) {
fire_write_hook(config, &name);
if is_success_tool_call(&resp) && is_executed_write(&name, config.read_only) {
// 多进程兜底: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 拒绝时触发,
/// 与 dispatch 的执行判定一致。回调仅作通知(如 GUI 刷新),不承载返回结果。
fn fire_write_hook(config: &ServerConfig, name: &str) {
let Some(cb) = &config.on_write_call else { return };
let Some(spec) = tools::find(name) else { return };
if spec.risk != RiskLevel::Low && should_execute(config.read_only, spec.risk) {
cb(name);
/// 判定工具是否属于「会执行的成功写操作」:risk != Low 且未被 read-only/High 拒绝。
///
/// 变更标记与进程内写回调共用此判定,保证两通道对「哪些写操作需通知 GUI」语义一致;
/// 与 dispatch 的执行判定(`should_execute`)同源,避免逻辑漂移。
fn is_executed_write(name: &str, read_only: bool) -> bool {
match tools::find(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()
);
}
// ── 多进程兜底: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 / 超时 / 取消
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
let mut cancel_tick = tokio::time::interval(Duration::from_millis(500));
cancel_tick.tick().await; // 丢弃首个立即触发的 tick
// FR-P4:取消检测由 500ms interval 轮询改为 Notify 被动唤醒(set_cancelled 触发)。
// 先建 notified future 再进 loop,避免 notify 早于 await 的竞态窗口;
// 每轮 select! 后重建(Notified 单次消费),配合 is_cancelled 复查兜底。
loop {
let cancel_wait = ctx.node_status.cancel_notified();
tokio::select! {
recv = rx.recv() => match recv {
Ok(WorkflowEvent::HumanApprovalResponse {
@@ -154,7 +156,9 @@ impl Node for HumanNode {
_ = tokio::time::sleep_until(deadline) => {
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) {
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 df_workflow::dag_def::DagDef;
+122 -5
View File
@@ -67,6 +67,26 @@ pub async fn advance_task_atomic(
.await?
.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. 状态机校验(三类拒绝,错误类型区分供前端分辨):
// - 同态(from==to):Validation「相同状态,无需推进」(空操作,非状态机违例)
// - 非法转换(跳态/终态无后继等):InvalidState「非法状态转换 X→Y」
@@ -112,11 +132,45 @@ pub async fn advance_task_atomic(
.advance_status_atomic(id, from, target_status, bump)
.await?;
// 5. CAS 失败:并发已改动 status(或任务被删),拒绝静默成功。
updated.ok_or_else(|| df_types::error::Error::InvalidState {
current: "已变更(并发推进或旁路修改)".to_string(),
expected: from.to_string(),
})
// 5. CAS 失败:affected==0 有四种可能,回查区分,给出精确错误(不再一律误报「并发」)。
//
// 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(),
});
}
// 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 失败");
}
// ---------- 归档/软删任务的精确错误(治「已变更(并发推进或旁路修改)」误导) ----------
#[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]
async fn blocked_round_trip_does_not_bump() {
let repo = setup().await;
-3
View File
@@ -114,14 +114,11 @@ pub(crate) fn strip_readme_noise(content: &str) -> String {
if trimmed == "---" || trimmed == "+++" {
// 开头处的 frontmatter:跳到下一个匹配分隔符
let delim = trimmed;
let mut consumed_any = false;
for next in lines.by_ref() {
consumed_any = true;
if next.trim() == delim {
break;
}
}
let _ = consumed_any;
continue;
}
// ── 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())
}
/// 当前 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)]
pub struct Hello {
@@ -84,6 +95,9 @@ pub struct Hello {
pub device_id: String,
/// 配对 token
pub token: String,
/// 协议版本(缺省按 v1,兼容旧客户端)
#[serde(default = "default_proto")]
pub proto: u32,
}
/// Hello.kind 的传输表示(serde 字符串,与 ClientKind 解耦避免 rename 歧义)
@@ -298,6 +312,16 @@ async fn handle_connection(socket: WebSocket, state: RelayState, expected: Clien
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 kind: ClientKind = hello.kind.into();
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,
?kind,
device_id = %device_id,
proto = hello.proto,
"连接握手通过,进入收发循环"
);
@@ -545,4 +570,19 @@ mod tests {
fn max_frame_is_one_mib() {
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);
}
}
+156 -14
View File
@@ -112,6 +112,8 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
boundary_json: row.get("boundary_json").unwrap_or(None),
// loop_state 列老库经 v49 迁移补建。unwrap_or(None) 兜底(NULL=非任务会话)。
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()),
})
}
@@ -133,6 +135,8 @@ fn ai_tool_execution_from_row(row: &Row<'_>) -> std::result::Result<AiToolExecut
requested_at: row.get("requested_at")?,
executed_at: row.get("executed_at")?,
decided_by: row.get("decided_by")?,
// duration_ms 列老库经 v50 迁移补建。unwrap_or(None) 兜底(NULL=老数据/未测路径)。
duration_ms: row.get("duration_ms").unwrap_or(None),
})
}
@@ -187,22 +191,22 @@ impl_repo!(
from_row => |row| ai_conversation_from_row(row),
insert => |conn, rec| {
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)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
"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)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
params![
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
if rec.pinned { 1i32 } else { 0i32 },
rec.prompt_tokens, rec.completion_tokens,
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
],
)
},
update => |conn, rec| {
// loop_state 有意不在 SET 列(V49 生命周期标记独立更新,对齐 boundary 早期模式):
// loop_state/status 有意不在 SET 列(V49/V50 生命周期标记独立更新,对齐 boundary 早期模式):
// update_full 的调用方(save_conversation 等)从 get_by_id 读旧记录再改元数据写回,
// 读改写窗口内 loop 生命周期可变更(spawn 置 running/收敛清 None)——若 SET 含
// loop_state,旧值会覆盖新值(lost-update)。生命周期只经 set_loop_state 单点写。
// 读改写窗口内 loop 生命周期/终局态可变更——若 SET 含二者,旧值会覆盖新值(lost-update)。
// 生命周期只经 set_loop_state / set_status 单点写。
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",
params![
@@ -249,22 +253,22 @@ impl_repo!(
from_row => |row| ai_tool_execution_from_row(row),
insert => |conn, rec| {
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)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
"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, ?13)",
params![
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.requested_at, rec.executed_at, rec.decided_by
rec.requested_at, rec.executed_at, rec.decided_by, rec.duration_ms
],
)
},
update => |conn, rec| {
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![
rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name,
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 +302,15 @@ impl AiToolExecutionRepo {
let tx = guard.transaction().map_err(storage_err)?;
{
let mut stmt = tx.prepare(
"INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
"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, ?13)",
)
.map_err(storage_err)?;
for rec in &records {
stmt.execute(params![
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.requested_at, rec.executed_at, rec.decided_by
rec.requested_at, rec.executed_at, rec.decided_by, rec.duration_ms
])
.map_err(storage_err)?;
}
@@ -839,6 +843,29 @@ impl AiConversationRepo {
.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 的会话,进程重启后 loop 必死
@@ -1020,6 +1047,7 @@ mod tests {
requested_at: "0".into(),
executed_at: None,
decided_by: None,
duration_ms: None,
}
}
@@ -1068,6 +1096,43 @@ mod tests {
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),
/// 保留首次 snapshot。锁死 OR IGNORE 行为,防未来误改成 REPLACE 静默覆盖快照。
#[tokio::test]
@@ -1094,4 +1159,81 @@ mod tests {
.expect("snapshot");
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(),
})
.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(),
})
.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 应可读回");
}
}
+15 -9
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")?,
reasoning_tokens: row.get("reasoning_tokens")?,
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,
tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
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, ?20)",
)
.map_err(storage_err)?;
for rec in &records {
@@ -97,7 +98,7 @@ impl AiMessageRepo {
rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status,
rec.reasoning_content, rec.timestamp, rec.created_at,
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)?;
}
@@ -124,7 +125,7 @@ impl AiMessageRepo {
tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_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",
)
.map_err(storage_err)?;
@@ -171,13 +172,13 @@ impl AiMessageRepo {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at,
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"
} else {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at,
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"
};
let mut stmt = guard.prepare(sql).map_err(storage_err)?;
@@ -296,8 +297,8 @@ impl AiMessageRepo {
(id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
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, ?20)",
)
.map_err(storage_err)?;
for rec in &records {
@@ -306,7 +307,7 @@ impl AiMessageRepo {
rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status,
rec.reasoning_content, rec.timestamp, rec.created_at,
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)?;
}
@@ -381,6 +382,7 @@ mod tests {
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
is_estimated: None,
source: None,
}
}
@@ -544,6 +546,7 @@ mod tests {
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
is_estimated: None,
source: None,
},
AiMessageRecord {
id: "new_1".into(),
@@ -565,6 +568,7 @@ mod tests {
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
is_estimated: None,
source: None,
},
];
repo.replace_conversation("conv", records).await.expect("replace");
@@ -638,6 +642,7 @@ mod tests {
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
is_estimated: None,
source: None,
}],
)
.await
@@ -680,6 +685,7 @@ mod tests {
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
is_estimated: None,
source: None,
};
repo.replace_conversation("c", vec![rec()]).await.expect("1st");
repo.replace_conversation("c", vec![rec()]).await.expect("2nd");
+4
View File
@@ -14,6 +14,7 @@
//! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具
//! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22)
//! - [`mod@message_repo`]:AiMessageRepo(消息拆分存储,全专用方法不走宏)
//! - [`mod@outbox_repo`]:OutboxRepo(待发箱 ai_outbox,V51,含 claim CAS/reset_stale_sending,手写不走宏)
//!
//! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` /
//! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。
@@ -25,6 +26,7 @@ mod conversation_repo;
mod idea_eval_repo;
mod idea_repo;
mod message_repo;
mod outbox_repo;
mod project_event_repo;
mod project_module_repo;
mod project_repo;
@@ -38,6 +40,7 @@ pub use conversation_repo::*;
pub use idea_eval_repo::*;
pub use idea_repo::*;
pub use message_repo::*;
pub use outbox_repo::*;
pub use project_event_repo::*;
pub use project_module_repo::*;
pub use project_repo::*;
@@ -72,6 +75,7 @@ macro_rules! impl_repo {
update => |$u_conn:ident, $u_rec:ident| $update_body:expr
) => {
$(#[$meta])*
#[derive(Clone)]
pub struct $name {
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 终态不可重入队");
}
}
+28
View File
@@ -249,6 +249,34 @@ impl TaskRepo {
.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`)。
///
/// 专供 [`validate_parent_id`](super::task_validation) 的父任务存在性检查:
+284 -7
View File
@@ -1,7 +1,7 @@
//! 数据库迁移 — 建表 SQL 与版本管理
//! 数据库迁移 — 建表 SQL 与版本管理
use crate::migrations_helpers::column_exists;
use crate::migrations_helpers::{column_exists, table_exists};
use anyhow::Result;
use rusqlite::Connection;
@@ -59,7 +59,13 @@ pub fn run(conn: &Connection) -> Result<()> {
// V48 = 项目归档(projects.archived 列,归档功能 P2,同款三方对齐)。
// V49 = loop 生命周期标记(ai_conversations.loop_state TEXT,断点续跑底座:
// 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=桌面本端)。
let steps: [(i32, fn(&Connection) -> Result<()>); 52] = [
(1, migrate_v1),
(2, migrate_v2),
(3, migrate_v3),
@@ -113,6 +119,12 @@ pub fn run(conn: &Connection) -> Result<()> {
(48, migrate_v48),
// V49 = loop 生命周期标记(ai_conversations.loop_state,断点续跑底座)。
(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),
];
for (version, migrate_fn) in steps {
@@ -1450,6 +1462,184 @@ fn migrate_v49(conn: &Connection) -> Result<()> {
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 = 52;
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(())
}
/// 回填 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 表
///
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
@@ -2478,9 +2668,9 @@ mod tests {
)
.expect("查 schema_version 应成功");
assert_eq!(
max_version, 49,
"全量迁移后 schema_version 应为 49(实际 {}),说明某条 migrate_vN 链路断在中间",
max_version
max_version, LATEST_SCHEMA_VERSION as i64,
"全量迁移后 schema_version 应为顶点 {}(实际 {}),说明某条 migrate_vN 链路断在中间",
LATEST_SCHEMA_VERSION, max_version
);
// 4. V40 抽查:project_modules 表 description / status 列存在(老库 ALTER 补,新库 V40 也跑)
@@ -2556,7 +2746,7 @@ mod tests {
|r| r.get(0),
)
.unwrap();
assert_eq!(max_v, 49, "首轮应推进到 49");
assert_eq!(max_v, LATEST_SCHEMA_VERSION as i64, "首轮应推进到 {}", LATEST_SCHEMA_VERSION);
// 清空版本表强制全链第二遍(每步 execute 第二次)
conn.execute("DELETE FROM schema_version", []).unwrap();
@@ -2568,7 +2758,7 @@ mod tests {
|r| r.get(0),
)
.unwrap();
assert_eq!(max_v2, 49, "重跑后应重新推进到 49");
assert_eq!(max_v2, LATEST_SCHEMA_VERSION as i64, "重跑后应重新推进到 {}", LATEST_SCHEMA_VERSION);
}
// ============================================================
@@ -2674,4 +2864,91 @@ mod tests {
.unwrap();
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"
);
}
}
@@ -14,6 +14,19 @@ pub(crate) fn column_exists(conn: &Connection, table: &str, col: &str) -> bool {
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)]
mod tests {
use super::*;
@@ -74,4 +87,16 @@ mod tests {
assert!(!column_exists(&conn, "probe_t", "prompt"));
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");
}
}
+40
View File
@@ -396,6 +396,10 @@ pub struct AiConversationRecord {
/// 纯元数据标记不进 update_full 通用 UPDATE,走专用 set_loop_state。
#[serde(default)]
pub loop_state: Option<String>,
/// 会话终局态(V50):active=进行中/默认(含正常完成),incomplete=停靠/断流等未完成终局,
/// error=循环错误终局。与 loop_state(运行期 running/parked,管「能否续跑」)正交——
/// 本列回答「会话如何结束」,供未完成率等统计。不进 update_full,走专用 set_status 单点写。
pub status: String,
}
/// AI 工具执行审计记录
@@ -419,6 +423,11 @@ pub struct AiToolExecutionRecord {
pub requested_at: String,
pub executed_at: Option<String>,
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 表,消息拆分存储)。
@@ -470,6 +479,37 @@ pub struct AiMessageRecord {
/// 本轮 prompt 是否估算值(round_usage.prompt_tokens==0 → estimated_prompt 兜底打标)。
/// reload 逐条回显「估算」标注;老消息 NULL → None(向前兼容)。
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};
/// 当前 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)
///
/// 桌面端 WS 连 `/ws/agent` 后,首条消息发此 JSON,relay 校验 kind 与 token。
/// 字段名/serde rename 与 u-relay 严格一致(`#[serde(rename_all="snake_case")]`),
/// 否则 relay 反序列化失败 → 握手被拒。
///
/// `proto` 为协议版本,供 relay 端做版本协商(缺字段按 v1,新增破坏性变更时递增)。
///
/// 注意:本 crate 不依赖 u-relay(避免 lib→server 反向依赖),自行镜像协议结构。
/// 协议字段如有变更须同步 u-relay::relay::Hello 与本类型。
///
@@ -30,6 +43,9 @@ pub struct Hello {
pub channel_id: String,
/// 配对 token(env U_RELAY_TOKEN 同源)
pub token: String,
/// 协议版本(缺省按 v1,兼容旧客户端)
#[serde(default = "default_proto")]
pub proto: u32,
}
/// Hello.kind 传输表示(serde 字符串,与 u-relay::ClientKindWire 一致)
@@ -155,3 +171,28 @@ pub enum TunnelMessage {
/// 云后端转发的指令(上行,发往桌面端)
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 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)
///
@@ -105,9 +105,13 @@ const HANDSHAKE_PROBE: Duration = Duration::from_secs(3);
/// 包 `tokio::time::timeout` 在此窗口内未完成 → 返 TunnelError::Connect 显式错误。
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
/// 心跳间隔(应用层 Ping,补协议层 keepalive,防 NAT 连接表超时回收)
/// u-relay 心跳走应用层 control 帧 ping→pong,relay 本地直回不转对端
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(25);
/// 心跳间隔(WS 协议层 Ping,补 TCP keepalive,防 NAT/反代空闲回收连接)。
///
/// 上界约束:链路最短空闲超时在反代侧(生产 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);
@@ -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();
@@ -210,6 +215,7 @@ impl TunnelClient for WsTunnelClient {
kind: HelloKind::Agent,
channel_id: channel_id.to_string(),
token: token.to_string(),
proto: HELLO_PROTO_VERSION,
};
let hello_json = serde_json::to_string(&hello)?;
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 投影成
/// 类型化枚举用。未知字符串返 None。
///
/// ⚠️ TYPES-2:本函数返 None 是显式信号,但若调用方用 `unwrap_or_default()` 兜底,
/// 未知值会**静默归 Planning**(ProjectStatus::default),掩盖数据异常。建议调用方对
/// None 分支记 warn 审计(而非静默兜底),便于发现脏 status。
pub fn from_db_str(s: &str) -> Option<Self> {
Some(match s {
"planning" => ProjectStatus::Planning,
+18
View File
@@ -30,6 +30,10 @@ use df_types::types::{NodeId, NodeStatus};
pub struct StateMachine {
/// 节点状态映射(Arc 共享,clone 浅拷贝同一 HashMap)
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 {
@@ -37,9 +41,19 @@ impl StateMachine {
pub fn new() -> Self {
Self {
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`(保守默认:视为未启动,
@@ -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 返回,调用方持独立副本)
@@ -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)
> 命名规范:见本目录 [文档记录规范-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 引擎不绑定业务 等) |
| [功能决策记录-2026-06-14.md](./滚动规范/功能决策记录-2026-06-14.md) | 📋 滚动 | 功能**需求规格 + 设计决策规格**(为什么这么定 / 要做什么)。最高频引用(67 入链) |
| [功能决策记录-归档-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 自动化路径 |
| [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 项落地风险清单 |
| [上下文管理演进-任务技术设计-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 条再以成熟库评估 —— 是否认可?
@@ -170,6 +170,24 @@ Commander(控制端) ──wss──> nginx(:443 TLS 终结) ──ws──> u-r
| **Commander** | 用 pong 更新 `lastPongTime`,看门狗超时则重连 |
| **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. 部署配置
@@ -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,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,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,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 硬伤修复)
>
> **本文档为 ARCHITECTURE.md 的实质载体**(项目无独立 ARCHITECTURE.md 文件)。所有数据模型、设计决策均以源码为基准,已剔除虚构内容。
> **项目架构总纲见根 [ARCHITECTURE.md](../../../ARCHITECTURE.md)**,本文档为其补充细节。所有数据模型、设计决策均以源码为基准,已剔除虚构内容。
---
@@ -16,6 +16,15 @@
> 注:用户贴的"你好"会话原始调用(`"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:重复探索是系统性现象(非偶发)
@@ -50,6 +59,12 @@
## 实证 4:问候本身不触发工具,但弱模型任务中失控
- 修正:纯"你好"第一轮模型会正常回复能力介绍(09e7abfa/1982ff08 均如此),**0 例**"问候后紧邻调工具"
> **⚠️ 2026-10-10 更正(实证推翻本条)**:会话 `b4d6b4e0`(标题「打招呼」)的**首轮**即是裸问候
> 「你好」→ assistant **空文本 + 直接发 2 个 tool_calls**,无任何前置上下文。故"问候本身不触发工具"
> 不成立——至少对 `sensenova-6.7-flash-lite` 存在**首轮问候即开工具**的实例。原"0 例"结论源于当时未
> 检索到该会话(其轮次未落 ai_messages),属样本缺失导致的误判。据此,「工具全量暴露 + 弱模型自控弱」
> 的诱因比原判更严重:**无需任务上下文,裸问候即可触发工具探索**。
- 用户贴的"你好犯傻"更可能是:会话有历史上下文(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)。
### 🟡 FR-P3 — 全局单 SQLite 连接串行瓶颈 [仅代理报告待复核]
### 🟡 FR-P3 — 全局单 SQLite 连接串行瓶颈 [仅代理报告待复核] ⏸️ 评估后不改(2026-10-08)
**位置**:`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)。
### 🟡 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`
@@ -279,7 +281,9 @@
**建议**:取消走独立 `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`
@@ -289,7 +293,9 @@
**建议**:项目列表变动时刷新缓存(已有 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`
@@ -299,6 +305,8 @@
**建议**:watch 具体派生信号(如 toolCalls 的 status 集合)而非整个 messages deep 快照。
> **✅ 已完成(已消除)**:`AiChat.vue` 全仓 grep `JSON.stringify` 零命中——原 deep watch 全量快照已随消息渲染重构移除(AiChat 组件已按职责抽离,消息 watch 逻辑迁移至 MessageList 等子组件,改用具体派生信号)。病灶不复存在。
---
## 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) — 完整架构设计
- [PROGRESS.md](../../PROGRESS.md) — 工作进展与交接
- [PROGRESS.md](./PROGRESS.md) — 工作进展与交接
- [Phase1 架构决策](../02-架构设计/滚动规范/Phase1架构决策-2026-06-12.md)
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
# 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-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 行含历史)
├── 待审查.md # 代码审查队列(CR 登记+回填,67 条已审)
├── 待决策.md # 需人工决策项(产品取舍/方向选型/排期)
├── 待决策论证-2026-06-16.md # (归档) 7 项决策论证(D-01~04 已定 / DEC-01~03 已闭环)
├── 01-技术文档/
│ ├── SQLite-CRUD模式-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-架构设计/
│ ├── INDEX.md # 本目录分类索引(按性质四类 + 命名规范摘要)
│ ├── Phase1架构决策-2026-06-12.md
│ ├── 业务系统设计-2026-06-12.md
│ ├── 产品定位调整-2026-06-12.md
│ ├── 前后端类型对齐-2026-06-12.md
│ ├── 对抗论证裁决报告-2026-06-12.md
│ ├── 功能决策记录-2026-06-14.md # 需求规格 + 设计决策规格
│ ├── 功能决策记录-归档-2026-06-14.md
│ ├── 功能创意池-2026-06-14.md # 5 架构创意(演化/时效/回溯/债务/契约)
│ ├── 经验记录-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 下推/精确拉取/缓存/字段投影)
│ ├── 滚动规范/ # 长期维护的真相源 / 规范 / 决策记录
│ ├── 已编号方案/ # 带 F/B/CR 编号的方案文档
│ ├── 专项设计/ # 无编号的主题设计 / 审查派生方案
│ ├── 构想审查/ # 构想 / UX 改进 / 模块审查 / 论证
│ ├── 单对话并行多轮-设计-2026-06-20.md
│ ├── 单对话并行多轮-Phase0落地路线图-2026-06-20.md
│ ├── 上下文根治设计-20261006.md
│ └── 无人值守可靠性专项设计-20261006.md
├── 03-模块文档/
│ ├── df-storage-存储层-2026-06-12.md
│ ├── df-workflow-工作流引擎-2026-06-12.md
@@ -79,7 +45,9 @@ docs/
│ ├── DEVFLOW-1.CRUD层实施-2026-06-12.md
│ ├── DEVFLOW-2.IPC桥接实施-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-代码审查/
│ ├── 00-全库代码走查汇总-2026-08-02.md # 全库走查汇总(12 crate, 78 问题, 11 P0 / 30 P1 / 37 P2)
│ ├── 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 # 推进能力实现度0%,4阶段路径+决策点
│ ├── AI聊天组件极端数据场景分析-2026-06-17.md # aichat 交互层 20 项缺陷(HIGH×2/MED×8/LOW×8)
│ ├── 工作流审批审查报告-2026-06-14.md # (归档)工作流审批链路审查
│ ├── 前后端类型对齐-2026-06-12.md # (归档)Rust↔TS 类型契约核对
│ ├── 对抗论证裁决报告-2026-06-12.md # (归档)df-ideas 对抗评估裁决
│ └── 架构与缺陷复核报告-2026-06-14.md # (归档)复核已修项+回归审计
│ ├── aichat-技术债审查-2026-06-21.md # aichat 技术债审查(10 模块 0 P0 / 7 P1 / 25 P2 / 40 P3)
│ ├── aichat-会话实测分析-2026-06-22.md # aichat 会话实测分析
│ ├── 全量走查报告-2026-06-28.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-前端开发/
│ └── View改造指南-2026-06-12.md
│ ├── View改造指南-2026-06-12.md
│ └── 前端架构技术债-2026-06-19.md # 前端架构技术债盘点
├── 07-项目管理/
│ ├── PROGRESS.md # 项目进展与交接记录
│ ├── Batch.md # 批次执行记录
│ ├── Phase1任务清单-2026-06-12.md
│ └── Phase2计划-2026-06-12.md
│ ├── Phase2计划-2026-06-12.md
│ └── 用户主张思想总纲-2026-10.md # 跨会话用户主张蒸馏(注入用)
├── 08-用户指南/
│ ├── 使用手册-2026-06-12.md # 用户手册(对齐 06-15 代码基线)
│ ├── AI文件操作工具手册.md # AI 工具 API 全量参考(10 工具,2026-06-16 更新)
@@ -138,7 +126,7 @@ docs/
| 🚀 快速开始 | [README.md](./README.md) | 安装、启动、快速上手 |
| 📖 使用手册 | [使用手册-2026-06-12.md](./08-用户指南/使用手册-2026-06-12.md) | 用户手册 |
| 🔧 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 交互专区) |
| 🔍 审查队列 | [待审查.md](./待审查.md) | 代码审查登记+回填(67 条已审) |
| ⚖️ 决策项 | [待决策.md](./待决策.md) | 需人工决策的产品/设计/排期 |
@@ -174,9 +162,9 @@ docs/
| 文档 | 路径 | 说明 |
|------|------|------|
| 架构设计 | `../ARCHITECTURE.md` | 完整架构文档 |
| 项目进展 | `../PROGRESS.md` | 工作进展与交接 |
| 项目进展 | `./07-项目管理/PROGRESS.md` | 工作进展与交接 |
| 工作看板 | `./todo.md` | 任务/bug 跟踪(含决策记录) |
| Crate 结构 | `../ARCHITECTURE.md#四crate-结构` | 8 个 Crate 概览 |
| Crate 结构 | `../ARCHITECTURE.md#四crate-结构` | 13 个 Crate 概览 |
| 数据模型 | `../ARCHITECTURE.md#六数据模型` | SQLite 表结构定义 |
| Phase 规划 | `../ARCHITECTURE.md#八phase-规划` | 5 个 Phase 路线图 |
@@ -188,7 +176,7 @@ docs/
|----|------|------|
| Desktop | Tauri v2 | Rust 后端 + WebView 前端 |
| Frontend | Vue 3 + TypeScript + Pinia | Arco Design 组件库 |
| Engine | Rust Workspace (8 crate) | 多 crate 架构 |
| Engine | Rust Workspace (13 crate) | 多 crate 架构 |
| Storage | SQLite (rusqlite) | 本地优先,零运维 |
| 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) - 完整的使用指南,包含功能介绍、操作流程和最佳实践
### 📋 项目进度
- [PROGRESS.md](../PROGRESS.md) - 项目开发进展与交接记录
- [PROGRESS.md](./07-项目管理/PROGRESS.md) - 项目开发进展与交接记录
## 🎯 核心理念
+79 -298
View File
@@ -14,6 +14,12 @@
> **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-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-15 编号碰撞登记**: `REFACTOR-260619-04` 被复用(父③③.2 审批状态机拆分=待办 vs ToolCard 拆分=已完成)。后续引用审批状态机拆分时用「审批状态机拆分」描述,不再沿用该编号。
@@ -23,8 +29,7 @@
> 背景: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)
- [ ] 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 键盘化
- [~] 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 仅本批」拍板点仍在。
- 拍板点:修复范围「存量 vs 仅本批」——transition:all/outline:none 大半为存量;本批新代码 a11y 意识已好(svg aria-hidden 批补/MessageList role=log+aria-live/hover-only focus-within 补偿),仅清本批则工作量降 60%+
### 💡 2026-09-29 aichat 可用性与交互成本研究(三动线走查+异常循环截图实证,39 项新发现)
@@ -64,85 +69,6 @@
> 审批留在对话内(需要即时上下文),知识/灵感/通知放 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 核验)
@@ -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)。**均仍在未完成,审查复核确认,保留**。
**残留诊断代码(待清)**: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 新需求(已分析·待实施)
> 功能需求,已走查定位链路 + 方案记录,待用户确认后实施。
@@ -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 决策已被用户「不设并发会话上限」推翻作废。
### 🔴 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-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低不实施)
### 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 方案 → 待办化)
> 来源:[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兜底):
### 🔍 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 问题角度)
> 详单:[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 列表刷新收敛。
> **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 走查(阶段②最后一批)
> 详单见 [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)
> **全系统走查(阶段②)完成 ✅**——前端 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-A task_id 断裂:AiSelfReviewNode 必定失败
@@ -555,11 +340,6 @@ graph TD
> 来源:中断根治批次全量走查(正常设计路径无漏 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 处补终态事件
- **位置**:`agentic/mod.rs:417/:1316/:1379/:1437/:1593/:1423`、`stream_relay.rs:520-525`(MidStream 保文时 conv-deleted 跳过 teardown emit,最刺眼)
- **现状**:生成期间会话被删,裸 return 仅 guard Drop 迁 Idle,无终止事件。
@@ -577,13 +357,9 @@ graph TD
> 来源:上下文管理批收口期 rev-backend/rev-frontend/rev-crates 三路走查(历三次会话重启后续跑完成)。**全文与批次归并建议见 `docs/05-代码审查/aichat三路走查报告-2026-10-02.md`**。以下为可执行登记,开工以现场源码为准。
### 批次2(后端高危)并入项
### 批次3(前端体验)并入项
### 机械批(可独立小批)
### 登记观察(定级前先核)
# 子代理调度走查(2026-10-03 两路,详情 temp/走查-子代理{UX,调度后端}-2026-10-03.md)
@@ -595,9 +371,6 @@ graph TD
> 与上节 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 完成卡(前端全文)与父上下文(压缩)双轨无「已消化」降级机制 ✅设计完成(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 默认[需拍板]
@@ -637,10 +410,15 @@ graph TD
- [ ] P2 审计表无执行耗时字段(requested→executed 混入审批等待,耗时统计不可信)【部分缓解:AuditLog.vue:71 ⏱ durationLabel 已显示且诚实标注「审批→执行」;纯执行耗时独立字段归 V49 schema 迁移批】
- [ ] P2 messages blob 双存储冗余(89/89,单会话最大 1.7MB)【归 V49 schema 迁移批】
- [ ] 观察自动执行模式架空审批(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 次
- [ ] 915 ✅转待办 验收:知识库由「只写不读」转读写闭环——embedding_status 回填(或明确弃用向量路径),≥半数知识进检索候选,knowledge_search 调用与复用率实测上升(复用率 >50%、检索调用非个位数);口径=同库 DB 查询核验。
- [ ] 观察多模态未走通:贴图落"[图片已忽略]"占位,实际靠 run_command 外部 vision CLI 绕路
- [ ] 917 ✅转待办 验收:贴图走原生视觉路径不落占位——非视觉模型收图时明确拒绝或切视觉模型,视觉模型直接出识别结果,不再靠 run_command 调外部 vision CLI 绕路;实测贴图会话零"[图片已忽略]"占位。
- [ ] 观察编排面零使用: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 散落)
- [ ] 921 ✅转待办 验收:数据目录有保留策略——手工备份纳入轮转(定份数与周期),dev 库与主库职责明确(合并或清理);口径=%APPDATA%/top.1216.devflow 目录大小回落且备份数受控。
# ai-chat UX 走查第五轮(2026-10-05,可访问性视角,详情 temp/UX走查-aiChat缺陷-2026-10-05.md)
@@ -675,6 +453,7 @@ graph TD
- [ ] **资源面**:token/磁盘/内存/进程开销评估;日志与数据膨胀上限(借鉴日志无轮转教训)
- [ ] **回退开关**:新能力默认关或带单点回退(借鉴 GOAL_PIN 系开关范式);灰度顺序与观测指标
- [ ] P2 指令离线即丢(§7 不缓存):commander 发指令时 agent 离线=静默丢失,移动端控制场景用户无感知——补业务层送达回执或短缓冲
- [ ] 923 ✅转待办 验收:离线指令不静默丢——补业务层送达回执(发送方可见成功/失败)或短缓冲(重连后补投);口径=断网/离线场景实测指令有回执或重连后送达,无静默丢失。
- [ ] P2 Hello 无协议版本字段:下次破坏性变更仍是无通知硬切换——加 proto 版本字段(现在加便宜,以后加贵)
- [ ] P3 单共享 token 可冒充任意 channel_id(§8 自述):补 token 轮换 runbook(/etc/u-relay.env 改+重启+四方客户端同步换,锁步成本需写明)
- [ ] P3 心跳间隔未定值:nginx proxy_read_timeout=300s,间隔须 <300s,文档补建议值(30-60s)
@@ -731,7 +510,7 @@ graph TD
- [ ] 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-d1 挂机最小闭环(领单→执行→销账→循环+轻量续接;W-b wake 随其绑定实施)
@@ -824,32 +603,11 @@ graph TD
- 飞书四修+流式(占位卡片 edit_card 原地更新/表格降级/clip_for_card 超长裁剪/启动日志)——已上线
- 知识库 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,④双通道搁置)
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 内收到受理卡,完成后卡片变结果,全程无需等待。
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 内卡片更新关键节点。
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 用户拍板:根本性增强,源自提交质量检查三不达标)
> 根因:任务传递靠软机制(看板摘要+群消息阅读),无结构化任务单与销账门禁——🔴被静默跳过/i18n 描述失实/描述与内容偏差,三层同源。
@@ -860,17 +618,6 @@ graph TD
- [ ] **G3 描述反向核对**:提交前 diff 关键词与描述机械比对,不符打回(cm 技能增强)
- [ ] **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 机制先行)
- [ ] U1 任务卡生命周期组件(双端):桌面 TaskCard 八态投影枚举(全部由既有事件派生,零引擎新状态)+飞书 header 状态色结构卡(JSON 元素级规格已给,动作 v1 走文本命令)
@@ -879,24 +626,6 @@ graph TD
- [ ] U4 纠偏交互:桌面直发默认注入·下一轮生效(排队可切),回执+前缀逃生;飞书回复即注入保持
- [ ] U5 异常态话术公式:状态+真实原因+已保留什么+≥2条出路;禁自动判死静默任务(15min 重入退避是合法等待),给判断材料不替用户决策
- [ ] 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 发散条目)
- [ ] P1(S):工具结果瘦身——超 2K 结果正文落盘+句柄,上下文留摘要+路径(与 D2a 同构,最大头先行)
- [ ] P2(M):L1/L2/L3 分层组装器(build_for_request 改造:常驻+滑动窗口+检索召回)
@@ -925,13 +654,10 @@ graph TD
**补齐优先序:B5d 工具重试策略 → B5a 挂钟定级 → B5b 自动续作 → B5c 审批策略 → B2 预算闸 → 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-3 阶段汇报:AiTaskProgress(每 5 轮,mod.rs:1119 已 emit)接入 refresher——长任务的阶段性进展对飞书用户可见(当前只有桌面消费)。
- [ ] 13a-4 受理→首刷衔接:受理卡到首个 delta/心跳之间的静默窗口检查(>60s 无首刷则心跳兜底)。
验收: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 设计定稿)
- **抽象**:看板=任务队列(优先级/状态三态/验收口径)+ 领单纪律=消费者协议 + 治理门禁=质量闸。任何 Claude Code 实例 + 一个看板 = 一个自主工作单元。
- **组件三件套模板**(提炼自 devflow 今日实践,新场景开箱即用): 1.看板格式规范 2.领单纪律 CLAUDE.md 模板 3.治理门禁包(cm v1.3.0+pre-commit hook)
@@ -948,16 +674,71 @@ graph TD
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 刷新可观测是排查此类问题的前提。
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 个周期内被发现并通报。
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。
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 故障窗口。
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 ①** `history_tokens` 与真实请求体量**脱钩**:工具结果经 namespace 引用化入内存(`audit/mod.rs:1183-1190` + `namespace_store.rs:40-55` ALWAYS_LARGE_TOOLS 命中即引用,与体积无关),估算器只数字符(`context_helpers.rs:62-105`)→ 单条上万 token 的大结果只记 ~15 token;**预算/裁剪门(`context/mod.rs:185-205`)/压缩门(`context_lifecycle.rs:103-108`)三重门槛形同虚设**,每轮实际重发展开后的全量历史(223:1 结构浪费根因)。修:push/外置时按原文体量入账,或估算器识别 ref 取真实大小。
- **HIGH ②** 每轮 O(n) 全量深克隆 + sanitize + namespace 展开 + 摘要 + 估算(`context/mod.rs:198-205` / `agentic/mod.rs:1904-1921`,`1945-1957`,`1981-1984`)→ 会话内 O(n²) 累积(全量重发本体);治本=设计档 P2 分层组装。
- **★HIGH ③** 预算上限**恒 128K**不随模型窗口(`commands/ai/mod.rs:1239` `ContextManager::new(ContextConfig::default())`;`context_helpers.rs:131/140-142`;子代理同 `sub_agent.rs:117-127`)→ 1M 窗口模型被当 128K 裁(能力降级),小窗口模型预算高于实际窗口。修:`ContextConfig` 字段 pub,可在 src-tauri 侧按 resolved_model 窗口构造(无需改 df-ai)。
- **HIGH ④(压缩)** 压缩**单向不可逆**:被压原文对 LLM 永久不可见(`context/mod.rs:664-699` 标 Compressed + `sanitize.rs:42` is_active 过滤出局),设计 C-r1「压缩不丢/可召回」未落地 → 方向:复用 P1 句柄模式,压缩写 pointer 可回读。
- MED ⑤ `chars_ratio=0.35` 中文低估 2-3 倍、base64 高估(`context_helpers.rs:29-44/73-74`);⑥ 保护区固定 6 条 + 补偿仅 300 字(`:648/:655`);⑦ 分层组装 P2 未落地(窗口外历史无检索召回);⑧ **★** system 可变段(日期+项目/任务清单)内嵌稳定段致**前缀缓存失效**(`prompt.rs:611-635` 缓存拆分开关默认关;清单描述不截断 `:565/:584`);⑨ 滑动裁剪致前缀缓存整体失配(`context/mod.rs:239-246`);⑩ 一次压尽(windows 式分批未做);⑪ **后台压缩无 epoch/内容版本守卫**——`clear()` 保留 per_conv 条目且复位 is_compressing → 窗口内旧摘要可注入已清空的新上下文(`context_lifecycle.rs:206-323`,loop 侧有 F1 epoch,此 task 侧无);⑫ 后台压缩**无 panic 兜底** → is_compressing 永真致压缩永久停摆。
- LOW ⑬ 估值双轨(estimated_prompt 准确用于路由 vs history_tokens 失真用于预算)从不互相校准;⑭ sys_tokens 未计工具 schema。
- **已核良好**:裁剪三元组原子性正确(`context/mod.rs:612-642`);909 两处修复(锚点避让/单锁复位)现状有效,未见旧窗口复现。
941. [ ] **C-r0 验证命令后台化:方案已定,落地需拍板(行为变更)**(2026-10-10 Wave A 5a 勘察,代理按纪律拒改并交方案)
- 事实:`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 收件箱。设计先行(与队列/注入语义联动,防双头)。
961. [ ] **桌面轮次交互定位重定义(2026-10-11 用户拍板,补7 三条)**:①轨迹卡片收敛:除主回复气泡外交互收敛到一张「有动态+可折叠」的轨迹卡(现有轮次折叠类型),**修缺陷:正生成时卡片展开不了**;②轮次语义纠正:现「压缩后即新轮次」定位错——正确定义=**一次信息发起=一个任务轮次,中途补充信息(inject)仍属同一轮,全部完成后新发起才是新轮**;③轮次编号稳定化:现编号不稳定(刚是 5 一会下面又出现 1)——应固定为「该对话的第 1 轮/第 2 轮」持久编号(与消息 seq/轮边界锚点绑定,不随视图压缩/折叠漂移)。涉及:MessageList 折叠模型+轮次归属模型(压缩边界 vs 发起边界)+编号生成规则。设计先行,拆批实施。
-3438
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -1,15 +1,17 @@
{
"name": "devflow",
"private": true,
"version": "0.1.1",
"version": "0.1.2",
"type": "module",
"scripts": {
"dev": "vite",
"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",
"check:i18n": "node scripts/check-i18n.cjs",
"preview": "vite preview",
"test:e2e": "playwright test",
"test:scenario": "playwright test tests/e2e/stream-body-visibility.spec.ts",
"tauri": "tauri",
"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 });
}
});
+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
+11 -1
View File
@@ -36,7 +36,17 @@ if ($Mode -eq "verify") {
Write-Host "== 通道2: 全量发版换装(tauri build -> 静默安装 -> 启动验证) =="
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 }
$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
# 流程: 等旧进程退出并探测句柄释放, 静默安装, 按构建产物哈希校验且失败重试一次, 交互用户可见重启, 结束自清理
$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'
$builtExe = 'E:\wk-lab\devflow\target\release\devflow.exe'
$log = 'E:\wk-lab\devflow\swap-install.log'
$lockFile = 'E:\wk-lab\devflow\swap-install.lock'
$log = 'E:\wk-lab\devflow\temp\swap-install.log'
$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
if (Test-Path $builtExe) { $hashWant = (Get-FileHash $builtExe -Algorithm SHA256).Hash }
if (Test-Path $builtExe) { $hashWant = Get-Sha256 $builtExe }
# 0. SYSTEM 上下文转投用户会话
$who = whoami
@@ -69,7 +90,7 @@ function Invoke-SwapInstall {
"[$(Get-Date -Format 'HH:mm:ss')] FAIL: target missing after install" | Add-Content $log
return $null
}
$afterHash = (Get-FileHash $target -Algorithm SHA256).Hash
$afterHash = Get-Sha256 $target
$afterSize = (Get-Item $target).Length
"[$(Get-Date -Format 'HH:mm:ss')] installed size=$afterSize hash=$($afterHash.Substring(0, 12))" | Add-Content $log
return $afterHash
@@ -99,7 +120,7 @@ try {
# 3. 安装前快照, 供弱校验与前后比对
$before = @{ hash = 'absent'; size = 0 }
if (Test-Path $target) {
$before.hash = (Get-FileHash $target -Algorithm SHA256).Hash
$before.hash = Get-Sha256 $target
$before.size = (Get-Item $target).Length
}
$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
exit 1
}
+9 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "devflow"
version = "0.1.0"
version = "0.1.2"
description = "DevFlow Desktop App"
authors = [""]
edition = "2021"
@@ -23,6 +23,8 @@ tauri-plugin-window-state = "2"
tauri-plugin-notification = "2"
# Phase3 单实例:二次启动唤起已有进程主窗口,防多实例抢 18765 端口
tauri-plugin-single-instance = "2"
# B2 应用内升级:L1 接入 tauri-plugin-updater(内建 ed25519 签名校验)
tauri-plugin-updater = "2"
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
@@ -80,6 +82,12 @@ futures-util = "0.3"
# 使用 rustls-tls(非 native-tls),避免 Windows SChannel 同步阻塞 tokio 工作线程
# 致 BUG-2026-07-17(aichat 流式调用永久 hang,外层 tokio timeout 亦无法推进计时器)。
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 → 去噪音 + 截断)。
# 替代 http_request 拿原始 HTML(噪声大、爆 token)。turndown.js 移植,只读 GET,与 http_request 共享 SSRF 防护。
htmd = "0.5"
+2 -1
View File
@@ -24,6 +24,7 @@
"core:webview:allow-create-webview-window",
"dialog: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,
"rejected",
Some(format!("审批超时({}分钟)自动取消", timeout_minutes)),
None,
)
.await;
}
@@ -6,7 +6,7 @@
//!
//! 流程(对齐 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式):
//! ① 读 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 裁剪。
//!
//! 口径决策(任务让"你判断"):选**延迟 mutate(成功才改)**而非"失败回滚 status"。
@@ -35,6 +35,7 @@ use std::sync::Arc;
use tauri::AppHandle;
use tokio::sync::Mutex;
use df_ai::context::ContextManager;
use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary};
use df_ai::provider::ChatMessage;
use df_storage::models::AiProviderRecord;
@@ -54,11 +55,37 @@ const PROTECT_COUNT: usize = 6;
/// 改进3 B 常量开关:压缩失败时是否启用关键词摘要兜底(默认 true)。
///
/// true(默认):LLM 压缩失败 → compress_old_messages 标 compressed(释放 token)
/// + insert_at(0, system 关键词摘要),保留用户反复提及的主题词作续接锚点。
/// + push(system 关键词摘要)尾部追加,保留用户反复提及的主题词作续接锚点。
/// false(回退):回退原裸裁剪行为(不插关键词摘要,仅 build_for_request 裁剪保最近 6 条)。
/// 排障/对比用:置 false 即可观察无兜底时的裁剪效果。
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 之前预处理。
///
/// **L0-3(2026-08-09)后台化**:压缩 LLM 调用不再同步阻塞本轮 loop(非流式 LLM 最长 60s)。
@@ -114,7 +141,7 @@ pub(super) async fn maybe_auto_compress(
emit_ai_chat_event(app_handle, AiChatEvent::AiCompressing {
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 conv = session.conv(&conv_id);
conv.messages.set_compressing(true);
@@ -123,6 +150,9 @@ pub(super) async fn maybe_auto_compress(
// 切片前按当时实际长度钳制,防 index out of bounds panic
// (后续 compress_old_messages 内部自带 min(len) 钳制,口径一致)。
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 active_msgs: Vec<ChatMessage> = msgs[..cut]
.iter()
@@ -131,7 +161,7 @@ pub(super) async fn maybe_auto_compress(
.collect();
let lang = conv.agent_language.clone()
.unwrap_or_else(|| "zh-CN".to_string());
(active_msgs, lang)
(active_msgs, lang, head_id)
};
// T2-修复(方案A):展开 namespace 引用为真实内容,再喂压缩 LLM/关键词摘要。
@@ -203,7 +233,15 @@ pub(super) async fn maybe_auto_compress(
let conv_id = conv_id.to_string();
let provider_config = provider_config.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 {
// 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)。
// 失败 → 同 LLM 失败降级:复位 is_compressing + 关键词摘要兜底(标 compressed 释放
// token + 插关键词),保持「失败→兜底→扣 token」语义——否则 history_tokens 未降,
@@ -218,13 +256,8 @@ pub(super) async fn maybe_auto_compress(
"[ai] 后台压缩 provider 重建失败,降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
KEYWORD_FALLBACK_ENABLED,
);
// 单次锁内 check+mutate:复位 is_compressing(防 conv 两次锁间被删惰性重建)。
{
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(&conv_id) {
session.conv(&conv_id).messages.set_compressing(false);
}
}
// 复位 is_compressing 收口进 keyword_fallback_commit 单锁(909①-补:
// 原先此处单独取锁复位,与提交之间留并发二次压缩窗口)。
keyword_fallback_commit(
&session_arc,
&conv_id,
@@ -232,6 +265,7 @@ pub(super) async fn maybe_auto_compress(
&keyword_fallback,
pre_compress_tokens,
"压缩 provider 重建失败",
&head_id,
)
.await;
return;
@@ -252,7 +286,7 @@ pub(super) async fn maybe_auto_compress(
match compress_outcome {
Ok(Some(summary)) => {
// LLM 成功 → 标 compressed(扣 token)+ 摘要 system 插首位 + set_compressing(false)。
// LLM 成功 → 标 compressed(扣 token)+ 摘要 system 尾部追加 + set_compressing(false)。
// conv 已删(per_conv 无条目)时丢弃压缩结果——惰性重建会污染新 conv,
// is_compressing 标志随 conv 销毁无意义,直接 return。
{
@@ -261,9 +295,22 @@ pub(super) async fn maybe_auto_compress(
return;
}
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;
}
let _compressed = conv.messages.compress_old_messages(protect_start);
// NL 摘要插首位 + JSON 卡片回填 WorkingContext,决策随压缩重锚定
conv.messages.insert_at(0, ChatMessage::system(&summary.nl_summary));
// NL 摘要尾部追加(而非 insert_at(0) 头部插入):头部插入会位移整条
// 前缀字节,使 DeepSeek 等自动前缀缓存的 provider 全部缓存失效
// (命中率掉到个位数)。尾部追加紧邻本轮内容 LLM 仍可见,前缀稳定
// 命中率最大化。JSON 卡片回填 WorkingContext,决策随压缩重锚定。
conv.messages.push_system_anchor(ChatMessage::system(&summary.nl_summary));
conv.messages.set_compressing(false);
conv.working_context.sync_after_compress(&summary.json_card);
}
@@ -271,7 +318,7 @@ pub(super) async fn maybe_auto_compress(
conv_id = %conv_id,
iteration,
pre_tokens = pre_compress_tokens,
"[ai] 自动压缩成功(后台),摘要已插首位"
"[ai] 自动压缩成功(后台),摘要已尾部追加"
);
// 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体),
// 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。
@@ -286,29 +333,28 @@ pub(super) async fn maybe_auto_compress(
// 单次锁内 check+mutate(防 conv 在两次锁间被删致惰性重建污染新 conv)。
let mut session = session_arc.lock().await;
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) => {
// LLM 失败 → 改进3 B:仍标 compressed 释放 token + 关键词摘要塞回首条(非裸裁剪)。
// LLM 失败 → 改进3 B:仍标 compressed 释放 token + 关键词摘要尾部追加(非裸裁剪)。
//
// 旧行为:消息状态完全不变,降级走 build_for_request 裁剪(丢主题)。
// 新行为(KEYWORD_FALLBACK_ENABLED=true 默认):
// - compress_old_messages 标 [..protect_start] active 为 compressed(释放 token,
// 与成功路径一致,后续 build_for_request 不再把它们进 LLM 上下文);
// - keyword_fallback 非空 → insert_at(0, system 关键词摘要)作续接锚点;
// - keyword_fallback 非空 → push(system 关键词摘要)尾部追加作续接锚点;
// - keyword_fallback 空(无 user 消息/无可提取词)→ 不插,等价旧裁剪(保底)。
// 持久化语义不变:compressed 仍软删可追溯(DB 全量保留),与成功路径一致。
// 压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false)
// 误判生成结束(后端 loop 实际继续),致"压缩后停止"。降级为 warn + 兜底释放
// token + build_for_request 裁剪,loop 继续 stream_llm,用户自然看到后续回复。
// 单次锁内 check+mutate(防 conv 在两次锁间被删致惰性重建污染新 conv)。
{
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(&conv_id) {
session.conv(&conv_id).messages.set_compressing(false);
}
}
// 复位 is_compressing 收口进 keyword_fallback_commit 单锁(909①-补,
// 同 provider 重建失败路径;消锁外复位→再取锁提交的并发窗口)。
tracing::warn!(
conv_id = %conv_id,
error = %e,
@@ -323,10 +369,21 @@ pub(super) async fn maybe_auto_compress(
&keyword_fallback,
pre_compress_tokens,
"压缩失败",
&head_id,
)
.await;
}
}
}) // 闭 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 +393,22 @@ pub(super) async fn maybe_auto_compress(
/// 关键词兜底提交单点(DRY #8 收敛,2026-10-01)。
///
/// 抽自 [`maybe_auto_compress`] 后台 task 内的逐字双胞胎块:压缩 provider 重建失败路径
/// 与压缩 LLM 失败路径共用「KEYWORD_FALLBACK_ENABLED 门控 → 锁内 check+mutate
/// (标 compressed 释放 token + 关键词摘要插首位,非空才插)→ 成功插入打 info」。
/// 与压缩 LLM 失败路径共用「复位 is_compressing → KEYWORD_FALLBACK_ENABLED 门控 → 锁内
/// check+mutate(标 compressed 释放 token + 关键词摘要尾部追加,非空才插)→ 成功插入打 info」。
/// 两条失败路径只差日志正文,经 `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 已复位;复位会误伤新会话可能已启的新压缩信令)。
async fn keyword_fallback_commit(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
@@ -349,30 +416,167 @@ async fn keyword_fallback_commit(
keyword_fallback: &str,
pre_tokens: u32,
log_head: &str,
head_id: &Option<String>,
) {
if !KEYWORD_FALLBACK_ENABLED {
return;
}
let inserted = {
let mut session = session_arc.lock().await;
if !session.per_conv.contains_key(conv_id) {
return;
}
let conv = session.conv(conv_id);
let _compressed = conv.messages.compress_old_messages(protect_start);
if keyword_fallback.is_empty() {
// 939⑪ 内容版本守卫:上下文已被整体替换 → 丢弃兜底提交(标志亦不复位)。
if !version_matches(head_id, &head_id_of(&mut conv.messages)) {
tracing::warn!(
conv_id = %conv_id,
"[ai] {}兜底提交前上下文版本已变(疑似 clear 重建),丢弃",
log_head
);
return;
}
// 复位与提交同锁原子(909①-补):消「锁外复位→再取锁提交」之间的并发二次压缩窗口。
conv.messages.set_compressing(false);
if !KEYWORD_FALLBACK_ENABLED {
// 开关关闭 → 回退原裸裁剪行为:不压缩不插摘要,仅完成复位。
false
} else {
conv.messages.insert_at(0, ChatMessage::system(keyword_fallback));
true
let _compressed = conv.messages.compress_old_messages(protect_start);
if keyword_fallback.is_empty() {
false
} else {
// 尾部追加(而非 insert_at(0) 头部插入):头部插入位移整条前缀字节,
// 使 DeepSeek 等自动前缀缓存 provider 全部缓存失效(命中率掉到个位数)。
// 尾部追加紧邻本轮内容 LLM 仍可见,前缀稳定命中率最大化。
conv.messages.push_system_anchor(ChatMessage::system(keyword_fallback));
true
}
}
};
if inserted {
tracing::info!(
conv_id = %conv_id,
pre_tokens,
"[ai] {}兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)",
"[ai] {}兜底(后台):关键词摘要已尾部追加(compressed 标记已扣 token)",
log_head
);
}
}
#[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).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).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).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 必须复位,否则压缩永久停摆"
);
}
}
+5 -1
View File
@@ -41,7 +41,7 @@ pub(crate) async fn insert_system_notice(
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
conv.messages.push(ChatMessage::system(text));
conv.messages.push_system_anchor(ChatMessage::system(text));
tracing::info!(conv_id = %conv_id, "[ai] {}", log_tag);
}
}
@@ -104,6 +104,8 @@ pub async fn try_continue_agent_loop(
let zero_usage = df_ai::provider::TokenUsage::default();
super::emit_ai_completed_once(
app, &emit_conv_id, &zero_usage, false,
// 零 token 收敛信号,非截断(看板 953)。
false,
None, None, None, true,
&snap.pinned_goals_snapshot, false,
).await;
@@ -177,6 +179,8 @@ pub async fn try_continue_agent_loop(
let zero_usage = df_ai::provider::TokenUsage::default();
super::emit_ai_completed_once(
app, &conv_id_owned, &zero_usage, false,
// 零 token 收敛信号,非截断(看板 953)。
false,
None, None, None, true,
&snap.pinned_goals_snapshot, false,
).await;
+323 -29
View File
@@ -46,6 +46,8 @@ use super::conversation::{save_conversation, TokenAccumulator};
use super::knowledge_inject::{maybe_spawn_extraction};
use super::title::{ensure_conversation_title, spawn_ensure_title};
use super::{AiChatEvent, AiSession, ErrorType};
// B5a 挂钟闸分任务定级:轮间闸读会话策略(未注册落 legacy 硬停,确定性任务检查点续跑)。
use super::unattended::{wall_clock_policy_for, WallClockAction};
// ConvState 经本文件内 `pub mod conv_state;` 同 crate 直接访问(conv_state::ConvState)。
/// L1 补丁:run_agentic_loop 入口 provider 解析超时保护的内部错误类型。
@@ -102,6 +104,8 @@ pub const ROUND_REENTRY_BACKOFF_SECS: [u64; ROUND_REENTRY_MAX] = [60, 300, 900];
/// 无人值守长任务(max_iterations=0 不限轮次)缺 token/挂钟兜底——模型死循环级输出
/// 白烧预算无人踩闸。三闸在每轮头检查(轮间粒度,单轮内不中断):
/// ①挂钟:超 UNATTENDED_WALL_SECS(默认 30min,对齐委托任务超时底座)停靠 wall_clock;
/// 未注册分档的会话落此 legacy 阈值;已注册会话由 unattended::wall_clock_policy_for 的
/// 策略决定(确定性任务放宽+检查点续跑,探索性/失控疑似硬停),见下方挂钟闸;
/// ②累计 token:超 UNATTENDED_MAX_TOKENS(默认 5M)停靠 token_budget;
/// ③轮次:max_iterations>0 既有 for 上界 + 达上限停靠分支,不重复设闸。
/// 超限走既有 emit_task_parked——前端词表已预留,零新契约。
@@ -279,6 +283,21 @@ pub const GOAL_VERIFY_ENABLED: bool = false;
/// 保证 loop 必然终止,绝不死循环。LLM 续轮内调了工具(无论成败)计数器重置(说明在行动)。
pub const GOAL_VERIFY_MAX_ROUNDS: u32 = 2;
/// D5 空轮判据自愈开关(默认 true,u-claw 空轮自愈经验,看板 B 类)。
///
/// 会话轮次「无产出」(无工具调用且文本纯空白)连续 EMPTY_ROUND_PARK_THRESHOLD 次
/// → 判定卡死,走停靠止损(emit_task_parked 正典出口,停靠卡「继续」一键续跑),
/// 而非静默空收敛(改动前:空轮直接判 converged,用户收到零内容的「完成」)。
/// 未达阈值的空轮注入 nudge 续轮自愈(先自愈后止损,对齐 u-claw 经验)。
/// false(回退):整块跳过,空轮行为逐字节等价改动前(静默收敛),排障/对比用。
pub const EMPTY_ROUND_GUARD_ENABLED: bool = true;
/// D5 空轮停靠阈值(默认 3)。
///
/// 对齐 STALL_BREAKER_THRESHOLD/CIRCUIT_BREAKER_THRESHOLD=3 量级:连续 3 次无产出才判卡死;
/// 单次空轮(偶发 provider 抖动/纯 reasoning 输出未成文)由 nudge 续轮自愈,不触发停靠。
pub const EMPTY_ROUND_PARK_THRESHOLD: u32 = 3;
/// G2 警示是否回顾目标(默认 true,需 G1 goal 字段)。
///
/// true(默认):警示文本引用 pinned_goal(若存在)提示「回顾目标: {goal}」,精准;
@@ -341,6 +360,19 @@ pub const CIRCUIT_BREAKER_ENABLED: bool = true;
/// 本开关无意义;CIRCUIT_BREAKER_ENABLED=true 时本开关决定呈现形态。
pub const CIRCUIT_BREAKER_HELP_EVENT: bool = true;
/// 熔断呈现形态开关(#904 附项,2026-10-09):熔断同轮双卡叠加治理。
///
/// 背景:熔断路径原同时 emit 两张卡——求助卡 AiHelpRequired(换思路/授权路径/人工接管)
/// + 停靠卡 AiTaskParked(继续/归档),同轮叠加语义打架且文案冗长;且停靠卡「继续」
/// 正是熔断后的正典恢复入口(ai_continue_loop → recover_parked_inner)。
///
/// true(默认):熔断只发停靠一张卡——AiTaskParked 为唯一呈现,reason 内联原求助卡的
/// 关键信息(失败样本/路径),前端停靠卡即含恢复指引,零双卡叠加。
/// false(回退):保留旧行为(求助卡 + 停靠卡双发),用于排障/对比。
/// 注:本开关仅在 CIRCUIT_BREAKER_HELP_EVENT=true 时生效;后者=false 时求助卡本就是
/// AiError 错误气泡,与停靠卡不同族,不构成双卡叠加,维持原样。
pub const CIRCUIT_BREAKER_PARK_ONLY: bool = true;
// 占位配对完整性开关(解 400 orphan)。
//
// 单一真相源:`df_ai::context_helpers::PLACEHOLDER_INTEGRITY_ENABLED`(本模块顶部已 use)。
@@ -868,6 +900,12 @@ pub(crate) async fn run_agentic_loop(
// 区分"正常收敛退出"与"达 MAX 被截断退出"——后者末轮 tool_calls 仍非空(tool_result 不再回传 LLM),属异常
let mut converged = false;
// 看板 953:收敛轮截断标志。finalize_round_output 判定正常收敛时若该轮流被 provider
// max_tokens 上限腰斩(RoundOutcome::Continue 的 truncated_by_max_tokens),把标志带出 loop,
// 正常完成 AiCompleted 透传前端渲染截断提示条。仅 Converged 分支写;stop/保文/达上限等
// 退出路径不经过该分支,保持 false(截断只对「用户看到的最终回复」有意义)。
let mut converged_truncated = false;
// L1 断路器:连续同类工具失败滚动窗口(VecDeque<key>,长度封顶 CIRCUIT_BREAKER_WINDOW)。
// key=结构化信号(tool_name + 错误类别)。每轮 process_tool_calls 后追加末尾失败 key,
// 超窗自动淘汰最旧。窗口内同 key 计数达 CIRCUIT_BREAKER_THRESHOLD → 熔断退出。
@@ -896,6 +934,9 @@ pub(crate) async fn run_agentic_loop(
// 且无 applied.matched_target 证据时强制续轮,每续 +1。达 GOAL_VERIFY_MAX_ROUNDS 强制收敛
// + AiCompleted 标注 goal_unverified。续轮内调了工具则重置(说明 LLM 在行动非空转)。
let mut goal_verify_rounds: u32 = 0;
// D5 空轮连续计数(EMPTY_ROUND_GUARD_ENABLED 门控):连续「无产出」轮数,
// finalize_round_output 内记账——空轮 +1,有产出轮清零;达阈值停靠止损。
let mut empty_round_streak: u32 = 0;
// DeepSeek thinking 模式推理内容跨轮透传
let mut last_reasoning_content: Option<String> = None;
@@ -1085,6 +1126,8 @@ pub(crate) async fn run_agentic_loop(
&coord_usage,
// Coordinator 路径无 round(未 stream),无估算语义 → false。
false,
// Coordinator 路径无 round,无截断语义 → false(看板 953)。
false,
None, None, None, true,
&pinned_goals_snapshot,
&app_handle,
@@ -1150,8 +1193,9 @@ pub(crate) async fn run_agentic_loop(
// 无注入扩轮时动态判定与原上界等价,行为零变化。扩轮总数封顶防注入洪水无限续跑。
let mut injected_extra_rounds: usize = 0;
// H2 token 三闸基点:loop 启动挂钟起点(轮间粒度检查,单轮不中断)
let loop_started = tokio::time::Instant::now();
// H2 token 三闸基点:loop 启动挂钟起点(轮间粒度检查,单轮不中断)。
// 可变:确定性任务到点走检查点续跑时顺延起点(见下方挂钟闸)。
let mut loop_started = tokio::time::Instant::now();
// H3 外部干预感知基点:loop 启动时在被监视工作区取 git HEAD 快照(轮间粒度比对)。
// 设计口径:loop_epoch 管「引擎内接管」,H3 管「引擎外干预」——用户/另一 AI 会话在 loop
@@ -1206,10 +1250,30 @@ pub(crate) async fn run_agentic_loop(
{
let elapsed = loop_started.elapsed().as_secs();
let total_tokens = tokens.total() as u64;
let over = if elapsed >= UNATTENDED_WALL_SECS {
// ── 挂钟闸(B5a):读会话策略决定阈值与到点动作 ──
// None(未注册/分档关)→ legacy 30min 硬停,零行为变更;
// CheckpointContinue(确定性任务)→ 到点存进度 + 顺延挂钟起点后本轮续跑,不停工;
// HardPark(探索性/失控疑似)→ 维持原 emit_task_parked(wall_clock) + teardown。
let wall_policy = wall_clock_policy_for(&conv_id);
let wall_deadline = wall_policy.map_or(UNATTENDED_WALL_SECS, |p| p.deadline_secs);
let mut wall_exceeded = elapsed >= wall_deadline;
if wall_exceeded
&& matches!(wall_policy.map(|p| p.action), Some(WallClockAction::CheckpointContinue))
{
// 检查点续跑:落库当前进度(不等 loop 出口),挂钟起点顺延,本轮继续。
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
save_conversation(&session_arc, &db, &conv_id, Some(&save_usage), Some(&resolved_model), true).await;
loop_started = tokio::time::Instant::now();
wall_exceeded = false;
tracing::info!(
conv_id = %conv_id, elapsed, deadline = wall_deadline,
"[ai] 挂钟到点,检查点续跑(确定性任务)"
);
}
let over = if wall_exceeded {
Some((
"wall_clock",
format!("任务运行超 {} 分钟,安全停靠:已完成进度已保存,点继续或重发消息可续跑", UNATTENDED_WALL_SECS / 60),
format!("任务运行超 {} 分钟,安全停靠:已完成进度已保存,点继续或重发消息可续跑", wall_deadline / 60),
))
} else if total_tokens >= UNATTENDED_MAX_TOKENS {
Some((
@@ -1229,6 +1293,7 @@ pub(crate) async fn run_agentic_loop(
Some(&save_usage), Some(&resolved_model),
false, &provider_config, &llm_concurrency, &mut guard,
&emit_usage, last_round_estimated,
false,
Some(true), None, Some(reason), true,
&pinned_goals_snapshot, &app_handle, &loop_epoch_arc, my_epoch, false,
).await;
@@ -1261,6 +1326,7 @@ pub(crate) async fn run_agentic_loop(
Some(&save_usage), Some(&resolved_model),
false, &provider_config, &llm_concurrency, &mut guard,
&emit_usage, last_round_estimated,
false,
Some(true), None, Some("external_change"), true,
&pinned_goals_snapshot, &app_handle, &loop_epoch_arc, my_epoch, false,
).await;
@@ -1351,9 +1417,13 @@ pub(crate) async fn run_agentic_loop(
"[ai] 连续 {} 轮失败,重入预算耗尽,任务停靠",
consecutive_round_failures,
);
// 停靠语义:超时类(上游收了请求不响应,非确定性)与其他 Fatal 走
// 不同文案——超时突出「可继续」,由停靠卡「继续」一键续跑而非中断;
// 其余 Fatal 维持原连续失败文案(确定性错误,续跑无益)。
let reason = fatal_park_reason(consecutive_round_failures, &error);
crate::commands::ai::emit_task_parked(
&app_handle, &conv_id,
&format!("模型调用连续 {} 轮失败(重入退避后仍未恢复),任务安全停靠:{}", consecutive_round_failures, error),
&reason,
"fatal",
);
return;
@@ -1392,12 +1462,15 @@ pub(crate) async fn run_agentic_loop(
&full_text, &tool_calls_acc, &round_usage, round_is_estimated,
&last_reasoning_content,
&mut goal_verify_rounds,
&mut empty_round_streak,
round_truncated,
).await {
// RunTools → 继续工具调用段(工具调用数由该段内部取,与原时序一致)
RoundEpilogue::RunTools => {}
RoundEpilogue::Converged => {
// 无工具调用且无续轮需求 → 正常收敛退出(原 converged = true; break,语义不变)
// 看板 953:收敛轮被 max_tokens 截断时把标志带出 loop(正常完成 emit 透传前端)
converged_truncated = round_truncated;
converged = true;
break;
}
@@ -1520,6 +1593,7 @@ pub(crate) async fn run_agentic_loop(
&mut guard,
&emit_usage,
last_round_estimated,
false,
Some(true), None, Some("max_rounds"), false,
&pinned_goals_snapshot,
&app_handle,
@@ -1584,6 +1658,7 @@ pub(crate) async fn run_agentic_loop(
emit_ai_completed_once(
&app_handle, &conv_id, &normal_usage,
last_round_estimated,
converged_truncated,
None, None, None, true,
&pinned_goals_snapshot,
true,
@@ -1607,6 +1682,7 @@ pub(crate) async fn run_agentic_loop(
&mut guard,
&emit_usage,
last_round_estimated,
false,
None, None, None, true,
&pinned_goals_snapshot,
&app_handle,
@@ -1724,6 +1800,8 @@ async fn prepare_round_inputs(
&emit_usage,
// 入口 stop:本轮尚未 stream,last_round_estimated 仍为初始 false(无估算)。
last_round_estimated,
// 入口 stop:停止语义非截断,恒 false(看板 953)。
false,
None, None, None, true,
pinned_goals_snapshot,
app_handle,
@@ -1788,7 +1866,7 @@ async fn prepare_round_inputs(
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
conv.messages.push(ChatMessage::system(&marker_text));
conv.messages.push_system_anchor(ChatMessage::system(&marker_text));
tracing::info!(
conv_id = %conv_id,
iteration,
@@ -2020,6 +2098,8 @@ async fn finalize_round_output(
round_is_estimated: bool,
last_reasoning_content: &Option<String>,
goal_verify_rounds: &mut u32,
// D5 空轮连续计数:空轮 +1 / 有产出轮清零,达 EMPTY_ROUND_PARK_THRESHOLD 停靠止损。
empty_round_streak: &mut u32,
// 本轮流因 provider max_tokens 上限截断(P2-1),收敛时据实提示不冒充完整
round_truncated_by_max_tokens: bool,
) -> RoundEpilogue {
@@ -2031,6 +2111,11 @@ async fn finalize_round_output(
// 追加 assistant 消息到历史 + G1 目标提取(扁平重构,原嵌套 8 层 → 3 层)
let has_tool_calls = !tool_calls_acc.is_empty();
// D5 空轮记账:有产出轮(有工具调用或文本非空)清零连续空轮计数;
// 空轮(+1/止损/nudge)在下方 !has_tool_calls 分支处理。
if EMPTY_ROUND_GUARD_ENABLED && !round_is_barren(has_tool_calls, full_text) {
*empty_round_streak = 0;
}
{
let mut session = session_arc.lock().await;
if !session.per_conv.contains_key(conv_id) {
@@ -2120,6 +2205,8 @@ async fn finalize_round_output(
guard,
&emit_usage,
last_round_estimated,
// stop 语义非截断,恒 false(看板 953)。
false,
None, None, None, true,
pinned_goals_snapshot,
app_handle,
@@ -2131,6 +2218,43 @@ async fn finalize_round_output(
// 无工具调用 → 最终文本响应。正常收敛退出,或目标验证续轮(GOAL_VERIFY_ENABLED)。
if !has_tool_calls {
// D5 空轮判据自愈(先于目标验证:空轮无任何产出,谈不上验证目标):
// 本轮无产出(纯空白文本 + 无工具调用,push_assistant_message 已跳过落库)→ 连续计数 +1;
// 达阈值 → 停靠止损(正典 park 序列对齐 H2 挂钟闸:先 emit_task_parked 再 teardown
// 正典收尾,停靠卡「继续」一键续跑),替代改动前的「静默空收敛完成」;
// 未达阈值 → 注入 nudge 续轮自愈(先自愈后止损),落到 RunTools(空 acc)即纯续轮。
if EMPTY_ROUND_GUARD_ENABLED && round_is_barren(has_tool_calls, full_text) {
*empty_round_streak += 1;
if empty_round_should_park(*empty_round_streak, EMPTY_ROUND_PARK_THRESHOLD) {
tracing::warn!(
conv_id = %conv_id, iteration, streak = *empty_round_streak,
"[ai] 连续 {} 轮无产出,判定卡死,任务停靠止损", *empty_round_streak
);
let message = format!(
"连续 {} 轮无任何产出(空文本且无工具调用),疑似模型卡死,已安全停靠:点继续或重发消息可续跑",
*empty_round_streak
);
crate::commands::ai::emit_task_parked(
&app_handle, &conv_id, &message, "empty_convergence",
);
// teardown 正典收尾(save + guard 复位 + AiCompleted(incomplete))再退,
// 口径对齐 H2 闸停靠(spawn_title=false / emit_incomplete=Some(true))。
let save_usage = usage_delta_since(tokens, saved_token_snapshot);
let emit_usage = tokens_snapshot(tokens);
teardown_round(
session_arc, db, conv_id,
Some(&save_usage), Some(resolved_model),
false, provider_config, llm_concurrency, guard,
&emit_usage, last_round_estimated,
false,
Some(true), None, Some("empty_convergence"), true,
pinned_goals_snapshot, app_handle, loop_epoch_arc, my_epoch, false,
).await;
return RoundEpilogue::Exit;
}
insert_empty_round_nudge(session_arc, conv_id, iteration, *empty_round_streak).await;
return RoundEpilogue::RunTools;
}
// 目标验证续轮:开关开 + 有钉扎目标 + 本轮无 applied.matched_target 证据 + 未达上限
// → 不收敛,注入 nudge 强制续轮(治过早宣称,机制非 prompt 说教)。
// 任一条件不满足 → 正常收敛(逐字节等价改动前)。
@@ -2261,12 +2385,16 @@ async fn process_round_tools(
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
return ToolsOutcome::Exit;
}
emit_circuit_breaker_tripped(app_handle, conv_id, max_count, &sample_key);
// #904 附项:默认只发停靠一张卡(CIRCUIT_BREAKER_PARK_ONLY),求助卡与停靠卡
// 同轮叠加语义打架;求助卡关键信息(失败样本)内联进停靠卡 reason。开关关则退旧双卡。
if !CIRCUIT_BREAKER_PARK_ONLY {
emit_circuit_breaker_tripped(app_handle, conv_id, max_count, &sample_key);
}
// 安全停靠(设计 §四):熔断即终局,补发 AiTaskParked(原状只 emit 求助卡,
// 托盘场景无独立事件;guard.reset 已在上方完成,现场已 save 于前一轮)。
crate::commands::ai::emit_task_parked(
app_handle, conv_id,
&format!("连续同类失败 {} 次已熔断,任务安全停靠;已完成进度已保存,调整思路后重发消息可继续", max_count),
&format!("连续同类失败 {} 次已熔断(最近错误: {}),任务安全停靠;已完成进度已保存,调整思路后重发消息可继续", max_count, sample_key),
"circuit_breaker",
);
return ToolsOutcome::Exit;
@@ -2379,7 +2507,7 @@ async fn insert_goal_verify_nudge(
let conv = session.conv(conv_id);
// 尾部追加而非 insert_at(0):nudge 紧邻本轮内容语义不变,且不位移既有前缀字节
// (insert_at(0) 会把整条历史后移,自动前缀缓存/Anthropic 显式缓存全失效)。
conv.messages.push(ChatMessage::system(&nudge_text));
conv.messages.push_system_anchor(ChatMessage::system(&nudge_text));
tracing::info!(
conv_id = %conv_id, iteration, verify_rounds,
"[GOAL-VERIFY] 目标未验证,注入续轮 nudge(强制续轮)"
@@ -2387,6 +2515,53 @@ async fn insert_goal_verify_nudge(
}
}
// ============================================================
// D5 空轮判据自愈(u-claw 空轮自愈经验,看板 B 类)
//
// 会话轮次「无产出」(无工具调用 + 文本纯空白)连续 N 次 → 判卡死,停靠止损而非静默空收敛。
// 与既有熔断的边界(实施前查重,确认无重复覆盖):
// - G2/stall_detector/L1 断路器:工具层信号(签名重复/连续同类失败),空轮无工具调用,
// 不进其计数面;
// - ROUND_REENTRY/H2 三闸:网络失败与预算兜底;空轮是「流成功返回但零内容」,两者均不覆盖;
// - sub_agent EmptyConvergence(P3-7):子代理侧同型判据已落(空收敛降级 Truncated),
// 本闸补主会话侧对齐(stop_kind 沿用 empty_convergence 词表,零新契约)。
// ============================================================
/// 纯函数:单轮是否「无产出」。无工具调用且文本纯空白 = 无产出
/// (判据对齐 push_assistant_message 的空轮不落库口径:纯空白且无 tool_calls 即无任何信息)。
fn round_is_barren(has_tool_calls: bool, text: &str) -> bool {
!has_tool_calls && text.trim().is_empty()
}
/// 纯函数:连续无产出是否达停靠阈值(u-claw 判据:连续 N 次空轮 → 卡死止损)。
fn empty_round_should_park(streak: u32, threshold: u32) -> bool {
streak >= threshold
}
/// 注入空轮自愈 nudge(强制续轮,非软提示):直给结论或调工具,二选一。
/// 复用 insert_goal_verify_nudge 的尾部追加范式(nudge 紧邻本轮内容语义不变,
/// 不位移既有前缀字节,自动前缀缓存不失效)。
async fn insert_empty_round_nudge(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
iteration: usize,
streak: u32,
) {
let nudge_text = format!(
"⚠ 你上一轮没有输出任何内容(无文本也无工具调用),已连续 {} 轮。请立即给出有效动作:要么直接输出当前结论与进展,要么调用工具推进任务;不得再次空响应,否则任务将被安全停靠。",
streak
);
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
conv.messages.push_system_anchor(ChatMessage::system(&nudge_text));
tracing::info!(
conv_id = %conv_id, iteration, streak,
"[EMPTY-ROUND] 空轮未达停靠阈值,注入自愈 nudge 续轮"
);
}
}
// ── emit_circuit_breaker_tripped: L1 断路器熔断统一 emit(扁平抽自原嵌套块) ──
// 开关 CIRCUIT_BREAKER_HELP_EVENT: true=求助卡(默认), false=AiError 错误气泡(兜底回退)。
fn emit_circuit_breaker_tripped(app_handle: &AppHandle, conv_id: &str, max_count: u32, sample_key: &str) {
@@ -2542,15 +2717,19 @@ async fn check_stall_breaker(
);
let path_desc = loop_hit_path.as_deref().unwrap_or("未知路径");
// DRY #1:双写正典(熔断求助卡跨端可见)
emit_ai_chat_event(
app_handle,
AiChatEvent::AiHelpRequired {
reason: format!("连续 {} 轮检测到对同一文件({})反复写删,疑似「写完就删」自毁循环已停止", new_loop_count, path_desc),
context: "写文件后又删除同一文件的紧凑交替,可能在反复销毁自己的工作成果而无进展。".into(),
options: vec!["回顾目标".into(), "换思路".into(), "停止".into()],
conversation_id: Some(conv_id.to_string()),
},
);
// #904 附项:默认只发停靠一张卡(CIRCUIT_BREAKER_PARK_ONLY),求助卡与停靠卡同轮
// 叠加语义打架;开关关则退旧双卡行为。
if !CIRCUIT_BREAKER_PARK_ONLY {
emit_ai_chat_event(
app_handle,
AiChatEvent::AiHelpRequired {
reason: format!("连续 {} 轮检测到对同一文件({})反复写删,疑似「写完就删」自毁循环已停止", new_loop_count, path_desc),
context: "写文件后又删除同一文件的紧凑交替,可能在反复销毁自己的工作成果而无进展。".into(),
options: vec!["回顾目标".into(), "换思路".into(), "停止".into()],
conversation_id: Some(conv_id.to_string()),
},
);
}
// P1 bg 化:熔断需人工介入,系统通知补位(条件发送,失败静默)
crate::commands::ai::notify::notify_session_event(
app_handle,
@@ -2573,15 +2752,19 @@ async fn check_stall_breaker(
"[ai] G2 探索熔断:连续 {} 次检测到重复工具调用,疑似卡住停止", new_count
);
// DRY #1:双写正典(熔断求助卡跨端可见)
emit_ai_chat_event(
app_handle,
AiChatEvent::AiHelpRequired {
reason: format!("连续 {} 次检测到重复的工具调用(同样的 grep/读文件反复),疑似卡住已停止", new_count),
context: "工具调用签名高度重复,可能在反复做同一件事而无进展。".into(),
options: vec!["回顾目标".into(), "换思路".into(), "停止".into()],
conversation_id: Some(conv_id.to_string()),
},
);
// #904 附项:默认只发停靠一张卡(CIRCUIT_BREAKER_PARK_ONLY),求助卡与停靠卡同轮
// 叠加语义打架;开关关则退旧双卡行为。
if !CIRCUIT_BREAKER_PARK_ONLY {
emit_ai_chat_event(
app_handle,
AiChatEvent::AiHelpRequired {
reason: format!("连续 {} 次检测到重复的工具调用(同样的 grep/读文件反复),疑似卡住已停止", new_count),
context: "工具调用签名高度重复,可能在反复做同一件事而无进展。".into(),
options: vec!["回顾目标".into(), "换思路".into(), "停止".into()],
conversation_id: Some(conv_id.to_string()),
},
);
}
// P1 bg 化:熔断需人工介入,系统通知补位(条件发送,失败静默)
crate::commands::ai::notify::notify_session_event(
app_handle,
@@ -2753,6 +2936,34 @@ fn classify_permanent_provider_error(error: &str) -> bool {
model_missing || no_access || param_reject
}
// ── is_timeout_error: 超时类失败判定(首字节超时/流式超时/调用超时) ──
// 超时是「上游收了请求却不响应」的非确定性失败:重入退避后仍未恢复时,续跑可能因上游恢复
// 而成功,故落停靠语义给一键「继续」,而非让用户只见裸错误文案后中断。与之相对,4xx/鉴权/
// 参数类 Fatal 是确定性错误(改配置才有解,续跑无益),不套用本判定——判定只读错误文本的
// 超时特征(引擎各超时出口文案含「超时」或 diag 包装的 timeout 分类)。
fn is_timeout_error(error: &str) -> bool {
error.to_lowercase().contains("timeout") || error.contains("超时")
}
/// 重入预算耗尽时的停靠文案决策(纯函数,便于单测锁定「超时类 → 停靠」契约)。
///
/// 超时类走「超时 + 可续跑」文案(点明可一键继续);其余 Fatal 维持原「连续失败」文案。
/// stop_kind 均用既有 "fatal"(不新增停靠族:前端 stop_kind 词表/i18n 未同步,新值会退化
/// 为中性兜底标签;超时与其他 Fatal 的区分落在文案语义,卡片「继续」按钮两者皆已具备)。
fn fatal_park_reason(failures: usize, error: &str) -> String {
if is_timeout_error(error) {
format!(
"模型响应连续 {} 轮超时(上游收了请求未及时响应,重入退避后仍未恢复),任务安全停靠:已完成进度已保存,点「继续」可基于当前上下文续跑",
failures
)
} else {
format!(
"模型调用连续 {} 轮失败(重入退避后仍未恢复),任务安全停靠:{}",
failures, error
)
}
}
// ── emit_fatal_error: 致命错误统一 emit AiError(扁平抽自原 Fatal/Exhausted 双分支重复代码) ──
// 双写经 emit_ai_chat_event。error_type: 可判定的永久性 provider 错误(模型不存在/令牌无权/
// 参数拒绝类 400)归 ProviderConfig,其余维持 Network 现状(2026-10-01 两端统一时的口径)。
@@ -2827,6 +3038,8 @@ pub(crate) async fn emit_ai_completed_once(
conv_id: &str,
usage: &df_ai::provider::TokenUsage,
is_estimated: bool,
// 看板 953:最终收敛回复因 provider max_tokens 截断(仅正常收敛出口传 true,其余 false)
truncated_by_max_tokens: bool,
emit_incomplete: Option<bool>,
publish_incomplete: Option<bool>,
incomplete_reason: Option<&str>,
@@ -2863,7 +3076,7 @@ pub(crate) async fn emit_ai_completed_once(
// publish 端:do_publish=false(max_iterations)跳过 publish(保留原无 publish 行为);
// MidStream incomplete 分叉(emit=Some(true)/publish=None)事件值不同,不能并成单次
// emit_ai_chat_event 调用,分路构造(其余 5 路径两端同值,走单次双写)。
let ev = completed_event(usage, is_estimated, emit_incomplete, incomplete_reason, conv_id, pinned_goals);
let ev = completed_event(usage, is_estimated, truncated_by_max_tokens, emit_incomplete, incomplete_reason, conv_id, pinned_goals);
if do_publish && emit_incomplete == publish_incomplete {
emit_ai_chat_event(app_handle, ev);
// P0 修复(2026-10-05):正常完成恒走此早退,完成通知桥必须在此投递——
@@ -2876,7 +3089,7 @@ pub(crate) async fn emit_ai_completed_once(
let _ = app_handle.emit("ai-chat-event", ev);
if do_publish {
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(
completed_event(usage, is_estimated, publish_incomplete, None, conv_id, pinned_goals),
completed_event(usage, is_estimated, truncated_by_max_tokens, publish_incomplete, None, conv_id, pinned_goals),
);
}
// P1 bg 化:完成出口系统通知(条件发送,失败静默;stop 出口经调用方传 false 关闭)
@@ -2921,9 +3134,12 @@ pub(crate) async fn last_active_assistant_text(
/// 构造 AiCompleted 事件(emit_ai_completed_once 内部去重用,字段口径见其文档)。
/// incomplete_reason 与 incomplete 配对:emit 侧事件随 emit_incomplete 取值,publish 侧
/// 分叉事件(与 emit 不一致时)incomplete 为 None,reason 同步 None。
/// truncated_by_max_tokens(看板 953):最终收敛回复是否被 provider max_tokens 截断,
/// 透传前端标截断提示条;非正常收敛出口恒 false。
fn completed_event(
usage: &df_ai::provider::TokenUsage,
is_estimated: bool,
truncated_by_max_tokens: bool,
incomplete: Option<bool>,
incomplete_reason: Option<&str>,
conv_id: &str,
@@ -2937,6 +3153,7 @@ fn completed_event(
prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens,
reasoning_tokens: usage.reasoning_tokens,
is_estimated,
truncated_by_max_tokens,
incomplete,
incomplete_reason: incomplete_reason.map(str::to_string),
conversation_id: Some(conv_id.to_string()),
@@ -3160,6 +3377,9 @@ pub(crate) fn usage_delta_since(
// - save_usage/save_model:透传 save_conversation;Coordinator 传 (None, None) 仍会调 save(幂等)
// - emit_usage:emit AiCompleted 的 token 三元组来源(各退出点经 tokens.add/round-derived 后传入)
// - emit_estimated(G4.3, 2026-08-05):本轮 token 用量是否估算值,透传 emit_ai_completed_once 仅展示
// - truncated_by_max_tokens(看板 953):最终收敛回复因 provider max_tokens 截断,透传
// emit_ai_completed_once 仅展示标注;本 helper 覆盖的退出点(stop/保文/达上限等)均非
// 「正常收敛被腰斩」语义,调用方恒传 false(唯一 true 出口是正常完成直调 emit_ai_completed_once)
// - spawn_title:true → spawn_ensure_title(provider_config 后台生成,失败有 extract_title 兜底)
// - emit_incomplete/publish_incomplete/do_publish:透传 emit_ai_completed_once(见该 helper 文档)
// - loop_epoch_arc/my_epoch:F1 并发 epoch——旧 loop(epoch 不匹配)只 disarm 跳过 save/reset/emit,
@@ -3178,6 +3398,7 @@ pub(crate) async fn teardown_round(
guard: &mut GeneratingGuard,
emit_usage: &df_ai::provider::TokenUsage,
emit_estimated: bool,
truncated_by_max_tokens: bool,
emit_incomplete: Option<bool>,
publish_incomplete: Option<bool>,
// 不完整原因(emit 侧,与 emit_incomplete 配对):Some("max_rounds")=达最大轮次自动收尾,
@@ -3213,6 +3434,7 @@ pub(crate) async fn teardown_round(
// generating 复位后再 emit Completed(对齐原各路径顺序)
emit_ai_completed_once(
app_handle, conv_id, emit_usage, emit_estimated,
truncated_by_max_tokens,
emit_incomplete, publish_incomplete, incomplete_reason, do_publish,
pinned_goals,
notify,
@@ -3477,6 +3699,56 @@ mod round_reentry_tests {
}
}
// 902 契约:超时类失败在轮次重入预算耗尽时落停靠语义(而非只留裸错误文案中断)。
// 停靠文案由纯函数 fatal_park_reason 决策,本测试锁「超时类 → 超时+可续跑文案 /
// 非超时 Fatal → 原连续失败文案」两分支(完整 loop 依赖 Tauri 运行时,契约由纯函数锁定)。
#[cfg(test)]
mod fatal_park_tests {
use super::{fatal_park_reason, is_timeout_error};
#[test]
fn timeout_class_recognized() {
// 首字节超时(stream_recv 出口文案,全角括号)
assert!(is_timeout_error(
"流式响应超时(10秒未收到首字节,服务无响应或请求体过大)"
));
// 专用线程 120s 硬超时
assert!(is_timeout_error("LLM 调用超时(120s)"));
// diag 包装的英文分类(timeout)
assert!(is_timeout_error(
"[u-switch] 流式接收错误(timeout): request timed out"
));
// 非超时:4xx/鉴权/模型不存在不误判
assert!(!is_timeout_error(
"[x] AI 调用失败(HTTP 401): unauthorized"
));
assert!(!is_timeout_error("model is not found"));
}
#[test]
fn timeout_exhaustion_parks_with_continue_guidance() {
let reason = fatal_park_reason(4, "LLM 调用超时(120s)");
assert!(reason.contains("超时"), "超时类停靠文案须点明超时: {reason}");
assert!(
reason.contains("继续"),
"超时类停靠文案须给一键「继续」指引: {reason}"
);
}
#[test]
fn non_timeout_fatal_keeps_generic_reason() {
let reason = fatal_park_reason(4, "[x] AI 调用失败(HTTP 404): model not found");
assert!(
reason.contains("连续 4 轮失败"),
"非超时 Fatal 维持原连续失败文案: {reason}"
);
assert!(
reason.contains("model not found"),
"原诊断文本须保留: {reason}"
);
}
}
// 末轮注入扩轮(todo.md 28)loop 侧契约:到达原轮次终点时,注入侧顺延的余量必须被消费
// 续跑(「末轮注入→有续轮」),无余量则收尾(与原 for 自然结束等价,零行为变化)。
// 与注入侧判定 injection_hits_final_round(commands/ai/commands/chat.rs)分半锁同一契约。
@@ -3519,3 +3791,25 @@ mod inject_extra_rounds_tests {
assert_eq!(inject_extra_rounds_decision(usize::MAX, 0, 5), None);
}
}
#[cfg(test)]
mod empty_round_guard_tests {
use super::{empty_round_should_park, round_is_barren};
#[test]
fn barren_needs_no_tools_and_blank_text() {
// 空文本/纯空白 + 无工具调用 = 无产出;任一维度有内容即有产出
assert!(round_is_barren(false, ""));
assert!(round_is_barren(false, " \n\t "));
assert!(!round_is_barren(false, "结论:已完成"));
assert!(!round_is_barren(true, ""), "有工具调用即有产出(工具轮)");
}
#[test]
fn park_exactly_at_consecutive_threshold() {
assert!(!empty_round_should_park(1, 3));
assert!(!empty_round_should_park(2, 3));
assert!(empty_round_should_park(3, 3), "达阈值即停靠止损");
assert!(empty_round_should_park(4, 3));
}
}
@@ -335,10 +335,13 @@ pub(crate) fn summarize_tool_results(
.and_then(|id| id_to_name.get(id))
.map(|(n, _)| n.as_str())
.unwrap_or("tool");
// read_file 豁免压缩:AI 定向读代码,压缩 content 阉割代码分析能力。
// read_file handler 自带 limit 硬上限 2000 行,无爆 prompt 风险。
// 豁免判定在决策记录之前(豁免消息不进决策表,每轮恒豁免无漂移)。
if resolved_name == "read_file" {
// read_file 小文件豁免(P1 2026-10-10 改高阈值):AI 定向读代码,小文件需完整语义;
// 超 READ_FILE_OFFLOAD_BYTES 的读取不再豁免——实测 read_file 占上下文体量 29.6%
// (单条 max 150KB),原「一律豁免」让最大头裸奔(65K 均值压不下来的直接原因之一)。
// 超阈值走压缩+落盘,句柄可无损读回(read_file 读句柄路径)。
if resolved_name == "read_file"
&& content_len <= df_ai::context_helpers::READ_FILE_OFFLOAD_BYTES
{
tracing::debug!(
conv_id = %conv_id, iteration,
content_len,
+162 -12
View File
@@ -40,6 +40,27 @@ use super::{check_retry_exhausted, resolve_model_with_override};
use super::{emit_fatal_error, teardown_round, tokens_snapshot, usage_delta_since};
use super::super::event_bus::emit_ai_chat_event;
// ============================================================
// MidStream 短重试/续传预算(看板 #906,打扰治理)
// ============================================================
//
// 背景:网络抖动(如 u-switch 抖动期)导致流中途断,原路径(MidStreamSaved)直接
// emit_task_parked 停靠——无 Fatal 家族的 ROUND_REENTRY_MAX 重入预算,一次抖动即弹
// 「响应流中断/任务已停靠」卡并进行中任务终止,用户实测频繁打扰。
//
// 本层对齐 Fatal 重入语义,但采用「续传」而非「重发」:partial 已作为 assistant 消息
// 入库,续传请求 = 原请求 + partial(assistant)+ 续传 user 提示,模型从断点继续;
// 成功则本轮转为 Continue(不弹卡、不保文停靠),全部耗尽才落回原保文+停靠路径。
// 退避比 Fatal 短(5s/15s):midstream 断多为瞬态网络抖动,且 partial 已落库风险低,
// 快速恢复优于长等;仅 2 次预算,避免抖动期长时间静默。
pub(crate) const MIDSTREAM_REENTRY_MAX: usize = 2;
pub(crate) const MIDSTREAM_REENTRY_BACKOFF_SECS: [u64; MIDSTREAM_REENTRY_MAX] = [5, 15];
/// 续传提示(合成 user 消息,不落库):告知模型上一条回复因网络中断被截断,请从断点继续。
/// 不要求重述已完成内容(避免重复),只补完剩余部分。
pub(crate) const MIDSTREAM_CONTINUE_PROMPT: &str =
"[系统] 上一条回复因网络中断被截断,请从中断处继续补全剩余内容;不要重述已输出的部分,直接接着写。";
// ============================================================
// 单 Provider 流式结果 + fallback 辅助
// ============================================================
@@ -334,7 +355,7 @@ pub(crate) async fn stream_round(
// 重试总预算(挂钟,含 sleep + 各次请求耗时),对齐 retry::MAX_TOTAL_BUDGET 30s。
// 本轮各 candidate 共享一个 30s 预算(切换 provider 不重置预算,
// 防多 provider 串行重试累加超过单轮总预算)。超预算直接放弃重试交最终错误/保文路径。
let retry_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30);
// #906:该预算现于 'midstream 循环内每次(重)入刷新(续传重入获全新 30s),故此处不再声明。
// 外层 for candidate 包裹 stream_one_provider。
// 顺序:[primary, ...candidates](空池兜底时仅 primary)。
@@ -375,7 +396,21 @@ pub(crate) async fn stream_round(
);
}
let (full_text, tool_calls_acc, round_usage, incomplete, round_reasoning_content, round_truncated) = {
// ── MidStream 短重试/续传预算(看板 #906)──
// 包裹 candidate 循环:流中途断(incomplete)时,若预算未耗尽则「续传」重试——
// 把已接收 partial 作为 assistant 消息追加进请求(模型从断点继续),而非整轮重发
// (整轮重发会与前端已渲染的 partial 重复)。全部耗尽才落回原保文+停靠路径。
// 续传请求消息:首次 = 原请求;每次续传追加 [partial(assistant), 续传提示(user)]。
// 注:partial 同时 push 进 session(见下方成功/耗尽两分支),保证落库与前端一致
// (前端 flush 后 partial 占一个气泡,续传文本占下一个气泡)。
let mut cont_messages: Vec<ChatMessage> = messages.to_vec();
let mut midstream_attempt: usize = 0;
let (full_text, tool_calls_acc, round_usage, incomplete, round_reasoning_content, round_truncated) = 'midstream: loop {
// 每次(重)入 midstream 循环刷新 30s 流前重试预算(对齐 Fatal 重入「每 per-permit 重取
// 重入获全新 30s 预算」语义)。
let retry_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30);
// outcome 累积成功结果(Complete/Partial),InitFailed 不写入。
let mut outcome: Option<(String, std::collections::HashMap<u32, ToolCallDraft>, df_ai::provider::TokenUsage, bool, Option<String>, bool)> = None;
// 追踪最后一个 Exhausted candidate 的诊断文本,供「全 candidate 耗尽」时 emit 最终 AiError。
@@ -414,7 +449,7 @@ pub(crate) async fn stream_round(
let stream_outcome = stream_one_provider(
candidate,
messages,
&cont_messages,
tool_defs,
app_handle,
stop_flag,
@@ -471,7 +506,7 @@ pub(crate) async fn stream_round(
}
}
match outcome {
let round_result = match outcome {
Some(r) => r,
None => {
// 全 candidate 耗尽(含单 provider 场景):沿用原单 provider 失败语义。
@@ -492,7 +527,76 @@ pub(crate) async fn stream_round(
save_conversation(session_arc, db, conv_id, None, Some(&*resolved_model), true).await;
return RoundOutcome::Fatal { error: err_msg };
}
};
// ── MidStream 短重试/续传判定(看板 #906)──
// 仅当本轮 incomplete(流中途断)且用户未点停时进入;否则直接交回主框架。
let (text, tool_calls, usage, round_incomplete, rc, truncated) = round_result;
if !(round_incomplete && !stop_flag.load(Ordering::SeqCst)) {
break 'midstream (text, tool_calls, usage, round_incomplete, rc, truncated);
}
// 用户停优先:网络断与用户点停并发时,停靠语义让位停止路径(原行为保留)。
// 预算耗尽:落回原保文+停靠路径(交回主框架)。
if midstream_attempt >= MIDSTREAM_REENTRY_MAX {
break 'midstream (text, tool_calls, usage, round_incomplete, rc, truncated);
}
// ── 续传重试 ──
// ① 已接收 partial 落库为 assistant 消息(每段都落:续传轮的 partial 是「本次续传段」,
// 与前端每次 flush 出的气泡一一对应,保证落库与前端视图一致)。
let partial_trimmed = text.trim().to_string();
if !partial_trimmed.is_empty() {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
let mut msg = ChatMessage::assistant(&partial_trimmed);
msg.model = Some(resolved_model.clone());
msg.reasoning_content = None;
conv.messages.push(msg);
} else {
tracing::warn!(stale_conv = %conv_id, "[ai] MidStream 续传前 conv 已删除,放弃续传");
break 'midstream (text, tool_calls, usage, round_incomplete, rc, truncated);
}
}
// ② 本段 usage 累加(计费口径:每段都真实调用了 provider,不累加会少计中间段成本)。
// 置于 conv 存在性校验之后:conv 已删时上方已 break(不累加),避免与终局保文分支重复计。
// 用与主链同一派生点 round_usage_effective(prompt 估算兜底口径一致)。
let (attempt_usage, _) = round_usage_effective(&usage, estimated_prompt);
tokens.add_usage(&attempt_usage);
midstream_attempt += 1;
let delay = MIDSTREAM_REENTRY_BACKOFF_SECS[midstream_attempt - 1];
tracing::warn!(
conv_id = %conv_id,
attempt = midstream_attempt,
max_attempts = MIDSTREAM_REENTRY_MAX,
delay_secs = delay,
text_len = partial_trimmed.len(),
"[ai] 流中途断,{}s 后从断点续传({}/{})",
delay, midstream_attempt, MIDSTREAM_REENTRY_MAX,
);
// ③ 通知前端:flush 当前 partial 到独立气泡,续传文本渲染到新气泡(AiAgentRound
// 触发前端 flushCurrentText + 新建 assistant 气泡)。round 传 0 = 仅 flush 语义
// (前端 round>0 才更新进度/轮次,0 不跳进度条,对齐 helpers.rs 续跑 round:0 用法)。
// 注:iteration==0 时 prepare_round_inputs 不发 AiAgentRound,故此处显式补发。
emit_ai_chat_event(app_handle, AiChatEvent::AiAgentRound {
round: 0,
conversation_id: Some(conv_id.to_string()),
});
// ④ 构造续传请求:原请求 + 本次 partial(assistant)+ 续传提示(user)。
// 半截 reasoning 不回传(对齐 B-260617-16 MidStream 保文决策:残缺推理回传
// reasoning 模型可能引发 thinking 块错配,且对续写无增益)。
*last_reasoning_content = None;
cont_messages.push(ChatMessage::assistant(&partial_trimmed));
cont_messages.push(ChatMessage::user(MIDSTREAM_CONTINUE_PROMPT));
// ⑤ 退避后重入 candidate 循环(刷新 30s 流前重试预算;候选链沿用,饱和降级计数跨轮保留)。
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
continue 'midstream;
};
// G4.3:本轮 token 用量是否估算值(provider 未报 prompt_tokens → estimated_prompt 兜底),
@@ -512,10 +616,12 @@ pub(crate) async fn stream_round(
// CR-30-2 / UX-2025-04 / 决策 a1: MidStream 保文路径——partial_text 已接收,
// 入库为正常 assistant 消息 + emit AiCompleted(incomplete=true) + 追加系统提示消息。
// 不走 AiError(非异常中断,已有可用文本),不重试(决策 a1)。
// 不走 AiError(非异常中断,已有可用文本)。
// #906 变更:续传预算耗尽后才到达此处(预算内已由上方 'midstream 循环续传,成功则走
// Continue 不落此)。续传路径已把「中间各段」partial 落库(循环内 push),此处补「最后一段」
// + 系统提示(最后一段未在循环内 push,无重叠)。
// 并发时用户停优先:网络断同时用户点停 → stop_flag true 时不走保文路径,
// 落入下方 ~1508 stop_flag 检查走停止路径(用户意图优先)。
// partial 文本仍由下方 push_assistant_message(~1485)保文不丢。
// 落入下方 stop_flag 检查走停止路径(用户意图优先)。
if incomplete && !stop_flag.load(Ordering::SeqCst) {
// 本轮 usage 补全(prompt 估算兜底,含 cache/reasoning 分项)已由 round_usage_effective 单点派生
let usage = round_usage_full;
@@ -536,8 +642,10 @@ pub(crate) async fn stream_round(
}
// 与 push_assistant_message 一致:trim 归一化,纯空白保文(网络中断只有空白)不落库
let full_text = full_text.trim();
let conv = session.conv(conv_id);
// #906:续传路径已把「中间各段」partial 落库(循环内 push),但「最后一段」
// (预算耗尽时 break 的 text)未落库——故此处始终补最后一段(无重叠,不重复)。
if !full_text.is_empty() {
let conv = session.conv(conv_id);
let mut msg = ChatMessage::assistant(full_text);
msg.model = Some(resolved_model.clone());
// B-260617-16: MidStream 保文【不回填】reasoning_content——半截推理回传
@@ -555,11 +663,17 @@ pub(crate) async fn stream_round(
// 消息级估算标记:本轮 prompt 是否 estimated 兜底,reload 逐条回显对齐 live 态
msg.is_estimated = Some(round_is_estimated);
conv.messages.push(msg);
// 追加系统提示消息:响应因网络中断不完整(对齐决策 a1 系统提示机制)
let mut notice = ChatMessage::system("⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。");
notice.model = Some(resolved_model.clone());
conv.messages.push(notice);
}
// 追加系统提示消息:响应因网络中断不完整(对齐决策 a1 系统提示机制)。
// #906:续传也失败时提示文案含「已自动重试」,让用户理解已尽力。
let notice_text = if midstream_attempt > 0 {
"⚠ 响应因网络中断不完整(已自动续传重试仍未恢复),以上为已接收的部分内容。可重新发送或点继续以获取完整回复。"
} else {
"⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。"
};
let mut notice = ChatMessage::system(notice_text);
notice.model = Some(resolved_model.clone());
conv.messages.push(notice);
}
// 统一走 teardown_round 收尾(save + spawn_title + reset + emit)。
@@ -577,6 +691,8 @@ pub(crate) async fn stream_round(
guard,
&emit_usage,
*last_round_estimated,
// MidStream 已按不完整收尾,截断标志不再叠加(false,看板 953)。
false,
Some(true), None, Some("network"), true,
pinned_goals_snapshot,
app_handle,
@@ -678,3 +794,37 @@ pub(crate) fn merge_volatile_tail(messages: &mut Vec<ChatMessage>, tail: &str) {
}
messages.push(ChatMessage::user(tail));
}
// MidStream 短重试/续传预算(看板 #906)契约:退避表长须覆盖重入上限(取值索引
// attempt-1 不越界)且单调递增(快速恢复语义:midstream 断多为瞬态网络抖动,退避比
// Fatal 家族短)。续传提示须非空(合成 user 消息,空提示模型无续传指令)。
#[cfg(test)]
mod midstream_reentry_tests {
use super::{MIDSTREAM_CONTINUE_PROMPT, MIDSTREAM_REENTRY_BACKOFF_SECS, MIDSTREAM_REENTRY_MAX};
#[test]
fn backoff_schedule_covers_max_attempts_and_ascending() {
assert_eq!(
MIDSTREAM_REENTRY_BACKOFF_SECS.len(),
MIDSTREAM_REENTRY_MAX,
"退避表长须覆盖续传上限(取值索引 attempt-1 不越界)",
);
for w in MIDSTREAM_REENTRY_BACKOFF_SECS.windows(2) {
assert!(w[0] < w[1], "退避须单调递增: {:?}", w);
}
// 处方锚定:5s/15s(短退避,抖动期快速恢复;显著短于 Fatal 家族 60/300/900)
assert_eq!(MIDSTREAM_REENTRY_BACKOFF_SECS, [5, 15]);
assert!(
MIDSTREAM_REENTRY_BACKOFF_SECS[0] < 60,
"midstream 首退避须短于 Fatal 家族首退避(60s),体现快速恢复语义",
);
}
#[test]
fn continue_prompt_non_empty() {
assert!(
!MIDSTREAM_CONTINUE_PROMPT.trim().is_empty(),
"续传提示不可为空(空提示模型无续传指令)",
);
}
}
+384 -54
View File
@@ -33,9 +33,12 @@
//! (父侧 ai_chat_stop / ai_chat_force_send / ai_conversation_delete 经 SubAgentRegistry 级联)。
//!
//! ## 事件与展示边界
//! 仅在结束时挂父会话 emit 一条 AiAgentFinished(前端展示二期消费);不 emit AiCompleted /
//! AiMaxRounds 终态事件、不 spawn 标题/知识提炼。stream_round 复用带来的残留(均为网络异常
//! 罕见路径,消费方按「未知 conversation_id 丢弃」自我过滤,完整清单记录于此):
//! 运行期展示类事件(轮次/流式文本/工具生命周期/命令输出)经 `route_scope` 把子会话
//! conv 改挂父会话再推送,父会话视图能看到子代理实时进展(治「切回父会话 spawn 卡停在旧态」
//! 的幽灵态);复用既有 emit 双写出口,不另开旁路。仅结束时挂父会话 emit 一条 AiAgentFinished
//! (前端展示二期消费);不 emit AiCompleted / AiMaxRounds 终态事件、不 spawn 标题/知识提炼。
//! 非展示类事件(Fatal 的 AiError、MidStream 的 AiConvStateChanged 等)不改写,维持子会话
//! 路由——消费方按「未知 conversation_id 丢弃」自我过滤,完整残留清单记录于此:
//! - Fatal 分支会向子 conv emit AiError;
//! - MidStream 分支会触发一次子 conv 的后台标题任务(写库被零落库拦截,仅一次无效 LLM 调用);
//! - MidStream 分支 teardown_round 还会向子 conv emit 一条完整 AiCompleted(incomplete=true,
@@ -45,11 +48,11 @@ use std::collections::{BTreeMap, HashMap, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tauri::AppHandle;
use tauri::{AppHandle, State};
use tokio::sync::Mutex;
use df_ai::ai_tools::AiToolRegistry;
use df_ai::context::{ContextManager, TokenEstimator};
use df_ai::context::{ContextConfig, ContextManager, TokenEstimator};
use df_ai::context_helpers::PLACEHOLDER_INTEGRITY_ENABLED;
use df_ai::provider::{ChatMessage, MessageRole, ToolDefinition};
use df_ai::router::{Modality, TaskRequirements};
@@ -63,13 +66,14 @@ use crate::commands::ai::agentic::{
};
use crate::commands::ai::audit::{process_tool_calls, ToolExecPolicy};
use crate::commands::ai::conversation::{
save_conversation_meta, truncate_for_persist, SaveMetaOverride, TokenAccumulator,
save_conversation_meta, SaveMetaOverride, TokenAccumulator, TRUNCATE_THRESHOLD,
};
use crate::commands::ai::event_bus::emit_ai_chat_event;
use crate::commands::ai::event_bus::{emit_ai_chat_event, route_scope};
use crate::commands::ai::{AiChatEvent, ToolCallDraft};
use crate::state::LlmConcurrency;
use crate::state::{AppState, LlmConcurrency};
use super::guard::GeneratingGuard;
use super::context_lifecycle::maybe_auto_compress;
use crate::commands::ai::provider_pool::ProviderPool;
use super::router_setup::resolve_model_with_override;
use super::{
@@ -109,10 +113,34 @@ pub(crate) const SUB_AGENT_WALL_TIMEOUT_SECS: u64 = 900;
/// 自达统一出口。
pub(crate) const SUB_AGENT_TOOL_BATCH_HEADROOM_SECS: u64 = 65;
/// 子代理上下文窗口预算上限(token)。自动压缩触发阈值与 build_for_request 裁剪预算
/// 的数据源,口径同 [`ContextConfig::max_tokens`]。默认 128_000 对齐 ContextConfig::default
/// ——接入压缩层不改动既有预算(零行为变更);值单点集中于此,后续按需分档只改本常量。
const SUB_AGENT_CONTEXT_MAX_TOKENS: u32 = 128_000;
/// 子代理会话的上下文配置(压缩/裁剪预算来源)。仅覆写 max_tokens 为子代理常量,
/// 输出预留与安全系数沿用 ContextConfig::default,与主会话同一压缩链路口径一致。
fn sub_agent_context_config() -> ContextConfig {
ContextConfig {
max_tokens: SUB_AGENT_CONTEXT_MAX_TOKENS,
..ContextConfig::default()
}
}
/// 出口报告内联诊断文本的封顶(字符数)。Fatal 的 provider 原始错误串可能携带超长
/// 堆栈/响应体,父会话报告只需头部即可定位(401/404/参数错),超长部分截断省略。
const DIAG_TEXT_MAX_CHARS: usize = 400;
/// 任务书钉扎副本的长度上限(字符)。钉扎进 system prompt 的副本是循环不变量(不受
/// build_for_request 预算裁剪),超长任务书会在每轮 system prompt 里永续常驻占 token;
/// 首条 user 消息已带完整任务书,钉扎副本只需够覆盖目标/完成标准/约束,超限截尾留标注。
const SUB_AGENT_TASK_PIN_MAX_CHARS: usize = 4_000;
/// 子代理报告交付父会话前的超长截断头/尾保留字符数(对齐对话侧落库截断 HEAD/TAIL 量级,
/// 保证头尾语义一致:结论在头、末尾未尽事项在尾)。阈值本身复用 TRUNCATE_THRESHOLD。
const SUB_AGENT_REPORT_HEAD: usize = 3_072;
const SUB_AGENT_REPORT_TAIL: usize = 3_072;
// ============================================================
// 结果类型
// ============================================================
@@ -185,6 +213,10 @@ pub(crate) struct SubAgentOutcome {
pub(crate) rejected_tools: Vec<(String, u32)>,
/// 停止类别(终态事件 stop_kind 数据源);启动失败=Error,正常收敛=Converged。
pub(crate) stop_kind: Option<SubAgentStopKind>,
/// 实际使用的模型 id(candidate 切换/池空回退后的最终值,非请求值)。
/// 请求 override 落空回退到别的模型时,父会话据此知道子代理「实际用什么跑的」;
/// 启动期失败(未解析出模型)为空串。
pub(crate) model: String,
}
impl SubAgentOutcome {
@@ -197,6 +229,7 @@ impl SubAgentOutcome {
tokens: 0,
rejected_tools: Vec::new(),
stop_kind: Some(SubAgentStopKind::Error),
model: String::new(),
}
}
}
@@ -424,6 +457,50 @@ pub(crate) fn cap_diag_text(s: &str, max_chars: usize) -> String {
out
}
/// 任务书钉扎副本的长度收敛(治「spawn task 全文永续无瘦身」):超上限时保留头部
/// (目标/完成标准通常在任务书前部)并附省略标注,完整任务书仍在首条 user 消息里
/// (钉扎只为把目标锚定在不受裁剪的 system 段,无需常驻全文)。纯函数可单测。
fn cap_pinned_task(task: &str) -> String {
if task.chars().count() <= SUB_AGENT_TASK_PIN_MAX_CHARS {
return task.to_string();
}
let head: String = task.chars().take(SUB_AGENT_TASK_PIN_MAX_CHARS).collect();
format!("{}\n[…任务书过长,钉扎副本已截断;完整任务书见首条消息]…", head)
}
/// 子代理报告交付父会话前的超长收敛(阈值/头尾语义与对话落库截断一致,省略文案按子代理
/// 语境改写)。通用 truncate_for_persist 的「完整内容仅在内存态可读」是面向本地 DB 持久化的
/// 措辞,而子代理报告是交付给父 LLM 的返回值——须明示完整报告随子代理轨迹落库,
/// 父侧不误以为拿不到全文。纯函数可单测。
fn cap_report_for_parent(content: &str) -> String {
let chars: Vec<char> = content.chars().collect();
if chars.len() <= TRUNCATE_THRESHOLD {
return content.to_string();
}
let head: String = chars.iter().take(SUB_AGENT_REPORT_HEAD).collect();
let tail: String = chars[chars.len() - SUB_AGENT_REPORT_TAIL..].iter().collect();
let omitted = chars.len() - SUB_AGENT_REPORT_HEAD - SUB_AGENT_REPORT_TAIL;
format!(
"{}\n\n[...报告过长,已省略中间 {} 字符(头尾保留);完整报告随子代理轨迹落库...]\n\n{}",
head, omitted, tail
)
}
/// 把子代理采样温度注入 provider 的 config JSON(供应商级温度覆盖的数据源:stream_relay
/// 读 candidate.config 的 "temperature" 字段决定请求 temperature)。保留 config 内既有字段,
/// 仅覆写 temperature;config 缺失/非法时新建仅含 temperature 的对象。钳到 [0,2],防越界值打挂 provider。
fn inject_temperature(provider: &mut df_storage::models::AiProviderRecord, temperature: f32) {
let t = temperature.clamp(0.0, 2.0) as f64;
let mut obj = provider
.config
.as_deref()
.and_then(|cfg| serde_json::from_str::<serde_json::Value>(cfg).ok())
.and_then(|v| v.as_object().cloned())
.unwrap_or_default();
obj.insert("temperature".to_string(), serde_json::json!(t));
provider.config = Some(serde_json::Value::Object(obj).to_string());
}
/// 判定 tool_result 内容是否为子代理拒绝 envelope(S2 报告协议解析核心,纯函数可单测)。
/// 仅识别结构化 JSON 的 status=rejected + reason=sub_agent_denied;非 JSON/正常结果/
/// 其他拒绝(如主会话 path_blacklist)一律不算,防被拒清单误计。
@@ -462,30 +539,60 @@ pub(crate) fn append_rejected_section(summary: String, rejected: &[(String, u32)
/// 替换「默认最多 6 轮」类硬编码文案——父可传 1..20,子代理必须按真实预算规划探索节奏,
/// 预算将尽时主动收尾交卷,而非被截断后拿末轮过程性独白当报告。
/// 工具纪律段经 prompt::sub_agent_tool_discipline_section 同源拼接(P1-1)。
/// `lang` 为父会话语言(agent_language,缺省中文):决定本段与工具纪律段语言;
/// 环境画像(日期 + OS/shell 执行姿势)与父会话同源注入(子代理同样无法自行获取日期)。
/// 派工纪律段经 super::DISPATCH_DISCIPLINE 母本同源拼接(N1),与主会话共享同一文本。
pub(crate) fn sub_agent_system_prompt(max_rounds: u32) -> String {
let mut p = format!(
"你是 DevFlow 主会话派生的子代理,在隔离上下文中独立执行父任务交办的任务书。\n\
## 定位\n\
- 面向父任务执行:只做任务书范围内的事,用户看不到你的过程,无法与你交互或审批\n\
- 探索/检索/核对类工作用工具完成,结论写进最终回复\n\
- 你没有审批通道:路径授权申请与中高风险工具会被直接拒绝并返回失败结果,\n\
遇到被拒绝的操作改用已授权方式完成,或在报告中说明缺口\n\
- 本轮预算(实际生效值):最多 {max_rounds} 轮 / 累计 token 上限 {max_tokens} / \
挂钟上限 {wall_secs} 秒(15 分钟)。收敛即停,不做任务书之外的无关探索;\n\
预算将尽(最后 1 轮)必须直接输出结论报告,不要留下未收尾的探索\n\
## 输出要求\n\
- 最终回复就是交付给父任务的结论报告,不要寒暄、不要向用户提问\n\
- 报告结构:结论先行,然后列关键发现/证据/数据,最后列未尽事项与建议\n\
- 若有工具调用被拒绝:在未尽事项中说明哪些子任务因工具权限受限未完成,父会话据此决定接手方式",
max_rounds = max_rounds,
max_tokens = SUB_AGENT_MAX_TOTAL_TOKENS,
wall_secs = SUB_AGENT_WALL_TIMEOUT_SECS,
);
pub(crate) fn sub_agent_system_prompt(max_rounds: u32, lang: &str) -> String {
// lang 只决定本段与工具纪律段语言;派工纪律段(DISPATCH_DISCIPLINE)是跨端共享母本、
// 文本单份(见 agentic/mod.rs),不随 lang 分叉。
let body = if lang == "en" {
format!(
"You are a sub-agent spawned by the DevFlow main session, running in an isolated context to carry out the parent's task brief.\n\
## Role\n\
- Execute for the parent task only; the user cannot see your process and cannot interact with or approve you\n\
- Use tools for exploration/retrieval/verification work; put conclusions in your final reply\n\
- You have no approval channel: path-authorization requests and medium/high-risk tools are denied outright with a failure result;\n\
for a denied operation, switch to an authorized way or state the gap in your report\n\
- This task's budget (actual values): up to {max_rounds} rounds / cumulative token cap {max_tokens} / wall-clock cap {wall_secs}s (15 min). Stop when converged, do no exploration beyond the brief;\n\
when the budget is nearly exhausted (last round) output the conclusion report directly, leave no exploration unfinished\n\
## Output requirements\n\
- Your final reply IS the conclusion report delivered to the parent; no pleasantries, do not ask the user questions\n\
- Report structure: conclusion first, then key findings/evidence/data, finally open items and recommendations\n\
- Preserve file paths, identifiers and error messages verbatim in the report (the parent locates by them; do not rewrite or summarize)\n\
- If any tool call was denied: state in open items which subtasks were left incomplete due to tool-permission limits, so the parent decides how to take over",
max_rounds = max_rounds,
max_tokens = SUB_AGENT_MAX_TOTAL_TOKENS,
wall_secs = SUB_AGENT_WALL_TIMEOUT_SECS,
)
} else {
format!(
"你是 DevFlow 主会话派生的子代理,在隔离上下文中独立执行父任务交办的任务书。\n\
## 定位\n\
- 面向父任务执行:只做任务书范围内的事,用户看不到你的过程,无法与你交互或审批\n\
- 探索/检索/核对类工作用工具完成,结论写进最终回复\n\
- 你没有审批通道:路径授权申请与中高风险工具会被直接拒绝并返回失败结果,\n\
遇到被拒绝的操作改用已授权方式完成,或在报告中说明缺口\n\
- 本轮预算(实际生效值):最多 {max_rounds} 轮 / 累计 token 上限 {max_tokens} / \
挂钟上限 {wall_secs} 秒(15 分钟)。收敛即停,不做任务书之外的无关探索;\n\
预算将尽(最后 1 轮)必须直接输出结论报告,不要留下未收尾的探索\n\
## 输出要求\n\
- 最终回复就是交付给父任务的结论报告,不要寒暄、不要向用户提问\n\
- 报告结构:结论先行,然后列关键发现/证据/数据,最后列未尽事项与建议\n\
- 报告中的文件路径、标识符、错误信息照原样保留(父会话据此定位,勿改写或概括)\n\
- 若有工具调用被拒绝:在未尽事项中说明哪些子任务因工具权限受限未完成,父会话据此决定接手方式",
max_rounds = max_rounds,
max_tokens = SUB_AGENT_MAX_TOTAL_TOKENS,
wall_secs = SUB_AGENT_WALL_TIMEOUT_SECS,
)
};
// 环境画像(日期 + OS/shell 执行姿势)与父会话同源注入:日期 LLM 无法自行获取,
// 不注入则子代理写文件/报告里的日期全靠猜。
let mut p = crate::commands::ai::prompt::env_profile_line();
p.push_str(&body);
// 工具纪律段与父会话 system prompt 同源(read_symbol 优先/重复调用检测/URL 三工具
// 纪律/禁臆造 URL/查配置用 get_app_config);obscura 安装引导等交互条款已在共享函数
// 内裁剪,不适用于子代理。
p.push_str(&crate::commands::ai::prompt::sub_agent_tool_discipline_section("zh"));
// 内裁剪,不适用于子代理。lang 透传父会话语言,英文会话得英文纪律段。
p.push_str(&crate::commands::ai::prompt::sub_agent_tool_discipline_section(lang));
// 派工纪律同源注入(N1):引用父模块母本常量,与主会话 build_runtime_prompt 共享同一文本
// (报告限长/结论先行/证据随附直接约束子代理输出形态,若再派工亦同规约束);
// 内置于二进制而非知识库,防新实例知识库为空时纪律失传。
@@ -517,6 +624,9 @@ pub(crate) async fn run_sub_agent_loop(
allowed: Vec<String>,
max_rounds: u32,
model_override: Option<String>,
// 采样温度独立覆盖(P3):None=沿用 provider 配置/默认 0.7(默认不变,与主链路一致),
// Some(t)=本次子代理克隆候选注入 t,不影响主会话。
temperature_override: Option<f32>,
llm_concurrency: LlmConcurrency,
// 父会话 save 元数据快照(P3-5):出口落库显式传入,防继承当前活跃会话的
// provider/created_at。
@@ -526,7 +636,7 @@ pub(crate) async fn run_sub_agent_loop(
session_arc.clone(), tools_arc, db.clone(), app_handle.clone(),
parent_conv_id.clone(), child_conv_id.clone(),
system_prompt, task_prompt, tool_defs, allowed,
max_rounds, model_override, llm_concurrency.clone(),
max_rounds, model_override, temperature_override, llm_concurrency.clone(),
).await;
// 统一出口:挂父会话 emit 终态(前端展示二期消费;报告本体经父 tool_result 返回值交付)
@@ -600,6 +710,7 @@ async fn run_sub_agent_loop_inner(
allowed: Vec<String>,
max_rounds: u32,
model_override: Option<String>,
temperature_override: Option<f32>,
llm_concurrency: LlmConcurrency,
) -> SubAgentOutcome {
// 挂钟 deadline(三重熔断之三)
@@ -611,9 +722,10 @@ async fn run_sub_agent_loop_inner(
// (build_for_request 保尾弃头)时第一个被逐出,完成标准/约束随之蒸发。system 是
// 循环不变量(sys_tokens 一次估算缓存),不受消息淘汰影响;消息流首条 user 仍保留,
// 被淘汰也不丢目标。system_prompt 参数在此定形,后续全循环复用。
// 钉扎副本经 cap_pinned_task 收敛长度:超长任务书只钉目标锚,防全文字符串每轮常驻占 token。
let system_prompt = format!(
"{}\n## 任务书钉扎(全程有效,上下文裁剪不可逐出)\n<task>\n{}\n</task>\n",
system_prompt, task_prompt
system_prompt, cap_pinned_task(&task_prompt)
);
// 并发份额:global + per_conv(child) 双非阻塞 try_acquire,任一耗尽即 failed
@@ -644,6 +756,9 @@ async fn run_sub_agent_loop_inner(
session.deleted_convs.insert(child_conv_id.clone());
session.conv_origins.insert(child_conv_id.clone(), SUB_AGENT_ORIGIN.to_string());
let conv = session.conv(&child_conv_id);
// 上下文预算单点接入:子会话 ContextManager 用子代理配置(默认值与父会话一致),
// 供压缩触发阈值与 build_for_request 裁剪读取;新条目消息为空,直接替换无损。
conv.messages = ContextManager::new(sub_agent_context_config());
conv.messages.push(ChatMessage::user(&task_prompt));
conv.stop_flag.store(false, Ordering::SeqCst);
conv.iteration_used = 0;
@@ -655,7 +770,7 @@ async fn run_sub_agent_loop_inner(
GeneratingGuard::new(child_conv_id.clone(), child_epoch.clone(), my_epoch, app_handle.clone());
// ── provider 解析(30s 超时,对齐主 loop 入口;失败走 failed 返回值,不 emit AiError)──
let (primary_provider, candidates) = {
let (primary_provider, mut candidates) = {
let resolve = tokio::time::timeout(std::time::Duration::from_secs(30), async {
let provider_repo = AiProviderRepo::new(&db);
let pool = match provider_repo.list_all().await {
@@ -695,6 +810,16 @@ async fn run_sub_agent_loop_inner(
let mut resolved_model = resolve_model_with_override(&req_probe, &primary_provider, &model_override);
let mut provider_config = primary_provider;
// 子代理采样温度独立覆盖(P3):默认 None 不注入(沿用 provider 配置或 0.7,行为不变)。
// Some(t) 时把 temperature 写进本次子代理的候选 config JSON——stream_relay 的供应商级
// 温度覆盖正从 candidate.config 读取,故只改克隆候选即可生效,不触碰主会话/DB 记录。
if let Some(temp) = temperature_override {
inject_temperature(&mut provider_config, temp);
for c in candidates.iter_mut() {
inject_temperature(c, temp);
}
}
// system_prompt 是整个子循环的不变量,token 估算一次缓存复用(对齐主 loop)
let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt);
@@ -733,6 +858,35 @@ async fn run_sub_agent_loop_inner(
break;
}
// 子代理运行期进度可见(父会话视图):每轮起算前挂父会话发 AiAgentRound 建独立气泡,
// 本轮后续流式文本/工具事件经 conv 路由改挂父会话续写该气泡(见 route_scope)。
emit_ai_chat_event(&app_handle, AiChatEvent::AiAgentRound {
round: round + 1,
conversation_id: Some(parent_conv_id.clone()),
});
// ── 自动压缩(智能裁剪):复用父会话同一压缩链路 ——
// 子代理长跑探索/多文件阅读同样累积上下文,缺此层只剩 build_for_request 硬裁剪、
// 无 LLM 摘要重锚定,主题随裁剪蒸发。provider 沿用本会话当前 candidate(与父 loop
// 同款);压缩后台化(fire-and-forget)不阻塞本轮,结果下一轮生效。返回 true =
// 子会话条目已消失,退出循环(对齐 prepare 段的 conv 缺失处理)。压缩过程产生的
// 展示类事件(AiCompressing/AiAutoCompressed)挂子会话,消费方按未知 conv 丢弃,
// 与模块头「非展示类事件不改写」残留清单一并说明。
if maybe_auto_compress(
&session_arc,
&child_conv_id,
&app_handle,
&provider_config,
&llm_concurrency,
round as usize,
sys_tokens,
).await {
status = SubAgentStatus::Failed;
stop_reason = "子会话条目已消失".to_string();
stop_kind = Some(SubAgentStopKind::Error);
break;
}
// ── prepare:build_for_request(预算裁剪)+ system prompt + namespace 展开 +
// tool_result 视图压缩 + 占位配对断言 + 输入 token 估值(对齐主 loop prepare 段)──
let (messages, estimated_prompt) = {
@@ -797,18 +951,23 @@ async fn run_sub_agent_loop_inner(
// 子循环 future(报告蒸发+注册表泄漏+子会话滞留)。stream_round 内部短锁段无跨
// await 持锁、mid-flight 无消息写,drop 安全(guard 由本函数持有,出口统一复位)。
let stream_budget = deadline.saturating_duration_since(tokio::time::Instant::now());
match tokio::time::timeout(stream_budget, stream_round(
&session_arc, &db, &child_conv_id,
&messages, &tool_defs, &app_handle,
&child_stop, &child_notify,
DEFAULT_MAX_AGENT_RETRIES, &model_override, &agentic_req, estimated_prompt,
&mut last_round_estimated, &mut last_reasoning_content,
&mut resolved_model, &mut provider_config, &mut provider_saturation,
&mut tokens, &mut saved_token_snapshot, &mut guard,
&candidates, &llm_concurrency,
// 子会话无目标钉扎,空快照(该参数仅供主 loop AiCompleted 快照透传)
&[],
&child_epoch, my_epoch,
// 子代理展示类事件改挂父会话:本轮流式(AiTextDelta/AiHeartbeat/AiStreamRetry 等)
// 经 route_scope 从子会话改挂父会话推送,父会话视图实时可见(终态/状态机事件不改写)。
match tokio::time::timeout(stream_budget, route_scope(
child_conv_id.clone(), parent_conv_id.clone(),
stream_round(
&session_arc, &db, &child_conv_id,
&messages, &tool_defs, &app_handle,
&child_stop, &child_notify,
DEFAULT_MAX_AGENT_RETRIES, &model_override, &agentic_req, estimated_prompt,
&mut last_round_estimated, &mut last_reasoning_content,
&mut resolved_model, &mut provider_config, &mut provider_saturation,
&mut tokens, &mut saved_token_snapshot, &mut guard,
&candidates, &llm_concurrency,
// 子会话无目标钉扎,空快照(该参数仅供主 loop AiCompleted 快照透传)
&[],
&child_epoch, my_epoch,
),
)).await {
Ok(RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated, truncated_by_max_tokens }) => {
rounds_done += 1;
@@ -868,11 +1027,16 @@ async fn run_sub_agent_loop_inner(
.values()
.map(|d| (d.id.clone(), d.name.clone()))
.collect();
let pending = process_tool_calls(
&session_arc, tool_calls, &tools_arc, &db, &app_handle, &child_conv_id,
ToolExecPolicy::SubAgent,
// P2-1 执行期白名单:Low 工具不在名单同样就地拒绝(layer=whitelist-clamp)
Some(&allowed),
// 工具事件(AiToolCallStarted/Completed/AiCommandOutput 等)同样改挂父会话,
// 让父会话视图看到子代理的工具执行实时状态;子会话自身消息写入不受影响。
let pending = route_scope(
child_conv_id.clone(), parent_conv_id.clone(),
process_tool_calls(
&session_arc, tool_calls, &tools_arc, &db, &app_handle, &child_conv_id,
ToolExecPolicy::SubAgent,
// P2-1 执行期白名单:Low 工具不在名单同样就地拒绝(layer=whitelist-clamp)
Some(&allowed),
),
).await;
// S2 报告协议:收集本轮被拒清单(deny 单点已写入结构化 envelope,此处只读解析)
collect_rejected_tools(&session_arc, &child_conv_id, &round_calls, &mut rejected_tools).await;
@@ -951,8 +1115,8 @@ async fn run_sub_agent_loop_inner(
// ── 报告提取:收敛轮 full_text 优先;截断/失败取末条 assistant 文本附终止说明;再无则用原因 ──
let total_tokens = tokens_snapshot(&tokens).total_tokens as u64;
let summary = if !report.trim().is_empty() {
// 父侧注入前超长截断复用现有 truncate_for_persist(头尾保留语义一致)
truncate_for_persist(report.trim())
// 父侧注入前超长截断:用子代理语境文案(非通用落库文案),阈值/头尾语义一致
cap_report_for_parent(report.trim())
} else {
match last_assistant_text(&session_arc, &child_conv_id).await {
Some(text) => {
@@ -961,7 +1125,7 @@ async fn run_sub_agent_loop_inner(
} else {
format!("{}\n\n(子代理提前终止:{})", text, stop_reason)
};
truncate_for_persist(combined.trim())
cap_report_for_parent(combined.trim())
}
None if !stop_reason.is_empty() => format!("子代理未产出报告:{}", stop_reason),
None => "子代理未产出报告".to_string(),
@@ -979,6 +1143,7 @@ async fn run_sub_agent_loop_inner(
status = status.as_str(),
rounds = rounds_done,
tokens = total_tokens,
model = %resolved_model,
summary_len = summary.len(),
"[sub-agent] 子代理结束"
);
@@ -991,6 +1156,8 @@ async fn run_sub_agent_loop_inner(
tokens: total_tokens,
rejected_tools: rejected,
stop_kind,
// 实际生效模型(candidate 切换/回退后的最终值),父会话据此知晓子代理真用了哪个模型
model: resolved_model.clone(),
}
}
@@ -1088,6 +1255,99 @@ async fn collect_rejected_tools(
}
}
// ============================================================
// 完成卡「已消化」标记(KV 持久,app_settings)
// ============================================================
/// 完成卡「已消化」标记的 KV key 前缀:key = `agentCardDigested:{parentConvId}`,
/// 值 = 已标记 agentId 的 JSON 字符串数组。完成卡本身是派生态(由子代理轨迹重建),
/// 「是否已消化」是用户偏好,故复用 app_settings KV 而不落 SQLite 新表/新列;轨迹删除后
/// 孤儿 key 自然失效(重建路径只对轨迹存在的卡应用标记),无需清理任务。
pub(crate) const AGENT_CARD_DIGESTED_KEY_PREFIX: &str = "agentCardDigested:";
/// 组装某父会话的已消化标记 KV key。
pub(crate) fn digested_kv_key(parent_conv_id: &str) -> String {
format!("{}{}", AGENT_CARD_DIGESTED_KEY_PREFIX, parent_conv_id)
}
/// 解析 KV 原始串为已消化 agentId 列表。容错:缺省/非 JSON/非数组一律降级为空,
/// 数组内空串与非字符串项跳过;结果去重升序,保证确定性与幂等收口。纯函数可单测。
pub(crate) fn parse_digested_ids(raw: Option<&str>) -> Vec<String> {
let Some(s) = raw else {
return Vec::new();
};
// 结构不符(坏值/旧格式残留)一律空列表兜底,不向调用方抛错——标记是纯偏好,读不动即视为未标记
let Ok(serde_json::Value::Array(items)) = serde_json::from_str::<serde_json::Value>(s) else {
return Vec::new();
};
let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for it in items {
if let Some(id) = it.as_str().filter(|id| !id.is_empty()) {
set.insert(id.to_string());
}
}
set.into_iter().collect()
}
/// 批量置位/清位已消化标记,返回新列表(去重升序)。空 id 忽略;幂等(重复置位/清位收敛)。
pub(crate) fn apply_digested(list: Vec<String>, agent_ids: &[String], digested: bool) -> Vec<String> {
let mut set: std::collections::BTreeSet<String> = list.into_iter().collect();
for id in agent_ids.iter().filter(|id| !id.is_empty()) {
if digested {
set.insert(id.clone());
} else {
set.remove(id);
}
}
set.into_iter().collect()
}
/// 序列化已消化列表为稳定 KV 值(JSON 字符串数组;空列表序列化为 `[]`,便于清位后覆盖旧值)。
pub(crate) fn serialize_digested_ids(list: &[String]) -> String {
serde_json::to_string(list).unwrap_or_else(|_| "[]".to_string())
}
/// 读某父会话的已消化标记列表(完成卡重建路径读 KV 合并的数据源,只读无副作用)。
#[tauri::command]
pub async fn ai_sub_agent_digested_list(
state: State<'_, AppState>,
conversation_id: String,
) -> Result<Vec<String>, String> {
let raw = state
.settings
.get(&digested_kv_key(&conversation_id))
.await
.map_err(crate::commands::err_str)?;
Ok(parse_digested_ids(raw.as_deref()))
}
/// 批量置位/清位完成卡「已消化」标记(digested=true 标记,false 取消),返回更新后的完整列表。
///
/// 为什么由后端收口读改写而非前端各自 settings KV 自读自写:同会话多卡批量标记并发时,
/// 前端 read-modify-write 会互相覆盖丢标记;键格式(agentCardDigested:{conv} → string[])
/// 亦单点约定,前端不散落拼 key。
#[tauri::command]
pub async fn ai_sub_agent_digest(
state: State<'_, AppState>,
conversation_id: String,
agent_ids: Vec<String>,
digested: bool,
) -> Result<Vec<String>, String> {
let key = digested_kv_key(&conversation_id);
let raw = state
.settings
.get(&key)
.await
.map_err(crate::commands::err_str)?;
let list = apply_digested(parse_digested_ids(raw.as_deref()), &agent_ids, digested);
state
.settings
.set(&key, &serialize_digested_ids(&list))
.await
.map_err(crate::commands::err_str)?;
Ok(list)
}
// ============================================================
// 单元测试:白名单钳制 / max_rounds 熔断 / registry 级联注册移除
// ============================================================
@@ -1329,7 +1589,7 @@ mod tests {
/// 同源工具纪律段(P1-1)。
#[test]
fn sub_agent_prompt_mentions_report_contract() {
let p = sub_agent_system_prompt(6);
let p = sub_agent_system_prompt(6, "zh-CN");
assert!(p.contains("子代理"));
assert!(p.contains("最终回复"));
assert!(p.contains("审批通道"), "应说明无审批通道(防挂死设计的前提告知)");
@@ -1339,8 +1599,12 @@ mod tests {
assert!(p.contains(&SUB_AGENT_MAX_TOTAL_TOKENS.to_string()), "token 上限注入");
assert!(p.contains(&SUB_AGENT_WALL_TIMEOUT_SECS.to_string()), "挂钟上限注入");
assert!(!p.contains("默认最多 6 轮"), "硬编码缺省文案应已移除: {}", p);
// 环境画像(日期)与父会话同源注入:子代理无法自行获取日期
assert!(p.contains("当前日期"), "环境画像日期须注入: {}", p);
// 报告约定:文件路径原样保留
assert!(p.contains("文件路径"), "报告约定须含文件路径原样保留: {}", p);
// 父设小预算时按真实值注入(防按 6 轮规划被截断)
assert!(sub_agent_system_prompt(3).contains("最多 3 轮"));
assert!(sub_agent_system_prompt(3, "zh-CN").contains("最多 3 轮"));
// P1-1 工具纪律段同源拼接
assert!(p.contains("read_symbol"), "read_symbol 优先指引须注入: {}", p);
assert!(p.contains("重复调用检测"), "重复调用检测须注入");
@@ -1348,6 +1612,37 @@ mod tests {
assert!(p.contains("get_app_config"), "查配置正解须注入");
assert!(p.contains("失败 2 次必换方案"), "render=true 铁律须注入");
assert!(!p.contains("要我帮你装吗"), "obscura 交互条款不进子代理");
// lang=en:主体与工具纪律段均转英文(派工纪律段母本单份仍中文,见函数文档)
let en = sub_agent_system_prompt(6, "en");
assert!(en.contains("sub-agent"), "英文主体: {}", en);
assert!(en.contains("no approval channel"));
assert!(en.contains(DUPLICATE_EN_MARKER), "英文纪律段须随 lang 透传");
assert!(!en.contains("你没有审批通道"));
}
/// 英文工具纪律段特征串(间接断言 lang="en" 已透传到 discipline 段)。
const DUPLICATE_EN_MARKER: &str = "Duplicate call detection";
/// 任务书钉扎长度收敛:上限内原样,超限截断并留标注(治全文字符串每轮常驻)。
#[test]
fn cap_pinned_task_bounds_length() {
let short = "统计 crates 下 TODO 数量";
assert_eq!(cap_pinned_task(short), short);
let long = "x".repeat(SUB_AGENT_TASK_PIN_MAX_CHARS + 500);
let capped = cap_pinned_task(&long);
assert!(capped.chars().count() < long.chars().count(), "超限须收敛");
assert!(capped.contains("完整任务书见首条消息"), "须留截断标注");
}
/// 子代理报告截断:阈值内原样,超阈截断并用子代理语境文案(不含落库语境的「内存态」)。
#[test]
fn cap_report_for_parent_uses_subagent_wording() {
let short = "结论:已完成";
assert_eq!(cap_report_for_parent(short), short);
let long = "y".repeat(TRUNCATE_THRESHOLD + 100);
let capped = cap_report_for_parent(&long);
assert!(capped.contains("随子代理轨迹落库"), "须用子代理语境文案");
assert!(!capped.contains("内存态"), "不得沿用父侧落库文案");
}
/// P2-4 停止类别枚举序列化为 snake_case 字符串(事件面消费契约)。
@@ -1436,4 +1731,39 @@ mod tests {
vec!["write_file×3".to_string(), "run_command×1".to_string()]
);
}
/// 完成卡「已消化」标记:KV key 形态 + 容错解析(坏值/空串/非串项) + 批量置位清位幂等 + 序列化往返。
#[test]
fn digested_marker_kv_roundtrip_and_tolerance() {
assert_eq!(digested_kv_key("conv-1"), "agentCardDigested:conv-1");
// 容错解析:缺省/非 JSON/非数组 一律空;去重升序;跳过空串与非串项
assert!(parse_digested_ids(None).is_empty());
assert!(parse_digested_ids(Some("not json")).is_empty());
assert!(parse_digested_ids(Some("{}")).is_empty());
assert_eq!(
parse_digested_ids(Some(r#"["b","a","a","",1,null]"#)),
vec!["a".to_string(), "b".to_string()]
);
// 批量置位(空 id 忽略) + 幂等
let list = apply_digested(
vec![],
&["p::agent-2".to_string(), "p::agent-1".to_string(), String::new()],
true,
);
assert_eq!(list, vec!["p::agent-1".to_string(), "p::agent-2".to_string()]);
let list = apply_digested(list, &["p::agent-1".to_string()], true);
assert_eq!(list, vec!["p::agent-1".to_string(), "p::agent-2".to_string()], "重复置位幂等");
// 批量清位(含不存在 id,不 panic) + 幂等
let list = apply_digested(list, &["p::agent-1".to_string(), "ghost".to_string()], false);
assert_eq!(list, vec!["p::agent-2".to_string()]);
assert!(apply_digested(list, &["p::agent-2".to_string()], false).is_empty());
// 序列化 → 解析 往返;空列表序列化为 `[]`
let list = vec!["x".to_string(), "y".to_string()];
assert_eq!(parse_digested_ids(Some(&serialize_digested_ids(&list))), list);
assert_eq!(serialize_digested_ids(&[]), "[]");
}
}
+3 -3
View File
@@ -182,7 +182,7 @@ pub(super) async fn insert_pending_approval(
conversation_id: Some(conv_id.to_string()),
};
emit_ai_chat_event(&app_handle, ev);
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"), current_message_id).await;
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"), current_message_id, None).await;
return;
}
@@ -224,7 +224,7 @@ pub(super) async fn insert_pending_approval(
"等待工具审批",
&format!("工具 {} 已暂停,批准后继续执行", draft.name),
);
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id).await;
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id, None).await;
}
/// (容错/恢复,开关 `df-ai-approval-retry`):查审计表推算同 (会话, 工具, 参数) 重试计数。
@@ -366,7 +366,7 @@ pub(super) async fn handle_approval_tool(
};
emit_ai_chat_event(&app_handle, ev);
// 审计:去重命中记一条(status 透传缓存来源 completed/rejected/failed,;decided_by=auto_dedup),不进 pending
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(warned_content), Some("auto_dedup"), current_message_id).await;
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(warned_content), Some("auto_dedup"), current_message_id, None).await;
return;
}
}
+14 -2
View File
@@ -4,6 +4,7 @@
//! re-export 经 audit/mod.rs `pub(crate) use finalize::audit_finalize;` 保持
//! `commands::ai::audit::*` 路径透明(调用方零变更)。
use crate::commands::ai::tool_result_offload;
use crate::commands::now_millis;
use crate::state::AppState;
@@ -12,7 +13,13 @@ use crate::state::AppState;
/// 走专用 find_by_tool_call_id —— 通用 query 宏硬编码 ORDER BY created_at DESC,
/// 而 ai_tool_executions 无该列,调用会报 "no such column: created_at" 被 unwrap_or_default 吞掉,
/// 导致审批后审计记录永久卡 pending。
pub(crate) async fn audit_finalize(state: &AppState, tool_call_id: &str, status: &str, result: Option<String>) {
pub(crate) async fn audit_finalize(
state: &AppState,
tool_call_id: &str,
status: &str,
result: Option<String>,
duration_ms: Option<i64>,
) {
// 区分 Err(DB 故障)与 Ok(None)(真无记录):原 unwrap_or_default 把 Err 压成 None,
// DB 故障被「未找到」日志掩盖,审批后审计记录卡 pending 无确诊线索。
let mut rec = match state.ai_tool_executions.find_by_tool_call_id(tool_call_id).await {
@@ -29,8 +36,13 @@ pub(crate) async fn audit_finalize(state: &AppState, tool_call_id: &str, status:
rec.status = status.to_string();
rec.decided_by = Some("human".to_string());
rec.executed_at = Some(now_millis());
// V50 纯执行耗时:审批路径由调用方(ai_approve/ai_authorize_dir)在真执行窗口测量后传入,
// 与「requested_at→executed_at」(含等待人工批准)解耦。拒绝/超时未执行传 None。
rec.duration_ms = duration_ms;
if let Some(r) = result {
rec.result = Some(r);
// 与 audit_tool_call 的写侧一致:超阈值结果外置落盘,DB 只留句柄,防审批路径大结果
// 绕过外置直接内联落库(工具真正执行发生在审批通过之后,这条才是大结果主入口)。
rec.result = Some(tool_result_offload::offload_result(tool_call_id, &rec.tool_name, &r));
}
if let Err(e) = state.ai_tool_executions.update_full(&rec).await {
tracing::error!("audit_finalize: 回填 tool_call_id={} 审计状态失败: {}", tool_call_id, e);
+85 -27
View File
@@ -15,6 +15,7 @@ use super::event_bus::emit_ai_chat_event;
use crate::state::{SettingsNorm, log_slow_lock};
use super::{AiChatEvent, AiSession, ApprovalKind, PathAuthRequest, PendingApproval, ToolCallDraft};
use super::tools::entity_resolve;
use crate::commands::ai::is_destructive_command;
// utils(audit/utils.rs):RiskLevel ↔ 字符串转换 + 字符串安全截断纯 helper。
// 第五批从本文件抽离,行为零变更。pub(crate) use 保持 finalize / restore 子模块
@@ -163,7 +164,7 @@ async fn deny_sub_agent_tool(
};
emit_ai_chat_event(app_handle, ev);
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"rejected", risk_level, Some(msg), Some("sub_agent_denied"), current_message_id).await;
"rejected", risk_level, Some(msg), Some("sub_agent_denied"), current_message_id, None).await;
}
/// 任务边界拒绝单点(无人值守,设计 §三):结构化失败 tool_result + emit Completed + 审计留痕。
@@ -211,7 +212,7 @@ async fn deny_boundary_tool(
};
emit_ai_chat_event(app_handle, ev);
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"rejected", risk_level, Some(msg), Some(layer), current_message_id).await;
"rejected", risk_level, Some(msg), Some(layer), current_message_id, None).await;
}
/// P2-1 执行期白名单判定(纯函数可单测):SubAgent 策略下工具是否被白名单裁掉。
@@ -253,6 +254,23 @@ pub(crate) async fn execute_with_heartbeat(
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::time::Duration;
// ── B5d 工具失败记忆:执行前拦截「同 (conv,tool,args) 连败 ≥2 次」的重复调用 ──
// 治「404 连环十几次无退避换路」:拦截时回填结构化换路提示(改参数/换工具/报告放弃),
// 引导 LLM 换路而非原样重试。成功即清零(record 侧),防误杀。
// 拦截直接返 Err(错误 tool_result),不执行工具——process_tool_calls 侧按既有错误路径处理。
if let Some(blocked) = super::tool_failure_memory::precheck(name, &args) {
tracing::warn!(
conv_id = %conv_id,
tool = %name,
tool_call_id = %tool_call_id,
"[failure-memory] 同调用连败达标,执行前拦截"
);
return Err(anyhow::anyhow!(blocked));
}
// record 侧需 args 计算 memory_key,但下方 tools.execute(name, args) 会 move args。
// 此处留一份 clone 供执行后 record 使用(仅工具执行路径,非热循环,开销可接受)。
let args_for_memory = args.clone();
// ── W-h1 感知租约:写类文件工具执行前置校验(跨会话互斥,治互踩三连) ──
// 写类(write_file/patch_file/append_file/delete_file/rename_file 等:非 readonly
// 且有 path_args)执行前 acquire;任一路径被**其他会话**占用 → 立即返错(拒绝文案
@@ -411,7 +429,7 @@ pub(crate) async fn execute_with_heartbeat(
// 三路 select:exec 正常完成 / outer_secs 兜底超时(既有语义,drop exec 腰斩) /
// 监视判死提前退出(同样 drop exec,防审批被删或会话已删后空转到超时上限)。
// exec 先完成时 select 丢弃 abort_watch,监视零泄漏(非 spawn,随 select 作用域消亡)。
tokio::select! {
let exec_result = tokio::select! {
result = &mut exec_fut => result,
_ = tokio::time::sleep(Duration::from_secs(outer_secs)) => {
tracing::error!(
@@ -432,7 +450,17 @@ pub(crate) async fn execute_with_heartbeat(
);
Err(anyhow::anyhow!("工具 {} 提前中止: {}", name, reason))
}
};
// ── B5d 工具失败记忆:执行后回写(成功清零 / 失败递增并记最近错误) ──
// 与入口 precheck 配对:成功即删记忆(知识保鲜,防误杀);失败累加计数,
// 达阈值后下次 precheck 拦截。error 摘要取 Err 文案(超时/提前退出/工具自身错误同源)。
match &exec_result {
Ok(_) => super::tool_failure_memory::record(name, &args_for_memory, true, None),
Err(e) => super::tool_failure_memory::record(name, &args_for_memory, false, Some(e.to_string())),
}
exec_result
}
// ============================================================
@@ -587,6 +615,7 @@ async fn cancel_expired_approval(
tc_id,
"rejected",
Some(format!("审批超时({}分钟)自动取消", timeout_minutes)),
None,
)
.await;
// 5. 落库会话状态变更(占位消息替换后需持久化,防重启后丢失)
@@ -719,7 +748,7 @@ pub(crate) async fn process_tool_calls(
emit_ai_chat_event(&app_handle, ev);
let risk = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"failed", risk, Some(err_payload.clone()), Some("auto_resolve_fail"), current_message_id).await;
"failed", risk, Some(err_payload.clone()), Some("auto_resolve_fail"), current_message_id, None).await;
}
}
}
@@ -865,7 +894,7 @@ pub(crate) async fn process_tool_calls(
audit_tool_call(
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"rejected", risk_level, Some(err_msg), Some("auto_blacklist"),
current_message_id,
current_message_id, None,
).await;
}
@@ -924,7 +953,7 @@ pub(crate) async fn process_tool_calls(
audit_tool_call(
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"),
current_message_id,
current_message_id, None,
).await;
continue;
}
@@ -963,7 +992,7 @@ pub(crate) async fn process_tool_calls(
audit_tool_call(
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"pending", risk_level, None, None,
current_message_id,
current_message_id, None,
).await;
}
@@ -1032,8 +1061,27 @@ pub(crate) async fn process_tool_calls(
// 黑名单恒拒不在此层——Denied 分支在 path_auth 预校验先行硬拒,边界永不参与。
if !is_sub_agent {
if let Some(b) = &boundary {
if crate::commands::ai::boundary::risk_allowed(b, &draft.name, &args, risk_level) {
// all 档 = 用户显式完全接管(L3 最高授权),优先于任务边界:
// 授权语义上"显式全放权"应覆盖"默认收权边界",否则用户开 all 反被边界拦死
// (功能反转缺陷:越授权越跑不动)。all 档下不在此层拦截,落到下方
// handle_approval_tool 走正常判定链(其内 all 档放行 High)。
// 唯一保留的安全线:破坏性命令(all 档也不放行,纵深防御独立于风险等级)。
if auto_exec_mode == "all" {
let destructive = draft.name == "run_command"
&& args.get("command").and_then(|v| v.as_str())
.map(is_destructive_command).unwrap_or(false);
if destructive {
deny_boundary_tool(
session_arc, conv_id, &draft, tools_arc, &audit_repo,
"boundary-risk", "破坏性命令恒拒(all 档亦不放行,纵深防御)",
current_message_id, app_handle,
).await;
continue;
}
// 非破坏性:all 档放行,不 continue,落到 handle_approval_tool
} else if crate::commands::ai::boundary::risk_allowed(b, &draft.name, &args, risk_level) {
boundary_auto.push((draft, args, risk_level));
continue;
} else {
let reason = if risk_level == RiskLevel::High {
"工具风险 High 超出任务风险上限".to_string()
@@ -1044,8 +1092,8 @@ pub(crate) async fn process_tool_calls(
session_arc, conv_id, &draft, tools_arc, &audit_repo,
"boundary-risk", &reason, current_message_id, app_handle,
).await;
continue;
}
continue;
}
}
// AC-2 ③:run_command 目录列举绕行检测(最保守版,只警告不硬拒)。
@@ -1079,13 +1127,16 @@ pub(crate) async fn process_tool_calls(
// 阻塞同会话 IPC);改 join_all 并行多工具减少总阻塞时间。P0 重构后锁外并行,根本消除阻塞。
// join_all 保序——结果顺序 = trust_hits 输入顺序 = tc_list 原始 index 顺序,不额外 sort
if !trust_hits.is_empty() {
let results: Vec<(ToolCallDraft, RiskLevel, Result<String, String>)> =
let results: Vec<(ToolCallDraft, RiskLevel, Result<String, String>, i64)> =
futures::future::join_all(trust_hits.into_iter().map(|(draft, args, _dir_label, risk_level)| {
let tools = tools_arc.clone();
let app_clone = app_handle.clone();
let conv_clone = conv_id.to_string();
async move {
// V50 纯执行耗时:本闭包内逐工具测真执行墙钟(并行下各工具独立,非批 wall)。
let exec_started = std::time::Instant::now();
let exec_result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone, &draft.id).await;
let exec_ms = exec_started.elapsed().as_millis() as i64;
match exec_result {
Ok(val) => {
let content = val.to_string();
@@ -1099,7 +1150,7 @@ pub(crate) async fn process_tool_calls(
// 数据变更类工具执行成功后 emit df-data-changed 联动刷新
// (write_file/run_command 不在 data_change_for_tool 映射内,emit_data_changed 内部 None 即 noop)
emit_data_changed(&app_clone, &draft.name);
(draft, risk_level, Ok(content))
(draft, risk_level, Ok(content), exec_ms)
}
Err(e) => {
let content = e.to_string();
@@ -1110,7 +1161,7 @@ pub(crate) async fn process_tool_calls(
conversation_id: Some(conv_clone),
};
emit_ai_chat_event(&app_clone, ev);
(draft, risk_level, Err(content))
(draft, risk_level, Err(content), exec_ms)
}
}
}
@@ -1118,7 +1169,7 @@ pub(crate) async fn process_tool_calls(
// 串行回填 tool_result + 审计(短 lock push + 锁外 audit)
for (draft, risk_level, outcome) in results {
for (draft, risk_level, outcome, exec_ms) in results {
let (status, content) = match outcome {
Ok(c) => ("completed", c),
Err(c) => ("failed", c),
@@ -1146,7 +1197,7 @@ pub(crate) async fn process_tool_calls(
}
}
// 锁外:审计(trust 放行仍记一条,decided_by=auto_trust),留痕可追溯
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, Some(content), Some("auto_trust"), current_message_id).await;
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, Some(content), Some("auto_trust"), current_message_id, Some(exec_ms)).await;
}
}
@@ -1209,7 +1260,7 @@ pub(crate) async fn process_tool_calls(
audit_tool_call(
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"completed", risk_level, Some(warned_content), Some("cache_hit"),
current_message_id,
current_message_id, None,
).await;
tracing::info!(
conv_id = %conv_id,
@@ -1225,13 +1276,16 @@ pub(crate) async fn process_tool_calls(
}
let low_risk = low_risk_uncached;
// 携带 args + 原始 JSON result(create_idea source 补全需解析 result.id + args.source)。
let results: Vec<(ToolCallDraft, serde_json::Value, RiskLevel, Result<String, String>)> =
let results: Vec<(ToolCallDraft, serde_json::Value, RiskLevel, Result<String, String>, i64)> =
futures::future::join_all(low_risk.into_iter().map(|(draft, args, risk_level)| {
let tools = tools_arc.clone();
let app_clone = app_handle.clone();
let conv_clone = conv_id.to_string();
async move {
// V50 纯执行耗时:闭包内逐工具测真执行墙钟(并行各自独立)。
let exec_started = std::time::Instant::now();
let result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone, &draft.id).await;
let exec_ms = exec_started.elapsed().as_millis() as i64;
match result {
Ok(val) => {
// L3 emit 双写:Low 风险工具执行成功 emit Completed 双路发布。
@@ -1244,7 +1298,7 @@ pub(crate) async fn process_tool_calls(
// AR-11(方案A):数据变更工具执行成功后 emit df-data-changed,
// 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。
emit_data_changed(&app_clone, &draft.name);
(draft, val.clone(), risk_level, Ok(val.to_string()))
(draft, val.clone(), risk_level, Ok(val.to_string()), exec_ms)
}
Err(e) => {
// AR-6(定向B):Low 工具失败不 emit AiError。
@@ -1260,7 +1314,7 @@ pub(crate) async fn process_tool_calls(
conversation_id: Some(conv_clone),
};
emit_ai_chat_event(&app_clone, ev);
(draft, serde_json::Value::Null, risk_level, Err(err_msg))
(draft, serde_json::Value::Null, risk_level, Err(err_msg), exec_ms)
}
}
}
@@ -1272,7 +1326,7 @@ pub(crate) async fn process_tool_calls(
// 砍 N 次串行 INSERT 尾巴(治 aichat 效率走查 T1-1)。审计失败不阻断主流程(记日志)。
let mut audit_records: Vec<df_storage::models::AiToolExecutionRecord> =
Vec::with_capacity(results.len());
for (draft, raw_result, risk_level, outcome) in results {
for (draft, raw_result, risk_level, outcome, exec_ms) in results {
let (status, content) = match outcome {
Ok(c) => ("completed", c),
Err(c) => ("failed", c),
@@ -1323,7 +1377,7 @@ pub(crate) async fn process_tool_calls(
}
audit_records.push(build_audit_record(
conv_id, &draft.id, &draft.name, &draft.args, status, risk_level,
Some(content), Some(decided_by), current_message_id,
Some(content), Some(decided_by), current_message_id, Some(exec_ms),
));
}
// 单事务批量 INSERT(一次 spawn_blocking,砍 N 次串行尾巴;失败仅记日志不阻断)。
@@ -1375,7 +1429,7 @@ pub(crate) async fn process_tool_calls(
dupe_audit_records.push(build_audit_record(
conv_id, &draft.id, &draft.name, &draft.args,
"completed", risk_level, Some(warned_content), Some("cache_hit"),
current_message_id,
current_message_id, None,
));
}
if !dupe_audit_records.is_empty() {
@@ -1403,13 +1457,17 @@ pub(crate) async fn process_tool_calls(
"[ai] 任务边界放行 {} 个工具进入并行执行(decided_by=boundary_auto)",
boundary_auto.len()
);
let results: Vec<(ToolCallDraft, RiskLevel, Result<String, String>)> =
let results: Vec<(ToolCallDraft, RiskLevel, Result<String, String>, i64)> =
futures::future::join_all(boundary_auto.into_iter().map(|(draft, args, risk_level)| {
let tools = tools_arc.clone();
let app_clone = app_handle.clone();
let conv_clone = conv_id.to_string();
async move {
match execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone, &draft.id).await {
// V50 纯执行耗时:闭包内逐工具测真执行墙钟。
let exec_started = std::time::Instant::now();
let exec_result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone, &draft.id).await;
let exec_ms = exec_started.elapsed().as_millis() as i64;
match exec_result {
Ok(val) => {
// L3 emit 双写:边界放行工具执行成功 emit Completed 双路发布。
let ev = AiChatEvent::AiToolCallCompleted {
@@ -1419,7 +1477,7 @@ pub(crate) async fn process_tool_calls(
};
emit_ai_chat_event(&app_clone, ev);
emit_data_changed(&app_clone, &draft.name);
(draft, risk_level, Ok(val.to_string()))
(draft, risk_level, Ok(val.to_string()), exec_ms)
}
Err(e) => {
// AR-6 同款:失败包进 tool_result 让 LLM 自纠,不 emit AiError 不断轮。
@@ -1430,13 +1488,13 @@ pub(crate) async fn process_tool_calls(
conversation_id: Some(conv_clone),
};
emit_ai_chat_event(&app_clone, ev);
(draft, risk_level, Err(err_msg))
(draft, risk_level, Err(err_msg), exec_ms)
}
}
}
})).await;
for (draft, risk_level, outcome) in results {
for (draft, risk_level, outcome, exec_ms) in results {
let (status, content) = match outcome {
Ok(c) => ("completed", c),
Err(c) => ("failed", c),
@@ -1460,7 +1518,7 @@ pub(crate) async fn process_tool_calls(
// 锁外审计:risk 真实等级透传(F-#97 防失真),decided_by=boundary_auto
// 标注授权来自任务边界(无人值守事后可全量追溯)。
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
status, risk_level, Some(content), Some("boundary_auto"), current_message_id).await;
status, risk_level, Some(content), Some("boundary_auto"), current_message_id, Some(exec_ms)).await;
}
}
+27 -2
View File
@@ -40,6 +40,7 @@ pub(crate) fn build_audit_record(
result: Option<String>,
decided_by: Option<&str>,
message_id: Option<&str>,
duration_ms: Option<i64>,
) -> AiToolExecutionRecord {
let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None };
// D2a 结果外置:超 8KB 的审计 result 落盘留句柄,治 DB 膨胀(devflow.db 193.7MB 实证)。
@@ -66,6 +67,10 @@ pub(crate) fn build_audit_record(
requested_at: now_millis(),
executed_at,
decided_by: decided_by.map(|s| s.to_string()),
// V50 纯执行耗时:由调用方在真执行路径测量后传入(执行前→执行后墙钟);
// 未真执行(deny/pending/缓存命中)传 None。与 requested_at→executed_at 解耦,
// 后者在审批路径混入等待人工批准时长。
duration_ms,
}
}
@@ -88,10 +93,11 @@ pub(crate) async fn audit_tool_call(
result: Option<String>,
decided_by: Option<&str>,
message_id: Option<&str>,
duration_ms: Option<i64>,
) {
let record = build_audit_record(
conv_id, tool_call_id, tool_name, arguments,
status, risk_level, result, decided_by, message_id,
status, risk_level, result, decided_by, message_id, duration_ms,
);
if let Err(e) = repo.insert(record).await {
tracing::error!(
@@ -119,7 +125,8 @@ pub(crate) async fn record_audit(
decided_by: Option<&str>,
message_id: Option<&str>,
) {
audit_tool_call(repo, conv_id, tool_call_id, tool_name, arguments, status, risk_level, result, decided_by, message_id).await;
// 事件总线旁路(无真执行计时),duration_ms 传 None。
audit_tool_call(repo, conv_id, tool_call_id, tool_name, arguments, status, risk_level, result, decided_by, message_id, None).await;
}
/// 查询审计历史记录(分页,按 requested_at 倒序)。
@@ -159,6 +166,10 @@ pub struct ToolExecutionDto {
pub executed_at: Option<String>,
/// human/auto,None 表示尚未决策
pub decided_by: Option<String>,
/// 纯执行耗时(毫秒,V50):真执行墙钟,与审批等待解耦;None=未执行/老数据。
/// 前端审计面板可用此替代 executed_at-requested_at(后者含审批等待)。
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<i64>,
}
/// 审批历史查询入参(前端透传,空值=不过滤)。
@@ -247,6 +258,7 @@ pub async fn list_tool_executions(
requested_at: r.requested_at,
executed_at: r.executed_at,
decided_by: r.decided_by,
duration_ms: r.duration_ms,
})
.collect();
let has_more = (offset as i64 + items.len() as i64) < total;
@@ -491,6 +503,16 @@ pub async fn tool_failure_stats(
Ok(aggregate_tool_stats(rows))
}
/// 读回外置工具结果原文(D2a 大结果外置的读取侧入口)。
///
/// 审计记录中 result 超 8KB 时被外置为句柄 JSON(含 path 字段),DB 只留句柄。
/// 前端审计面板展示历史时,遇句柄需按 path 读回原文全文——本 IPC 即该入口。
/// 过期(>90 天归档清理)后文件不在,返回带语义的错误提示(降级不 panic)。
#[tauri::command]
pub async fn ai_read_offloaded_result(path: String) -> Result<String, String> {
tool_result_offload::read_offloaded(&path)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -512,6 +534,7 @@ mod tests {
requested_at: t.to_string(),
executed_at: None,
decided_by: None,
duration_ms: None,
})
.await
.expect("测试数据插入应成功");
@@ -581,6 +604,7 @@ mod tests {
requested_at: t.to_string(),
executed_at: Some(t.to_string()),
decided_by: Some("auto".to_string()),
duration_ms: None,
})
.await
.expect("测试数据插入应成功");
@@ -608,6 +632,7 @@ mod tests {
origin: origin.map(|s| s.to_string()),
boundary_json: None,
loop_state: None,
status: "active".to_string(),
})
.await
.expect("测试会话插入应成功");
+104 -26
View File
@@ -27,7 +27,7 @@ use crate::state::{AppState, log_slow_lock};
use crate::commands::{err_str, now_millis};
// chat.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。
use super::super::agentic::{run_agentic_loop, try_continue_agent_loop};
use super::super::agentic::{resolve_model_with_override, run_agentic_loop, try_continue_agent_loop};
use super::super::event_bus::emit_ai_chat_event;
#[cfg(test)]
use super::super::agentic::GOAL_MAX_CHARS;
@@ -695,6 +695,7 @@ pub async fn ai_chat_send(
parts,
mention_spans,
true,
None, // #958:标题/内部路径无外部来源
)
.await
.map(|_| "ok".to_string())
@@ -772,6 +773,8 @@ async fn reply_status_summary(
prompt_cache_miss_tokens: 0,
reasoning_tokens: 0,
is_estimated: false,
// 零 token 收敛信号,非截断(看板 953;枚举新字段,Rust 字面量强制显式赋值)。
truncated_by_max_tokens: false,
incomplete: None,
incomplete_reason: None,
conversation_id: Some(target),
@@ -804,6 +807,8 @@ pub(crate) async fn spawn_agentic_session(
parts: Option<Vec<ContentPart>>,
mention_spans: Option<Vec<MentionSpanDto>>,
pin_active: bool,
// #958 溯源:外部渠道(飞书等)原始消息定位,None=桌面本端。随 user 消息落库
source: Option<df_ai::provider::MessageSource>,
) -> Result<String, String> {
// 获取活跃提供商(只读,失败可直接返回,不影响生成标志)
let provider_config = super::super::prompt::get_active_provider(state).await?;
@@ -820,6 +825,19 @@ pub(crate) async fn spawn_agentic_session(
other => (other, false),
};
// offload(#958 引用化,vision 判定后):存活图片片 base64 → media_store 落盘引用
// (hash 去重,OSS 配置在则 https 引用跨设备可解析)。桌面粘贴图/前端上传在此统一收口
// ——消息只存引用,SQLite 不进大 base64,历史重放零负担。落盘失败保 base64 片不丢图。
// 在锁前做(与上方预拉同窗口,不持锁 I/O)
let parts = match parts {
Some(ps) if !ps.is_empty() => {
let oss_cfg = crate::media_store::read_oss_config(&state.db).await;
let store = crate::media_store::MediaStore::new(state.data_dir.clone(), oss_cfg);
Some(crate::media_store::offload_image_parts(ps, &store).await)
}
other => other,
};
// 纯发图(无文本无技能)且模型非视觉:降级后无任何有效内容,原「[图片已忽略…]」占位串
// 会被当 user 消息喂模型(回复跑偏且浪费一轮 LLM 调用)。此处直接拒绝发送,错误经前端
// 发送失败通道透出;图文混合(有文本/技能)维持占位后缀现状。置于锁与消息 push 之前,
@@ -866,6 +884,17 @@ pub(crate) async fn spawn_agentic_session(
return Err("AI 正在生成中,请等待完成".to_string());
}
let conv = session.conv(&target);
// 939 ★③:按目标模型实际上下文窗口重设消息预算(仅在新建空会话生效,收口见 model_context)。
// 路由口径同主 loop pre-loop(needs_tool_use=true;estimated_context/tier 置零——预算只关心窗口,
// 与 loop 内按真实估计的重路由无关);override 穿透/兜底由 resolve_model_with_override 统一处理。
let budget_req = df_ai::router::TaskRequirements {
modalities: vec![df_ai::router::Modality::Text],
needs_tool_use: true,
estimated_context: 0,
tier: None,
};
let budget_model = resolve_model_with_override(&budget_req, &provider_config, &model_override);
conv.apply_model_window(&budget_model, &provider_config.model_configs);
// 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口迁移设置(不再手动赋值)。
conv.stop_flag.store(false, Ordering::SeqCst);
conv.agent_language = language.clone();
@@ -906,9 +935,13 @@ pub(crate) async fn spawn_agentic_session(
// F-260614-05 Phase 2c: parts 非空走 user_parts(多模态),空/None 走 user()(纯文本零回归)。
// user_parts 内部把 parts 挂到 ChatMessage.parts,provider 在 has_image() 为真时转 vision 端点。
if let Some(ps) = parts.as_ref().filter(|p| !p.is_empty()) {
conv.messages.push(ChatMessage::user_parts(&user_content, ps.clone()));
let mut m = ChatMessage::user_parts(&user_content, ps.clone());
m.source = source.clone();
conv.messages.push(m);
} else {
conv.messages.push(ChatMessage::user(&user_content));
let mut m = ChatMessage::user(&user_content);
m.source = source.clone();
conv.messages.push(m);
}
// 新用户轮把本请求钉扎为 WorkingContext 常驻目标(截断 200 字符,与 agentic G1 目标钉扎 GOAL_MAX_CHARS 同量级)。
// 喂原文 message 而非落库变换文本 user_content:begin_turn 契约是"本条用户请求",
@@ -1094,6 +1127,7 @@ pub(crate) fn orchestration_session_spawner() -> df_nodes::ai_agent_node::SpawnF
None,
None,
false,
None, // #958:编排路径无外部来源
)
.await
{
@@ -1256,7 +1290,7 @@ pub async fn ai_approve(
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
}
// 审计:拒绝(决策者=human)
audit_finalize(&state, &tool_call_id, "rejected", None).await;
audit_finalize(&state, &tool_call_id, "rejected", None, None).await;
// F-260616-11 决策 a: 审批续跑 iteration 累计(不重置)——读 per_conv.iteration_used 透传
// 作 start_iteration,防多次审批反复跑满 max_iterations 致 token 失控。
// F-09 B 批4:per_conv 唯一真相源(conv_id 来源 approval.conversation_id);无 conv_id 时 0。
@@ -1315,6 +1349,8 @@ pub async fn ai_approve(
// 仅分支 run_workflow,其余工具审批路径完全不变(含 AE-04 trust_key_for 写 trust / audit 终态
// 回填 / emit_data_changed / save / try_continue)。workflow 联动任务推进由 run_workflow 内部
// 按 task_id+target_status 自动处理(workflow.rs:270-308),此处不重复推进。
// V50 纯执行耗时:只测真执行窗口(run_workflow / execute_with_heartbeat),不含等待人工批准。
let exec_started = std::time::Instant::now();
let exec_result: anyhow::Result<serde_json::Value> = if approval.tool_name == "run_workflow" {
super::super::execute_run_workflow_for_tool(&app, &state, &args, conv_id.as_deref()).await
} else {
@@ -1329,6 +1365,7 @@ pub async fn ai_approve(
&app, conv_id.as_deref().unwrap_or_default(), &id,
).await
};
let exec_ms = exec_started.elapsed().as_millis() as i64;
// 工具失败不 return Err:把错误包成 tool_result,落库 + emit completed + 续循环全走通。
// 否则前端 approveToolCall 的 catch 会回滚 pending_approval,审批按钮卡死无法消除。
let (audit_status, result_val) = match &exec_result {
@@ -1336,7 +1373,7 @@ pub async fn ai_approve(
Err(e) => ("failed", serde_json::Value::String(e.to_string())),
};
// 审计:人工审批后无论成败回填(决策者=human)
audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string())).await;
audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string()), Some(exec_ms)).await;
// AR-11(方案A):人工审批通过且工具执行成功(非 failed)后 emit df-data-changed,
// 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。
@@ -1607,7 +1644,7 @@ pub async fn ai_authorize_dir(
conversation_id: conv_id.clone(),
};
emit_ai_chat_event(&app, ev_approval);
audit_finalize(&state, &tool_call_id, "rejected", Some(err_msg)).await;
audit_finalize(&state, &tool_call_id, "rejected", Some(err_msg), None).await;
if let Some(ref cid) = conv_id {
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
}
@@ -1657,6 +1694,8 @@ pub async fn ai_authorize_dir(
tracing::debug!(tool = %approval.tool_name, "[AI-AUTHZ] execute 前");
// 防工具卡死:超时兜底已收敛进 execute_with_heartbeat 的 outer_secs 分档,
// execute 不返回时超时返 Err → 走 failed 分支 finalize+emit+try_continue,IPC 必返回不卡。
// V50 纯执行耗时:只测真执行窗口,不含等待人工批准。
let exec_started = std::time::Instant::now();
let exec_result: anyhow::Result<serde_json::Value> = if approval.tool_name == "run_workflow" {
super::super::execute_run_workflow_for_tool(&app, &state, &args, conv_id.as_deref()).await
} else {
@@ -1668,6 +1707,7 @@ pub async fn ai_authorize_dir(
&app, conv_id.as_deref().unwrap_or_default(), &tool_call_id,
).await
};
let exec_ms = exec_started.elapsed().as_millis() as i64;
tracing::debug!(
ok = exec_result.is_ok(),
status = if exec_result.is_ok() { "completed" } else { "failed" },
@@ -1685,7 +1725,7 @@ pub async fn ai_authorize_dir(
if let Some(note) = cross_drive_hint(&approval.tool_name, &req.raw_paths) {
result_val = append_cross_drive_note(result_val, &note);
}
audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string())).await;
audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string()), Some(exec_ms)).await;
if exec_result.is_ok() {
emit_data_changed(&app, &approval.tool_name);
// MED-1(审查): path 绑定工具(Medium)审批执行后 reload 白名单。
@@ -2250,6 +2290,16 @@ pub async fn ai_chat_force_send(
other => (other, false),
};
// offload(#958 引用化,同 ai_chat_send):重发路径同样收口 base64→引用
let parts = match parts {
Some(ps) if !ps.is_empty() => {
let oss_cfg = crate::media_store::read_oss_config(&state.db).await;
let store = crate::media_store::MediaStore::new(state.data_dir.clone(), oss_cfg);
Some(crate::media_store::offload_image_parts(ps, &store).await)
}
other => other,
};
// 纯发图(无文本无技能)且模型非视觉:同 spawn_agentic_session 的拒绝闸(占位串不喂模型),
// 置于锁与消息 push 之前,拒绝零副作用。
if degraded && message.trim().is_empty() && skill.is_none() {
@@ -2383,25 +2433,6 @@ pub async fn ai_chat_force_send(
session.conv(&conv_id).stop_flag.store(false, std::sync::atomic::Ordering::SeqCst);
}
// 通知前端目标 conv 旧生成已结束(若先前在生成;emit 在锁外,避免持锁调 runtime emit)
if let Some(ref cid) = old_conv_id {
let ev = AiChatEvent::AiCompleted {
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
prompt_cache_hit_tokens: 0,
prompt_cache_miss_tokens: 0,
reasoning_tokens: 0,
// 零 token 收敛信号,非估算(无真实 LLM 调用)。
is_estimated: false,
incomplete: None,
incomplete_reason: None,
conversation_id: Some(cid.clone()),
pinned_goals: old_pinned_goals.clone(),
};
emit_ai_chat_event(&app, ev);
}
// system prompt 构建(照 ai_chat_send):augmentation 注入 + 知识注入顺序一致。
let _tool_defs = state.ai_tools.tool_definitions();
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
@@ -2461,9 +2492,50 @@ pub async fn ai_chat_force_send(
sorted.first().map(|p| p.to_string_lossy().to_string())
};
// 看板 949 后端消竞态:旧任务终态 emit 挪到「新 loop 占用 generating 之后」。
// 原 emit 位于「复位+占用」与 spawn 之间:前端迟到收尾仲裁(useAiLifecycleEvents
// cleanupTerminatedConversation)收到旧 AiCompleted 时查 ai_is_generating,若新 loop 尚未
// spawn / 入口未迁移 Generating 会读到 false → 误判真收尾 → setStreaming(false),新 loop
// 内容照流(AiTextDelta 无条件累加)但发送按钮失态。此处 spawn 后短轮询等新 loop 入口
// GeneratingGuard 迁移 Generating(同步迁移,正常毫秒级),占用成功或 2s 到期后再 emit;
// 到期未占用也 emit(旧任务确已终局,前端按真收尾收敛语义仍正确,只是失去仲裁保护)。
let occupied_probe = conv_id.clone();
tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, new_epoch, h3_watch_dir).await;
});
let occupied = {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
loop {
if state.conv_states.is_active(&occupied_probe) {
break true;
}
if std::time::Instant::now() >= deadline {
break false;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
};
tracing::debug!(conv_id = %occupied_probe, occupied, "[ai] force_send 旧终态 emit 前新 loop 占用检查");
// 通知前端目标 conv 旧生成已结束(若先前在生成;emit 在锁外,避免持锁调 runtime emit)
if let Some(ref cid) = old_conv_id {
let ev = AiChatEvent::AiCompleted {
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
prompt_cache_hit_tokens: 0,
prompt_cache_miss_tokens: 0,
reasoning_tokens: 0,
// 零 token 收敛信号,非估算(无真实 LLM 调用)。
is_estimated: false,
// 零 token 收敛信号,非截断(看板 953;枚举新字段,Rust 字面量强制显式赋值)。
truncated_by_max_tokens: false,
incomplete: None,
incomplete_reason: None,
conversation_id: Some(cid.clone()),
pinned_goals: old_pinned_goals.clone(),
};
emit_ai_chat_event(&app, ev);
}
Ok("ok".to_string())
}
@@ -2528,6 +2600,8 @@ pub async fn ai_chat_stop(
prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0,
// 零 token 收敛信号,非估算(无真实 LLM 调用)。
is_estimated: false,
// 零 token 收敛信号,非截断(看板 953;枚举新字段,Rust 字面量强制显式赋值)。
truncated_by_max_tokens: false,
incomplete: None,
incomplete_reason: None,
conversation_id: Some(target.clone()),
@@ -2649,6 +2723,8 @@ pub async fn ai_chat_stop(
reasoning_tokens: 0,
// 零 token 收敛信号,非估算(无真实 LLM 调用)。
is_estimated: false,
// 零 token 收敛信号,非截断(看板 953;枚举新字段,Rust 字面量强制显式赋值)。
truncated_by_max_tokens: false,
incomplete: None,
incomplete_reason: None,
conversation_id: conv_id.clone(),
@@ -2847,6 +2923,8 @@ pub async fn ai_stop_loop(
reasoning_tokens: 0,
// 零 token 收敛信号,非估算(无真实 LLM 调用)。
is_estimated: false,
// 零 token 收敛信号,非截断(看板 953;枚举新字段,Rust 字面量强制显式赋值)。
truncated_by_max_tokens: false,
incomplete: None,
incomplete_reason: None,
conversation_id: Some(conversation_id),
@@ -79,6 +79,11 @@ pub fn record_to_message(rec: &AiMessageRecord) -> ChatMessage {
.as_deref()
.filter(|s| !s.is_empty())
.and_then(|s| serde_json::from_str(s).ok());
let source = rec
.source
.as_deref()
.filter(|s| !s.is_empty())
.and_then(|s| serde_json::from_str(s).ok());
let tool_calls = rec
.tool_calls
.as_deref()
@@ -107,6 +112,7 @@ pub fn record_to_message(rec: &AiMessageRecord) -> ChatMessage {
prompt_cache_miss_tokens: rec.prompt_cache_miss_tokens,
reasoning_tokens: rec.reasoning_tokens,
is_estimated: rec.is_estimated,
source,
}
}
@@ -137,6 +143,10 @@ pub fn message_to_record(
.as_ref()
.map(|s| s.as_db_str().to_string())
.unwrap_or_else(|| "active".to_string());
let source = msg
.source
.as_ref()
.map(|s| serde_json::to_string(s).unwrap_or_default());
AiMessageRecord {
id,
conversation_id: conversation_id.to_string(),
@@ -157,6 +167,7 @@ pub fn message_to_record(
prompt_cache_miss_tokens: msg.prompt_cache_miss_tokens,
reasoning_tokens: msg.reasoning_tokens,
is_estimated: msg.is_estimated,
source,
}
}
@@ -348,6 +359,11 @@ pub(crate) async fn recover_parked_inner(
return Err("该会话正在生成中,无需恢复".to_string());
}
let message = PARKED_RECOVERY_MESSAGE.to_string();
// V50 终局态复位:恢复即重新进行,清 incomplete(停靠时已标)。loop 侧收尾单点在 agentic 主循环,
// 此处先复位保证续作期间语义正确;续作后正常收敛/再次停靠会再各自覆写。
if let Err(e) = state.ai_conversations.set_status(conversation_id, "active").await {
tracing::warn!(conv_id = %conversation_id, error = %e, "[ai] 恢复会话复位 status=active 失败");
}
spawn_agentic_session(
app,
state,
@@ -359,6 +375,7 @@ pub(crate) async fn recover_parked_inner(
None, // parts:纯文本
None, // mention_spans
true, // pin_active:恢复动作即用户当前意图
None, // #958:恢复路径无外部来源
)
.await
}
@@ -917,6 +934,8 @@ pub async fn ai_conversation_delete(
let zero_usage = df_ai::provider::TokenUsage::default();
crate::commands::ai::agentic::emit_ai_completed_once(
&app, &conversation_id, &zero_usage, false,
// 会话删除终态补发,非截断(看板 953)。
false,
Some(true), None, Some("deleted"), true,
&[], false,
).await;
@@ -1171,6 +1190,7 @@ mod tests {
id: Some("msg_test_1".into()),
role: MessageRole::User,
content: "你好".into(),
source: None,
parts: None,
tool_call_id: None,
tool_calls: None,
@@ -1573,6 +1593,7 @@ mod tests {
origin: origin.map(|s| s.to_string()),
boundary_json: None,
loop_state: None,
status: "active".to_string(),
})
.await
.expect("测试会话插入应成功");
+92 -4
View File
@@ -14,7 +14,7 @@ use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
// 摘要任务,选 Standard 智力保摘要质量;不传 tools(纯文本出,避免 LLM 调工具跑偏)。
// 池空/无匹配兜底 default_model(与 title.rs 一致,行为可预期)。
use df_ai::router::{
is_likely_image_model, select_model_id, Modality, TaskRequirements,
is_likely_image_model, select_model_id, Modality, ModelConfig, TaskRequirements,
};
use df_storage::models::AiProviderRecord;
@@ -72,15 +72,20 @@ pub(crate) async fn compress_via_llm(
let active_msgs = df_ai::context::ContextManager::sanitize_messages(active_msgs);
// 路由选模型:无 tool_use(纯文本摘要)。
// None(池空/无匹配)→ 兜底 default_model(行为不变)。
// None(池空/无匹配)→ 兜底 default_model,但须校验其 enabled(停用则回落池内启用模型,
// 见 resolve_fallback_model);池内无任何启用模型 → Err 让调用方降级,不再静默用停用模型。
let compress_req = TaskRequirements {
modalities: vec![Modality::Text],
needs_tool_use: false,
estimated_context: 0,
tier: None,
};
let model = select_model_id(&compress_req, &provider_config.model_configs)
.unwrap_or_else(|| provider_config.default_model.clone());
let model = match select_model_id(&compress_req, &provider_config.model_configs) {
Some(m) => m,
None => {
resolve_fallback_model(&provider_config.model_configs, &provider_config.default_model)?
}
};
// 防御:选中/兜底模型是图像模型时返 Err(走关键词摘要兜底,避免文本任务 404 刷屏)。
if is_likely_image_model(&model, &provider_config.model_configs) {
return Err(format!(
@@ -150,6 +155,46 @@ pub(crate) async fn compress_via_llm(
})
}
/// `select_model_id` 返 None(池空/无匹配)时的**兜底模型解析**(治 929 次生缺口)。
///
/// 原兜底直接 `provider_config.default_model.clone()`,不校验该模型的 `enabled` ——
/// 用户在设置里停用 default_model 后,压缩/标题仍照用被停用的模型(与 agentic 侧 override 路径
/// `resolve_model_with_override` 同源缺口:停用只挡路由,不挡兜底)。此处在 enabled 口径下解析:
///
/// - **default_model 在池中且启用** → 采用(正常兜底);
/// - **default_model 不在池中**(未拉取模型/池空/配置未同步)→ 无法校验,采用 default_model
/// (不因无法校验而阻断,保持旧行为);
/// - **default_model 在池中被显式停用** → 回落池中任一 enabled 模型(与 `select_model_id`
/// 的 enabled 过滤同口径);
/// - **池内无任何 enabled 模型** → Err(明确报错,不再静默沿用停用模型,让用户感知配置问题)。
///
/// 与 `select_model_id` 分工:后者按「需求匹配 + weight」选优;本函数只解决「兜底候选是否被停用」
/// 的校验与回落,不重复路由逻辑。抽为纯函数便于单测(零 IO)。
pub(crate) fn resolve_fallback_model(
pool: &[ModelConfig],
default_model: &str,
) -> Result<String, String> {
// 池中有该条目 → 以其 enabled 为准;无该条目 → 无法校验,不阻断(采用 default_model)。
let default_available = pool
.iter()
.find(|m| m.model_id == default_model)
.map(|m| m.enabled)
.unwrap_or(true);
if default_available {
return Ok(default_model.to_string());
}
// 回落:池中任一 enabled 模型(与 select_model_id 的 enabled 过滤同口径)。
pool.iter()
.find(|m| m.enabled)
.map(|m| m.model_id.clone())
.ok_or_else(|| {
format!(
"无可用模型:default_model「{}」已在模型配置中停用,且池内无任何启用模型(请在设置中启用模型)",
default_model
)
})
}
/// 阶段2(占位配对完整性):检测压缩段是否含未闭合占位 tool_result。
///
/// 占位 tool_result(content 带 __PENDING__ 标记,审批挂起)若无对应 tool_call 头(头被裁/丢)
@@ -445,4 +490,47 @@ mod tests {
];
assert!(!compress_segment_has_unclosed_placeholder(&msgs));
}
// ── 929 次生缺口:resolve_fallback_model(兜底模型须校验 enabled) ──
/// 构造池内模型(enabled 可控,其余用默认)。
fn mc(id: &str, enabled: bool) -> ModelConfig {
ModelConfig { enabled, ..ModelConfig::with_defaults(id) }
}
#[test]
fn fallback_default_enabled_in_pool_used() {
// default_model 在池中且启用 → 采用 default_model
let pool = vec![mc("deepseek-chat", true), mc("glm-4-flash", true)];
assert_eq!(
resolve_fallback_model(&pool, "deepseek-chat").unwrap(),
"deepseek-chat"
);
}
#[test]
fn fallback_default_absent_from_pool_used() {
// default_model 不在池中(未拉取/池空)→ 无法校验,不阻断,采用 default_model
let empty: Vec<ModelConfig> = vec![];
assert_eq!(resolve_fallback_model(&empty, "legacy-model").unwrap(), "legacy-model");
let pool = vec![mc("glm-4-flash", true)];
assert_eq!(resolve_fallback_model(&pool, "legacy-model").unwrap(), "legacy-model");
}
#[test]
fn fallback_default_disabled_falls_back_to_enabled_pool_model() {
// default_model 在池中被停用 → 回落池中任一 enabled 模型(不静默沿用停用模型)
let pool = vec![mc("stopped-model", false), mc("glm-4-flash", true)];
let got = resolve_fallback_model(&pool, "stopped-model").unwrap();
assert_eq!(got, "glm-4-flash", "停用的 default_model 应回落启用模型");
}
#[test]
fn fallback_default_disabled_and_no_enabled_errors() {
// default_model 停用 + 池内无任何启用模型 → 明确报错(而非静默用停用模型)
let pool = vec![mc("stopped-model", false), mc("also-stopped", false)];
let err = resolve_fallback_model(&pool, "stopped-model").unwrap_err();
assert!(err.contains("stopped-model"), "错误信息应含被停用的 default_model 名便于定位");
assert!(err.contains("停用"), "错误信息应说明停用原因");
}
}
+18 -5
View File
@@ -546,6 +546,8 @@ async fn save_conversation_inner(
.filter(|s| !s.is_empty()),
// #10:懒创建继承 loop running(时序根治——spawn 先置标后落库的 UPDATE 0 行丢失)
loop_state: if conv_is_active { Some("running".to_string()) } else { None },
// V50 终局态:新落库默认 active(进行中);终局由收尾路径 set_status 覆写。
status: "active".to_string(),
};
if let Err(e) = conv_repo.insert(rec).await {
tracing::warn!("落库对话失败 {conv_id}: {e}");
@@ -575,17 +577,27 @@ async fn save_conversation_inner(
.advance_persisted_count(total_len, needs_full_rewrite);
}
// 自动 checkpoint(每 20 轮或总 token > 150k 时创建)
// 自动 checkpoint(每 20 条消息为一个快照点)
{
let total_tokens: i64 = persist_msgs.len() as i64;
if total_tokens > 0 && total_tokens % 20 == 0 {
let msg_count: i64 = persist_msgs.len() as i64;
if msg_count > 0 && msg_count % 20 == 0 {
let snapshot = serde_json::to_string(&persist_msgs).unwrap_or_default();
if !snapshot.is_empty() && snapshot.len() < 1_000_000 {
let ck_id = format!("ck_{}_{}", conv_id.replace('-', ""), total_tokens);
// token_total 语义=真实 token 用量(逐条 prompt+completion 累加),非消息条数。
// 原误用 persist_msgs.len() 当 token,致统计差 3 个量级(p50=120 vs 数万级)。
let token_total: i64 = persist_msgs
.iter()
.map(|m| {
m.prompt_tokens.unwrap_or(0) as i64
+ m.completion_tokens.unwrap_or(0) as i64
})
.sum();
// ck_id 仍以消息条数锚定(同轮/同长度重存幂等跳过),与 token_total 语义解耦。
let ck_id = format!("ck_{}_{}", conv_id.replace('-', ""), msg_count);
let ck_now = now_millis().to_string();
// 参数化绑定(走 AiConversationRepo::insert_checkpoint),替代原 format! 拼 SQL,
// 防 snapshot 含引号/特殊字符致注入或损坏。
if let Err(e) = conv_repo.insert_checkpoint(&ck_id, conv_id, &snapshot, total_tokens, &ck_now).await {
if let Err(e) = conv_repo.insert_checkpoint(&ck_id, conv_id, &snapshot, token_total, &ck_now).await {
tracing::warn!("checkpoint 写入失败 {conv_id}: {e}");
}
}
@@ -776,6 +788,7 @@ mod tests {
id: None,
role,
content: content.to_string(),
source: None,
parts: None,
tool_call_id: None,
tool_calls: None,
+119
View File
@@ -84,10 +84,69 @@ pub const DEFAULT_BUS_CAPACITY: usize = 256;
/// 事件值语义:emit 与 publish 收到**同一份事件值**(内部 clone),对齐原手抄 `ev.clone()` 两行式。
/// 消费 ev(调用方无需再 clone)。
pub(crate) fn emit_ai_chat_event(app: &AppHandle, ev: AiChatEvent) {
// 子代理运行期事件改挂(见 CONV_ROUTE_OVERRIDE):作用域内命中来源会话的展示类事件
// 改挂目标会话再双写;未进作用域或未命中时保持原双写,常规路径零行为变化。
if let Ok((from, to)) = CONV_ROUTE_OVERRIDE.try_with(|r| r.clone()) {
if let Some(v) = route_conv_event(&ev, &from, &to) {
let _ = app.emit("ai-chat-event", v.clone());
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish(v);
return;
}
}
let _ = app.emit("ai-chat-event", ev.clone());
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
}
// 子代理运行期事件路由覆写(2026-10-10)。
//
// spawn_agent 子循环运行期,子会话(from)的运行事件默认挂子会话 conv 推送,父会话视图收不
// 到子代理的实时进展(切回父会话只见 spawn 卡停在旧态,出现「已完成」幽灵态)。本 task-local
// 在子循环作用域内把 from 会话的**展示类**事件改挂父会话(to)conv 再推送,复用同一 emit 双写
// 出口(不开旁路);终态/状态机/审批/求助类事件不改写,避免污染父会话生成态与生命周期。
tokio::task_local! {
static CONV_ROUTE_OVERRIDE: (String, String);
}
/// 在「把 from 会话的展示类事件改挂 to 会话」的作用域内执行 future(子代理运行期包裹子循环
/// 的流式与工具调用;作用域外事件路由不变)。
pub(crate) async fn route_scope<F, R>(from: String, to: String, fut: F) -> R
where
F: std::future::Future<Output = R>,
{
CONV_ROUTE_OVERRIDE.scope((from, to), fut).await
}
/// 展示类事件白名单:仅这些随子代理运行改挂父会话视图(轮次/文本/心跳/重试/工具生命周期/
/// 命令输出)。负例(AiCompleted/AiError/AiAgentFinished/AiConvStateChanged/审批/求助/停靠等)
/// 承载父或子各自的会话生命周期,改写会污染父会话生成态,故不改写。
const ROUTABLE_EVENT_TYPES: &[&str] = &[
"AiAgentRound",
"AiTextDelta",
"AiHeartbeat",
"AiStreamRetry",
"AiToolCallStarted",
"AiToolCallCompleted",
"AiToolAutoApproved",
"AiCommandOutput",
];
/// 展示类且 conversation_id 命中 from → 返回改挂 to 后的 JSON 值;否则 None(调用方走原双写)。
///
/// 用序列化探针取 type/conversation_id,避免为 40+ 事件变体逐一手写 match(改一处漏一处的
/// 漂移源)。命中率极低(仅子代理运行期),开销可忽略。
fn route_conv_event(ev: &AiChatEvent, from: &str, to: &str) -> Option<serde_json::Value> {
let mut v = serde_json::to_value(ev).ok()?;
let ty = v.get("type").and_then(|t| t.as_str())?;
if !ROUTABLE_EVENT_TYPES.contains(&ty) {
return None;
}
if v.get("conversation_id").and_then(|c| c.as_str()) != Some(from) {
return None;
}
v["conversation_id"] = serde_json::Value::String(to.to_string());
Some(v)
}
// ============================================================
// EventBus — 基于 tokio::sync::broadcast 的 pub-sub 总线(载荷 serde_json::Value)
//
@@ -337,4 +396,64 @@ mod tests {
assert_eq!(received, expected.clone(), "每个变体应原样透传(全透传不丢)");
}
}
// ============================================================
// route_conv_event — 子代理运行期事件改挂(白名单 × from 命中)
//
// 锁定三态:① 白名单内 + conversation_id==from → 改挂 to 返回 Some;
// ② 非白名单 → None(不改挂,避免污染父会话生成态);
// ③ conversation_id != from / None → None(不误改他会话事件)。
// ============================================================
/// 白名单内且 conversation_id 命中 from → 返回改挂 to 后的值(仅 conversation_id 变,type/其余字段原样)。
#[test]
fn route_conv_event_rewrites_from_to() {
let ev = AiChatEvent::AiTextDelta {
delta: "hi".into(),
conversation_id: Some("sub".into()),
};
let v = route_conv_event(&ev, "sub", "parent")
.expect("白名单内且命中 from 应返回 Some");
assert_eq!(v["type"], "AiTextDelta", "type 判别字段应保留");
assert_eq!(v["conversation_id"], "parent", "conversation_id 应改挂到 to");
assert_eq!(v["delta"], "hi", "其余字段应原样透传");
}
/// 非白名单事件(承载父/子会话生命周期)→ None,不改写。
#[test]
fn route_conv_event_rejects_non_whitelisted() {
// AiMaxRoundsReached 不在 ROUTABLE_EVENT_TYPES,改写会污染父会话状态机
let ev = AiChatEvent::AiMaxRoundsReached {
conversation_id: Some("sub".into()),
};
assert!(
route_conv_event(&ev, "sub", "parent").is_none(),
"非白名单事件不应改挂"
);
}
/// conversation_id 非 from(他会话来源)→ None,不动。
#[test]
fn route_conv_event_skips_unmatched_from() {
let ev = AiChatEvent::AiAgentRound {
round: 2,
conversation_id: Some("other".into()),
};
assert!(
route_conv_event(&ev, "sub", "parent").is_none(),
"conversation_id 非 from 不应改挂"
);
}
/// conversation_id 缺省(None)→ 不命中 → None。
#[test]
fn route_conv_event_skips_none_conv() {
let ev = AiChatEvent::AiHeartbeat {
conversation_id: None,
};
assert!(
route_conv_event(&ev, "sub", "parent").is_none(),
"conversation_id 缺省不应改挂"
);
}
}
+4 -1
View File
@@ -260,7 +260,10 @@ async fn fetch_with_obscura(url_raw: &str, max_length: usize) -> anyhow::Result<
let started = Instant::now();
// ── 检测 obscura ──
let obscura_path = match find_obscura() {
// find_obscura 内部 std::thread + recv_timeout 阻塞探活(单候选最多 15s),须在阻塞池跑,
// 否则卡住单线程 runtime;join 失败(理论不发生)按"未装"降级。
let detected = tokio::task::spawn_blocking(find_obscura).await.ok().flatten();
let obscura_path = match detected {
Some(p) => p,
None => {
// 未装:回退静态 + hint。

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