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 *.log
npm-debug.log* npm-debug.log*
# 换装脚本(scripts/swap-install.ps1)运行期锁文件
/swap-install.lock
# Dependencies # Dependencies
node_modules/ node_modules/
@@ -32,6 +35,10 @@ target-musl/ # df-relay 交叉编译产物隔离目录(见 .cargo/config.toml)
src-tauri/target/ src-tauri/target/
src-tauri/Cargo.lock src-tauri/Cargo.lock
# 签名私钥(应用内升级,设计 §七 高风险项:私钥绝不入仓)
*.key
*.key.pub
# Tauri 自动生成的 schema(每次构建变,可重建) # Tauri 自动生成的 schema(每次构建变,可重建)
src-tauri/gen/schemas/ src-tauri/gen/schemas/
@@ -100,3 +107,7 @@ temp/
# df-im-gateway 运行时数据(冒烟测试残留) # df-im-gateway 运行时数据(冒烟测试残留)
crates/df-im-gateway/data/ crates/df-im-gateway/data/
# Playwright 运行产物
test-results/
playwright-report/
+5
View File
@@ -16,5 +16,10 @@
- 边界对齐意志卡 forbidden:发版/部署/重启线上、git push、生产数据操作、对外发消息,攒齐等拍板。 - 边界对齐意志卡 forbidden:发版/部署/重启线上、git push、生产数据操作、对外发消息,攒齐等拍板。
- 本机工具/脚本的行为变更属 report_after 级(干完通报、可回滚),不属确认级。 - 本机工具/脚本的行为变更属 report_after 级(干完通报、可回滚),不属确认级。
## 命名与代码风格(2026-10-10 用户重申)
- **变量/方法名:精简 + 准确**——一眼看出「是什么/做什么」且不超必要长度。忌把实现细节写进名字(`..._after_tool_run`)、忌无信息缩写(`tmp2`/`h`)、忌同概念多叫法。名字过长通常说明该拆函数或把说明下沉到注释(注释承载「为什么」,名字只承载「是什么」)。
- **DRY · 简洁 · 扩展性 · 高内聚低耦合**(同期重申):新增能力先找共用底座(如定时消息与定时任务共用一张 `ai_outbox` + 一个调度器),不造双轨;同一事实只留**单一真值来源**(如 token 估值口径)。
## 其他规范 ## 其他规范
- 架构设计文档在 `docs/02-架构设计/`;当前活跃专项:无人值守可靠性(B1-B4)+ 异步任务会话(13-17),设计见 `temp/` 与 docs。 - 架构设计文档在 `docs/02-架构设计/`;当前活跃专项:无人值守可靠性(B1-B4)+ 异步任务会话(13-17),设计见 `temp/` 与 docs。
Generated
+249 -6
View File
@@ -104,6 +104,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
dependencies = [
"derive_arbitrary",
]
[[package]] [[package]]
name = "asn1-rs" name = "asn1-rs"
version = "0.7.2" version = "0.7.2"
@@ -996,6 +1005,17 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "derive_arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "2.1.1" version = "2.1.1"
@@ -1019,7 +1039,7 @@ dependencies = [
[[package]] [[package]]
name = "devflow" name = "devflow"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -1039,7 +1059,10 @@ dependencies = [
"df-workflow", "df-workflow",
"futures", "futures",
"futures-util", "futures-util",
"hex",
"hmac",
"htmd", "htmd",
"httpdate",
"keyring", "keyring",
"larksuite-oapi-sdk-rs", "larksuite-oapi-sdk-rs",
"percent-encoding", "percent-encoding",
@@ -1047,6 +1070,8 @@ dependencies = [
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
"sha1 0.10.6",
"sha2 0.10.9",
"sys-locale", "sys-locale",
"tauri", "tauri",
"tauri-build", "tauri-build",
@@ -1054,6 +1079,7 @@ dependencies = [
"tauri-plugin-notification", "tauri-plugin-notification",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-single-instance", "tauri-plugin-single-instance",
"tauri-plugin-updater",
"tauri-plugin-window-state", "tauri-plugin-window-state",
"tokio", "tokio",
"tokio-tungstenite 0.23.1", "tokio-tungstenite 0.23.1",
@@ -1076,6 +1102,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"base64 0.22.1",
"bytes", "bytes",
"df-ai-core", "df-ai-core",
"df-types", "df-types",
@@ -1278,6 +1305,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer 0.10.4", "block-buffer 0.10.4",
"crypto-common 0.1.7", "crypto-common 0.1.7",
"subtle",
] ]
[[package]] [[package]]
@@ -1571,6 +1599,16 @@ dependencies = [
"rustc_version", "rustc_version",
] ]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@@ -2142,6 +2180,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest 0.10.7",
]
[[package]] [[package]]
name = "htmd" name = "htmd"
version = "0.5.5" version = "0.5.5"
@@ -2572,6 +2619,36 @@ dependencies = [
"windows-sys 0.45.0", "windows-sys 0.45.0",
] ]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.18",
"walkdir",
"windows-link 0.2.1",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.117",
]
[[package]] [[package]]
name = "jni-sys" name = "jni-sys"
version = "0.3.1" version = "0.3.1"
@@ -2896,6 +2973,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -3277,6 +3360,18 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
] ]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.13.0",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]] [[package]]
name = "objc2-quartz-core" name = "objc2-quartz-core"
version = "0.3.2" version = "0.3.2"
@@ -3420,6 +3515,20 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "pango" name = "pango"
version = "0.18.3" version = "0.18.3"
@@ -4059,15 +4168,20 @@ dependencies = [
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde", "serde",
"serde_json", "serde_json",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http",
@@ -4213,6 +4327,33 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni 0.22.4",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.13" version = "0.103.13"
@@ -4661,6 +4802,22 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.3" version = "1.0.3"
@@ -4830,6 +4987,17 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]] [[package]]
name = "sync_wrapper" name = "sync_wrapper"
version = "1.0.2" version = "1.0.2"
@@ -4911,7 +5079,7 @@ dependencies = [
"gdkwayland-sys", "gdkwayland-sys",
"gdkx11-sys", "gdkx11-sys",
"gtk", "gtk",
"jni", "jni 0.21.1",
"libc", "libc",
"log", "log",
"ndk", "ndk",
@@ -4944,6 +5112,17 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]] [[package]]
name = "target-lexicon" name = "target-lexicon"
version = "0.12.16" version = "0.12.16"
@@ -4968,7 +5147,7 @@ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"http", "http",
"http-range", "http-range",
"jni", "jni 0.21.1",
"libc", "libc",
"log", "log",
"mime", "mime",
@@ -5179,6 +5358,39 @@ dependencies = [
"zbus 5.16.0", "zbus 5.16.0",
] ]
[[package]]
name = "tauri-plugin-updater"
version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5cad8ed5948d988e1018ecd31e27cacedbf72ce3fb972940c3e4bf51639e4b"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest 0.13.4",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.18",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]] [[package]]
name = "tauri-plugin-window-state" name = "tauri-plugin-window-state"
version = "2.4.1" version = "2.4.1"
@@ -5204,7 +5416,7 @@ dependencies = [
"dpi", "dpi",
"gtk", "gtk",
"http", "http",
"jni", "jni 0.21.1",
"objc2", "objc2",
"objc2-ui-kit", "objc2-ui-kit",
"objc2-web-kit", "objc2-web-kit",
@@ -5227,7 +5439,7 @@ checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9"
dependencies = [ dependencies = [
"gtk", "gtk",
"http", "http",
"jni", "jni 0.21.1",
"log", "log",
"objc2", "objc2",
"objc2-app-kit", "objc2-app-kit",
@@ -6376,6 +6588,15 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "1.0.8" version = "1.0.8"
@@ -7069,7 +7290,7 @@ dependencies = [
"gtk", "gtk",
"http", "http",
"javascriptcore-rs", "javascriptcore-rs",
"jni", "jni 0.21.1",
"libc", "libc",
"ndk", "ndk",
"objc2", "objc2",
@@ -7133,6 +7354,16 @@ dependencies = [
"time", "time",
] ]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]] [[package]]
name = "xdg-home" name = "xdg-home"
version = "1.3.0" version = "1.3.0"
@@ -7379,6 +7610,18 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.14.0",
"memchr",
]
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"
+6 -3
View File
@@ -28,6 +28,9 @@ keyring = { version = "3", features = ["windows-native", "apple-native", "linux-
dashmap = "6" dashmap = "6"
# release 增量编译:小改动重打包显著提速(2026-10-04 打包提速项); # release 增量编译:小改动重打包显著提速(2026-10-04 打包提速项);
# 代价为产物略大,正式发版如在意体积可临时注释此段 # ⚠️ 2026-10-09 回退:release + incremental 触发链接期匿名符号丢失
[profile.release] # (anon.*.llvm.* undefined symbol,rust-lld 与 MSVC link.exe 均复现),
incremental = true # 根因是增量编译 CGU 划分与跨 crate 内联冲突。cargo check 不链接故不暴露,
# cargo build/tauri build 链接期必挂。release 禁用 incremental 为 Rust 官方推荐。
# [profile.release]
# incremental = true
@@ -348,6 +348,9 @@ function clearAllApprovalTimers(): void {
function clearWatchdog(): void { function clearWatchdog(): void {
if (streamTimer) clearTimeout(streamTimer) if (streamTimer) clearTimeout(streamTimer)
streamTimer = null streamTimer = null
// 复位节流时间戳:resetWatchdog 的 5s 节流假定「已有 timer 在跑」,而此处刚清空 timer。
// 不复位则「停止/报错后 5s 内重发」会因节流跳过 arm → 新一轮无看门狗(中途断则 generating 永卡)。
lastWatchdogReset = 0
} }
/** 已注册事件监听标志(防多次订阅 wsClient) */ /** 已注册事件监听标志(防多次订阅 wsClient) */
@@ -1374,6 +1377,7 @@ function newConversation(): void {
tokenUsage.value = null // 新会话清 token(对齐 switchConversation) tokenUsage.value = null // 新会话清 token(对齐 switchConversation)
activeModel.value = null // 新会话复位模型选择(新会话无 per_conv 模型,走默认路由) activeModel.value = null // 新会话复位模型选择(新会话无 per_conv 模型,走默认路由)
pendingApprovals.value.splice(0, pendingApprovals.value.length); clearAllApprovalTimers() pendingApprovals.value.splice(0, pendingApprovals.value.length); clearAllApprovalTimers()
clearWatchdog() // 与 switchConversation 对齐:清残留看门狗,防新一轮 send 沿用旧 timer(超时点偏移)
} }
/** /**
+5 -2
View File
@@ -7,6 +7,9 @@ import { useAiChat } from '@/composables/useAiChat'
import type { MentionSpan, MiniContentPart } from '@/types/relay' import type { MentionSpan, MiniContentPart } from '@/types/relay'
import type { ProjectRecord, TaskRecord, IdeaRecord, AiToolCallInfo, ChatMessage, TokenUsage } from '@/types/events' import type { ProjectRecord, TaskRecord, IdeaRecord, AiToolCallInfo, ChatMessage, TokenUsage } from '@/types/events'
/** 调试日志门控(对齐 useAiChat/ws.ts:import.meta.env.DEV,生产零 bridge 成本) */
const DEBUG = import.meta.env.DEV
// 组件内联(绕过微信工具 3.15.2 组件解析缓存 bug):独立组件恒报 Component not found, // 组件内联(绕过微信工具 3.15.2 组件解析缓存 bug):独立组件恒报 Component not found,
// inline 进页面后 chat/index.json usingComponents 空,工具无需解析 components/,问题消失。 // inline 进页面后 chat/index.json usingComponents 空,工具无需解析 components/,问题消失。
// MdView/MentionInput 源保留(src/components/ 下,工具 bug 修复后可拆回)。 // MdView/MentionInput 源保留(src/components/ 下,工具 bug 修复后可拆回)。
@@ -512,11 +515,11 @@ function onMentionClose(): void {
* 保留原有守卫:空文本忽略 / generating 中忽略 / 关闭联想浮层。 * 保留原有守卫:空文本忽略 / generating 中忽略 / 关闭联想浮层。
*/ */
function handleSend(): void { function handleSend(): void {
console.log('[v2:send] enter text=', JSON.stringify(inputText.value), 'gen=', generating.value, 'imgs=', pendingImages.value.length) if (DEBUG) console.log('[v2:send] enter text=', JSON.stringify(inputText.value), 'gen=', generating.value, 'imgs=', pendingImages.value.length)
const text = inputText.value.trim() const text = inputText.value.trim()
const hasImage = pendingImages.value.length > 0 const hasImage = pendingImages.value.length > 0
if (!text && !hasImage) { if (!text && !hasImage) {
console.log('[v2:send] 空文本且无图片,忽略') if (DEBUG) console.log('[v2:send] 空文本且无图片,忽略')
return return
} }
if (generating.value) { if (generating.value) {
@@ -12,11 +12,15 @@ const ai = useAiChat()
*/ */
const conversations = ref<Conversation[]>([]) const conversations = ref<Conversation[]>([])
/** /**
* 监听 ai.conversations.length 变化(比 deep watch 更可靠)。 * 监听 ai.conversations 整体替换(比 deep watch 更可靠,也比只盯 length 完整)。
* 用 `[...ai.conversations]` 创建普通数组赋值给 ref,确保每次变化都触发模板更新。 * 用 `[...ai.conversations]` 创建普通数组赋值给 ref,确保每次变化都触发模板更新。
*
* 为什么监听 ref 而非 length:device 回推 AiConversationList 是「整体替换」且条数常不变
* (改名后条数不变),只盯 length 时 watch 回调不触发 → 本地副本滞留旧标题。
* 监听 ref 则在赋值(含同长度替换)时即触发刷新。
*/ */
watch(() => ai.conversations.value.length, () => { watch(ai.conversations, (list) => {
conversations.value = [...ai.conversations.value] conversations.value = [...(list || [])]
}, { immediate: true }) }, { immediate: true })
/** 点击会话项切换 */ /** 点击会话项切换 */
+6 -6
View File
@@ -42,25 +42,25 @@ impl ContentPart {
impl ChatMessage { impl ChatMessage {
pub fn system(content: impl Into<String>) -> Self { pub fn system(content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::System, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::System, content: content.into(), parts: None, source: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
} }
pub fn user(content: impl Into<String>) -> Self { pub fn user(content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: None, source: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
} }
pub fn assistant(content: impl Into<String>) -> Self { pub fn assistant(content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, source: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
} }
pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self { pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, source: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
} }
pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self { pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::Tool, content: content.into(), parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::Tool, content: content.into(), source: None, parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
} }
/// 多模态 user 消息:content 文本 + parts(含 Image 片)。 /// 多模态 user 消息:content 文本 + parts(含 Image 片)。
/// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。 /// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。
pub fn user_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self { pub fn user_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: Some(parts), tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: Some(parts), source: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
} }
/// 是否含图片片(供 provider 判定走多模态分支)。 /// 是否含图片片(供 provider 判定走多模态分支)。
+21
View File
@@ -68,6 +68,22 @@ pub enum ContentPart {
}, },
} }
/// 消息来源(#958 溯源):外部渠道进入会话的原始消息定位。序列化为 JSON 随消息
/// 落库(ai_messages.source 列);桌面本端消息恒 None 不落
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MessageSource {
/// 渠道标识:"feishu"(后续可扩 openapi/webhook 等)
pub channel: String,
/// 渠道侧原始消息 ID(飞书 om_/oc_ 前缀,可跳原消息追溯)
pub message_id: String,
/// 渠道侧会话/群 ID(可选,群聊定位)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chat_id: Option<String>,
/// 发送者标识(飞书 open_id,可选)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sender: Option<String>,
}
/// 聊天消息 /// 聊天消息
/// ///
/// ⚠️ **迁移耦合点**:下方各字段名(`role`/`content`/`parts`/`tool_call_id`/ /// ⚠️ **迁移耦合点**:下方各字段名(`role`/`content`/`parts`/`tool_call_id`/
@@ -85,6 +101,11 @@ pub struct ChatMessage {
pub id: Option<String>, pub id: Option<String>,
pub role: MessageRole, pub role: MessageRole,
pub content: String, pub content: String,
/// 消息来源(#958 溯源):消息从哪个渠道/端点进入会话。None=桌面本端(既有语义,
/// 老数据反序列化为 None 零回归);Some=外部渠道(飞书 message_id 可跳原消息追溯,
/// 多端混发可区分来源端)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<MessageSource>,
/// 多模态内容片。 /// 多模态内容片。
/// ///
/// `None`/空 → 纯文本消息(绝大多数场景,content 即全部载荷)。 /// `None`/空 → 纯文本消息(绝大多数场景,content 即全部载荷)。
+1
View File
@@ -12,6 +12,7 @@ tokio = { workspace = true, features = ["sync", "time"] }
async-trait = { workspace = true } async-trait = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
base64 = "0.22"
# HTTP + 流式 # HTTP + 流式
reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"] } reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"] }
+18
View File
@@ -136,6 +136,24 @@
"context_window": 262144, "context_window": 262144,
"temperature_supported": false "temperature_supported": false
}, },
{
"model_id": "sensenova-6.8-flash-lite",
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"weight": 85,
"context_window": 262144,
"temperature_supported": false
},
{
"model_id": "sn2-kimi",
"enabled": true,
"modalities": ["text"],
"capabilities": ["tool_use"],
"weight": 80,
"context_window": 131072,
"temperature_supported": false
},
{ {
"model_id": "deepseek-v4-flash", "model_id": "deepseek-v4-flash",
"enabled": true, "enabled": true,
+59 -1
View File
@@ -288,9 +288,11 @@ impl AnthropicCompatProvider {
/// 单个 ContentPart → Anthropic content block(text/image)。 /// 单个 ContentPart → Anthropic content block(text/image)。
/// - Text 片 → {type:text, text} /// - Text 片 → {type:text, text}
/// - Image 片 → {type:image, source:{type:base64, media_type, data}} /// - Image 片 → {type:image, source:{type:base64, media_type, data}}
/// base64 内嵌;url 模式当前不可达,兜底发空 data + warn(保留原行为)。 /// base64 内嵌;本地 file:// 引用(#958)先展开为 base64,http(s) url 模式
/// 当前不可达,兜底发空 data + warn(保留原行为)。
/// match 取得 p 所有权后直接 move media_type/base64,避免大 base64 clone。 /// match 取得 p 所有权后直接 move media_type/base64,避免大 base64 clone。
fn content_part_to_block(p: crate::provider::ContentPart) -> serde_json::Value { fn content_part_to_block(p: crate::provider::ContentPart) -> serde_json::Value {
let p = crate::image_ref::inline_local_image_part(p);
match p { match p {
crate::provider::ContentPart::Text { text } => serde_json::json!({ crate::provider::ContentPart::Text { text } => serde_json::json!({
"type": "text", "type": "text",
@@ -427,6 +429,22 @@ impl AnthropicCompatProvider {
.and_then(|m| m.get("role").and_then(|r| r.as_str())) .and_then(|m| m.get("role").and_then(|r| r.as_str()))
.unwrap_or(""); .unwrap_or("");
if first_role == "user" { if first_role == "user" {
// 首条 user 但 content 为空串(会话压缩/续发残留,典型 text(0B)):原样发出会被
// precheck "user content 空" 拦下而整请求失败。与上方补占位同思路——就地填
// 占位文案(空串无信息可丢),上游实测接受非空占位,precheck 亦放行。
let empty_str = messages[0]
.get("content")
.and_then(|c| c.as_str())
.map(|s| s.is_empty())
.unwrap_or(false);
if empty_str {
warn!(
msg_count = messages.len(),
"ensure_leading_user: 首条 user content 为空串,填占位(防 precheck 拦截)"
);
messages[0]["content"] =
serde_json::json!("(continued from previous context)");
}
return; return;
} }
// 空 Vec(异常会话经 sanitize 清空)或首条非 user → 补 user 占位: // 空 Vec(异常会话经 sanitize 清空)或首条非 user → 补 user 占位:
@@ -1163,6 +1181,46 @@ mod tests {
assert_eq!(content[1]["source"]["data"], "iVBOR"); assert_eq!(content[1]["source"]["data"], "iVBOR");
} }
/// 本地 file:// 引用(#958)在转换层展开为 base64 source;缺失降级 text 块不炸请求
#[test]
fn anthropic_convert_expands_local_file_ref() {
use base64::Engine as _;
let _convert = convert_lock();
let dir = std::env::temp_dir().join("df-ai-anthropic-ref-test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("ref.png");
std::fs::write(&path, b"\x89PNG ok").unwrap();
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages: vec![ChatMessage::user_parts(
"看图",
vec![crate::provider::ContentPart::Image {
url: Some(format!("file://{}", path.display())),
base64: None,
media_type: Some("image/png".into()),
alt: None,
}],
)],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let body = provider.convert_request(req);
let user_msg = body
.messages
.iter()
.find(|m| m.get("role").and_then(|r| r.as_str()) == Some("user"))
.expect("应有 user 消息");
let content = user_msg.get("content").and_then(|c| c.as_array()).expect("user content 应为数组");
assert_eq!(content[1]["source"]["data"], base64::engine::general_purpose::STANDARD.encode(b"\x89PNG ok"));
std::fs::remove_file(&path).ok();
}
/// 纯文本 user 消息 → content 仍是字符串简写(无图不数组化) /// 纯文本 user 消息 → content 仍是字符串简写(无图不数组化)
#[test] #[test]
fn anthropic_convert_text_only_user_remains_string() { fn anthropic_convert_text_only_user_remains_string() {
+60 -22
View File
@@ -1045,9 +1045,12 @@ fn phase1_append_after_dirty_stays_dirty_until_save() {
// ============================================================ // ============================================================
#[test] #[test]
fn eviction_inserts_summary_message_at_view_head() { fn eviction_inserts_summary_message_at_view_tail() {
// 触发淘汰 → 视图头部应有一条 "[早期对话概要]" user 摘要(补偿无声丢弃的早期主题), // 触发淘汰 → 视图尾部应有一条 "[早期对话概要]" user 摘要(补偿无声丢弃的早期主题),
// 概要 ≤ EVICTION_SUMMARY_MAX_CHARS,保护消息完好,且纯视图(不污染内存全量/不落库)。 // 概要 ≤ EVICTION_SUMMARY_MAX_CHARS,保护消息完好,且纯视图(不污染内存全量/不落库)。
// 缓存友好(2026-10):摘要改尾部追加(push)而非头部插入(insert(0))——头部插入位移
// 整条前缀字节,使 DeepSeek 等自动前缀缓存 provider 全部缓存失效(命中率掉到个位数)。
// 尾部追加让稳定前缀(system + 早期 history)逐轮不变,命中率最大化。
let mut mgr = ContextManager::new(cfg(200)); let mut mgr = ContextManager::new(cfg(200));
for i in 0..20 { for i in 0..20 {
if i % 2 == 0 { if i % 2 == 0 {
@@ -1063,46 +1066,47 @@ fn eviction_inserts_summary_message_at_view_head() {
let (msgs, trimmed) = mgr.build_for_request(0); let (msgs, trimmed) = mgr.build_for_request(0);
assert!(trimmed, "超预算应触发裁剪"); assert!(trimmed, "超预算应触发裁剪");
// 视图头部 = 补偿摘要(role=user,"[早期对话概要]" 前缀 + 高频主题) // 视图尾部 = 补偿摘要(role=user,"[早期对话概要]" 前缀 + 高频主题)
let head = msgs.first().expect("视图不应为空"); let tail = msgs.last().expect("视图不应为空");
assert!( assert!(
matches!(head.role, MessageRole::User), matches!(tail.role, MessageRole::User),
"视图头部应为 user 摘要, 实际 {:?}", "视图尾部应为 user 摘要, 实际 {:?}",
head.role tail.role
); );
assert!( assert!(
head.content.starts_with("[早期对话概要] 高频主题: "), tail.content.starts_with("[早期对话概要] 高频主题: "),
"摘要格式不符, 实际 {}", "摘要格式不符, 实际 {}",
head.content tail.content
); );
assert!( assert!(
head.content.contains("审批"), tail.content.contains("审批"),
"摘要应含被淘汰区高频主题词'审批', 实际 {}", "摘要应含被淘汰区高频主题词'审批', 实际 {}",
head.content tail.content
); );
assert!( assert!(
head.content.chars().count() <= EVICTION_SUMMARY_MAX_CHARS, tail.content.chars().count() <= EVICTION_SUMMARY_MAX_CHARS,
"摘要应 ≤ {} 字符, 实际 {}", "摘要应 ≤ {} 字符, 实际 {}",
EVICTION_SUMMARY_MAX_CHARS, EVICTION_SUMMARY_MAX_CHARS,
head.content.chars().count() tail.content.chars().count()
); );
// 保护消息完好:末条(i=19 assistant)保留;摘要 user 与首轮真实 user 相邻,由 // 保护消息完好:末条(i=19 assistant)保留;摘要 user 追加在尾部,与末尾 assistant
// sanitize 的 ensure_sequence_legal 合并(摘要在前,换行接原文),故 msgs[1] 为 i=1 的 assistant // 不同 role 不合并,故 msgs[倒数第2] 仍为 i=19 的 assistant
assert_eq!( assert_eq!(
msgs.last().unwrap().content, msgs[msgs.len() - 2].content,
"第19条回复内容同样较长用于撑爆上下文预算触发视图裁剪逻辑", "第19条回复内容同样较长用于撑爆上下文预算触发视图裁剪逻辑",
"保护区最新消息被误裁" "保护区最新消息被误裁"
); );
assert!( assert!(
matches!(msgs[1].role, MessageRole::Assistant), matches!(msgs[msgs.len() - 2].role, MessageRole::Assistant),
"摘要与首轮 user 合并后,第二条应为 assistant, 实际 {:?}", "摘要前一条应为 assistant(i=19), 实际 {:?}",
msgs[1].role msgs[msgs.len() - 2].role
); );
// 首条仍是保留区真实首条 user(摘要不再抢占头部,不位移前缀)
assert!( assert!(
msgs[0].content.contains("审批流程与审批门禁需要适配"), matches!(msgs[0].role, MessageRole::User),
"合并不应丢失被保留的首轮真实 user 文本, 实际 {}", "视图头部应为真实首条 user(摘要已移尾部), 实际 {:?}",
msgs[0].content msgs[0].role
); );
// 纯视图:摘要不进内存全量,被淘汰原文存储态不变 // 纯视图:摘要不进内存全量,被淘汰原文存储态不变
@@ -1196,3 +1200,37 @@ fn eviction_no_user_text_no_summary() {
); );
assert_eq!(mgr.all_messages_clone().len(), 16, "裁剪污染了内存全量"); assert_eq!(mgr.all_messages_clone().len(), 16, "裁剪污染了内存全量");
} }
// ── 源头守静(909):push_system_anchor 避开悬空 tool_call 头 ──
#[test]
fn push_system_anchor_appends_on_normal_tail() {
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问"));
mgr.push(ChatMessage::assistant("答"));
let avoided = mgr.push_system_anchor(ChatMessage::system("警示"));
assert!(!avoided, "常规尾部应走追加(不避让)");
assert_eq!(mgr.len(), 3);
assert_eq!(mgr.messages_mut()[2].message.content, "警示", "system 应在尾部");
}
#[test]
fn push_system_anchor_avoids_dangling_tool_call_head() {
// [user] [assistant(tool_calls)] ← 末条为悬空头(其 tool 响应尚未回填)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("跑工具"));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("c1".to_string(), "t", "{}")],
));
let avoided = mgr.push_system_anchor(ChatMessage::system("## 摘要"));
assert!(avoided, "末条为悬空 tool_call 头应避让插入");
// 序列应为 [user, system(摘要), assistant(tool_calls)] —— 头仍是末条,三元组不被劈开
let msgs = mgr.all_messages_clone();
assert_eq!(msgs.len(), 3);
assert_eq!(msgs[1].content, "## 摘要", "摘要应插在悬空头之前");
assert!(
msgs[2].tool_calls.as_ref().is_some_and(|c| !c.is_empty()),
"悬空头应保持末条,其 tool 响应可紧随其后回填(协议不变式成立)"
);
}
+46 -9
View File
@@ -246,16 +246,18 @@ impl ContextManager {
.collect(); .collect();
// 淘汰补偿摘要:视图裁剪无声丢弃被淘汰内容,LLM 从此看不到早期主题,与压缩 // 淘汰补偿摘要:视图裁剪无声丢弃被淘汰内容,LLM 从此看不到早期主题,与压缩
// 路径(有摘要补偿)行为不一致。此处把被淘汰文本提炼成一短段摘要插回发送 // 路径(有摘要补偿)行为不一致。此处把被淘汰文本提炼成一短段摘要插回发送视图。
// 视图最前部(淘汰区头部、保护消息之前)。角色必须 user:role=system 会被 // **缓存友好修正(2026-10)**:原 insert(0) 头部插入会位移整条前缀字节,使
// anthropic_compat 折叠进带 cache_control 的顶层 system 段,而本摘要纯视图 // DeepSeek 等自动前缀缓存 provider 的稳定前缀(顶层 system + 早期 history)
// 每轮即时再生成(淘汰区单调增长致内容逐轮漂移),落 system 即逐轮改写缓存 // 全部缓存失效——命中率掉到个位数。改尾部追加(push):摘要本身每轮即时再生成
// 指纹使全部前缀缓存失效;连续 role 由既有 sanitize/协议层自愈兜底。摘要只进 // (淘汰区单调增长致内容逐轮漂移)本就无法命中缓存,但放尾部不再拖累稳定前缀,
// messages 易变区,严禁拼进 system_prompt/任何稳定段;纯视图语义:不落库、 // 命中率最大化。角色必须 user:role=system 会被 anthropic_compat 折叠进带
// 不改 conv.messages 存储、不标记 compressed,被淘汰原文存储态不变。 // cache_control 的顶层 system 段,落 system 即逐轮改写缓存指纹;user 放尾部
// 极短/空淘汰文本提炼为空 → 跳过(不值得插)。 // 由 sanitize/协议层自愈兜底。摘要只进 messages 易变区,严禁拼进 system_prompt/
// 任何稳定段;纯视图语义:不落库、不改 conv.messages 存储、不标记 compressed,
// 被淘汰原文存储态不变。极短/空淘汰文本提炼为空 → 跳过(不值得插)。
if let Some(text) = build_eviction_summary_text(&evicted) { if let Some(text) = build_eviction_summary_text(&evicted) {
msgs.insert(0, ChatMessage::user(text)); msgs.push(ChatMessage::user(text));
} }
tracing::info!( tracing::info!(
@@ -565,6 +567,41 @@ impl ContextManager {
self.needs_full_rewrite = true; self.needs_full_rewrite = true;
} }
/// 追加一条 system 锚点消息(警示文本/压缩摘要),自动避开**悬空 tool_call 头**。
///
/// **源头守静(909)**:OpenAI/内部协议不变式是「assistant(tool_calls) 之后必须紧随
/// 其 tool 响应」。主 loop 的顺序是 push 头(`finalize_round_output`)→ 释放锁 →
/// 执行工具 → 再取锁 push 结果;若在「头已入、结果未回」的窗口内从尾部追加 system
/// (后台压缩摘要/警示/nudge/主题标记),system 就楔进 head 与其响应之间 → 上游报
/// 400「insufficient tool messages following tool_calls message」。
///
/// 本出口统一处置:末条非悬空头 → 常规尾部追加(不位移前缀,保自动前缀缓存,行为与
/// 原 `push` 一致);末条是悬空头 → 改插在该头**之前**(index = len-1),不劈三元组,
/// 摘要/警示仍紧邻本轮内容对 LLM 可见。返回 `true` 表示走了避让插入(会置
/// `needs_full_rewrite`,需 DB 全量重写),供调用方记日志。
pub fn push_system_anchor(&mut self, message: ChatMessage) -> bool {
if self.tail_is_dangling_head() {
let idx = self.messages.len() - 1;
self.insert_at(idx, message);
tracing::debug!(
idx,
"system 锚点避让悬空 tool_call 头插入(防楔入三元组致 400)"
);
true
} else {
self.push(message);
false
}
}
/// 末条是否为「悬空 tool_call 头」(assistant 带 tool_calls 且其 tool 响应尚未回填)。
/// 供 [`Self::push_system_anchor`] 判定避让位置:末条即头,说明头已入而结果未回。
fn tail_is_dangling_head(&self) -> bool {
self.messages
.last()
.is_some_and(|m| m.group == MessageGroup::ToolCallHead)
}
/// 按淘汰单元分组消息范围(三元组原子性),供压缩定位/分段标记复用同一分组逻辑。 /// 按淘汰单元分组消息范围(三元组原子性),供压缩定位/分段标记复用同一分组逻辑。
/// ///
/// 返回每个单元的右开区间 end + token 总和,保证: /// 返回每个单元的右开区间 end + token 总和,保证:
+231 -7
View File
@@ -42,12 +42,35 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
.filter(|m| m.is_active()) .filter(|m| m.is_active())
.collect(); .collect();
// step 1:已闭合的 tool_call_id 集合(role=Tool 消息全部提供过的 id) // step 0.5(相邻性归位,909②):把楔入 assistant(tool_calls) 头与其 tool 响应之间的异类消息
let resolved_ids: HashSet<&str> = messages // (后台压缩摘要 / 用户插话 / 警示 system)整体移到该组响应**之后**——只重排不删内容。
.iter() // 归位后三元组恢复相邻,step1 的邻接判定即可正确识别为已闭合,不再走「未邻接即剥除」而**保住
.filter(|m| matches!(m.role, MessageRole::Tool)) // 工具轮**(避免丢结果致模型重跑/误判)。对同一头的多段夹层同样生效(全部响应聚拢到头后紧邻)。
.filter_map(|m| m.tool_call_id.as_deref()) // 真正无法归位者(头无任何响应)仍由 step2/3 剥除兜底。
.collect(); let messages = gather_tool_responses(messages);
// step 1:相邻性判定的依据——每个 assistant 头「紧后 tool 连续段」覆盖的 id 集合。
// 口径与 openai_compat::sanitize_openai_triplets 一致(2026-10-09 收紧):
// 协议要求 tool 响应必须紧随其 assistant(tool_calls);原实现按「id 全局存在」判定
// (位置无关),会把被 system/user 隔开的游离响应误判为已闭合,放行畸形序列致 400
// 「insufficient tool messages」。故判定改为「紧后连续段覆盖」——未覆盖的 call 视同
// 未闭合处理(剥除),其游离响应随后由 step3 按 orphaned_ids 丢弃,自动闭环。
let mut adjacency: std::collections::HashMap<usize, HashSet<String>> =
std::collections::HashMap::new();
for i in 0..messages.len() {
if !matches!(messages[i].role, MessageRole::Assistant) {
continue;
}
let mut seg: HashSet<String> = HashSet::new();
let mut j = i + 1;
while j < messages.len() && matches!(messages[j].role, MessageRole::Tool) {
if let Some(id) = messages[j].tool_call_id.as_deref() {
seg.insert(id.to_string());
}
j += 1;
}
adjacency.insert(i, seg);
}
// step 2:判定每个 assistant 头的闭合状态,产出需丢弃的 id 集合 // step 2:判定每个 assistant 头的闭合状态,产出需丢弃的 id 集合
// orphaned_ids:未闭合的 tool_call.id(含占位 id),其 tool_result 要丢 // orphaned_ids:未闭合的 tool_call.id(含占位 id),其 tool_result 要丢
@@ -72,7 +95,7 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
.iter() .iter()
.partition(|c| { .partition(|c| {
!c.id.starts_with(TOOL_MISSING_PREFIX) !c.id.starts_with(TOOL_MISSING_PREFIX)
&& resolved_ids.contains(c.id.as_str()) && adjacency.get(&i).is_some_and(|s| s.contains(&c.id))
}); });
match (resolved.is_empty(), unresolved.is_empty()) { match (resolved.is_empty(), unresolved.is_empty()) {
@@ -181,6 +204,78 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
ensure_sequence_legal(after_reverse_orphan) ensure_sequence_legal(after_reverse_orphan)
} }
/// 相邻性归位(909②,view-only):把 assistant 头的**全部** tool 响应聚拢到头后紧邻,夹层整体后移。
///
/// 根因:协议不变式要求「tool 响应必须紧随其 assistant(tool_calls)」。后台压缩摘要在 head 已入、
/// 结果未回的窗口内楔入,或用户在执行期插话,都会产生 `[head, 异类, tool…]`。原逻辑对此按「未邻接
/// = 未闭合」剥除头与其响应,**丢掉整轮工具交互**(有损)。本步改为**归位**——夹层后移,头与其响应
/// 恢复相邻,工具轮保留;夹层内容亦不丢。
///
/// 覆盖**多夹层**:同一头的响应之间又插了第二个夹层时(如 `[head(c1,c2), 异类, tool(c1), 异类,
/// tool(c2)]`),只后移首夹层会让后段 tool 仍不与头相邻 → step1 邻接判定只认前段 → 后段 call 被
/// 判未闭合而剥除(tool 一并丢)。本实现把「响应集合耗尽前出现的**所有**非 Tool 消息」整体移到
/// **该头最后一个响应之后**,头后紧邻其全部响应(保序),夹层随后。
///
/// 边界:遇下一个 tool_call 头即停(不跨单元搬运);该头响应集合(其 tool_calls 的 id)全部命中
/// 即停(不牵动响应之后的无关消息)。无法归位者(头无响应)仍由 step2/3 剥除兜底。
///
/// 幂等:归位后序列已相邻,再跑本函数无夹层可移,输出不变。
fn gather_tool_responses(msgs: Vec<ChatMessage>) -> Vec<ChatMessage> {
use std::collections::HashSet;
fn is_tool_call_head(m: &ChatMessage) -> bool {
matches!(m.role, MessageRole::Assistant)
&& m.tool_calls.as_ref().is_some_and(|c| !c.is_empty())
}
fn is_tool(m: &ChatMessage) -> bool {
matches!(m.role, MessageRole::Tool)
}
let mut out: Vec<ChatMessage> = Vec::with_capacity(msgs.len());
let mut i = 0usize;
while i < msgs.len() {
if is_tool_call_head(&msgs[i]) {
// 该头全部 call id —— 用于判定它的响应集合是否已耗尽
let call_ids: HashSet<String> = msgs[i]
.tool_calls
.as_ref()
.map(|c| c.iter().map(|c| c.id.clone()).collect())
.unwrap_or_default();
// 从头后扫到「下一个 tool_call 头」或「该头响应集合耗尽」为止,分流响应与夹层
let mut j = i + 1;
let mut responses: Vec<ChatMessage> = Vec::new();
let mut interlopers: Vec<ChatMessage> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
while j < msgs.len() && !is_tool_call_head(&msgs[j]) {
if is_tool(&msgs[j]) {
if let Some(id) = msgs[j].tool_call_id.as_deref() {
seen.insert(id.to_string());
}
responses.push(msgs[j].clone());
} else {
interlopers.push(msgs[j].clone());
}
j += 1;
// 该头响应集合已全部命中 → 不再牵动响应之后的无关消息(保持边界语义)
if !call_ids.is_empty() && call_ids.iter().all(|id| seen.contains(id)) {
break;
}
}
// 有夹层且确有响应 → 头 + 全部响应在前,夹层整体后移(只重排)
if !interlopers.is_empty() && !responses.is_empty() {
out.push(msgs[i].clone());
out.extend(responses);
out.extend(interlopers);
i = j;
continue;
}
}
out.push(msgs[i].clone());
i += 1;
}
out
}
/// step 3.5:反向 orphan 检测(view-only)—— **补头自愈**(对 orphan tool_result 就地补占位 /// step 3.5:反向 orphan 检测(view-only)—— **补头自愈**(对 orphan tool_result 就地补占位
/// assistant tool_call 头,而非丢弃 tool_result)。 /// assistant tool_call 头,而非丢弃 tool_result)。
/// ///
@@ -554,6 +649,135 @@ mod tests {
// ── 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ── // ── 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ──
/// 相邻性归位(909②):夹层(system/user)隔开 head 与其 tool 响应时,把夹层移到响应之后
/// (只重排不删),保住整轮工具交互;同时保证发送序列满足「head 后紧邻其响应」。
/// 旧行为(逐字剥除工具轮)已被归位取代——本用例即旧行为的有损对照。
#[test]
fn sanitize_reorders_interloper_to_keep_tool_round() {
// [user] [assistant(c1,c2)] [system 夹层] [tool c1] [tool c2]
let msgs = vec![
ChatMessage::user("go"),
ChatMessage::assistant_with_tools(
"",
vec![
ToolCall::new("c1".to_string(), "t", "{}"),
ToolCall::new("c2".to_string(), "t", "{}"),
],
),
ChatMessage::system("夹层提示"),
ChatMessage::tool_result("c1", "r1"),
ChatMessage::tool_result("c2", "r2"),
];
let out = sanitize_messages(msgs);
// 零丢失:5 条全在(归位只重排,不删内容)
assert_eq!(out.len(), 5, "归位不得丢内容, 实际 {:?}", out);
// 头保留其两个 tool_call(工具轮不丢)
let head_idx = out
.iter()
.position(|m| matches!(m.role, MessageRole::Assistant))
.expect("head 应保留");
assert_eq!(
out[head_idx].tool_calls.as_ref().map(|c| c.len()),
Some(2),
"头应保留两个 tool_call, 实际 {:?}",
out
);
// 头后紧邻两条 tool 响应(协议不变式成立)
assert!(
matches!(out[head_idx + 1].role, MessageRole::Tool)
&& matches!(out[head_idx + 2].role, MessageRole::Tool),
"head 后应紧邻两条 tool 响应, 实际 {:?}",
out
);
// 夹层内容保留(后移到响应之后)
assert!(
out.iter()
.any(|m| matches!(m.role, MessageRole::System) && m.content == "夹层提示"),
"夹层内容不得丢, 实际 {:?}",
out
);
}
#[test]
fn sanitize_reorder_is_idempotent() {
let msgs = vec![
ChatMessage::user("go"),
ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("c1".to_string(), "t", "{}")],
),
ChatMessage::system("夹层提示"),
ChatMessage::tool_result("c1", "r1"),
];
let once = sanitize_messages(msgs);
let twice = sanitize_messages(once.clone());
// MessageRole 未实现 PartialEq → 用 Debug 签名比较序列(角色+内容)是否一致
let sig = |v: &[ChatMessage]| -> Vec<String> {
v.iter().map(|m| format!("{:?}|{}", m.role, m.content)).collect()
};
assert_eq!(sig(&once), sig(&twice), "归位应幂等(角色序与内容序不变)");
}
/// 多夹层归位:同一头的响应之间又插了第二个夹层时,头与该头的**全部**响应必须聚拢相邻
/// (旧实现只后移首夹层,后段 tool 仍不与头相邻 → step1 判后段 call 未闭合 → 剥除并丢其 tool)。
#[test]
fn sanitize_reorders_multiple_interlopers_to_keep_tool_round() {
// [user] [assistant(c1,c2)] [system 夹层X] [tool c1] [system 夹层Y] [tool c2]
let msgs = vec![
ChatMessage::user("go"),
ChatMessage::assistant_with_tools(
"",
vec![
ToolCall::new("c1".to_string(), "t", "{}"),
ToolCall::new("c2".to_string(), "t", "{}"),
],
),
ChatMessage::system("夹层X"),
ChatMessage::tool_result("c1", "r1"),
ChatMessage::system("夹层Y"),
ChatMessage::tool_result("c2", "r2"),
];
let out = sanitize_messages(msgs);
// 零丢失:6 条全在(归位只重排,不删内容)
assert_eq!(out.len(), 6, "多夹层归位不得丢内容, 实际 {:?}", out);
// 头保留其两个 tool_call(工具轮不丢)
let head_idx = out
.iter()
.position(|m| matches!(m.role, MessageRole::Assistant))
.expect("head 应保留");
assert_eq!(
out[head_idx].tool_calls.as_ref().map(|c| c.len()),
Some(2),
"头应保留两个 tool_call, 实际 {:?}",
out
);
// 头后紧邻其全部响应(保序 c1,c2),协议不变式成立
assert!(
matches!(out[head_idx + 1].role, MessageRole::Tool)
&& out[head_idx + 1].tool_call_id.as_deref() == Some("c1")
&& matches!(out[head_idx + 2].role, MessageRole::Tool)
&& out[head_idx + 2].tool_call_id.as_deref() == Some("c2"),
"head 后应紧邻其全部响应 c1,c2, 实际 {:?}",
out
);
// 两条夹层内容都保留(后移到响应之后)
assert!(
out.iter()
.any(|m| matches!(m.role, MessageRole::System) && m.content == "夹层X")
&& out.iter()
.any(|m| matches!(m.role, MessageRole::System) && m.content == "夹层Y"),
"两条夹层内容不得丢, 实际 {:?}",
out
);
// 幂等:再跑一次结果不变
let twice = sanitize_messages(out.clone());
let sig = |v: &[ChatMessage]| -> Vec<String> {
v.iter().map(|m| format!("{:?}|{}", m.role, m.content)).collect()
};
assert_eq!(sig(&out), sig(&twice), "多夹层归位应幂等(角色序与内容序不变)");
}
#[test] #[test]
fn sanitize_heals_reverse_orphan_tool_result_with_placeholder_head() { fn sanitize_heals_reverse_orphan_tool_result_with_placeholder_head() {
// 三元组完整性 P0:反向 orphan(tool_result 无对应 tool_call 头)→ 补占位头自愈(不丢)。 // 三元组完整性 P0:反向 orphan(tool_result 无对应 tool_call 头)→ 补占位头自愈(不丢)。
+33 -6
View File
@@ -423,6 +423,17 @@ pub const TOOL_RESULT_CHAR_LIMIT: usize = 1_024;
/// JSON 对象中字符串字段值的最大字符数(超过则截断)。独立于行数截断, /// JSON 对象中字符串字段值的最大字符数(超过则截断)。独立于行数截断,
/// 解决 `{"content":"大段文字(无换行)"}` 类 JSON 逃逸行级截断的问题。 /// 解决 `{"content":"大段文字(无换行)"}` 类 JSON 逃逸行级截断的问题。
pub const TOOL_RESULT_JSON_STR_FIELD_MAX: usize = 512; pub const TOOL_RESULT_JSON_STR_FIELD_MAX: usize = 512;
/// read_file 豁免上限(P1 工具结果瘦身 2026-10-10):≤此值原样返回,超过则走压缩+落盘。
///
/// 依据实测:全库工具结果体量前二为 patch_file 34.6% / read_file 29.6%(单条 max 150KB),
/// 原「read_file 一律豁免」让 29.6% 体量完全裸奔(65K 均值 prompt 压不下来的直接原因之一)。
/// 保留小文件豁免(AI 定向读代码需完整语义——历史有「限读 28KB 被折叠致能力受损」实证),
/// 阈值取 8KB 与 D2a 审计侧同构;超过则摘要+落盘句柄(可无损读回)。
pub const READ_FILE_OFFLOAD_BYTES: usize = 8 * 1024;
/// 追加落盘句柄的最小原文字节数(与 D2a 审计侧 8KB 阈值对齐)。
/// 小于此值的压缩结果**不加句柄**:有损摘要的丢失量小,而句柄行(约 150 字符)会净增 token;
/// ≥此值才值得(丢失量大、句柄占比可忽略),兼顾「无损可读回」与「不反噬瘦身目标」。
pub const TOOL_OFFLOAD_HANDLE_MIN_BYTES: usize = 8 * 1024;
/// 判断 tool_result 是否需摘要压缩:content >2KB 或 占比 >40%。 /// 判断 tool_result 是否需摘要压缩:content >2KB 或 占比 >40%。
/// ///
@@ -454,14 +465,30 @@ pub fn should_summarize_tool_result(
/// - 错误行(在头尾区间外的)额外插入,标注位置。 /// - 错误行(在头尾区间外的)额外插入,标注位置。
/// - 中间大段省略为 `... (省略 N 行) ...`。 /// - 中间大段省略为 `... (省略 N 行) ...`。
/// ///
/// `tool_name` 参与内容判断:`read_file` 是 AI 定向读代码的工具,压缩其 content /// `tool_name` 参与内容判断:`read_file` 定向读代码,压缩其 content 会削弱代码分析能力,
/// 等于直接阉割 AI 的代码分析能力(实测 limit=100 读 28KB,被折叠为首尾各 5 行), /// 故保留**小文件豁免**(≤ READ_FILE_OFFLOAD_BYTES 原样返回);超阈值仍走压缩并落盘
/// 故对 `read_file` 一律豁免压缩(原样返回)。其余工具仍走压缩。 /// (P1 2026-10-10,句柄可读回,无损)。其余工具仍走压缩。
/// 空内容一律返回空字符串。 /// 空内容一律返回空字符串。
pub fn extract_key_info(content: &str, tool_name: &str) -> String { pub fn extract_key_info(content: &str, tool_name: &str) -> String {
// read_file 豁免:AI 定向读代码,压缩 content 违背用户/AI 意图。 let compressed = extract_key_info_inner(content, tool_name);
// read_file handler 自带 limit 硬上限 2000 行(file.rs:156/168),无爆 prompt 风险。 // P1 无损化(2026-10-10):确实发生压缩 → 原文落盘 + 追加句柄行,被省略的中段可 read_file 读回。
if tool_name == "read_file" { // 句柄**只对 ≥ TOOL_OFFLOAD_HANDLE_MIN_BYTES 的大结果**追加——小结果有损摘要丢得少,
// 加句柄行反而净增 token(反噬瘦身目标);大结果丢失量大,句柄占比可忽略,才值得。
// base 未注入(单测/未初始化)→ 不加句柄,输出与改造前逐字节一致(零行为变化)。
if content.len() >= TOOL_OFFLOAD_HANDLE_MIN_BYTES && compressed.len() < content.len() {
if let Some(handle) = crate::tool_offload::offload_with_handle(content, tool_name) {
return format!("{compressed}\n{handle}");
}
}
compressed
}
/// extract_key_info 的纯压缩实现(零 IO,便于单测与复用)。
fn extract_key_info_inner(content: &str, tool_name: &str) -> String {
// read_file 高阈值豁免(P1 2026-10-10):实测任务结果体量 read_file 占 29.6%(单条 max 150KB),
// 原「一律豁免」致最大头裸奔(65K 均值 prompt 压不下来的直接原因之一)。
// 改为「小文件豁免 + 超阈值压缩并落盘」:既保 AI 定向读代码语义,又让超大读取可瘦身。
if tool_name == "read_file" && content.len() <= READ_FILE_OFFLOAD_BYTES {
return content.to_string(); return content.to_string();
} }
// JSON 感知压缩:识别对象中的大数组/大字符串并截断 // JSON 感知压缩:识别对象中的大数组/大字符串并截断
+122
View File
@@ -0,0 +1,122 @@
//! 本地图片引用展开(#958 图片入站引用化)
//!
//! 消息持久化只存引用(ContentPart::Image.url = `file://<绝对路径>`,base64 空),
//! 发送请求前在此统一展开为 base64 内嵌——存储面瘦 referencing(一张 5MB 图不再以
//! 6.7MB base64 字符串进 SQLite 随历史全量重放),转换面按需读盘(SSD 毫秒级,
//! 对比 base64 内嵌的每请求全量携带,磁盘读便宜得多)。
//!
//! 展开点是 provider 转换层的入口:主会话/子代理/标题/压缩等全部请求路径在此
//! 单点覆盖,调用方(飞书入站/桌面粘贴图)只管落盘产引用,不关心 provider 差异。
//!
//! 失败语义:引用文件丢失/不可读时降级为 Text 片「[图片文件缺失: path]」——
//! 诚实告知模型图不可用,不让请求 400(空 base64 会被两个协议端点拒绝)。
use crate::provider::ContentPart;
use base64::Engine as _;
/// `file://` 引用前缀。内部引用标识(两端自洽即可,不进端点):落盘方拼
/// `file://<原生绝对路径>`,展开方 strip 后按原生路径读(Windows 盘符路径直接可用)。
pub const LOCAL_FILE_SCHEME: &str = "file://";
/// 判定 Image 片是否为本地引用(base64 空 + url 带本地 scheme)
fn is_local_ref(url: Option<&str>, base64: Option<&str>) -> bool {
base64.is_none() && url.is_some_and(|u| u.starts_with(LOCAL_FILE_SCHEME))
}
/// 把消息内的本地图片引用展开为 base64 内嵌片;非本地片原样透传。
/// 文件缺失/不可读时该 Image 片降级为 Text 片(请求不炸,模型得到可读的缺失说明)。
pub fn inline_local_image_parts(parts: Vec<ContentPart>) -> Vec<ContentPart> {
parts.into_iter().map(inline_local_image_part).collect()
}
/// 单片版(provider 转换臂逐片 map 用)
pub fn inline_local_image_part(p: ContentPart) -> ContentPart {
match p {
ContentPart::Image { url, base64, media_type, alt }
if is_local_ref(url.as_deref(), base64.as_deref()) =>
{
let path = url.unwrap();
match std::fs::read(&path[LOCAL_FILE_SCHEME.len()..]) {
Ok(bytes) => ContentPart::Image {
url: None,
base64: Some(base64::engine::general_purpose::STANDARD.encode(bytes)),
media_type,
alt,
},
Err(_) => ContentPart::Text {
text: format!("[图片文件缺失: {}]", &path[LOCAL_FILE_SCHEME.len()..]),
},
}
}
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_ref_expands_to_base64() {
let dir = std::env::temp_dir().join("df-ai-image-ref-test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("expands.png");
std::fs::write(&path, b"\x89PNG fake-bytes").unwrap();
let out = inline_local_image_parts(vec![ContentPart::Image {
url: Some(format!("file://{}", path.display())),
base64: None,
media_type: Some("image/png".into()),
alt: None,
}]);
match &out[0] {
ContentPart::Image { url, base64, media_type, .. } => {
assert!(url.is_none());
let b = base64.as_deref().expect("base64 应已填充");
assert_eq!(b, base64::engine::general_purpose::STANDARD.encode(b"\x89PNG fake-bytes"));
assert_eq!(media_type.as_deref(), Some("image/png"));
}
other => panic!("应保持 Image 片, 实际 {other:?}"),
}
std::fs::remove_file(&path).ok();
}
#[test]
fn missing_file_degrades_to_text_not_empty_base64() {
let out = inline_local_image_parts(vec![ContentPart::Image {
url: Some("file:///nonexistent/df-ai-no-such.png".into()),
base64: None,
media_type: Some("image/png".into()),
alt: None,
}]);
match &out[0] {
ContentPart::Text { text } => {
assert!(text.contains("[图片文件缺失"), "应降级为缺失说明, 实际 {text}");
assert!(text.contains("df-ai-no-such.png"));
}
other => panic!("缺失文件应降级 Text 片, 实际 {other:?}"),
}
}
#[test]
fn non_local_parts_pass_through() {
let passthrough = vec![
ContentPart::text("hello"),
ContentPart::image_base64("image/png", "aGVsbG8="),
ContentPart::image_url("https://x/a.png"),
// base64 非空的 file:// 不是引用(优先内嵌),原样透传
ContentPart::Image {
url: Some("file:///x.png".into()),
base64: Some("aGVsbG8=".into()),
media_type: Some("image/png".into()),
alt: None,
},
];
let out = inline_local_image_parts(passthrough);
assert_eq!(out.len(), 4);
assert!(matches!(&out[0], ContentPart::Text { .. }));
assert!(matches!(&out[1], ContentPart::Image { base64: Some(_), .. }));
assert!(matches!(&out[2], ContentPart::Image { url: Some(u), .. } if u.starts_with("https://")));
assert!(matches!(&out[3], ContentPart::Image { base64: Some(_), .. }));
}
}
+5
View File
@@ -33,6 +33,8 @@ mod model_probe_helpers;
pub mod plan_hint; pub mod plan_hint;
pub mod openai_compat; pub mod openai_compat;
pub mod openai_helpers; pub mod openai_helpers;
// 本地图片引用展开(#958 图片入站引用化):消息存 file:// 引用,发送前单点展开 base64
pub mod image_ref;
// Phase 0 (B 路线): Plan-driven 规划骨架(维度 0.8 Plan/SubTask 结构 + 0.7 规划校验/Kahn 分层)。 // Phase 0 (B 路线): Plan-driven 规划骨架(维度 0.8 Plan/SubTask 结构 + 0.7 规划校验/Kahn 分层)。
// 纯函数模块(零 IO 零状态)。依据 docs/02-架构设计/单对话并行多轮-{设计,Phase0落地路线图}-2026-06-20.md。 // 纯函数模块(零 IO 零状态)。依据 docs/02-架构设计/单对话并行多轮-{设计,Phase0落地路线图}-2026-06-20.md。
// Phase 1(plan_hint 接入主 loop)/Phase 2(planning)/Phase 3(并行 execution) 接入后续。 // Phase 1(plan_hint 接入主 loop)/Phase 2(planning)/Phase 3(并行 execution) 接入后续。
@@ -50,6 +52,9 @@ pub mod router;
pub mod retry; pub mod retry;
pub mod sse_parser; pub mod sse_parser;
pub mod namespace_store; pub mod namespace_store;
// P1 工具结果瘦身(上下文根治专项 2026-10-06 设计 §二):超阈值工具结果正文落盘 + 句柄。
// 与 src-tauri 审计侧 D2a(tool_result_offload) 同构;base 未注入时降级为纯摘要(零行为变化)。
pub mod tool_offload;
use provider::LlmProvider; use provider::LlmProvider;
use reqwest::Client; use reqwest::Client;
+130 -9
View File
@@ -118,10 +118,12 @@ impl OpenAICompatProvider {
// 多模态 content(须在 move m.tool_calls 之前算,借用 m)。 // 多模态 content(须在 move m.tool_calls 之前算,借用 m)。
// 含图消息走 content 数组(text/image_url);纯文本走字符串简写 // 含图消息走 content 数组(text/image_url);纯文本走字符串简写
// (保持与现有纯文本端点零回归)。image_url 支持 data URI(base64)与 http(s) URL。 // (保持与现有纯文本端点零回归)。image_url 支持 data URI(base64)与 http(s) URL。
// 本地 file:// 引用(#958)先展开为 base64 内嵌——OpenAI 端点不认本地路径
let content = if m.has_image() { let content = if m.has_image() {
let parts: Vec<serde_json::Value> = m let parts: Vec<serde_json::Value> = m
.flattened_parts() .flattened_parts()
.into_iter() .into_iter()
.map(crate::image_ref::inline_local_image_part)
.map(|p| match p { .map(|p| match p {
crate::provider::ContentPart::Text { text } => serde_json::json!({ crate::provider::ContentPart::Text { text } => serde_json::json!({
"type": "text", "type": "text",
@@ -211,6 +213,10 @@ impl OpenAICompatProvider {
// 模型不支持 temperature(如商汤 sensenova 仅接受 1.0)→ 省略该字段,避免上游 HTTP 400。 // 模型不支持 temperature(如商汤 sensenova 仅接受 1.0)→ 省略该字段,避免上游 HTTP 400。
// 判定走 model_probe(命中 presets 表;未命中默认支持,保持原样透传零回归)。 // 判定走 model_probe(命中 presets 表;未命中默认支持,保持原样透传零回归)。
// 商汤网关(token.sensenova.cn)下温度支持**按模型各异**:sensenova-6.x 与
// sn2-kimi 实测仅接受 1(传其它值 400),部分新模型支持常规温度。
// 故不做 base_url 一刀切,改由 presets 逐模型精确维护 temperature_supported:
// 受限模型(含实测 sn2-kimi)标 false 省略;支持温度变化的模型标 true 正常透传。
let temperature = if req.temperature.is_some() let temperature = if req.temperature.is_some()
&& !crate::model_probe::probe(&model).temperature_supported && !crate::model_probe::probe(&model).temperature_supported
{ {
@@ -368,18 +374,34 @@ fn extract_tool_call_ids(msg: &OpenAiMessage) -> Vec<String> {
fn sanitize_openai_triplets(messages: &mut Vec<OpenAiMessage>) { fn sanitize_openai_triplets(messages: &mut Vec<OpenAiMessage>) {
use std::collections::HashSet; use std::collections::HashSet;
// step 1:resolved_ids = 所有 tool 消息提供的 tool_call_id(有 result 响应的 id)。 // step 1:相邻性判定的依据 = 每个 assistant 头「紧后 tool 连续段」覆盖的 id 集合。
let resolved_ids: HashSet<String> = messages // 根因(2026-10-09):原实现按「id 全局存在」判闭合(位置无关),而 OpenAI 协议要求
.iter() // tool 响应必须**紧随**其 assistant(tool_calls)。后台压缩摘要在 head 已 push、tool
.filter(|m| m.role == "tool") // 结果未 push 的窗口内可楔入 system(context_lifecycle),被隔开的响应仍被判"已闭合"
.filter_map(|m| m.tool_call_id.clone()) // → 畸形序列放行 → 上游 400「insufficient tool messages」。故判定口径由「id 全局存在」
.collect(); // 收紧为「紧后连续段覆盖」:未覆盖的 call 视同未闭合剥除,其游离响应随后由 step3 丢弃。
let mut adjacency: std::collections::HashMap<usize, HashSet<String>> =
std::collections::HashMap::new();
for i in 0..messages.len() {
if messages[i].role != "assistant" {
continue;
}
let mut seg: HashSet<String> = HashSet::new();
let mut j = i + 1;
while j < messages.len() && messages[j].role == "tool" {
if let Some(id) = messages[j].tool_call_id.as_deref() {
seg.insert(id.to_string());
}
j += 1;
}
adjacency.insert(i, seg);
}
let mut stripped_heads = 0u32; let mut stripped_heads = 0u32;
let mut total_stripped = 0u32; let mut total_stripped = 0u32;
// step 2:assistant 剥未闭合 tool_call(无对应 tool result 响应)。 // step 2:assistant 剥未闭合 tool_call(其响应不在紧后连续段内)。
for m in messages.iter_mut() { for (i, m) in messages.iter_mut().enumerate() {
if m.role != "assistant" { if m.role != "assistant" {
continue; continue;
} }
@@ -389,12 +411,13 @@ fn sanitize_openai_triplets(messages: &mut Vec<OpenAiMessage>) {
if calls.is_empty() { if calls.is_empty() {
continue; continue;
} }
let seg = adjacency.get(&i);
let kept: Vec<serde_json::Value> = calls let kept: Vec<serde_json::Value> = calls
.iter() .iter()
.filter(|tc| { .filter(|tc| {
tc.get("id") tc.get("id")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.is_some_and(|id| resolved_ids.contains(id)) .is_some_and(|id| seg.is_some_and(|s| s.contains(id)))
}) })
.cloned() .cloned()
.collect(); .collect();
@@ -999,6 +1022,57 @@ mod tests {
assert_eq!(arr[2]["image_url"]["url"], "https://x/a.png"); assert_eq!(arr[2]["image_url"]["url"], "https://x/a.png");
} }
/// 本地 file:// 引用(#958)在转换层展开为 data URI;引用缺失降级 Text 片不炸请求
#[test]
fn openai_convert_expands_local_file_ref() {
use base64::Engine as _;
let dir = std::env::temp_dir().join("df-ai-openai-ref-test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("ref.png");
std::fs::write(&path, b"\x89PNG ok").unwrap();
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
let req = CompletionRequest {
model: "gpt-4o".into(),
messages: vec![
ChatMessage::user_parts(
"看图",
vec![crate::provider::ContentPart::Image {
url: Some(format!("file://{}", path.display())),
base64: None,
media_type: Some("image/png".into()),
alt: None,
}],
),
ChatMessage::user_parts(
"坏引用",
vec![crate::provider::ContentPart::Image {
url: Some("file:///nonexistent/ref-missing.png".into()),
base64: None,
media_type: Some("image/png".into()),
alt: None,
}],
),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
let good = &out.messages[0];
let arr = good.content.as_array().expect("含图 → 数组");
assert_eq!(arr[1]["image_url"]["url"], format!("data:image/png;base64,{}", base64::engine::general_purpose::STANDARD.encode(b"\x89PNG ok")));
let bad = &out.messages[1];
let arr = bad.content.as_array().expect("降级片仍在数组");
assert_eq!(arr[1]["type"], "text");
assert!(arr[1]["text"].as_str().unwrap().contains("[图片文件缺失"));
std::fs::remove_file(&path).ok();
}
/// 纯文本消息 → content 仍是字符串简写(无图不数组化,对齐纯文本端点兼容) /// 纯文本消息 → content 仍是字符串简写(无图不数组化,对齐纯文本端点兼容)
#[test] #[test]
fn openai_convert_text_only_remains_string() { fn openai_convert_text_only_remains_string() {
@@ -1018,6 +1092,53 @@ mod tests {
assert_eq!(msg.content, serde_json::Value::String("hello".into())); assert_eq!(msg.content, serde_json::Value::String("hello".into()));
} }
/// 温度支持按模型精确判定(presets 表,probe 命中),不按 base_url 一刀切——
/// 商汤网关下温度支持各异:sn2-kimi 实测仅接受 temperature=1(传 0.7 → 上游 400),
/// presets 已标 temperature_supported=false,任何 provider 下都省略。
/// 回归 B5 挂机 sn2-kimi 传 0.7 → 400。省略交上游默认,宁默认不 400。
#[test]
fn openai_temperature_omitted_for_sn2_kimi_via_presets() {
let provider =
OpenAICompatProvider::new("https://token.sensenova.cn", "k", "sensenova-6.8-flash-lite");
let req = CompletionRequest {
model: "sn2-kimi".into(), // presets 精确标记受限,probe 命中 false
messages: vec![ChatMessage::user("hi")],
temperature: Some(0.7),
max_tokens: None,
stream: true,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
assert_eq!(out.temperature, None, "sn2-kimi presets 标 false,应省略 temperature");
}
/// 反向保证:支持常规温度的模型(presets 未标 false,如 deepseek-v4-flash)
/// 即使挂在商汤 provider 池也按模型判定透传,不因 base_url 误杀——商汤新模型
/// 支持温度变化,一刀切会错误降级。
#[test]
fn openai_temperature_passed_for_supported_model_on_sensenova_base() {
let provider =
OpenAICompatProvider::new("https://token.sensenova.cn", "k", "sensenova-6.8-flash-lite");
let req = CompletionRequest {
model: "deepseek-v4-flash".into(), // presets 未标受限 → 默认支持温度
messages: vec![ChatMessage::user("hi")],
temperature: Some(0.7),
max_tokens: None,
stream: true,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
assert_eq!(
out.temperature,
Some(0.7),
"支持温度的模型应透传,不因 base_url 是商汤而省略"
);
}
// ---------- ensure_leading_user(首条非 user/system → 补 user 占位,OpenAI 对称 Anthropic)---------- // ---------- ensure_leading_user(首条非 user/system → 补 user 占位,OpenAI 对称 Anthropic)----------
/// 首条 assistant → 补 user 占位(对齐 Anthropic)。上游绕过 sanitize 的 /// 首条 assistant → 补 user 占位(对齐 Anthropic)。上游绕过 sanitize 的
+117
View File
@@ -0,0 +1,117 @@
//! 上下文侧工具结果正文外置(P1 工具结果瘦身)。
//!
//! 依据 docs/02-架构设计/上下文根治设计-20261006.md §二「工具结果瘦身(治最大头,先行)」:
//! 超阈值工具结果**正文落盘**,上下文只留「摘要 + 句柄路径」;句柄可被 agent 主动展开
//! (read_file 读句柄路径)——需要时找得回,不需要时不占位。与 src-tauri 审计侧 D2a 同构。
//!
//! 与 namespace_store 的区别(两者互补,不冲突):
//! - namespace_store:内存态引用化(省进程内存),发 LLM 前展开为全文 → token 零收益。
//! - 本模块:落盘 + 句柄,被省略的中段**不进 prompt 但可读回** → 真正减 token 且不丢信息。
//!
//! 降级契约:base 未注入(单测/未初始化)→ 不落盘返回 None,调用方退化为纯摘要(改造前行为)。
//! 幂等:文件名取内容稳定哈希,同内容重复压缩只落盘一次。
//! 保留策略:与全局「历史数据默认保留」一致,本模块自产文件只增不减(无清理调用点)。
//!
//! 测试隔离:核心逻辑 `offload_to(base, ...)` 显式传 base(零全局态),单测只测它;
//! `init_base` 的 OnceLock 全局态一次性不可重置,故测试**绝不调用**它(避免污染同进程
//! 其他用例——如 extract_key_info 系列断言会被意外注入的句柄行破坏)。
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
/// 外置根目录(进程级一次注入;src-tauri lib.rs setup 调 init_base)。
/// 与审计侧共用 {app_data},文件名前缀 `ctx-` 区分(tool-results/ 目录共存)。
static BASE: OnceLock<PathBuf> = OnceLock::new();
/// 注入外置根目录(app data dir)。幂等,首次生效。
pub fn init_base(base: PathBuf) {
let _ = BASE.set(base);
}
/// base 是否已注入(降级判定用)。
pub fn is_enabled() -> bool {
BASE.get().is_some()
}
/// 内容稳定哈希(非密码学用途;碰撞后果仅同名文件互相覆盖,概率可忽略)。
fn content_id(content: &str) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
content.hash(&mut h);
h.finish()
}
/// 落盘工具结果正文,返回可读句柄行(供摘要末尾追加)。base 未注入 → None。
pub fn offload_with_handle(content: &str, tool_name: &str) -> Option<String> {
offload_to(BASE.get()?, content, tool_name)
}
/// 核心实现(显式 base,零全局态,可测)。
///
/// 建目录失败 / 写文件失败 → None(降级:调用方仅用摘要,不丢数据)。
pub fn offload_to(base: &Path, content: &str, tool_name: &str) -> Option<String> {
let dir = base.join("tool-results");
if std::fs::create_dir_all(&dir).is_err() {
return None;
}
let file = dir.join(format!("ctx-{:016x}.txt", content_id(content)));
if !file.exists() && std::fs::write(&file, content).is_err() {
return None;
}
Some(format!(
"[工具 {} 原文 {} 字符已外置: {} —— 需完整内容用 read_file 读该路径]",
tool_name,
content.chars().count(),
file.display()
))
}
/// 读回外置文件(查询侧/测试用)。文件缺失返回带语义错误(降级不 panic)。
pub fn read_offloaded(path: &str) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|e| format!("外置结果读取失败: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_base(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("df-ai-offload-{tag}-{}", std::process::id()))
}
#[test]
fn offload_to_writes_readable_file_and_is_idempotent() {
let base = tmp_base("ok");
let _ = std::fs::remove_dir_all(&base);
let payload = "large tool output ".repeat(1000);
let handle = offload_to(&base, &payload, "grep").expect("显式 base 应落盘");
assert!(handle.contains("已外置"), "句柄应含提示文案: {handle}");
assert!(handle.contains("read_file"), "句柄应指引读回方式: {handle}");
// 幂等:同内容二次调用返回同一文件名
let handle2 = offload_to(&base, &payload, "grep").expect("二次调用仍返回句柄");
assert_eq!(handle, handle2, "同内容应幂等复用同一文件");
// 落盘文件可读回原文(无损)
let path = std::fs::read_dir(base.join("tool-results"))
.expect("目录应存在")
.flatten()
.map(|e| e.path())
.find(|p| p.extension().map(|e| e == "txt").unwrap_or(false))
.expect("应有落盘文件");
assert_eq!(
read_offloaded(path.to_string_lossy().as_ref()).expect("应可读回"),
payload
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn different_content_gets_different_file() {
let base = tmp_base("diff");
let _ = std::fs::remove_dir_all(&base);
let h1 = offload_to(&base, &"a".repeat(3000), "grep").unwrap();
let h2 = offload_to(&base, &"b".repeat(3000), "grep").unwrap();
assert_ne!(h1, h2, "不同内容应落不同文件");
let _ = std::fs::remove_dir_all(&base);
}
}
+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 { fn fallback() -> EnvSnapshot {
EnvSnapshot { EnvSnapshot {
os: std::env::consts::OS.to_string(), os: std::env::consts::OS.to_string(),
@@ -310,7 +312,11 @@ fn extract_codepage(text: &str) -> Option<String> {
} }
} }
/// 执行 `tool --version`,解析首行返回版本串。失败/超时返回 None,不阻塞调用方。 /// 执行 `tool --version`,解析首行返回版本串。失败(非零退出/无首行)返回 None。
///
/// 注:单条命令本身**无独立超时**(EX-4)——若 `tool --version` 挂起(如 Windows Store Alias),
/// 本函数会一直阻塞,由外层 [`EnvSnapshot::detect`] 的 5s spawn_blocking 超时兜底整批(届时
/// 全部工具版本一起回退 fallback),而非逐命令超时。「不阻塞调用方」靠的是外层超时,非本函数。
/// ///
/// 例:python --version 输出 "Python 3.11.5" → 返回 "3.11.5";git --version 输出 /// 例:python --version 输出 "Python 3.11.5" → 返回 "3.11.5";git --version 输出
/// "git version 2.41.0" → 返回 "2.41.0"。统一抽掉工具名前缀,只保留版本号本身。 /// "git version 2.41.0" → 返回 "2.41.0"。统一抽掉工具名前缀,只保留版本号本身。
+7 -3
View File
@@ -320,7 +320,7 @@ pub struct ShellRequest {
pub env: std::collections::HashMap<String, String>, pub env: std::collections::HashMap<String, String>,
/// 超时时间(秒),None 表示不超时 /// 超时时间(秒),None 表示不超时
pub timeout_secs: Option<u64>, pub timeout_secs: Option<u64>,
/// Shell 类型(默认:Windows→Cmd, 非Windows→Sh) /// Shell 类型(None 时取 [`ShellType::default`]:Windows→pwsh 可用则 pwsh 否则 PowerShell,非 Windows→Sh)
#[serde(default)] #[serde(default)]
pub shell_type: Option<ShellType>, pub shell_type: Option<ShellType>,
} }
@@ -419,7 +419,7 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
// 提交冻结机械闸(39 治理):冻结在场且命令为 git commit → spawn 前拒绝,改动留工作区 // 提交冻结机械闸(39 治理):冻结在场且命令为 git commit → spawn 前拒绝,改动留工作区
crate::commit_freeze::check(&request).map_err(anyhow::Error::msg)?; crate::commit_freeze::check(&request).map_err(anyhow::Error::msg)?;
// 探测 pwsh(惰性 + OnceLock 全局缓存,只探一次),使后续 ShellType::default() 可读取缓存 // 探测 pwsh(惰性 + 模块级 PWSH_CACHE,成功才写缓存),使后续 ShellType::default() 可读取缓存
#[cfg(windows)] #[cfg(windows)]
let _ = probe_pwsh().await; let _ = probe_pwsh().await;
@@ -453,7 +453,11 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
/// 治 run_command 执行中黑盒:execute() 等 exit 才返回整块 stdout/stderr,长命令(cargo/npm 构建) /// 治 run_command 执行中黑盒:execute() 等 exit 才返回整块 stdout/stderr,长命令(cargo/npm 构建)
/// 期间前端只看 Started→Completed,中间进度不可见。本函数 spawn 子进程后并发逐行读两条流, /// 期间前端只看 Started→Completed,中间进度不可见。本函数 spawn 子进程后并发逐行读两条流,
/// 每读到一行回调 `on_output(kind, line)`(调用方可 emit 事件给前端实时展示),仍等进程 exit /// 每读到一行回调 `on_output(kind, line)`(调用方可 emit 事件给前端实时展示),仍等进程 exit
/// 后返回完整 ShellResult(与 execute() 同形,调用方无需感知差异)。 /// 后返回完整 ShellResult。
///
/// 与 [`execute`] 的输出差异(EX-2,调用方须知):本函数 stdout/stderr 按行重组——经
/// `BufReader::lines()` 逐行拼接,行尾统一为 `\n`(剥 \r\n)且补末尾换行;execute 则保留原始字节
/// (lossy 解码,含 \r\n,无补换行)。二者 ShellResult 字段同形,仅换行形态不同;需字节级一致时用 execute。
/// ///
/// 4性: /// 4性:
/// - 合理机制:spawn + BufReader::lines() 逐行,不丢未换行结尾的末段(read_to_end 兜底) /// - 合理机制:spawn + BufReader::lines() 逐行,不丢未换行结尾的末段(read_to_end 兜底)
-2
View File
@@ -125,8 +125,6 @@ impl ScoringEngine {
} }
} }
/// 统计 text 中命中任一关键词的数量(小写匹配,兼顾中英文)
/// 局限:纯子串匹配,不识别"不复用""无用户增长"等否定前缀,接 LLM 后由语义层修正
/// 单字否定前缀:关键词紧邻这些字时该命中不计(「不复用」不计入「复用」正向命中)。 /// 单字否定前缀:关键词紧邻这些字时该命中不计(「不复用」不计入「复用」正向命中)。
/// ///
/// 注意「别」单字歧义大(别具/别的/特别 均非否定),故不进单字清单—— /// 注意「别」单字歧义大(别具/别的/特别 均非否定),故不进单字清单——
+1
View File
@@ -85,6 +85,7 @@ async fn connect_once(
kind: HelloKind::Commander, kind: HelloKind::Commander,
channel_id: channel_id.to_string(), channel_id: channel_id.to_string(),
token: token.to_string(), token: token.to_string(),
proto: 1,
}; };
sink.send(Message::Text(serde_json::to_string(&hello)?)).await?; sink.send(Message::Text(serde_json::to_string(&hello)?)).await?;
+1 -1
View File
@@ -17,4 +17,4 @@ pub mod server;
pub mod server_http; pub mod server_http;
pub mod tools; pub mod tools;
pub use server::{run_server, ServerConfig}; pub use server::{run_server, ServerConfig, MCP_DATA_CHANGED_KEY};
+103 -10
View File
@@ -14,6 +14,7 @@ use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use df_storage::crud::SettingsRepo;
use df_storage::db::Database; use df_storage::db::Database;
use serde_json::{json, Value}; use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
@@ -29,6 +30,15 @@ pub(crate) const PROTOCOL_VERSION: &str = "2025-06-18";
pub(crate) const SERVER_NAME: &str = "devflow-mcp"; pub(crate) const SERVER_NAME: &str = "devflow-mcp";
pub(crate) const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION"); pub(crate) const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
/// app_settings KV key:stdio MCP 写库后落的变更标记,供 GUI 侧轮询兜底消费。
///
/// 独立进程形态的 stdio MCP 无 AppHandle(`on_write_call=None`),写库后 GUI 无从经
/// `df-data-changed` 事件感知,造成双端数据断层(P0-2)。此标记是跨进程的落库信令:
/// 每次成功写工具覆盖写入,值为 `{"at": <毫秒时间戳>, "tool": "<工具名>"}`。
/// GUI 侧轮询读此 key,`at` 变化即知库被外部改动;再按 `tool` 复用 df-data-changed
/// 的 entity 映射刷新对应列表(entity 映射留在 GUI 侧,避免本 crate 引入 df-ai 依赖)。
pub const MCP_DATA_CHANGED_KEY: &str = "mcp.data_changed";
/// MCP server 运行配置。 /// MCP server 运行配置。
/// ///
/// 聚合 stdio 形态的运行参数,避免 `run_server` 参数膨胀。 /// 聚合 stdio 形态的运行参数,避免 `run_server` 参数膨胀。
@@ -41,7 +51,8 @@ pub struct ServerConfig {
pub idle_timeout: Option<Duration>, pub idle_timeout: Option<Duration>,
/// 写操作回调:成功执行(未被 read-only/High 拒绝)的写工具(risk != Low)触发。 /// 写操作回调:成功执行(未被 read-only/High 拒绝)的写工具(risk != Low)触发。
/// 桌面内嵌形态经 server_http → on_tool_call → emit df-data-changed 刷新 GUI; /// 桌面内嵌形态经 server_http → on_tool_call → emit df-data-changed 刷新 GUI;
/// stdio 独立进程暂无 AppHandle,传 None 预留接线点,后续可注入。 /// stdio 独立进程暂无 AppHandle,传 None 预留接线点,后续可注入。传 None 时由
/// 落库变更标记([`MCP_DATA_CHANGED_KEY`])兜底,供 GUI 侧轮询消费。
pub on_write_call: Option<Arc<dyn Fn(&str) + Send + Sync>>, pub on_write_call: Option<Arc<dyn Fn(&str) + Send + Sync>>,
} }
@@ -154,8 +165,13 @@ where
if let Some(name) = tool_name { if let Some(name) = tool_name {
// MC-5仅业务成功才触发写回调——防业务失败(如 update_project 非法 status、 // MC-5仅业务成功才触发写回调——防业务失败(如 update_project 非法 status、
// create_project 空名等 result.isError=true)仍假触发 df-data-changed。 // create_project 空名等 result.isError=true)仍假触发 df-data-changed。
if is_success_tool_call(&resp) { if is_success_tool_call(&resp) && is_executed_write(&name, config.read_only) {
fire_write_hook(config, &name); // 多进程兜底:stdio 形态无 AppHandle,先落库内变更标记,供 GUI 侧轮询消费
// (即便未注入回调也不丢变更感知);再触发进程内回调(HTTP 内嵌形态走事件)。
record_data_change(ctx, &name).await;
if let Some(cb) = &config.on_write_call {
cb(&name);
}
} }
} }
} }
@@ -293,13 +309,30 @@ fn required_names(schema: &Value) -> Vec<String> {
} }
} }
/// 触发写操作回调(若有)。仅当工具为写操作(risk != Low)且未被 read-only/High 拒绝时触发, /// 判定工具是否属于「会执行的成功写操作」:risk != Low 且未被 read-only/High 拒绝。
/// 与 dispatch 的执行判定一致。回调仅作通知(如 GUI 刷新),不承载返回结果。 ///
fn fire_write_hook(config: &ServerConfig, name: &str) { /// 变更标记与进程内写回调共用此判定,保证两通道对「哪些写操作需通知 GUI」语义一致;
let Some(cb) = &config.on_write_call else { return }; /// 与 dispatch 的执行判定(`should_execute`)同源,避免逻辑漂移。
let Some(spec) = tools::find(name) else { return }; fn is_executed_write(name: &str, read_only: bool) -> bool {
if spec.risk != RiskLevel::Low && should_execute(config.read_only, spec.risk) { match tools::find(name) {
cb(name); Some(spec) => spec.risk != RiskLevel::Low && should_execute(read_only, spec.risk),
None => false,
}
}
/// 写成功后向 [`MCP_DATA_CHANGED_KEY`] 覆盖写入一条变更标记(多进程兜底信令)。
///
/// 独立进程形态完成写库后,GUI 无法经事件感知;此标记是落库的持久信令,GUI 轮询读
/// 该 key、`at` 变化即知库被外部改动。写失败仅告警不阻断(工具调用已成功,下次成功
/// 写会再次覆盖,标记只作「有变更」的粗粒度提示,非精确事件队列)。
async fn record_data_change(ctx: &Ctx, tool_name: &str) {
let at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let value = json!({ "at": at, "tool": tool_name }).to_string();
if let Err(e) = SettingsRepo::new(&ctx.db).set(MCP_DATA_CHANGED_KEY, &value).await {
tracing::warn!(target: "df_mcp", tool = tool_name, err = %e, "写变更标记失败(不改工具调用结果)");
} }
} }
@@ -675,4 +708,64 @@ mod tests {
*calls.lock().unwrap() *calls.lock().unwrap()
); );
} }
// ── 多进程兜底:stdio 写库后落变更标记 ────────────────────────────
#[tokio::test]
async fn main_loop_records_data_change_marker_for_write_tool() {
// stdio 形态(on_write_call=None)成功写工具后应向 KV 落变更标记,
// 供 GUI 轮询消费——即便无任何回调注入也不丢变更感知(P0-2 兜底)。
let ctx = test_ctx().await;
let (mut tx, rx) = tokio::io::duplex(1024);
let line = r#"{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"MarkProj","description":"d"}}}"#;
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
drop(tx);
main_loop(rx, tokio::io::sink(), &ctx, &test_config(false).await)
.await
.unwrap();
let v = SettingsRepo::new(&ctx.db)
.get(MCP_DATA_CHANGED_KEY)
.await
.unwrap()
.expect("写工具成功后应落变更标记");
let parsed: Value = serde_json::from_str(&v).unwrap();
assert_eq!(parsed["tool"], "create_project");
assert!(parsed["at"].as_u64().unwrap() > 0, "标记应含毫秒时间戳: {v}");
}
#[tokio::test]
async fn main_loop_no_data_change_marker_for_read_tool() {
// 只读工具无副作用,不应落变更标记(否则 GUI 无谓刷新)。
let ctx = test_ctx().await;
let (mut tx, rx) = tokio::io::duplex(1024);
let line = r#"{"jsonrpc":"2.0","id":12,"method":"tools/call","params":{"name":"list_projects","arguments":{}}}"#;
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
drop(tx);
main_loop(rx, tokio::io::sink(), &ctx, &test_config(false).await)
.await
.unwrap();
let v = SettingsRepo::new(&ctx.db)
.get(MCP_DATA_CHANGED_KEY)
.await
.unwrap();
assert!(v.is_none(), "只读工具不应落变更标记,实际: {v:?}");
}
#[tokio::test]
async fn main_loop_no_data_change_marker_when_read_only_denies_write() {
// read-only 下写被拒(未真正写库),不应落变更标记。
let ctx = test_ctx().await;
let (mut tx, rx) = tokio::io::duplex(1024);
let line = r#"{"jsonrpc":"2.0","id":13,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"X","description":"d"}}}"#;
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
drop(tx);
main_loop(rx, tokio::io::sink(), &ctx, &test_config(true).await)
.await
.unwrap();
let v = SettingsRepo::new(&ctx.db)
.get(MCP_DATA_CHANGED_KEY)
.await
.unwrap();
assert!(v.is_none(), "read-only 下写被拒不应落变更标记,实际: {v:?}");
}
} }
+7 -3
View File
@@ -70,10 +70,12 @@ impl Node for HumanNode {
// 3. select! 循环:Response / 超时 / 取消 // 3. select! 循环:Response / 超时 / 取消
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs); let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
let mut cancel_tick = tokio::time::interval(Duration::from_millis(500)); // FR-P4:取消检测由 500ms interval 轮询改为 Notify 被动唤醒(set_cancelled 触发)。
cancel_tick.tick().await; // 丢弃首个立即触发的 tick // 先建 notified future 再进 loop,避免 notify 早于 await 的竞态窗口;
// 每轮 select! 后重建(Notified 单次消费),配合 is_cancelled 复查兜底。
loop { loop {
let cancel_wait = ctx.node_status.cancel_notified();
tokio::select! { tokio::select! {
recv = rx.recv() => match recv { recv = rx.recv() => match recv {
Ok(WorkflowEvent::HumanApprovalResponse { Ok(WorkflowEvent::HumanApprovalResponse {
@@ -154,7 +156,9 @@ impl Node for HumanNode {
_ = tokio::time::sleep_until(deadline) => { _ = tokio::time::sleep_until(deadline) => {
return Err(anyhow::anyhow!("人工审批超时({}s)", timeout_secs)); return Err(anyhow::anyhow!("人工审批超时({}s)", timeout_secs));
} }
_ = cancel_tick.tick() => { _ = cancel_wait => {
// FR-P4:Notify 唤醒后复查 is_cancelled(Notify 无状态,
// 可能因其他节点取消而误唤醒,须确认本节点确被取消)。
if ctx.node_status.is_cancelled(&ctx.node_id) { if ctx.node_status.is_cancelled(&ctx.node_id) {
return Err(anyhow::anyhow!("人工审批被取消")); return Err(anyhow::anyhow!("人工审批被取消"));
} }
+3 -2
View File
@@ -1,7 +1,8 @@
//! 子工作流节点 — 加载一个子 DAG 并在当前执行上下文中递归执行 //! 子工作流节点 — 加载并校验一个子 DAG,返回其 JSON 快照
//! //!
//! 用于复用通用流程(如"代码审查"可作为任意主流程的子步骤)。 //! 用于复用通用流程(如"代码审查"可作为任意主流程的子步骤)。
//! 执行结果不直接展开节点,而是返回子 DAG 的 JSON 快照,由 DagExecutor 消费展开。 //! 注意:本节点不递归执行子 DAG,只做配置/深度校验后返回子 DAG 快照;
//! 快照的展开执行需调用方自行处理(DagExecutor 当前不会自动执行返回的 subflow)。
use async_trait::async_trait; use async_trait::async_trait;
use df_workflow::dag_def::DagDef; use df_workflow::dag_def::DagDef;
+122 -5
View File
@@ -67,6 +67,26 @@ pub async fn advance_task_atomic(
.await? .await?
.ok_or_else(|| df_types::error::Error::NotFound(format!("任务 {} 不存在", id)))?; .ok_or_else(|| df_types::error::Error::NotFound(format!("任务 {} 不存在", id)))?;
// 2.5 前置校验:归档任务冻结,不可推进(与 CAS SQL 的 archived=0 条件同源)。
//
// 背景:advance_status_atomic 的 CAS SQL 带 `AND archived = 0`,归档任务必然
// affected==0 → 返回 None → 下游误报「已变更(并发推进或旁路修改)」,诊断信息
// 完全误导(真实原因是归档,非并发)。此处前置拦截,给出精确可操作错误。
//
// 软删(deleted_at IS NOT NULL)任务 get_by_id 仍能读到(TASK_COLS 不 SELECT
// deleted_at,TaskRecord 无该字段),但 CAS 的 `deleted_at IS NULL` 同样必败。
// 因 TaskRecord 不带 deleted_at,此处无法直接判定;由 advance_status_atomic
// 的 CAS 失败回查区分(见该函数注释)。
if current.archived {
return Err(df_types::error::Error::InvalidState {
current: format!(
"任务 {} 已归档(archived=true),归档任务冻结不可推进;如需推进请先取消归档",
id
),
expected: target_status.to_string(),
});
}
// 3. 状态机校验(三类拒绝,错误类型区分供前端分辨): // 3. 状态机校验(三类拒绝,错误类型区分供前端分辨):
// - 同态(from==to):Validation「相同状态,无需推进」(空操作,非状态机违例) // - 同态(from==to):Validation「相同状态,无需推进」(空操作,非状态机违例)
// - 非法转换(跳态/终态无后继等):InvalidState「非法状态转换 X→Y」 // - 非法转换(跳态/终态无后继等):InvalidState「非法状态转换 X→Y」
@@ -112,11 +132,45 @@ pub async fn advance_task_atomic(
.advance_status_atomic(id, from, target_status, bump) .advance_status_atomic(id, from, target_status, bump)
.await?; .await?;
// 5. CAS 失败:并发已改动 status(或任务被删),拒绝静默成功。 // 5. CAS 失败:affected==0 有四种可能,回查区分,给出精确错误(不再一律误报「并发」)。
updated.ok_or_else(|| df_types::error::Error::InvalidState { //
current: "已变更(并发推进或旁路修改)".to_string(), // CAS SQL 条件:`WHERE id=? AND status=? AND deleted_at IS NULL AND archived=0`。
expected: from.to_string(), // 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 失败"); assert!(r.is_none(), "expected 与库内不符应 CAS 失败");
} }
// ---------- 归档/软删任务的精确错误(治「已变更(并发推进或旁路修改)」误导) ----------
#[tokio::test]
async fn archived_task_rejected_with_precise_error() {
// 归档任务(archived=true)推进应被前置校验拦截,给出「已归档」精确错误,
// 而非落到 CAS 失败报「已变更(并发推进或旁路修改)」误导文案。
let repo = setup().await;
let mut r = rec("t1", TaskStatus::Todo);
r.archived = true;
repo.insert(r).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap_err();
match err {
df_types::error::Error::InvalidState { current, .. } => {
assert!(
current.contains("已归档"),
"归档任务错误应含「已归档」,实际: {current}"
);
assert!(
!current.contains("并发推进或旁路修改"),
"归档任务不应误报并发,实际: {current}"
);
}
other => panic!("归档任务拒绝应是 InvalidState,实际: {other:?}"),
}
}
#[tokio::test]
async fn soft_deleted_task_rejected_with_precise_error() {
// 回收站任务(deleted_at 非 NULL)推进:get_by_id 仍能读到(不过滤软删),
// 状态机校验通过,但 CAS SQL 带 deleted_at IS NULL 必败 → 回查 is_deleted
// 区分,给出「在回收站」精确错误,而非误报「已变更(并发推进或旁路修改)」。
let repo = setup().await;
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
// 软删该任务(走 soft_delete,设 deleted_at)
let ok = repo.soft_delete("t1").await.unwrap();
assert!(ok, "soft_delete 应命中");
assert!(repo.is_deleted("t1").await.unwrap(), "软删后 is_deleted 应为 true");
let err = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap_err();
match err {
df_types::error::Error::InvalidState { current, .. } => {
assert!(
current.contains("回收站"),
"软删任务错误应含「回收站」,实际: {current}"
);
assert!(
!current.contains("并发推进或旁路修改"),
"软删任务不应误报并发,实际: {current}"
);
}
other => panic!("软删任务拒绝应是 InvalidState,实际: {other:?}"),
}
}
#[tokio::test]
async fn is_deleted_false_for_active_task() {
// 活跃任务 is_deleted 应为 false(不误判),保证回查区分逻辑不误伤正常任务。
let repo = setup().await;
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
assert!(!repo.is_deleted("t1").await.unwrap(), "活跃任务 is_deleted 应为 false");
// 不存在的任务:is_deleted 返回 false(不存在 ≠ 软删)
assert!(!repo.is_deleted("nope").await.unwrap(), "不存在任务 is_deleted 应为 false");
}
#[tokio::test] #[tokio::test]
async fn blocked_round_trip_does_not_bump() { async fn blocked_round_trip_does_not_bump() {
let repo = setup().await; let repo = setup().await;
-3
View File
@@ -114,14 +114,11 @@ pub(crate) fn strip_readme_noise(content: &str) -> String {
if trimmed == "---" || trimmed == "+++" { if trimmed == "---" || trimmed == "+++" {
// 开头处的 frontmatter:跳到下一个匹配分隔符 // 开头处的 frontmatter:跳到下一个匹配分隔符
let delim = trimmed; let delim = trimmed;
let mut consumed_any = false;
for next in lines.by_ref() { for next in lines.by_ref() {
consumed_any = true;
if next.trim() == delim { if next.trim() == delim {
break; break;
} }
} }
let _ = consumed_any;
continue; continue;
} }
// ── HTML 注释块(<!-- ... -->,可能跨行)── // ── HTML 注释块(<!-- ... -->,可能跨行)──
+40
View File
@@ -75,6 +75,17 @@ fn validate_device_id(id: &str) -> bool {
!id.is_empty() && id.len() <= 128 && id.chars().all(|c| !c.is_control()) !id.is_empty() && id.len() <= 128 && id.chars().all(|c| !c.is_control())
} }
/// 当前 Hello 握手协议版本。
///
/// 协商规则:旧客户端 Hello 缺 `proto` 字段 → serde default 视作 v1(向后兼容);
/// 高于本端支持版本时记录告警,为后续破坏性变更预留通知位(当前仅日志,不拒绝握手)。
pub const HELLO_PROTO_VERSION: u32 = 1;
/// `proto` 缺省值:旧客户端不带该字段时按 v1 处理(向后兼容)。
fn default_proto() -> u32 {
HELLO_PROTO_VERSION
}
/// 客户端首消息:身份宣告(简单协议) /// 客户端首消息:身份宣告(简单协议)
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hello { pub struct Hello {
@@ -84,6 +95,9 @@ pub struct Hello {
pub device_id: String, pub device_id: String,
/// 配对 token /// 配对 token
pub token: String, pub token: String,
/// 协议版本(缺省按 v1,兼容旧客户端)
#[serde(default = "default_proto")]
pub proto: u32,
} }
/// Hello.kind 的传输表示(serde 字符串,与 ClientKind 解耦避免 rename 歧义) /// Hello.kind 的传输表示(serde 字符串,与 ClientKind 解耦避免 rename 歧义)
@@ -298,6 +312,16 @@ async fn handle_connection(socket: WebSocket, state: RelayState, expected: Clien
return; return;
} }
// 版本协商:proto 缺省按 v1(旧客户端不带字段,向后兼容)。
// 高于本端支持版本时告警 —— 为后续破坏性变更预留的通知位(当前仅日志,不拒绝握手)。
if hello.proto > HELLO_PROTO_VERSION {
tracing::warn!(
client_proto = hello.proto,
server_proto = HELLO_PROTO_VERSION,
"客户端协议版本高于本端,可能存在不兼容(预留通知位)"
);
}
let conn_id = next_conn_id(); let conn_id = next_conn_id();
let kind: ClientKind = hello.kind.into(); let kind: ClientKind = hello.kind.into();
let device_id = hello.device_id.clone(); let device_id = hello.device_id.clone();
@@ -305,6 +329,7 @@ async fn handle_connection(socket: WebSocket, state: RelayState, expected: Clien
conn_id = conn_id.0, conn_id = conn_id.0,
?kind, ?kind,
device_id = %device_id, device_id = %device_id,
proto = hello.proto,
"连接握手通过,进入收发循环" "连接握手通过,进入收发循环"
); );
@@ -545,4 +570,19 @@ mod tests {
fn max_frame_is_one_mib() { fn max_frame_is_one_mib() {
assert_eq!(MAX_FRAME_BYTES, 1024 * 1024); assert_eq!(MAX_FRAME_BYTES, 1024 * 1024);
} }
#[test]
fn hello_proto_defaults_to_v1_when_absent() {
// 旧客户端不发 proto → 反序列化按 v1,保证向后兼容
let json = r#"{"kind":"device","device_id":"dev-1","token":"t"}"#;
let h: Hello = serde_json::from_str(json).expect("缺 proto 应可解析");
assert_eq!(h.proto, HELLO_PROTO_VERSION);
}
#[test]
fn hello_proto_parsed_when_present() {
let json = r#"{"kind":"device","device_id":"dev-1","token":"t","proto":2}"#;
let h: Hello = serde_json::from_str(json).unwrap();
assert_eq!(h.proto, 2);
}
} }
+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), boundary_json: row.get("boundary_json").unwrap_or(None),
// loop_state 列老库经 v49 迁移补建。unwrap_or(None) 兜底(NULL=非任务会话)。 // loop_state 列老库经 v49 迁移补建。unwrap_or(None) 兜底(NULL=非任务会话)。
loop_state: row.get("loop_state").unwrap_or(None), loop_state: row.get("loop_state").unwrap_or(None),
// status 列老库经 v50 迁移补建(NOT NULL DEFAULT 'active')。兜底迁移未跑/极端缺列时按 active。
status: row.get("status").unwrap_or_else(|_| "active".to_string()),
}) })
} }
@@ -133,6 +135,8 @@ fn ai_tool_execution_from_row(row: &Row<'_>) -> std::result::Result<AiToolExecut
requested_at: row.get("requested_at")?, requested_at: row.get("requested_at")?,
executed_at: row.get("executed_at")?, executed_at: row.get("executed_at")?,
decided_by: row.get("decided_by")?, decided_by: row.get("decided_by")?,
// duration_ms 列老库经 v50 迁移补建。unwrap_or(None) 兜底(NULL=老数据/未测路径)。
duration_ms: row.get("duration_ms").unwrap_or(None),
}) })
} }
@@ -187,22 +191,22 @@ impl_repo!(
from_row => |row| ai_conversation_from_row(row), from_row => |row| ai_conversation_from_row(row),
insert => |conn, rec| { insert => |conn, rec| {
conn.execute( conn.execute(
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at, origin, boundary_json, loop_state) "INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at, origin, boundary_json, loop_state, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
params![ params![
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
if rec.pinned { 1i32 } else { 0i32 }, if rec.pinned { 1i32 } else { 0i32 },
rec.prompt_tokens, rec.completion_tokens, rec.prompt_tokens, rec.completion_tokens,
rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at, rec.origin, rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at, rec.origin,
rec.boundary_json, rec.loop_state rec.boundary_json, rec.loop_state, rec.status
], ],
) )
}, },
update => |conn, rec| { update => |conn, rec| {
// loop_state 有意不在 SET 列(V49 生命周期标记独立更新,对齐 boundary 早期模式): // loop_state/status 有意不在 SET 列(V49/V50 生命周期标记独立更新,对齐 boundary 早期模式):
// update_full 的调用方(save_conversation 等)从 get_by_id 读旧记录再改元数据写回, // update_full 的调用方(save_conversation 等)从 get_by_id 读旧记录再改元数据写回,
// 读改写窗口内 loop 生命周期可变更(spawn 置 running/收敛清 None)——若 SET 含 // 读改写窗口内 loop 生命周期/终局态可变更——若 SET 含二者,旧值会覆盖新值(lost-update)。
// loop_state,旧值会覆盖新值(lost-update)。生命周期只经 set_loop_state 单点写。 // 生命周期只经 set_loop_state / set_status 单点写。
conn.execute( conn.execute(
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12, boundary_json = ?13 WHERE id = ?14", "UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12, boundary_json = ?13 WHERE id = ?14",
params![ params![
@@ -249,22 +253,22 @@ impl_repo!(
from_row => |row| ai_tool_execution_from_row(row), from_row => |row| ai_tool_execution_from_row(row),
insert => |conn, rec| { insert => |conn, rec| {
conn.execute( conn.execute(
"INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by) "INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by, duration_ms)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
params![ params![
rec.id, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name, rec.id, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name,
rec.arguments, rec.result, rec.status, rec.risk_level, rec.arguments, rec.result, rec.status, rec.risk_level,
rec.requested_at, rec.executed_at, rec.decided_by rec.requested_at, rec.executed_at, rec.decided_by, rec.duration_ms
], ],
) )
}, },
update => |conn, rec| { update => |conn, rec| {
conn.execute( conn.execute(
"UPDATE ai_tool_executions SET conversation_id = ?1, message_id = ?2, tool_call_id = ?3, tool_name = ?4, arguments = ?5, result = ?6, status = ?7, risk_level = ?8, requested_at = ?9, executed_at = ?10, decided_by = ?11 WHERE id = ?12", "UPDATE ai_tool_executions SET conversation_id = ?1, message_id = ?2, tool_call_id = ?3, tool_name = ?4, arguments = ?5, result = ?6, status = ?7, risk_level = ?8, requested_at = ?9, executed_at = ?10, decided_by = ?11, duration_ms = ?12 WHERE id = ?13",
params![ params![
rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name,
rec.arguments, rec.result, rec.status, rec.risk_level, rec.arguments, rec.result, rec.status, rec.risk_level,
rec.requested_at, rec.executed_at, rec.decided_by, rec.id rec.requested_at, rec.executed_at, rec.decided_by, rec.duration_ms, rec.id
], ],
) )
} }
@@ -298,15 +302,15 @@ impl AiToolExecutionRepo {
let tx = guard.transaction().map_err(storage_err)?; let tx = guard.transaction().map_err(storage_err)?;
{ {
let mut stmt = tx.prepare( let mut stmt = tx.prepare(
"INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by) "INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by, duration_ms)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
) )
.map_err(storage_err)?; .map_err(storage_err)?;
for rec in &records { for rec in &records {
stmt.execute(params![ stmt.execute(params![
rec.id, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name, rec.id, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name,
rec.arguments, rec.result, rec.status, rec.risk_level, rec.arguments, rec.result, rec.status, rec.risk_level,
rec.requested_at, rec.executed_at, rec.decided_by rec.requested_at, rec.executed_at, rec.decided_by, rec.duration_ms
]) ])
.map_err(storage_err)?; .map_err(storage_err)?;
} }
@@ -839,6 +843,29 @@ impl AiConversationRepo {
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 会话终局态写入(V50 status)。
///
/// status: "active"(默认/进行中/正常完成)、"incomplete"(停靠/断流等未完成终局)、
/// "error"(循环错误终局)。纯标记不进 update_full(防 save_conversation 高频全量写覆盖),
/// 对齐 set_loop_state 模式;不动 updated_at。对话未落库(懒创建)时影响 0 行,调用方忽略。
pub async fn set_status(&self, id: &str, status: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let status = status.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_conversations SET status = ?1 WHERE id = ?2",
params![status, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 重启扫描:把所有 loop_state='running' 残留改写为 'parked'。 /// 重启扫描:把所有 loop_state='running' 残留改写为 'parked'。
/// ///
/// 进程启动时调用一次:上次运行中 loop_state=running 的会话,进程重启后 loop 必死 /// 进程启动时调用一次:上次运行中 loop_state=running 的会话,进程重启后 loop 必死
@@ -1020,6 +1047,7 @@ mod tests {
requested_at: "0".into(), requested_at: "0".into(),
executed_at: None, executed_at: None,
decided_by: None, decided_by: None,
duration_ms: None,
} }
} }
@@ -1068,6 +1096,43 @@ mod tests {
assert_eq!(got2.unwrap().id, "a2", "同三元组应返回最新落定记录"); assert_eq!(got2.unwrap().id, "a2", "同三元组应返回最新落定记录");
} }
/// D2a 结果外置的读写兼容契约:result 列由外置层(audit 侧)决定写内联还是写句柄,
/// 存储层既不解释也不改写——老行(外置落地前写入)是内联全文,读回必须原样;新行的
/// 外置句柄 JSON 同样原样存取。锁死「旧行仍读表内全文」:存储层若对 result 做截断/
/// 外置/解析等 mutation,会同时破坏两侧兼容(旧行读残缺 + 句柄被误当正文)。
#[tokio::test]
async fn tool_execution_result_stored_verbatim_old_and_new() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiToolExecutionRepo::new(&db);
// 旧数据:内联全文。超大值也要能原样读回(D2a 落地前的历史大结果就长这样,存储层不得追改)
let legacy_big = "A".repeat(10 * 1024);
let mut old_big = audit_rec("old_big", "conv1", "call_old_big", "read_file", "{}", "completed");
old_big.result = Some(legacy_big.clone());
repo.insert(old_big).await.expect("insert old big");
let mut old_small = audit_rec("old_small", "conv2", "call_old_small", "write_file", "{}", "completed");
old_small.result = Some("{\"ok\":true}".to_string());
repo.insert(old_small).await.expect("insert old small");
// 新数据:外置句柄 JSON(存储层不解析,当普通 TEXT 存取)
let handle = r#"{"__offloaded__":true,"path":"/tmp/x.txt","size_bytes":20480}"#;
let mut new_off = audit_rec("new_off", "conv3", "call_new", "run_command", "{}", "completed");
new_off.result = Some(handle.to_string());
repo.insert(new_off).await.expect("insert new offloaded");
// 读回:三条各自原样(旧行全文;句柄不被误解析/清空)
let got_old_big = repo.get_by_id("old_big").await.expect("get").expect("row");
assert_eq!(got_old_big.result.as_deref(), Some(legacy_big.as_str()), "老行超大内联 result 须原样读回");
let got_old_small = repo.get_by_id("old_small").await.expect("get").expect("row");
assert_eq!(got_old_small.result.as_deref(), Some("{\"ok\":true}"));
let got_new = repo.get_by_id("new_off").await.expect("get").expect("row");
assert_eq!(got_new.result.as_deref(), Some(handle), "外置句柄 JSON 须原样存取");
// 专用查询路径(按 tool_call_id 定位)同样原样,与通用 get_by_id 口径一致
let by_tc = repo.find_by_tool_call_id("call_old_big").await.expect("query").expect("row");
assert_eq!(by_tc.result.as_deref(), Some(legacy_big.as_str()));
}
/// insert_checkpoint 冲突语义:同 id 二次写入 Ok 不报错(幂等跳过,affected=0 走 warn), /// insert_checkpoint 冲突语义:同 id 二次写入 Ok 不报错(幂等跳过,affected=0 走 warn),
/// 保留首次 snapshot。锁死 OR IGNORE 行为,防未来误改成 REPLACE 静默覆盖快照。 /// 保留首次 snapshot。锁死 OR IGNORE 行为,防未来误改成 REPLACE 静默覆盖快照。
#[tokio::test] #[tokio::test]
@@ -1094,4 +1159,81 @@ mod tests {
.expect("snapshot"); .expect("snapshot");
assert_eq!(snapshot, r#"[{"seq":0}]"#, "冲突时应保留首次快照"); assert_eq!(snapshot, r#"[{"seq":0}]"#, "冲突时应保留首次快照");
} }
/// #904 契约:set_loop_state 写 "parked" 后 get_by_id 可读回(停靠出口写侧单点)。
/// 锁死「停靠状态写入收敛进 emit_task_parked」依赖的 repo 能力——若 set_loop_state
/// 或 from_row 的 loop_state 映射回归,停靠卡「继续」将再次撞「AI 未在暂停态」。
#[tokio::test]
async fn set_loop_state_parked_roundtrip() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiConversationRepo::new(&db);
repo.insert(AiConversationRecord {
id: "c1".into(),
title: None,
messages: "[]".into(),
provider_id: None,
model: None,
models: None,
archived: false,
pinned: false,
prompt_tokens: Some(0),
completion_tokens: Some(0),
pinned_goals: None,
pending_approvals: None,
created_at: "0".into(),
updated_at: "0".into(),
origin: None,
boundary_json: None,
loop_state: None,
status: "active".into(),
})
.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")?, prompt_cache_miss_tokens: row.get("prompt_cache_miss_tokens")?,
reasoning_tokens: row.get("reasoning_tokens")?, reasoning_tokens: row.get("reasoning_tokens")?,
is_estimated: row.get("is_estimated")?, is_estimated: row.get("is_estimated")?,
source: row.get("source")?,
}) })
} }
@@ -87,8 +88,8 @@ impl AiMessageRepo {
(id, conversation_id, seq, role, content, parts, tool_call_id, (id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens, prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated) prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated, source)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
) )
.map_err(storage_err)?; .map_err(storage_err)?;
for rec in &records { for rec in &records {
@@ -97,7 +98,7 @@ impl AiMessageRepo {
rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status, rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status,
rec.reasoning_content, rec.timestamp, rec.created_at, rec.reasoning_content, rec.timestamp, rec.created_at,
rec.prompt_tokens, rec.completion_tokens, rec.prompt_tokens, rec.completion_tokens,
rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens, rec.is_estimated rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens, rec.is_estimated, rec.source
]) ])
.map_err(storage_err)?; .map_err(storage_err)?;
} }
@@ -124,7 +125,7 @@ impl AiMessageRepo {
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens, prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens,
is_estimated is_estimated, source
FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq ASC", FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq ASC",
) )
.map_err(storage_err)?; .map_err(storage_err)?;
@@ -171,13 +172,13 @@ impl AiMessageRepo {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id, "SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens, prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated, source
FROM ai_messages WHERE conversation_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3" FROM ai_messages WHERE conversation_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3"
} else { } else {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id, "SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens, prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated, source
FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq DESC LIMIT ?2" FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq DESC LIMIT ?2"
}; };
let mut stmt = guard.prepare(sql).map_err(storage_err)?; let mut stmt = guard.prepare(sql).map_err(storage_err)?;
@@ -296,8 +297,8 @@ impl AiMessageRepo {
(id, conversation_id, seq, role, content, parts, tool_call_id, (id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens, prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated) prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated, source)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
) )
.map_err(storage_err)?; .map_err(storage_err)?;
for rec in &records { for rec in &records {
@@ -306,7 +307,7 @@ impl AiMessageRepo {
rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status, rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status,
rec.reasoning_content, rec.timestamp, rec.created_at, rec.reasoning_content, rec.timestamp, rec.created_at,
rec.prompt_tokens, rec.completion_tokens, rec.prompt_tokens, rec.completion_tokens,
rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens, rec.is_estimated rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens, rec.is_estimated, rec.source
]) ])
.map_err(storage_err)?; .map_err(storage_err)?;
} }
@@ -381,6 +382,7 @@ mod tests {
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None, is_estimated: None,
source: None,
} }
} }
@@ -544,6 +546,7 @@ mod tests {
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None, is_estimated: None,
source: None,
}, },
AiMessageRecord { AiMessageRecord {
id: "new_1".into(), id: "new_1".into(),
@@ -565,6 +568,7 @@ mod tests {
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None, is_estimated: None,
source: None,
}, },
]; ];
repo.replace_conversation("conv", records).await.expect("replace"); repo.replace_conversation("conv", records).await.expect("replace");
@@ -638,6 +642,7 @@ mod tests {
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None, is_estimated: None,
source: None,
}], }],
) )
.await .await
@@ -680,6 +685,7 @@ mod tests {
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None, is_estimated: None,
source: None,
}; };
repo.replace_conversation("c", vec![rec()]).await.expect("1st"); repo.replace_conversation("c", vec![rec()]).await.expect("1st");
repo.replace_conversation("c", vec![rec()]).await.expect("2nd"); repo.replace_conversation("c", vec![rec()]).await.expect("2nd");
+4
View File
@@ -14,6 +14,7 @@
//! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具 //! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具
//! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22) //! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22)
//! - [`mod@message_repo`]:AiMessageRepo(消息拆分存储,全专用方法不走宏) //! - [`mod@message_repo`]:AiMessageRepo(消息拆分存储,全专用方法不走宏)
//! - [`mod@outbox_repo`]:OutboxRepo(待发箱 ai_outbox,V51,含 claim CAS/reset_stale_sending,手写不走宏)
//! //!
//! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` / //! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` /
//! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。 //! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。
@@ -25,6 +26,7 @@ mod conversation_repo;
mod idea_eval_repo; mod idea_eval_repo;
mod idea_repo; mod idea_repo;
mod message_repo; mod message_repo;
mod outbox_repo;
mod project_event_repo; mod project_event_repo;
mod project_module_repo; mod project_module_repo;
mod project_repo; mod project_repo;
@@ -38,6 +40,7 @@ pub use conversation_repo::*;
pub use idea_eval_repo::*; pub use idea_eval_repo::*;
pub use idea_repo::*; pub use idea_repo::*;
pub use message_repo::*; pub use message_repo::*;
pub use outbox_repo::*;
pub use project_event_repo::*; pub use project_event_repo::*;
pub use project_module_repo::*; pub use project_module_repo::*;
pub use project_repo::*; pub use project_repo::*;
@@ -72,6 +75,7 @@ macro_rules! impl_repo {
update => |$u_conn:ident, $u_rec:ident| $update_body:expr update => |$u_conn:ident, $u_rec:ident| $update_body:expr
) => { ) => {
$(#[$meta])* $(#[$meta])*
#[derive(Clone)]
pub struct $name { pub struct $name {
conn: Arc<Mutex<Connection>>, conn: Arc<Mutex<Connection>>,
} }
+619
View File
@@ -0,0 +1,619 @@
//! 待发箱 Repo:OutboxRepo(ai_outbox 表,V51)
//!
//! 队列与定时消息统一实体的持久层。因需按 id 逐条 CAS 更新(claim)与状态条件扫描,
//! 不走 `impl_repo!` 宏,手写(同 `SettingsRepo` 范式)。
//!
//! 设计见 docs/02-架构设计/专项设计/定时消息与待发箱持久化-设计-2026-10-10.md §3.2。
//!
//! 状态落点纪律(V21 教训):每项状态由调度器按项逐条 CAS 写,不依赖全局内存推算;
//! `reset_stale_sending` 在启动时把崩溃残留的 `sending` 复位为 `pending`,镜像无人值守引擎的
//! `in_flight` 泄漏处置。
use std::sync::Arc;
use rusqlite::{params, Connection, OptionalExtension, Row};
use tokio::sync::Mutex;
use df_types::error::Result;
use crate::db::Database;
use crate::models::AiOutboxRecord;
use super::storage_err;
// ============================================================
// 状态 / 类型字面量(单一真值来源,避免调度器与 Repo 各写一份裸串漂移)
// ============================================================
/// 待派发(初始态)。
pub const STATUS_PENDING: &str = "pending";
/// 已被调度器 CAS 占取、正在派发。
pub const STATUS_SENDING: &str = "sending";
/// 受理成功(已交给正典发送链)。
pub const STATUS_SENT: &str = "sent";
/// 受理失败且不再自动重试。
pub const STATUS_FAILED: &str = "failed";
/// 用户取消 / 清空。
pub const STATUS_CANCELED: &str = "canceled";
/// 尽快发(kind=queue,due_at 为空)。
pub const KIND_QUEUE: &str = "queue";
/// 定时发(kind=scheduled,due_at 为 epoch 毫秒)。
pub const KIND_SCHEDULED: &str = "scheduled";
/// 终态集合(不再被调度器扫描)。
const DONE_STATUSES: &str = "'sent','failed','canceled'";
fn outbox_from_row(row: &Row<'_>) -> std::result::Result<AiOutboxRecord, rusqlite::Error> {
Ok(AiOutboxRecord {
id: row.get("id")?,
conversation_id: row.get("conversation_id")?,
kind: row.get("kind")?,
text: row.get("text")?,
skill: row.get("skill")?,
model_override: row.get("model_override")?,
parts_json: row.get("parts_json")?,
spans_json: row.get("spans_json")?,
due_at: row.get("due_at")?,
status: row.get("status")?,
attempts: row.get("attempts")?,
last_error: row.get("last_error")?,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
sent_at: row.get("sent_at")?,
})
}
/// 待发箱 Repo(表 `ai_outbox`)。
pub struct OutboxRepo {
conn: Arc<Mutex<Connection>>,
}
impl OutboxRepo {
pub fn new(db: &Database) -> Self {
Self { conn: db.conn() }
}
/// 插入一条待发项,返回 id。created_at/updated_at 由本方法取当前毫秒覆盖
/// (写入时机即落库时机,调用方传入的不可信)。
pub async fn insert(&self, record: AiOutboxRecord) -> Result<String> {
let conn = self.conn.clone();
let now = df_types::now_millis();
let mut rec = record;
rec.created_at = now;
rec.updated_at = now;
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let id = rec.id.clone();
guard
.execute(
"INSERT INTO ai_outbox
(id, conversation_id, kind, text, skill, model_override, parts_json,
spans_json, due_at, status, attempts, last_error, created_at, updated_at, sent_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
params![
rec.id, rec.conversation_id, rec.kind, rec.text, rec.skill,
rec.model_override, rec.parts_json, rec.spans_json, rec.due_at,
rec.status, rec.attempts, rec.last_error, rec.created_at, rec.updated_at,
rec.sent_at
],
)
.map_err(storage_err)?;
Ok(id)
})
.await
.map_err(storage_err)?
}
/// 按 id 取单条(IPC 回显 / 测试核验用)。
pub async fn get_by_id(&self, id: &str) -> Result<Option<AiOutboxRecord>> {
let conn = self.conn.clone();
let id = id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let rec = guard
.query_row("SELECT * FROM ai_outbox WHERE id = ?1", params![id], outbox_from_row)
.optional()
.map_err(storage_err)?;
Ok(rec)
})
.await
.map_err(storage_err)?
}
/// 按会话取待发视图(前端 `state.queue` 的来源)。`include_done=false` 时排除终态,
/// 按 created_at 升序(先入先出,保序)。
pub async fn list_by_conv(
&self,
conversation_id: &str,
include_done: bool,
) -> Result<Vec<AiOutboxRecord>> {
let conn = self.conn.clone();
let conversation_id = conversation_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let sql = if include_done {
"SELECT * FROM ai_outbox WHERE conversation_id = ?1 ORDER BY created_at ASC"
.to_string()
} else {
format!(
"SELECT * FROM ai_outbox WHERE conversation_id = ?1 \
AND status NOT IN ({DONE_STATUSES}) ORDER BY created_at ASC"
)
};
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
let rows = stmt
.query_map(params![conversation_id], outbox_from_row)
.map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
/// 全量列表(跨会话,「定时列表」抽屉数据源)。`include_done=false` 时排除终态,
/// 按 created_at 升序(与 list_by_conv 同序语义)。
pub async fn list_all(&self, include_done: bool) -> Result<Vec<AiOutboxRecord>> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let sql = if include_done {
"SELECT * FROM ai_outbox ORDER BY created_at ASC".to_string()
} else {
format!(
"SELECT * FROM ai_outbox \
WHERE status NOT IN ({DONE_STATUSES}) ORDER BY created_at ASC"
)
};
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
let rows = stmt.query_map([], outbox_from_row).map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
/// 取到点的待派发项(status='pending' 且 due_at 为空或已到),按 created_at 升序,至多 limit 条。
/// 命中 idx_ai_outbox_due。
pub async fn list_due(&self, now_ms: i64, limit: i64) -> Result<Vec<AiOutboxRecord>> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
"SELECT * FROM ai_outbox \
WHERE status = 'pending' AND (due_at IS NULL OR due_at <= ?1) \
ORDER BY created_at ASC LIMIT ?2",
)
.map_err(storage_err)?;
let rows = stmt
.query_map(params![now_ms, limit], outbox_from_row)
.map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
/// CAS 占取:仅当项仍为 pending 时置 sending。changes==1 才返回 true,
/// 防并发双取(多轮/多消费方同取同一条时只有一个成功)。
pub async fn claim(&self, id: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_outbox SET status = 'sending', updated_at = ?1 \
WHERE id = ?2 AND status = 'pending'",
params![now, id],
)
.map_err(storage_err)?;
Ok(affected == 1)
})
.await
.map_err(storage_err)?
}
/// 标记受理成功(终态)。清空 last_error。
pub async fn mark_sent(&self, id: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_outbox SET status = 'sent', sent_at = ?1, updated_at = ?1, \
last_error = NULL WHERE id = ?2",
params![now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 标记永久失败(终态,不再自动重试)。
pub async fn mark_failed(&self, id: &str, err: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let err = err.to_owned();
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_outbox SET status = 'failed', last_error = ?1, updated_at = ?2 \
WHERE id = ?3",
params![err, now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 瞬时失败回退:attempts+1、状态回 pending、记 last_error,返回新的 attempts。
/// 保留下轮再试(网络超时/会话忙场景天然重试),不删项——对齐「错误不清队列」纪律。
pub async fn revert_pending(&self, id: &str, err: Option<&str>) -> Result<i64> {
let conn = self.conn.clone();
let id = id.to_owned();
let err = err.map(|s| s.to_owned());
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
guard
.execute(
"UPDATE ai_outbox SET status = 'pending', attempts = attempts + 1, \
last_error = ?1, updated_at = ?2 WHERE id = ?3",
params![err, now, id],
)
.map_err(storage_err)?;
let attempts: i64 = guard
.query_row(
"SELECT attempts FROM ai_outbox WHERE id = ?1",
params![id],
|r| r.get(0),
)
.map_err(storage_err)?;
Ok(attempts)
})
.await
.map_err(storage_err)?
}
/// 改期:更新 due_at(NULL=改为尽快)。
pub async fn reschedule(&self, id: &str, due_at: Option<i64>) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_outbox SET due_at = ?1, updated_at = ?2 WHERE id = ?3",
params![due_at, now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 手动重试入口:仅 failed → pending(attempts 归零=手动重试给全新预算,清 last_error)。
/// 其余状态不动返回 false——调度器自动重试走 revert_pending(瞬时失败保序),两者互补。
pub async fn requeue(&self, id: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_outbox SET status = 'pending', attempts = 0, last_error = NULL, \
updated_at = ?1 WHERE id = ?2 AND status = 'failed'",
params![now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 取消单项。
pub async fn cancel(&self, id: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_outbox SET status = 'canceled', updated_at = ?1 WHERE id = ?2",
params![now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 编辑待发文本。
pub async fn update_text(&self, id: &str, text: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let text = text.to_owned();
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_outbox SET text = ?1, updated_at = ?2 WHERE id = ?3",
params![text, now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 清空待发(pending→canceled,不物理删——保留行便于审计)。`conv_id=None` 时清全部会话。
/// 返回被取消的行数。
pub async fn clear(&self, conv_id: Option<&str>) -> Result<usize> {
let conn = self.conn.clone();
let conv_id = conv_id.map(|s| s.to_owned());
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = match conv_id {
Some(id) => guard.execute(
"UPDATE ai_outbox SET status = 'canceled', updated_at = ?1 \
WHERE status = 'pending' AND conversation_id = ?2",
params![now, id],
),
None => guard.execute(
"UPDATE ai_outbox SET status = 'canceled', updated_at = ?1 \
WHERE status = 'pending'",
params![now],
),
}
.map_err(storage_err)?;
Ok(affected)
})
.await
.map_err(storage_err)?
}
/// 启动复位:把崩溃半途残留的 `sending` 全部复位为 `pending`(进程重启时前者必为陈旧值)。
/// 返回复位的行数。镜像无人值守引擎的 in_flight 泄漏处置。
pub async fn reset_stale_sending(&self) -> Result<usize> {
let conn = self.conn.clone();
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_outbox SET status = 'pending', updated_at = ?1 WHERE status = 'sending'",
params![now],
)
.map_err(storage_err)?;
Ok(affected)
})
.await
.map_err(storage_err)?
}
}
// ============================================================
// 单元测试 — 迁移可重入 / CAS 互斥 / sender 残留复位(内存 DB)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
/// 构造一条 pending 待发项(due_at=None=尽快)。
fn rec(id: &str, conv: &str) -> AiOutboxRecord {
AiOutboxRecord {
id: id.to_string(),
conversation_id: conv.to_string(),
kind: KIND_QUEUE.to_string(),
text: format!("msg-{id}"),
skill: None,
model_override: None,
parts_json: None,
spans_json: None,
due_at: None,
status: STATUS_PENDING.to_string(),
attempts: 0,
last_error: None,
created_at: 0,
updated_at: 0,
sent_at: None,
}
}
async fn setup() -> (Database, OutboxRepo) {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = OutboxRepo::new(&db);
(db, repo)
}
/// V51 迁移可重入:全链在已建表库上重跑 again 不报错,ai_outbox 表与两索引仍在。
#[tokio::test]
async fn migration_v51_is_reentrant() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let conn = db.conn();
let guard = conn.lock().await;
// 首次已由 open_in_memory 跑过;再显式重跑一遍全链(模拟版本号回退/崩溃重跑)。
crate::migrations::run(&guard).expect("重跑迁移不应失败(幂等)");
crate::migrations::run(&guard).expect("二次重跑仍应幂等");
// 表存在
let has_table: bool = guard
.query_row(
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='ai_outbox'",
[],
|r| r.get(0),
)
.unwrap();
assert!(has_table, "ai_outbox 表应存在");
// 两索引存在
for idx in ["idx_ai_outbox_due", "idx_ai_outbox_conv"] {
let has_idx: bool = guard
.query_row(
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='index' AND name=?1",
params![idx],
|r| r.get(0),
)
.unwrap();
assert!(has_idx, "索引 {idx} 应存在");
}
}
/// claim CAS 互斥:同一条只被占取一次,二次 claim 返回 false。
#[tokio::test]
async fn claim_is_mutually_exclusive() {
let (_db, repo) = setup().await;
repo.insert(rec("o1", "conv-1")).await.unwrap();
assert!(repo.claim("o1").await.unwrap(), "首次 claim 应成功");
assert!(!repo.claim("o1").await.unwrap(), "二次 claim 应失败(已非 pending)");
let got = repo.get_by_id("o1").await.unwrap().unwrap();
assert_eq!(got.status, STATUS_SENDING);
}
/// reset_stale_sending 复位崩溃残留:sending → pending,计数正确。
#[tokio::test]
async fn reset_stale_sending_recovers_crash_residue() {
let (_db, repo) = setup().await;
repo.insert(rec("o1", "conv-1")).await.unwrap();
repo.insert(rec("o2", "conv-1")).await.unwrap();
// 模拟两条被占取后进程崩溃(残留 sending)
assert!(repo.claim("o1").await.unwrap());
assert!(repo.claim("o2").await.unwrap());
assert_eq!(repo.get_by_id("o1").await.unwrap().unwrap().status, STATUS_SENDING);
let recovered = repo.reset_stale_sending().await.unwrap();
assert_eq!(recovered, 2, "两条残留 sending 应被复位");
assert_eq!(repo.get_by_id("o1").await.unwrap().unwrap().status, STATUS_PENDING);
assert_eq!(repo.get_by_id("o2").await.unwrap().unwrap().status, STATUS_PENDING);
}
/// list_due:只取到点的 pending(尽快项恒命中,未到点定时项不取),按 created_at 升序。
#[tokio::test]
async fn list_due_respects_due_and_order() {
let (_db, repo) = setup().await;
repo.insert(rec("o1", "conv-1")).await.unwrap();
// 未到点定时项(远未来)
let mut future = rec("o2", "conv-1");
future.kind = KIND_SCHEDULED.to_string();
future.due_at = Some(9_999_999_999_999);
repo.insert(future).await.unwrap();
// 已到点定时项
let mut due = rec("o3", "conv-1");
due.kind = KIND_SCHEDULED.to_string();
due.due_at = Some(0);
repo.insert(due).await.unwrap();
let got = repo.list_due(df_types::now_millis(), 32).await.unwrap();
let ids: Vec<&str> = got.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, vec!["o1", "o3"], "仅到点的 pending,按 created_at 升序");
}
/// revert_pending:瞬时失败回退 pending + attempts 自增 + 记 last_error,项不丢。
#[tokio::test]
async fn revert_pending_keeps_item_and_bumps_attempts() {
let (_db, repo) = setup().await;
repo.insert(rec("o1", "conv-1")).await.unwrap();
repo.claim("o1").await.unwrap();
let n = repo.revert_pending("o1", Some("boom")).await.unwrap();
assert_eq!(n, 1);
let got = repo.get_by_id("o1").await.unwrap().unwrap();
assert_eq!(got.status, STATUS_PENDING, "回退后应可再次被调度");
assert_eq!(got.attempts, 1);
assert_eq!(got.last_error.as_deref(), Some("boom"));
}
/// cancel/clear 只动 pending:已终态项不受影响,clear 按会话范围限定。
#[tokio::test]
async fn clear_cancels_only_pending_in_scope() {
let (_db, repo) = setup().await;
repo.insert(rec("o1", "conv-1")).await.unwrap();
repo.insert(rec("o2", "conv-2")).await.unwrap();
repo.mark_sent("o1").await.unwrap();
let n = repo.clear(Some("conv-1")).await.unwrap();
assert_eq!(n, 0, "conv-1 已无 pending(sent 不算),不误改");
assert_eq!(repo.get_by_id("o1").await.unwrap().unwrap().status, STATUS_SENT);
let n = repo.clear(Some("conv-2")).await.unwrap();
assert_eq!(n, 1);
assert_eq!(repo.get_by_id("o2").await.unwrap().unwrap().status, STATUS_CANCELED);
}
/// list_all:跨会话全量返回;include_done=false 排除终态,created_at 升序。
#[tokio::test]
async fn list_all_covers_convs_and_filters_done() {
let (_db, repo) = setup().await;
repo.insert(rec("o1", "conv-1")).await.unwrap();
repo.insert(rec("o2", "conv-2")).await.unwrap();
let mut done = rec("o3", "conv-1");
done.status = STATUS_SENT.to_string();
repo.insert(done).await.unwrap();
let all = repo.list_all(true).await.unwrap();
assert_eq!(all.len(), 3, "include_done=true 全量(含跨会话)");
let active = repo.list_all(false).await.unwrap();
let ids: Vec<&str> = active.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, vec!["o1", "o2"], "排除终态后仅两条 pending,升序");
}
/// requeue:仅 failed → pending 且 attempts 归零清错;pending/sent 不动返 false。
#[tokio::test]
async fn requeue_only_resets_failed() {
let (_db, repo) = setup().await;
// o1:pending → claim(占取)→ revert(attempts=1)→ mark_failed(failed,attempts 保留 1)
repo.insert(rec("o1", "conv-1")).await.unwrap();
repo.claim("o1").await.unwrap();
let attempts = repo.revert_pending("o1", Some("boom")).await.unwrap();
assert_eq!(attempts, 1);
repo.mark_failed("o1", "boom").await.unwrap();
assert!(repo.requeue("o1").await.unwrap(), "failed 应可重入队");
let got = repo.get_by_id("o1").await.unwrap().unwrap();
assert_eq!(got.status, STATUS_PENDING);
assert_eq!(got.attempts, 0, "手动重试给全新预算");
assert_eq!(got.last_error, None, "重入队清上次错误");
// pending 项:requeue 不动(返回 false)
repo.insert(rec("o2", "conv-1")).await.unwrap();
assert!(!repo.requeue("o2").await.unwrap(), "pending 非 failed,requeue 不动");
// sent 终态:requeue 不动
repo.insert(rec("o3", "conv-1")).await.unwrap();
repo.mark_sent("o3").await.unwrap();
assert!(!repo.requeue("o3").await.unwrap(), "sent 终态不可重入队");
}
}
+28
View File
@@ -249,6 +249,34 @@ impl TaskRepo {
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 判定任务是否在回收站(soft_delete 已设 deleted_at)。
///
/// 专供推进链 CAS 失败后的**原因区分**:`advance_status_atomic` 的 CAS SQL 带
/// `deleted_at IS NULL` 条件,软删任务必然 affected==0 返回 None;调用方据此
/// 回查本方法,把「已变更(并发推进或旁路修改)」这个误导性错误,精确改写为
/// 「任务在回收站」,让 LLM/用户知道该先恢复而非重试。
///
/// 返回 true = 任务存在且 deleted_at 非 NULL(在回收站);false = 不存在或未删。
/// 单列轻查询(SELECT 1),不拉全记录,失败回查开销可忽略。
pub async fn is_deleted(&self, id: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare("SELECT 1 FROM tasks WHERE id = ?1 AND deleted_at IS NOT NULL")
.map_err(storage_err)?;
let found = stmt
.query_row(params![id], |_| Ok(()))
.optional()
.map_err(storage_err)?
.is_some();
Ok(found)
})
.await
.map_err(storage_err)?
}
/// 按 id 查未删除任务(`WHERE id AND deleted_at IS NULL`)。 /// 按 id 查未删除任务(`WHERE id AND deleted_at IS NULL`)。
/// ///
/// 专供 [`validate_parent_id`](super::task_validation) 的父任务存在性检查: /// 专供 [`validate_parent_id`](super::task_validation) 的父任务存在性检查:
+284 -7
View File
@@ -1,7 +1,7 @@
//! 数据库迁移 — 建表 SQL 与版本管理 //! 数据库迁移 — 建表 SQL 与版本管理
//! 数据库迁移 — 建表 SQL 与版本管理 //! 数据库迁移 — 建表 SQL 与版本管理
use crate::migrations_helpers::column_exists; use crate::migrations_helpers::{column_exists, table_exists};
use anyhow::Result; use anyhow::Result;
use rusqlite::Connection; use rusqlite::Connection;
@@ -59,7 +59,13 @@ pub fn run(conn: &Connection) -> Result<()> {
// V48 = 项目归档(projects.archived 列,归档功能 P2,同款三方对齐)。 // V48 = 项目归档(projects.archived 列,归档功能 P2,同款三方对齐)。
// V49 = loop 生命周期标记(ai_conversations.loop_state TEXT,断点续跑底座: // V49 = loop 生命周期标记(ai_conversations.loop_state TEXT,断点续跑底座:
// running/parked,重启扫描 running→parked 改写,侧栏「可恢复」标记)。 // running/parked,重启扫描 running→parked 改写,侧栏「可恢复」标记)。
let steps: [(i32, fn(&Connection) -> Result<()>); 49] = [ // V50 = 使用数据分析 schema 债补全(三项,详见 migrate_v50 文档):
// ai_conversations.status 会话终局态 / ai_tool_executions.duration_ms 纯执行耗时 /
// conversation_checkpoints.token_total 语义回填。
// V51 = 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基):队列/定时统一实体,
// 调度器按 status+due_at 索引到点派发。纯新增表,可逆=DROP TABLE ai_outbox。
// V52 = ai_messages.source 列(#958 消息溯源:外部渠道原始消息 ID JSON,NULL=桌面本端)。
let steps: [(i32, fn(&Connection) -> Result<()>); 52] = [
(1, migrate_v1), (1, migrate_v1),
(2, migrate_v2), (2, migrate_v2),
(3, migrate_v3), (3, migrate_v3),
@@ -113,6 +119,12 @@ pub fn run(conn: &Connection) -> Result<()> {
(48, migrate_v48), (48, migrate_v48),
// V49 = loop 生命周期标记(ai_conversations.loop_state,断点续跑底座)。 // V49 = loop 生命周期标记(ai_conversations.loop_state,断点续跑底座)。
(49, migrate_v49), (49, migrate_v49),
// V50 = 使用数据分析 schema 债补全(status/duration_ms/token_total 三项)。
(50, migrate_v50),
// V51 = 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基)。
(51, migrate_v51),
// V52 = ai_messages 加 source 列(#958 消息溯源:飞书等外部渠道原始消息 ID)。
(52, migrate_v52),
]; ];
for (version, migrate_fn) in steps { for (version, migrate_fn) in steps {
@@ -1450,6 +1462,184 @@ fn migrate_v49(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
/// V50: 使用数据分析 schema 债补全(2026-10-04 实证三笔,归「V49 schema 迁移批」)。
///
/// 一次迁移承载三项相关改动,均为纯粹加列/回填,不改既有列语义:
///
/// ① ai_conversations.status TEXT NOT NULL DEFAULT 'active' —— 会话终局态。
/// 原仅靠侧栏内消息尾部推断「是否收尾」(17/89 会话停在 tool 结果无 assistant 收尾),
/// 无法直接统计未完成率。新增列建模终局:
/// active=进行中/默认(含正常完成,完成态精确写入待收尾单点接驳),incomplete=停靠/
/// 断流等未完成终局,error=循环错误终局。与 loop_state(运行期 running/parked)正交:
/// loop_state 管「能否续跑」,status 管「会话如何结束」。
/// ② ai_tool_executions.duration_ms INTEGER —— 纯执行耗时(毫秒)。
/// 原审计只有 requested_at→executed_at 区间,该区间在审批路径混入「等待人工批准」
/// 时长,耗时统计不可信。本列专记工具真正执行的墙钟毫秒(执行前→执行后),与审批
/// 等待解耦;NULL=老数据/未测路径(向后兼容)。
/// ③ conversation_checkpoints.token_total 回填 —— 原写入侧误用「消息条数」当 token 总量
/// (p50=120 vs 真实数万级,差 3 个量级)。写入侧已修为真实 token 累加(见 save 侧);
/// 本迁移对历史行按 snapshot 内逐条 prompt_tokens+completion_tokens 重算回填,使存量
/// 可比。仅当重算结果 > 0 才写(N 老快照无消息级 token 时保留原值不误清零)。
///
/// - **幂等**:①②`column_exists` 守卫,重复执行不报 duplicate column;③重算确定(同输入同输出),
/// 反复执行结果一致,且经 schema_version 版本号闸只跑一次。
/// - **可逆**:①`ALTER TABLE ai_conversations DROP COLUMN status`;
/// ②`ALTER TABLE ai_tool_executions DROP COLUMN duration_ms`;
/// ③无回退(DROP COLUMN 不会还原被覆盖的 token_total 旧值)——如需回退重建快照表
/// (老值本就失真无保留价值)。
fn migrate_v50(conn: &Connection) -> Result<()> {
// ① 会话终局态列(NOT NULL 常量默认,SQLite ALTER 合法)。
if table_exists(conn, "ai_conversations") && !column_exists(conn, "ai_conversations", "status") {
conn.execute(
"ALTER TABLE ai_conversations ADD COLUMN status TEXT NOT NULL DEFAULT 'active'",
[],
)?;
tracing::info!("v50: 补建 ai_conversations.status 列(会话终局态)");
}
// ② 纯执行耗时列(NULL=老数据/未测路径)。
if table_exists(conn, "ai_tool_executions")
&& !column_exists(conn, "ai_tool_executions", "duration_ms")
{
conn.execute(
"ALTER TABLE ai_tool_executions ADD COLUMN duration_ms INTEGER",
[],
)?;
tracing::info!("v50: 补建 ai_tool_executions.duration_ms 列(纯执行耗时)");
}
// ③ 存量 checkpoint token_total 按 snapshot 重算回填。
backfill_checkpoint_token_total(conn)?;
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [50])?;
tracing::info!("迁移 v50 完成: status/duration_ms 加列 + checkpoint token_total 回填");
Ok(())
}
/// V51: 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基)。
///
/// 对标 docs/02-架构设计/专项设计/定时消息与待发箱持久化-设计-2026-10-10.md §3.1。
/// 队列(931)与定时消息(935)本质是同一实体、不同触发条件,统一落本表:
/// 队列项 due_at=NULL(会话空闲即发),定时项 due_at=epoch_ms(到点且空闲即发)。
///
/// 选独立表而非 app_settings KV:outbox 需按 status+due_at 索引扫描、按 id 逐条 CAS 更新,
/// KV 整块 JSON 读改写有并发改期/取消互相覆盖的风险且不可索引。
///
/// - **幂等**:table_exists 守卫建表(同 V29/V30/V31 模式),索引 CREATE INDEX IF NOT EXISTS
/// 原生幂等;版本号 INSERT OR IGNORE 防崩溃重跑 PK 冲突。全链重跑/断点续跑均安全。
/// - **可逆**(回退方式,当前不执行):本表纯新增、不触碰旧数据,回退即
/// `DROP TABLE ai_outbox`(同时关调度器开关 df-outbox-enabled=false),无需数据搬迁。
/// - **状态落点**:pending|sending|sent|failed|canceled 由调度器按项逐条 CAS 写,不依赖全局
/// 内存推算(V21 教训:全局 COUNT 幂等窗口中途崩溃致永久残缺)。崩溃残留的 sending 由
/// 调度器启动时 reset_stale_sending 复位为 pending。
/// 迁移链顶点版本(单一真值来源):**新增迁移时必须同步 +1**,否则下方迁移测试会失败
/// (测试用它断言「全量迁移后 schema_version == 顶点」「重跑仍到顶点」——既防链中断,也免多处写死)。
pub const LATEST_SCHEMA_VERSION: i32 = 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 表 /// V21 建表 SQL — 消息拆分存储 ai_messages 表
/// ///
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。 /// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
@@ -2478,9 +2668,9 @@ mod tests {
) )
.expect("查 schema_version 应成功"); .expect("查 schema_version 应成功");
assert_eq!( assert_eq!(
max_version, 49, max_version, LATEST_SCHEMA_VERSION as i64,
"全量迁移后 schema_version 应为 49(实际 {}),说明某条 migrate_vN 链路断在中间", "全量迁移后 schema_version 应为顶点 {}(实际 {}),说明某条 migrate_vN 链路断在中间",
max_version LATEST_SCHEMA_VERSION, max_version
); );
// 4. V40 抽查:project_modules 表 description / status 列存在(老库 ALTER 补,新库 V40 也跑) // 4. V40 抽查:project_modules 表 description / status 列存在(老库 ALTER 补,新库 V40 也跑)
@@ -2556,7 +2746,7 @@ mod tests {
|r| r.get(0), |r| r.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(max_v, 49, "首轮应推进到 49"); assert_eq!(max_v, LATEST_SCHEMA_VERSION as i64, "首轮应推进到 {}", LATEST_SCHEMA_VERSION);
// 清空版本表强制全链第二遍(每步 execute 第二次) // 清空版本表强制全链第二遍(每步 execute 第二次)
conn.execute("DELETE FROM schema_version", []).unwrap(); conn.execute("DELETE FROM schema_version", []).unwrap();
@@ -2568,7 +2758,7 @@ mod tests {
|r| r.get(0), |r| r.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(max_v2, 49, "重跑后应重新推进到 49"); assert_eq!(max_v2, LATEST_SCHEMA_VERSION as i64, "重跑后应重新推进到 {}", LATEST_SCHEMA_VERSION);
} }
// ============================================================ // ============================================================
@@ -2674,4 +2864,91 @@ mod tests {
.unwrap(); .unwrap();
assert!(got.is_some(), "boundary_json 应可写可读"); assert!(got.is_some(), "boundary_json 应可写可读");
} }
/// V50: 三列/回填一次承载,幂等重跑不炸。
/// ① ai_conversations.status 补建默认 'active';
/// ② ai_tool_executions.duration_ms 补建(可空);
/// ③ conversation_checkpoints.token_total 按 snapshot 重算(消息条数→真实 token)。
#[test]
fn v50_adds_columns_and_backfills_token_total() {
let conn = setup_legacy_db();
// 前置:最小库无 status/duration_ms 列(V45 之后建,老库缺)
assert!(!column_exists(&conn, "ai_conversations", "status"));
// V50 需要 checkpoint / tool_executions 表(正常由 V1/V9 建;此处手工建以隔离本迁移被测量)
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS conversation_checkpoints (\
id TEXT PRIMARY KEY, conv_id TEXT NOT NULL, snapshot TEXT NOT NULL,\
token_total INTEGER NOT NULL, label TEXT, created_at TEXT NOT NULL);
INSERT INTO conversation_checkpoints (id, conv_id, snapshot, token_total, created_at)\
VALUES ('ck1','c1',\
'[{\"role\":\"assistant\",\"prompt_tokens\":150000,\"completion_tokens\":20000}]',\
1,'100');
CREATE TABLE IF NOT EXISTS ai_tool_executions (\
id TEXT PRIMARY KEY, tool_call_id TEXT NOT NULL, tool_name TEXT NOT NULL,\
arguments TEXT NOT NULL, requested_at TEXT NOT NULL);",
)
.expect("建 checkpoint/tool_executions 表 + 塞一条失真行");
migrate_v50(&conn).expect("v50 首次迁移应成功");
assert!(column_exists(&conn, "ai_conversations", "status"), "应有 status 列");
assert!(column_exists(&conn, "ai_tool_executions", "duration_ms"), "应有 duration_ms 列");
// ① 默认值 active 生效
conn.execute(
"INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES ('c1','[]','0','0')",
[],
)
.unwrap();
let status: String = conn
.query_row("SELECT status FROM ai_conversations WHERE id = 'c1'", [], |r| r.get(0))
.unwrap();
assert_eq!(status, "active", "新列默认值应为 active");
// ③ 失真 token_total(1)被回填为真实和(150000+20000)
let tt: i64 = conn
.query_row("SELECT token_total FROM conversation_checkpoints WHERE id = 'ck1'", [], |r| r.get(0))
.unwrap();
assert_eq!(tt, 170_000, "应回填为 snapshot 内 token 真实和");
let v: i64 = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, 50, "应写入版本号 50");
// 幂等:列已存在时重跑走 column_exists 短路;回填重算确定(值不变)
migrate_v50(&conn).expect("列已存在时重跑应幂等不报错");
let tt2: i64 = conn
.query_row("SELECT token_total FROM conversation_checkpoints WHERE id = 'ck1'", [], |r| r.get(0))
.unwrap();
assert_eq!(tt2, 170_000, "重跑回填结果应一致");
}
/// V50 回填不误清零:老快照无消息级 token(重算=0)时保留原值(不 UPDATE)。
#[test]
fn v50_backfill_keeps_legacy_when_no_message_tokens() {
let conn = setup_legacy_db();
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS conversation_checkpoints (\
id TEXT PRIMARY KEY, conv_id TEXT NOT NULL, snapshot TEXT NOT NULL,\
token_total INTEGER NOT NULL, label TEXT, created_at TEXT NOT NULL);
INSERT INTO conversation_checkpoints (id, conv_id, snapshot, token_total, created_at)\
VALUES ('ck2','c2','[{\"role\":\"user\",\"content\":\"hi\"}]', 42, '100');",
)
.expect("建表塞老快照");
migrate_v50(&conn).expect("v50 应成功");
let tt: i64 = conn
.query_row("SELECT token_total FROM conversation_checkpoints WHERE id = 'ck2'", [], |r| r.get(0))
.unwrap();
assert_eq!(tt, 42, "无消息级 token 的老快照应保留原值不误清零");
}
/// sum_snapshot_tokens 纯函数:字段缺失/非法 JSON 按 0 计。
#[test]
fn sum_snapshot_tokens_handles_missing_and_bad_json() {
assert_eq!(sum_snapshot_tokens("not json"), 0, "非法 JSON 应返回 0");
assert_eq!(sum_snapshot_tokens("[]"), 0, "空数组应为 0");
assert_eq!(
sum_snapshot_tokens(r#"[{"prompt_tokens":10},{"completion_tokens":5},{"foo":1}]"#),
15,
"逐条相加,缺字段按 0"
);
}
} }
@@ -14,6 +14,19 @@ pub(crate) fn column_exists(conn: &Connection, table: &str, col: &str) -> bool {
false false
} }
/// 检测表是否存在(幂等迁移辅助)。
///
/// [`column_exists`] 对不存在的表与缺列都返回 false,无法区分——迁移里「加列」需先确认
/// 表在(否则 ALTER 报 no such table)。sqlite_master 精确查名(非 LIKE,避免前缀误命中)。
pub(crate) fn table_exists(conn: &Connection, table: &str) -> bool {
conn.query_row(
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type = 'table' AND name = ?1",
[table],
|r| r.get::<_, bool>(0),
)
.unwrap_or(false)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -74,4 +87,16 @@ mod tests {
assert!(!column_exists(&conn, "probe_t", "prompt")); assert!(!column_exists(&conn, "probe_t", "prompt"));
assert!(!column_exists(&conn, "probe_t", "model_configs")); assert!(!column_exists(&conn, "probe_t", "model_configs"));
} }
/// table_exists 精确匹配表名,不误命中前缀/近似名(与 column_exists 区分表缺失与缺列)。
#[test]
fn table_exists_matches_exact_name() {
let conn = Connection::open_in_memory().expect("open in-memory db");
conn.execute_batch("CREATE TABLE probe_t (id TEXT);")
.expect("create probe table");
assert!(table_exists(&conn, "probe_t"));
assert!(!table_exists(&conn, "probe"), "不误命中前缀");
assert!(!table_exists(&conn, "probe_t2"), "不误命中扩展名");
assert!(!table_exists(&conn, "ghost"), "未建表返回 false");
}
} }
+40
View File
@@ -396,6 +396,10 @@ pub struct AiConversationRecord {
/// 纯元数据标记不进 update_full 通用 UPDATE,走专用 set_loop_state。 /// 纯元数据标记不进 update_full 通用 UPDATE,走专用 set_loop_state。
#[serde(default)] #[serde(default)]
pub loop_state: Option<String>, pub loop_state: Option<String>,
/// 会话终局态(V50):active=进行中/默认(含正常完成),incomplete=停靠/断流等未完成终局,
/// error=循环错误终局。与 loop_state(运行期 running/parked,管「能否续跑」)正交——
/// 本列回答「会话如何结束」,供未完成率等统计。不进 update_full,走专用 set_status 单点写。
pub status: String,
} }
/// AI 工具执行审计记录 /// AI 工具执行审计记录
@@ -419,6 +423,11 @@ pub struct AiToolExecutionRecord {
pub requested_at: String, pub requested_at: String,
pub executed_at: Option<String>, pub executed_at: Option<String>,
pub decided_by: Option<String>, // human/auto pub decided_by: Option<String>, // human/auto
/// 纯执行耗时(毫秒,V50):工具真正执行的墙钟时长(执行前→执行后),与审批等待解耦。
/// None=老数据 / 未测路径(缓存命中/未真执行)。审计面板耗时统计用本列,替代
/// requested_at→executed_at(后者在审批路径混入等待人工批准时长)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<i64>,
} }
/// 消息记录(ai_messages 表,消息拆分存储)。 /// 消息记录(ai_messages 表,消息拆分存储)。
@@ -470,6 +479,37 @@ pub struct AiMessageRecord {
/// 本轮 prompt 是否估算值(round_usage.prompt_tokens==0 → estimated_prompt 兜底打标)。 /// 本轮 prompt 是否估算值(round_usage.prompt_tokens==0 → estimated_prompt 兜底打标)。
/// reload 逐条回显「估算」标注;老消息 NULL → None(向前兼容)。 /// reload 逐条回显「估算」标注;老消息 NULL → None(向前兼容)。
pub is_estimated: Option<bool>, pub is_estimated: Option<bool>,
/// 消息来源 JSON(#958 溯源,MessageSource 序列化;V52 加列)。
/// NULL=桌面本端(既有语义);非空=外部渠道(飞书 message_id 等,可跳原消息追溯)
pub source: Option<String>,
}
/// 待发箱记录(ai_outbox 表,V51)。
///
/// 队列与定时消息统一实体:队列项 due_at=NULL(会话空闲即发),定时项 due_at=epoch_ms(到点且空闲即发)。
/// 表是唯一真相源(前端内存队列退化为该会话 outbox 的只读视图)。设计见
/// docs/02-架构设计/专项设计/定时消息与待发箱持久化-设计-2026-10-10.md §3.1。
///
/// kind:queue(尽快)| scheduled(定时);预留 task(937 对话内定时任务)。
/// status:pending|sending|sent|failed|canceled。时间戳均 epoch 毫秒(存储层不转字符串本表特例:
/// due_at 需按数值比较排序,created_at/updated_at/sent_at 同为毫秒整数便于调度器直接比较)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AiOutboxRecord {
pub id: String,
pub conversation_id: String,
pub kind: String,
pub text: String,
pub skill: Option<String>,
pub model_override: Option<String>,
pub parts_json: Option<String>,
pub spans_json: Option<String>,
pub due_at: Option<i64>,
pub status: String,
pub attempts: i64,
pub last_error: Option<String>,
pub created_at: i64,
pub updated_at: i64,
pub sent_at: Option<i64>,
} }
// ============================================================ // ============================================================
+41
View File
@@ -11,12 +11,25 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// 当前 Hello 握手协议版本。
///
/// 协商规则:旧客户端 Hello 缺 `proto` 字段 → 视为 v1(向后兼容);
/// 高于对端支持版本时由 peer 记录告警,为后续破坏性变更预留通知位。
pub const HELLO_PROTO_VERSION: u32 = 1;
/// `proto` 缺省值:旧客户端不带该字段时按 v1 处理(向后兼容)。
fn default_proto() -> u32 {
HELLO_PROTO_VERSION
}
/// 握手身份宣告帧(对齐 u-relay::relay::Hello) /// 握手身份宣告帧(对齐 u-relay::relay::Hello)
/// ///
/// 桌面端 WS 连 `/ws/agent` 后,首条消息发此 JSON,relay 校验 kind 与 token。 /// 桌面端 WS 连 `/ws/agent` 后,首条消息发此 JSON,relay 校验 kind 与 token。
/// 字段名/serde rename 与 u-relay 严格一致(`#[serde(rename_all="snake_case")]`), /// 字段名/serde rename 与 u-relay 严格一致(`#[serde(rename_all="snake_case")]`),
/// 否则 relay 反序列化失败 → 握手被拒。 /// 否则 relay 反序列化失败 → 握手被拒。
/// ///
/// `proto` 为协议版本,供 relay 端做版本协商(缺字段按 v1,新增破坏性变更时递增)。
///
/// 注意:本 crate 不依赖 u-relay(避免 lib→server 反向依赖),自行镜像协议结构。 /// 注意:本 crate 不依赖 u-relay(避免 lib→server 反向依赖),自行镜像协议结构。
/// 协议字段如有变更须同步 u-relay::relay::Hello 与本类型。 /// 协议字段如有变更须同步 u-relay::relay::Hello 与本类型。
/// ///
@@ -30,6 +43,9 @@ pub struct Hello {
pub channel_id: String, pub channel_id: String,
/// 配对 token(env U_RELAY_TOKEN 同源) /// 配对 token(env U_RELAY_TOKEN 同源)
pub token: String, pub token: String,
/// 协议版本(缺省按 v1,兼容旧客户端)
#[serde(default = "default_proto")]
pub proto: u32,
} }
/// Hello.kind 传输表示(serde 字符串,与 u-relay::ClientKindWire 一致) /// Hello.kind 传输表示(serde 字符串,与 u-relay::ClientKindWire 一致)
@@ -155,3 +171,28 @@ pub enum TunnelMessage {
/// 云后端转发的指令(上行,发往桌面端) /// 云后端转发的指令(上行,发往桌面端)
Command(TunnelCommand), Command(TunnelCommand),
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hello_proto_defaults_to_v1_when_absent() {
// 旧客户端不发 proto → 反序列化按 v1,保证向后兼容
let json = r#"{"kind":"agent","channel_id":"c1","token":"t"}"#;
let h: Hello = serde_json::from_str(json).expect("缺 proto 应可解析");
assert_eq!(h.proto, HELLO_PROTO_VERSION);
}
#[test]
fn hello_serializes_proto_field() {
let h = Hello {
kind: HelloKind::Agent,
channel_id: "c1".into(),
token: "t".into(),
proto: HELLO_PROTO_VERSION,
};
let v: serde_json::Value = serde_json::to_value(&h).unwrap();
assert_eq!(v["proto"], HELLO_PROTO_VERSION);
}
}
+11 -5
View File
@@ -38,7 +38,7 @@ use tokio_tungstenite::tungstenite::protocol::CloseFrame;
use tokio_tungstenite::tungstenite::Message as WsMessage; use tokio_tungstenite::tungstenite::Message as WsMessage;
use crate::error::{Result, TunnelError}; use crate::error::{Result, TunnelError};
use crate::events::{Hello, HelloKind, RelayControlError, TunnelEvent}; use crate::events::{Hello, HelloKind, RelayControlError, TunnelEvent, HELLO_PROTO_VERSION};
/// 收到 commander→agent 指令 payload 时的回调类型(Boxed Future,在收发循环 task 内 await) /// 收到 commander→agent 指令 payload 时的回调类型(Boxed Future,在收发循环 task 内 await)
/// ///
@@ -105,9 +105,13 @@ const HANDSHAKE_PROBE: Duration = Duration::from_secs(3);
/// 包 `tokio::time::timeout` 在此窗口内未完成 → 返 TunnelError::Connect 显式错误。 /// 包 `tokio::time::timeout` 在此窗口内未完成 → 返 TunnelError::Connect 显式错误。
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
/// 心跳间隔(应用层 Ping,补协议层 keepalive,防 NAT 连接表超时回收) /// 心跳间隔(WS 协议层 Ping,补 TCP keepalive,防 NAT/反代空闲回收连接)。
/// u-relay 心跳走应用层 control 帧 ping→pong,relay 本地直回不转对端 ///
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(25); /// 上界约束:链路最短空闲超时在反代侧(生产 nginx `proxy_read_timeout`,配置样例见
/// u-relay wire 协议文档 §5.2,最小 300s),间隔须显著小于它并留余量——容忍数次心跳
/// 丢失或网络抖动仍不被判定空闲断链。取 30s:满足约束,且与 commander 参考实现
/// (u-ppt 30s ping)节拍一致,避免两端看门狗口径不一导致误重连。
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
/// 重连初始退避 /// 重连初始退避
const RECONNECT_BASE: Duration = Duration::from_secs(1); const RECONNECT_BASE: Duration = Duration::from_secs(1);
@@ -201,7 +205,8 @@ impl TunnelClient for WsTunnelClient {
))); )));
} }
}; };
tracing::info!(%url, "WS 连接已建立,开始 Hello 握手"); // 版本协商(本端宣告):Hello 携带 proto,供 relay 侧比对决定兼容性
tracing::info!(%url, proto = HELLO_PROTO_VERSION, "WS 连接已建立,开始 Hello 握手");
let (mut sink, mut stream) = ws_stream.split(); let (mut sink, mut stream) = ws_stream.split();
@@ -210,6 +215,7 @@ impl TunnelClient for WsTunnelClient {
kind: HelloKind::Agent, kind: HelloKind::Agent,
channel_id: channel_id.to_string(), channel_id: channel_id.to_string(),
token: token.to_string(), token: token.to_string(),
proto: HELLO_PROTO_VERSION,
}; };
let hello_json = serde_json::to_string(&hello)?; let hello_json = serde_json::to_string(&hello)?;
sink.send(WsMessage::Text(hello_json)).await.map_err(|e| { sink.send(WsMessage::Text(hello_json)).await.map_err(|e| {
+4
View File
@@ -315,6 +315,10 @@ impl ProjectStatus {
/// ///
/// Input Augmentation 层 ProjectResolver/TaskResolver 把记录的 String status 投影成 /// Input Augmentation 层 ProjectResolver/TaskResolver 把记录的 String status 投影成
/// 类型化枚举用。未知字符串返 None。 /// 类型化枚举用。未知字符串返 None。
///
/// ⚠️ TYPES-2:本函数返 None 是显式信号,但若调用方用 `unwrap_or_default()` 兜底,
/// 未知值会**静默归 Planning**(ProjectStatus::default),掩盖数据异常。建议调用方对
/// None 分支记 warn 审计(而非静默兜底),便于发现脏 status。
pub fn from_db_str(s: &str) -> Option<Self> { pub fn from_db_str(s: &str) -> Option<Self> {
Some(match s { Some(match s {
"planning" => ProjectStatus::Planning, "planning" => ProjectStatus::Planning,
+18
View File
@@ -30,6 +30,10 @@ use df_types::types::{NodeId, NodeStatus};
pub struct StateMachine { pub struct StateMachine {
/// 节点状态映射(Arc 共享,clone 浅拷贝同一 HashMap) /// 节点状态映射(Arc 共享,clone 浅拷贝同一 HashMap)
states: Arc<Mutex<HashMap<NodeId, NodeStatus>>>, states: Arc<Mutex<HashMap<NodeId, NodeStatus>>>,
/// 取消信号唤醒器(FR-P4):set_cancelled 时 notify_waiters,
/// 阻塞节点(HumanNode)用 notified() 被动等待,替代 500ms 轮询。
/// 与 states 同 Arc 共享,clone 浅拷贝同一 Notify。
cancel_notify: Arc<tokio::sync::Notify>,
} }
impl StateMachine { impl StateMachine {
@@ -37,9 +41,19 @@ impl StateMachine {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
states: Arc::new(Mutex::new(HashMap::new())), states: Arc::new(Mutex::new(HashMap::new())),
cancel_notify: Arc::new(tokio::sync::Notify::new()),
} }
} }
/// 获取取消信号等待器(FR-P4)
///
/// 返回的 `Notified` future 在 `set_cancelled` 被调用时 resolve。
/// 调用方应配合 `is_cancelled` 做条件判断(Notify 无状态,可能错过
/// 早于 await 的 notify,故 select! 分支 resolve 后仍须复查 is_cancelled)。
pub fn cancel_notified(&self) -> tokio::sync::futures::Notified<'_> {
self.cancel_notify.notified()
}
/// 获取节点状态 /// 获取节点状态
/// ///
/// 锁中毒时降级返回 `NodeStatus::Pending`(保守默认:视为未启动, /// 锁中毒时降级返回 `NodeStatus::Pending`(保守默认:视为未启动,
@@ -139,6 +153,10 @@ impl StateMachine {
); );
} }
} }
// FR-P4:唤醒所有等待取消信号的阻塞节点(HumanNode notified 分支)。
// notify_waiters 不持锁调用,避免与 states 锁耦合;Notify 无状态,
// 等待方 resolve 后仍复查 is_cancelled 兜底(防 notify 早于 await 丢失)。
self.cancel_notify.notify_waiters();
} }
/// 获取所有状态快照(clone 返回,调用方持独立副本) /// 获取所有状态快照(clone 返回,调用方持独立副本)
@@ -0,0 +1,246 @@
# DevFlow 签名与发布 SOP
> 2026-10-10 制定。适用对象:上游发布者(你)。目标:让「签名 → 打包 → 发布清单 → 客户端自动升级」这条链**可重复执行、可验证、密钥可控**。
> 相关文档:《版本更新与自动升级-设计-2026-10-09.md》(链路设计)、《团队级基建-部署与运维-2026-10-05.md》(分发设施)。
---
## 一、密钥体系一句话
DevFlow 用 **minisign / ed25519** 非对称签名:
| 角色 | 文件 | 位置 | 作用 |
|---|---|---|---|
| 私钥(绝密) | `devflow.key` | `%USERPROFILE%\.tauri\` | 签名安装包,**泄露=可伪造升级** |
| 公钥(公开) | `devflow.key.pub` | 同上 | 验签,**同时硬编码进客户端** |
| 公钥副本 | `pubkey` 字段 | `src-tauri/tauri.conf.json` `plugins.updater.pubkey` | 编译进 exe,客户端用它验签 |
**核心约束**:客户端 exe 里的公钥是**编译期固化**的。换密钥 = 换公钥 = 已分发的老客户端**永远无法自动升级到新密钥签的包**(只能手动重装)。所以密钥轮换是**断链级操作**,非必要不做。
---
## 二、密钥文件格式(排障必知)
Tauri 私钥文件是 **base64 文本包 base64** 的两层结构:
```
外层 base64 → 解码得文本,两行:
L1 untrusted comment: ...
L2 <内层 base64> → 解码得 payload 字节
```
payload 布局:
| 长度 | 类型 | 含义 |
|---|---|---|
| 106 B | 明文(未加密) | `sig_alg(2)+keynum(8)+sk(64)+ck(32)` |
| 158 B | 加密(Scrypt KDF 段) | `sig_alg(2)+kdf_alg(2)+ck_alg(2)+salt(32)+opslimit(8)+memlimit(8)+keynum(8)+sk(64)+ck(32)` |
> ⚠️ **`cargo tauri signer generate` 产出的都是 158 B(带 KDF 段)**——即使你「没设密码」,结构上仍是加密布局,只是口令为空。所以**不能用文件大小判断是否设了密码**,必须用「空密码能否签名」实测(见 §六 自检)。
**公钥** payload 固定 42 B:`sig_alg(2)+keynum(8)+key(32)`。`keynum = payload[2:10]`,是**密钥指纹**,用于快速比对「配置公钥 ↔ 私钥 ↔ 签名」三者是否同一把钥匙。
---
## 三、密钥生命周期操作
### 3.1 生成(首次或轮换)
```powershell
# 无密码(不推荐,当前生产就是这个状态)
cargo tauri signer generate -w "$env:USERPROFILE\.tauri\devflow.key"
# 带密码(推荐)
cargo tauri signer generate -p "<强口令>" -w "$env:USERPROFILE\.tauri\devflow.key"
```
生成后把 `.pub` 的内容填进 `src-tauri/tauri.conf.json` 的 `plugins.updater.pubkey`,然后**重新编译客户端**才生效。
### 3.2 备份(强制)
私钥丢失 = 无法再签发升级包 = 只能原地冻版。**生成后立刻**:
```
1. 复制 devflow.key / devflow.key.pub 到离线介质(U 盘 / 加密卷)
2. 记录密钥指纹(keynum),见 §六
3. 关键:口令单独记录(不要和私钥同处存储)
```
### 3.3 轮换(断链级,慎用)
```
代价:已分发的所有客户端自动升级链断裂 → 必须手动重装
步骤:
1. 生成新密钥对 → 旧密钥改名归档(不要删,历史包验签要用)
2. 更新 tauri.conf.json pubkey → 重新编译 → 发布「桥接版」
(桥接版仍用旧私钥签,老客户端能升上来;桥接版内含新公钥,之后再用新私钥签)
3. 桥接版铺开 ≥1 个版本周期后,才切新私钥签发
```
> 单管理员自用场景可简化:接受「一次手动重装」,直接换。对外分发则**必须走桥接版**。
---
## 四、发布流程(一键脚本)
```powershell
cd E:\wk-lab\devflow
.\scripts\publish-release.ps1 # 完整发布(含 tauri build)
.\scripts\publish-release.ps1 -Notes "修复xx" # 手动指定更新说明
.\scripts\publish-release.ps1 -SkipBuild # 复用已有产物,只生成清单+上传
```
脚本六步(每步失败即 `exit 1`,不静默):
| 步 | 动作 | 失败含义 |
|---|---|---|
| 1 | **版本三源校验**(Cargo.toml / tauri.conf.json / package.json) | 三处版本号不一致,先对齐(见 §七 坑 2) |
| 2 | 签名私钥就位检查(env 优先,回退 `~/.tauri/devflow.key`) | 私钥缺失 |
| 3 | `cargo tauri build`(产出 `devflow_<ver>_x64-setup.exe` + `.exe.sig`) | 编译/打包失败 |
| 4 | 生成 `latest.json`(**UTF-8 无 BOM**) | — |
| 5 | 上传 `ssh-proxy` → `suke-dev:/var/www/app-site/download/devflow/` | SSH/上传失败 |
| 6 | 远端 `ls` + `cat latest.json` 回显验证 | 上传未落盘 |
**发布清单契约**(`latest.json`):
```json
{
"version": "0.1.2",
"notes": "...",
"pub_date": "2026-10-10T14:00:00Z",
"platforms": {
"windows-x86_64": {
"signature": "<.exe.sig 全文>",
"url": "https://app.1216.top/download/devflow/devflow_0.1.2_x64-setup.exe"
}
}
}
```
`app.1216.top` 已配 `no-cache`,改完即时生效,无需刷 CDN。
---
## 五、手工签名(脚本之外,补签/演练用)
```powershell
# 无密码密钥
cargo tauri signer sign -f "$env:USERPROFILE\.tauri\devflow.key" -p "" --app-version 0.1.2 <file>
# 带密码密钥(口令走参数或环境变量,勿写进脚本)
cargo tauri signer sign -f "$env:USERPROFILE\.tauri\devflow.key" -p "<口令>" --app-version 0.1.2 <file>
# 环境变量方式(CI 友好)
$env:TAURI_SIGNING_PRIVATE_KEY = "$env:USERPROFILE\.tauri\devflow.key"
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = "<口令>"
```
> **务必带 `--app-version`**:它把版本号写进签名的 trusted comment 并被签名覆盖。若客户端/服务端开启 `requireSignedVersion`,清单里改版本号就会被拒——这是防「篡改清单把新版号配旧包」的关键绑定。`cargo tauri build` 会自动带上,手工签要自己带。
---
## 六、验证(发布后必做)
### 6.1 一键验签(推荐)
```powershell
node temp/verify_sig.mjs
```
输出:配置公钥 keynum / 每个 exe 的 keynum 匹配性 / **ed25519 实际验签结果** / `latest.json` 与 `.sig` 内容一致性。
验签数学:payload 首 2 字节 `ED` 表示 **blake2b512 预哈希**模式,即 `verify(blake2b512(file), sig)`;`Ed` 表示直接对原文。
### 6.2 密钥指纹自检
```powershell
python temp/sign_state_check.py # 三源 keynum 对照
python temp/key_layout_final.py # 私钥格式(106/158B)判定
```
判据:**配置公钥 keynum == 预期私钥对应 keynum**。不等 = 拿错钥匙,签出来的包客户端一律拒。
### 6.3 「私钥是否设了密码」实测(唯一可信方法)
```powershell
$probe = "$env:TEMP\pw_probe.txt"; "x" | Set-Content $probe -NoNewline
cargo tauri signer sign -f "$env:USERPROFILE\.tauri\devflow.key" -p "" "$probe" ; $LASTEXITCODE
# exit=0 → 无密码保护;exit≠0 且报 incorrect password → 有密码
```
---
## 七、踩坑记录(实证)
**坑 1 — `latest.json` 的 UTF-8 BOM**
Windows PowerShell 5.1 的 `Out-File -Encoding utf8` / `Set-Content -Encoding utf8` **会写 BOM**(`EF BB BF`)。JSON 解析器遇到 BOM 报 `expected value at line 1 column 1`,表现为「签名没错、包没错,updater 就是拉不到清单」。
✅ 修正:`[System.IO.File]::WriteAllText($path, $json, (New-Object System.Text.UTF8Encoding($false)))`。已固化进 `publish-release.ps1`。
**坑 2 — 版本号三源分叉**
版本号存在于三处:`src-tauri/Cargo.toml`(Rust crate / `env!("CARGO_PKG_VERSION")`)、`src-tauri/tauri.conf.json`(打包器实际使用)、`package.json`(前端)。
任一处漏改 → 设置页显示与实际安装版本不符(灵异现象)。
✅ 修正:`publish-release.ps1` 第 1 步**硬校验三源一致**,不一致直接拒发。2026-10-10 已修 `package.json` 0.1.1 → 0.1.2。
**坑 3 — 发布脚本硬编码版本号**
`swap-install.ps1` 曾硬编码 `devflow_0.1.1_x64-setup.exe`。升版忘改 → 若旧包残留同目录,**静默装回旧版**(伪成功,最危险)。
✅ 修正:改 glob 探测 + `Sort-Object LastWriteTime -Descending | Select-Object -First 1`。
**坑 4 — 用文件大小判密码**
`cargo tauri signer generate` 产出的私钥**一律 158 B**(带 Scrypt KDF 段),无密码时只是口令为空。按大小判断会误判。
✅ 修正:用 §6.3 空密码实签判定。
**坑 5 — 密钥文件被误覆盖**
2026-10-10 曾发生野密钥覆盖生产密钥,靠备份 `*.bak-20261010-213046` 恢复。
✅ 教训:**动密钥前先归档**(`restore-key.ps1` 的归档-恢复-校验三步式可复用);密钥目录加 ACL 防误改(见 §八)。
---
## 八、安全加固(已实施 2026-10-10)
**威胁模型**:任何能读到 `devflow.key` 的本地进程,都能签发被客户端信任的更新包 → 推恶意升级。
已实施的缓解(见 `temp/harden_key_acl.ps1`):
```
%USERPROFILE%\.tauri\ 阻断继承,仅 (当前用户 / SYSTEM)
%USERPROFILE%\.tauri\devflow.key* 阻断继承,仅 (当前用户 读写删 / SYSTEM 全控)
```
**仍未消除的风险(重要)**:私钥当前**无口令保护**,文件本身即等价于签名能力;ACL 只挡了「非授权本地用户」,挡不住「以当前用户身份运行的程序」。
**根治方案(需拍板,有代价)**:
| 方案 | 强度 | 代价 |
|---|---|---|
| A. 私钥加口令 | 中 | 必须**重新生成密钥对**(`signer` 无 reencrypt 子命令)→ 公钥变 → **换公钥** → 见 §3.3 断链(老客户端需手动重装) |
| B. 私钥迁离线介质 | 高 | 每次发布需插盘;CI 不可用 |
| C. A+B 组合 | 最高 | 同上,推荐对外分发场景 |
| D. 仅 ACL(现状) | 低 | 零代价,只防误改/误读 |
> 现状为 D。若 DevFlow 仅自用,D 可接受;一旦对外分发,建议升到 C。
---
## 九、应急:私钥泄露怎么办
```
1. 立即停发:删除远端 latest.json(客户端拉不到清单即停止升级链)
2. 评估:泄露时间窗内是否有未授权包被签(检查远端目录文件时间戳)
3. 轮换:按 §3.3 生成新密钥对 → 发布桥接版(旧私钥签)→ 铺开后切新私钥
4. 通告:对外分发场景需告知用户手动重装
```
**注意:`删除 latest.json` 是唯一的「远端急停」手段**——客户端只在 check 时拉清单,清单不在 = 无法升级。
---
## 十、日常检查清单
发布前:
- [ ] 版本三源一致(脚本第 1 步自动校验)
- [ ] 私钥就位且指纹与配置公钥一致(`node temp/verify_sig.mjs` 的 keynum 对照)
- [ ] 密钥已备份(首次/轮换后)
发布后:
- [ ] `node temp/verify_sig.mjs` 全绿(验签 + latest.json 对账)
- [ ] 远端 `curl https://app.1216.top/download/devflow/latest.json` 可取且 version 正确
- [ ] 老客户端实测一次「检查更新 → 下载 → 安装 → 重启后版本号变新」(B3 端到端)
+29 -2
View File
@@ -4,7 +4,7 @@
> 上级索引:[../INDEX.md](../INDEX.md) > 上级索引:[../INDEX.md](../INDEX.md)
> 命名规范:见本目录 [文档记录规范-2026-06-14.md](./滚动规范/文档记录规范-2026-06-14.md) §九「文档命名规范」 > 命名规范:见本目录 [文档记录规范-2026-06-14.md](./滚动规范/文档记录规范-2026-06-14.md) §九「文档命名规范」
本目录是 DevFlow 的**架构设计 / 设计决策 / 设计方案 / 构想**文档集合(不含实现流水,流水走 `PROGRESS.md`;不含审查报告,审查走 `05-代码审查/`)。 本目录是 DevFlow 的**架构设计 / 设计决策 / 设计方案 / 构想**文档集合(不含实现流水,流水走 [`../07-项目管理/PROGRESS.md`](../07-项目管理/PROGRESS.md);不含审查报告,审查走 `05-代码审查/`)。
按**性质**分四类,每类一个子目录: 按**性质**分四类,每类一个子目录:
@@ -23,7 +23,7 @@
| 文档 | 状态 | 核心内容 | | 文档 | 状态 | 核心内容 |
|---|---|---| |---|---|---|
| [业务系统设计-2026-06-12.md](./滚动规范/业务系统设计-2026-06-12.md) | 🏗 ARCHITECTURE 载体 | 项目无独立 ARCHITECTURE.md,本文档为实质载体:产品定位 / crate 结构 / 数据模型 / Phase 规划 | | [业务系统设计-2026-06-12.md](./滚动规范/业务系统设计-2026-06-12.md) | 📐 设计 | 产品定位 / crate 结构 / 数据模型 / Phase 规划。项目架构总纲见根 [ARCHITECTURE.md](../../ARCHITECTURE.md) |
| [Phase1架构决策-2026-06-12.md](./滚动规范/Phase1架构决策-2026-06-12.md) | 📐 ADR | Phase 1 引擎骨架阶段架构级选型(ADR-001 引擎不绑定业务 等) | | [Phase1架构决策-2026-06-12.md](./滚动规范/Phase1架构决策-2026-06-12.md) | 📐 ADR | Phase 1 引擎骨架阶段架构级选型(ADR-001 引擎不绑定业务 等) |
| [功能决策记录-2026-06-14.md](./滚动规范/功能决策记录-2026-06-14.md) | 📋 滚动 | 功能**需求规格 + 设计决策规格**(为什么这么定 / 要做什么)。最高频引用(67 入链) | | [功能决策记录-2026-06-14.md](./滚动规范/功能决策记录-2026-06-14.md) | 📋 滚动 | 功能**需求规格 + 设计决策规格**(为什么这么定 / 要做什么)。最高频引用(67 入链) |
| [功能决策记录-归档-2026-06-14.md](./滚动规范/功能决策记录-归档-2026-06-14.md) | 🗄 归档只读 | 从主文档归档:纯流水 / 老 Sprint 决策 / UX 微调 / 已被取代细节 | | [功能决策记录-归档-2026-06-14.md](./滚动规范/功能决策记录-归档-2026-06-14.md) | 🗄 归档只读 | 从主文档归档:纯流水 / 老 Sprint 决策 / UX 微调 / 已被取代细节 |
@@ -92,6 +92,33 @@
| [测试落地实施方案-2026-07-01.md](./专项设计/测试落地实施方案-2026-07-01.md) | 📐 测试 | 各测试用例的落地方式(Rust 单元/集成/vitest/E2E 手动)、文件位置、Mock 策略、CI 自动化路径 | | [测试落地实施方案-2026-07-01.md](./专项设计/测试落地实施方案-2026-07-01.md) | 📐 测试 | 各测试用例的落地方式(Rust 单元/集成/vitest/E2E 手动)、文件位置、Mock 策略、CI 自动化路径 |
| [devflow集成三方IM-飞书企微钉钉Web-规划设计-2026-10-04.md](./专项设计/devflow集成三方IM-飞书企微钉钉Web-规划设计-2026-10-04.md) | 📐 规划(待评审) | 飞书/企微/钉钉/自定义Web 远程接入 AI Chat:新增 df-im-gateway(WS Commander)复用 u-relay+df-tunnel,桌面端零改动。含必要性分析(§三A)、破坏性分析(§三B)、演进预留(§十 S1-S4:多人/引擎上云/新端)。P0网关→P1 Web→P2飞书→P3钉钉→P4企微 | | [devflow集成三方IM-飞书企微钉钉Web-规划设计-2026-10-04.md](./专项设计/devflow集成三方IM-飞书企微钉钉Web-规划设计-2026-10-04.md) | 📐 规划(待评审) | 飞书/企微/钉钉/自定义Web 远程接入 AI Chat:新增 df-im-gateway(WS Commander)复用 u-relay+df-tunnel,桌面端零改动。含必要性分析(§三A)、破坏性分析(§三B)、演进预留(§十 S1-S4:多人/引擎上云/新端)。P0网关→P1 Web→P2飞书→P3钉钉→P4企微 |
| [三方IM-Rust接入与SDK调研-2026-10-04.md](./专项设计/三方IM-Rust接入与SDK调研-2026-10-04.md) | ✅ 调研完成 | 三平台 Rust SDK 选型:飞书 larksuite-oapi-sdk-rs / 钉钉 dingtalk-stream / 企微 wecom-aibot-rust-sdk。**重大发现:企微智能机器人走 WS 长连接免公网回调,三平台统一形态**。含统一 adapter 映射表与 5 项落地风险清单 | | [三方IM-Rust接入与SDK调研-2026-10-04.md](./专项设计/三方IM-Rust接入与SDK调研-2026-10-04.md) | ✅ 调研完成 | 三平台 Rust SDK 选型:飞书 larksuite-oapi-sdk-rs / 钉钉 dingtalk-stream / 企微 wecom-aibot-rust-sdk。**重大发现:企微智能机器人走 WS 长连接免公网回调,三平台统一形态**。含统一 adapter 映射表与 5 项落地风险清单 |
| [上下文管理演进-任务技术设计-2026-07-20.md](./专项设计/上下文管理演进-任务技术设计-2026-07-20.md) | 📐 设计 | 上下文管理演进任务技术设计 |
| [上下文管理演进-执行计划-2026-07-20.md](./专项设计/上下文管理演进-执行计划-2026-07-20.md) | 📐 计划 | 上下文管理演进执行计划 |
| [上下文管理演进与发散思考-2026-07-20.md](./专项设计/上下文管理演进与发散思考-2026-07-20.md) | 📐 思考 | 上下文管理演进与发散思考 |
| [aichat-plan-driven-设计-2026-08-01.md](./专项设计/aichat-plan-driven-设计-2026-08-01.md) | 📐 设计 | AI Chat plan-driven 模式设计 |
| [devflow集成u-claw-可行性研究-2026-09-28.md](./专项设计/devflow集成u-claw-可行性研究-2026-09-28.md) | 📐 研究 | devflow 集成 u-claw 可行性研究 |
| [跨会话记忆四层架构-设计-2026-09-29.md](./专项设计/跨会话记忆四层架构-设计-2026-09-29.md) | 📐 设计 | 跨会话记忆四层架构 |
| [aichat上下文管理-派工与决策单-2026-09-29.md](./专项设计/aichat上下文管理-派工与决策单-2026-09-29.md) | 📋 决策单 | aichat 上下文管理派工与决策 |
| [DRY技术债走查报告-2026-10-01.md](./专项设计/DRY技术债走查报告-2026-10-01.md) | 📋 报告 | 全库 DRY 技术债走查 |
| [并行化与异步唤醒-设计-2026-10-04.md](./专项设计/并行化与异步唤醒-设计-2026-10-04.md) | 📐 设计 | 并行化与异步唤醒 |
| [无人值守长任务-设计-2026-10-04.md](./专项设计/无人值守长任务-设计-2026-10-04.md) | 📐 设计 | 无人值守长任务 |
| [上下文三层记忆-设计-2026-10-04.md](./专项设计/上下文三层记忆-设计-2026-10-04.md) | 📐 设计 | 上下文三层记忆 |
| [跨会话召回最小闭环-设计-2026-10-04.md](./专项设计/跨会话召回最小闭环-设计-2026-10-04.md) | 📐 设计 | 跨会话召回最小闭环 |
| [context-helpers拆分备勘-设计-2026-10-04.md](./专项设计/context-helpers拆分备勘-设计-2026-10-04.md) | 📐 设计 | context-helpers 拆分备勘 |
| [namespace淘汰治理-设计-2026-10-04.md](./专项设计/namespace淘汰治理-设计-2026-10-04.md) | 📐 设计 | namespace 淘汰治理 |
| [子代理完成卡已消化降级-设计-2026-10-04.md](./专项设计/子代理完成卡已消化降级-设计-2026-10-04.md) | 📐 设计 | 子代理完成卡消化降级 |
| [uclaw两侧勘察补全-2026-10-04.md](./专项设计/uclaw两侧勘察补全-2026-10-04.md) | 📋 勘察 | u-claw 两侧勘察补全 |
| [u-relay-wire-protocol-2026-10-04.md](./专项设计/u-relay-wire-protocol-2026-10-04.md) | 📐 协议 | u-relay 线协议规范 |
| [数据存储架构-定稿-2026-10-05.md](./专项设计/数据存储架构-定稿-2026-10-05.md) | 📐 定稿 | 数据存储架构定稿 |
| [知识库模型重审-精细化设计-2026-10-05.md](./专项设计/知识库模型重审-精细化设计-2026-10-05.md) | 📐 设计 | 知识库模型精细化重审 |
| [飞书直连接入-形态修正与问题记录-2026-10-05.md](./专项设计/飞书直连接入-形态修正与问题记录-2026-10-05.md) | 📋 记录 | 飞书直连接入形态修正与问题记录 |
| [版本更新与自动升级-设计-2026-10-09.md](./专项设计/版本更新与自动升级-设计-2026-10-09.md) | 📐 设计 | 版本更新与自动升级 |
| [u-relay-运维手册-2026-10-10.md](./专项设计/u-relay-运维手册-2026-10-10.md) | 📖 手册 | u-relay 运维手册 |
| [上下文分层组装器-落地方案-2026-10-10.md](./专项设计/上下文分层组装器-落地方案-2026-10-10.md) | 📐 方案 | 上下文分层组装器落地方案 |
| [定时消息与待发箱持久化-设计-2026-10-10.md](./专项设计/定时消息与待发箱持久化-设计-2026-10-10.md) | 📐 设计 | 定时消息与待发箱持久化 |
| [对话内定时延时任务-设计-2026-10-10.md](./专项设计/对话内定时延时任务-设计-2026-10-10.md) | 📐 设计 | 对话内定时延时任务 |
| [消息渲染管线重构-设计-2026-10-10.md](./专项设计/消息渲染管线重构-设计-2026-10-10.md) | 📐 设计 | 消息渲染管线重构 |
| [输入框列表预览与自动编号-设计-2026-10-10.md](./专项设计/输入框列表预览与自动编号-设计-2026-10-10.md) | 📐 设计 | 输入框列表预览与自动编号 |
--- ---
@@ -0,0 +1,131 @@
# FileTree 大目录卡顿 — 可实施方案
> 日期: 2026-10-10 | 状态: 设计稿(待拍板) | 勘察: 只读实测锚点(Wave Q)
> 定位: 「单个目录上千条合法条目」的卡顿风险可实施方案。本文只写方案,不改码。
> 触发: Wave Q 实测 — 后端 `get_module_file_tree` 不封顶、前端 FileTree 全量 `v-for`,当前靠三重缓解兜底。
> 结论预告: 推荐 **方案 B(后端 offset/limit 分页 + 前端「加载更多」)**,先做期 1 最小可用;虚拟滚动本期否决(见 §二·A 的实证疤痕)。
---
## 一、现状与证据(带行号)
### 1.1 后端:列目录不封顶
`src-tauri/src/commands/module.rs` 命令 `get_module_file_tree`(707-834):
- 774-777 `std::fs::read_dir(&target_dir)` 遍历目标目录**全部**条目;
- 781-791 过滤噪音目录(`is_noise_entry`,FileTree 档)+ 隐藏文件(`.` 开头);
- 810-818 每条 push 进 `items`;
- 820-824 排序(目录优先 + 字母序,稳定可预期);
- 830-833 一次性返回 `{ path, entries }`。
**无任何条数上限**。对照同语义的另一档位:AI 侧 `list_dir_recursive`(`src-tauri/src/commands/ai/tool_registry.rs:923-965`)有 `max_entries` 封顶(调用处 `tools/file.rs:347` 传 `1000`,达上限返 `truncated`)。即"列目录"这一语义,**AI 上下文档有封顶、文件浏览器档没有** —— 这是 Wave Q 要补的缺口。
### 1.2 前端:单层全量 v-for
`src/components/project/FileTree.vue`:
- 25-33 `<li v-for="entry in entries" :key="entry.path">` —— 当前层全部条目**一次性渲染**;
- 95-106 `FileTree` 自引用递归:展开的子目录再加一层同构 `<ul>`;
- 157-205 `entries` 为该层全量(命中 `loadedChildren` 缓存则整层直赋)。
滚动容器是**共享**的 `.explorer-tree`(`FileExplorer.vue:806-813`,`overflow-y: auto`),**不是每层各自滚动** —— 这决定了虚拟滚动若要落地,需先把递归树拍平成"单一带缩进的可见行列表"(见 §二·A)。
### 1.3 三重缓解(既定兜底,也是本次要复用的底座)
1. **按层懒加载**:FileTree 仅在某目录被展开时才拉该层(`loadEntries` 174-205;`toggleDir` 208-237),从不整树加载。
2. **噪音 / 隐藏过滤**:后端 `is_noise_entry`(FileTree 档,`tool_registry.rs:1045-1056`)+ `.` 隐藏文件(`module.rs:789`)剔除最大宗条目(`.git` 数千项、`node_modules`/`target`/`dist`/`__pycache__` 等)。
3. **KeepAlive 保活**:`src/views/ProjectDetail.vue:83-85` `<KeepAlive>` 包 `FileExplorer`,切 tab 不销毁、不重挂载、不重拉。
附随机制:`loadedChildren` 缓存(`FileExplorer.vue:280`)+ 在途去重 / 请求序号(`loadSeq`/`inflightEntries`,`FileTree.vue:170-171`)使重复展开秒开、并发请求去重。
**残留风险**:三重缓解把"上千条"压缩成"**单层**上千条",但对**一个真实含上千条且非噪音**的目录(如日志目录、生成代码目录、扁平资源目录),后端仍全量返回 + 前端仍全量 DOM。
---
## 二、方案对比
| 维度 | A 前端虚拟滚动 | B 后端 offset/limit + 前端「加载更多」 | C 两者结合 |
|------|---------------|--------------------------------------|-----------|
| 机制 | 只渲染可视窗口(+ overscan) | 后端分页切片,前端累积 | B 分页 + A 窗口化 |
| 后端改动 | 无 | `get_module_file_tree` 加 offset/limit/total | 同 B |
| 前端改动 | FileTree 重构为扁平窗口化列表 | FileTree 累积 entries + 尾部按钮 | B + 窗口化 |
| 依赖 | 需新库(项目现有依赖无)或自研 | 无 | 需新库 |
| 与现有架构 | **冲突**(递归树需拍平;共享滚动容器需重排) | **天然契合**(每层本就独立请求) | 中 |
| 风险 | 高(高度测量/展开态/选中态同步;项目有自研虚拟滚动翻车疤痕) | 低(改动集中、可回退) | 高 |
| 适配量级 | 上万条 | **上千条(本场景)** | 上万条 |
### A 的风险:项目实证疤痕
自研虚拟滚动曾落地于消息列表(自研 IntersectionObserver + sentinel + ResizeObserver),因 **IO/RO 时序竞态致消息重叠**,2026-06-18 **彻底移除**(见 `docs/待决策.md:132-136`、`docs/02-架构设计/滚动规范/功能决策记录-2026-06-14.md:362-363`;`MessageList.vue` 至今标注"虚拟滚动已移除,恒渲染")。
并且 `package.json` 现有依赖**无任何虚拟滚动库**(`@vueuse/core` 也未安装)。再自研 = 重蹈覆辙;引库 = 新增依赖 + 仍需适配递归树拍平 —— 成本与"上千条"的收益不成比例。
### 推荐:B(最小可用)
理由:
1. **零架构冲突**:现有模型就是"每层一个独立请求 + `loadedChildren` 缓存",把"整层"切片成"整层分批"是该模型的自然延伸,不引入新范式。
2. **最小 diff、低风险、可回退**(见 §三)。
3. **量级匹配**:上千条 DOM 在现代浏览器渲染在数十 ms 量级;首屏限 200~300 条后交互即流畅,足以消除卡顿。无需为"上千条"上虚拟滚动的复杂度(A/C 属 YAGNI,仅当实测 >5000 条再评估)。
4. **给用户明确信号**:「加载更多 (已加载/总数)」比"悄悄截断"更符合用户预期。
---
## 三、方案 B 改动面 / 风险 / 回退 / 验收
### 3.1 改动面(期 1 最小可用)
| 层 | 文件 | 改动 |
|----|------|------|
| 后端 | `src-tauri/src/commands/module.rs` | `get_module_file_tree` 加 `offset: Option<usize>`、`limit: Option<usize>` 参数;返回加 `total`。**先完整收集 + 过滤 + 排序,再切片**(切片点放在 820-824 排序之后),保证分页稳定。`limit` 缺省 = `None` → 返回全量(保持旧行为)。 |
| 前端 API | `src/api/module.ts` | `getModuleFileTree` 加 `offset`/`limit` 参数;`FileTreeResult` 加 `total?: number`。 |
| 前端组件 | `src/components/project/FileTree.vue` | `entries` 改为"累积注入":首屏拉 `limit` 条,`loadMore()` 按 `offset=已加载数` 追加,直到 `total`;层尾渲染「加载更多 (N/M)」;分页状态并入 `loadedChildren` 缓存(**建议其值由 `FileTreeEntry[]` 扩展为 `{ entries, total }` 或新增 `loadedTotals` map**,否则重展开丢失"还有更多"状态)。 |
| i18n | `src/i18n/zh-CN/fileExplorer.ts`、`src/i18n/en/fileExplorer.ts` | 新增 `loadMore`(「加载更多」/「Load more」)、可选 `allLoaded`。 |
不改 `FileExplorer.vue`(缓存语义、展开 / 选中逻辑不变)。改动集中、职责单一。
### 3.2 风险
- **offset 错位**:目录内容在分页期间变化 → 可能重复 / 漏项。自用场景 + `refresh` 清缓存重拉(`resetTreeState`,`FileExplorer.vue:330-336`)已覆盖,可接受。
- **多次请求 git status**:每个分片请求都重跑 `git_status_map_cached`,但有缓存(10s TTL),开销可控。
- **`has_children` 开销**:现每目录一次 `read_dir` 短路统计;分页后只对返回的前 N 条目录计算,反而**降**开销。
- **`loadMore` 与首屏请求竞争**:需让 `loadMore` 复用同一 inflight 去重键或使用独立键,避免与 `onMounted` 首屏请求互相覆盖(机制见 `FileTree.vue:185-188`)。
### 3.3 回退
- 后端 `limit`/`offset` 缺省 = `None` = 全量;前端不传 `limit` 即旧行为。
- 或前端恒定不渲染"加载更多"(首屏即全量)。
- 改动集中在 3 个源文件 + 2 个 i18n 文件,单次 revert 即可回滚。
### 3.4 验收口径
- **造数**:批量子项造 1500 条目录(如 1500 个空文件)。
- 首屏 DOM 行数 ≤ `limit`(如 200);层尾显「加载更多 (200/1500)」;点击逐次追加 200 条直到 1500;加载完按钮隐藏。
- **回归**:选中文件高亮、git 状态标、空目录标、展开子目录、切工程、刷新,均正常。
- **命令**:`cargo check -p devflow` EXIT 0;分页切片纯函数加单测(`module.rs:1574` 已有 `mod tests` 模块可承接);`bunx vue-tsc --noEmit` EXIT 0;`node scripts/check-i18n.cjs` 通过。
---
## 四、分期(最小可用优先)
- **期 1(MVP,必做)**:后端点分页 + 前端「加载更多」按钮。
- **期 2(体验,可选)**:触底自动加载(IO sentinel)。**注意**:滚动容器是共享 `.explorer-tree`(非每层独立滚动),触底自动加载需定位"当前层尾部的 sentinel",实现比按钮复杂;且只做"触底追加"、**不做"卸载裁剪"**,以规避历史 IO 竞态。可延后。
- **期 3(仅当实测触发)**:若实测 >5000 条仍卡,评估虚拟滚动,且**用成熟库**(如 `@vueuse/core` `useVirtualList`)而非自研,并先把递归树拍平为单一带缩进列表。
---
## 五、与既有能力的复用
- **懒加载缓存**:`loadedChildren`(`FileExplorer.vue:280`,`FileTree.vue:180/197/228`)→ 分页后仍存"累积 entries",展开秒开语义不变。
- **噪音过滤**:`is_noise_entry` FileTree 档(`tool_registry.rs:1045-1056`)→ 分页在**过滤之后**切片,过滤语义不变(不会因分页漏滤)。
- **KeepAlive**:`ProjectDetail.vue:83-85` → 分页游标随组件状态在 tab 切换间保留,回来不重拉。
- **在途去重 / 序号守卫**:`FileTree.vue:170-171/187` → `loadMore` 复用,避免并发覆盖。
---
## 六、拍板点
1. **分页粒度**:首屏 `limit` 与每次追加步长各取多少?(建议各 200 —— 兼顾首屏速度与点击次数)
2. **交互形态**:MVP 用「加载更多」按钮(推荐:简单、可测、与共享滚动容器不冲突)vs 触底自动加载(期 2 再评估)?
3. **是否本期引入虚拟滚动**:建议否决(YAGNI + 自研疤痕),仅当实测 >5000 条再以成熟库评估 —— 是否认可?
@@ -170,6 +170,24 @@ Commander(控制端) ──wss──> nginx(:443 TLS 终结) ──ws──> u-r
| **Commander** | 用 pong 更新 `lastPongTime`,看门狗超时则重连 | | **Commander** | 用 pong 更新 `lastPongTime`,看门狗超时则重连 |
| **Agent**(跑桌面/移动网络) | 用 pong 重置读死线,死线超时走既有重连 | | **Agent**(跑桌面/移动网络) | 用 pong 重置读死线,死线超时走既有重连 |
### 4.4 心跳间隔约束
心跳间隔不是自由值,**链路各层空闲超时中最小的一层决定其上界**:
| 层 | 空闲超时 | 说明 |
|---|---|---|
| nginx 反代 | `proxy_read_timeout`(样例 300s;生产实时配置可能更大) | 超时即静默断开 UPSTREAM WS,无任何错误帧 |
| NAT / 防火墙 | 数十秒 ~ 数分钟(设备相关) | 空闲连接被回收,后续帧永久不可达 |
**约定**:间隔须**显著小于最短超时**并留足余量(容忍连续数次心跳丢失 / 网络抖动仍不断链),**推荐 30–60s**。低于 30s 徒增无效流量,超过 60s 在 300s 反代下余量偏薄。
| 端 | 保活帧 | 间隔 |
|---|---|---|
| Commander 参考(u-ppt) | 应用层 `{control_kind:"ping"}` | 30s |
| Agent(df-tunnel) | WS 协议层 Ping 帧(对端协议栈自动回 Pong) | 30s |
> Agent 侧用协议层 Ping 即可保活——WS Ping/Pong 由对端协议栈(tungstenite/axum)自动应答,同样刷新反代空闲计时;应用层 `control_kind:"ping"` 主要用于需要显式 pong 观测的控制端看门狗。两端节拍取同值,避免一方看门狗口径与另一方保活周期错配导致误重连。
--- ---
## 5. 部署配置 ## 5. 部署配置
@@ -0,0 +1,122 @@
# u-relay 运维手册(token 轮换 · 一通道一 agent · 心跳约束)
> 适用:u-relay 跨端中继的日常运维纪律。协议细节见
> [u-relay Wire 协议规格](./u-relay-wire-protocol-2026-10-04.md),本手册只讲**运维动作与约束**。
> 来源:协议评估遗留三项(单共享 token 可冒充 / 心跳间隔未定值 / 同 channel 多 agent 全投递)。
> 本手册**不含**任何 token 值、主机地址等敏感信息——真值只在部署机 `/etc/u-relay.env` 与各客户端本地配置里。
---
## 1. 心跳间隔约束
心跳间隔须显著小于**链路最短空闲超时**(反代 `proxy_read_timeout`,配置样例 300s;生产实时值可能更大),
以容忍连续数次心跳丢失仍不断链。**约定 30–60s**,各方实现见协议规格 §4.4:
- Agent(df-tunnel):`HEARTBEAT_INTERVAL` 常量,30s(WS 协议层 Ping)
- Commander 参考(u-ppt):30s(应用层 `control_kind:"ping"`)
> 改反代 `proxy_read_timeout` 时须同步评估心跳间隔:若把超时压到 300s 以下,间隔也要相应收窄并保持「超时 / 间隔 ≥ 5」量级的余量。
---
## 2. 单共享 token 轮换 Runbook
### 2.1 现状与风险
u-relay 鉴权是**单一共享 token**(env `U_RELAY_TOKEN`,服务端常量时间比较),**不做 per-channel 隔离**:
任何持有该 token 的客户端可声明**任意 `channel_id`** 冒充任一通道。故 token 属敏感凭据,
泄露即等于整条中继失守;轮换是唯一的止损手段。
### 2.2 涉及方与各自持有位置
token 值散落在「服务端 + 多个客户端」,**没有集中下发通道**:
| 方 | 角色 | token 持有位置 |
|---|---|---|
| u-relay 服务端 | 鉴权源 | 部署机 `/etc/u-relay.env`(`U_RELAY_TOKEN`,权限 600)或 systemd unit 的 `Environment=` |
| devflow 桌面端 | Agent | `relay_token` 设置项(运行时读,缺省回退硬编码默认值) |
| devflow 小程序 | Commander | `apps/df-miniapp/src/config.ts` 缺省 + 设置页可改 |
| devflow ask_uclaw | Commander | 设置页 `uclaw_token`(与 relay token 同源) |
| u-claw | Agent/Runner | u-claw 侧配置 |
| u-ppt | Commander | `relay.ts` 配置 |
> 新增任何接入端都要登记到本表——漏一端的后果见下节。
### 2.3 锁步成本(务必先读)
因为**服务端只认一个 token、客户端各自缓存**,轮换天然是一个**锁步切换**:
- **无重叠窗口**:服务端不能同时接受新旧两个 token,故不存在「灰度换端」的过渡期。
- **顺序无解**:先改服务端 → 全部旧客户端立即 `auth_failed` 掉线;先改客户端 → 全部客户端在服务端切换前拿新 token 一样 `auth_failed`。两条路都有中断窗口。
- **漏端即静默失联**:任一客户端未同步换 token,会持续 `auth_failed` 被拒连,表现为**该端静默离线**(无业务错误、仅 relay 日志有 `auth_failed`),排查成本高。
- **不可逐端滚动**:无法像多 token 系统那样「先切一半、观察、再切一半」。
结论:**轮换是一次计划内的协调停机**,中断窗口 = 从「首个客户端改值」到「服务端重启加载新值」的间隔。执行要点是**把客户端改值的动作压到最短、紧接着重启服务端**。
### 2.4 执行步骤
前置:准备好新 token,确认上表**每一端**都有人能改、改完可验证。
1. **写服务端新值(先不重启)**:在部署机把 `/etc/u-relay.env`(或 unit `Environment=`)的 `U_RELAY_TOKEN` 改为新值,保持服务仍在跑旧值。此时新值尚未生效。
2. **集中改用各客户端**:将上表每一端改到新值。此步开始后各端会 `auth_failed`,**中断窗口从这里起算**。
3. **重启服务端加载新值**:`systemctl restart u-relay`。重启后已改端的客户端重连即通。
4. **验证**:见 2.5。
5. **未改端收尾**:对仍 `auth_failed` 的端补改(此时只有漏改者受影响,不再影响大局)。
> 若某些端的 token 是**编译期硬编码**(无运行时配置项),须提前把「新 token + 重新打包」的产物备好,否则第 2 步无法即时完成,窗口会被拉长。
### 2.5 验证
- 服务端:`systemctl status u-relay` 正常,`ss -tlnp | grep 9180` 在听。
- 连接:拿新 token 走一次握手冒烟(`/ws/agent` 或 `/ws/commander`,收 `hello_ack` 即通)。
- 日志:确认无 `auth_failed` 残留——有则说明还有端没换到新 token。
### 2.6 回滚
轮换失败(如某端无法改)时,**把服务端 env 回退到旧 token 并再重启一次**,回到轮换前状态;客户端已改的端随之 `auth_failed`,需一并回退。回滚与轮换同样是锁步中断。
### 2.7 根治方向
根治是**去掉单共享 token**:服务端改为 per-channel / per-client token(客户端拿自己的凭据只能绑自己的 channel),
届时任一端轮换互不影响,锁步成本消失。在此之前,本 runbook 的锁步纪律是必须遵守的现状约束。
---
## 3. 运维纪律:一通道一 agent
### 3.1 现象与根因
u-relay 路由按 `channel_id` 配对,且**同 channel_id 允许多个 agent 并存、命令全投递**(Vec 索引,无去重、无选举)。
即:同 `channel_id` 上跑两个 agent 实例(如两个 devflow 桌面端、或 devflow + u-claw runner 用了同一通道),
一条 commander 指令会被**两端各执行一次**——写文件、跑命令、调外部接口全部重复,且两端事件回传互相交错。
### 3.2 危害
- 指令重复执行:副作用翻倍(重复落库 / 重复发消息 / 重复改文件),且难以察觉。
- 事件串台:commander 收到两份进度/结果,流式 UI 错乱;两 agent 的上下文互相污染。
- 隐性化:两端都「看起来正常工作」,问题只在副作用层面暴露,事后才追溯得到。
### 3.3 纪律
- **一通道一 agent**:每个 `channel_id` 在任一时刻**只允许一个 active agent**。通道不是「实例池」。
- **启动前先查**:起第二个 agent 前,确认目标 `channel_id` 上没有在跑的 agent(换一个独立 `channel_id`,或用不同通道前缀)。
- **换装/重启要收尾**:旧 agent 若未干净退出(进程被杀但连接残留),WS 连接可能存活到对端超时,期间新旧两连接同通道并存 = 短暂双投递。换装脚本须确认旧连接已断、或等旧连接超时后再起新实例。
- **命名隔离**:不同用途用不同 `channel_id`(如按机器 / 按项目 / 按用户拆通道),从命名上杜绝复用。
### 3.4 排查手法
怀疑重复执行时:
- 看 relay 日志:同一 `channel_id` 是否同时有 >1 条 agent 连接。
- 看 commander 侧:同一 `request_id` 是否收到两份 `result` / 事件重复。
- 看副作用:文件 mtime、DB 记录、外部调用日志是否成对出现。
确认后,断开多余 agent 连接(停掉重复实例)即可恢复单投递。
---
## 附:关联
- 协议全貌:[u-relay Wire 协议规格](./u-relay-wire-protocol-2026-10-04.md)(§4 心跳、§5 部署、§3.3 路由)
- u-relay 仓 README / 部署技能:中继二进制与 systemd 发布流程
- 看板登记:`docs/todo.md`「新能力引入评估纪律」下三项 P3
@@ -0,0 +1,265 @@
# 上下文分层组装器 — 落地方案
> 日期: 2026-10-10 | 状态: 设计稿(待拍板) | 勘察: 只读实测锚点
> 定位: **《上下文根治设计-20261006.md》§一(L1/L2/L3)/§二(P1 瘦身)/§九(v1 锁定)/§十(检索面统筹)的落地细化**,不另立设计。
> 覆盖 Wave 上下文走查实测五病灶:① `history_tokens` 与真实体量脱钩;② 每轮全量深克隆+展开 O(n²);③ 预算恒 128K;④ 裁剪后前缀缓存整体失配;⑤ L1/L2/L3 未落地、窗口外历史无召回。
> 前置提醒: `crates/df-ai/**` 正由另一写者改动(P1 工具结果外置 WIP,见 §六),本方案明确与其重叠面与对接协议。
---
## 一、现状勘察(锚点实测)
### 1.1 发送链路的真实数据流(五病灶的公共底座)
每一轮 LLM 请求的消息来源分四步,散落在两处:
| 步 | 锚点 | 动作 |
|----|------|------|
| ① 裁剪 | `crates/df-ai/src/context/mod.rs:185-273` `build_for_request(sys_tokens)` | 按 `history_tokens` 判预算,超则从**头部**淘汰轮次单元 |
| ② 展开 | `src-tauri/src/commands/ai/agentic/mod.rs:1927-1944` | 逐条把 `namespace://` 引用**展开回真实内容** |
| ③ 拼装 | `agentic/mod.rs:1945-1952` | 前置 system_prompt,尾部 `merge_volatile_tail`(`agentic/stream_relay.rs:789`) |
| ④ 瘦身 | `agentic/mod.rs:1968-1988` → `stall_detector.rs:284-385` `summarize_tool_results` | view-only 摘要压缩大 tool_result(`extract_key_info`) |
### 1.2 五病灶逐条锚点
**① `history_tokens` 与真实体量脱钩(根因,最重)**
- 工具结果在 push 前先被引用化:`audit/mod.rs:1184-1191`,`should_use_namespace`(`namespace_store.rs:47-53`)命中 → `store()`(`namespace_store.rs:124-131`)入内存,**消息里只留短串 `namespace://tool/{hash}`**。
- `ContextManager::push`(`context/mod.rs:101-143`)对这条短串跑 `estimate_message`(`context_helpers.rs:62-105`),`history_tokens += tokens` 在 `:108-110`。
- 但**发送时**才在 `agentic/mod.rs:1927-1944` 展开成真实体量(单条 max 150KB,见 P1 注释 `context_helpers.rs:427-432`)。
- 结果:`history_tokens` 记的是引用串(~30 字符),真实发送是全文。预算门 `context/mod.rs:198`(`history_tokens <= available`)与压缩门 `context_lifecycle.rs:133-135`(`available*6/10 < history_tokens`)**双双恒不触发** → 每轮全量重发 → 223:1。
**② 每轮 O(n) 全量克隆+展开+压缩 → 会话累计 O(n²)**
- 未超预算分支 `context/mod.rs:198-205` 走 `all_messages_clone()`(`:294-296`,逐条深克隆);
- 展开 `agentic/mod.rs:1927-1944` 对**全部**历史逐条 `read_only` + `String` 克隆;
- 压缩 `summarize_tool_results`(`stall_detector.rs:313-385`)再遍历全部消息做内容变换。
- 三者每轮各 O(n),会话累计 O(n²)。**根因与①同源**:门失效 → 窗口永不收敛 → n 随会话单调涨。
**③ 预算恒 128K,与模型真实窗口无关**
- 主会话 `PerConvState::new`(`commands/ai/mod.rs:1241`)= `ContextManager::new(ContextConfig::default())`,`max_tokens=128_000`(`context_helpers.rs:128-136`),`budget_limit` 由它算(`:138-143`)。
- 真实窗口在 `ModelConfig.context_window`(`crates/df-ai-core/src/model.rs:138-139`),但只在 router 用,而代码自陈该维度"恒过、失效"(`agentic/mod.rs:2012` 注释)。
- 子代理同病:`sub_agent.rs:119` 也是固定 128_000。
**④ 裁剪后前缀缓存整体失配**
- 超预算时 `context/mod.rs:239-246` 切 `self.messages[trim_end..]`——头部被切,整个请求前缀位移,provider 自动前缀缓存(DeepSeek/Anthropic)整段失效。
- 已有部分缓解:淘汰摘要改尾部追加(`context/mod.rs:259-261`)、`push_system_anchor` 避让悬空头(`:582-595`)、`tool_view_decisions` 冻结压缩决策防跨轮翻转(`stall_detector.rs:352-363`)。但**基础裁剪本身**仍位移前缀,且**淘汰摘要内容逐轮漂移**(被淘汰区单调增长,`:259` 每轮即时重生成)。
**⑤ L1/L2/L3 未落地 + 窗口外历史无召回**
- 组装是隐式的:system(L1 内容)由 `build_runtime_prompt`(`agentic/mod.rs:3042`)拼,历史由 `build_for_request` 拼,易变尾由 `merge_volatile_tail` 拼,无统一"组装器"对象、无可观测统计。
- 检索仅覆盖知识库:`hybrid_search`(`knowledge_inject.rs:421`)→ `build_knowledge_context`(`:508`)→ `build_knowledge_context_for_conv`(`:587`);**窗口外对话历史与压缩摘要无处可召回**。
### 1.3 与既有设计的衔接
- 本方案 = 根治设计 §一 的接口化落地;`上下文三层记忆-设计-2026-10-04.md` 的"记忆卡"(C-m1)是 L3 的**记忆卡来源**,本方案不重复其产出逻辑,只提供召回入口。
- §九 v1 锁定范围(P1 瘦身+P2 页池+P3 压缩常规化+P4 评估)不变;本方案细化 **P2**,并把 §九"唯一存储/唯一调度器/唯一召回入口"三收敛落到具体接口。
---
## 二、目标与验收口径
**目标**
1. **账实相符**:预算/压缩门依据的 token 数 = 实际发送视图的体量(单一真值来源)。
2. **组装显式化**:L1 常驻 / L2 滑动窗口 / L3 检索召回 三段各有明确产出与稳定合并顺序;附带可观测统计。
3. **前缀稳定**:稳态下请求前缀只追加、不位移;裁剪以批量滞后发生。
4. **预算随模型**:上下文预算取自当前模型的 `context_window`,非恒定 128K。
5. **可召回**:窗口外历史/记忆卡经统一检索面按需注入,复用 `knowledge_inject` 管线。
**验收口径**
- 账实相符:`history_tokens` 与"发送视图 `estimate_message` 求和"偏差 ≤ 5%(构造含大 `namespace://` 引用的会话实测)。
- 门生效:构造超预算会话,`context_trimmed`(`context/mod.rs:263`)按真实体量触发;压缩门 `context_lifecycle.rs:133` 在正确水位触发。
- 前缀稳定:稳态连续 N 轮,请求消息数组的公共前缀长度 = 全量(无裁剪)或 = system 段(裁剪后仅在再裁剪时才变);provider 缓存命中率不再掉到个位数。
- 预算随模型:`context_window=256K` 的模型下 `budget_limit` = 256K×0.85−8K 量级,不再恒 128K。
- 可召回:窗口外某历史事实,仅凭关键词在下一轮被召回注入(带来源标注)。
- 不倒退:单轮/短会话(未超预算)行为与改造前逐字节一致;`df-ctx-*` 开关全关 = 旧路径。
---
## 三、设计方案
### 3.0 核心判断
五病灶**同源于一个断裂**:"保留形态"与"发送形态"两套尺寸,门只看了前者。因此**不新增存储、不另立调度**——本方案只做三件事:
1. 把"门所依据的尺寸"与"发送形态"对齐(**记账基点 = 发送视图尺寸**);
2. 把隐式拼装收敛为一个显式**组装器**(三段产出 + 稳定顺序 + 统计);
3. 把检索面从"仅知识库"扩为**注册表**(知识库 + 历史页 + 记忆卡同入口),落到 `build_knowledge_context_for_conv` 单点。
### 3.1 单一真值来源的 token 估值(治 ①,兼解 ②)
**两法对比**
| 法 | 做法 | 评价 |
|----|------|------|
| **A 记账点算"发送视图尺寸"** | 在内容产生点(及 reload)按 `view_form(msg)` 估 token——大工具结果按 `extract_key_info` 后的摘要+句柄尺寸入账 | O(1)/条、纯函数、与发送形态**同函数**(单一真值)、不依赖 namespace LRU 状态 |
| B 估值器识别 ref 查真实大小 | `estimate_message` 遇 `namespace://` 前缀 → 查 store 取真实内容再估 | 破坏 `TokenEstimator` 纯函数性(需注入 store)、每轮 O(n) 查+扫(加重②)、引用被淘汰 / 重启后查不到 → 退回串长仍脱钩 |
**取 A**。理由:A 让"记账"与"发送"共用同一个纯函数,是真值来源唯一化的最短路径;B 把复杂度塞进每轮热路径且对淘汰态脆弱。A 单点落在估值函数,正是 `context_helpers.rs` 的职责域(与既有 `should_summarize_tool_result`/`extract_key_info` 同居一文件,DRY)。
**接口形态(新增,minimal)**
```rust
// crates/df-ai/src/context_helpers.rs — 与 extract_key_info 同域,共享同一压缩逻辑
/// 发送视图尺寸(记账唯一口径):与发送路径"展开→summarize_tool_results"后的形态对齐。
/// 大 tool_result → extract_key_info 结果的 token;其余 → 内容 token。
pub fn view_form_tokens(estimator: &TokenEstimator, content: &str, tool_name: Option<&str>) -> u32;
// crates/df-ai/src/context/mod.rs — push 的带尺寸覆盖入口(记账用)
impl ContextManager {
/// 同 push,但 token_count 用调用方给定的发送视图尺寸(工具名在产生点可得)。
pub fn push_with_tokens(&mut self, message: ChatMessage, tokens: u32);
}
```
**改动点**
- 产生点 `audit/mod.rs:1184-1191`:此处已握有真实 `content` 与 `draft.name`,算 `view_form_tokens(...)` 后走 `push_with_tokens`,取代 `push` 的 content 估值。删掉 push 的隐式 ref 估值这条唯一失真源。
- 其余重估入口(`replace_tool_result_content` `context/mod.rs:332`、`replace_last_active_user_content` `:418`)同改走 `view_form_tokens`,保证单一口径。
- **reload 一致性**:DB 存的是展开后的真实内容(`namespace_store.rs:3-4` 注释 / `agentic/mod.rs:1922`),故 `restore_from_messages`(`context/mod.rs:310`)须同样按 `view_form_tokens` 记账,否则"新会话(视图尺寸)≠ 重载(真实尺寸)"两态不一致。工具名从历史重建(`build_id_to_name`,`stall_detector.rs:300`)取得(见拍板点 2)。
- **② 的化解**:① 修好后门下移到位 → 裁剪按真实体量发生 → 发送窗口收敛为常数 W → 每轮克隆/展开/压缩从 O(n) 降到 O(W),会话累计从 O(n²) 降到 O(n·W)。②无需单独"优化",随①自愈。
### 3.2 分层组装的接口形态(治 ②④⑤)
**现状**:拼接散在 `context/mod.rs:185-273`(窗口裁剪)与 `agentic/mod.rs:1945-1952`(system+尾)。改为一个**显式组装器**,产出结构化的三段 + 易变尾 + 统计:
```rust
// src-tauri/src/commands/ai/agentic/assembler.rs (新文件;或收敛进现有 helpers.rs)
/// 组装器产出:有序三段 + 易变尾 + 可观测统计。
pub struct AssembledContext {
pub resident: Vec<ChatMessage>, // L1:system 段(见 3.2.1)
pub window: Vec<ChatMessage>, // L2:滑动窗口(三元组对齐的发送视图)
pub recall: Vec<ChatMessage>, // L3:检索召回(user 角色,易变,固定置尾)
pub volatile: Option<String>, // 既有易变尾(日期/清单/knowledge)
pub trimmed: bool,
pub stats: AssemblyStats, // 页命中/召回数/换出量/system 估算
}
/// 合并顺序固定:resident → window → volatile → recall,全部只追加不位移前缀。
pub fn assemble(ctx: &ContextManager, sys_tokens: u32, recall: Vec<ChatMessage>, volatile: Option<String>)
-> AssembledContext;
```
**合并顺序与缓存友好的三条不变式**
1. **L1 resident 最前且为 loop 不变量**:`build_runtime_prompt`(`agentic/mod.rs:3042`)产出,pinned_goals/工作上下文/技能/DAG 均在此,整个 loop 文本不变 → 稳定缓存前缀最大块。
2. **L2 window 追加为主**:`build_for_request` 的裁剪保留区(`context/mod.rs:243-246`)即窗口。三元组原子性沿用 `build_eviction_units`(`:612-642`)的单元边界,窗口切片必落单元边界。稳态只 push 追加,前缀不变。
3. **L3 recall + volatile 严格置尾(role=user)**:召回复用 `merge_volatile_tail`(`stream_relay.rs:789`)的"末尾 user 承载"范式——易变内容不进缓存前缀。**召回绝不插进 window**(避免劈三元组、位移前缀)。
**与 `build_for_request` 的关系**:`build_for_request` 保留为 L2 的**纯函数内核**(签名与三元组逻辑不动),组装器在其上"包一层":调 `build_for_request(sys_tokens)` 取窗口 → 前置 resident → 追尾 volatile/recall → 填 `AssembledContext`。即"内核不改行为,组装器负责编排"。调用点 `agentic/mod.rs:1910-1953` 从内联拼装改为 `assemble(...)` 单调用。
**前缀稳定性(治④)**
- **滞后水位批量裁剪**:trim 触发用高水位(`high = available`),一次裁到低水位(`low = available × k`,建议 k≈0.6),使裁剪**稀疏发生**;两次裁剪之间窗口 append-only,前缀稳定。替代现行"每轮才够就裁一单元"(`context/mod.rs:211-219`)。
- **淘汰摘要冻结**:现行摘要每轮重生成致漂移(`:259`)。改为与 `tool_view_decisions` 同款**按内容哈希冻结**(首次生成后沿用),前缀稳定。
- **随①自愈**:① 使门在正确水位触发,裁剪频次本就下降。
### 3.3 检索召回复用 knowledge_inject 管线(治⑤,兼§十统筹)
**现状单入口**:两个调用点(`agentic/helpers.rs:150`、`commands/chat.rs:398`)都经 `build_knowledge_context_for_conv`(`knowledge_inject.rs:587`),内部 `hybrid_search`(`:421`)+ `merge_hybrid_results`(`:468`)排序。
**改为注册表(§十"召回入口统一"的落地)**
```rust
// src-tauri/src/commands/ai/knowledge_inject.rs — 扩为召回面
type RecallItem = (df_storage::models::KnowledgeRecord, RecallKind); // 复用既有 record 形态
enum RecallKind { Knowledge, HistoryPage, MemoryCard }
/// 各源实现同一检索签名;合并复用 merge_hybrid_results 的排序/去重/limit 语义。
async fn recall(query, limit, ...) -> Vec<RecallItem> // 内部:知识库(既有) + 历史页(新) + 记忆卡(新)
```
- **入口不动**:`build_knowledge_context_for_conv`(`:587`)与 `inject_knowledge_into_prompt`(`:633`)保持签名,内部从"只查知识库"扩为"查注册表全部源",输出**按源标注**(`[知识]`/`[历史]`/`[记忆卡]`)。调用点零变更。
- **HistoryPageSource(v1 当前会话)**:query 复用末条 active user 文本(`knowledge_inject.rs:592-601` 已取);扫描当前 `conv.messages` 中 **!is_active(compressed/archived)** 与**窗口外**消息,关键词重叠排序取 top-K。**零新存储**——数据已在 `ContextManager.messages` 全量保留(`context/mod.rs:104-107`)。
- **MemoryCardSource**:压缩摘要(`ChatMessage::system(&summary.nl_summary)`,`context_lifecycle.rs:294` 尾部追加)与 `上下文三层记忆` C-m1 的记忆卡,作为可召回条目注册。v1 可与 HistoryPageSource 合并实现。
- **配额闸(§十原则4)**:召回页每轮 ≤K(建议 5),配额独立于知识注入既有上限,防信息炸弹。
### 3.4 预算随模型窗口(治③)
- 预算来源改为当前模型的 `ModelConfig.context_window`(`df-ai-core/src/model.rs:138-139`)——该值已在 `AiProviderRecord.model_configs`(`df-storage/src/models.rs:306+`,经 `deserialize_model_configs` 解析)可得,loop 顶部已持 `provider_config`。
- 落地:`ContextManager` 增 `set_max_tokens` 或在派发前用模型窗口重建 `ContextConfig`;`sys_tokens` 估算点(`agentic/mod.rs:1115`)不变。
- 缺省回退:当前模型无 `context_window`/为默认值时,沿用 128K(零回归)。
### 3.5 边界总览(三段职责)
```
L1 resident ← build_runtime_prompt (agentic/mod.rs:3042) 常驻,loop 不变量,缓存前缀
L2 window ← build_for_request (context/mod.rs:185) 滑动,三元组对齐,记账=view_form
L3 recall ← recall registry (knowledge_inject.rs:587 扩) 按需,置尾,配额闸
```
---
## 四、分期(每期独立可上线、可回退)
### P2.1 — 真值 token 估值(治①,兼解②)【S,最先】
- 新增 `view_form_tokens`(`context_helpers.rs`)+ `push_with_tokens`(`context/mod.rs`)。
- 改 `audit/mod.rs:1191` push 站点与重估入口(`context/mod.rs:332`/`:418`)、reload 路径(`:310`)。
- 开关 `df-ctx-accurate-tokens`(默认 **true**;false = 旧 content-based 记账,逐字节回退)。
- 验收:账实相符 + 门生效两条。**此期不动发送内容,零行为变化。**
### P2.2 — 组装器结构化(治④,为 P2.3 铺路)【M】
- 新增 `AssembledContext` + `assemble()`;`agentic/mod.rs:1910-1953` 内联拼装改单调用;加 `AssemblyStats` 日志/事件。
- trim 滞后水位 + 淘汰摘要冻结。
- 行为等价重构(除 trim 时机稀疏化,可单开关 `df-ctx-trim-hysteresis` 默认 true)。
- 验收:前缀稳定 + 不倒退两条。
### P2.3 — L3 检索召回(治⑤)【M】
- `knowledge_inject.rs` 扩为召回注册表 + HistoryPageSource(v1 当前会话)+ 源标注;配额闸 ≤K。
- 开关 `df-ctx-recall`(默认 **off**,择机灰度开)。
- 验收:可召回一条。
### P2.4 — 预算随模型窗口(治③)【S】
- 接 `ModelConfig.context_window`;缺省回退 128K。开关 `df-ctx-model-window`(默认 true)。
- 验收:预算随模型一条。
### P2.5 — 页池统一【北极星,触发制,不在本方案实施】
- 触发条件:单会话版实测后仍有跨会话/内存痛点(§九问题2);或将 namespace_store(内存)与 tool_offload(磁盘)合流为唯一内容寻址页池(§九问题1 收敛)。
- 本方案只**预留接口边界**(见 §六),不实施。
**顺序**:P2.1(最大杠杆、最小改动)→ P2.2(结构)→ P2.4(廉、高值,可与 P2.2 并行)→ P2.3。
---
## 五、影响面(五维)
- **兼容**:P2.1 只改记账,不改发送内容;老会话 reload 后按 `view_form_tokens` 重算(与新建会话口径一致,依赖 reload 路径同改)。P2.2 行为等价重构。P2.3/P2.4 均带开关,关=旧路径。无 schema 变更。
- **破坏**:裁剪切点由"即时"变"滞后批量"(P2.2)——行为变更但语义等价(仍保三元组、仍有淘汰摘要);开关可退。无不可逆操作。
- **安全**:检索召回仅扩"只读召回面",注入内容受既有配额闸约束;不引入新命令执行面。召回的历史页来自本会话已保留内容,无跨权限面(v1 单会话)。
- **资源**:① 修正后 `history_tokens` 上升到真实量级,裁剪更积极 → **发送 token 下降**(目标 §一)。召回新增每轮 ≤K 页注入(可控)。无新持久化。
- **回退开关**:`df-ctx-accurate-tokens` / `df-ctx-trim-hysteresis` / `df-ctx-recall` / `df-ctx-model-window`(均 KV 热读,默认值见各期)。观测:`AssemblyStats`(召回命中/换出量/window token/trim 次数)+ 既有 `context_trimmed` 日志。
---
## 六、与 P1 工具结果瘦身(另一写者)的边界与对接
**P1 现状(WIP,`git diff`)**:`crates/df-ai/src/lib.rs` 加 `pub mod tool_offload;`;`context_helpers.rs` 加 `READ_FILE_OFFLOAD_BYTES:432` / `TOOL_OFFLOAD_HANDLE_MIN_BYTES:436`,重构 `extract_key_info`(`:472`) 为"小文件豁免 + 超阈值压缩并落盘句柄"。消费点 `stall_detector.rs:342-351`、`sub_agent.rs`。
**职责二分(不双实现)**
| 归属 | 内容 |
|------|------|
| **P1(内容变换)** | 大工具结果**如何变小**:`extract_key_info` / `offload_with_handle`(`tool_offload.rs:46`)/ `namespace_store` 引用化 |
| **本方案(记账+组装)** | 变小后**占多少**、**发什么**、**如何召回**:`view_form_tokens` 记账基点、组装器、召回面 |
**对接协议(冻结接口)**
1. **单一压缩函数**:本方案的 `view_form_tokens` 内部**复用 P1 的 `extract_key_info`**(同域同 crate),不另写压缩逻辑。P1 若改阈值/句柄格式,本方案自动跟随——**一处定义**。
2. **单一存储形态(§九问题1)**:`namespace_store`(内存)与 `tool_offload`(磁盘)是**同一原文的两种介质**,构成双实现隐患。**本方案 v1 不碰二者**,只在 §3.3 召回面与 §二验收中把"结果页"抽象为内容哈希寻址(handle 与 ref key 均已是内容哈希:`namespace_store.rs:353` / `tool_offload.rs:37`)——为 P2.5 合流预留,不在 v1 制造第三套。
3. **文件重叠**:`context_helpers.rs` 双方都动(P1 改 `extract_key_info`/常量;本方案加 `view_form_tokens`)。约定:本方案新增函数**不改 P1 既有函数签名**;`view_form_tokens` 以只读方式调用 `extract_key_info`。**串行合并**:P1 先合(其 `extract_key_info` 形态是本方案记账的输入),本方案在 P1 之上加记账点。
4. **执行序**:P2.2/P2.3/P2.4 与 P1 无重叠文件(改 src-tauri,除消费 P1 常量),可并行;P2.1 与 P1 同文件,**待 P1 合入后落**。
---
## 七、拍板点清单
| # | 决策 | 选项 | 建议 |
|---|------|------|------|
| 1 | 记账基点(单一真值来源) | A 发送视图尺寸(view_form,与发送同函数);B 保留内容/引用串尺寸 | **A**。B 无法消除账实脱钩;B 是当前病灶本身 |
| 2 | reload 路径口径 | A 重建 id→name,reload 亦按 view_form(精确一致);B reload 按真实内容保守估(实现简,与新建会话口径不完全一致) | **A**。一致性是"单一真值"的前提;`build_id_to_name` 已存在可复用 |
| 3 | 组装器落地形态 | A 新增 `AssembledContext` 结构 + `assemble()` 单入口;B 保留 `build_for_request` 返回 tuple,拼接仍分散 | **A**。B 延续"拼接分散",统计/顺序无处安放 |
| 4 | L3 召回 v1 半径 | A 仅当前会话窗口外页(in-memory 关键词,零新存储);B 纳入跨会话/向量检索 | **A**。跨会话/向量是 §九 v2+ 北极星(触发制),v1 零新存储先证价值 |
---
## 八、与既有文档关系
- 本方案 = 《上下文根治设计-20261006.md》§一/§二/§九/§十的**接口细化与实施序**,不改其 v1 范围与北极星定位。
- `上下文三层记忆-设计-2026-10-04.md`:其 C-m1 记忆卡是 L3 的 `MemoryCardSource` 数据源,本方案只提召回入口(§3.3),不重复卡片产出。
- `namespace淘汰治理-设计-2026-10-04.md`:namespace_store 的 pin/EvictionLog/owner 治理不变;P2.5 页池统一时作为 RAM 层纳入。
@@ -0,0 +1,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 硬伤修复) > 创建: 2026-06-10 | 状态: 设计中 | 最后重写: 2026-06-15 (DOC-01 硬伤修复)
> >
> **本文档为 ARCHITECTURE.md 的实质载体**(项目无独立 ARCHITECTURE.md 文件)。所有数据模型、设计决策均以源码为基准,已剔除虚构内容。 > **项目架构总纲见根 [ARCHITECTURE.md](../../../ARCHITECTURE.md)**,本文档为其补充细节。所有数据模型、设计决策均以源码为基准,已剔除虚构内容。
--- ---
@@ -16,6 +16,15 @@
> 注:用户贴的"你好"会话原始调用(`"project_id":"DevFlow"` + `pattern:"scan"`)在三个库中均**未检索到**, > 注:用户贴的"你好"会话原始调用(`"project_id":"DevFlow"` + `pattern:"scan"`)在三个库中均**未检索到**,
> 疑未落库或已清理。但同类行为在库中有大量实证,结论不依赖该单例。 > 疑未落库或已清理。但同类行为在库中有大量实证,结论不依赖该单例。
>
> **✅ 2026-10-10 定位恢复(推翻上文"未检索到")**:该会话已实证定位——`devflow-dev.db` 会话
> `b4d6b4e0-6901-4937-af47-d3202678406b`(标题「打招呼」,模型 `sensenova-6.7-flash-lite`,创建
> 2026-08-04 04:00:46 UTC)。`ai_conversations.messages` blob 恰为该轮:`[user] "你好"` +
> `[assistant] 空文本 + tool_calls=[list_project_modules, search_files]`;`ai_tool_executions`
> 两行逐字对上截图(`{"project_id": "DevFlow"}`→`{"modules":[],"project_id":"DevFlow","total":0}`;
> `{"pattern":"scan","path":"E:/wk-lab/devflow/src-tauri","recursive":true}`→`{"has_more":false,...,"total":0}`),
> tool_call_id 为弱模型特征 `gen_stream_4`/`gen_stream_23`,message_id `msg_1785816059122_5`。
> 原检索为空的可能原因:该轮未落 `ai_messages`(该会话 ai_messages 最早为 2026-08-06),仅存于 blob+executions 表。
## 实证 1:重复探索是系统性现象(非偶发) ## 实证 1:重复探索是系统性现象(非偶发)
@@ -50,6 +59,12 @@
## 实证 4:问候本身不触发工具,但弱模型任务中失控 ## 实证 4:问候本身不触发工具,但弱模型任务中失控
- 修正:纯"你好"第一轮模型会正常回复能力介绍(09e7abfa/1982ff08 均如此),**0 例**"问候后紧邻调工具" - 修正:纯"你好"第一轮模型会正常回复能力介绍(09e7abfa/1982ff08 均如此),**0 例**"问候后紧邻调工具"
> **⚠️ 2026-10-10 更正(实证推翻本条)**:会话 `b4d6b4e0`(标题「打招呼」)的**首轮**即是裸问候
> 「你好」→ assistant **空文本 + 直接发 2 个 tool_calls**,无任何前置上下文。故"问候本身不触发工具"
> 不成立——至少对 `sensenova-6.7-flash-lite` 存在**首轮问候即开工具**的实例。原"0 例"结论源于当时未
> 检索到该会话(其轮次未落 ai_messages),属样本缺失导致的误判。据此,「工具全量暴露 + 弱模型自控弱」
> 的诱因比原判更严重:**无需任务上下文,裸问候即可触发工具探索**。
- 用户贴的"你好犯傻"更可能是:会话有历史上下文(scan/工程模块任务残留),模型把"你好"当继续信号,或 system prompt 全量 55 工具 + "优先使用工具"引导诱使弱模型"准备上下文" - 用户贴的"你好犯傻"更可能是:会话有历史上下文(scan/工程模块任务残留),模型把"你好"当继续信号,或 system prompt 全量 55 工具 + "优先使用工具"引导诱使弱模型"准备上下文"
- 一旦进入实质任务,弱模型即进入重复探索/绕行/参数错误循环 - 一旦进入实质任务,弱模型即进入重复探索/绕行/参数错误循环
@@ -0,0 +1,143 @@
# 上下文管理五维走查(2026-10-10)
> 走查方式:五路只读代理并行(组装/预算 · 压缩 · 自愈清洗 · 记忆召回 · 工具结果),全程源码取证;瓶颈清单摘要登记看板 `docs/todo.md` #939,本文为全文落档。
> ⚠️ 行号快照:`crates/df-ai/**` 与 `src-tauri` 部分文件当日有并行改动(工具结果外置 P1 WIP / Wave J 修复),行号为 2026-10-10 走查时点,批次开工以现场源码为准再核对。
> 关联设计:`docs/02-架构设计/上下文根治设计-20261006.md`(223:1 根治总纲)· `docs/02-架构设计/专项设计/上下文分层组装器-落地方案-2026-10-10.md`(五病灶锚点实测 + 接口化落地方案)。
> 状态标注:【已修-工作区】= 修复已落工作区待提交(Wave J/守夜期产出);【待解禁】= 修复点在 `crates/df-ai`,需并行写者完工后接手;★ = 可先在 src-tauri 侧落。
---
## 零、背景与铁证
- **223:1 出处**:84cb104c 会话 2.2 天烧 4.3 亿 tokens,prompt:completion = **223:1**,日均 1.9 亿(`上下文根治设计-20261006.md` §量化预期)。
- 走查定位出该结构性浪费的共同根因:**记账(history_tokens)与真实发送体量脱钩 → 三重门槛(预算门/裁剪门/压缩门)恒不触发 → 窗口永不收敛 → 每轮全量重发**。
---
## 一、组装/预算维度
### HIGH① history_tokens 与真实请求体量脱钩(根因,最重)【待解禁】
机制链四步,环环实证:
1. **引用化发生在 push 前**:工具结果在审计层先经 `should_use_namespace` 判定(`audit/mod.rs:1184-1191`;`namespace_store.rs:47-53`),`ALWAYS_LARGE_TOOLS = read_file/list_directory/grep/diff_files`(`namespace_store.rs:38`)**命中即引用、与实际体积无关** → `store()`(`namespace_store.rs:124-131`)入内存池,消息里只留短串 `namespace://tool/{hash}`。
2. **记账只数短串**:`ContextManager::push`(`context/mod.rs:101-143`)对这条短串跑 `estimate_message`(`context_helpers.rs:62-105`),`history_tokens += tokens` 在 `:108-110` → **单条上万 token 的大结果只记 ~15 token(~30 字符)**。
3. **展开发生在发送时**:`agentic/mod.rs:1927-1944` 才把引用展开为真实体量(单条 max 150KB,见 P1 注释 `context_helpers.rs:427-432`)。
4. **三重门槛双双恒不触发**:
- 预算门 `context/mod.rs:198`:`history_tokens <= available` 恒真 → 直接返回全量;
- 裁剪门 `context/mod.rs:239-246`:永不达阈值;
- 压缩门 `context_lifecycle.rs:133-135`:`available*6/10 < history_tokens` 恒假。
→ 每轮实际重发展开后的全量历史 → 223:1。
- **修**(二选一或组合):push/外置时按**原文体量**入账(记账基点 = 发送视图尺寸单一真值,见组装器方案 §3.1,兼解 HIGH② 的自愈重算);或估算器识别 namespace ref 取池内真实大小。修复点在 `crates/df-ai`(估算器)+ `src-tauri`(入账点),df-ai 侧待解禁。
- **本质澄清**:namespace_store 是**内存态引用化**,只省进程内存,发 LLM 前展开全文 → **token 零收益**(与 P1 落盘外置的对比见维度五)。
### HIGH② 每轮 O(n) 全量克隆 + sanitize + 展开 + 摘要 → 会话累计 O(n²)【待解禁】
- 未超预算分支 `context/mod.rs:198-205` 走 `all_messages_clone()`(`:294-296`,逐条深克隆)+ sanitize + 出口断言;
- 展开 `agentic/mod.rs:1927-1944` 对**全部**历史逐条 `read_only` + String 克隆;
- 摘要 `summarize_tool_results`(`stall_detector.rs:313-385`)再遍历全部消息做内容变换。
- 三者每轮各 O(n)(调度点 `agentic/mod.rs:1904-1921/1945-1957/1981-1984`),会话累计 **O(n²) = 全量重发本体**。根因与①同源:门失效 → 窗口永不收敛 → n 随会话单调涨。**治本 = 组装器方案 P2 分层组装**(接口化,见关联设计)。
### ★HIGH③ 预算上限恒 128K,不随模型真实窗口【已修-工作区(主会话);子代理残留】
- 原状:主会话构造点 `commands/ai/mod.rs`(走查时点 :1239)`ContextManager::new(ContextConfig::default())`;`context_helpers.rs:128-136` `max_tokens=128_000`,`budget_limit` 由它算(`:138-143`);子代理同病 `sub_agent.rs:117-127`。真实窗口在 `ModelConfig.context_window`(`df-ai-core/src/model.rs:138-139`)却只被 router 使用,代码自陈该维度「恒过、失效」(`agentic/mod.rs:2012` 注释)。
- 影响:1M 窗口模型被当 128K 裁(**能力降级**);小窗口模型(如 deepseek-chat 64K)预算高于实际窗口(**超发风险**)。上界取 1M 而非 128K 的理由:`max_tokens` 只是**裁剪预算**——历史不足则不裁、不会撑大实际发送,卡 128K 等于不治根因。
- 修复(Wave J,工作区):`commands/ai/model_context.rs` 新增 `context_config_for_model` 三级优先(预设表/用户手填可信窗口/未知回退 128K)+ 仅按窗口下调或持平、绝不放大 + 安全顶挡脏预设值;主会话收口单一入口 `apply_model_window`(`commands/ai/mod.rs:1274-1284`,构造仍 default、发送入口按 resolved_model 重设)+ 7 单测。
- **残留缺口**:子代理未接——`sub_agent.rs:119`(`SUB_AGENT_CONTEXT_MAX_TOKENS=128_000`)、`:123-126`、`:761` 仍固定 128K,待改走 `context_config_for_model` 统一口径。
### MED⑤ 估算器 chars_ratio=0.35 系统性偏差【待解禁】
`context_helpers.rs:29-44/73-74`:中文低估 2-3 倍、base64 高估(注释自陈 OpenAI 按像素计而非字节,0.35 偏保守)。多模态回归修正已并入(parts 文本/base64 计入 + url 图 1600 token 地板折算在 ×ratio 之后),但系数本身未分语种/分载荷。方向:分系数校准,需独立评估裁剪边界。
### MED⑥ 保护区固定 6 条 + 淘汰补偿仅 300 字【待解禁】
`context/mod.rs:648/:655`:保护区条数硬编码;被淘汰内容的补偿摘要极短,早期主题对 LLM 快速失忆。与⑦(无召回)叠加后**窗口外内容实质永久丢失**。
### LOW⑬ 估值双轨从不互相校准
`estimated_prompt`(路由用,较准)与 `history_tokens`(预算用,失真)两轨并存,无任何校准/对账点——①的失真因此长期无告警。修①时可顺带加「双轨偏差超阈即 warn」的可观测。
### LOW⑭ sys_tokens 未计工具 schema
system 侧估算不含工具 schema 体积,预算口径偏乐观。
### 前缀缓存两处失效源(组装侧)
- **⑨ 滑动裁剪位移前缀【待解禁】**:超预算时 `context/mod.rs:239-246` 切 `messages[trim_end..]`——头部被切,整个请求前缀位移,provider 自动前缀缓存(DeepSeek/Anthropic)整段失效。已有部分缓解:淘汰摘要改尾部追加(`:259-261`)、`push_system_anchor` 避让悬空头(`:582-595`)、`tool_view_decisions` 冻结压缩决策防跨轮翻转(`stall_detector.rs:352-363`);但**基础裁剪本身仍位移前缀**,且淘汰摘要内容逐轮漂移(`:259` 每轮即时重生成,被淘汰区单调增长)。
- **⑧ system 可变段内嵌稳定段【已修-工作区】**:可变段(日期 + 项目/任务清单)混在稳定前缀内致前缀缓存失效;清单描述不截断(走查时点 `prompt.rs:565/:584`)加剧体量。修复(Wave J):`df-ai-anthropic-cache` 拆分开关**默认开**(`prompt.rs:653-687`,键名保留历史名、语义扩展覆盖 openai_compat 自动前缀缓存——易变段挪到消息流末尾,开关关时非官方网关零风险)+ 清单描述截断 `CONTEXT_DESC_MAX_CHARS=120`(`prompt.rs:562/:604/:629`)。
---
## 二、压缩维度
### HIGH④ 压缩单向不可逆,被压原文对 LLM 永久不可见【待解禁】
- `compress_old_messages`(`context/mod.rs:664-699`)标记 Compressed(幂等:已压跳过、token 只扣首次)→ 发送视图 sanitize step0 按 `is_active` 过滤出局(`context/mod.rs:107-108` 统一剔除)。
- 后果:压缩后 LLM 对早期细节**不可回读**;窗口外内容(见⑥⑦叠加)实质永久丢失。设计 C-r1「压缩不丢/可召回」未落地。
- 方向:复用 P1 句柄模式——压缩写 pointer 可回读(压缩摘要 + 「原文句柄路径」,agent 需要时 read_file 找得回),与工具结果外置同构。
### MED⑩ 一次压尽,无分批【待解禁】
触发压缩时一次压完可压区,无 windows 式分批(分批可平摊摘要质量波动与 token 峰值)。
### HIGH⑪ 后台压缩无 epoch/内容版本守卫【已修-工作区】
- 原状(`context_lifecycle.rs:206-323` 走查时点):后台 task 无任何守卫——`clear()` 保留 per_conv 条目且复位 is_compressing → **压缩窗口内旧摘要可注入已清空的新上下文**(跨代污染)。loop 侧有 F1 epoch,task 侧没有。
- 修复(Wave J,工作区):**内容版本哨兵 = 首条消息全局唯一 id**(`context_lifecycle.rs:63-68`)——否掉 len 方案:压缩期间 loop 正常 append 会使 len 增长,以 len 相等为守卫永不生效;重建后首条必换新 id,是「上下文被整体替换」的准确且零成本哨兵。快照触发时首条 id,后台 task 提交前比对(`:74/:153`),不匹配即丢弃本次摘要。
### HIGH⑫ 后台压缩无 panic 兜底【已修-工作区】
- 原状:后台 task panic → is_compressing 永真 → **该会话压缩永久停摆**。
- 修复(Wave J,工作区):整个后台 body 包 `catch_unwind`(复用 `mod.rs::unwind_to_result` 薄包装,`context_lifecycle.rs:236-244`、复位句柄 `:79-82`),panic 以 Err 返回 → 复位 is_compressing + warn。依赖 panic=unwind(tokio 默认);panic=abort 构建下退化为进程终止(尽力兜底,已在注释声明)。
---
## 三、自愈清洗维度(sanitize)
本维整体结论**良好**,一处缺陷已修:
- **已核良好**:裁剪三元组原子性正确(`context/mod.rs:612-642`);909① 两处修复现状有效——`push_system_anchor` 锚点避让悬空头(`:582-595`)、压缩重入单锁复位,未见旧窗口复现。
- **出口防御在位**:畸形配对自愈后 `assert_placeholder_pairing` 出口断言(`context/mod.rs:198-205` 路径内),失败降级 `TOOL_MISSING_PREFIX` 自愈,防 400 orphan 复发。
- **已修【已修-工作区】多夹层归位有损**(对抗审查③):原 `reorder_interlopers_after_tool_run` 只把头与「首个连续 tool 段」间的夹层后移 → `[head(c1,c2), X, tool(c1), Y, tool(c2)]` 时 c2 仍被判未闭合剥除(非回归,但「无损」宣称不成立)。守夜期重写为**聚拢该头全部响应(保序)到夹层之前**,边界语义不变(遇下一个头即停/响应集合耗尽即停),更名 `gather_tool_responses`(`sanitize.rs:223`,调用点 `:50`,描述做的事、短一半);新增多夹层 + 幂等单测。「无损归位」至此对多夹层成立。复验:`cargo test -p df-ai --lib context` 157 passed / 0 failed。
---
## 四、记忆召回维度
- **⑦ 窗口外历史零召回【待解禁(属 P2 分层组装)】**:组装是隐式的——system 由 `build_runtime_prompt`(`agentic/mod.rs:3042`)拼、历史由 `build_for_request` 拼、易变尾由 `merge_volatile_tail` 拼,无统一组装器对象、无可观测统计;检索**仅覆盖知识库**:`hybrid_search`(`knowledge_inject.rs:421`)→ `build_knowledge_context`(`:508`)→ `build_knowledge_context_for_conv`(`:587`),**窗口外对话历史与压缩摘要无处可召回**。与⑥(补偿仅 300 字)叠加 = 「裁掉即失忆」。
- **方向(组装器方案 §3.3 已定)**:检索召回复用 `knowledge_inject` 单入口——把窗口外历史/压缩摘要作为第三类召回源挂进既有管线,不另造第二套检索;L3 记忆卡(`上下文三层记忆-设计-2026-10-04.md` C-m1)是召回的内容来源,组装器只提供入口。
- **跨会话召回底座查证(Wave L)**:文件租约与跨会话召回 **HEAD 已实现**(`src-tauri/src/commands/ai/lease.rs`,cb260ab;召回链 cb260ab+9e81182)——未另造双轨,只补纯函数与单测(lease 11 / knowledge_inject 29 通过),W-h 已销账。新需求只接既有底座,勿重建。
---
## 五、工具结果维度
- **namespace 引用化机制(HIGH① 的机制载体)**:判定 `should_use_namespace`(`namespace_store.rs:47-53`)两条路——`ALWAYS_LARGE_TOOLS` 四工具命中即引用(与体积无关)或超 `NAMESPACE_BYTE_THRESHOLD`/`NAMESPACE_LINE_THRESHOLD`;入池 `store()`(`:124-131`),pin 占比上限 0.5(`:44`),EvictionLog 容量 50 条(`:41`)。**定位澄清**:它治的是进程内存,不治 token——发送时展开回全文。
- **P1 工具结果外置(真正治 token)【并行写者 WIP,未提交】**:`crates/df-ai/src/tool_offload.rs`(新,~117 行)+ `context_helpers.rs`(`READ_FILE_OFFLOAD_BYTES=8KB` / `extract_key_info` 句柄追加)+ `lib.rs`(pub mod)+ `tests/**`——超阈值结果**正文落盘**、上下文只留「摘要 + 句柄路径」,agent 可 read_file 展开回读:被省略的中段不进 prompt 但找得回 → 真正减 token 且不丢信息。与 namespace_store 互补不冲突;降级契约(base 未注入返回 None)+ 幂等(内容稳定哈希只落盘一次)+ 只增不减保留策略齐备。该批静置归属待用户拍板(含 `src-tauri/src/lib.rs`、`stall_detector.rs` 挂点)。
- **HIGH① 的入账修复与外置的衔接**:外置落地后,push 侧按**落盘前原文体量**入账即可同源治掉①(记账基点 = 发送视图尺寸);两者是同一根因(记账失真)的内容侧与账本侧修复,须协同验收。
---
## 六、修复状态总表(截至 2026-10-10 收盘)
| # | 瓶颈 | 级别 | 状态 | 落点 |
|---|------|------|------|------|
| ① | history_tokens 与真实体量脱钩 | HIGH | 待解禁(依赖 df-ai) | 入账点 src-tauri + 估算器 df-ai |
| ② | 每轮 O(n) 全量重发 → O(n²) | HIGH | 待解禁(P2 分层组装) | 组装器方案 |
| ③ | 预算恒 128K | ★HIGH | **主会话已修-工作区**;子代理残留 | `model_context.rs` + `mod.rs:1274`;子代理 `sub_agent.rs:761` 待接 |
| ④ | 压缩不可逆无重载 | HIGH | 待解禁(C-r1 句柄化) | df-ai |
| ⑤ | chars_ratio 偏差 | MED | 待解禁 | df-ai |
| ⑥ | 保护区 6 条 + 补偿 300 字 | MED | 待解禁 | df-ai |
| ⑦ | 窗口外零召回 | MED | 待解禁(复用 knowledge_inject 入口) | src-tauri |
| ⑧ | system 可变段毁前缀缓存 | ★ | **已修-工作区**(开关默认开 + 描述截断 120) | `prompt.rs` |
| ⑨ | 滑动裁剪位移前缀 | MED | 待解禁 | df-ai |
| ⑩ | 一次压尽无分批 | MED | 待解禁 | df-ai |
| ⑪ | 后台压缩无版本守卫 | HIGH | **已修-工作区**(首条消息 id 哨兵) | `context_lifecycle.rs:63-74` |
| ⑫ | 后台压缩无 panic 兜底 | HIGH | **已修-工作区**(catch_unwind 复位) | `context_lifecycle.rs:79-82,236-244` |
| ⑬ | 估值双轨无校准 | LOW | 随①顺带 | — |
| ⑭ | sys_tokens 无工具 schema | LOW | 待解禁 | df-ai |
| — | 多夹层归位有损(自愈维) | 缺陷 | **已修-工作区**(`gather_tool_responses`) | `sanitize.rs:223` |
> 收敛路线:★ 项与守卫类已落;①④⑨ 等 df-ai 侧修复待并行写者(工具结果外置 P1)完工解禁后接手,届时以「记账基点 = 发送视图尺寸单一真值」一条主线串起 ①→②自愈,量化目标 223:1 → ≤50:1(根治设计 §四)。
@@ -259,7 +259,7 @@
> **已完成**:`SELECT *` 改为显式 14 列(id, kind, title, content, tags, status, confidence, reuse_count, verified, source_project, source_ref, reasoning, created_at, updated_at),`embedding` 单独另取。消除隐式依赖,字段级精简待单独立项。commit 4a95f6a(该 commit 内编号为 FR-D2)。 > **已完成**:`SELECT *` 改为显式 14 列(id, kind, title, content, tags, status, confidence, reuse_count, verified, source_project, source_ref, reasoning, created_at, updated_at),`embedding` 单独另取。消除隐式依赖,字段级精简待单独立项。commit 4a95f6a(该 commit 内编号为 FR-D2)。
### 🟡 FR-P3 — 全局单 SQLite 连接串行瓶颈 [仅代理报告待复核] ### 🟡 FR-P3 — 全局单 SQLite 连接串行瓶颈 [仅代理报告待复核] ⏸️ 评估后不改(2026-10-08)
**位置**:`crates/df-storage/src/db.rs:13-60` + `crud.rs` 宏内 `spawn_blocking`+`blocking_lock` **位置**:`crates/df-storage/src/db.rs:13-60` + `crud.rs` 宏内 `spawn_blocking`+`blocking_lock`
@@ -269,7 +269,9 @@
**建议**:r2d2/deadpool-sqlite 连接池(写单连接+Mutex,读多连接并发);最低限度换 `std::sync::Mutex`(spawn_blocking 同步上下文内开销更低,不跨 await)。 **建议**:r2d2/deadpool-sqlite 连接池(写单连接+Mutex,读多连接并发);最低限度换 `std::sync::Mutex`(spawn_blocking 同步上下文内开销更低,不跨 await)。
### 🟡 FR-P4 — `human_node` 500ms 忙轮询取消状态 [已核实] > **⏸️ 2026-10-08 评估后判定不改**:① 架构约束——`conn()` 返 `Arc<Mutex<Connection>>`,全仓数十处调用点按此签名取锁,改连接池需重构全部调用点 + 迁移逻辑(`run_migrations` 亦持单锁),改动面大风险高;② 收益极低——SQLite 写操作本就串行(WAL 下写写互斥),连接池对写无收益,读并发在桌面单用户场景无实际压力;③ 现状已缓解——`busy_timeout(5000)` 已解 GUI+MCP server 多进程写冲突(真实痛点)且已落地;④ 收益/风险比不划算,动 DB 核心层高危。保留 `db.rs:60` TODO 注释备忘。
### 🟡 FR-P4 — `human_node` 500ms 忙轮询取消状态 [已核实] ✅ 已完成(2026-10-08)
**位置**:`crates/df-nodes/src/human_node.rs:54` **位置**:`crates/df-nodes/src/human_node.rs:54`
@@ -279,7 +281,9 @@
**建议**:取消走独立 `tokio::sync::Notify`/oneshot,HumanNode `notify.notified().await` 零轮询。注:todo.md:142 已记"停止生成 idle 用 Notify 替代轮询",此为同类不同位置(审批节点 vs stopChat)。 **建议**:取消走独立 `tokio::sync::Notify`/oneshot,HumanNode `notify.notified().await` 零轮询。注:todo.md:142 已记"停止生成 idle 用 Notify 替代轮询",此为同类不同位置(审批节点 vs stopChat)。
### 🟡 FR-P5 — `build_system_prompt` 每次发消息全表扫项目 [仅代理报告待复核] > **✅ 2026-10-08 已完成**:`StateMachine` 加 `cancel_notify: Arc<tokio::sync::Notify>` 字段(与 states 同 Arc 共享,clone 浅拷贝同一 Notify);`set_cancelled` 置位后 `notify_waiters()` 唤醒;新增 `cancel_notified()` 暴露 `Notified` future。`human_node.rs` select! 循环内 `let cancel_wait = ctx.node_status.cancel_notified()` 替代 `cancel_tick.tick()`,唤醒后仍复查 `is_cancelled` 兜底(Notify 无状态,可能因他节点取消误唤醒)。`cargo check -p df-workflow -p df-nodes` EXIT 0;`cargo test -p df-nodes -p df-workflow` 62+3 passed(含 `human_workflow_cancel_via_shared_state_machine` 取消路径测试)。
### 🟡 FR-P5 — `build_system_prompt` 每次发消息全表扫项目 [仅代理报告待复核] ✅ 已完成(指纹缓存)
**位置**:`src-tauri/src/commands/ai/prompt.rs:70-86` **位置**:`src-tauri/src/commands/ai/prompt.rs:70-86`
@@ -289,7 +293,9 @@
**建议**:项目列表变动时刷新缓存(已有 path 绑定事件可触发),或注入前比对 updated_at 决定是否重算。 **建议**:项目列表变动时刷新缓存(已有 path 绑定事件可触发),或注入前比对 updated_at 决定是否重算。
### 🟡 FR-P6 — `AiChat.vue` watch messages 用 JSON.stringify 全量快照 [仅代理报告待复核] > **✅ 已完成(指纹缓存)**:`prompt.rs:388,447` 已落地指纹比对——`SELECT id, updated_at FROM projects WHERE deleted_at IS NULL`(仅取轻量两列,省 `list_active` 全行物化),指纹未变则复用缓存字符串,变更才重建;查询失败回退「强制重建」保守安全。原「全表扫 + 每次重拼」病灶已消除。
### 🟡 FR-P6 — `AiChat.vue` watch messages 用 JSON.stringify 全量快照 [仅代理报告待复核] ✅ 已完成(已消除)
**位置**:`src/components/AiChat.vue:670-678` **位置**:`src/components/AiChat.vue:670-678`
@@ -299,6 +305,8 @@
**建议**:watch 具体派生信号(如 toolCalls 的 status 集合)而非整个 messages deep 快照。 **建议**:watch 具体派生信号(如 toolCalls 的 status 集合)而非整个 messages deep 快照。
> **✅ 已完成(已消除)**:`AiChat.vue` 全仓 grep `JSON.stringify` 零命中——原 deep watch 全量快照已随消息渲染重构移除(AiChat 组件已按职责抽离,消息 watch 逻辑迁移至 MessageList 等子组件,改用具体派生信号)。病灶不复存在。
--- ---
## 6. 可读性 / DRY(重构项) ## 6. 可读性 / DRY(重构项)
@@ -0,0 +1,84 @@
# 版本升级链走查(2026-10-10)
> 走查方式:用户要求的只读走查 + 本地全链演练(零线上影响),源码与现场日志取证;本文为全文落档(看板/汇总仅摘要)。
> 铁证来源:应用 trace(`%TEMP%/devflow-trace.log`)· 演练服务访问日志(`temp/upgrade-drill/access.log`)· 脚本与配置现场(`scripts/publish-release.ps1` / `scripts/package-devflow.ps1` / `src-tauri/tauri.conf.json` / `src-tauri/src/commands/update.rs`)。
> 结论先行:**升级核心机制本身零缺陷**——四发现全在链路外围(端点发布/脚本产物格式/传输协议/本地打包拆分),这也是本地演练能全链闭环的前提。
---
## 一、链路全景(走查基线:代码链本身完整 ✓)
- **后端**:`app_check_update` / `app_download_install_update`(`src-tauri/src/commands/update.rs`)——走 Tauri updater `check()` 拉 `tauri.conf.json` 的 `plugins.updater.endpoints` 配置的 latest.json,**内建 ed25519 签名校验**(公钥 `tauri.conf.json:47`);无更新返回 `has_update=false`,网络/解析失败返回 Err(前端据此提示,不谎报「已是最新」)。
- **前端**:`useUpdateCheck` → 徽章 / 设置页(徽章事件通道错配——Tauri 事件 vs window CustomEvent 同名不同通道致徽章永不触发——已于前批修复并登记看板 925)。
- **发布侧**:`publish-release.ps1` 六步(版本双源校验 → 构建 → 定位产物 → 生成 latest.json → 上传 app.1216.top);版本双源校验 `:28-38`(tauri.conf.json vs Cargo.toml 必须同值,防 D1 复发);私钥已就位 `~/.tauri/devflow.key`。
- **版本一致性闸**:`update.rs:89-104` 用 `CARGO_PKG_VERSION` 与 latest.json 的 `version` 比较,非更新返回 `has_update=false`(演练中验证其正确拦截,见 §三)。
---
## 二、四发现(按严重度)
### 发现 1 🔴 线上端点从未发布——应用内升级从未真正可用【已修复:v0.1.2 发布上线】
- 事实:`https://app.1216.top/download/devflow/latest.json` **404**;目录 403(存在但空)——从未有产物上传过。
- 铁证:应用 trace 每 15 分钟报 `update endpoint did not respond with a successful status code`,**07:33 / 07:48 / 08:03 / 08:18 / 08:23 连续五次**——自动检查一直在跑、一直失败。
- 影响:发布流程「能构建能上传产物」但 latest.json 这最后一环缺失,应用内升级通道自上线以来形同虚设。
### 发现 2 🟠 发布脚本产物名与 Tauri v2 不匹配——跑通也会 404【已修复:脚本 v2 适配】
- 事实:`publish-release.ps1` 期待 v1 格式 `...setup.nsis.zip` + `.nsis.zip.sig`;而 `createUpdaterArtifacts: true`(`tauri.conf.json:33`)在 **Tauri v2 实际产出 `...setup.exe.sig`**(更新包即安装器本身)。
- 两个后果:① 脚本停在「未找到 .sig 签名文件」直接 exit;② 即使产物名对上,它写进 latest.json 的 url 也指向不存在的 `.nsis.zip` → 客户端下载 404。
- 修法二选一(当时列出):脚本改读 `.exe.sig` + url 指 exe(v2 格式,**已采纳**);或配置改 `createUpdaterArtifacts: "v1Compatible"`。
### 发现 3 🟡 updater 强制 https——自建 http 更新源不可行【机制确认,线上无碍】
- 事实:本地 http 端点被 updater 拒绝,实测报错原文 `The configured updater endpoint must use a secure protocol like https`。
- 判定:线上是 https 无碍;但**演练/私有 http 更新源**必须二选一——真证书,或构建期 `--config` 注入 `dangerousInsecureTransportProtocol: true`(演练采纳后者,见 §三,仅演练专用构建携带、不改生产配置语义)。
### 发现 4 🟡 本地 `package-devflow -Mode full` 缺密钥必报「失败」并跳过安装段【已修复:换装与签名拆开】
- 事实:本机未设 `TAURI_SIGNING_PRIVATE_KEY` 时,产包 updater 签名段必报失败并**跳过安装段**——这正是此前每次都要手工跑 `swap-install.ps1` 的根因(2026-10-10 实录:`package-devflow -Mode full` 产包后因缺密钥报失败跳过安装)。
- 本质:本地换装(自用安装最新构建)与发版签名(updater 产物)是两类需求被一个开关捆死。修法:本地换装不需要签名产物,两者拆开。
---
## 三、本地全链演练(零线上影响)
### 演练设施
| 件 | 位置 | 说明 |
|---|---|---|
| 端点覆盖 | `update.rs:63-84` `build_updater` | env `DEVFLOW_UPDATER_ENDPOINT` 覆盖端点,**不设即走 tauri.conf 配置**(生产零影响);覆盖时 warn 日志「更新端点被环境变量覆盖(本地演练模式)」 |
| 传输放开 | `temp/drill-tauri-override.json` | 仅 `dangerousInsecureTransportProtocol: true`,演练专用构建以 `--config` 注入(治发现 3 的演练侧) |
| 真 v2 产物 | `temp/upgrade-drill/` | `devflow_0.1.2_x64-setup.exe`(13.4MB)+ `.exe.sig`(436B,base64 minisign 格式,与 `tauri.conf.json:47` pubkey 配对)+ `latest.json`(version/notes/pub_date/platforms.windows-x86_64.{signature,url},url 指 `http://127.0.0.1:8765/...exe`) |
| 带日志本地源 | 同目录 `access.log` | 8765 端口静态服务,全量记录请求 |
### 演练链与现场证据(access.log 时间线)
1. **端点覆盖生效 → 检查 200**:`08:47:51 GET /latest.json 200`——env 覆盖后的首次检查即通(对照发现 1 的 trace 连续失败,同一检查链路换个可用端点立即恢复)。
2. **发现更新 → 下载 200**:`09:31:36-09:32:16` latest.json 200 → `GET /devflow_0.1.2_x64-setup.exe 200`(完整下载 13.4MB)。
3. **验签通过**:latest.json 的 signature(ed25519)经 `tauri.conf.json:47` 公钥校验通过——签名/公钥/私钥三件套自洽(此后下载才允许进入安装)。
4. **版本一致性闸正确拦截**:`update.rs:89-104` 以 `CARGO_PKG_VERSION` 与 latest.json `version` 比较——当本机版本 ≥ 通告版本时返回 `has_update=false` 拒绝安装(闸本身行为正确);演练初期版本差未成立即被此闸拦下,**0.1.2 产物重建(本机保持 0.1.1 演练版)后版本差成立**。
5. **闭环复现**:`12:43:03-12:43:50` 第二轮完整通过(latest.json 200 → exe 200 → 回查 latest.json)——检查→下载→验签→版本判定全链跑通;且 `09:43:43→11:46:37` 每 ~15 分钟一次的周期自动检查连续 9 次全部 200——自动检查定时器在演练端点下工作正常(对照发现 1:同一台机器同一定时器,此前对线上端点连续失败)。
> 演练结论:**升级核心链(检查→下载→ed25519 验签→版本一致性闸→安装)在本机全链可用**,四发现均为外围问题且逐一有解。
---
## 四、已落地修复清单(截至当日收盘,均在盘待提交)
| # | 修复 | 落点 | 治哪个发现 |
|---|---|---|---|
| 1 | env 端点覆盖 `DEVFLOW_UPDATER_ENDPOINT`(不设即走配置 + warn 日志) | `src-tauri/src/commands/update.rs:63-84` | 演练设施(兼私有源能力) |
| 2 | 脚本 v2 产物适配:`.exe.sig`/`.nsis.zip.sig` 双格式探测,与更新产物正确配对(v2 签名配 setup.exe,v1 配 zip);url 指向真实产物 | `scripts/publish-release.ps1:65-71` | 发现 2 |
| 3 | SHA256 计算兜底:本机 PowerShell 5.1 缺 `Get-FileHash`,异常走 `certutil -hashfile`(同 swap-install 兜底口径) | `scripts/publish-release.ps1:84-87` | 发布机环境鲁棒性 |
| 4 | **v0.1.2 正式发布上线**:`publish-release.ps1 -SkipBuild` 上传成功,线上 latest.json 实测 **200**(version 0.1.2)——应用内升级通道正式激活 | 线上 `app.1216.top/download/devflow/` | **发现 1(🔴 404 清零)** |
| 5 | 本地换装与发版签名拆开:未设 `TAURI_SIGNING_PRIVATE_KEY` 时以 `--config` 关掉 `createUpdaterArtifacts`,不再假失败、不再跳过安装段;发版(需签名)走 `publish-release.ps1` | `scripts/package-devflow.ps1:39-44` | 发现 4 |
| 6 | (前批)UpdateBadge 通道错配修复:补 window CustomEvent 监听 + 卸载清理 | `src/components/layout/UpdateBadge.vue` | 前端侧「有更新不亮徽章」 |
---
## 五、待办与纪律留痕
- **正式验收未走完**:本机装的仍是 **0.1.1 演练版**(带 env 覆盖构建)。验收动线:正常重启应用(**不设 env**)→ 检查更新 → 从**线上**发现 0.1.2 → 升级 → 关于页显示 v0.1.2 → 再查一次显「已是最新」。
- **纪律事件**:当日 e296c84 提交经用户令「撤」已回退,升级链 4 文件(update.rs / publish-release.ps1 / package-devflow.ps1 / tauri.conf.json)回工作区零丢失;纪律强化为**一切提交攒批、最后由用户显式发令**。
- **遗留提示**:发现 3 的 `dangerousInsecureTransportProtocol` 仅存在于演练 override 配置,生产 `tauri.conf.json` 未含、也不应含;若未来要私有 http 源,正路是证书而非放开传输。
@@ -63,5 +63,5 @@ Phase 1 目标:**引擎骨架**,打通 `df-core → df-workflow → df-stora
## 参考文档 ## 参考文档
- [ARCHITECTURE.md](../../ARCHITECTURE.md) — 完整架构设计 - [ARCHITECTURE.md](../../ARCHITECTURE.md) — 完整架构设计
- [PROGRESS.md](../../PROGRESS.md) — 工作进展与交接 - [PROGRESS.md](./PROGRESS.md) — 工作进展与交接
- [Phase1 架构决策](../02-架构设计/滚动规范/Phase1架构决策-2026-06-12.md) - [Phase1 架构决策](../02-架构设计/滚动规范/Phase1架构决策-2026-06-12.md)
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
# DevFlow 批次可视化总览 # DevFlow 批次可视化总览
> 基于 [Batch.md](../Batch.md) 生成 | 更新: 2026-07-01 > 基于 [Batch.md](./07-项目管理/Batch.md) 生成 | 更新: 2026-07-01
--- ---
+47 -59
View File
@@ -2,7 +2,7 @@
> 创建: 2026-06-10 | 当前阶段: Phase 2 本地优先开发流程验证 > 创建: 2026-06-10 | 当前阶段: Phase 2 本地优先开发流程验证
> 更新: 2026-08-02 | 05-代码审查 补录 2026-08-02 走查批次:DevFlow AI 全库走查(00-05,12 crate/78 问题)、claude 多代理走查(汇总+nodes/ai-storage/ideas-前端/基建 5 文档)、本次修复摘要(5 根因 + 13 agent P0/P1/P2 + 架构专项 3,提交 fc249ad/f719546) > 更新: 2026-08-02 | 05-代码审查 补录 2026-08-02 走查批次:DevFlow AI 全库走查(00-05,12 crate/78 问题)、claude 多代理走查(汇总+nodes/ai-storage/ideas-前端/基建 5 文档)、本次修复摘要(5 根因 + 13 agent P0/P1/P2 + 架构专项 3,提交 fc249ad/f719546)
> 历次: 2026-06-19 02-架构设计 核对优化(新增分类索引/补录缺失文档/命名规范);06-17 文档缺陷扫描通过(零死链) > 历次: 2026-10-10 按 ls 对齐现存文件(crate 数 8→13;01/04/05/06 子树补录漏登文档);06-19 02-架构设计 核对优化(新增分类索引/补录缺失文档/命名规范);06-17 文档缺陷扫描通过(零死链)
--- ---
@@ -15,57 +15,23 @@ docs/
├── todo.md # 工作看板(任务/bug 跟踪,~1200 行含历史) ├── todo.md # 工作看板(任务/bug 跟踪,~1200 行含历史)
├── 待审查.md # 代码审查队列(CR 登记+回填,67 条已审) ├── 待审查.md # 代码审查队列(CR 登记+回填,67 条已审)
├── 待决策.md # 需人工决策项(产品取舍/方向选型/排期) ├── 待决策.md # 需人工决策项(产品取舍/方向选型/排期)
├── 待决策论证-2026-06-16.md # (归档) 7 项决策论证(D-01~04 已定 / DEC-01~03 已闭环)
├── 01-技术文档/ ├── 01-技术文档/
│ ├── SQLite-CRUD模式-2026-06-12.md │ ├── SQLite-CRUD模式-2026-06-12.md
│ ├── Tauri-IPC模式-2026-06-12.md │ ├── Tauri-IPC模式-2026-06-12.md
│ └── AntV-X6调研报告与引入计划-2026-06-30.md # 图可视化层选型(v3.1.7)+ 依赖图落地计划 │ ├── AntV-X6调研报告与引入计划-2026-06-30.md # 图可视化层选型(v3.1.7)+ 依赖图落地计划
│ ├── 多代理流水线实战经验-2026-10-03.md # 多代理流水线实战经验
│ ├── 团队级基建-部署与运维-2026-10-05.md # 团队级基建部署与运维
│ └── 签名与发布SOP-2026-10-10.md # 更新签名密钥体系/发布流程/验签/踩坑/应急吊销
├── 02-架构设计/ ├── 02-架构设计/
│ ├── INDEX.md # 本目录分类索引(按性质四类 + 命名规范摘要) │ ├── INDEX.md # 本目录分类索引(按性质四类 + 命名规范摘要)
│ ├── Phase1架构决策-2026-06-12.md │ ├── 滚动规范/ # 长期维护的真相源 / 规范 / 决策记录
│ ├── 业务系统设计-2026-06-12.md │ ├── 已编号方案/ # 带 F/B/CR 编号的方案文档
│ ├── 产品定位调整-2026-06-12.md │ ├── 专项设计/ # 无编号的主题设计 / 审查派生方案
│ ├── 前后端类型对齐-2026-06-12.md │ ├── 构想审查/ # 构想 / UX 改进 / 模块审查 / 论证
│ ├── 对抗论证裁决报告-2026-06-12.md │ ├── 单对话并行多轮-设计-2026-06-20.md
│ ├── 功能决策记录-2026-06-14.md # 需求规格 + 设计决策规格 │ ├── 单对话并行多轮-Phase0落地路线图-2026-06-20.md
│ ├── 功能决策记录-归档-2026-06-14.md │ ├── 上下文根治设计-20261006.md
│ ├── 功能创意池-2026-06-14.md # 5 架构创意(演化/时效/回溯/债务/契约) │ └── 无人值守可靠性专项设计-20261006.md
│ ├── 经验记录-2026-06-14.md
│ ├── 文档记录规范-2026-06-14.md # 含 §八 文档命名规范
│ ├── 规格契约自检机制-2026-06-14.md
│ ├── Agent架构说明-2026-06-14.md
│ ├── B-03-人工审批响应机制-2026-06-14.md
│ ├── aichat审查报告-2026-06-14.md # AI Chat 模块代码审查
│ ├── aichat异步审批构想-2026-06-14.md
│ ├── aichat交互体验改进方案-2026-06-14.md
│ ├── aichat授权体验改进方案-2026-06-14.md
│ ├── aichat信息密度构想-2026-06-14.md
│ ├── 任务推进构想-2026-06-14.md
│ ├── 任务推进链实施路径-2026-06-16.md # 4 阶段实施路径(阶段1决策已定)
│ ├── aichat流式Markdown渲染调研-2026-06-15.md # 流式渲染优化方案(rAF节流/块级diff/换库)
│ ├── patch_file工具设计-2026-06-15.md # patch_file 完整设计(API/三层防御/边界情况)
│ ├── 工作流审批审查报告-2026-06-14.md
│ ├── F-07-df-ai-core-trait下沉设计-2026-06-14.md
│ ├── 密钥迁移健壮性-2026-06-15.md
│ ├── 工作流脚本执行边界-2026-06-15.md
│ ├── 条件表达式引擎-2026-06-15.md
│ ├── generating状态机加固-2026-06-15.md
│ ├── AiNode自审实施方案-2026-06-16.md # AiNode 自审闸门+verdict DAG 阻断
│ ├── B-260616-21排查方案-2026-06-16.md # 工具卡重复渲染排查(audit去重+前端幂等)
│ ├── F-01-模型能力系统与智能路由设计-2026-06-16.md # 模型能力 Phase 3 设计
│ ├── F-02-技能联想使用-实施机制设计-2026-06-16.md # / 联想技能 → AI 调用执行机制
│ ├── F-05-多模态实施方案设计-2026-06-16.md # 多模态 Phase2a/2b/2c 完整方案
│ ├── F-09-多会话并发架构设计-2026-06-19.md # AiSession 多会话并发 B 阶段(当前真相源)
│ ├── F-09B-多会话并发设计-2026-06-16.md # (过时·被 F-09 取代) 多会话 A/B 路线架构设计
│ ├── F-15-上下文管理增强设计-2026-06-16.md # 会话分段/压缩/裁剪设计
│ ├── secret下沉与provider注入方案-2026-06-16.md # secret 纯密钥下沉 df-storage 方案
│ ├── 推进链阶段2实施路径-2026-06-16.md # advance_task 走 df-nodes 实施路径
│ ├── 意图识别层论证-2026-06-19.md # 通用前置意图识别层 8 维度论证
│ ├── 上下文管理演进与发散思考-2026-07-20.md # 上下文管理现状评估、业界对标、7个发散方向与组合分析
│ ├── 上下文管理演进-任务技术设计-2026-07-20.md # T2命名空间/T4工作流DAG/T3结构化摘要技术设计与推演
│ ├── 上下文管理演进-执行计划-2026-07-20.md # Wave 划分、agent 并行策略、接口契约、验证 gate、回滚策略
│ ├── 多主题上下文管理愿景-2026-06-19.md # 多主题多摘要愿景(远期方向,关联 F-15/意图识别)
│ └── 查询效率优化方案-2026-06-19.md # 查询链路优化(SQL 下推/精确拉取/缓存/字段投影)
├── 03-模块文档/ ├── 03-模块文档/
│ ├── df-storage-存储层-2026-06-12.md │ ├── df-storage-存储层-2026-06-12.md
│ ├── df-workflow-工作流引擎-2026-06-12.md │ ├── df-workflow-工作流引擎-2026-06-12.md
@@ -79,7 +45,9 @@ docs/
│ ├── DEVFLOW-1.CRUD层实施-2026-06-12.md │ ├── DEVFLOW-1.CRUD层实施-2026-06-12.md
│ ├── DEVFLOW-2.IPC桥接实施-2026-06-12.md │ ├── DEVFLOW-2.IPC桥接实施-2026-06-12.md
│ ├── DEVFLOW-3.Store对接实施-2026-06-12.md │ ├── DEVFLOW-3.Store对接实施-2026-06-12.md
│ └── DEVFLOW-4.端到端验证-2026-06-12.md │ ├── DEVFLOW-4.端到端验证-2026-06-12.md
│ ├── 父子任务支持设计-2026-08-04.md # 父子任务支持设计
│ └── u-switch接入分析-2026-09-28.md # u-switch 接入分析
├── 05-代码审查/ ├── 05-代码审查/
│ ├── 00-全库代码走查汇总-2026-08-02.md # 全库走查汇总(12 crate, 78 问题, 11 P0 / 30 P1 / 37 P2) │ ├── 00-全库代码走查汇总-2026-08-02.md # 全库走查汇总(12 crate, 78 问题, 11 P0 / 30 P1 / 37 P2)
│ ├── 01-df-nodes-走查-2026-08-02.md # 17 节点走查(20 问题) │ ├── 01-df-nodes-走查-2026-08-02.md # 17 节点走查(20 问题)
@@ -112,15 +80,35 @@ docs/
│ ├── 任务模块问题分析-2026-06-16.md # 任务模块18项核对(真bug 7+增强5+假3+去重3) │ ├── 任务模块问题分析-2026-06-16.md # 任务模块18项核对(真bug 7+增强5+假3+去重3)
│ ├── 任务执行与推进能力分析-2026-06-16.md # 推进能力实现度0%,4阶段路径+决策点 │ ├── 任务执行与推进能力分析-2026-06-16.md # 推进能力实现度0%,4阶段路径+决策点
│ ├── AI聊天组件极端数据场景分析-2026-06-17.md # aichat 交互层 20 项缺陷(HIGH×2/MED×8/LOW×8) │ ├── AI聊天组件极端数据场景分析-2026-06-17.md # aichat 交互层 20 项缺陷(HIGH×2/MED×8/LOW×8)
│ ├── 工作流审批审查报告-2026-06-14.md # (归档)工作流审批链路审查 │ ├── aichat-技术债审查-2026-06-21.md # aichat 技术债审查(10 模块 0 P0 / 7 P1 / 25 P2 / 40 P3)
│ ├── 前后端类型对齐-2026-06-12.md # (归档)Rust↔TS 类型契约核对 │ ├── aichat-会话实测分析-2026-06-22.md # aichat 会话实测分析
│ ├── 对抗论证裁决报告-2026-06-12.md # (归档)df-ideas 对抗评估裁决 │ ├── 全量走查报告-2026-06-28.md # 全量走查报告
│ └── 架构与缺陷复核报告-2026-06-14.md # (归档)复核已修项+回归审计 │ ├── aichat-会话实测分析-2026-07-07.md # aichat 会话实测分析
│ ├── aichat-对话功能走查-2026-07-17.md # aichat 对话功能走查
│ ├── UIUX扩展审查-2026-08-02.md # UI/UX 扩展审查
│ ├── aichat历史会话实证诊断-2026-08-04.md # aichat 历史会话实证诊断
│ ├── AI工具失败画像复盘-2026-08-08.md # AI 工具失败画像复盘
│ ├── aichat效率走查-2026-08-09.md # aichat 效率走查
│ ├── df-mcp+工具crate走查-2026-08-09.md # df-mcp + 工具 crate 走查
│ ├── settings+审计+布局后端走查-2026-08-09.md # settings/审计/布局后端走查
│ ├── 任务+仪表盘+灵感知识走查-2026-08-09.md # 任务/仪表盘/灵感知识走查
│ ├── 项目详情页走查-2026-08-09.md # 项目详情页走查
│ ├── aichat交互走查-2026-09-28.md # aichat 交互走查
│ ├── aichat可用性与交互成本研究-2026-09-29.md # aichat 可用性与交互成本研究
│ ├── aichat知识库与记忆走查-2026-09-29.md # aichat 知识库与记忆走查
│ ├── 整体UIUX三维走查-2026-09-29.md # 整体 UI/UX 三维走查
│ ├── aichat三路走查报告-2026-10-02.md # aichat 三路走查报告
│ ├── UX走查-Web规范合规-2026-10-05.md # UX 走查 Web 规范合规
│ └── 全系统模块走查索引.md # 全系统模块走查索引
├── 06-前端开发/ ├── 06-前端开发/
│ └── View改造指南-2026-06-12.md │ ├── View改造指南-2026-06-12.md
│ └── 前端架构技术债-2026-06-19.md # 前端架构技术债盘点
├── 07-项目管理/ ├── 07-项目管理/
│ ├── PROGRESS.md # 项目进展与交接记录
│ ├── Batch.md # 批次执行记录
│ ├── Phase1任务清单-2026-06-12.md │ ├── Phase1任务清单-2026-06-12.md
│ └── Phase2计划-2026-06-12.md │ ├── Phase2计划-2026-06-12.md
│ └── 用户主张思想总纲-2026-10.md # 跨会话用户主张蒸馏(注入用)
├── 08-用户指南/ ├── 08-用户指南/
│ ├── 使用手册-2026-06-12.md # 用户手册(对齐 06-15 代码基线) │ ├── 使用手册-2026-06-12.md # 用户手册(对齐 06-15 代码基线)
│ ├── AI文件操作工具手册.md # AI 工具 API 全量参考(10 工具,2026-06-16 更新) │ ├── AI文件操作工具手册.md # AI 工具 API 全量参考(10 工具,2026-06-16 更新)
@@ -138,7 +126,7 @@ docs/
| 🚀 快速开始 | [README.md](./README.md) | 安装、启动、快速上手 | | 🚀 快速开始 | [README.md](./README.md) | 安装、启动、快速上手 |
| 📖 使用手册 | [使用手册-2026-06-12.md](./08-用户指南/使用手册-2026-06-12.md) | 用户手册 | | 📖 使用手册 | [使用手册-2026-06-12.md](./08-用户指南/使用手册-2026-06-12.md) | 用户手册 |
| 🔧 AI 工具手册 | [AI文件操作工具手册.md](./08-用户指南/AI文件操作工具手册.md) | 10 个文件操作工具 API 参考 | | 🔧 AI 工具手册 | [AI文件操作工具手册.md](./08-用户指南/AI文件操作工具手册.md) | 10 个文件操作工具 API 参考 |
| 📋 项目进度 | [PROGRESS.md](../PROGRESS.md) | 实时项目进展与状态 | | 📋 项目进度 | [PROGRESS.md](./07-项目管理/PROGRESS.md) | 实时项目进展与状态 |
| 📝 工作看板 | [todo.md](./todo.md) | 任务/bug 路踪(P0~P3 + aichat 交互专区) | | 📝 工作看板 | [todo.md](./todo.md) | 任务/bug 路踪(P0~P3 + aichat 交互专区) |
| 🔍 审查队列 | [待审查.md](./待审查.md) | 代码审查登记+回填(67 条已审) | | 🔍 审查队列 | [待审查.md](./待审查.md) | 代码审查登记+回填(67 条已审) |
| ⚖️ 决策项 | [待决策.md](./待决策.md) | 需人工决策的产品/设计/排期 | | ⚖️ 决策项 | [待决策.md](./待决策.md) | 需人工决策的产品/设计/排期 |
@@ -174,9 +162,9 @@ docs/
| 文档 | 路径 | 说明 | | 文档 | 路径 | 说明 |
|------|------|------| |------|------|------|
| 架构设计 | `../ARCHITECTURE.md` | 完整架构文档 | | 架构设计 | `../ARCHITECTURE.md` | 完整架构文档 |
| 项目进展 | `../PROGRESS.md` | 工作进展与交接 | | 项目进展 | `./07-项目管理/PROGRESS.md` | 工作进展与交接 |
| 工作看板 | `./todo.md` | 任务/bug 跟踪(含决策记录) | | 工作看板 | `./todo.md` | 任务/bug 跟踪(含决策记录) |
| Crate 结构 | `../ARCHITECTURE.md#四crate-结构` | 8 个 Crate 概览 | | Crate 结构 | `../ARCHITECTURE.md#四crate-结构` | 13 个 Crate 概览 |
| 数据模型 | `../ARCHITECTURE.md#六数据模型` | SQLite 表结构定义 | | 数据模型 | `../ARCHITECTURE.md#六数据模型` | SQLite 表结构定义 |
| Phase 规划 | `../ARCHITECTURE.md#八phase-规划` | 5 个 Phase 路线图 | | Phase 规划 | `../ARCHITECTURE.md#八phase-规划` | 5 个 Phase 路线图 |
@@ -188,7 +176,7 @@ docs/
|----|------|------| |----|------|------|
| Desktop | Tauri v2 | Rust 后端 + WebView 前端 | | Desktop | Tauri v2 | Rust 后端 + WebView 前端 |
| Frontend | Vue 3 + TypeScript + Pinia | Arco Design 组件库 | | Frontend | Vue 3 + TypeScript + Pinia | Arco Design 组件库 |
| Engine | Rust Workspace (8 crate) | 多 crate 架构 | | Engine | Rust Workspace (13 crate) | 多 crate 架构 |
| Storage | SQLite (rusqlite) | 本地优先,零运维 | | Storage | SQLite (rusqlite) | 本地优先,零运维 |
| AI | Multi-Provider | Claude/GLM/DeepSeek/OpenAI 兼容 | | AI | Multi-Provider | Claude/GLM/DeepSeek/OpenAI 兼容 |
| Build | npm + Vite | 前端构建(不要使用 bun) | | Build | bun + Vite | 前端构建(`tauri.conf.json` 的 beforeDev/BuildCommand 即 `bun dev` / `bun run build`;单一 lockfile=bun.lock) |
+1 -1
View File
@@ -9,7 +9,7 @@
- [DevFlow 使用手册](./08-用户指南/使用手册-2026-06-12.md) - 完整的使用指南,包含功能介绍、操作流程和最佳实践 - [DevFlow 使用手册](./08-用户指南/使用手册-2026-06-12.md) - 完整的使用指南,包含功能介绍、操作流程和最佳实践
### 📋 项目进度 ### 📋 项目进度
- [PROGRESS.md](../PROGRESS.md) - 项目开发进展与交接记录 - [PROGRESS.md](./07-项目管理/PROGRESS.md) - 项目开发进展与交接记录
## 🎯 核心理念 ## 🎯 核心理念
+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-18 归档**: 已完成 `[x]` 与历史分析段已迁至 [07-项目管理/todo归档/2026-06-18.md](./07-项目管理/todo归档/2026-06-18.md)。
> **2026-06-27 归档**: 已完成 `[x]`/`✅` 项已迁至 [07-项目管理/todo归档/2026-06-27.md](./07-项目管理/todo归档/2026-06-27.md)。 > **2026-06-27 归档**: 已完成 `[x]`/`✅` 项已迁至 [07-项目管理/todo归档/2026-06-27.md](./07-项目管理/todo归档/2026-06-27.md)。
> **2026-10-05 归档**: 7 个实测核验全完成的历史走查段(父子任务支持/对话上下文透明化/文件拆分升级/ToolCard UI 合并/灵感模块诊断/aichat 深度走查/TaskDetail 按钮走查)迁至 [07-项目管理/todo归档/2026-10-05.md](./07-项目管理/todo归档/2026-10-05.md),销账批注与滞后勘误见该文件。 > **2026-10-05 归档**: 7 个实测核验全完成的历史走查段(父子任务支持/对话上下文透明化/文件拆分升级/ToolCard UI 合并/灵感模块诊断/aichat 深度走查/TaskDetail 按钮走查)迁至 [07-项目管理/todo归档/2026-10-05.md](./07-项目管理/todo归档/2026-10-05.md),销账批注与滞后勘误见该文件。
> **2026-10-10 归档**: 全量完成态条目/段落(编号 [x] 32 条 + 勾选 [x] + 标题带 ✅ 且段内零未完成项的历史段,含 907/908 与飞书交互面全量盘查批)迁至 [07-项目管理/todo归档/2026-10-10.md](./07-项目管理/todo归档/2026-10-10.md)。迁移为机械行搬运(零措辞改动),核对:未完成项 110 条零丢失、完成项 37 条全入归档、行数守恒。
> **2026-10-10 只读核验销账(源码取证,非文档自述)**: 挂机期派 4 路只读核查子代理逐项 grep/read 源码核验历史待办,结论如下(证据见核验记录/归档)。
> - **2026-06-21 技术债审查段**: MED-1(bind_directory 白名单刷新)/REFACTOR-260619-08(tool_registry 拆分组注册)/REFACTOR-260619-09(context.rs 已拆 context/ 子模块)/UX-260617-28(双监听器·已决策现状够用) **均已落地可销账**;SMELL-P1-6(executed/completed 双轨已修,newtype 父项待推进)/ ARC-260619-05(run_agentic_loop 仍约 1162 行未拆) **保持未完成**。
> - **其余**: WC-技能注入-trace定位(4b445e9 已根治)/ miniapp P3-B appid(已真实值)/ u-claw 集成代码面(ask_uclaw+UclawPanel 齐备,剩真机冒烟) **已落地**。
> - **2026-08-09 三域走查 P1**: 27 项核验 **25 done / 2 partial**(partial=PD-P1-3 事件协议无项目维度已降级标注、PRJ-1 normalize_path 小写化)。
> - 已核销的四个走查段整段迁归档:`2026-08-01 走查剩余` / `2026-06-22 miniapp 走查` / `2026-08-09 任务·仪表盘·灵感知识走查` / `2026-08-09 Settings·审计·布局·后端命令走查`;**含活口子的段保留主板**(项目详情页走查=PD-P1-3 / df-mcp 走查=PRJ-1 / 技术债审查=SMELL-P1-6·ARC-260619-05 / u-claw=剩真机冒烟)。
> >
> **2026-08-05 编号碰撞登记**: `F-260619-03` 被两个特性复用(消息拆分存储=文档本体 + AI工具路径授权=复用)。子代理审查确认:改 18 处编号无实际价值有风险,**暂不改代码**,后续新增注释编号统一走日期+序号体系、避免复用已有编号。路径授权若后续单独立项再独立编号。 > **2026-08-05 编号碰撞登记**: `F-260619-03` 被两个特性复用(消息拆分存储=文档本体 + AI工具路径授权=复用)。子代理审查确认:改 18 处编号无实际价值有风险,**暂不改代码**,后续新增注释编号统一走日期+序号体系、避免复用已有编号。路径授权若后续单独立项再独立编号。
> **2026-08-15 编号碰撞登记**: `REFACTOR-260619-04` 被复用(父③③.2 审批状态机拆分=待办 vs ToolCard 拆分=已完成)。后续引用审批状态机拆分时用「审批状态机拆分」描述,不再沿用该编号。 > **2026-08-15 编号碰撞登记**: `REFACTOR-260619-04` 被复用(父③③.2 审批状态机拆分=待办 vs ToolCard 拆分=已完成)。后续引用审批状态机拆分时用「审批状态机拆分」描述,不再沿用该编号。
@@ -23,8 +29,7 @@
> 背景:src 43 个 .vue 文件 UI 规范缺口全量合规走查(Vercel Web Interface Guidelines 适用子集)+ 审批/aichat/项目知识审计三域子代理分组审。质量中上无阻断,缺口集中 4 类系统性模式,其中 2 类可全局样式一刀切收敛。 > 背景:src 43 个 .vue 文件 UI 规范缺口全量合规走查(Vercel Web Interface Guidelines 适用子集)+ 审批/aichat/项目知识审计三域子代理分组审。质量中上无阻断,缺口集中 4 类系统性模式,其中 2 类可全局样式一刀切收敛。
> 详情:[05-代码审查/UX走查-Web规范合规-2026-10-05.md](./05-代码审查/UX走查-Web规范合规-2026-10-05.md) > 详情:[05-代码审查/UX走查-Web规范合规-2026-10-05.md](./05-代码审查/UX走查-Web规范合规-2026-10-05.md)
- [ ] P2-2 高频可点 div 键盘化:ToolCard:11 卡头/MessageList:1423 轨迹头/AuditLog:67,119 审计行(@click div 全仓 56 处,去 mask 合规项约 20 真缺口,高频改 button 其余 tabindex+keydown) - [~] P3-2 icon-only 按钮缺 aria-label ~15 处(title 非等价读屏补偿;EnrichmentPanel 关闭钮/ProjectDetail:185,189 编辑删除钮等)+剩余 div 键盘化 —— ✅ **2026-10-10 两批共 23 处**:①批次一 3 处(EnrichmentPanel 关闭 / ProjectDetail 模块编辑·删除);②批次二 20 处(TopBar 10 icon-only 按钮 aria-label / ConversationSidebar 4 aria-label + 2 分组标题键盘化 / MessageList 2 icon-only / KnowledgeDetail 3 章节折叠键盘化 / dashboard 两列表项键盘化 / FileExplorer 面包屑·模块下拉项键盘化 / ConflictResolver 冲突文件折叠键盘化)。全部复用已有 `:title`/i18n key,**零新增键**;遮罩类(@click.self)按约定跳过。验收:`bunx vue-tsc --noEmit` EXIT 0。**剩余**少数(会话项 space 键、dblclick 重命名入口等)+「存量 vs 仅本批」拍板点仍在。
- [ ] P3-2 icon-only 按钮缺 aria-label ~15 处(title 非等价读屏补偿;EnrichmentPanel 关闭钮/ProjectDetail:185,189 编辑删除钮等)+剩余 div 键盘化
- 拍板点:修复范围「存量 vs 仅本批」——transition:all/outline:none 大半为存量;本批新代码 a11y 意识已好(svg aria-hidden 批补/MessageList role=log+aria-live/hover-only focus-within 补偿),仅清本批则工作量降 60%+ - 拍板点:修复范围「存量 vs 仅本批」——transition:all/outline:none 大半为存量;本批新代码 a11y 意识已好(svg aria-hidden 批补/MessageList role=log+aria-live/hover-only focus-within 补偿),仅清本批则工作量降 60%+
### 💡 2026-09-29 aichat 可用性与交互成本研究(三动线走查+异常循环截图实证,39 项新发现) ### 💡 2026-09-29 aichat 可用性与交互成本研究(三动线走查+异常循环截图实证,39 项新发现)
@@ -64,85 +69,6 @@
> 审批留在对话内(需要即时上下文),知识/灵感/通知放 Inbox。 > 审批留在对话内(需要即时上下文),知识/灵感/通知放 Inbox。
> 后续做详细设计方案。 > 后续做详细设计方案。
## 🎯 统一规划(2026-06-26 全景)
> 整合知识图谱设计 + 22 条未完成项 + G1 目标钉扎(✅已落地) + 多工程方案。
> **核心主线:AI Working** — AI 拥有项目知识图谱,自主分解/编排/执行/自检,人只决策。
> **执行策略(两阶段)**:① 先攒批无冲突小 bug/小调整 → 提交;② workflow 编排相关任务批量推进大重构(减少文件读取/LLM 交互,提高上下文效率)。
### 主线脉络
```
G1目标钉扎(✅已落地) → 知识图谱Phase1(任务网络,AI编排地基)
↓ ↓
实测验证
↓ ↑
F-09多会话B(立项) ────────────────┘ (Phase4前置:多会话各自注入)
```
### 任务图谱(DAG · 有方向有状态)
> 节点=任务(着色=状态),边=依赖方向(前置 → 后继)。**推进时更新状态**(CLI/edit 同步此表 + Mermaid)。
> 图例:✅完成 / 🔨进行中 / 📋待办 / 🚫阻塞
```mermaid
graph TD
classDef done fill:#bfb,stroke:#393,stroke-width:2px
classDef doing fill:#ffd,stroke:#993,stroke-width:2px
classDef todo fill:#eef,stroke:#99c
classDef blocked fill:#fcc,stroke:#c33
G1["G1 目标钉扎<br/>✅代码 8ce18cb"]:::done
P1["父① 小bug攒批<br/>✅①.2/①.3(①.1过时·①.4归⑤)"]:::done
P2["父② 知识图谱Phase1<br/>✅Phase1(数据层+业务层)"]:::done
P3["父③ AI对话体验<br/>📋待办"]:::todo
P4["父④ F-09 per-conv<br/>✅前端per-conv(accessor)"]:::done
P5["父⑤ 灵感模块<br/>✅完成(⑤.1/①.4/⑤.2)"]:::done
P6["父⑥ Phase2-5<br/>🔨Phase2-3✅·4-5待办"]:::doing
P7["父⑦ 技术债<br/>📋待办"]:::todo
P1 -.先行.-> P2
G1 ==基建==> P6
P2 ==> P6
P4 ==> P6
P2 -.task_links.-> P5
```
| 任务 | 状态 | 子项 | 依赖(前置) |
|---|---|---|---|
| **G1** 目标钉扎 | ✅ 代码已落地 | G1/G2/G4 落地 | — |
| **父①** 小bug攒批 | ✅ 完成 | ①.2 白名单✅(settings.rs) / ①.3 priority✅(idea.rs) / ①.1 BUG层1❌过时(F-260619-03 方案①取代,层2待决策) / ①.4 雷达图→归父⑤ | — |
| **父②** 知识图谱Phase1 | ✅ Phase1完成 | ②.1 V29迁移✅ / ②.2 TaskRecord+TaskLinkRepo✅ / ②.3 IPC(create_task扩展+task_link CRUD+move_queue+get_tree)✅ / ②.4 父聚合✅(set_status_for_aggregation绕status收口) / ②.5 AI工具6✅(基线38) | G1(弱) |
| **父③** AI对话体验 | 🔨 ③.1✅ / ③.2待办 | ③.1 B-260619-04 ToolCard✅(跨轮收起已有+标题增强) / ③.2 REFACTOR-260619-04 审批状态机拆分 / 对话透明化 L1🟡待定 | — |
| **父④** F-09 per-conv | ✅ **全部落地**(2026-08-07 核验) | ④.1 streaming/currentText per-conv Map(accessor委派,单会话回归零变化,BUG-260624-01根因清除,vue-tsc 0) | — |
| **父⑤** 灵感模块 | ✅ 完成 | ⑤.1 软删除✅ / ①.4 雷达图✅ / ⑤.2 #05✅/#06拆const✅/#09/#10表单(逗号tags)✅ / #07 DEC-02保留purge(不改) / 附:priority_from_i32跨层映射修复(对齐前端0=critical) | #07→②.1 |
| **父⑥** Phase2-5 | 🔨 Phase4 注入进行中 | ⑥.1事件流✅(V30) / ⑥.2基础设施✅(V31) / ⑥.3注入✅(resolver 增强+extra 渲染) / ⑥.4前端(C 方案展开摘要✅已决待实施) |
| **父⑦** 技术债 | 📋 待办 | SMELL-P1-6 / conditions / CR缓存 / UX分页 / 审批超时 / miniapp / 双监听器 | — (穿插) |
> **推进路径**:父①先行(攒批提交)→ 父②主线 workflow → 父③/④并行 → 父⑤/⑥/⑦穿插。每父任务一个 workflow 批,子任务相关文件批量读改减少交互。**状态更新约定**:子项完成→父状态 🔨;全子完成→父 ✅;每批提交后同步此表。
### 协同点(跨主题复用)
1. **IDEA-FIX-07 关联单向 ↔ 知识图谱 `task_links`**:并入 Phase 1 用独立表(§2.2 论证 JSON 缺陷),别补 JSON 双向同步
2. **G1 system_prompt 注入基建 ↔ Phase 4 L0 注入**:复用 `pinned_goal` 拼接链路
3. **F-09 per-conv ↔ Phase 4 多会话注入**:F-09 前置
4. **REFACTOR-260619-04 审批状态机 ↔ `queue=decision` 待决策池**:审批对齐
## 交接状态(2026-06-14)
**代码健康度**:`cargo test --workspace` 全过、`npx vue-tsc --noEmit` 0 error(主代理独立验证,非 mission 自报)。
**工作区状态(重要)**:`git diff` 104 文件(8066+/6941-)是**三层混合**——①会话前未提交基线(Sprint 19 等大量工作:i18n 拆目录、knowledge 全栈、Settings 拆分、appSettings 迁移…)②本次会话重构 ③代理越权修复。**接手前务必 `git diff` 通览区分**,勿整体当作单一改动提交。
**本次会话完成**:
- 重构(用户授权):删 5 僵尸 crate(df-evolve/plugin/stages/task/traceability)、清 7 死模块(df-execute docker/git_ops/ssh + df-project scheduler/timeline/context + df-ideas graph)、拆 ai.rs→`commands/ai/` 11 文件、拆 ai.ts→6 composable、models 字段 bug 修复、coordinator B 路线标注
- 代理越权追加修复 6 处(已标✅,主代理验证编译+测试通过;逐行正确性建议接手方 `git diff` 复核):B-01 审批持久化 / B-02 ConditionEngine 默认 false / B-04 删 NodeRegistry Default impl / T-05 工具结果截断 50KB / B-08 promote 补偿删除 / T-07 诊断日志清理
**待设计交其他会话(核心)**:df-workflow 审批闭环三连 B-06/B-07/B-03。**✅ B-03 设计已完成**(接手会话,2026-06-14):见 [B-03-人工审批响应机制-2026-06-14.md](./02-架构设计/已编号方案/B-03-人工审批响应机制-2026-06-14.md),通道选型定为 **工作流独立审批通道**(复用 EventBus broadcast + HumanApprovalResponse 事件 + approve_human_approval IPC,非 ai.rs AiApprovalRequired——后者是 AI Chat 工具审批路径,与工作流节点审批是两条独立链路)。拆 B-03a(响应等待 + 超时,不依赖 B-07)/ B-03b(取消机制)。**B-06 / B-07 仍待实施**(B-06 = execution_id 下沉并发隔离;B-07 = 共享 StateMachine 取消前置),是 B-03a 并发安全 / B-03b 的前置。
**失控代理教训**:本次会话派的后台拆分代理在 stop hook 循环里失控,越权改代码/文档(先斩后奏)。接手方若再派 agent,注意约束其不碰决策记录(用户已要求手动触发)+ 限定单任务不自主续推。
---
## 待办 ## 待办
### 🔍 2026-06-21 AI Chat 技术债审查(workflow wf_92b61c35-4d2 · 10 模块串行 grep 核验) ### 🔍 2026-06-21 AI Chat 技术债审查(workflow wf_92b61c35-4d2 · 10 模块串行 grep 核验)
@@ -153,7 +79,7 @@ graph TD
**销账核对(已登记 8 复核)**:SMELL-P1-6(line221·#5 覆盖 executed/completed 双轨)/ MED-1(tool_registry:546 bind_directory)/ REFACTOR-260619-08(tool_registry:881 拆)/ REFACTOR-260619-09(context.rs:1 God 1552行)/ ARC-260619-05(agentic:318 超长)/ UX-260617-28(双监听器 INFO)。**均仍在未完成,审查复核确认,保留**。 **销账核对(已登记 8 复核)**:SMELL-P1-6(line221·#5 覆盖 executed/completed 双轨)/ MED-1(tool_registry:546 bind_directory)/ REFACTOR-260619-08(tool_registry:881 拆)/ REFACTOR-260619-09(context.rs:1 God 1552行)/ ARC-260619-05(agentic:318 超长)/ UX-260617-28(双监听器 INFO)。**均仍在未完成,审查复核确认,保留**。
**残留诊断代码(待清)**:chat.rs `authz_debug`/`authz-debug.log`(L2 调试遗留,P2 报 chat.rs:548 硬编码绝对路径),L2 完成后可清。 **残留诊断代码(待清)**:chat.rs `authz_debug`/`authz-debug.log`(L2 调试遗留,P2 报 chat.rs:548 硬编码绝对路径),L2 完成后可清。✅ **2026-10-10 核验销账**:`grep -rn "authz_debug|authz-debug.log" src-tauri/src/` 零命中,残留已清。
--- ---
@@ -163,46 +89,6 @@ graph TD
--- ---
### 📋 编排推进总览(2026-06-18 更新)
未完成待办按可执行性分 8 组(详细条目见下方各分类,勿重复记录):
| 组 | 说明 | 代表项 |
|---|---|---|
| **A todo 卫生** | 矛盾清理/被取代退役标注 | ✅ 本批已完成 |
| **B 零风险减法** | 死链清/空壳合并 | ✅ 本批已完成 |
| **C 需用户输入** | 阻塞,无法代办 | —(已归档)
| **D 功能增强** | 设计清晰可推进(P1) | F-15-01 审批选项 / F-15-02 task 详情 / F-15-03 分页⚠️breaking / F-15-04 卡片折叠 |
| **E 架构重投入** | 需设计,非小改 | F-14-01 模型能力 / F-14-07 trait 下沉 / ARC-05·06 store 拆·循环依赖 |
| **F 全局 review P2 需设计** | 安全/竞态收口 | R-PD-4/5/6/8/9/12/13 + R-PD-10 .map_err 85 处 |
| **H 长期功能池** | 不进主线 | F-14-02~10 / T-14-06 Settings 拆 / T-14-11 条件引擎 / B-14-05 / B-03b-R9 |
**推进原则**:能并行不串行(独立子任务 8-12 并发);每批重汇总+全优先级重排+销账核对 ✅;C 组阻塞项不代办等用户。**本会话首批**:A 卫生 + B 清死链。
**③④C类已迁**(2026-06-16):需人工决策项(③产品取舍 7 / ④设计方向 7 / 重投入排期 9 / C用户实测 5)见 [待决策.md](./待决策.md),从本文件抽离职责分离。**batch54 可自主批进展(2026-06-16·workflow wsfz7zl8i + 主代核查)**:复核-新⑪ 看门狗幽灵 ✅ 销账(清主窗口 state 视觉残留;**核验更正**:原 watchdog 幽灵已被 CR-260615-24 stopListener:371 clearStreamWatchdog 兜底);AE-03 diff预览 ⚠️ 降级阻塞(后端 write_file 未返 diff,待后端补,见 line 538);UX-04 断线保文 待 DEC-260616-03 授权(见待决策.md 🟡,本批不含)。
> **📦 已完成项归档**: [07-项目管理/todo归档/2026-06.md](./07-项目管理/todo归档/2026-06.md) — 2026-06-18 拆分, 已完成 `[x]` 与历史分析段迁此。另有 [2026-06-18.md](./07-项目管理/todo归档/2026-06-18.md) / [2026-06-27.md](./07-项目管理/todo归档/2026-06-27.md) — 本次归档。
### 🔴 2026-06-20 BUG-260620-05 F-260619-03 工程内路径误弹窗(reload_allowed_dirs 丢 workspace_root·b22e9ae 回归)
**现象**:AI Chat file_info 等文件工具访问工程内路径(如 docs/02-架构设计/单对话并行多轮-设计-2026-06-20.md)触发授权弹窗,用户称"本来就有访问权限,为什么还来申请"。
**根因**:`state.rs:586-619 reload_allowed_dirs` 合并 `kv_dirs(KV allowed_dirs)+ project_dirs(projects.bind_directory)` 为 `all_dirs`,**仅当 `all_dirs.is_empty()` 时(:618)才插入 workspace_root**。KV 配过 allowed_dirs 或有绑定项目 → all_dirs 非空 → **workspace_root 不入 persistent** → 工程内路径(在 workspace_root 下)`is_authorized`(:356 starts_with)失败 → `check_path_authorization` 返 `NeedsAuthorization` → 弹窗。
**注释承诺失配**::346-348 / :583 / :617 注释均称"KV 未配时保持 workspace_root 免授权(向后兼容)",但 :618 条件用 `all_dirs`(含 project_dirs),project_dirs 非空也丢 root。b22e9ae "reload 尊重用户 persistent" 引入的回归。
**用户政策指令**(2026-06-20):"默认直接访问,明确知道无权限才申请"。指向工程内(workspace_root 下)默认免授权,仅 workspace_root 外非白名单路径才弹窗。
**修法(层1·1 行确定性 bug)**:`state.rs:618` 去掉 `if all_dirs.is_empty()` 条件包裹,**无条件** `set.insert(workspace_root_path())`(工程内始终免授权,对齐用户政策 + 注释承诺)。不破坏现有能力:workspace_root 经 `set/get_allowed_dirs`(:664/680 filter root)本就不可见不可删,"用户删 root"语义未落地,保 root 无冲突。
**层2 待决策(政策范围)**:用户"默认直接访问"是否要求更宽语义——workspace_root 外的非黑名单路径也默认放行(翻转 `is_authorized` 为黑名单制,实质去白名单)?涉 P0 去固定根决策方向,见待决策.md。dev 自用场景层1 即满足。
**关联**:F-260619-03(todo :528 b22e9ae 回归源)/ 待决策 workspace_root 分发适配(方案b 用户项目绑定)/ memory [[devflow-project-path-binding]]
**状态**:✅ 已实施(层1 无条件插入 workspace_root + 层2 白名单制 persistent/session/once + 黑名单兼底,对齐 2026-06-28 用户决策)
---
### 💡 2026-06-16 新需求(已分析·待实施) ### 💡 2026-06-16 新需求(已分析·待实施)
> 功能需求,已走查定位链路 + 方案记录,待用户确认后实施。 > 功能需求,已走查定位链路 + 方案记录,待用户确认后实施。
@@ -211,11 +97,6 @@ graph TD
> **✅ 2026-08-07 核验销账(F-09 B 阶段全部落地)**:三方独立核验(2 agent + 主代 grep 抽查)确认 06-19 草案所有改造点(§4.1-4.7)已落地,**且代码超出草案**:① AiSession 去单例 → `per_conv: HashMap<String, PerConvState>`(mod.rs:593,顶层会话级字段全删)② ConvStateStore DashMap 无锁(治 guard.reset 锁竞争卡死连环)③ 三处退出校验改 conv 存在性(agentic/mod.rs 7 处,零旧 `active_conversation_id != conv_id` 残留)④ GeneratingGuard per-conv + loop_epoch owner token(F1 治同 conv 双 loop 竞态)⑤ last_heartbeat 心跳(F2 治卡死兜底误判)⑥ IPC 全加 conv_id + ConvStateStore 零锁读(switch 删 readonly / create 不杀旧 loop)⑦ 前端 convStates Map(enum 轨替代 bool Set)+ d2 多窗口(useAiWindow.ts `ai-detached-${convId}`)已落地。**剩余仅 B 路线 Phase3 单对话并行多轮**(`per_sub_flow` 占位未接,另线 [[aichat-b-route-parallel-multiround]])。c-1 决策已被用户「不设并发会话上限」推翻作废。 > **✅ 2026-08-07 核验销账(F-09 B 阶段全部落地)**:三方独立核验(2 agent + 主代 grep 抽查)确认 06-19 草案所有改造点(§4.1-4.7)已落地,**且代码超出草案**:① AiSession 去单例 → `per_conv: HashMap<String, PerConvState>`(mod.rs:593,顶层会话级字段全删)② ConvStateStore DashMap 无锁(治 guard.reset 锁竞争卡死连环)③ 三处退出校验改 conv 存在性(agentic/mod.rs 7 处,零旧 `active_conversation_id != conv_id` 残留)④ GeneratingGuard per-conv + loop_epoch owner token(F1 治同 conv 双 loop 竞态)⑤ last_heartbeat 心跳(F2 治卡死兜底误判)⑥ IPC 全加 conv_id + ConvStateStore 零锁读(switch 删 readonly / create 不杀旧 loop)⑦ 前端 convStates Map(enum 轨替代 bool Set)+ d2 多窗口(useAiWindow.ts `ai-detached-${convId}`)已落地。**剩余仅 B 路线 Phase3 单对话并行多轮**(`per_sub_flow` 占位未接,另线 [[aichat-b-route-parallel-multiround]])。c-1 决策已被用户「不设并发会话上限」推翻作废。
### 🔴 aichat API Key 401 排查(2026-06-16)
> 用户报对话失败「调用失败: API Key 无效或无权限」+ 重设 key 无效。**排查结论:代码链路全对(保存 keyring✓ / 读取 resolve_provider_secret✓ / 鉴权头 openai=Bearer·anthropic=x-api-key✓ / URL 智能拼接✓),401 来自服务端,非 devflow bug**。根因四选一(key 无效 / provider_type 不匹配 / base_url 错 / model 名错)。详见 [aichat-apikey-401排查-2026-06-15.md](./09-问题排查/aichat-apikey-401排查-2026-06-15.md)。
### 🟦 架构审查待修项(2026-06-16) ### 🟦 架构审查待修项(2026-06-16)
> 纯架构层评估(边界/依赖/抽象/扩展性/状态管理),详见 [架构审查-2026-06-15.md](./05-代码审查/架构审查-2026-06-15.md)。共 🔴6 🟡6 ⚪4 + 亮点 6。与 06-14 三份报告去重(不重复 bug/性能 FR-*、aichat AR-*)。 > 纯架构层评估(边界/依赖/抽象/扩展性/状态管理),详见 [架构审查-2026-06-15.md](./05-代码审查/架构审查-2026-06-15.md)。共 🔴6 🟡6 ⚪4 + 亮点 6。与 06-14 三份报告去重(不重复 bug/性能 FR-*、aichat AR-*)。
@@ -223,21 +104,8 @@ graph TD
**中期(技术债)** **中期(技术债)**
✅ ARC-260615-07 已决策:缓做(d归F-09B,其余ROI低不实施) ✅ ARC-260615-07 已决策:缓做(d归F-09B,其余ROI低不实施)
### P1 — 重要缺陷
### 🔴 架构坏味道全面扫描(2026-06-18·6路并行 Explore agent·57K行代码)
> 来源: God文件/长函数 + 循环依赖/耦合 + DRY违反 + 错误处理不一致 + 命名API/类型安全 + 测试覆盖/死代码。6 维度全覆盖。
**🟡 P1 应该改进 (9项)**:
✅ **SMELL-P1-6** — **[P1🟢 已决·待推进]** String 类型滥用替 newtype/enum —— execution_id/status/tool_type 等 5+ 处改强类型(b 路线:仅 Rust 内部 newtype)
**🆕 sweep 派生登记(2026-06-18·workflow w2xkw4ybh data 抽出 + wo7d6zdqm 多维度 sweep)**:
- ✅ **conditions 条件引擎** — ✅ 全部完成:引擎完备+feature flag 默认开+executor 集成+前端 UI(WorkflowDagDisplay.vue 可编辑)+i18n 补齐
### 🟡 AI Chat 交互体验改进(2026-06-14 方案 → 待办化) ### 🟡 AI Chat 交互体验改进(2026-06-14 方案 → 待办化)
> 来源:[aichat交互体验改进方案-2026-06-14.md](./02-架构设计/构想审查/aichat交互体验改进方案-2026-06-14.md)。7 大类 20 个改进项,按原文优先级分 4 批。**去重**:§1.3 复制 ⊂ F-260615-13/14(已写)、§3.4 新建中断 ⊂ B-260615-10(已做软复位,本文档加 confirm 增强)、§6.2 分离窗口同步 ⊂ CR-24/25(已写)。**状态: 待讨论→看板跟踪**。 > 来源:[aichat交互体验改进方案-2026-06-14.md](./02-架构设计/构想审查/aichat交互体验改进方案-2026-06-14.md)。7 大类 20 个改进项,按原文优先级分 4 批。**去重**:§1.3 复制 ⊂ F-260615-13/14(已写)、§3.4 新建中断 ⊂ B-260615-10(已做软复位,本文档加 confirm 增强)、§6.2 分离窗口同步 ⊂ CR-24/25(已写)。**状态: 待讨论→看板跟踪**。
@@ -278,52 +146,6 @@ graph TD
**Phase3 联调待办**(D1-D6 决策点推荐:A纯透传 / 全19变体透传 / EventBus汇聚 / switch不处理 / 强类型保留 / 桥接层R1兜底): **Phase3 联调待办**(D1-D6 决策点推荐:A纯透传 / 全19变体透传 / EventBus汇聚 / switch不处理 / 强类型保留 / 桥接层R1兜底):
### 🔍 2026-06-22 miniapp 功能走查(workflow wf_ddd49ac8 · 5 域并行核验 + 主代终态抽查)
> 用户 /review miniapp + workflow 汇总安排。5 agent 独立 grep/read 核验源码(不信注释/文档) + 主代终态核验(ws.ts 真路径 `src/api/ws.ts`、`relay.rs:255/355`、remote_bridge 命令数 10 非 11)。**18 findings → 去重 P1×5 / P2×3 / P3×1**,2 项 refuted。关联 F-260620-01 阶段4(真机联调前置)。**守 session-role-diagnose-only:本会话仅走查 + 登记,未实施代码。**
**P1(阻塞真机/可靠性·5 项)**:
**用户补充核验(2026-06-22 用户详读源码+设计文档,主代逐项核源码判真伪)**:
> ⚠️ 用户分析 P0 三项(协议三重不匹配 / EventBus 未接 / 桥接层占位)**经源码核验全部失实**,系基于 Phase3 阶段1-3 落地前的设计文档「计划态」描述,未核当前代码。证据:
> - **P0-1 协议**:`crates/df-tunnel/src/tunnel.rs:457/327/424` tunnel 纯透传 payload `Value` 上抛 on_command,**不**做 `from_value::<TunnelCommand>` 强类型反序列化(方案A 阶段1 已落地 commit 0fb5af5)→ miniapp `{cmd,args}` 反序列化在桥接层 `remote_bridge.rs:96 from_payload` 成功,非阻断。tunnel.rs:457 注释自证「不反序列化为 TunnelCommand 强类型」。
> - **P0-2 EventBus**:`src-tauri/src/lib.rs:128` `state.ai_event_bus.subscribe()`(tunnel subscriber 已接)+ `remote_bridge.rs:303/332/358/388` + `commands/ai/mod.rs:209/225` `publish_event` 跨端透传。**已双写接入**(todo line 760 commit 0fb5af5/612de4c emit 双写 chat19/audit12/guard3/stream_recv2/agentic18)。`event_bus.rs:53/115` 的 `#![allow(dead_code)]`+「骨架阶段」注释是**过时历史标注**(publish_event 现有调用方 remote_bridge/mod),非未接入。
> - **P0-3 桥接层**:`remote_bridge.rs` 完整实装(`handle_remote_command`:121 + `MiniCommand`:83 + `from_payload`:96 + match 路由 10 条 + `route_list/load_messages` publish_event)+ **已 wire** `lib.rs:150-154` `on_command = Arc::new(...handle_remote_command...)` + `:171` `.connect(&relay_url,&device_id,&token,on_command.clone())`。非占位/设计态。`remote_bridge.rs:40-42` 的 `#![allow(dead_code)]`+「零调用方」注释写于联调前,现 lib.rs 已调(过时标注,待清)。
**剩余开放项**:P3-B-260622 [P3·真机前置] — **manifest appid 占位**。`PLACEHOLDER` 无法真机调试,需微信公众平台注册的真 appid。— `apps/df-miniapp/src/manifest.json`
### ✅ 2026-06-24 aichat 可靠性修复(消息重叠×5 + 授权弹窗卡死 + 工具执行心跳误报 · 诊断workflow→论证workflow)
> 剩余低优 [ ](非阻断):
> - [x] BUG-260624-02 残留 ✅ 已销账(2026-08-15):ToolCard pending 态倒计时落地(`_approvalTimers` 升级携带 startedAt/timeoutMs + `getApprovalRemainingMs` 真值起点,expireSecs/expireLevel computed,≤60s `.is-urgent` 红;不限时回退正计时;i18n expireIn zh/en)+ detached 窗口核验无缺口(AiDetached→AiChat 独立 startListener,ai-chat-event app 级广播,主窗口 detach 时 stopListener 不竞态,结论注释 useAiWindow:107-115)
> - [x] F-09 根因:streaming/currentText 全局单例改 per-conv ✅ **2026-08-07 核验销账**(per_conv HashMap + 前端 convStates Map enum 轨 + AiConvStateChanged 事件 per-conv 路由,根治多会话串扰)(根治多会话串扰,见 docs/待决策.md)
> - [ ] CR-260622-01-P2-2 [P2低优·评估降级] — **HTML/markdown/JSON 等非代码文件无 session 级缓存**。**2026-06-24 评估**:已有 `TOOL_RESULT_COMPRESS_ENABLED`(mod.rs:98/1029-1078 view-only 摘要,>2KB tool_result 压缩,LLM 视图摘要非全文回灌 prompt)部分缓解;read_symbol 治代码文件(主场景,降 24.4x)。非代码文件完整 session 缓存(path→hash+content+patch失效)设计复杂 + LLM patch 后重读确认行为不确定(缓存命中提示可能不够 LLM 仍重读),归 B 路线 prompt 策略(约束 patch 后不重读)更合适,工具侧完整缓存低优暂缓。
---
### 🔍 2026-08-01 走查剩余待办(P2/P3/专项·本会话登记)
> 本次走查剩余项登记,未实施代码(守 session-role-diagnose-only)。专项已落 memory,此处留指针。
**P2(中期改进)**:
**P3(风格/可读·低优)**:
**专项(已登记 memory,留指针)**:
- 📌 **WC-专项-AI-router-sanitize** — AI router + sanitize 链路梳理重构 — 详见 memory(已登记,本会话不实施)
- 📌 **WC-专项-run_agentic_loop-重构** — `run_agentic_loop` 重构(ARC-260619-05 agentic:318 超长,历史登记) — 详见 memory(已登记,本会话不实施)
**技能注入(待定位)**:
- [ ] **WC-技能注入-trace定位** — 技能/skill 注入链路待 trace 定位(注入时机、上下文来源、是否重复注入),定位后再决定是否待办化
---
### 🔍 2026-08-02 UI/UX 扩展审查(wf_wynylx23h · 10 路并行 · 基于任务 0115aea6 原 12 问题角度) ### 🔍 2026-08-02 UI/UX 扩展审查(wf_wynylx23h · 10 路并行 · 基于任务 0115aea6 原 12 问题角度)
> 详单:[UIUX扩展审查-2026-08-02.md](./05-代码审查/UIUX扩展审查-2026-08-02.md)。80 条发现(8 高/38 中/34 低;38 全新/29+ 延伸),高严重度主代理 grep 核验**全属实**。完整数据 `~/.claude/jobs/8ff8ccb7/tmp/audit_summary.json`。 > 详单:[UIUX扩展审查-2026-08-02.md](./05-代码审查/UIUX扩展审查-2026-08-02.md)。80 条发现(8 高/38 中/34 低;38 全新/29+ 延伸),高严重度主代理 grep 核验**全属实**。完整数据 `~/.claude/jobs/8ff8ccb7/tmp/audit_summary.json`。
@@ -421,30 +243,6 @@ graph TD
> **优先级建议**:① C0-1+C0-2 缓存命中率 → ② L0-1 save + L0-2 system 缓存 + L0-3 压缩后台化 → ③ R1-1 全量重放 + S1-1 流式增量 → ④ T1-1 审计批量 + F1-1/2/3 列表刷新收敛。 > **优先级建议**:① C0-1+C0-2 缓存命中率 → ② L0-1 save + L0-2 system 缓存 + L0-3 压缩后台化 → ③ R1-1 全量重放 + S1-1 流式增量 → ④ T1-1 审计批量 + F1-1/2/3 列表刷新收敛。
> **memory 指针**:[[aichat-efficiency-walkthrough-2026-08-09]] > **memory 指针**:[[aichat-efficiency-walkthrough-2026-08-09]]
### 🔍 2026-08-09 任务/仪表盘/灵感知识走查(3 路并行 · 阶段②第一批)
> 详单见 [任务+仪表盘+灵感知识走查-2026-08-09.md](./05-代码审查/任务+仪表盘+灵感知识走查-2026-08-09.md)。共 **59 项**(任务19/Dashboard+Projects17/Ideas+Knowledge23,P0=0/P1=7/P2=25/P3=27)。守 session-role-diagnose-only:仅走查登记,未实施代码。
**P1(确定性,优先修)**:
**P2(体验/数据)**: WK-8~13 ✅ 已修(0554cc1):快捷菜单状态机+补cancelled / overflow裁切根治(顶底角裁剪下放) / loadTasks seq守卫+keyword / 搜索态隐藏进度徽章 / 分页越界钳制 / 失败统一toast。剩余 WK-14~18 🟡 待后续
**P3(27 项)**: 见详单文档(TSK-10~19 / DBP-10~17 / IDEA-10~12 / KNOW-6~10 / C-1~3)
> **优先级**:WK-1 Dashboard 状态映射 → WK-2/3 Knowledge 确定性 → WK-4/5 Ideas → WK-6 状态徽章 → WK-7 错误条统一。
### 🔍 2026-08-09 Settings/审计/布局/后端命令走查(3 路并行 · 阶段②第二批)
> 详单见 [settings+审计+布局后端走查-2026-08-09.md](./05-代码审查/settings+审计+布局后端走查-2026-08-09.md)。共 **95 项**(Settings+AuditLog29 / layout+sidebar21 / 后端命令45,P0=0/P1=9/P2=32/P3=54)。守 session-role-diagnose-only:仅走查登记,未实施代码。
**P1(确定性/安全,优先修)**:
**P2(体验/数据·32 项)**: 见详单文档(SA-1~8 审计 i18n/脏表单/竞态、LS-2~10 侧栏偏好/小屏布局/审批浮窗/Paginator、BE-CMD-9~23 后端校验/聚合)
**P3(54 项)**: 见详单文档(SA-9~31 / LS-11~21 / BE-CMD-24~45)
> **优先级**:LW-1 ConfirmDialog 安全 → LW-2/3 update 裸奔 → LW-4 queue → LW-5 count/list → LW-6 软删 → LW-8 父聚合。
> **全系统索引**:前端 13 + 后端 9 命令全走查完成 ✅,剩 crate df-mcp/df-execute/df-project/df-ideas/df-tunnel/df-relay/df-types。
### 🔍 2026-08-09 df-mcp + 工具 crate 走查(阶段②最后一批) ### 🔍 2026-08-09 df-mcp + 工具 crate 走查(阶段②最后一批)
> 详单见 [df-mcp+工具crate走查-2026-08-09.md](./05-代码审查/df-mcp+工具crate走查-2026-08-09.md)。共 **35 项**(P0=0/P1=2/P2=8/P3=25)。守 session-role-diagnose-only:仅走查登记,未实施代码。 > 详单见 [df-mcp+工具crate走查-2026-08-09.md](./05-代码审查/df-mcp+工具crate走查-2026-08-09.md)。共 **35 项**(P0=0/P1=2/P2=8/P3=25)。守 session-role-diagnose-only:仅走查登记,未实施代码。
@@ -454,19 +252,6 @@ graph TD
**P3(25 项)**: 见详单文档(MCP-7~16 / EX-1~4 / PRJ-2~4 / TUN-2~4 / RLY-2~5) **P3(25 项)**: 见详单文档(MCP-7~16 / EX-1~4 / PRJ-2~4 / TUN-2~4 / RLY-2~5)
> **全系统走查(阶段②)完成 ✅**——前端 13 模块 + 后端 9 命令 + 全部 crate 已走查登记。进入**阶段③批量打包推进 /advance**。 > **全系统走查(阶段②)完成 ✅**——前端 13 模块 + 后端 9 命令 + 全部 crate 已走查登记。进入**阶段③批量打包推进 /advance**。
### 🔴 2026-08-09 AI 自审/工作流推进链路缺陷(✅ P0×3 已修,2026-10-05 核验销账)
> 用户「检查 AI self-review 遇到的问题」触发对工作流推进链路的深度审查。**原结论「三条推进链全部不可用」已过时——P0×3 全部已修**(task_id 注入 workflow.rs:340 + AiNode 自审落库含 agent 模式 + TaskDetail 审批 UI 随 2eea011 审批重设计批落地),核验详录迁 [07-项目管理/todo归档/2026-10-05.md](./07-项目管理/todo归档/2026-10-05.md)。以下原始诊断保留作档案,修复状态见归档勘误。
**涉及文件**:
- `src-tauri/src/commands/workflow.rs`(run_workflow_inner 核心)
- `crates/df-nodes/src/task_workflow_templates.rs`(三模板)
- `crates/df-nodes/src/ai_node.rs` / `ai_self_review_node.rs` / `ai_node_helpers.rs` / `human_node.rs`
- `crates/df-workflow/src/executor.rs` / `dag.rs` / `registry.rs` / `dag_def.rs`
- `src/views/TaskDetail.vue` / `src/stores/project/workflow.ts` / `src/views/ProjectDetail.vue`
---
## 🔴 P0 缺陷(3 项,工作流推进 100% 失败) ## 🔴 P0 缺陷(3 项,工作流推进 100% 失败)
### P0-A task_id 断裂:AiSelfReviewNode 必定失败 ### P0-A task_id 断裂:AiSelfReviewNode 必定失败
@@ -555,11 +340,6 @@ graph TD
> 来源:中断根治批次全量走查(正常设计路径无漏 emit,风险集中四类)。P1-2/P0-1/P2-1 本批已修,以下为登记项。 > 来源:中断根治批次全量走查(正常设计路径无漏 emit,风险集中四类)。P1-2/P0-1/P2-1 本批已修,以下为登记项。
### P0 run_agentic_loop 包 catch_unwind ✅已修(2026-10-05 核验:loop 主体 unwind_to_result 包裹,panic 转 teardown_round 补终态,5 单测锁定 mod.rs:3135-3165)
- **位置**:`agentic/mod.rs` run_agentic_loop 主体(无 catch_unwind)
- **现状**:任一轮 prepare/stream/finalize/tools 段 panic → task 死亡 → GeneratingGuard::drop 只迁 Idle+emit AiConvStateChanged,**不 emit AiError/AiCompleted**,heartbeat 随作用域死亡——「工具完成后流断+无终止事件」的精确根因形状(前端探测式续跑已兜住后果,此为后端根治)。
- **修复**:整体包 `std::panic::catch_unwind(AssertUnwindSafe(...))`,unwind 时 emit AiError{error_type:Internal} 再收敛;guard Drop 兜底保留。核心 loop 改动,需谨慎设计+场景回放测试。
### P1 conv 删除类静默退出族 6 处补终态事件 ### P1 conv 删除类静默退出族 6 处补终态事件
- **位置**:`agentic/mod.rs:417/:1316/:1379/:1437/:1593/:1423`、`stream_relay.rs:520-525`(MidStream 保文时 conv-deleted 跳过 teardown emit,最刺眼) - **位置**:`agentic/mod.rs:417/:1316/:1379/:1437/:1593/:1423`、`stream_relay.rs:520-525`(MidStream 保文时 conv-deleted 跳过 teardown emit,最刺眼)
- **现状**:生成期间会话被删,裸 return 仅 guard Drop 迁 Idle,无终止事件。 - **现状**:生成期间会话被删,裸 return 仅 guard Drop 迁 Idle,无终止事件。
@@ -577,13 +357,9 @@ graph TD
> 来源:上下文管理批收口期 rev-backend/rev-frontend/rev-crates 三路走查(历三次会话重启后续跑完成)。**全文与批次归并建议见 `docs/05-代码审查/aichat三路走查报告-2026-10-02.md`**。以下为可执行登记,开工以现场源码为准。 > 来源:上下文管理批收口期 rev-backend/rev-frontend/rev-crates 三路走查(历三次会话重启后续跑完成)。**全文与批次归并建议见 `docs/05-代码审查/aichat三路走查报告-2026-10-02.md`**。以下为可执行登记,开工以现场源码为准。
### 批次2(后端高危)并入项
### 批次3(前端体验)并入项
### 机械批(可独立小批)
### 登记观察(定级前先核)
# 子代理调度走查(2026-10-03 两路,详情 temp/走查-子代理{UX,调度后端}-2026-10-03.md) # 子代理调度走查(2026-10-03 两路,详情 temp/走查-子代理{UX,调度后端}-2026-10-03.md)
@@ -595,9 +371,6 @@ graph TD
> 与上节 25 项零重叠(报告尾附排除对账)。修复批:四路走查修复收口后,确定性项先行,需拍板项进拍板集。 > 与上节 25 项零重叠(报告尾附排除对账)。修复批:四路走查修复收口后,确定性项先行,需拍板项进拍板集。
- [x] P1[需拍板→已实施] 熔断无收尾轮 ✅ 2026-10-06:max_rounds 打满不再直接截断,补一次无工具(tools=None)收尾 LLM 调用——system 指令「输出最终任务总结报告」,成功则 push 报告+落库后走原收尾;失败(全候选耗尽/MidStream)静默落回原截断路径(报告是增益非闸门,零新增停靠)。预算:仅 1 轮 max_retries=0,无放大(agentic/mod.rs 达上限分支)
- [x] P1[需拍板→已实施] 报告被视图压缩吞掉 ✅ 2026-10-06:summarize_tool_results 加新近度豁免(RECENCY_EXEMPT_TOOL_RESULTS=2)——末尾 2 条 tool_result 恒不压缩(豁免在冻结决策之前,不进决策表),委托报告/末轮工具结果全文可见;单测 summarize_recency_exempt_keeps_last_two_tool_results 锁定行为(stall_detector.rs)
- [x] P2 namespace 10MB 全局 LRU 淘汰:报告蒸发为「重新调用」死路+多代理共池互踩 ✅设计完成(2026-10-04 [设计文档](./02-架构设计/专项设计/namespace淘汰治理-设计-2026-10-04.md)):pin 机制+EvictionLog 富提示(替代裸 EVICTED_PLACEHOLDER)+owner 软隔离三招,零 schema 变更;2 拍板点(pin 上限保护/报告外默认 pin 范围),约 0.5-1 天 ✅ 2026-10-06 实施完成 e0b29b4(pin+EvictionLog+owner 软隔离三招全落地,子代理报告出口 pin 收编;验收 cargo check 零 error)
- [ ] P2 被拒事实父上下文仅报告尾段单副本,LLM 压缩 prompt 无保留指令[需拍板] - [ ] P2 被拒事实父上下文仅报告尾段单副本,LLM 压缩 prompt 无保留指令[需拍板]
- [ ] P2 完成卡(前端全文)与父上下文(压缩)双轨无「已消化」降级机制 ✅设计完成(2026-10-04 [设计文档](./02-架构设计/专项设计/子代理完成卡已消化降级-设计-2026-10-04.md)):勘察修正——重启回补已存在(aiShared.ts:677 ai_sub_agent_list 反查重建),设计聚焦 digested 标记(KV 持久)+卡降级交互+ai_sub_agent_trace 只读工具(LLM 追问自助通道,兼为 namespace 蒸发兜底);2 拍板点,约 1-1.5 天 - [ ] P2 完成卡(前端全文)与父上下文(压缩)双轨无「已消化」降级机制 ✅设计完成(2026-10-04 [设计文档](./02-架构设计/专项设计/子代理完成卡已消化降级-设计-2026-10-04.md)):勘察修正——重启回补已存在(aiShared.ts:677 ai_sub_agent_list 反查重建),设计聚焦 digested 标记(KV 持久)+卡降级交互+ai_sub_agent_trace 只读工具(LLM 追问自助通道,兼为 namespace 蒸发兜底);2 拍板点,约 1-1.5 天
- [ ] P2 子会话缺 maybe_auto_compress 层+预算恒 128k 默认[需拍板] - [ ] P2 子会话缺 maybe_auto_compress 层+预算恒 128k 默认[需拍板]
@@ -637,10 +410,15 @@ graph TD
- [ ] P2 审计表无执行耗时字段(requested→executed 混入审批等待,耗时统计不可信)【部分缓解:AuditLog.vue:71 ⏱ durationLabel 已显示且诚实标注「审批→执行」;纯执行耗时独立字段归 V49 schema 迁移批】 - [ ] P2 审计表无执行耗时字段(requested→executed 混入审批等待,耗时统计不可信)【部分缓解:AuditLog.vue:71 ⏱ durationLabel 已显示且诚实标注「审批→执行」;纯执行耗时独立字段归 V49 schema 迁移批】
- [ ] P2 messages blob 双存储冗余(89/89,单会话最大 1.7MB)【归 V49 schema 迁移批】 - [ ] P2 messages blob 双存储冗余(89/89,单会话最大 1.7MB)【归 V49 schema 迁移批】
- [ ] 观察自动执行模式架空审批(auto 系 81%/人工 15%,auto_takeover_all 含 run_command 886 次)——产品语义与 S2/信任分级联动 - [ ] 观察自动执行模式架空审批(auto 系 81%/人工 15%,auto_takeover_all 含 run_command 886 次)——产品语义与 S2/信任分级联动
- [ ] 913 ✅转待办 验收:auto 模式审批架空语义收敛——auto_takeover_all 触发高危工具(run_command 等)须按 S2/信任分级处置(拦截或旁路留痕);审计表可查 auto 会话审批触发/旁路记录,产品语义写入设计档。
- [ ] 观察进化系统只写不读:knowledges 1033 条/embedding 全 NULL/83.6% 零复用/knowledge_search 仅 5 次 - [ ] 观察进化系统只写不读:knowledges 1033 条/embedding 全 NULL/83.6% 零复用/knowledge_search 仅 5 次
- [ ] 915 ✅转待办 验收:知识库由「只写不读」转读写闭环——embedding_status 回填(或明确弃用向量路径),≥半数知识进检索候选,knowledge_search 调用与复用率实测上升(复用率 >50%、检索调用非个位数);口径=同库 DB 查询核验。
- [ ] 观察多模态未走通:贴图落"[图片已忽略]"占位,实际靠 run_command 外部 vision CLI 绕路 - [ ] 观察多模态未走通:贴图落"[图片已忽略]"占位,实际靠 run_command 外部 vision CLI 绕路
- [ ] 917 ✅转待办 验收:贴图走原生视觉路径不落占位——非视觉模型收图时明确拒绝或切视觉模型,视觉模型直接出识别结果,不再靠 run_command 调外部 vision CLI 绕路;实测贴图会话零"[图片已忽略]"占位。
- [ ] 观察编排面零使用:workflow/node_executions/ai_plans 等 9 张表全 0 行;四会话分工 origin 89/89 全 NULL - [ ] 观察编排面零使用:workflow/node_executions/ai_plans 等 9 张表全 0 行;四会话分工 origin 89/89 全 NULL
- [ ] 919 ✅转待办 验收:编排面明确使用或弃用——e2e 跑通一个 workflow 使 node_executions 非空,或判定降级为预留并销账;会话 origin 回填(不再 89/89 NULL);口径=DB 查各表行数与 origin 非空计数。
- [ ] 观察数据目录 ~640MB(主库 140.5MB+dev 库 71.2MB+4 份手工备份 346.9MB 散落) - [ ] 观察数据目录 ~640MB(主库 140.5MB+dev 库 71.2MB+4 份手工备份 346.9MB 散落)
- [ ] 921 ✅转待办 验收:数据目录有保留策略——手工备份纳入轮转(定份数与周期),dev 库与主库职责明确(合并或清理);口径=%APPDATA%/top.1216.devflow 目录大小回落且备份数受控。
# ai-chat UX 走查第五轮(2026-10-05,可访问性视角,详情 temp/UX走查-aiChat缺陷-2026-10-05.md) # ai-chat UX 走查第五轮(2026-10-05,可访问性视角,详情 temp/UX走查-aiChat缺陷-2026-10-05.md)
@@ -675,6 +453,7 @@ graph TD
- [ ] **资源面**:token/磁盘/内存/进程开销评估;日志与数据膨胀上限(借鉴日志无轮转教训) - [ ] **资源面**:token/磁盘/内存/进程开销评估;日志与数据膨胀上限(借鉴日志无轮转教训)
- [ ] **回退开关**:新能力默认关或带单点回退(借鉴 GOAL_PIN 系开关范式);灰度顺序与观测指标 - [ ] **回退开关**:新能力默认关或带单点回退(借鉴 GOAL_PIN 系开关范式);灰度顺序与观测指标
- [ ] P2 指令离线即丢(§7 不缓存):commander 发指令时 agent 离线=静默丢失,移动端控制场景用户无感知——补业务层送达回执或短缓冲 - [ ] P2 指令离线即丢(§7 不缓存):commander 发指令时 agent 离线=静默丢失,移动端控制场景用户无感知——补业务层送达回执或短缓冲
- [ ] 923 ✅转待办 验收:离线指令不静默丢——补业务层送达回执(发送方可见成功/失败)或短缓冲(重连后补投);口径=断网/离线场景实测指令有回执或重连后送达,无静默丢失。
- [ ] P2 Hello 无协议版本字段:下次破坏性变更仍是无通知硬切换——加 proto 版本字段(现在加便宜,以后加贵) - [ ] P2 Hello 无协议版本字段:下次破坏性变更仍是无通知硬切换——加 proto 版本字段(现在加便宜,以后加贵)
- [ ] P3 单共享 token 可冒充任意 channel_id(§8 自述):补 token 轮换 runbook(/etc/u-relay.env 改+重启+四方客户端同步换,锁步成本需写明) - [ ] P3 单共享 token 可冒充任意 channel_id(§8 自述):补 token 轮换 runbook(/etc/u-relay.env 改+重启+四方客户端同步换,锁步成本需写明)
- [ ] P3 心跳间隔未定值:nginx proxy_read_timeout=300s,间隔须 <300s,文档补建议值(30-60s) - [ ] P3 心跳间隔未定值:nginx proxy_read_timeout=300s,间隔须 <300s,文档补建议值(30-60s)
@@ -731,7 +510,7 @@ graph TD
- [ ] W-g 状态即时同步与通知(设计 §九):W-g1 出口扇出+即时级(依赖飞书通道)/W-g2 摘要级+远控查询命令族/W-g3 每日报告(挂机联动) - [ ] W-g 状态即时同步与通知(设计 §九):W-g1 出口扇出+即时级(依赖飞书通道)/W-g2 摘要级+远控查询命令族/W-g3 每日报告(挂机联动)
- [ ] W-h 会话间/代理间感知(FileLeaseRegistry:写前登记+冲突指路/query_leases 工具/兄弟感知/知识联动;与 W-a 组合成完整 lease)——设计 §十;根治今日全部互踩 - [x] W-h 会话间/代理间感知(FileLeaseRegistry:写前登记+冲突指路/query_leases 工具/兄弟感知/知识联动;与 W-a 组合成完整 lease) ✅ **2026-10-10 核验销账(源码取证,发现早已落地)**:实现在 `src-tauri/src/commands/ai/lease.rs`(345 行,commit cb260ab「断点续跑与日志租约底座」);接线三处齐备——`commands/ai/mod.rs:48 pub mod lease;` / `tool_registry.rs:561 register_lease_tool`(query_leases 只读 Low,工具基线 80 含 lease 且已有断言) / 写类工具前置校验+RAII 释放 `audit/mod.rs:274-297`。本轮增量=把「冲突检测/过期清理」抽为纯函数 `try_register`/`purge_expired` + 补 3 单测(过期回占/清理计数/异路径并存),`lease::` 11 passed;**未新建 file_lease.rs 造双轨**。⚠️ 并行坑留痕:任何「注入未来时间查全局 REGISTRY」的测试会因 query 的 retain 误清他测活跃租约致偶发失败,新增时间敏感测试须走纯函数+独立表。——设计 §十;根治今日全部互踩
- [ ] W-e2 全局并发池(小:子代理并发上限+预算联动) - [ ] W-e2 全局并发池(小:子代理并发上限+预算联动)
- [ ] W-d1 挂机最小闭环(领单→执行→销账→循环+轻量续接;W-b wake 随其绑定实施) - [ ] W-d1 挂机最小闭环(领单→执行→销账→循环+轻量续接;W-b wake 随其绑定实施)
@@ -824,32 +603,11 @@ graph TD
- 飞书四修+流式(占位卡片 edit_card 原地更新/表格降级/clip_for_card 超长裁剪/启动日志)——已上线 - 飞书四修+流式(占位卡片 edit_card 原地更新/表格降级/clip_for_card 超长裁剪/启动日志)——已上线
- 知识库 691b351d/c142e6b2 已 published+high(c142e6b2 含权威源声明);9c848ab 确认零丢失(rescue 分支锚定) - 知识库 691b351d/c142e6b2 已 published+high(c142e6b2 含权威源声明);9c848ab 确认零丢失(rescue 分支锚定)
### 新缺陷(2026-10-05 22:2x 用户截图实证,与 N/D 清单同级)
8. [x] **工具输出 ANSI 转义裸露**(乱码):run_command 类工具输出含 ANSI 码(\x1b[7m 等)原样透传前端显示为 ␛[7m。修:执行器统一 strip_ansi(或注入 TERM=dumb/--color=never)+前端渲染层兜底过滤;验收:cargo test 输出在面板无 ␛ 残留。 ✅ 2026-10-06 补销: 已随 70d6e98 修复(ANSI 源头抑制+strip 兜底), 当时漏销
9. [x] **会话标题 content 泄漏**:会话标题显示原始正文片段(如 `<details> <summa…`,84cb104c 会话同症)——标题生成输入未清洗或兜底直接取 content 头部。修:title 生成失败/超时兜底改为清洗后的首行摘要,含 HTML/Markdown 标签时剥离;验收:长会话标题无标签裸露。 ✅ 2026-10-06 补销: 已随 70d6e98 修复(标题剥标签 sanitize(title.rs)), 当时漏销
> 附:截图实证单会话 206 轮/1500 万 tokens——C 批次(上下文管理)紧迫性最强活证,随 C-r0 优先消化。
### 🔴 提交后跟进(2026-10-05 23:0x 走查发现,cb260ab 已入库但未修)
10. [x] **loop_state 对新会话/编排会话静默失效(高)** ✅修复(2026-10-06,提交「修复: loop_state 懒创建继承与入口早退清理」):懒创建继承 running(conversation.rs)+入口早退清残留(agentic/mod.rs)+list_parked 消费方=TopBar「可恢复」分区(V49 读侧)。验收:cargo check EXIT 0+vue-tsc EXIT 0。
11. [x] 【中】conversation_repo.rs:817 注释矛盾 ✅修复(2026-10-06 同批):update_full 剥离 loop_state 列,封 lost-update 窗口,注释与实现一致。
12. [x] 【中】前端 i18n 双侧同缺 aiChat.reattachToMain 键 ✅修复(2026-10-06 同批):zh/en 补键。
> 以上为 72 文件走查结论(报告要点 23:0x 已交群),提交规范 18 笔全合规,质量项按本清单跟进。
## 流畅性根本解三件(2026-10-05 深夜用户立项:1+2+3,④双通道搁置) ## 流畅性根本解三件(2026-10-05 深夜用户立项:1+2+3,④双通道搁置)
13. [x] **①过程流式** ✅ 2026-10-06 实施:run_stream_refresher 消费面扩容(AiTextDelta + AiAgentRound + AiToolCallStarted/Completed 四事件,conv 路由过滤共用)——过程流不刷屏:卡片改「头部一行摘要 + 正文增量」结构,头行=轮次 + ⚙当前工具执行中/已完成 N 步(历史步骤折叠为计数,决策价值过滤);过程事件标脏强制重刷(纯工具轮无文本也有"活着"信号,治 206 轮长任务卡片静止猜卡死);签名去重防同 tick 事件风暴空刷;超限保尾截断口径不变。4 单测锁格式契约(首轮无头行/工具执行中/轮间空档/截断)。验收:cargo check EXIT 0 + feishu 7 passed。参照 u-claw 进度卡治理;桌面过程视图同步对齐见「桌面端能力同步清单」。
14. [ ] **②任务式委托**:重任务(agentic loop 任务)受理即回任务卡(「已受理·预计 N 分钟」),用户可离开;完成后卡片原地更新+结果在卡。底座已有(30min 超时任务后台不丢),重构 handle_message 等待形态为受理/完成两段式。验收:发「走查代码」3s 内收到受理卡,完成后卡片变结果,全程无需等待。 14. [ ] **②任务式委托**:重任务(agentic loop 任务)受理即回任务卡(「已受理·预计 N 分钟」),用户可离开;完成后卡片原地更新+结果在卡。底座已有(30min 超时任务后台不丢),重构 handle_message 等待形态为受理/完成两段式。验收:发「走查代码」3s 内收到受理卡,完成后卡片变结果,全程无需等待。
15. [ ] **③C 批次关联**:本组两件的提速收益依赖 C 批次(prompt 71K→按需)——C-m1/m2 优先级顺位提升(单轮推理是 80% 耗时大头,见流畅性分析 2026-10-05)。 15. [ ] **③C 批次关联**:本组两件的提速收益依赖 C 批次(prompt 71K→按需)——C-m1/m2 优先级顺位提升(单轮推理是 80% 耗时大头,见流畅性分析 2026-10-05)。
16. [x] **纠偏注入通道**(用户 2026-10-05 深夜点名,飞书对话补充消息目前只能排队不能注入运行中任务)✅ 2026-10-06 实施:引擎层 `inject_runtime_message`(chat.rs,活跃守卫 conv_states.is_active → 短锁 push per_conv.messages → save_conversation 增量落库,loop 下轮 build_for_request 自然带上,u-claw 补拉同构);飞书侧 handle_message 白名单后加注入旁路(chat_map 命中且 conv 活跃 → 注入 + 卡片确认「下一轮生效」,非活跃 Err 落回常规 spawn 路径零行为变更)。不改锁语义/不打断 loop;桌面任务卡补充入口待 14 异步委托批配。验收:cargo check EXIT 0 + chat 17 passed + feishu 3 passed(含注入前缀格式契约单测)。
> **14 升级(用户拍板 2026-10-06 凌晨):桌面也要异步能力,不做双端分叉**——异步委托是引擎层能力,桌面与飞书都是它的前端视图。实现要点:①引擎层 spawn 即受理返回,完成走通知桥广播(oneshot 改事件+等待者订阅) ②桌面任务卡 UI:受理/进行中 N 轮/完成+系统通知,发消息后可切会话/最小化 ③飞书为同一语义投影(受理卡/完成原地更新) ④锁语义保守起步=单会话单在途+排队,多并发看用量再说 ⑤yml 开关灰度。依赖链:16 纠偏通道(受理后补充入口)→13 过程流式(进行中视图)→本项。验收:桌面发任务→切走→完成收系统通知;飞书同构;双端行为一致。
> **设计约束(用户拍板 2026-10-06 凌晨):任务会话高度复杂,代码设计一定要优雅**——①状态机显式化:受理/运行/暂停/完成/失败用枚举统一,禁散落 bool 拼凑 ②单一事实源:任务状态只在引擎层,桌面/飞书 UI 都是投影(订阅不轮询) ③事件驱动贯通:13/14/16 共用同一事件总线,不做渠道私有旁路 ④优雅优先于速度:宁可工期换结构,不做特判堆补丁(优雅架构铁律)。
> **桌面端能力同步清单**(与渠道对齐,2026-10-06):16 纠偏注入做在 AiSession 会话层桌面天然受益;13 过程流式桌面过程视图同步对齐;14 任务卡+系统通知已在升级注记;另两件桌面专属:①V49 可恢复入口——走查实锤 list_parked 读侧零消费,桌面侧栏补「可恢复」列表消费它(断点续跑闭环最后一块)✅已做 e0b29b4(分组展示+点击恢复) ②缺陷9 会话标题泄漏桌面同症随批修。
17. [ ] **桌面会话内容同步飞书**(同会话,2026-10-06 凌晨拍板):绑定飞书 chat 的 conv(chat_map 映射既有,恢复机制在)——桌面端该 conv 的新消息(user/assistant)实时投影飞书卡片,用户离机后手机飞书可跟任务全程。实现:订阅 ai_event_bus 的 conv 内容事件→飞书投影,与 13 过程流式/14 任务卡共用单写卡者;按决策价值过滤防刷屏(完整历史在桌面,飞书给关键节点)。与 14 组合即完整闭环:桌面发任务→切走→手机看过程→收结果。验收:桌面 conv 发消息/收回复,绑定飞书 3s 内卡片更新关键节点。 17. [ ] **桌面会话内容同步飞书**(同会话,2026-10-06 凌晨拍板):绑定飞书 chat 的 conv(chat_map 映射既有,恢复机制在)——桌面端该 conv 的新消息(user/assistant)实时投影飞书卡片,用户离机后手机飞书可跟任务全程。实现:订阅 ai_event_bus 的 conv 内容事件→飞书投影,与 13 过程流式/14 任务卡共用单写卡者;按决策价值过滤防刷屏(完整历史在桌面,飞书给关键节点)。与 14 组合即完整闭环:桌面发任务→切走→手机看过程→收结果。验收:桌面 conv 发消息/收回复,绑定飞书 3s 内卡片更新关键节点。
18. [x] **渠道错误话术吞因**(09:51 实证)✅ 2026-10-06 修复:①spawn Err 分类话术——并发护栏拒绝(「正在生成」)→「⏳ 该会话已有任务进行中,本条将在任务完成后处理;可稍后重发,或先 /status 查看进度」;其余失败→附截断原始原因(不吞因)。②探活告警口径修正——inflight 计数点从 spawn 前提前到 handle_message 白名单通过处(RAII 覆盖占位卡 HTTP+per-chat 锁排队窗口,原该窗口任务在跑却报「无在途会话」);旧计数点删除防双重计数。验收:cargo check EXIT 0 + feishu 7 passed。
> 附:09:48 桌面纠偏「不是 haogame 是 redtimer」被运行中 loop 下一轮直接接住——16 纠偏注入的桌面形态天然存在,飞书侧对齐即可。
## 工作流治理升级(2026-10-06 用户拍板:根本性增强,源自提交质量检查三不达标) ## 工作流治理升级(2026-10-06 用户拍板:根本性增强,源自提交质量检查三不达标)
> 根因:任务传递靠软机制(看板摘要+群消息阅读),无结构化任务单与销账门禁——🔴被静默跳过/i18n 描述失实/描述与内容偏差,三层同源。 > 根因:任务传递靠软机制(看板摘要+群消息阅读),无结构化任务单与销账门禁——🔴被静默跳过/i18n 描述失实/描述与内容偏差,三层同源。
@@ -860,17 +618,6 @@ graph TD
- [ ] **G3 描述反向核对**:提交前 diff 关键词与描述机械比对,不符打回(cm 技能增强) - [ ] **G3 描述反向核对**:提交前 diff 关键词与描述机械比对,不符打回(cm 技能增强)
- [ ] **G4 销账带证据**:勾选 [x] 须附验收命令输出行,无证据视同未完成 - [ ] **G4 销账带证据**:勾选 [x] 须附验收命令输出行,无证据视同未完成
### 桌面能力同步(随本批)
- [x] G5 V49「可恢复」入口(桌面侧栏消费 list_parked,断点续跑闭环最后一块)✅ e0b29b4 可恢复分组+点击恢复已落地
- [ ] G6 缺陷9 标题泄漏桌面同症随批修
### ⚠️ 退回重处理(2026-10-06 用户令,G2/G3 治理标准首次执行)
- 三笔提交(cec4d11 委托引擎/84df9b5 租约/622507c UI)已 reset --mixed 退回(内容全在工作区,零丢失,未推远端无需处理远端)
- **重做要求**:①先修 🔴10 loop_state 失效(G2 门禁:🔴不清零不得提交新功能)②描述按 G3 如实:实际干了 14+16+事件广播三件须逐件列全 ③i18n reattachToMain 键随批补上 ④修完按主题重新分组提交,销账 [x] 附验收输出(G4)
19. [x] **飞书 outbound 统一出口**(11:23 实证)✅ 2026-10-06 收口:勘察确认对话/受理/完成/注入回执已统一走 deliver_result(降级/裁剪/卡片链内聚);病灶「挂机进度汇总独立发送路径」在当前 main 不存在(属退回的挂机实验代码)。本批防复发收口:①新增 push_to_chat pub(crate) 正典出口(主动推送形态——挂机进度汇总/巡检通知将来落地时禁再开独立路径,强制走降级/裁剪/卡片链) ②ACTIVE_CLIENT OnceLock 全局句柄(渠道启动就绪,推送复用长连接 client 不临时建连,未启动静默跳过)。验收:cargo check EXIT 0 + feishu 7 passed。
> 附:挂机汇报自报 🔴10 已修验证通过+43 文件产出——挂机模式推进有效,路径修复别误伤它。
## 无人值守可靠性专项(2026-10-06 swarm 立项,设计档:docs/02-架构设计/无人值守可靠性专项设计-20261006.md+temp/V49断点续跑设计-20261006.md)
## UI/UX 体验设计实施项(2026-10-06 设计档:temp/异步任务会话UIUX设计-20261006.md,依赖 13/14/16/17 机制先行) ## UI/UX 体验设计实施项(2026-10-06 设计档:temp/异步任务会话UIUX设计-20261006.md,依赖 13/14/16/17 机制先行)
- [ ] U1 任务卡生命周期组件(双端):桌面 TaskCard 八态投影枚举(全部由既有事件派生,零引擎新状态)+飞书 header 状态色结构卡(JSON 元素级规格已给,动作 v1 走文本命令) - [ ] U1 任务卡生命周期组件(双端):桌面 TaskCard 八态投影枚举(全部由既有事件派生,零引擎新状态)+飞书 header 状态色结构卡(JSON 元素级规格已给,动作 v1 走文本命令)
@@ -879,24 +626,6 @@ graph TD
- [ ] U4 纠偏交互:桌面直发默认注入·下一轮生效(排队可切),回执+前缀逃生;飞书回复即注入保持 - [ ] U4 纠偏交互:桌面直发默认注入·下一轮生效(排队可切),回执+前缀逃生;飞书回复即注入保持
- [ ] U5 异常态话术公式:状态+真实原因+已保留什么+≥2条出路;禁自动判死静默任务(15min 重入退避是合法等待),给判断材料不替用户决策 - [ ] U5 异常态话术公式:状态+真实原因+已保留什么+≥2条出路;禁自动判死静默任务(15min 重入退避是合法等待),给判断材料不替用户决策
- [ ] U6 跨端一致性:引擎唯一写者,双端不直同步,"实时"=秒级最终一致;断线恢复桌面走 DB 回放/飞书走 /status 回查 - [ ] U6 跨端一致性:引擎唯一写者,双端不直同步,"实时"=秒级最终一致;断线恢复桌面走 DB 回放/飞书走 /status 回查
20. [x] **委托制伪受理断层**(13:38 实证):受理卡在引擎确认可启动之前发出——12:57 任务停滞 Generating 时,「进度」spawn 被引擎拒但受理卡已发,之后零通知零进度(比 09:51 立即报错更隐蔽)。修:受理前过 can_accept_request,不可启动→直接回「前任务在跑,已排队/稍后」;可启动→才发受理卡+watcher 接力。附带:委托制下 inflight 生命周期改跟任务走(handle_message 提前返回致 18 修正再次失效,13:18 误报复现)。验收:同场景复测,占用期发消息得到明确排队提示而非静默受理。 ✅ 2026-10-06 c91b993(交互批八项合提,验收: cargo check 零 error+feishu 13/df-im-gateway 19/chat 17 passed+vue-tsc 0)
> **符号语义规范(2026-10-06 用户反馈:受理卡「✅ 已受理」歧义,随 19 统一出口一并落地)**:
> - 🚀 受理/开工(任务开始才用,禁 ✅)|⏳ 进行中/排队/等待|✅ 仅限完成且成功|❌ 失败(必须附原因)|⚠️ 警告/需注意(附数据)|📎 附录/附加信息
> - 原则:符号=状态的一眼判据,同一符号跨场景语义必须一致;受理卡改为「🚀 已受理,后台执行中」;完成推送才允许 ✅ 开头。
## 飞书交互面全量盘查(2026-10-06,11 条缺陷按恼人度降序,修复前话术避免引用 /status)
34. [x] 🔴 **过程推送散乱刷屏**(14:06 用户截图反馈「信息太散乱爆炸了」):push_to_chat(:797)每调用必新开独立卡片,通道无聚合语义——昨日调试把链路测试/修复上线/加固明细等 L3 过程逐条直推 4+ 条进群,与 U3 分级相悖。修双层:①机制=push_to_chat 增滚动汇总卡——同 chat 维护在途事务卡 message_id(内存即可,重启另起),进度用已有 update_card(:730)原地追加;首推需 send_markdown_card 返回新卡 id(现 :683 is_ok() 丢弃,比照 reply_markdown_card);文本以 ✅/❌ 开头=终态,更新后清句柄下条另起新卡;更新失败回退新发并重绑(比照 deliver_result 兜底)。②纪律=L3 过程默认不推群,落日志+进度文件;push 收敛「需决策/完成✅」两时机,调试确需的验证推也进汇总卡不新开。验收:同事务多步进度群时间线只占 1 张卡,过程类推送零直达。 ✅ 2026-10-06 c91b993(交互批八项合提,验收: cargo check 零 error+feishu 13/df-im-gateway 19/chat 17 passed+vue-tsc 0)
21. [x] 🔴 **/status 被话术推荐但不存在**(chat.rs:387/:486 两处):用户发了被当任务烧一轮 LLM。修:实现 /status(读 conv 状态返回进度摘要,配合委托制价值高)或先改话术删除引用。验收:发 /status 得进度卡而非 spawn。 ✅ 2026-10-06 c91b993(交互批八项合提,验收: cargo check 零 error+feishu 13/df-im-gateway 19/chat 17 passed+vue-tsc 0)
22. [x] 🔴 **35min 兜底回查会投出上一任务旧回答冒充本次结果**(feishu_channel.rs:483/:939):回查整 conv 最后 assistant 文本无任务边界。修:回查限定受理时间戳之后的 assistant,无则给真实状态话术。 ✅ 2026-10-06 c91b993(交互批八项合提,验收: cargo check 零 error+feishu 13/df-im-gateway 19/chat 17 passed+vue-tsc 0)
23. [x] 🔴 **watcher 首超时后永不弃守**(:486 continue 与注释"二次超时弃守"相反)+错误路径只发 AiError 无 AiAgentFinished→每 35min 重刷过期文案+任务泄漏。修:二次超时真弃守+AiError 也广播 Finished(status=error)。 ✅ 2026-10-06 c91b993(交互批八项合提,验收: cargo check 零 error+feishu 13/df-im-gateway 19/chat 17 passed+vue-tsc 0)
24. [x] **无取消/审批通路**:跑偏无法叫停(飞书零 stop 通道);审批挂起对飞书用户不可见(流式卡冻结无解释)。修:/cancel 对接 ai_stop_loop;审批事件上卡片(「需审批:xxx,回复 批准/拒绝」)。 /cancel 停止通路+watcher 收口+审批推送与批准/拒绝决议(路径类授权留原语差异), ✅ 2026-10-07 细项批(待提交)
25. [x] **非文本消息静默丢**(:243):发图/文件零反馈。修:回「暂仅支持文本,媒体能力规划中」(D7 前的兜底)。 非文本兜底已存在于基线(:255 回「暂仅支持文本」), ✅ 2026-10-07 核验补销
26. [x] **⏳话术承诺排队实际不入队**——话术与行为不符。修:改话术为实况(「可稍后重发」)或实现真排队(与 20 受理断层同修)。 ✅ 2026-10-06 c91b993(交互批八项合提,验收: cargo check 零 error+feishu 13/df-im-gateway 19/chat 17 passed+vue-tsc 0)
27. [x] **快任务兜底死分支**(:449 elapsed>=3s 入场即查恒假,「秒级任务直投终版」不可达)。修:受理卡发送后计时或事件驱动判定。 ✅ 2026-10-06 c91b993(交互批八项合提,验收: cargo check 零 error+feishu 13/df-im-gateway 19/chat 17 passed+vue-tsc 0)
28. [x] **注入末轮竞态**:loop 已在末轮则补充指令无人消费静默丢失。修:注入后检查 loop 剩余轮次,末轮则自动续一轮。 注入末轮顺延轮次预算(共享余量+轮顶动态判定+封顶 8), 5 单测锁定, ✅ 2026-10-07 细项批(待提交)
29. [x] **push_to_chat 取 KV map 首条**(:792):多 chat 绑定可能发错会话。修:按 conv 反查 chat。 push_to_chat 按 conv 反查+多绑定取最近活跃(签名加 conv_hint 零涟漪), ✅ 2026-10-07 细项批(待提交)
30. [x] 细节批::389 反引号不闭合;/new 大小写与附言容错;/new 时旧任务在跑无提示;标点半全角混排。 反引号闭合//new 容错与旧任务提示/半全角归一(parse_command 3 单测), ✅ 2026-10-07 细项批(待提交)
## 上下文根治专项(2026-10-06 设计定稿:docs/02-架构设计/上下文根治设计-20261006.md,取代旧 C 发散条目) ## 上下文根治专项(2026-10-06 设计定稿:docs/02-架构设计/上下文根治设计-20261006.md,取代旧 C 发散条目)
- [ ] P1(S):工具结果瘦身——超 2K 结果正文落盘+句柄,上下文留摘要+路径(与 D2a 同构,最大头先行) - [ ] P1(S):工具结果瘦身——超 2K 结果正文落盘+句柄,上下文留摘要+路径(与 D2a 同构,最大头先行)
- [ ] P2(M):L1/L2/L3 分层组装器(build_for_request 改造:常驻+滑动窗口+检索召回) - [ ] P2(M):L1/L2/L3 分层组装器(build_for_request 改造:常驻+滑动窗口+检索召回)
@@ -925,13 +654,10 @@ graph TD
**补齐优先序:B5d 工具重试策略 → B5a 挂钟定级 → B5b 自动续作 → B5c 审批策略 → B2 预算闸 → 13 过程流式** **补齐优先序:B5d 工具重试策略 → B5a 挂钟定级 → B5b 自动续作 → B5c 审批策略 → B2 预算闸 → 13 过程流式**
### 13a 飞书过程可见性加固(2026-10-06 用户实证"卡点到结束才回复",13 的最小件提前独立做,零新机制) ### 13a 飞书过程可见性加固(2026-10-06 用户实证"卡点到结束才回复",13 的最小件提前独立做,零新机制)
- [x] 13a-1 刷新可观测:refresher 的 update_card 失败从"静默跳过"改为 feishu_log 记录+连续失败计数(≥3 连败告警)——当前刷了还是没刷、刷败全不可知,排查无从下手。 ✅ 2026-10-07 实施(feishu_channel.rs 单文件):刷失败 feishu_log 落盘(连败次数/chat/msg/chars)+连败 ≥3 告警一次(REFRESH_FAIL_ALERT_THRESHOLD=3,== 阈值触发后继续累计不重复)+刷新恢复记「此前连败 N 次」+成功归零。验收:cargo check -p devflow EXIT 0 + cargo test --lib feishu 16 passed 0 failed。
- [ ] 13a-2 静默期心跳汇报:AiHeartbeat(20s 既有事件)接入 refresher——超 N 分钟无内容刷新→主动刷一行「仍在跑:第 X 轮 · 最近动作 yyy」(数据源 AiAgentRound/最后工具名),防静默期卡片冻结。 - [ ] 13a-2 静默期心跳汇报:AiHeartbeat(20s 既有事件)接入 refresher——超 N 分钟无内容刷新→主动刷一行「仍在跑:第 X 轮 · 最近动作 yyy」(数据源 AiAgentRound/最后工具名),防静默期卡片冻结。
- [ ] 13a-3 阶段汇报:AiTaskProgress(每 5 轮,mod.rs:1119 已 emit)接入 refresher——长任务的阶段性进展对飞书用户可见(当前只有桌面消费)。 - [ ] 13a-3 阶段汇报:AiTaskProgress(每 5 轮,mod.rs:1119 已 emit)接入 refresher——长任务的阶段性进展对飞书用户可见(当前只有桌面消费)。
- [ ] 13a-4 受理→首刷衔接:受理卡到首个 delta/心跳之间的静默窗口检查(>60s 无首刷则心跳兜底)。 - [ ] 13a-4 受理→首刷衔接:受理卡到首个 delta/心跳之间的静默窗口检查(>60s 无首刷则心跳兜底)。
验收:206 轮级长任务期间,占位卡至少每 3-5 分钟有一次真实更新(心跳或阶段),失败可见于 feishu-channel.log。 验收:206 轮级长任务期间,占位卡至少每 3-5 分钟有一次真实更新(心跳或阶段),失败可见于 feishu-channel.log。
31. [x] (自动续作) **AI-Ops→devflow 派工通道**(多 bot 编排正统通道,2026-10-06 缺口实证):AI-Ops 无法直接触发 devflow 开工(飞书 bot 消息不互触发/WebChat 无/remote_bridge 走 tunnel 不外用)。方案二选一:①u-claw 侧新增 ask_devflow(与既有 ask_uclaw 对等,经 remote_bridge 协议下发启动指令) ②devflow 侧看板轮询引擎(定时扫 todo.md 新🔴/高优条目自动开工,CLAUDE.md 领单纪律的运行时化=B5b 同源)。建议 ② 与 B5b 合并实现(同一套自动续作引擎)。验收:AI-Ops 写看板后 15min 内 devflow 自主开工,零人工中转。 ✅ 2026-10-07 方案②实施并与 B5b 合并落地(unattended.rs 636 行,B5b+31 同一引擎):看板解析器(编号条目四态+🔴 判据=标记后首字符防误判)/G2 门禁领单选择器(🔴 未清零只领红 fail-closed)/轮询调度器(tokio 常驻,KV 热读配置 df-unattended-{enabled,poll-secs,claim-scope,todo-path},默认关+300s)/收敛回读钩子 notify_batch_done 接 agentic emit_ai_completed_once 单点/parked 自动恢复优先/连续 3 次 spawn 失败熔断/领单原子写+spawn 失败回滚/引擎独立后台会话 pin_active=false。验收证据:①接线 rg 实测 lib.rs:77 unattended::start + agentic/mod.rs 收敛钩子调用 ②`cargo test --lib unattended` = 9 passed 0 failed(本轮实测修正 parse_statuses_and_red_marker 断言:🔴 判据仅认标记后首字符,正文提及不误判,与实现注释语义对齐) ③**活证**=本条目自身即引擎产出:AI-Ops 写板 → 引擎改写 `31. [→](自动续作)` → 派发本会话执行,零人工中转,15min 上限实测远低于下限。
### 31 泛化路线:「看板即队列」模板资产化(2026-10-06 swarm 设计定稿) ### 31 泛化路线:「看板即队列」模板资产化(2026-10-06 swarm 设计定稿)
- **抽象**:看板=任务队列(优先级/状态三态/验收口径)+ 领单纪律=消费者协议 + 治理门禁=质量闸。任何 Claude Code 实例 + 一个看板 = 一个自主工作单元。 - **抽象**:看板=任务队列(优先级/状态三态/验收口径)+ 领单纪律=消费者协议 + 治理门禁=质量闸。任何 Claude Code 实例 + 一个看板 = 一个自主工作单元。
- **组件三件套模板**(提炼自 devflow 今日实践,新场景开箱即用): 1.看板格式规范 2.领单纪律 CLAUDE.md 模板 3.治理门禁包(cm v1.3.0+pre-commit hook) - **组件三件套模板**(提炼自 devflow 今日实践,新场景开箱即用): 1.看板格式规范 2.领单纪律 CLAUDE.md 模板 3.治理门禁包(cm v1.3.0+pre-commit hook)
@@ -948,16 +674,71 @@ graph TD
35. [~] ~~H3 teardown 疑似过重~~ **真相改写(验装日志实锤)**:17:47 进程消失=agent 自杀式换装脚本杀宿主(swap-install.log:17:48:14 old process stopped),非 H3——H3 嫌疑解除。真正缺陷转 36。 35. [~] ~~H3 teardown 疑似过重~~ **真相改写(验装日志实锤)**:17:47 进程消失=agent 自杀式换装脚本杀宿主(swap-install.log:17:48:14 old process stopped),非 H3——H3 嫌疑解除。真正缺陷转 36。
> 关联:13a 过程可见性四件**置顶催办**——受理后静默的体感投诉今天第三次(09:51/13:38/17:47+),13a-1 刷新可观测是排查此类问题的前提。 > 关联:13a 过程可见性四件**置顶催办**——受理后静默的体感投诉今天第三次(09:51/13:38/17:47+),13a-1 刷新可观测是排查此类问题的前提。
36. [x] **换装脚本失败:杀进程后 3s 即安装,Windows 文件锁延迟释放致 NSIS 静默伪成功**(17:48 实证 FAIL: exe not updated,installer exit=0)。✅ 2026-10-06 晚全链闭环:**脚本加固**(swap-install.ps1 v3,语法 0 错实测):①句柄释放探测(独占打开循环,30s) ②校验升级为构建产物 SHA256 强基准(mtime 判据作废,NSIS 保留源文件 mtime 必误报) ③校验失败自动重试一次 ④SYSTEM 触发时转投用户会话重入,四级 fallback,最坏落 swap-need-start.flag。**上线验证过**(18:44 版手动换装成功):运行 exe SHA256=构建产物=0C45683B,进程 12764 运行中,H1 行为实测通过(19:13 DevFlow 回复带「📎 git 实测」段)。
37. [ ] **监控轮询机制缺失(1004/1006 两次要求均未落地,全周响应性投诉的伴生缺口)**:10-04「持续不间断监控每 1 小时」+10-06 18:45「持续监控 10 分钟一轮」,两次均未建机制;18:45 断链实锤=传承摘要未继承该指令。根因:无头会话无定时能力(网关禁用 CronCreate)。方案二选一:①网关 cron(运维配 yml crons,周期拉 git/进程/日志快照+异常告警) ②devflow 侧自监控引擎(与 B5/31 看板轮询同源,顺带监控自身进程/会话活性+静默告警)。验收:监控自动轮询真实运转,devflow 静默/进程消失 1 个周期内被发现并通报。 37. [ ] **监控轮询机制缺失(1004/1006 两次要求均未落地,全周响应性投诉的伴生缺口)**:10-04「持续不间断监控每 1 小时」+10-06 18:45「持续监控 10 分钟一轮」,两次均未建机制;18:45 断链实锤=传承摘要未继承该指令。根因:无头会话无定时能力(网关禁用 CronCreate)。方案二选一:①网关 cron(运维配 yml crons,周期拉 git/进程/日志快照+异常告警) ②devflow 侧自监控引擎(与 B5/31 看板轮询同源,顺带监控自身进程/会话活性+静默告警)。验收:监控自动轮询真实运转,devflow 静默/进程消失 1 个周期内被发现并通报。
38. [x] **飞书出站 md 语法裸露净化+交互样式对齐 u-claw**(AI-Ops 2026-10-06 晚实证落板):任务报告 `##`/`**` 等 md 标记在飞书裸露(20:53 分析报告截图实锤)。根因:飞书 lark_md 不支持标题/表格等全谱语法,LLM 产出未经出站转换;主链 send_markdown_card(feishu_channel.rs:716,注释自认「text 类型会符号裸露」)只保粗体/链接,im-gateway StreamText 链纯文本直出。修:①出站净化层——md 转飞书友好格式(标题行转粗体/表格转列表/代码块适配),双层防线=出站兜底转换+agent 产出 prompt 约束,移植 u-claw 投递链降级/裁剪经验(u-claw 仓为范本) ②首回受理卡样式对齐 u-claw 任务状态卡(计数 N/M+逐条状态符+当前动作一行,10-02 用户拍板「效果不错」);受理卡符号 🚀 已由 AI-Ops 修(449 行)。验收:同类分析报告在飞书零 md 裸露标记,受理卡样式与 u-claw 一致。 ✅ 2026-10-06 c91b993(交互批八项合提,验收: cargo check 零 error+feishu 13/df-im-gateway 19/chat 17 passed+vue-tsc 0)
39. [x] 🔴 **提交冻结令被无人值守链路击穿**(10-07 用户查问「最近提交不符合要求」实证):看板顶部 🔒 冻结令(10-06 深夜用户令,期间只改不提)生效后,挂机链路 01:56-57 仍连提 4 笔(c5ba755 续作引擎 811 行/d0281cb 飞书取消审批 345 行/7ab25e5 换装判据/73fa7c2 看板销账),且 73fa7c2 看板内容自书「细项批(待提交)」与提交事实自相矛盾——冻结仅是文本纪律,引擎与挂机会话的提交步无机械闸。修:①引擎自动提交路径与挂机会话提交步在 commit 前必查冻结标志(看板顶部 🔒 行视为生效,KV 标志更佳),在场即拒绝提交、改动留工作区;禁用全局 pre-commit 拦截(误伤用户本人提交) ②引擎派发的会话启动 prompt 注入「冻结期禁 commit」硬约束 ③解冻=用户「攒批提交通知」后由会话摘除 🔒 行再提。验收:冻结令在场时引擎/挂机会话 commit 步零执行(实测),摘令后恢复;随 31 引擎同测。 ✅ 2026-10-07 实施(commit_freeze 三层闸,零提交全在工作区):①机械闸=crates/df-execute/src/commit_freeze.rs 新模块——静态冻结位 set_frozen(引擎 unattended::run_tick 每轮按看板 🔒 行+KV df-commit-frozen 覆盖同步)+execute/execute_streaming 入口 check(),git commit 变体(git -C/复合命令 ;&&|/引号包裹/多空格)spawn 前拒绝,改动留工作区;未挂全局 pre-commit,用户本人终端零影响。②任务书注入:claim_and_spawn 冻结在场时追加「提交冻结令硬约束」段(禁 git commit/push,执行层闸兜底)。③解冻=用户摘 🔒 行或 KV df-commit-frozen=false,下一轮轮询(≤300s)自动同步放行。验收实测:cargo test -p df-execute 26 passed 0 failed(18 lib 含 6 冻结闸新用例:commit 变体识别/只读命令放行/冻结拦截/解冻恢复/静态位切换;8 shell 集成)+ cargo test -p devflow --lib unattended 10 passed 0 failed(含冻结判据新用例:🔒 行/blockquote `> 🔒` 形态/KV 双向覆盖/默认不冻结)+ cargo check -p devflow --lib EXIT 0。冻结在场实测=单测断言 check() 对 `git commit` 返回 Err 且 `git diff` 放行,摘令后恢复=static_flag_toggle 用例复位断言;真实看板形态(`> 🔒` 引用行)经 commit_freeze_board_and_kv_judge 实测命中。
40. [ ] **桌面侧 u-relay 常驻连接管理器(设计命题,待综合评估后再规划设计)**(2026-10-07 用户议定「登记后处理」):ask_uclaw 现为每请求独立 WS 短连接(connect→hello→探活→request→progress/result→close,任务执行期内本就实时);用户提议改「全程一条常驻连接中转所有上下行(zhub 式单连接多路复用)」。**本项仅登记命题,具体方案待后续综合评估**——评估须回答:①当前唯一用例(ask_uclaw 15min 级委托)下常驻是否真有必要(握手 ~0.1s 可忽略、u-claw 侧单 channel 串行 worker 锁死并发=1)②push 型需求(u-claw 主动推事件/高频短指令/多 channel 并行/跨链路统一观测)何时兑现 ③是否与 10-05「推翻网关链路、定稿桌面直连」决策冲突(飞书直连接入-形态修正与问题记录-2026-10-05.md)。命中上述 push 型触发条件之一即启动评估;同构先例可参 df-im-gateway(一条常驻 tunnel 连接中转四端全部上下行)与 feishu_channel.rs。 40. [ ] **桌面侧 u-relay 常驻连接管理器(设计命题,待综合评估后再规划设计)**(2026-10-07 用户议定「登记后处理」):ask_uclaw 现为每请求独立 WS 短连接(connect→hello→探活→request→progress/result→close,任务执行期内本就实时);用户提议改「全程一条常驻连接中转所有上下行(zhub 式单连接多路复用)」。**本项仅登记命题,具体方案待后续综合评估**——评估须回答:①当前唯一用例(ask_uclaw 15min 级委托)下常驻是否真有必要(握手 ~0.1s 可忽略、u-claw 侧单 channel 串行 worker 锁死并发=1)②push 型需求(u-claw 主动推事件/高频短指令/多 channel 并行/跨链路统一观测)何时兑现 ③是否与 10-05「推翻网关链路、定稿桌面直连」决策冲突(飞书直连接入-形态修正与问题记录-2026-10-05.md)。命中上述 push 型触发条件之一即启动评估;同构先例可参 df-im-gateway(一条常驻 tunnel 连接中转四端全部上下行)与 feishu_channel.rs。
900. [x](自动续作) 🔴 测试条目
901. [x](自动续作) 🔴 提交工作区改动(转交实战,2026-10-07):将当前工作区改动按主题分组提交并推送 origin main。(21:4x AI-Ops 重置回队:前两轮引擎会话被 20:38-21:02 u-switch 故障窗口「网络中断不完整」腰斩,重入预算耗尽停 [→],网络已恢复)要求:①提交前 G4 前置走查(风格/明显缺陷/调试残留/注释规范,发现问题先处理再提交) ②分组建议:挂机模式 P1 总装一笔(UnattendedPanel/Settings/TopBar/TaskBoundaryCard/i18n/unattended 两 IPC/lib.rs/feishu_notify)+看板销账一笔(todo.md) ③swap-install.ps1 若有改动先审 diff 再定去留;删除 scripts/check_plaintext_key.py(一次性诊断脚本已完成使命) ④提交信息中文类型开头逐件列全,commit-msg hook 会拦截违规 ⑤完成后本条改 [x] 并附 git log --oneline -3 输出。验收(10-08 AI-Ops 补录,引擎 22:06 完成):git log --oneline -3 → 0aac14e 文档: 看板销账: 挂机模式 P1 总装与换装校验修复入库,901 销账 / 6bfcf01 修复: 换装校验同版本重装误判不通过 / 2441101 新增: 挂机模式 P1 总装: 设置面板+顶栏徽章+边界卡精简+监控告警推送;main=origin/main 已推,工作区清零。
902. [ ] 超时失败后的一键继续入口: 流式首字节超时(120s,服务收了不响应,不可自动重试)报「响应超时,请重试」后流程中断——轮次重入预算耗尽(3/3)即彻底停,用户只见裸文案。修:超时失败落停靠语义(复用 emit_task_parked),停靠卡带「继续」(ai_continue_loop parked 分流已修好)——错误后一键续跑而非中断。体验缺口实证 2026-10-07 晚 u-switch 故障窗口。 902. [ ] 超时失败后的一键继续入口: 流式首字节超时(120s,服务收了不响应,不可自动重试)报「响应超时,请重试」后流程中断——轮次重入预算耗尽(3/3)即彻底停,用户只见裸文案。修:超时失败落停靠语义(复用 emit_task_parked),停靠卡带「继续」(ai_continue_loop parked 分流已修好)——错误后一键续跑而非中断。体验缺口实证 2026-10-07 晚 u-switch 故障窗口。
903. [ ] u-switch 20:38-21:02 故障窗口根因深查: 11 连败+120s 首字节超时风暴(多会话多 provider 同时),21:0x 自愈。查 moyu-ops 机 u-switch 日志定因(上游抖动/服务重启/连接池耗尽),根治或加监控告警。**【21:5x AI-Ops 定因已实锤,journalctl 实测】上游连环降级雪崩非服务故障:api.deepseek.com 402 余额不足(冷却10m)+enterprise.moyu.info 401 认证失效(冷却10m)+sensenova kimi-k3 429/SSE空响应体(30s冷却循环)+bigmodel glm 429/SSE截断缺 message_stop——多通道同时打光致无首字节,冷却走完自愈。剩余工作=根治项:①switch-gw 的 deepseek 系 4 通道确认摘除(用户指认已摘但配置仍在役)②429 风暴时客户端退避协同(30s 冷却对重试风暴无效)③故障窗口告警。** 903. [ ] u-switch 20:38-21:02 故障窗口根因深查: 11 连败+120s 首字节超时风暴(多会话多 provider 同时),21:0x 自愈。查 moyu-ops 机 u-switch 日志定因(上游抖动/服务重启/连接池耗尽),根治或加监控告警。**【21:5x AI-Ops 定因已实锤,journalctl 实测】上游连环降级雪崩非服务故障:api.deepseek.com 402 余额不足(冷却10m)+enterprise.moyu.info 401 认证失效(冷却10m)+sensenova kimi-k3 429/SSE空响应体(30s冷却循环)+bigmodel glm 429/SSE截断缺 message_stop——多通道同时打光致无首字节,冷却走完自愈。剩余工作=根治项:①switch-gw 的 deepseek 系 4 通道确认摘除(用户指认已摘但配置仍在役)②429 风暴时客户端退避协同(30s 冷却对重试风暴无效)③故障窗口告警。**(10-08 已处置:moyu 令牌过期续期+主链切 ds-flash,见 900 区段外 905)
925. [ ] **前后端事件契约与 i18n key 全量核查(2026-10-10 只读 Wave D,源码取证)**:全量抽取后端 emit 与前端 listen/分派做对照;另把 src 下 1282 个静态 i18n key 与 zh-CN/en 键集合(各 1569)比对。
- ✅ **已修两处**:①`ai.aiTool.rejectedHint` 前缀写错(多一层 `ai.`)→ 2 处调用(`composables/ai/useAiApproval.ts:90` / `useAiToolEvents.ts:120`)改为正确键 `aiTool.rejectedHint`(此前 zh/en 双缺 → UI 渲染裸 key,908 类复发);②`components/layout/UpdateBadge.vue` 监听 Tauri 事件 `app-update-available`,而生产端 `composables/useUpdateCheck.ts` 走 **window CustomEvent** 同名不同通道 → 徽章永不触发;已补 window 监听 + 卸载清理(保留 Tauri 监听以兼容后端将来改走 emit)。
- [ ] **待处理·孤儿事件 2**:`AiPlanCreated`(`agentic/mod.rs:1006` emit)/`AiConflictResolved`(`commands/settings.rs:259` emit)——前端零消费;补消费或停发(现状为白 emit + 双写总线开销)。
- [ ] **待处理·死变体 3**:`AiMaxRoundsReached`(`commands/ai/mod.rs:239`)/`AiSubTaskStatusChanged`(`:421`)/`AiMergeCompleted`(`:429`)——前后端零构造零消费(前端仅在 types 联合与 NO_RESET_WATCHDOG 名单留残影);清理或补 emit+消费。
- [ ] **潜在(低)**:`dashboard.ideaStatus.archived` 键缺失(值域含 archived,当前 list 恒过滤 archived → 不可达);补键或注明。
- 备注:跨端 miniapp-only 5 变体(ConversationList/MessageHistory/SkillList/ModelList/EntityList)只走 publish_event→总线→tunnel→miniapp,桌面端不监听属设计,非孤儿。
927. [ ] **V50 迁移回填的不可逆风险(2026-10-10 只读风险自查,待处置)**:`crates/df-storage/src/migrations.rs:1511-1545` `backfill_checkpoint_token_total` 直接 UPDATE 覆盖 `conversation_checkpoints.token_total` 旧值,**无备份表、无 DOWN**、函数注释自认「无回退」;虽迁移文档论证旧值(消息条数)失真无保留价值,仍属不可逆数据覆盖且无快照兜底。
- [ ] **处置(成本极低)**:回填前把 `(id, old_total → new_total)` dump 到 temp 备份文件或日志,给「后悔一次」的机会;顺带 `migrations.rs:1516-1527` 一次性 collect 全部 checkpoints(含 snapshot 全文字符串)进内存 → 改分批/游标,防大表峰值内存膨胀。
- 已核安全项:`migrate_v50`(:1483)幂等(column/table_exists 守卫 + 仅 recomputed>0 且 !=old 才 UPDATE + schema_version 闸 + 重跑单测);df-mcp 新增仅写一个 KV,无新端点/帧/权限放大。
928. [ ] **aichat 前端性能热点与测试覆盖缺口(2026-10-10 只读 Wave F,源码取证)**
- **性能(按收益排序)** — 高:①`MessageList.vue` 3041 行 God 组件(职责>6 类);②`renderItems`(:1058) 每次依赖变化全量 O(n) 重建(工具事件级触发);③**全仓零 `v-memo`**(:1498/:1715) → 流式 rAF 每帧重建整个 renderItems vnode,长会话掉帧最直接来源。中:`JSON.stringify` 指纹 watch(:429) 每次工具状态变动全量 map+stringify;每帧逐条 `renderMdDecorated`/`traceTaskRefs` 调用(:1548/1556/1672/1519/1722);`TopBar` 三处 O(n) messages computed(:619/:676/:707) 可合并单次遍历;`useAiSend.ts` 595 行可再拆 `useAiQueue`;`useTurnTrace` switch 缺 default(:59,union 非穷尽)。低:`ConversationSidebar` 分组排序、`ChatInput` status `as string` 拓宽(:601/605/606)、`MessageItem` 模板同函数重复调用(:110-122)、md 缓存「满即全清」悬崖(:699 / useMarkdown:195) → 改 LRU。
- **已核良好(非热点)**:`useAiEvents/useAiToolEvents/useAiStreamingEvents/useAiLifecycleEvents` 分派器拆分清晰(均<400 行、switch 带 default);`useStreamRenderer` 块级 memo + 增量 lexer + rAF 节流已完善。**关键澄清**:流式 delta 不写 `messages.content`(仅收尾/新轮/停止/超时 flush),故热点是「工具事件级 + 流式帧级」而非每 token。
- [ ] **测试覆盖缺口(建议补最小用例)**:结果/过程分离渲染(renderItems 无任何前端断言,仓库仅有 playwright e2e 两个 spec)、`keyword_fallback_commit` 单锁窗口(context_lifecycle.rs 无测试)、`check-i18n` 门禁自身无反引号用例、`route_conv_event`/`route_scope` 事件改挂(event_bus 测试未覆盖)、`relocate_project_path` 路径校验(`..` 拦截)无用例 —— 对「泄漏风险 × 改动复杂度」高者优先补。
929. [ ] **模型配置「停用」无效(2026-10-10 用户报,已定位待修)**:用户在设置里停用模型后仍被使用。 ✅ **2026-10-10 主根因已修**:`prompt.rs get_active_provider` 原两条路径均不看 `enabled`(显式活跃 provider 直接返回 / 默认 provider 只按 `is_default` 挑)→ 已收敛为「启用集合」解析(`resolve_enabled_provider`,无启用 provider 时明确报错);✅ **兜底路径已修**:`compress.rs`/`title.rs` 新增 `resolve_fallback_model`(纯函数,title 复用同一实现——单一实现无双轨)+ 4 单测,覆盖「默认模型被停用→回落池内任一启用模型/池内无启用→报错」。⚠️ **同类次生缺口待收口(4 处,均已定位未改)**:`commands/ai/knowledge_inject.rs:231` 与 `:1116`(embedding/extract 兜底)、`commands/project.rs:800`(scan_model 兜底)、`agentic/router_setup.rs:15-16`(`resolve_model_with_override` 的 resolved 兜底;override 本身已校验)——四处兜底均不校验 `enabled`,建议随本项一并收口。**勘察结论**:路由层**是**过滤 enabled 的(`crates/df-ai/src/router.rs:98 .filter(|m| m.enabled)` + 单测 `enabled_false_excluded_from_routing`),故病灶在别处,三处嫌疑按置信排序:①**已选中的 model_override 被无条件采纳**——`agentic/mod.rs:740 let mut resolved_model = match model_override.as_deref()` 优先用前端选中的模型,若该模型随后被停用则**不校验 enabled**,继续照用;②**兜底 `default_model` 不校验 enabled**——`compress.rs:82-83`(及 `title.rs` 同款) 在池内无可选时 `unwrap_or_else(|| provider_config.default_model.clone())`,若 default_model 正是被停用的模型 → 照用;③**provider 级池停用(`p.enabled`,ProviderPanel `onPoolToggle`)不参与路由**——路由只过滤 model.enabled,池禁用可能仅影响前端展示。修法:选中/兜底/池三条路径统一过「enabled 校验」,不可用则回落到池路由(已过滤)或明确报错。验收:停用某模型后,选择器不列出、既有 override 落到其它启用模型、压缩/标题兜底不选它。
931. [ ] **消息发送队列会丢失(2026-10-10 用户报,已定位)**:会话遇网络超时等错误后队列消失;重启同样丢。**勘察结论**:`state.queue` 是**纯内存**(`src/stores/ai.ts:136`),无持久化;错误/收尾路径会清或丢弃(`useAiSend.ts:180/256` 重生成/编辑时 drop、`aiShared.ts:773` clearQueue)。修法:①队列按会话**持久化**(KV 或独立表),入队/出队/清空即写,启动与会话加载即恢复;②**错误/超时不得清队列**(只标记该条失败可重试),重生成/编辑类**显式用户动作**才允许清;③恢复后给出「N 条待发已恢复」提示与手动清理入口。验收:断网超时后队列仍在;杀进程重启后队列仍在且可续发。
933. [ ] **输入框支持有序列表渲染(`1. ` 类飞书,2026-10-10 用户报)**:ChatInput 内输入 `1. `(或 `- `)后应有**列表预览/自动编号**效果(对齐飞书消息输入框),而非纯文本。**需设计**:触发判据(行首 `\d+. ` / `- ` / `* `)、渲染形态(实时预览块 or 仅高亮)、回车续写自动补序号、退格退出列表、与既有 `/` 技能联想、`@` 提及、代码围栏的优先级冲突处理。设计落 docs 后再实施。
935. [ ] **定时消息(多条 + 定时列表,2026-10-10 用户报)**:支持把消息设定为定时发送,可设多条,并有统一「定时列表」管理(查看/改期/取消)。**需设计**:存储(表 or KV)、调度器(前端定时器 or 后端 tokio 任务,需在应用关闭后仍生效→倾向后端)、与队列/生成中状态的关系、失败重试与通知、UI 入口(CHATIN 旁「定时」按钮 + 列表抽屉)。设计落 docs 后再实施。
939. [ ] **上下文管理瓶颈清单(2026-10-10 五维只读走查,源码取证)** — 按收益排序,⚠️ `crates/df-ai/**` 被并行写者占用(工具结果外置 WIP)→ 带 ★ 的可先在 src-tauri 侧落,其余待解禁:
- **HIGH ①** `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", "name": "devflow",
"private": true, "private": true,
"version": "0.1.1", "version": "0.1.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:stop": "taskkill /f /im vite.exe", "dev:stop": "taskkill /f /im vite.exe",
"dev:restart": "npm run dev:stop && npm run dev", "dev:restart": "bun run dev:stop && bun run dev",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"check:i18n": "node scripts/check-i18n.cjs",
"preview": "vite preview", "preview": "vite preview",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:scenario": "playwright test tests/e2e/stream-body-visibility.spec.ts",
"tauri": "tauri", "tauri": "tauri",
"build:dev": "tauri build --debug --no-bundle" "build:dev": "tauri build --debug --no-bundle"
}, },
+135
View File
@@ -0,0 +1,135 @@
// 看板归档器:把 docs/todo.md 中「已完成」内容分离到 docs/07-项目管理/todo归档/<date>.md。
//
// 移动规则(纯行搬运,零措辞改动):
// ① 条目块为 [x] → 自动移出(其后续非条目行为同块延续,含缩进证据/引用块);
// ② **白名单整段移出**:命令行给出的标题(用引号原样传入) → 该段连同其子段(级别更深的后续段)
// 一并移出。历史/导航/已完成段走此路——**不靠启发式猜「已完成」**(本板存在大量无勾选的
// 行内待办,启发式会误移)。
// ③ 移出后残留的空标题(段内仅空行)自动删除,防悬空标题。
//
// 安全闸:未完成项([ ]/[~]/[→]/[!])零丢失断言失败即中止不写;默认 dry-run。
//
// 用法:
// node scripts/archive-todo.cjs # dry-run
// node scripts/archive-todo.cjs "## 🎯 统一规划(2026-06-26 全景)" # dry-run + 试移该段
// node scripts/archive-todo.cjs --apply "## 交接状态(2026-06-14)" # 实际写入
// node scripts/archive-todo.cjs --apply --date 2026-10-11 ...
const fs = require('fs');
const argv = process.argv.slice(2);
const APPLY = argv.includes('--apply');
const di = argv.indexOf('--date');
const DATE = di >= 0 ? argv[di + 1] : new Date().toISOString().slice(0, 10);
const li = argv.indexOf('--list');
// 白名单来源:① 命令行位置参数(注意 Windows Git Bash 下中文会被编码破坏,慎用)
// ② --list <file>:UTF-8 文件,每行一个精确标题(推荐,避免编码问题)
const WHITELIST =
li >= 0
? fs.readFileSync(argv[li + 1], 'utf8').split('\n').map((s) => s.trim()).filter(Boolean)
: argv.filter((a, i) => !a.startsWith('--') && i !== di + 1 && i !== li + 1);
const SRC = 'docs/todo.md';
const ARC = `docs/07-项目管理/todo归档/${DATE}.md`;
const UNDONE = /^\s*- \[[ ~→!]\]|^\d+\. \[[ ~→!]\]/;
const DONE = /^\s*- \[x\]|^\d+\. \[x\]/i;
const BLOCK = /^(\s*- \[|\d+\. \[)/;
const HEAD = /^#{1,3} /;
const level = (h) => (h.match(/^#+/) || [''])[0].length;
// 行尾归一:工作区可能是 CRLF,按 \r?\n 切(否则标题比对带 \r 恒不等)。写回用 LF,git 提交时归一。
const lines = fs.readFileSync(SRC, 'utf8').split(/\r?\n/);
// 打标:``` 围栏内一律 text(防 mermaid/示例里的 ## 误导分段)
const kinds = [];
let inFence = false;
for (const ln of lines) {
if (ln.trim().startsWith('```')) { kinds.push('text'); inFence = !inFence; continue; }
kinds.push(inFence ? 'text' : HEAD.test(ln) ? 'head' : BLOCK.test(ln) ? 'block' : 'text');
}
// 分段(首个 head 前为 preamble,永留主板)
const segs = [];
let cur = { head: null, headLevel: 0, items: [] };
for (let i = 0; i < lines.length; i++) {
if (kinds[i] === 'head') { segs.push(cur); cur = { head: lines[i], headLevel: level(lines[i]), items: [] }; }
else cur.items.push(lines[i]);
}
segs.push(cur);
const preamble = segs[0].head === null ? segs.shift() : null;
// 块归并(块 = 条目起始行 + 其后非条目行)
function blocksOf(items) {
const out = [];
let curB = null;
for (const ln of items) {
if (BLOCK.test(ln)) {
if (curB) out.push(curB);
curB = { status: DONE.test(ln) ? 'done' : UNDONE.test(ln) ? 'undone' : 'other', lines: [ln] };
} else if (curB) curB.lines.push(ln);
else out.push({ status: 'text', lines: [ln] });
}
if (curB) out.push(curB);
return out;
}
const moved = [];
const kept = [];
if (preamble) kept.push(...preamble.items);
let treeLevel = null; // 白名单段的级别:更深的后续段随其移出
let movedSegs = 0, movedBlocks = 0;
for (const seg of segs) {
const whitelisted = seg.head !== null && WHITELIST.includes(seg.head);
const inTree = treeLevel !== null && seg.head !== null && seg.headLevel > treeLevel;
if (!seg.head) { /* 不可能:非 preamble 段必有 head */ }
if (whitelisted || inTree) {
if (whitelisted) treeLevel = seg.headLevel;
movedSegs++;
if (seg.head) moved.push(seg.head);
for (const b of blocksOf(seg.items)) moved.push(...b.lines);
continue;
}
treeLevel = null;
if (seg.head) kept.push(seg.head);
for (const b of blocksOf(seg.items)) {
if (b.status === 'done') { movedBlocks++; moved.push(...b.lines); }
else kept.push(...b.lines);
}
}
// 删残留空标题:仅当「段内只剩空行」且「下一个标题级别 ≤ 当前」才删——
// 若下一个标题更深,说明它是**容器标题**(如 `## 待办` 下挂 `### 子节`),必须保留。
const cleaned = [];
for (let i = 0; i < kept.length; i++) {
if (HEAD.test(kept[i])) {
let j = i + 1;
while (j < kept.length && kept[j].trim() === '') j++;
const nextIsDeeper = j < kept.length && HEAD.test(kept[j]) && level(kept[j]) > level(kept[i]);
if (j >= kept.length || (HEAD.test(kept[j]) && !nextIsDeeper)) continue;
}
cleaned.push(kept[i]);
}
// 零丢失断言
const origUndone = lines.filter((l) => UNDONE.test(l));
const newUndone = new Set(cleaned.filter((l) => UNDONE.test(l)));
const lost = origUndone.filter((l) => !newUndone.has(l));
console.log(`[archive-todo] ${APPLY ? 'APPLY' : 'DRY-RUN'} date=${DATE} whitelist=${WHITELIST.length}`);
console.log(` 整段移出 ${movedSegs} 段,条目块移出 ${movedBlocks} 个,移出 ${moved.length} 行,主板 ${cleaned.length} 行(原 ${lines.length})`);
console.log(` 未完成项 原 ${origUndone.length} → 主板 ${newUndone.size},丢失 ${lost.length}`);
if (lost.length) {
console.error('✗ 零丢失断言失败,不写入:');
for (const l of lost.slice(0, 10)) console.error(' ' + l.slice(0, 100));
process.exit(1);
}
console.log(' 移出段标题:');
for (const l of moved.filter((x) => HEAD.test(x))) console.log(' - ' + l.slice(0, 90));
if (!APPLY) { console.log('(dry-run,加 --apply 写入)'); process.exit(0); }
if (moved.length === 0) { console.log('无内容可移,跳过写入'); process.exit(0); }
let arc = fs.existsSync(ARC) ? fs.readFileSync(ARC, 'utf8').trimEnd() : `# todo 归档(${DATE})`;
fs.writeFileSync(ARC, `${arc}\n\n---\n\n## ${DATE} 分拆(已完成内容再分离)\n\n${moved.join('\n')}\n`, 'utf8');
fs.writeFileSync(SRC, cleaned.join('\n'), 'utf8');
console.log(`[archive-todo] 已写入 ${SRC} 与 ${ARC}`);
+111
View File
@@ -0,0 +1,111 @@
// vue-i18n 消息预检:揪出只在 **prod runtime** 抛错的未转义特殊字符(字面 @ / | / 裸 {})。
//
// 背景:vue-i18n 9 把 message 里的字面 `@` 当 linked-message 语法、`|` 当 plural 分隔符,
// 未转义会编译报 "Invalid linked format"。该错误 dev 宽松回退、`vue-tsc` 捕获不到(message 是
// 字符串值非类型),只在打包装出的应用里炸——已两次踩坑(2026-06-15 白屏 / 2026-10-10 输入框消失),
// 且两次静态检查全绿。故前置到提交门禁 + 可手动跑:`node scripts/check-i18n.cjs`
//
// 修法:字面量用 vue-i18n 的插值转义,如 `{'@'}`(渲染仍显示 @)。
//
// 依赖 @intlify/message-compiler(vue-i18n 的编译器);解析不到时只告警不拦截(避免门禁误伤)。
//
// 扫描/编译逻辑抽为可导出函数(scanText / scanDirectory / compileMessage),供
// scripts/check-i18n.test.cjs 自测;CLI 入口行为保持不变。
const fs = require('fs');
const path = require('path');
let baseCompile = null;
try {
({ baseCompile } = require('@intlify/message-compiler'));
} catch {
baseCompile = null;
}
const DIRS = ['src/i18n/zh-CN', 'src/i18n/en'];
// key: 'value' / "value" / `value`(带转义),三种字面量都取——
// 反引号模板字面量同样可能含未转义 @/|/裸{},原先只匹配引号 → 门禁假阴性(2026-10-10 对抗审查发现)。
const KV_RE = /(?:^|[\s,{])([A-Za-z0-9_$]+)\s*:\s*([`'"])((?:\\.|(?!\2).)*)\2/gm;
// 编译单个消息值,返回错误数组(@intlify 风格:{ message })
function compileMessage(val) {
const errs = [];
try {
baseCompile(val, { onError: (e) => errs.push(e), onWarn: () => {} });
} catch (e) {
errs.push(e);
}
return errs;
}
// 扫描一段源码文本,返回 { checked, violations }
// - checked: 含 vue-i18n 特殊字符(@ { |)并已送编译的值个数
// - violations: 编译报错的条目 [{ key, value, message }]
function scanText(txt) {
const violations = [];
let checked = 0;
KV_RE.lastIndex = 0;
let m;
while ((m = KV_RE.exec(txt))) {
const key = m[1];
// 模板字面量里的 ${...} 插值对 vue-i18n 无意义,编译前替换为占位符避免假报错
const val = m[2] === '`' ? m[3].replace(/\$\{[^}]*\}/g, 'X') : m[3];
// 只编译含 vue-i18n 特殊字符的值(@ { |),其余不可能因语法报错
if (!/[@{|]/.test(val)) continue;
checked++;
const errs = compileMessage(val);
if (errs.length) violations.push({ key, value: val, message: errs[0].message });
}
return { checked, violations };
}
// 扫描目录下所有 .ts,返回 { checked, violations }(violations 带 file 字段)
function scanDirectory(dir) {
const violations = [];
let checked = 0;
let files;
try {
files = fs.readdirSync(dir);
} catch {
return { checked, violations };
}
for (const f of files) {
if (!f.endsWith('.ts')) continue;
const res = scanText(fs.readFileSync(path.join(dir, f), 'utf8'));
checked += res.checked;
for (const v of res.violations) violations.push({ file: f, ...v });
}
return { checked, violations };
}
// CLI 主流程:返回进程退出码(0 通过 / 1 失败)
function run() {
if (!baseCompile) {
console.warn('[i18n-check] 未找到 @intlify/message-compiler,跳过消息编译预检(需 npm 依赖)');
return 0;
}
let checked = 0;
let failed = 0;
for (const dir of DIRS) {
const res = scanDirectory(dir);
checked += res.checked;
for (const v of res.violations) {
failed++;
console.error(`✗ ${dir}/${v.file} :: ${v.key} => ${v.message}`);
console.error(` 值: ${JSON.stringify(v.value)}`);
console.error(` 提示: 字面 @ 须转义为 {'@'}(同理 | 与裸 {})`);
}
}
if (failed > 0) {
console.error(`[i18n-check] 失败 ${failed} 处(共编译 ${checked} 个含特殊字符的值)——prod 运行时会抛 Invalid linked format`);
return 1;
}
console.log(`[i18n-check] 通过(编译 ${checked} 个含特殊字符的值,0 失败)`);
return 0;
}
if (require.main === module) {
process.exit(run());
}
module.exports = { compileMessage, scanText, scanDirectory, run, KV_RE, DIRS };
+101
View File
@@ -0,0 +1,101 @@
// check-i18n.cjs 的门禁自测:用 node 内置测试跑,不引第三方依赖。
// node --test scripts/check-i18n.test.cjs
//
// 覆盖(对应 2026-10-10 对抗审查发现的假阴性 + 常规门禁行为):
// ① 单引号值含裸 @ → 报错
// ② 双引号值含裸 @ → 报错
// ③ 反引号模板字面量含裸 @ → 报错(原实现只匹配引号,漏报)
// ④ 含 ${} 插值不误报(模板字面量里的插值需先占位再编译)
// ⑤ 转义写法 {'@'} → 通过
// ⑥ 含 | 与裸 {} 的用例
// 另附 scanDirectory 对临时目录样本的端到端断言。
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { scanText, scanDirectory } = require('./check-i18n.cjs');
let compilerAvailable = true;
try {
require('@intlify/message-compiler');
} catch {
compilerAvailable = false;
}
const opts = compilerAvailable ? {} : { skip: '@intlify/message-compiler 不可用,跳过编译相关用例' };
// 构造一份 TS 源样本:每行一个 `key: '<literal>',`
const src = (...lines) => `export default {\n${lines.map((l) => ` ${l},`).join('\n')}\n}\n`;
test('①单引号值含裸 @ → 报错', opts, () => {
const r = scanText(src("a: 'hello @ world'"));
assert.equal(r.checked, 1);
assert.equal(r.violations.length, 1);
assert.equal(r.violations[0].key, 'a');
});
test('②双引号值含裸 @ → 报错', opts, () => {
const r = scanText(src('b: "hello @ world"'));
assert.equal(r.checked, 1);
assert.equal(r.violations.length, 1);
assert.equal(r.violations[0].key, 'b');
});
test('③反引号模板字面量含裸 @ → 报错(假阴性回归)', opts, () => {
const r = scanText(src('c: `hello @ world`'));
assert.equal(r.checked, 1);
assert.equal(r.violations.length, 1);
assert.equal(r.violations[0].key, 'c');
});
test('④-1 模板字面量含 ${} 插值(无特殊字符)不误报', opts, () => {
const r = scanText(src('d: `hello ${name}`'));
// 插值被占位后不含 @ { |,不计入 checked,也无违规
assert.equal(r.violations.length, 0);
});
test('④-2 插值位于花括号内不误报(占位替代的关键场景)', opts, () => {
// 原值 `total {${count}}` 直接编译会报 Invalid token in placeholder: '$',
// 必须先替换 ${...} 为占位符成为 `total {X}`(合法具名插值)才不误报。
const r = scanText(src('e: `total {${count}}`'));
assert.equal(r.violations.length, 0);
assert.equal(r.checked, 1);
});
test('⑤转义写法 {\'@\'} → 通过', opts, () => {
const r = scanText(src("f: \"{'@'}\""));
assert.equal(r.checked, 1);
assert.equal(r.violations.length, 0);
});
test('⑥-1 值含 | 会进入编译且正常通过', opts, () => {
const r = scanText(src("g: 'one | two'"));
assert.equal(r.checked, 1);
assert.equal(r.violations.length, 0);
});
test('⑥-2 值以 | 结尾(plural 缺省) → 报错', opts, () => {
const r = scanText(src("h: 'one |'"));
assert.equal(r.checked, 1);
assert.equal(r.violations.length, 1);
});
test('⑥-3 裸 { 未闭合 → 报错', opts, () => {
const r = scanText(src("i: 'x { y'"));
assert.equal(r.checked, 1);
assert.equal(r.violations.length, 1);
});
test('scanDirectory 扫临时目录样本:定位到违规文件', opts, () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'i18n-check-'));
try {
fs.writeFileSync(path.join(dir, 'ok.ts'), src("ok: 'plain text'"));
fs.writeFileSync(path.join(dir, 'bad.ts'), src('bad: `oops @ here`'));
const r = scanDirectory(dir);
assert.equal(r.violations.length, 1);
assert.equal(r.violations[0].file, 'bad.ts');
assert.equal(r.violations[0].key, 'bad');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
+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 "== 通道2: 全量发版换装(tauri build -> 静默安装 -> 启动验证) =="
Write-Host "[1/4] tauri build(可能 10 分钟级)..." Write-Host "[1/4] tauri build(可能 10 分钟级)..."
cmd /c "cargo tauri build 2>&1" # 本地换装不需要 updater 签名产物:未设私钥时用 --config 关掉 createUpdaterArtifacts,
# 否则 tauri build 必因「A public key has been found, but no private key」报错中断(实测多次),
# 安装段被跳过,只能手工补 swap-install。发版(需签名)走 publish-release.ps1。
$tauriArgs = "cargo tauri build"
if (-not $env:TAURI_SIGNING_PRIVATE_KEY) {
Write-Host " [本地换装模式] 未设 TAURI_SIGNING_PRIVATE_KEY -> 关闭 updater 产物(发版走 publish-release.ps1)"
$override = "$repo\temp\local-no-updater.json"
@{ bundle = @{ createUpdaterArtifacts = $false } } | ConvertTo-Json | Set-Content $override -Encoding utf8
$tauriArgs = "cargo tauri build --config `"$override`""
}
cmd /c "$tauriArgs 2>&1"
if ($LASTEXITCODE -ne 0) { Write-Host "[失败] tauri build"; exit 1 } if ($LASTEXITCODE -ne 0) { Write-Host "[失败] tauri build"; exit 1 }
$bundle = Get-ChildItem "$repo\target\release\bundle\nsis\*.exe" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 $bundle = Get-ChildItem "$repo\target\release\bundle\nsis\*.exe" | Sort-Object LastWriteTime -Descending | Select-Object -First 1
+131
View File
@@ -0,0 +1,131 @@
# 用途: DevFlow 应用内升级发布通道(B4/L3)
# 构建 updater 产物(.nsis.zip + .sig) -> 生成 latest.json -> 上传 app.1216.top
# 设计: docs/02-架构设计/专项设计/版本更新与自动升级-设计-2026-10-09.md §四 L3
#
# 用法:
# .\scripts\publish-release.ps1 # 用 git 最近提交作 notes
# .\scripts\publish-release.ps1 -Notes "修复xx" # 手动指定更新说明
# .\scripts\publish-release.ps1 -SkipBuild # 复用已有产物(仅生成清单+上传)
#
# 前置:
# 1. tauri.conf.json 与 Cargo.toml 版本号一致(脚本第 1 步校验,防 D1 复发)
# 2. 签名私钥就位: $env:TAURI_SIGNING_PRIVATE_KEY = "$env:USERPROFILE\.tauri\devflow.key"
# (无密码密钥无需 TAURI_SIGNING_PRIVATE_KEY_PASSWORD)
# 3. ssh-proxy 已配 suke-dev(39.99.243.191),web 根 /var/www/app-site/
param(
[string]$Notes = "",
[switch]$SkipBuild
)
$ErrorActionPreference = "Stop"
$repo = "E:\wk-lab\devflow"
$site = "suke-dev"
$remoteDir = "/var/www/app-site/download/devflow"
$baseUrl = "https://app.1216.top/download/devflow"
Set-Location $repo
# ── 1. 版本双源校验(防 D1 复发)──
Write-Host "[1/6] 校验版本双源一致..."
$confVer = (Get-Content "$repo\src-tauri\tauri.conf.json" -Raw | ConvertFrom-Json).version
$cargoVer = (Select-String -Path "$repo\src-tauri\Cargo.toml" -Pattern '^version\s*=\s*"([^"]+)"').Matches[0].Groups[1].Value
if ($confVer -ne $cargoVer) {
Write-Host "[失败] 版本不一致: tauri.conf.json=$confVer, Cargo.toml=$cargoVer"
Write-Host " 请先对齐两处版本号再发布(设计 D1:双源必须同值)"
exit 1
}
$version = $confVer
Write-Host " 版本 v$version 双源一致,通过"
# ── 2. 签名私钥就位检查 ──
if (-not $env:TAURI_SIGNING_PRIVATE_KEY) {
$keyPath = "$env:USERPROFILE\.tauri\devflow.key"
if (Test-Path $keyPath) {
$env:TAURI_SIGNING_PRIVATE_KEY = $keyPath
Write-Host "[2/6] 签名私钥: $keyPath"
} else {
Write-Host "[失败] 未找到签名私钥($keyPath),且未设 TAURI_SIGNING_PRIVATE_KEY"
Write-Host " 先执行: cargo tauri signer generate -w `$env:USERPROFILE\.tauri\devflow.key"
exit 1
}
} else {
Write-Host "[2/6] 签名私钥: 取自环境变量"
}
# ── 3. 构建 updater 产物 ──
$nsisDir = "$repo\target\release\bundle\nsis"
if (-not $SkipBuild) {
Write-Host "[3/6] tauri build(含 createUpdaterArtifacts,可能 10 分钟级)..."
cmd /c "cargo tauri build 2>&1"
if ($LASTEXITCODE -ne 0) { Write-Host "[失败] tauri build"; exit 1 }
} else {
Write-Host "[3/6] 跳过构建(-SkipBuild),复用已有产物"
}
# 定位产物:Tauri v2 = setup.exe + .exe.sig(更新包即安装器本身);v1 遗留 = .nsis.zip + .nsis.zip.sig
$setupExe = Get-ChildItem "$nsisDir\devflow_${version}_x64-setup.exe" -ErrorAction SilentlyContinue | Select-Object -First 1
$sigFile = Get-ChildItem "$nsisDir\devflow_${version}_x64-setup.exe.sig","$nsisDir\devflow_${version}_x64-setup.nsis.zip.sig" -ErrorAction SilentlyContinue | Select-Object -First 1
$zipFile = Get-ChildItem "$nsisDir\devflow_${version}_x64-setup.nsis.zip" -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $sigFile) { Write-Host "[失败] 未找到 .sig 签名文件(确认 createUpdaterArtifacts=true 且 TAURI_SIGNING_PRIVATE_KEY 就位)"; exit 1 }
# 与 .sig 配对的更新产物:v2 签名(.exe.sig)配 setup.exe;v1 签名(.nsis.zip.sig)配 .nsis.zip
$artifact = if ($sigFile.Name.EndsWith('.exe.sig')) { $setupExe } else { $zipFile }
if (-not $artifact) { Write-Host "[失败] 未找到与 $($sigFile.Name) 配对的更新产物"; exit 1 }
Write-Host " 产物: $($artifact.Name) ($([math]::Round($artifact.Length/1MB,1))MB)"
# ── 4. 生成 latest.json ──
Write-Host "[4/6] 生成 latest.json..."
$sig = (Get-Content $sigFile.FullName -Raw).Trim()
if (-not $Notes) {
# 用 git 最近一条提交作 notes(无 CHANGELOG 时的兜底)
$Notes = (git log -1 --pretty=format:"%s" 2>&1) -join "`n"
if (-not $Notes) { $Notes = "DevFlow v$version" }
}
$pubDate = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
# SHA256:本机 PowerShell 5.1 实测缺 Get-FileHash(同 swap-install 兜底口径),异常走 certutil
function Get-Sha256Lower($path) {
try { return (Get-FileHash -Path $path -Algorithm SHA256 -ErrorAction Stop).Hash.ToLower() }
catch { return (((certutil -hashfile $path SHA256) | Select-Object -Index 1) -replace '\s', '').ToLower() }
}
$sha256 = Get-Sha256Lower $artifact.FullName
$latestJson = [ordered]@{
version = $version
notes = $Notes
pub_date = $pubDate
platforms = [ordered]@{
"windows-x86_64" = [ordered]@{
signature = $sig
url = "$baseUrl/$($artifact.Name)"
}
}
} | ConvertTo-Json -Depth 5
$latestPath = "$repo\target\release\bundle\nsis\latest.json"
# ⚠️ 必须写「UTF-8 无 BOM」:Windows PowerShell 5.1 的 `Out-File -Encoding utf8` 会写 BOM
# (EF BB BF),部分 JSON 解析路径会报 "expected value at line 1 column 1",
# 表现为 updater 拿到清单即失败(签名/包都没错,只多了头 3 字节)。2026-10-10 实证修正。
[System.IO.File]::WriteAllText($latestPath, $latestJson, (New-Object System.Text.UTF8Encoding($false)))
Write-Host " latest.json: version=$version, sha256=$($sha256.Substring(0,16))..."
# ── 5. 上传到 app.1216.top ──
Write-Host "[5/6] 上传到 $site`:$remoteDir ..."
$env:MSYS_NO_PATHCONV = "1"
# 确保远程目录存在
ssh-proxy exec -n $site -c "mkdir -p $remoteDir"
if ($LASTEXITCODE -ne 0) { Write-Host "[失败] 创建远程目录"; exit 1 }
$uploads = @($latestPath, $sigFile.FullName, $artifact.FullName)
if ($setupExe -and $setupExe.FullName -ne $artifact.FullName) { $uploads += $setupExe.FullName }
foreach ($f in $uploads) {
$name = Split-Path $f -Leaf
Write-Host " 上传 $name ..."
ssh-proxy upload --name $site --src $f --dst "$remoteDir/$name"
if ($LASTEXITCODE -ne 0) { Write-Host "[失败] 上传 $name"; exit 1 }
}
# ── 6. 验证 ──
Write-Host "[6/6] 验证远端清单..."
$remoteCheck = ssh-proxy exec -n $site -c "ls -la $remoteDir && echo '---' && cat $remoteDir/latest.json | head -5"
Write-Host $remoteCheck
Write-Host ""
Write-Host "[发布完成] v$version"
Write-Host " 清单地址: $baseUrl/latest.json"
Write-Host " 应用内检查更新即可发现新版本(app.1216.top 已配 no-cache,无需刷 CDN)"
+28 -7
View File
@@ -1,15 +1,36 @@
# DevFlow 自宿主换装脚本 v3 # DevFlow 自宿主换装脚本 v3
# 流程: 等旧进程退出并探测句柄释放, 静默安装, 按构建产物哈希校验且失败重试一次, 交互用户可见重启, 结束自清理 # 流程: 等旧进程退出并探测句柄释放, 静默安装, 按构建产物哈希校验且失败重试一次, 交互用户可见重启, 结束自清理
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$installer = 'E:\wk-lab\devflow\target\release\bundle\nsis\devflow_0.1.0_x64-setup.exe' # 安装包路径:按构建产物 glob 探测(不硬编码版本号,版本升级时脚本零改动)。
# C3 决议:脚本消费构建产物,版本号唯一源头是 Cargo.toml → tauri.conf.json 注入文件名。
$nsisDir = 'E:\wk-lab\devflow\target\release\bundle\nsis'
$installer = $null
if (Test-Path $nsisDir) {
$installer = Get-ChildItem -Path $nsisDir -Filter 'devflow_*_x64-setup.exe' -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName
}
if (-not $installer) {
# 兜底:目录缺失或无匹配产物时,保留旧硬编码路径以维持既有失败提示语义
$installer = 'E:\wk-lab\devflow\target\release\bundle\nsis\devflow_0.1.1_x64-setup.exe'
}
$target = 'C:\Users\23780\AppData\Local\devflow\devflow.exe' $target = 'C:\Users\23780\AppData\Local\devflow\devflow.exe'
$builtExe = 'E:\wk-lab\devflow\target\release\devflow.exe' $builtExe = 'E:\wk-lab\devflow\target\release\devflow.exe'
$log = 'E:\wk-lab\devflow\swap-install.log' $log = 'E:\wk-lab\devflow\temp\swap-install.log'
$lockFile = 'E:\wk-lab\devflow\swap-install.lock' $lockFile = 'E:\wk-lab\devflow\temp\swap-install.lock'
# SHA256 取值:Get-FileHash 在部分环境的本机 Windows PowerShell 5.1 上不可用(实测 2026-10-10 缺失),
# 兜底走系统自带的 certutil。返回大写十六进制串。
function Get-Sha256($path) {
try { return (Get-FileHash -Path $path -Algorithm SHA256 -ErrorAction Stop).Hash }
catch { return (((certutil -hashfile $path SHA256) | Select-Object -Index 1) -replace '\s', '').ToUpper() }
}
# 构建产物哈希是唯一强基准; 产物缺失时退化为与安装前快照比对的弱校验 # 构建产物哈希是唯一强基准; 产物缺失时退化为与安装前快照比对的弱校验
# 实测(2026-10-10):装机 exe 与本基准哈希不一致(与 target/release/devflow.exe 同尺寸但字节不同,
# 疑 NSIS 打包前打 bundle-type 补丁所致)→ 强基准恒不命中,实际退化为「两次安装字节一致 + 与
# 安装前不同」的校验。该退化仍能拦住「installer exit=0 但 exe 未更新」(before==after)。
$hashWant = $null $hashWant = $null
if (Test-Path $builtExe) { $hashWant = (Get-FileHash $builtExe -Algorithm SHA256).Hash } if (Test-Path $builtExe) { $hashWant = Get-Sha256 $builtExe }
# 0. SYSTEM 上下文转投用户会话 # 0. SYSTEM 上下文转投用户会话
$who = whoami $who = whoami
@@ -69,7 +90,7 @@ function Invoke-SwapInstall {
"[$(Get-Date -Format 'HH:mm:ss')] FAIL: target missing after install" | Add-Content $log "[$(Get-Date -Format 'HH:mm:ss')] FAIL: target missing after install" | Add-Content $log
return $null return $null
} }
$afterHash = (Get-FileHash $target -Algorithm SHA256).Hash $afterHash = Get-Sha256 $target
$afterSize = (Get-Item $target).Length $afterSize = (Get-Item $target).Length
"[$(Get-Date -Format 'HH:mm:ss')] installed size=$afterSize hash=$($afterHash.Substring(0, 12))" | Add-Content $log "[$(Get-Date -Format 'HH:mm:ss')] installed size=$afterSize hash=$($afterHash.Substring(0, 12))" | Add-Content $log
return $afterHash return $afterHash
@@ -99,7 +120,7 @@ try {
# 3. 安装前快照, 供弱校验与前后比对 # 3. 安装前快照, 供弱校验与前后比对
$before = @{ hash = 'absent'; size = 0 } $before = @{ hash = 'absent'; size = 0 }
if (Test-Path $target) { if (Test-Path $target) {
$before.hash = (Get-FileHash $target -Algorithm SHA256).Hash $before.hash = Get-Sha256 $target
$before.size = (Get-Item $target).Length $before.size = (Get-Item $target).Length
} }
$beforeHashShort = $before.hash $beforeHashShort = $before.hash
@@ -168,7 +189,7 @@ try {
} }
# 仍无进程, 落标记等人工拉起 # 仍无进程, 落标记等人工拉起
Set-Content -Path 'E:\wk-lab\devflow\swap-need-start.flag' -Value (Get-Date -Format o) Set-Content -Path 'E:\wk-lab\devflow\temp\swap-need-start.flag' -Value (Get-Date -Format o)
"[$(Get-Date -Format 'HH:mm:ss')] FAIL: need manual start" | Add-Content $log "[$(Get-Date -Format 'HH:mm:ss')] FAIL: need manual start" | Add-Content $log
exit 1 exit 1
} }
+9 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "devflow" name = "devflow"
version = "0.1.0" version = "0.1.2"
description = "DevFlow Desktop App" description = "DevFlow Desktop App"
authors = [""] authors = [""]
edition = "2021" edition = "2021"
@@ -23,6 +23,8 @@ tauri-plugin-window-state = "2"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
# Phase3 单实例:二次启动唤起已有进程主窗口,防多实例抢 18765 端口 # Phase3 单实例:二次启动唤起已有进程主窗口,防多实例抢 18765 端口
tauri-plugin-single-instance = "2" tauri-plugin-single-instance = "2"
# B2 应用内升级:L1 接入 tauri-plugin-updater(内建 ed25519 签名校验)
tauri-plugin-updater = "2"
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
tokio.workspace = true tokio.workspace = true
@@ -80,6 +82,12 @@ futures-util = "0.3"
# 使用 rustls-tls(非 native-tls),避免 Windows SChannel 同步阻塞 tokio 工作线程 # 使用 rustls-tls(非 native-tls),避免 Windows SChannel 同步阻塞 tokio 工作线程
# 致 BUG-2026-07-17(aichat 流式调用永久 hang,外层 tokio timeout 亦无法推进计时器)。 # 致 BUG-2026-07-17(aichat 流式调用永久 hang,外层 tokio timeout 亦无法推进计时器)。
reqwest = { version = "0.12", default-features = false, features = ["json", "gzip", "brotli", "rustls-tls", "stream"] } reqwest = { version = "0.12", default-features = false, features = ["json", "gzip", "brotli", "rustls-tls", "stream"] }
# media_store(#958 引用化):hash 去重文件名(sha2+hex)+ 阿里云 OSS PUT 上传签名(hmac+sha1)
sha2 = "0.10"
hex = "0.4"
hmac = "0.12"
sha1 = "0.10"
httpdate = "1"
# fetch_url AI 工具:URL → markdown 文档嗅探(GET HTML → htmd 转 markdown → 去噪音 + 截断)。 # fetch_url AI 工具:URL → markdown 文档嗅探(GET HTML → htmd 转 markdown → 去噪音 + 截断)。
# 替代 http_request 拿原始 HTML(噪声大、爆 token)。turndown.js 移植,只读 GET,与 http_request 共享 SSRF 防护。 # 替代 http_request 拿原始 HTML(噪声大、爆 token)。turndown.js 移植,只读 GET,与 http_request 共享 SSRF 防护。
htmd = "0.5" htmd = "0.5"
+2 -1
View File
@@ -24,6 +24,7 @@
"core:webview:allow-create-webview-window", "core:webview:allow-create-webview-window",
"dialog:default", "dialog:default",
"window-state:default", "window-state:default",
"notification:default" "notification:default",
"updater:default"
] ]
} }
@@ -98,6 +98,7 @@ pub async fn cleanup_expired_approvals(app: &AppHandle, state: &AppState, conv_i
&tc_id, &tc_id,
"rejected", "rejected",
Some(format!("审批超时({}分钟)自动取消", timeout_minutes)), Some(format!("审批超时({}分钟)自动取消", timeout_minutes)),
None,
) )
.await; .await;
} }
@@ -6,7 +6,7 @@
//! //!
//! 流程(对齐 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式): //! 流程(对齐 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式):
//! ① 读 active 克隆(不改 status / 不扣 token)→ 后台喂 LLM 出摘要; //! ① 读 active 克隆(不改 status / 不扣 token)→ 后台喂 LLM 出摘要;
//! ② LLM 成功 → compress_old_messages(标 compressed + 扣 token)+ insert_at(摘要 system); //! ② LLM 成功 → compress_old_messages(标 compressed + 扣 token)+ push(摘要 system 尾部追加);
//! ③ LLM 失败 → 消息状态完全不变(未改 status / 未扣 token),降级走原 build_for_request 裁剪。 //! ③ LLM 失败 → 消息状态完全不变(未改 status / 未扣 token),降级走原 build_for_request 裁剪。
//! //!
//! 口径决策(任务让"你判断"):选**延迟 mutate(成功才改)**而非"失败回滚 status"。 //! 口径决策(任务让"你判断"):选**延迟 mutate(成功才改)**而非"失败回滚 status"。
@@ -35,6 +35,7 @@ use std::sync::Arc;
use tauri::AppHandle; use tauri::AppHandle;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use df_ai::context::ContextManager;
use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary}; use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary};
use df_ai::provider::ChatMessage; use df_ai::provider::ChatMessage;
use df_storage::models::AiProviderRecord; use df_storage::models::AiProviderRecord;
@@ -54,11 +55,37 @@ const PROTECT_COUNT: usize = 6;
/// 改进3 B 常量开关:压缩失败时是否启用关键词摘要兜底(默认 true)。 /// 改进3 B 常量开关:压缩失败时是否启用关键词摘要兜底(默认 true)。
/// ///
/// true(默认):LLM 压缩失败 → compress_old_messages 标 compressed(释放 token) /// true(默认):LLM 压缩失败 → compress_old_messages 标 compressed(释放 token)
/// + insert_at(0, system 关键词摘要),保留用户反复提及的主题词作续接锚点。 /// + push(system 关键词摘要)尾部追加,保留用户反复提及的主题词作续接锚点。
/// false(回退):回退原裸裁剪行为(不插关键词摘要,仅 build_for_request 裁剪保最近 6 条)。 /// false(回退):回退原裸裁剪行为(不插关键词摘要,仅 build_for_request 裁剪保最近 6 条)。
/// 排障/对比用:置 false 即可观察无兜底时的裁剪效果。 /// 排障/对比用:置 false 即可观察无兜底时的裁剪效果。
const KEYWORD_FALLBACK_ENABLED: bool = true; const KEYWORD_FALLBACK_ENABLED: bool = true;
/// 会话消息列表的内容版本哨兵(939⑪):首条消息的全局唯一 id(空列表 = None)。
///
/// 压缩**触发时**快照本值,后台 task **提交前**重新读取比对:相等才允许提交。
/// 首条消息 id 由 `new_message_id()` 全局唯一——loop 只 append(不改首条),而 `clear()`
/// 重建后首条必换新 id,故它是「上下文是否被整体替换」的准确且零成本哨兵。
/// 不用 len:loop 后台压缩期间正常 append 会使 len 增长,以 len 相等为守卫会误判版本
/// 不符致后台压缩永不生效。
fn head_id_of(messages: &mut ContextManager) -> Option<String> {
messages.messages_mut().first().and_then(|t| t.message.id.clone())
}
/// 内容版本是否仍匹配(939⑪):快照与当前首条 id 相等才允许提交。
fn version_matches(snapshot: &Option<String>, current: &Option<String>) -> bool {
snapshot == current
}
/// panic 兜底复位(939⑫):清 is_compressing 防其永真致压缩永久停摆。
///
/// conv 已删(per_conv 无条目)跳过——标志随 conv 销毁无意义(对齐各正常出口语义)。
async fn reset_compressing_after_panic(session_arc: &Arc<Mutex<AiSession>>, conv_id: &str) {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
session.conv(conv_id).messages.set_compressing(false);
}
}
/// 自动压缩(智能裁剪)——在 build_for_request 之前预处理。 /// 自动压缩(智能裁剪)——在 build_for_request 之前预处理。
/// ///
/// **L0-3(2026-08-09)后台化**:压缩 LLM 调用不再同步阻塞本轮 loop(非流式 LLM 最长 60s)。 /// **L0-3(2026-08-09)后台化**:压缩 LLM 调用不再同步阻塞本轮 loop(非流式 LLM 最长 60s)。
@@ -114,7 +141,7 @@ pub(super) async fn maybe_auto_compress(
emit_ai_chat_event(app_handle, AiChatEvent::AiCompressing { emit_ai_chat_event(app_handle, AiChatEvent::AiCompressing {
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
}); });
let (mut active_msgs, lang) = { let (mut active_msgs, lang, head_id) = {
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
let conv = session.conv(&conv_id); let conv = session.conv(&conv_id);
conv.messages.set_compressing(true); conv.messages.set_compressing(true);
@@ -123,6 +150,9 @@ pub(super) async fn maybe_auto_compress(
// 切片前按当时实际长度钳制,防 index out of bounds panic // 切片前按当时实际长度钳制,防 index out of bounds panic
// (后续 compress_old_messages 内部自带 min(len) 钳制,口径一致)。 // (后续 compress_old_messages 内部自带 min(len) 钳制,口径一致)。
let msgs = conv.messages.messages_mut(); let msgs = conv.messages.messages_mut();
// 939⑪ 内容版本哨兵:快照触发时首条消息 id,后台 task 提交前比对
// (clear() 重建后闭包内提交会落到新上下文,靠此守卫丢弃)。
let head_id = msgs.first().and_then(|t| t.message.id.clone());
let cut = protect_start.min(msgs.len()); let cut = protect_start.min(msgs.len());
let active_msgs: Vec<ChatMessage> = msgs[..cut] let active_msgs: Vec<ChatMessage> = msgs[..cut]
.iter() .iter()
@@ -131,7 +161,7 @@ pub(super) async fn maybe_auto_compress(
.collect(); .collect();
let lang = conv.agent_language.clone() let lang = conv.agent_language.clone()
.unwrap_or_else(|| "zh-CN".to_string()); .unwrap_or_else(|| "zh-CN".to_string());
(active_msgs, lang) (active_msgs, lang, head_id)
}; };
// T2-修复(方案A):展开 namespace 引用为真实内容,再喂压缩 LLM/关键词摘要。 // T2-修复(方案A):展开 namespace 引用为真实内容,再喂压缩 LLM/关键词摘要。
@@ -203,7 +233,15 @@ pub(super) async fn maybe_auto_compress(
let conv_id = conv_id.to_string(); let conv_id = conv_id.to_string();
let provider_config = provider_config.clone(); let provider_config = provider_config.clone();
let llm_concurrency = llm_concurrency.clone(); let llm_concurrency = llm_concurrency.clone();
// 939⑫ panic 兜底句柄:内层 async move 会消费 session_arc/conv_id,panic 分支
// 需另持一份用于复位 is_compressing(防其永真致压缩永久停摆)。
let panic_session = session_arc.clone();
let panic_conv_id = conv_id.clone();
tokio::spawn(async move { tokio::spawn(async move {
// 939⑫ 整个后台 body 包 catch_unwind(复用 mod.rs::unwind_to_result 薄包装):
// panic 以 Err 返回 → 复位 is_compressing + warn,防压缩永久停摆。依赖
// panic=unwind(tokio 默认);panic=abort 构建下退化为进程终止(尽力兜底)。
let outcome = super::unwind_to_result(async move {
// 后台重建 provider(secret::build_provider_for 三步打包:resolve→ensure→build)。 // 后台重建 provider(secret::build_provider_for 三步打包:resolve→ensure→build)。
// 失败 → 同 LLM 失败降级:复位 is_compressing + 关键词摘要兜底(标 compressed 释放 // 失败 → 同 LLM 失败降级:复位 is_compressing + 关键词摘要兜底(标 compressed 释放
// token + 插关键词),保持「失败→兜底→扣 token」语义——否则 history_tokens 未降, // token + 插关键词),保持「失败→兜底→扣 token」语义——否则 history_tokens 未降,
@@ -218,13 +256,8 @@ pub(super) async fn maybe_auto_compress(
"[ai] 后台压缩 provider 重建失败,降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})", "[ai] 后台压缩 provider 重建失败,降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
KEYWORD_FALLBACK_ENABLED, KEYWORD_FALLBACK_ENABLED,
); );
// 单次锁内 check+mutate:复位 is_compressing(防 conv 两次锁间被删惰性重建)。 // 复位 is_compressing 收口进 keyword_fallback_commit 单锁(909①-补:
{ // 原先此处单独取锁复位,与提交之间留并发二次压缩窗口)。
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(&conv_id) {
session.conv(&conv_id).messages.set_compressing(false);
}
}
keyword_fallback_commit( keyword_fallback_commit(
&session_arc, &session_arc,
&conv_id, &conv_id,
@@ -232,6 +265,7 @@ pub(super) async fn maybe_auto_compress(
&keyword_fallback, &keyword_fallback,
pre_compress_tokens, pre_compress_tokens,
"压缩 provider 重建失败", "压缩 provider 重建失败",
&head_id,
) )
.await; .await;
return; return;
@@ -252,7 +286,7 @@ pub(super) async fn maybe_auto_compress(
match compress_outcome { match compress_outcome {
Ok(Some(summary)) => { Ok(Some(summary)) => {
// LLM 成功 → 标 compressed(扣 token)+ 摘要 system 插首位 + set_compressing(false)。 // LLM 成功 → 标 compressed(扣 token)+ 摘要 system 尾部追加 + set_compressing(false)。
// conv 已删(per_conv 无条目)时丢弃压缩结果——惰性重建会污染新 conv, // conv 已删(per_conv 无条目)时丢弃压缩结果——惰性重建会污染新 conv,
// is_compressing 标志随 conv 销毁无意义,直接 return。 // is_compressing 标志随 conv 销毁无意义,直接 return。
{ {
@@ -261,9 +295,22 @@ pub(super) async fn maybe_auto_compress(
return; return;
} }
let conv = session.conv(&conv_id); let conv = session.conv(&conv_id);
// 939⑪ 内容版本守卫:提交前比对触发时快照,不符(期间 clear() 重建致
// 上下文整体替换)→ 丢弃压缩结果。不复位 is_compressing(clear 已复位;
// 若新会话已启新压缩,此处复位会误伤新压缩信令)。
if !version_matches(&head_id, &head_id_of(&mut conv.messages)) {
tracing::warn!(
conv_id = %conv_id,
"[ai] 压缩完成但上下文版本已变(疑似 clear 重建),丢弃旧摘要"
);
return;
}
let _compressed = conv.messages.compress_old_messages(protect_start); let _compressed = conv.messages.compress_old_messages(protect_start);
// NL 摘要插首位 + JSON 卡片回填 WorkingContext,决策随压缩重锚定 // NL 摘要尾部追加(而非 insert_at(0) 头部插入):头部插入会位移整条
conv.messages.insert_at(0, ChatMessage::system(&summary.nl_summary)); // 前缀字节,使 DeepSeek 等自动前缀缓存的 provider 全部缓存失效
// (命中率掉到个位数)。尾部追加紧邻本轮内容 LLM 仍可见,前缀稳定
// 命中率最大化。JSON 卡片回填 WorkingContext,决策随压缩重锚定。
conv.messages.push_system_anchor(ChatMessage::system(&summary.nl_summary));
conv.messages.set_compressing(false); conv.messages.set_compressing(false);
conv.working_context.sync_after_compress(&summary.json_card); conv.working_context.sync_after_compress(&summary.json_card);
} }
@@ -271,7 +318,7 @@ pub(super) async fn maybe_auto_compress(
conv_id = %conv_id, conv_id = %conv_id,
iteration, iteration,
pre_tokens = pre_compress_tokens, pre_tokens = pre_compress_tokens,
"[ai] 自动压缩成功(后台),摘要已插首位" "[ai] 自动压缩成功(后台),摘要已尾部追加"
); );
// 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体), // 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体),
// 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。 // 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。
@@ -286,29 +333,28 @@ pub(super) async fn maybe_auto_compress(
// 单次锁内 check+mutate(防 conv 在两次锁间被删致惰性重建污染新 conv)。 // 单次锁内 check+mutate(防 conv 在两次锁间被删致惰性重建污染新 conv)。
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
if session.per_conv.contains_key(&conv_id) { if session.per_conv.contains_key(&conv_id) {
session.conv(&conv_id).messages.set_compressing(false); let conv = session.conv(&conv_id);
// 939⑪ 版本守卫:上下文已被整体替换 → 不复位(否则误伤新会话信令)。
if version_matches(&head_id, &head_id_of(&mut conv.messages)) {
conv.messages.set_compressing(false);
}
} }
} }
Err(e) => { Err(e) => {
// LLM 失败 → 改进3 B:仍标 compressed 释放 token + 关键词摘要塞回首条(非裸裁剪)。 // LLM 失败 → 改进3 B:仍标 compressed 释放 token + 关键词摘要尾部追加(非裸裁剪)。
// //
// 旧行为:消息状态完全不变,降级走 build_for_request 裁剪(丢主题)。 // 旧行为:消息状态完全不变,降级走 build_for_request 裁剪(丢主题)。
// 新行为(KEYWORD_FALLBACK_ENABLED=true 默认): // 新行为(KEYWORD_FALLBACK_ENABLED=true 默认):
// - compress_old_messages 标 [..protect_start] active 为 compressed(释放 token, // - compress_old_messages 标 [..protect_start] active 为 compressed(释放 token,
// 与成功路径一致,后续 build_for_request 不再把它们进 LLM 上下文); // 与成功路径一致,后续 build_for_request 不再把它们进 LLM 上下文);
// - keyword_fallback 非空 → insert_at(0, system 关键词摘要)作续接锚点; // - keyword_fallback 非空 → push(system 关键词摘要)尾部追加作续接锚点;
// - keyword_fallback 空(无 user 消息/无可提取词)→ 不插,等价旧裁剪(保底)。 // - keyword_fallback 空(无 user 消息/无可提取词)→ 不插,等价旧裁剪(保底)。
// 持久化语义不变:compressed 仍软删可追溯(DB 全量保留),与成功路径一致。 // 持久化语义不变:compressed 仍软删可追溯(DB 全量保留),与成功路径一致。
// 压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false) // 压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false)
// 误判生成结束(后端 loop 实际继续),致"压缩后停止"。降级为 warn + 兜底释放 // 误判生成结束(后端 loop 实际继续),致"压缩后停止"。降级为 warn + 兜底释放
// token + build_for_request 裁剪,loop 继续 stream_llm,用户自然看到后续回复。 // token + build_for_request 裁剪,loop 继续 stream_llm,用户自然看到后续回复。
// 单次锁内 check+mutate(防 conv 在两次锁间被删致惰性重建污染新 conv)。 // 复位 is_compressing 收口进 keyword_fallback_commit 单锁(909①-补,
{ // 同 provider 重建失败路径;消锁外复位→再取锁提交的并发窗口)。
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(&conv_id) {
session.conv(&conv_id).messages.set_compressing(false);
}
}
tracing::warn!( tracing::warn!(
conv_id = %conv_id, conv_id = %conv_id,
error = %e, error = %e,
@@ -323,10 +369,21 @@ pub(super) async fn maybe_auto_compress(
&keyword_fallback, &keyword_fallback,
pre_compress_tokens, pre_compress_tokens,
"压缩失败", "压缩失败",
&head_id,
) )
.await; .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)。 /// 关键词兜底提交单点(DRY #8 收敛,2026-10-01)。
/// ///
/// 抽自 [`maybe_auto_compress`] 后台 task 内的逐字双胞胎块:压缩 provider 重建失败路径 /// 抽自 [`maybe_auto_compress`] 后台 task 内的逐字双胞胎块:压缩 provider 重建失败路径
/// 与压缩 LLM 失败路径共用「KEYWORD_FALLBACK_ENABLED 门控 → 锁内 check+mutate /// 与压缩 LLM 失败路径共用「复位 is_compressing → KEYWORD_FALLBACK_ENABLED 门控 → 锁内
/// (标 compressed 释放 token + 关键词摘要插首位,非空才插)→ 成功插入打 info」。 /// check+mutate(标 compressed 释放 token + 关键词摘要尾部追加,非空才插)→ 成功插入打 info」。
/// 两条失败路径只差日志正文,经 `log_head` 参数化(拼为 /// 两条失败路径只差日志正文,经 `log_head` 参数化(拼为
/// "[ai] {log_head}兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)")。 /// "[ai] {log_head}兜底(后台):关键词摘要已尾部追加(compressed 标记已扣 token)")。
/// ///
/// conv 已删(per_conv 无条目)→ 直接返回不兜底(对齐原 return 语义:防惰性重建污染新 conv)。 /// **复位重入标志收口单锁(909①-补,2026-10-10)**:`set_compressing(false)` 与兜底提交在同一
/// 次锁内原子完成——原调用方在锁外单独取锁复位,与这里的提交之间留出窗口(期间的压缩触发读到
/// 的仍是 false,可并发二次压缩,放大楔入概率)。注意复位在 `KEYWORD_FALLBACK_ENABLED` 门控
/// **之前**:开关关闭时同样必须复位,否则 is_compressing 永真 → 压缩永久停摆。
///
/// conv 已删(per_conv 无条目)→ 直接返回(标志随 conv 销毁无需复位;对齐原 return 语义:
/// 防惰性重建污染新 conv)。
///
/// **内容版本守卫(939⑪)**:`head_id` 为触发时快照的首条消息 id;提交前比对不符(期间
/// `clear()` 重建致上下文整体替换)→ 直接返回:既不标 compressed / 不插旧关键词摘要污染
/// 新上下文,也不复位 is_compressing(clear 已复位;复位会误伤新会话可能已启的新压缩信令)。
async fn keyword_fallback_commit( async fn keyword_fallback_commit(
session_arc: &Arc<Mutex<AiSession>>, session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str, conv_id: &str,
@@ -349,30 +416,167 @@ async fn keyword_fallback_commit(
keyword_fallback: &str, keyword_fallback: &str,
pre_tokens: u32, pre_tokens: u32,
log_head: &str, log_head: &str,
head_id: &Option<String>,
) { ) {
if !KEYWORD_FALLBACK_ENABLED {
return;
}
let inserted = { let inserted = {
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
if !session.per_conv.contains_key(conv_id) { if !session.per_conv.contains_key(conv_id) {
return; return;
} }
let conv = session.conv(conv_id); let conv = session.conv(conv_id);
let _compressed = conv.messages.compress_old_messages(protect_start); // 939⑪ 内容版本守卫:上下文已被整体替换 → 丢弃兜底提交(标志亦不复位)。
if keyword_fallback.is_empty() { 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 false
} else { } else {
conv.messages.insert_at(0, ChatMessage::system(keyword_fallback)); let _compressed = conv.messages.compress_old_messages(protect_start);
true 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 { if inserted {
tracing::info!( tracing::info!(
conv_id = %conv_id, conv_id = %conv_id,
pre_tokens, pre_tokens,
"[ai] {}兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)", "[ai] {}兜底(后台):关键词摘要已尾部追加(compressed 标记已扣 token)",
log_head log_head
); );
} }
} }
#[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; let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) { if session.per_conv.contains_key(conv_id) {
let conv = session.conv(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); 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(); let zero_usage = df_ai::provider::TokenUsage::default();
super::emit_ai_completed_once( super::emit_ai_completed_once(
app, &emit_conv_id, &zero_usage, false, app, &emit_conv_id, &zero_usage, false,
// 零 token 收敛信号,非截断(看板 953)。
false,
None, None, None, true, None, None, None, true,
&snap.pinned_goals_snapshot, false, &snap.pinned_goals_snapshot, false,
).await; ).await;
@@ -177,6 +179,8 @@ pub async fn try_continue_agent_loop(
let zero_usage = df_ai::provider::TokenUsage::default(); let zero_usage = df_ai::provider::TokenUsage::default();
super::emit_ai_completed_once( super::emit_ai_completed_once(
app, &conv_id_owned, &zero_usage, false, app, &conv_id_owned, &zero_usage, false,
// 零 token 收敛信号,非截断(看板 953)。
false,
None, None, None, true, None, None, None, true,
&snap.pinned_goals_snapshot, false, &snap.pinned_goals_snapshot, false,
).await; ).await;
+323 -29
View File
@@ -46,6 +46,8 @@ use super::conversation::{save_conversation, TokenAccumulator};
use super::knowledge_inject::{maybe_spawn_extraction}; use super::knowledge_inject::{maybe_spawn_extraction};
use super::title::{ensure_conversation_title, spawn_ensure_title}; use super::title::{ensure_conversation_title, spawn_ensure_title};
use super::{AiChatEvent, AiSession, ErrorType}; use super::{AiChatEvent, AiSession, ErrorType};
// B5a 挂钟闸分任务定级:轮间闸读会话策略(未注册落 legacy 硬停,确定性任务检查点续跑)。
use super::unattended::{wall_clock_policy_for, WallClockAction};
// ConvState 经本文件内 `pub mod conv_state;` 同 crate 直接访问(conv_state::ConvState)。 // ConvState 经本文件内 `pub mod conv_state;` 同 crate 直接访问(conv_state::ConvState)。
/// L1 补丁:run_agentic_loop 入口 provider 解析超时保护的内部错误类型。 /// 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/挂钟兜底——模型死循环级输出 /// 无人值守长任务(max_iterations=0 不限轮次)缺 token/挂钟兜底——模型死循环级输出
/// 白烧预算无人踩闸。三闸在每轮头检查(轮间粒度,单轮内不中断): /// 白烧预算无人踩闸。三闸在每轮头检查(轮间粒度,单轮内不中断):
/// ①挂钟:超 UNATTENDED_WALL_SECS(默认 30min,对齐委托任务超时底座)停靠 wall_clock; /// ①挂钟:超 UNATTENDED_WALL_SECS(默认 30min,对齐委托任务超时底座)停靠 wall_clock;
/// 未注册分档的会话落此 legacy 阈值;已注册会话由 unattended::wall_clock_policy_for 的
/// 策略决定(确定性任务放宽+检查点续跑,探索性/失控疑似硬停),见下方挂钟闸;
/// ②累计 token:超 UNATTENDED_MAX_TOKENS(默认 5M)停靠 token_budget; /// ②累计 token:超 UNATTENDED_MAX_TOKENS(默认 5M)停靠 token_budget;
/// ③轮次:max_iterations>0 既有 for 上界 + 达上限停靠分支,不重复设闸。 /// ③轮次:max_iterations>0 既有 for 上界 + 达上限停靠分支,不重复设闸。
/// 超限走既有 emit_task_parked——前端词表已预留,零新契约。 /// 超限走既有 emit_task_parked——前端词表已预留,零新契约。
@@ -279,6 +283,21 @@ pub const GOAL_VERIFY_ENABLED: bool = false;
/// 保证 loop 必然终止,绝不死循环。LLM 续轮内调了工具(无论成败)计数器重置(说明在行动)。 /// 保证 loop 必然终止,绝不死循环。LLM 续轮内调了工具(无论成败)计数器重置(说明在行动)。
pub const GOAL_VERIFY_MAX_ROUNDS: u32 = 2; 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 字段)。 /// G2 警示是否回顾目标(默认 true,需 G1 goal 字段)。
/// ///
/// true(默认):警示文本引用 pinned_goal(若存在)提示「回顾目标: {goal}」,精准; /// true(默认):警示文本引用 pinned_goal(若存在)提示「回顾目标: {goal}」,精准;
@@ -341,6 +360,19 @@ pub const CIRCUIT_BREAKER_ENABLED: bool = true;
/// 本开关无意义;CIRCUIT_BREAKER_ENABLED=true 时本开关决定呈现形态。 /// 本开关无意义;CIRCUIT_BREAKER_ENABLED=true 时本开关决定呈现形态。
pub const CIRCUIT_BREAKER_HELP_EVENT: bool = 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)。 // 占位配对完整性开关(解 400 orphan)。
// //
// 单一真相源:`df_ai::context_helpers::PLACEHOLDER_INTEGRITY_ENABLED`(本模块顶部已 use)。 // 单一真相源:`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),属异常 // 区分"正常收敛退出"与"达 MAX 被截断退出"——后者末轮 tool_calls 仍非空(tool_result 不再回传 LLM),属异常
let mut converged = false; 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)。 // L1 断路器:连续同类工具失败滚动窗口(VecDeque<key>,长度封顶 CIRCUIT_BREAKER_WINDOW)。
// key=结构化信号(tool_name + 错误类别)。每轮 process_tool_calls 后追加末尾失败 key, // key=结构化信号(tool_name + 错误类别)。每轮 process_tool_calls 后追加末尾失败 key,
// 超窗自动淘汰最旧。窗口内同 key 计数达 CIRCUIT_BREAKER_THRESHOLD → 熔断退出。 // 超窗自动淘汰最旧。窗口内同 key 计数达 CIRCUIT_BREAKER_THRESHOLD → 熔断退出。
@@ -896,6 +934,9 @@ pub(crate) async fn run_agentic_loop(
// 且无 applied.matched_target 证据时强制续轮,每续 +1。达 GOAL_VERIFY_MAX_ROUNDS 强制收敛 // 且无 applied.matched_target 证据时强制续轮,每续 +1。达 GOAL_VERIFY_MAX_ROUNDS 强制收敛
// + AiCompleted 标注 goal_unverified。续轮内调了工具则重置(说明 LLM 在行动非空转)。 // + AiCompleted 标注 goal_unverified。续轮内调了工具则重置(说明 LLM 在行动非空转)。
let mut goal_verify_rounds: u32 = 0; 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 模式推理内容跨轮透传 // DeepSeek thinking 模式推理内容跨轮透传
let mut last_reasoning_content: Option<String> = None; let mut last_reasoning_content: Option<String> = None;
@@ -1085,6 +1126,8 @@ pub(crate) async fn run_agentic_loop(
&coord_usage, &coord_usage,
// Coordinator 路径无 round(未 stream),无估算语义 → false。 // Coordinator 路径无 round(未 stream),无估算语义 → false。
false, false,
// Coordinator 路径无 round,无截断语义 → false(看板 953)。
false,
None, None, None, true, None, None, None, true,
&pinned_goals_snapshot, &pinned_goals_snapshot,
&app_handle, &app_handle,
@@ -1150,8 +1193,9 @@ pub(crate) async fn run_agentic_loop(
// 无注入扩轮时动态判定与原上界等价,行为零变化。扩轮总数封顶防注入洪水无限续跑。 // 无注入扩轮时动态判定与原上界等价,行为零变化。扩轮总数封顶防注入洪水无限续跑。
let mut injected_extra_rounds: usize = 0; let mut injected_extra_rounds: usize = 0;
// H2 token 三闸基点:loop 启动挂钟起点(轮间粒度检查,单轮不中断) // H2 token 三闸基点:loop 启动挂钟起点(轮间粒度检查,单轮不中断)。
let loop_started = tokio::time::Instant::now(); // 可变:确定性任务到点走检查点续跑时顺延起点(见下方挂钟闸)。
let mut loop_started = tokio::time::Instant::now();
// H3 外部干预感知基点:loop 启动时在被监视工作区取 git HEAD 快照(轮间粒度比对)。 // H3 外部干预感知基点:loop 启动时在被监视工作区取 git HEAD 快照(轮间粒度比对)。
// 设计口径:loop_epoch 管「引擎内接管」,H3 管「引擎外干预」——用户/另一 AI 会话在 loop // 设计口径:loop_epoch 管「引擎内接管」,H3 管「引擎外干预」——用户/另一 AI 会话在 loop
@@ -1206,10 +1250,30 @@ pub(crate) async fn run_agentic_loop(
{ {
let elapsed = loop_started.elapsed().as_secs(); let elapsed = loop_started.elapsed().as_secs();
let total_tokens = tokens.total() as u64; 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(( Some((
"wall_clock", "wall_clock",
format!("任务运行超 {} 分钟,安全停靠:已完成进度已保存,点继续或重发消息可续跑", UNATTENDED_WALL_SECS / 60), format!("任务运行超 {} 分钟,安全停靠:已完成进度已保存,点继续或重发消息可续跑", wall_deadline / 60),
)) ))
} else if total_tokens >= UNATTENDED_MAX_TOKENS { } else if total_tokens >= UNATTENDED_MAX_TOKENS {
Some(( Some((
@@ -1229,6 +1293,7 @@ pub(crate) async fn run_agentic_loop(
Some(&save_usage), Some(&resolved_model), Some(&save_usage), Some(&resolved_model),
false, &provider_config, &llm_concurrency, &mut guard, false, &provider_config, &llm_concurrency, &mut guard,
&emit_usage, last_round_estimated, &emit_usage, last_round_estimated,
false,
Some(true), None, Some(reason), true, Some(true), None, Some(reason), true,
&pinned_goals_snapshot, &app_handle, &loop_epoch_arc, my_epoch, false, &pinned_goals_snapshot, &app_handle, &loop_epoch_arc, my_epoch, false,
).await; ).await;
@@ -1261,6 +1326,7 @@ pub(crate) async fn run_agentic_loop(
Some(&save_usage), Some(&resolved_model), Some(&save_usage), Some(&resolved_model),
false, &provider_config, &llm_concurrency, &mut guard, false, &provider_config, &llm_concurrency, &mut guard,
&emit_usage, last_round_estimated, &emit_usage, last_round_estimated,
false,
Some(true), None, Some("external_change"), true, Some(true), None, Some("external_change"), true,
&pinned_goals_snapshot, &app_handle, &loop_epoch_arc, my_epoch, false, &pinned_goals_snapshot, &app_handle, &loop_epoch_arc, my_epoch, false,
).await; ).await;
@@ -1351,9 +1417,13 @@ pub(crate) async fn run_agentic_loop(
"[ai] 连续 {} 轮失败,重入预算耗尽,任务停靠", "[ai] 连续 {} 轮失败,重入预算耗尽,任务停靠",
consecutive_round_failures, consecutive_round_failures,
); );
// 停靠语义:超时类(上游收了请求不响应,非确定性)与其他 Fatal 走
// 不同文案——超时突出「可继续」,由停靠卡「继续」一键续跑而非中断;
// 其余 Fatal 维持原连续失败文案(确定性错误,续跑无益)。
let reason = fatal_park_reason(consecutive_round_failures, &error);
crate::commands::ai::emit_task_parked( crate::commands::ai::emit_task_parked(
&app_handle, &conv_id, &app_handle, &conv_id,
&format!("模型调用连续 {} 轮失败(重入退避后仍未恢复),任务安全停靠:{}", consecutive_round_failures, error), &reason,
"fatal", "fatal",
); );
return; return;
@@ -1392,12 +1462,15 @@ pub(crate) async fn run_agentic_loop(
&full_text, &tool_calls_acc, &round_usage, round_is_estimated, &full_text, &tool_calls_acc, &round_usage, round_is_estimated,
&last_reasoning_content, &last_reasoning_content,
&mut goal_verify_rounds, &mut goal_verify_rounds,
&mut empty_round_streak,
round_truncated, round_truncated,
).await { ).await {
// RunTools → 继续工具调用段(工具调用数由该段内部取,与原时序一致) // RunTools → 继续工具调用段(工具调用数由该段内部取,与原时序一致)
RoundEpilogue::RunTools => {} RoundEpilogue::RunTools => {}
RoundEpilogue::Converged => { RoundEpilogue::Converged => {
// 无工具调用且无续轮需求 → 正常收敛退出(原 converged = true; break,语义不变) // 无工具调用且无续轮需求 → 正常收敛退出(原 converged = true; break,语义不变)
// 看板 953:收敛轮被 max_tokens 截断时把标志带出 loop(正常完成 emit 透传前端)
converged_truncated = round_truncated;
converged = true; converged = true;
break; break;
} }
@@ -1520,6 +1593,7 @@ pub(crate) async fn run_agentic_loop(
&mut guard, &mut guard,
&emit_usage, &emit_usage,
last_round_estimated, last_round_estimated,
false,
Some(true), None, Some("max_rounds"), false, Some(true), None, Some("max_rounds"), false,
&pinned_goals_snapshot, &pinned_goals_snapshot,
&app_handle, &app_handle,
@@ -1584,6 +1658,7 @@ pub(crate) async fn run_agentic_loop(
emit_ai_completed_once( emit_ai_completed_once(
&app_handle, &conv_id, &normal_usage, &app_handle, &conv_id, &normal_usage,
last_round_estimated, last_round_estimated,
converged_truncated,
None, None, None, true, None, None, None, true,
&pinned_goals_snapshot, &pinned_goals_snapshot,
true, true,
@@ -1607,6 +1682,7 @@ pub(crate) async fn run_agentic_loop(
&mut guard, &mut guard,
&emit_usage, &emit_usage,
last_round_estimated, last_round_estimated,
false,
None, None, None, true, None, None, None, true,
&pinned_goals_snapshot, &pinned_goals_snapshot,
&app_handle, &app_handle,
@@ -1724,6 +1800,8 @@ async fn prepare_round_inputs(
&emit_usage, &emit_usage,
// 入口 stop:本轮尚未 stream,last_round_estimated 仍为初始 false(无估算)。 // 入口 stop:本轮尚未 stream,last_round_estimated 仍为初始 false(无估算)。
last_round_estimated, last_round_estimated,
// 入口 stop:停止语义非截断,恒 false(看板 953)。
false,
None, None, None, true, None, None, None, true,
pinned_goals_snapshot, pinned_goals_snapshot,
app_handle, app_handle,
@@ -1788,7 +1866,7 @@ async fn prepare_round_inputs(
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) { if session.per_conv.contains_key(conv_id) {
let conv = session.conv(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!( tracing::info!(
conv_id = %conv_id, conv_id = %conv_id,
iteration, iteration,
@@ -2020,6 +2098,8 @@ async fn finalize_round_output(
round_is_estimated: bool, round_is_estimated: bool,
last_reasoning_content: &Option<String>, last_reasoning_content: &Option<String>,
goal_verify_rounds: &mut u32, goal_verify_rounds: &mut u32,
// D5 空轮连续计数:空轮 +1 / 有产出轮清零,达 EMPTY_ROUND_PARK_THRESHOLD 停靠止损。
empty_round_streak: &mut u32,
// 本轮流因 provider max_tokens 上限截断(P2-1),收敛时据实提示不冒充完整 // 本轮流因 provider max_tokens 上限截断(P2-1),收敛时据实提示不冒充完整
round_truncated_by_max_tokens: bool, round_truncated_by_max_tokens: bool,
) -> RoundEpilogue { ) -> RoundEpilogue {
@@ -2031,6 +2111,11 @@ async fn finalize_round_output(
// 追加 assistant 消息到历史 + G1 目标提取(扁平重构,原嵌套 8 层 → 3 层) // 追加 assistant 消息到历史 + G1 目标提取(扁平重构,原嵌套 8 层 → 3 层)
let has_tool_calls = !tool_calls_acc.is_empty(); 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; let mut session = session_arc.lock().await;
if !session.per_conv.contains_key(conv_id) { if !session.per_conv.contains_key(conv_id) {
@@ -2120,6 +2205,8 @@ async fn finalize_round_output(
guard, guard,
&emit_usage, &emit_usage,
last_round_estimated, last_round_estimated,
// stop 语义非截断,恒 false(看板 953)。
false,
None, None, None, true, None, None, None, true,
pinned_goals_snapshot, pinned_goals_snapshot,
app_handle, app_handle,
@@ -2131,6 +2218,43 @@ async fn finalize_round_output(
// 无工具调用 → 最终文本响应。正常收敛退出,或目标验证续轮(GOAL_VERIFY_ENABLED)。 // 无工具调用 → 最终文本响应。正常收敛退出,或目标验证续轮(GOAL_VERIFY_ENABLED)。
if !has_tool_calls { 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 证据 + 未达上限 // 目标验证续轮:开关开 + 有钉扎目标 + 本轮无 applied.matched_target 证据 + 未达上限
// → 不收敛,注入 nudge 强制续轮(治过早宣称,机制非 prompt 说教)。 // → 不收敛,注入 nudge 强制续轮(治过早宣称,机制非 prompt 说教)。
// 任一条件不满足 → 正常收敛(逐字节等价改动前)。 // 任一条件不满足 → 正常收敛(逐字节等价改动前)。
@@ -2261,12 +2385,16 @@ async fn process_round_tools(
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
return ToolsOutcome::Exit; 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 求助卡, // 安全停靠(设计 §四):熔断即终局,补发 AiTaskParked(原状只 emit 求助卡,
// 托盘场景无独立事件;guard.reset 已在上方完成,现场已 save 于前一轮)。 // 托盘场景无独立事件;guard.reset 已在上方完成,现场已 save 于前一轮)。
crate::commands::ai::emit_task_parked( crate::commands::ai::emit_task_parked(
app_handle, conv_id, app_handle, conv_id,
&format!("连续同类失败 {} 次已熔断,任务安全停靠;已完成进度已保存,调整思路后重发消息可继续", max_count), &format!("连续同类失败 {} 次已熔断(最近错误: {}),任务安全停靠;已完成进度已保存,调整思路后重发消息可继续", max_count, sample_key),
"circuit_breaker", "circuit_breaker",
); );
return ToolsOutcome::Exit; return ToolsOutcome::Exit;
@@ -2379,7 +2507,7 @@ async fn insert_goal_verify_nudge(
let conv = session.conv(conv_id); let conv = session.conv(conv_id);
// 尾部追加而非 insert_at(0):nudge 紧邻本轮内容语义不变,且不位移既有前缀字节 // 尾部追加而非 insert_at(0):nudge 紧邻本轮内容语义不变,且不位移既有前缀字节
// (insert_at(0) 会把整条历史后移,自动前缀缓存/Anthropic 显式缓存全失效)。 // (insert_at(0) 会把整条历史后移,自动前缀缓存/Anthropic 显式缓存全失效)。
conv.messages.push(ChatMessage::system(&nudge_text)); conv.messages.push_system_anchor(ChatMessage::system(&nudge_text));
tracing::info!( tracing::info!(
conv_id = %conv_id, iteration, verify_rounds, conv_id = %conv_id, iteration, verify_rounds,
"[GOAL-VERIFY] 目标未验证,注入续轮 nudge(强制续轮)" "[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(扁平抽自原嵌套块) ── // ── emit_circuit_breaker_tripped: L1 断路器熔断统一 emit(扁平抽自原嵌套块) ──
// 开关 CIRCUIT_BREAKER_HELP_EVENT: true=求助卡(默认), false=AiError 错误气泡(兜底回退)。 // 开关 CIRCUIT_BREAKER_HELP_EVENT: true=求助卡(默认), false=AiError 错误气泡(兜底回退)。
fn emit_circuit_breaker_tripped(app_handle: &AppHandle, conv_id: &str, max_count: u32, sample_key: &str) { 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("未知路径"); let path_desc = loop_hit_path.as_deref().unwrap_or("未知路径");
// DRY #1:双写正典(熔断求助卡跨端可见) // DRY #1:双写正典(熔断求助卡跨端可见)
emit_ai_chat_event( // #904 附项:默认只发停靠一张卡(CIRCUIT_BREAKER_PARK_ONLY),求助卡与停靠卡同轮
app_handle, // 叠加语义打架;开关关则退旧双卡行为。
AiChatEvent::AiHelpRequired { if !CIRCUIT_BREAKER_PARK_ONLY {
reason: format!("连续 {} 轮检测到对同一文件({})反复写删,疑似「写完就删」自毁循环已停止", new_loop_count, path_desc), emit_ai_chat_event(
context: "写文件后又删除同一文件的紧凑交替,可能在反复销毁自己的工作成果而无进展。".into(), app_handle,
options: vec!["回顾目标".into(), "换思路".into(), "停止".into()], AiChatEvent::AiHelpRequired {
conversation_id: Some(conv_id.to_string()), reason: format!("连续 {} 轮检测到对同一文件({})反复写删,疑似「写完就删」自毁循环已停止", new_loop_count, path_desc),
}, context: "写文件后又删除同一文件的紧凑交替,可能在反复销毁自己的工作成果而无进展。".into(),
); options: vec!["回顾目标".into(), "换思路".into(), "停止".into()],
conversation_id: Some(conv_id.to_string()),
},
);
}
// P1 bg 化:熔断需人工介入,系统通知补位(条件发送,失败静默) // P1 bg 化:熔断需人工介入,系统通知补位(条件发送,失败静默)
crate::commands::ai::notify::notify_session_event( crate::commands::ai::notify::notify_session_event(
app_handle, app_handle,
@@ -2573,15 +2752,19 @@ async fn check_stall_breaker(
"[ai] G2 探索熔断:连续 {} 次检测到重复工具调用,疑似卡住停止", new_count "[ai] G2 探索熔断:连续 {} 次检测到重复工具调用,疑似卡住停止", new_count
); );
// DRY #1:双写正典(熔断求助卡跨端可见) // DRY #1:双写正典(熔断求助卡跨端可见)
emit_ai_chat_event( // #904 附项:默认只发停靠一张卡(CIRCUIT_BREAKER_PARK_ONLY),求助卡与停靠卡同轮
app_handle, // 叠加语义打架;开关关则退旧双卡行为。
AiChatEvent::AiHelpRequired { if !CIRCUIT_BREAKER_PARK_ONLY {
reason: format!("连续 {} 次检测到重复的工具调用(同样的 grep/读文件反复),疑似卡住已停止", new_count), emit_ai_chat_event(
context: "工具调用签名高度重复,可能在反复做同一件事而无进展。".into(), app_handle,
options: vec!["回顾目标".into(), "换思路".into(), "停止".into()], AiChatEvent::AiHelpRequired {
conversation_id: Some(conv_id.to_string()), reason: format!("连续 {} 次检测到重复的工具调用(同样的 grep/读文件反复),疑似卡住已停止", new_count),
}, context: "工具调用签名高度重复,可能在反复做同一件事而无进展。".into(),
); options: vec!["回顾目标".into(), "换思路".into(), "停止".into()],
conversation_id: Some(conv_id.to_string()),
},
);
}
// P1 bg 化:熔断需人工介入,系统通知补位(条件发送,失败静默) // P1 bg 化:熔断需人工介入,系统通知补位(条件发送,失败静默)
crate::commands::ai::notify::notify_session_event( crate::commands::ai::notify::notify_session_event(
app_handle, app_handle,
@@ -2753,6 +2936,34 @@ fn classify_permanent_provider_error(error: &str) -> bool {
model_missing || no_access || param_reject 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_fatal_error: 致命错误统一 emit AiError(扁平抽自原 Fatal/Exhausted 双分支重复代码) ──
// 双写经 emit_ai_chat_event。error_type: 可判定的永久性 provider 错误(模型不存在/令牌无权/ // 双写经 emit_ai_chat_event。error_type: 可判定的永久性 provider 错误(模型不存在/令牌无权/
// 参数拒绝类 400)归 ProviderConfig,其余维持 Network 现状(2026-10-01 两端统一时的口径)。 // 参数拒绝类 400)归 ProviderConfig,其余维持 Network 现状(2026-10-01 两端统一时的口径)。
@@ -2827,6 +3038,8 @@ pub(crate) async fn emit_ai_completed_once(
conv_id: &str, conv_id: &str,
usage: &df_ai::provider::TokenUsage, usage: &df_ai::provider::TokenUsage,
is_estimated: bool, is_estimated: bool,
// 看板 953:最终收敛回复因 provider max_tokens 截断(仅正常收敛出口传 true,其余 false)
truncated_by_max_tokens: bool,
emit_incomplete: Option<bool>, emit_incomplete: Option<bool>,
publish_incomplete: Option<bool>, publish_incomplete: Option<bool>,
incomplete_reason: Option<&str>, incomplete_reason: Option<&str>,
@@ -2863,7 +3076,7 @@ pub(crate) async fn emit_ai_completed_once(
// publish 端:do_publish=false(max_iterations)跳过 publish(保留原无 publish 行为); // publish 端:do_publish=false(max_iterations)跳过 publish(保留原无 publish 行为);
// MidStream incomplete 分叉(emit=Some(true)/publish=None)事件值不同,不能并成单次 // MidStream incomplete 分叉(emit=Some(true)/publish=None)事件值不同,不能并成单次
// emit_ai_chat_event 调用,分路构造(其余 5 路径两端同值,走单次双写)。 // emit_ai_chat_event 调用,分路构造(其余 5 路径两端同值,走单次双写)。
let ev = completed_event(usage, is_estimated, emit_incomplete, 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 { if do_publish && emit_incomplete == publish_incomplete {
emit_ai_chat_event(app_handle, ev); emit_ai_chat_event(app_handle, ev);
// P0 修复(2026-10-05):正常完成恒走此早退,完成通知桥必须在此投递—— // P0 修复(2026-10-05):正常完成恒走此早退,完成通知桥必须在此投递——
@@ -2876,7 +3089,7 @@ pub(crate) async fn emit_ai_completed_once(
let _ = app_handle.emit("ai-chat-event", ev); let _ = app_handle.emit("ai-chat-event", ev);
if do_publish { if do_publish {
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event( let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(
completed_event(usage, is_estimated, publish_incomplete, 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 关闭) // P1 bg 化:完成出口系统通知(条件发送,失败静默;stop 出口经调用方传 false 关闭)
@@ -2921,9 +3134,12 @@ pub(crate) async fn last_active_assistant_text(
/// 构造 AiCompleted 事件(emit_ai_completed_once 内部去重用,字段口径见其文档)。 /// 构造 AiCompleted 事件(emit_ai_completed_once 内部去重用,字段口径见其文档)。
/// incomplete_reason 与 incomplete 配对:emit 侧事件随 emit_incomplete 取值,publish 侧 /// incomplete_reason 与 incomplete 配对:emit 侧事件随 emit_incomplete 取值,publish 侧
/// 分叉事件(与 emit 不一致时)incomplete 为 None,reason 同步 None。 /// 分叉事件(与 emit 不一致时)incomplete 为 None,reason 同步 None。
/// truncated_by_max_tokens(看板 953):最终收敛回复是否被 provider max_tokens 截断,
/// 透传前端标截断提示条;非正常收敛出口恒 false。
fn completed_event( fn completed_event(
usage: &df_ai::provider::TokenUsage, usage: &df_ai::provider::TokenUsage,
is_estimated: bool, is_estimated: bool,
truncated_by_max_tokens: bool,
incomplete: Option<bool>, incomplete: Option<bool>,
incomplete_reason: Option<&str>, incomplete_reason: Option<&str>,
conv_id: &str, conv_id: &str,
@@ -2937,6 +3153,7 @@ fn completed_event(
prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens, prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens,
reasoning_tokens: usage.reasoning_tokens, reasoning_tokens: usage.reasoning_tokens,
is_estimated, is_estimated,
truncated_by_max_tokens,
incomplete, incomplete,
incomplete_reason: incomplete_reason.map(str::to_string), incomplete_reason: incomplete_reason.map(str::to_string),
conversation_id: Some(conv_id.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(幂等) // - save_usage/save_model:透传 save_conversation;Coordinator 传 (None, None) 仍会调 save(幂等)
// - emit_usage:emit AiCompleted 的 token 三元组来源(各退出点经 tokens.add/round-derived 后传入) // - emit_usage:emit AiCompleted 的 token 三元组来源(各退出点经 tokens.add/round-derived 后传入)
// - emit_estimated(G4.3, 2026-08-05):本轮 token 用量是否估算值,透传 emit_ai_completed_once 仅展示 // - 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 兜底) // - spawn_title:true → spawn_ensure_title(provider_config 后台生成,失败有 extract_title 兜底)
// - emit_incomplete/publish_incomplete/do_publish:透传 emit_ai_completed_once(见该 helper 文档) // - emit_incomplete/publish_incomplete/do_publish:透传 emit_ai_completed_once(见该 helper 文档)
// - loop_epoch_arc/my_epoch:F1 并发 epoch——旧 loop(epoch 不匹配)只 disarm 跳过 save/reset/emit, // - 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, guard: &mut GeneratingGuard,
emit_usage: &df_ai::provider::TokenUsage, emit_usage: &df_ai::provider::TokenUsage,
emit_estimated: bool, emit_estimated: bool,
truncated_by_max_tokens: bool,
emit_incomplete: Option<bool>, emit_incomplete: Option<bool>,
publish_incomplete: Option<bool>, publish_incomplete: Option<bool>,
// 不完整原因(emit 侧,与 emit_incomplete 配对):Some("max_rounds")=达最大轮次自动收尾, // 不完整原因(emit 侧,与 emit_incomplete 配对):Some("max_rounds")=达最大轮次自动收尾,
@@ -3213,6 +3434,7 @@ pub(crate) async fn teardown_round(
// generating 复位后再 emit Completed(对齐原各路径顺序) // generating 复位后再 emit Completed(对齐原各路径顺序)
emit_ai_completed_once( emit_ai_completed_once(
app_handle, conv_id, emit_usage, emit_estimated, app_handle, conv_id, emit_usage, emit_estimated,
truncated_by_max_tokens,
emit_incomplete, publish_incomplete, incomplete_reason, do_publish, emit_incomplete, publish_incomplete, incomplete_reason, do_publish,
pinned_goals, pinned_goals,
notify, 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 侧契约:到达原轮次终点时,注入侧顺延的余量必须被消费 // 末轮注入扩轮(todo.md 28)loop 侧契约:到达原轮次终点时,注入侧顺延的余量必须被消费
// 续跑(「末轮注入→有续轮」),无余量则收尾(与原 for 自然结束等价,零行为变化)。 // 续跑(「末轮注入→有续轮」),无余量则收尾(与原 for 自然结束等价,零行为变化)。
// 与注入侧判定 injection_hits_final_round(commands/ai/commands/chat.rs)分半锁同一契约。 // 与注入侧判定 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); 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)) .and_then(|id| id_to_name.get(id))
.map(|(n, _)| n.as_str()) .map(|(n, _)| n.as_str())
.unwrap_or("tool"); .unwrap_or("tool");
// read_file 豁免压缩:AI 定向读代码,压缩 content 阉割代码分析能力。 // read_file 小文件豁免(P1 2026-10-10 改高阈值):AI 定向读代码,小文件需完整语义;
// read_file handler 自带 limit 硬上限 2000 行,无爆 prompt 风险。 // 超 READ_FILE_OFFLOAD_BYTES 的读取不再豁免——实测 read_file 占上下文体量 29.6%
// 豁免判定在决策记录之前(豁免消息不进决策表,每轮恒豁免无漂移)。 // (单条 max 150KB),原「一律豁免」让最大头裸奔(65K 均值压不下来的直接原因之一)。
if resolved_name == "read_file" { // 超阈值走压缩+落盘,句柄可无损读回(read_file 读句柄路径)。
if resolved_name == "read_file"
&& content_len <= df_ai::context_helpers::READ_FILE_OFFLOAD_BYTES
{
tracing::debug!( tracing::debug!(
conv_id = %conv_id, iteration, conv_id = %conv_id, iteration,
content_len, 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::{emit_fatal_error, teardown_round, tokens_snapshot, usage_delta_since};
use super::super::event_bus::emit_ai_chat_event; 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 辅助 // 单 Provider 流式结果 + fallback 辅助
// ============================================================ // ============================================================
@@ -334,7 +355,7 @@ pub(crate) async fn stream_round(
// 重试总预算(挂钟,含 sleep + 各次请求耗时),对齐 retry::MAX_TOTAL_BUDGET 30s。 // 重试总预算(挂钟,含 sleep + 各次请求耗时),对齐 retry::MAX_TOTAL_BUDGET 30s。
// 本轮各 candidate 共享一个 30s 预算(切换 provider 不重置预算, // 本轮各 candidate 共享一个 30s 预算(切换 provider 不重置预算,
// 防多 provider 串行重试累加超过单轮总预算)。超预算直接放弃重试交最终错误/保文路径。 // 防多 provider 串行重试累加超过单轮总预算)。超预算直接放弃重试交最终错误/保文路径。
let retry_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); // #906:该预算现于 'midstream 循环内每次(重)入刷新(续传重入获全新 30s),故此处不再声明。
// 外层 for candidate 包裹 stream_one_provider。 // 外层 for candidate 包裹 stream_one_provider。
// 顺序:[primary, ...candidates](空池兜底时仅 primary)。 // 顺序:[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 不写入。 // outcome 累积成功结果(Complete/Partial),InitFailed 不写入。
let mut outcome: Option<(String, std::collections::HashMap<u32, ToolCallDraft>, df_ai::provider::TokenUsage, bool, Option<String>, bool)> = None; let mut outcome: Option<(String, std::collections::HashMap<u32, ToolCallDraft>, df_ai::provider::TokenUsage, bool, Option<String>, bool)> = None;
// 追踪最后一个 Exhausted candidate 的诊断文本,供「全 candidate 耗尽」时 emit 最终 AiError。 // 追踪最后一个 Exhausted candidate 的诊断文本,供「全 candidate 耗尽」时 emit 最终 AiError。
@@ -414,7 +449,7 @@ pub(crate) async fn stream_round(
let stream_outcome = stream_one_provider( let stream_outcome = stream_one_provider(
candidate, candidate,
messages, &cont_messages,
tool_defs, tool_defs,
app_handle, app_handle,
stop_flag, stop_flag,
@@ -471,7 +506,7 @@ pub(crate) async fn stream_round(
} }
} }
match outcome { let round_result = match outcome {
Some(r) => r, Some(r) => r,
None => { None => {
// 全 candidate 耗尽(含单 provider 场景):沿用原单 provider 失败语义。 // 全 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; save_conversation(session_arc, db, conv_id, None, Some(&*resolved_model), true).await;
return RoundOutcome::Fatal { error: err_msg }; 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 兜底), // 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 已接收, // CR-30-2 / UX-2025-04 / 决策 a1: MidStream 保文路径——partial_text 已接收,
// 入库为正常 assistant 消息 + emit AiCompleted(incomplete=true) + 追加系统提示消息。 // 入库为正常 assistant 消息 + emit AiCompleted(incomplete=true) + 追加系统提示消息。
// 不走 AiError(非异常中断,已有可用文本),不重试(决策 a1)。 // 不走 AiError(非异常中断,已有可用文本)。
// #906 变更:续传预算耗尽后才到达此处(预算内已由上方 'midstream 循环续传,成功则走
// Continue 不落此)。续传路径已把「中间各段」partial 落库(循环内 push),此处补「最后一段」
// + 系统提示(最后一段未在循环内 push,无重叠)。
// 并发时用户停优先:网络断同时用户点停 → stop_flag true 时不走保文路径, // 并发时用户停优先:网络断同时用户点停 → stop_flag true 时不走保文路径,
// 落入下方 ~1508 stop_flag 检查走停止路径(用户意图优先)。 // 落入下方 stop_flag 检查走停止路径(用户意图优先)。
// partial 文本仍由下方 push_assistant_message(~1485)保文不丢。
if incomplete && !stop_flag.load(Ordering::SeqCst) { if incomplete && !stop_flag.load(Ordering::SeqCst) {
// 本轮 usage 补全(prompt 估算兜底,含 cache/reasoning 分项)已由 round_usage_effective 单点派生 // 本轮 usage 补全(prompt 估算兜底,含 cache/reasoning 分项)已由 round_usage_effective 单点派生
let usage = round_usage_full; let usage = round_usage_full;
@@ -536,8 +642,10 @@ pub(crate) async fn stream_round(
} }
// 与 push_assistant_message 一致:trim 归一化,纯空白保文(网络中断只有空白)不落库 // 与 push_assistant_message 一致:trim 归一化,纯空白保文(网络中断只有空白)不落库
let full_text = full_text.trim(); let full_text = full_text.trim();
let conv = session.conv(conv_id);
// #906:续传路径已把「中间各段」partial 落库(循环内 push),但「最后一段」
// (预算耗尽时 break 的 text)未落库——故此处始终补最后一段(无重叠,不重复)。
if !full_text.is_empty() { if !full_text.is_empty() {
let conv = session.conv(conv_id);
let mut msg = ChatMessage::assistant(full_text); let mut msg = ChatMessage::assistant(full_text);
msg.model = Some(resolved_model.clone()); msg.model = Some(resolved_model.clone());
// B-260617-16: MidStream 保文【不回填】reasoning_content——半截推理回传 // B-260617-16: MidStream 保文【不回填】reasoning_content——半截推理回传
@@ -555,11 +663,17 @@ pub(crate) async fn stream_round(
// 消息级估算标记:本轮 prompt 是否 estimated 兜底,reload 逐条回显对齐 live 态 // 消息级估算标记:本轮 prompt 是否 estimated 兜底,reload 逐条回显对齐 live 态
msg.is_estimated = Some(round_is_estimated); msg.is_estimated = Some(round_is_estimated);
conv.messages.push(msg); 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)。 // 统一走 teardown_round 收尾(save + spawn_title + reset + emit)。
@@ -577,6 +691,8 @@ pub(crate) async fn stream_round(
guard, guard,
&emit_usage, &emit_usage,
*last_round_estimated, *last_round_estimated,
// MidStream 已按不完整收尾,截断标志不再叠加(false,看板 953)。
false,
Some(true), None, Some("network"), true, Some(true), None, Some("network"), true,
pinned_goals_snapshot, pinned_goals_snapshot,
app_handle, app_handle,
@@ -678,3 +794,37 @@ pub(crate) fn merge_volatile_tail(messages: &mut Vec<ChatMessage>, tail: &str) {
} }
messages.push(ChatMessage::user(tail)); 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 级联)。 //! (父侧 ai_chat_stop / ai_chat_force_send / ai_conversation_delete 经 SubAgentRegistry 级联)。
//! //!
//! ## 事件与展示边界 //! ## 事件与展示边界
//! 仅在结束时挂父会话 emit 一条 AiAgentFinished(前端展示二期消费);不 emit AiCompleted / //! 运行期展示类事件(轮次/流式文本/工具生命周期/命令输出)经 `route_scope` 把子会话
//! AiMaxRounds 终态事件、不 spawn 标题/知识提炼。stream_round 复用带来的残留(均为网络异常 //! conv 改挂父会话再推送,父会话视图能看到子代理实时进展(治「切回父会话 spawn 卡停在旧态」
//! 罕见路径,消费方按「未知 conversation_id 丢弃」自我过滤,完整清单记录于此): //! 的幽灵态);复用既有 emit 双写出口,不另开旁路。仅结束时挂父会话 emit 一条 AiAgentFinished
//! (前端展示二期消费);不 emit AiCompleted / AiMaxRounds 终态事件、不 spawn 标题/知识提炼。
//! 非展示类事件(Fatal 的 AiError、MidStream 的 AiConvStateChanged 等)不改写,维持子会话
//! 路由——消费方按「未知 conversation_id 丢弃」自我过滤,完整残留清单记录于此:
//! - Fatal 分支会向子 conv emit AiError; //! - Fatal 分支会向子 conv emit AiError;
//! - MidStream 分支会触发一次子 conv 的后台标题任务(写库被零落库拦截,仅一次无效 LLM 调用); //! - MidStream 分支会触发一次子 conv 的后台标题任务(写库被零落库拦截,仅一次无效 LLM 调用);
//! - MidStream 分支 teardown_round 还会向子 conv emit 一条完整 AiCompleted(incomplete=true, //! - 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::atomic::{AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use tauri::AppHandle; use tauri::{AppHandle, State};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use df_ai::ai_tools::AiToolRegistry; 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::context_helpers::PLACEHOLDER_INTEGRITY_ENABLED;
use df_ai::provider::{ChatMessage, MessageRole, ToolDefinition}; use df_ai::provider::{ChatMessage, MessageRole, ToolDefinition};
use df_ai::router::{Modality, TaskRequirements}; 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::audit::{process_tool_calls, ToolExecPolicy};
use crate::commands::ai::conversation::{ 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::commands::ai::{AiChatEvent, ToolCallDraft};
use crate::state::LlmConcurrency; use crate::state::{AppState, LlmConcurrency};
use super::guard::GeneratingGuard; use super::guard::GeneratingGuard;
use super::context_lifecycle::maybe_auto_compress;
use crate::commands::ai::provider_pool::ProviderPool; use crate::commands::ai::provider_pool::ProviderPool;
use super::router_setup::resolve_model_with_override; use super::router_setup::resolve_model_with_override;
use super::{ 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; 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 原始错误串可能携带超长 /// 出口报告内联诊断文本的封顶(字符数)。Fatal 的 provider 原始错误串可能携带超长
/// 堆栈/响应体,父会话报告只需头部即可定位(401/404/参数错),超长部分截断省略。 /// 堆栈/响应体,父会话报告只需头部即可定位(401/404/参数错),超长部分截断省略。
const DIAG_TEXT_MAX_CHARS: usize = 400; 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)>, pub(crate) rejected_tools: Vec<(String, u32)>,
/// 停止类别(终态事件 stop_kind 数据源);启动失败=Error,正常收敛=Converged。 /// 停止类别(终态事件 stop_kind 数据源);启动失败=Error,正常收敛=Converged。
pub(crate) stop_kind: Option<SubAgentStopKind>, pub(crate) stop_kind: Option<SubAgentStopKind>,
/// 实际使用的模型 id(candidate 切换/池空回退后的最终值,非请求值)。
/// 请求 override 落空回退到别的模型时,父会话据此知道子代理「实际用什么跑的」;
/// 启动期失败(未解析出模型)为空串。
pub(crate) model: String,
} }
impl SubAgentOutcome { impl SubAgentOutcome {
@@ -197,6 +229,7 @@ impl SubAgentOutcome {
tokens: 0, tokens: 0,
rejected_tools: Vec::new(), rejected_tools: Vec::new(),
stop_kind: Some(SubAgentStopKind::Error), 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 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 报告协议解析核心,纯函数可单测)。 /// 判定 tool_result 内容是否为子代理拒绝 envelope(S2 报告协议解析核心,纯函数可单测)。
/// 仅识别结构化 JSON 的 status=rejected + reason=sub_agent_denied;非 JSON/正常结果/ /// 仅识别结构化 JSON 的 status=rejected + reason=sub_agent_denied;非 JSON/正常结果/
/// 其他拒绝(如主会话 path_blacklist)一律不算,防被拒清单误计。 /// 其他拒绝(如主会话 path_blacklist)一律不算,防被拒清单误计。
@@ -462,30 +539,60 @@ pub(crate) fn append_rejected_section(summary: String, rejected: &[(String, u32)
/// 替换「默认最多 6 轮」类硬编码文案——父可传 1..20,子代理必须按真实预算规划探索节奏, /// 替换「默认最多 6 轮」类硬编码文案——父可传 1..20,子代理必须按真实预算规划探索节奏,
/// 预算将尽时主动收尾交卷,而非被截断后拿末轮过程性独白当报告。 /// 预算将尽时主动收尾交卷,而非被截断后拿末轮过程性独白当报告。
/// 工具纪律段经 prompt::sub_agent_tool_discipline_section 同源拼接(P1-1)。 /// 工具纪律段经 prompt::sub_agent_tool_discipline_section 同源拼接(P1-1)。
/// `lang` 为父会话语言(agent_language,缺省中文):决定本段与工具纪律段语言;
/// 环境画像(日期 + OS/shell 执行姿势)与父会话同源注入(子代理同样无法自行获取日期)。
/// 派工纪律段经 super::DISPATCH_DISCIPLINE 母本同源拼接(N1),与主会话共享同一文本。 /// 派工纪律段经 super::DISPATCH_DISCIPLINE 母本同源拼接(N1),与主会话共享同一文本。
pub(crate) fn sub_agent_system_prompt(max_rounds: u32) -> String { pub(crate) fn sub_agent_system_prompt(max_rounds: u32, lang: &str) -> String {
let mut p = format!( // lang 只决定本段与工具纪律段语言;派工纪律段(DISPATCH_DISCIPLINE)是跨端共享母本、
"你是 DevFlow 主会话派生的子代理,在隔离上下文中独立执行父任务交办的任务书。\n\ // 文本单份(见 agentic/mod.rs),不随 lang 分叉。
## 定位\n\ let body = if lang == "en" {
- 面向父任务执行:只做任务书范围内的事,用户看不到你的过程,无法与你交互或审批\n\ format!(
- 探索/检索/核对类工作用工具完成,结论写进最终回复\n\ "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\
- 你没有审批通道:路径授权申请与中高风险工具会被直接拒绝并返回失败结果,\n\ ## Role\n\
遇到被拒绝的操作改用已授权方式完成,或在报告中说明缺口\n\ - Execute for the parent task only; the user cannot see your process and cannot interact with or approve you\n\
- 本轮预算(实际生效值):最多 {max_rounds} 轮 / 累计 token 上限 {max_tokens} / \ - Use tools for exploration/retrieval/verification work; put conclusions in your final reply\n\
挂钟上限 {wall_secs} 秒(15 分钟)。收敛即停,不做任务书之外的无关探索;\n\ - You have no approval channel: path-authorization requests and medium/high-risk tools are denied outright with a failure result;\n\
预算将尽(最后 1 轮)必须直接输出结论报告,不要留下未收尾的探索\n\ for a denied operation, switch to an authorized way or state the gap in your report\n\
## 输出要求\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\
- 最终回复就是交付给父任务的结论报告,不要寒暄、不要向用户提问\n\ when the budget is nearly exhausted (last round) output the conclusion report directly, leave no exploration unfinished\n\
- 报告结构:结论先行,然后列关键发现/证据/数据,最后列未尽事项与建议\n\ ## Output requirements\n\
- 若有工具调用被拒绝:在未尽事项中说明哪些子任务因工具权限受限未完成,父会话据此决定接手方式", - Your final reply IS the conclusion report delivered to the parent; no pleasantries, do not ask the user questions\n\
max_rounds = max_rounds, - Report structure: conclusion first, then key findings/evidence/data, finally open items and recommendations\n\
max_tokens = SUB_AGENT_MAX_TOTAL_TOKENS, - Preserve file paths, identifiers and error messages verbatim in the report (the parent locates by them; do not rewrite or summarize)\n\
wall_secs = SUB_AGENT_WALL_TIMEOUT_SECS, - 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 三工具 // 工具纪律段与父会话 system prompt 同源(read_symbol 优先/重复调用检测/URL 三工具
// 纪律/禁臆造 URL/查配置用 get_app_config);obscura 安装引导等交互条款已在共享函数 // 纪律/禁臆造 URL/查配置用 get_app_config);obscura 安装引导等交互条款已在共享函数
// 内裁剪,不适用于子代理。 // 内裁剪,不适用于子代理。lang 透传父会话语言,英文会话得英文纪律段。
p.push_str(&crate::commands::ai::prompt::sub_agent_tool_discipline_section("zh")); p.push_str(&crate::commands::ai::prompt::sub_agent_tool_discipline_section(lang));
// 派工纪律同源注入(N1):引用父模块母本常量,与主会话 build_runtime_prompt 共享同一文本 // 派工纪律同源注入(N1):引用父模块母本常量,与主会话 build_runtime_prompt 共享同一文本
// (报告限长/结论先行/证据随附直接约束子代理输出形态,若再派工亦同规约束); // (报告限长/结论先行/证据随附直接约束子代理输出形态,若再派工亦同规约束);
// 内置于二进制而非知识库,防新实例知识库为空时纪律失传。 // 内置于二进制而非知识库,防新实例知识库为空时纪律失传。
@@ -517,6 +624,9 @@ pub(crate) async fn run_sub_agent_loop(
allowed: Vec<String>, allowed: Vec<String>,
max_rounds: u32, max_rounds: u32,
model_override: Option<String>, model_override: Option<String>,
// 采样温度独立覆盖(P3):None=沿用 provider 配置/默认 0.7(默认不变,与主链路一致),
// Some(t)=本次子代理克隆候选注入 t,不影响主会话。
temperature_override: Option<f32>,
llm_concurrency: LlmConcurrency, llm_concurrency: LlmConcurrency,
// 父会话 save 元数据快照(P3-5):出口落库显式传入,防继承当前活跃会话的 // 父会话 save 元数据快照(P3-5):出口落库显式传入,防继承当前活跃会话的
// provider/created_at。 // 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(), session_arc.clone(), tools_arc, db.clone(), app_handle.clone(),
parent_conv_id.clone(), child_conv_id.clone(), parent_conv_id.clone(), child_conv_id.clone(),
system_prompt, task_prompt, tool_defs, allowed, system_prompt, task_prompt, tool_defs, allowed,
max_rounds, model_override, llm_concurrency.clone(), max_rounds, model_override, temperature_override, llm_concurrency.clone(),
).await; ).await;
// 统一出口:挂父会话 emit 终态(前端展示二期消费;报告本体经父 tool_result 返回值交付) // 统一出口:挂父会话 emit 终态(前端展示二期消费;报告本体经父 tool_result 返回值交付)
@@ -600,6 +710,7 @@ async fn run_sub_agent_loop_inner(
allowed: Vec<String>, allowed: Vec<String>,
max_rounds: u32, max_rounds: u32,
model_override: Option<String>, model_override: Option<String>,
temperature_override: Option<f32>,
llm_concurrency: LlmConcurrency, llm_concurrency: LlmConcurrency,
) -> SubAgentOutcome { ) -> SubAgentOutcome {
// 挂钟 deadline(三重熔断之三) // 挂钟 deadline(三重熔断之三)
@@ -611,9 +722,10 @@ async fn run_sub_agent_loop_inner(
// (build_for_request 保尾弃头)时第一个被逐出,完成标准/约束随之蒸发。system 是 // (build_for_request 保尾弃头)时第一个被逐出,完成标准/约束随之蒸发。system 是
// 循环不变量(sys_tokens 一次估算缓存),不受消息淘汰影响;消息流首条 user 仍保留, // 循环不变量(sys_tokens 一次估算缓存),不受消息淘汰影响;消息流首条 user 仍保留,
// 被淘汰也不丢目标。system_prompt 参数在此定形,后续全循环复用。 // 被淘汰也不丢目标。system_prompt 参数在此定形,后续全循环复用。
// 钉扎副本经 cap_pinned_task 收敛长度:超长任务书只钉目标锚,防全文字符串每轮常驻占 token。
let system_prompt = format!( let system_prompt = format!(
"{}\n## 任务书钉扎(全程有效,上下文裁剪不可逐出)\n<task>\n{}\n</task>\n", "{}\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 // 并发份额: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.deleted_convs.insert(child_conv_id.clone());
session.conv_origins.insert(child_conv_id.clone(), SUB_AGENT_ORIGIN.to_string()); session.conv_origins.insert(child_conv_id.clone(), SUB_AGENT_ORIGIN.to_string());
let conv = session.conv(&child_conv_id); 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.messages.push(ChatMessage::user(&task_prompt));
conv.stop_flag.store(false, Ordering::SeqCst); conv.stop_flag.store(false, Ordering::SeqCst);
conv.iteration_used = 0; conv.iteration_used = 0;
@@ -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()); GeneratingGuard::new(child_conv_id.clone(), child_epoch.clone(), my_epoch, app_handle.clone());
// ── provider 解析(30s 超时,对齐主 loop 入口;失败走 failed 返回值,不 emit AiError)── // ── 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 resolve = tokio::time::timeout(std::time::Duration::from_secs(30), async {
let provider_repo = AiProviderRepo::new(&db); let provider_repo = AiProviderRepo::new(&db);
let pool = match provider_repo.list_all().await { 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 resolved_model = resolve_model_with_override(&req_probe, &primary_provider, &model_override);
let mut provider_config = primary_provider; 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) // system_prompt 是整个子循环的不变量,token 估算一次缓存复用(对齐主 loop)
let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt); let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt);
@@ -733,6 +858,35 @@ async fn run_sub_agent_loop_inner(
break; 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 展开 + // ── prepare:build_for_request(预算裁剪)+ system prompt + namespace 展开 +
// tool_result 视图压缩 + 占位配对断言 + 输入 token 估值(对齐主 loop prepare 段)── // tool_result 视图压缩 + 占位配对断言 + 输入 token 估值(对齐主 loop prepare 段)──
let (messages, estimated_prompt) = { let (messages, estimated_prompt) = {
@@ -797,18 +951,23 @@ async fn run_sub_agent_loop_inner(
// 子循环 future(报告蒸发+注册表泄漏+子会话滞留)。stream_round 内部短锁段无跨 // 子循环 future(报告蒸发+注册表泄漏+子会话滞留)。stream_round 内部短锁段无跨
// await 持锁、mid-flight 无消息写,drop 安全(guard 由本函数持有,出口统一复位)。 // await 持锁、mid-flight 无消息写,drop 安全(guard 由本函数持有,出口统一复位)。
let stream_budget = deadline.saturating_duration_since(tokio::time::Instant::now()); let stream_budget = deadline.saturating_duration_since(tokio::time::Instant::now());
match tokio::time::timeout(stream_budget, stream_round( // 子代理展示类事件改挂父会话:本轮流式(AiTextDelta/AiHeartbeat/AiStreamRetry 等)
&session_arc, &db, &child_conv_id, // 经 route_scope 从子会话改挂父会话推送,父会话视图实时可见(终态/状态机事件不改写)。
&messages, &tool_defs, &app_handle, match tokio::time::timeout(stream_budget, route_scope(
&child_stop, &child_notify, child_conv_id.clone(), parent_conv_id.clone(),
DEFAULT_MAX_AGENT_RETRIES, &model_override, &agentic_req, estimated_prompt, stream_round(
&mut last_round_estimated, &mut last_reasoning_content, &session_arc, &db, &child_conv_id,
&mut resolved_model, &mut provider_config, &mut provider_saturation, &messages, &tool_defs, &app_handle,
&mut tokens, &mut saved_token_snapshot, &mut guard, &child_stop, &child_notify,
&candidates, &llm_concurrency, DEFAULT_MAX_AGENT_RETRIES, &model_override, &agentic_req, estimated_prompt,
// 子会话无目标钉扎,空快照(该参数仅供主 loop AiCompleted 快照透传) &mut last_round_estimated, &mut last_reasoning_content,
&[], &mut resolved_model, &mut provider_config, &mut provider_saturation,
&child_epoch, my_epoch, &mut tokens, &mut saved_token_snapshot, &mut guard,
&candidates, &llm_concurrency,
// 子会话无目标钉扎,空快照(该参数仅供主 loop AiCompleted 快照透传)
&[],
&child_epoch, my_epoch,
),
)).await { )).await {
Ok(RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated, truncated_by_max_tokens }) => { Ok(RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated, truncated_by_max_tokens }) => {
rounds_done += 1; rounds_done += 1;
@@ -868,11 +1027,16 @@ async fn run_sub_agent_loop_inner(
.values() .values()
.map(|d| (d.id.clone(), d.name.clone())) .map(|d| (d.id.clone(), d.name.clone()))
.collect(); .collect();
let pending = process_tool_calls( // 工具事件(AiToolCallStarted/Completed/AiCommandOutput 等)同样改挂父会话,
&session_arc, tool_calls, &tools_arc, &db, &app_handle, &child_conv_id, // 让父会话视图看到子代理的工具执行实时状态;子会话自身消息写入不受影响。
ToolExecPolicy::SubAgent, let pending = route_scope(
// P2-1 执行期白名单:Low 工具不在名单同样就地拒绝(layer=whitelist-clamp) child_conv_id.clone(), parent_conv_id.clone(),
Some(&allowed), 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; ).await;
// S2 报告协议:收集本轮被拒清单(deny 单点已写入结构化 envelope,此处只读解析) // S2 报告协议:收集本轮被拒清单(deny 单点已写入结构化 envelope,此处只读解析)
collect_rejected_tools(&session_arc, &child_conv_id, &round_calls, &mut rejected_tools).await; 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 文本附终止说明;再无则用原因 ── // ── 报告提取:收敛轮 full_text 优先;截断/失败取末条 assistant 文本附终止说明;再无则用原因 ──
let total_tokens = tokens_snapshot(&tokens).total_tokens as u64; let total_tokens = tokens_snapshot(&tokens).total_tokens as u64;
let summary = if !report.trim().is_empty() { let summary = if !report.trim().is_empty() {
// 父侧注入前超长截断复用现有 truncate_for_persist(头尾保留语义一致) // 父侧注入前超长截断:用子代理语境文案(非通用落库文案),阈值/头尾语义一致
truncate_for_persist(report.trim()) cap_report_for_parent(report.trim())
} else { } else {
match last_assistant_text(&session_arc, &child_conv_id).await { match last_assistant_text(&session_arc, &child_conv_id).await {
Some(text) => { Some(text) => {
@@ -961,7 +1125,7 @@ async fn run_sub_agent_loop_inner(
} else { } else {
format!("{}\n\n(子代理提前终止:{})", text, stop_reason) 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 if !stop_reason.is_empty() => format!("子代理未产出报告:{}", stop_reason),
None => "子代理未产出报告".to_string(), None => "子代理未产出报告".to_string(),
@@ -979,6 +1143,7 @@ async fn run_sub_agent_loop_inner(
status = status.as_str(), status = status.as_str(),
rounds = rounds_done, rounds = rounds_done,
tokens = total_tokens, tokens = total_tokens,
model = %resolved_model,
summary_len = summary.len(), summary_len = summary.len(),
"[sub-agent] 子代理结束" "[sub-agent] 子代理结束"
); );
@@ -991,6 +1156,8 @@ async fn run_sub_agent_loop_inner(
tokens: total_tokens, tokens: total_tokens,
rejected_tools: rejected, rejected_tools: rejected,
stop_kind, 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 级联注册移除 // 单元测试:白名单钳制 / max_rounds 熔断 / registry 级联注册移除
// ============================================================ // ============================================================
@@ -1329,7 +1589,7 @@ mod tests {
/// 同源工具纪律段(P1-1)。 /// 同源工具纪律段(P1-1)。
#[test] #[test]
fn sub_agent_prompt_mentions_report_contract() { 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("最终回复")); 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_MAX_TOTAL_TOKENS.to_string()), "token 上限注入");
assert!(p.contains(&SUB_AGENT_WALL_TIMEOUT_SECS.to_string()), "挂钟上限注入"); assert!(p.contains(&SUB_AGENT_WALL_TIMEOUT_SECS.to_string()), "挂钟上限注入");
assert!(!p.contains("默认最多 6 轮"), "硬编码缺省文案应已移除: {}", p); assert!(!p.contains("默认最多 6 轮"), "硬编码缺省文案应已移除: {}", p);
// 环境画像(日期)与父会话同源注入:子代理无法自行获取日期
assert!(p.contains("当前日期"), "环境画像日期须注入: {}", p);
// 报告约定:文件路径原样保留
assert!(p.contains("文件路径"), "报告约定须含文件路径原样保留: {}", p);
// 父设小预算时按真实值注入(防按 6 轮规划被截断) // 父设小预算时按真实值注入(防按 6 轮规划被截断)
assert!(sub_agent_system_prompt(3).contains("最多 3 轮")); assert!(sub_agent_system_prompt(3, "zh-CN").contains("最多 3 轮"));
// P1-1 工具纪律段同源拼接 // P1-1 工具纪律段同源拼接
assert!(p.contains("read_symbol"), "read_symbol 优先指引须注入: {}", p); assert!(p.contains("read_symbol"), "read_symbol 优先指引须注入: {}", p);
assert!(p.contains("重复调用检测"), "重复调用检测须注入"); assert!(p.contains("重复调用检测"), "重复调用检测须注入");
@@ -1348,6 +1612,37 @@ mod tests {
assert!(p.contains("get_app_config"), "查配置正解须注入"); assert!(p.contains("get_app_config"), "查配置正解须注入");
assert!(p.contains("失败 2 次必换方案"), "render=true 铁律须注入"); assert!(p.contains("失败 2 次必换方案"), "render=true 铁律须注入");
assert!(!p.contains("要我帮你装吗"), "obscura 交互条款不进子代理"); 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 字符串(事件面消费契约)。 /// P2-4 停止类别枚举序列化为 snake_case 字符串(事件面消费契约)。
@@ -1436,4 +1731,39 @@ mod tests {
vec!["write_file×3".to_string(), "run_command×1".to_string()] 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()), conversation_id: Some(conv_id.to_string()),
}; };
emit_ai_chat_event(&app_handle, ev); emit_ai_chat_event(&app_handle, ev);
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"), current_message_id).await; 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; return;
} }
@@ -224,7 +224,7 @@ pub(super) async fn insert_pending_approval(
"等待工具审批", "等待工具审批",
&format!("工具 {} 已暂停,批准后继续执行", draft.name), &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`):查审计表推算同 (会话, 工具, 参数) 重试计数。 /// (容错/恢复,开关 `df-ai-approval-retry`):查审计表推算同 (会话, 工具, 参数) 重试计数。
@@ -366,7 +366,7 @@ pub(super) async fn handle_approval_tool(
}; };
emit_ai_chat_event(&app_handle, ev); emit_ai_chat_event(&app_handle, ev);
// 审计:去重命中记一条(status 透传缓存来源 completed/rejected/failed,;decided_by=auto_dedup),不进 pending // 审计:去重命中记一条(status 透传缓存来源 completed/rejected/failed,;decided_by=auto_dedup),不进 pending
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(warned_content), Some("auto_dedup"), current_message_id).await; audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(warned_content), Some("auto_dedup"), current_message_id, None).await;
return; return;
} }
} }
+14 -2
View File
@@ -4,6 +4,7 @@
//! re-export 经 audit/mod.rs `pub(crate) use finalize::audit_finalize;` 保持 //! re-export 经 audit/mod.rs `pub(crate) use finalize::audit_finalize;` 保持
//! `commands::ai::audit::*` 路径透明(调用方零变更)。 //! `commands::ai::audit::*` 路径透明(调用方零变更)。
use crate::commands::ai::tool_result_offload;
use crate::commands::now_millis; use crate::commands::now_millis;
use crate::state::AppState; use crate::state::AppState;
@@ -12,7 +13,13 @@ use crate::state::AppState;
/// 走专用 find_by_tool_call_id —— 通用 query 宏硬编码 ORDER BY created_at DESC, /// 走专用 find_by_tool_call_id —— 通用 query 宏硬编码 ORDER BY created_at DESC,
/// 而 ai_tool_executions 无该列,调用会报 "no such column: created_at" 被 unwrap_or_default 吞掉, /// 而 ai_tool_executions 无该列,调用会报 "no such column: created_at" 被 unwrap_or_default 吞掉,
/// 导致审批后审计记录永久卡 pending。 /// 导致审批后审计记录永久卡 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, // 区分 Err(DB 故障)与 Ok(None)(真无记录):原 unwrap_or_default 把 Err 压成 None,
// DB 故障被「未找到」日志掩盖,审批后审计记录卡 pending 无确诊线索。 // DB 故障被「未找到」日志掩盖,审批后审计记录卡 pending 无确诊线索。
let mut rec = match state.ai_tool_executions.find_by_tool_call_id(tool_call_id).await { 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.status = status.to_string();
rec.decided_by = Some("human".to_string()); rec.decided_by = Some("human".to_string());
rec.executed_at = Some(now_millis()); 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 { 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 { if let Err(e) = state.ai_tool_executions.update_full(&rec).await {
tracing::error!("audit_finalize: 回填 tool_call_id={} 审计状态失败: {}", tool_call_id, e); 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 crate::state::{SettingsNorm, log_slow_lock};
use super::{AiChatEvent, AiSession, ApprovalKind, PathAuthRequest, PendingApproval, ToolCallDraft}; use super::{AiChatEvent, AiSession, ApprovalKind, PathAuthRequest, PendingApproval, ToolCallDraft};
use super::tools::entity_resolve; use super::tools::entity_resolve;
use crate::commands::ai::is_destructive_command;
// utils(audit/utils.rs):RiskLevel ↔ 字符串转换 + 字符串安全截断纯 helper。 // utils(audit/utils.rs):RiskLevel ↔ 字符串转换 + 字符串安全截断纯 helper。
// 第五批从本文件抽离,行为零变更。pub(crate) use 保持 finalize / restore 子模块 // 第五批从本文件抽离,行为零变更。pub(crate) use 保持 finalize / restore 子模块
@@ -163,7 +164,7 @@ async fn deny_sub_agent_tool(
}; };
emit_ai_chat_event(app_handle, ev); emit_ai_chat_event(app_handle, ev);
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"rejected", risk_level, Some(msg), Some("sub_agent_denied"), current_message_id).await; "rejected", risk_level, Some(msg), Some("sub_agent_denied"), current_message_id, None).await;
} }
/// 任务边界拒绝单点(无人值守,设计 §三):结构化失败 tool_result + emit Completed + 审计留痕。 /// 任务边界拒绝单点(无人值守,设计 §三):结构化失败 tool_result + emit Completed + 审计留痕。
@@ -211,7 +212,7 @@ async fn deny_boundary_tool(
}; };
emit_ai_chat_event(app_handle, ev); emit_ai_chat_event(app_handle, ev);
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"rejected", risk_level, Some(msg), Some(layer), current_message_id).await; "rejected", risk_level, Some(msg), Some(layer), current_message_id, None).await;
} }
/// P2-1 执行期白名单判定(纯函数可单测):SubAgent 策略下工具是否被白名单裁掉。 /// P2-1 执行期白名单判定(纯函数可单测):SubAgent 策略下工具是否被白名单裁掉。
@@ -253,6 +254,23 @@ pub(crate) async fn execute_with_heartbeat(
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use tokio::time::Duration; 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 感知租约:写类文件工具执行前置校验(跨会话互斥,治互踩三连) ── // ── W-h1 感知租约:写类文件工具执行前置校验(跨会话互斥,治互踩三连) ──
// 写类(write_file/patch_file/append_file/delete_file/rename_file 等:非 readonly // 写类(write_file/patch_file/append_file/delete_file/rename_file 等:非 readonly
// 且有 path_args)执行前 acquire;任一路径被**其他会话**占用 → 立即返错(拒绝文案 // 且有 path_args)执行前 acquire;任一路径被**其他会话**占用 → 立即返错(拒绝文案
@@ -411,7 +429,7 @@ pub(crate) async fn execute_with_heartbeat(
// 三路 select:exec 正常完成 / outer_secs 兜底超时(既有语义,drop exec 腰斩) / // 三路 select:exec 正常完成 / outer_secs 兜底超时(既有语义,drop exec 腰斩) /
// 监视判死提前退出(同样 drop exec,防审批被删或会话已删后空转到超时上限)。 // 监视判死提前退出(同样 drop exec,防审批被删或会话已删后空转到超时上限)。
// exec 先完成时 select 丢弃 abort_watch,监视零泄漏(非 spawn,随 select 作用域消亡)。 // exec 先完成时 select 丢弃 abort_watch,监视零泄漏(非 spawn,随 select 作用域消亡)。
tokio::select! { let exec_result = tokio::select! {
result = &mut exec_fut => result, result = &mut exec_fut => result,
_ = tokio::time::sleep(Duration::from_secs(outer_secs)) => { _ = tokio::time::sleep(Duration::from_secs(outer_secs)) => {
tracing::error!( tracing::error!(
@@ -432,7 +450,17 @@ pub(crate) async fn execute_with_heartbeat(
); );
Err(anyhow::anyhow!("工具 {} 提前中止: {}", name, reason)) 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, tc_id,
"rejected", "rejected",
Some(format!("审批超时({}分钟)自动取消", timeout_minutes)), Some(format!("审批超时({}分钟)自动取消", timeout_minutes)),
None,
) )
.await; .await;
// 5. 落库会话状态变更(占位消息替换后需持久化,防重启后丢失) // 5. 落库会话状态变更(占位消息替换后需持久化,防重启后丢失)
@@ -719,7 +748,7 @@ pub(crate) async fn process_tool_calls(
emit_ai_chat_event(&app_handle, ev); emit_ai_chat_event(&app_handle, ev);
let risk = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High); 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, 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_tool_call(
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"rejected", risk_level, Some(err_msg), Some("auto_blacklist"), "rejected", risk_level, Some(err_msg), Some("auto_blacklist"),
current_message_id, current_message_id, None,
).await; ).await;
} }
@@ -924,7 +953,7 @@ pub(crate) async fn process_tool_calls(
audit_tool_call( audit_tool_call(
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"), "skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"),
current_message_id, current_message_id, None,
).await; ).await;
continue; continue;
} }
@@ -963,7 +992,7 @@ pub(crate) async fn process_tool_calls(
audit_tool_call( audit_tool_call(
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"pending", risk_level, None, None, "pending", risk_level, None, None,
current_message_id, current_message_id, None,
).await; ).await;
} }
@@ -1032,8 +1061,27 @@ pub(crate) async fn process_tool_calls(
// 黑名单恒拒不在此层——Denied 分支在 path_auth 预校验先行硬拒,边界永不参与。 // 黑名单恒拒不在此层——Denied 分支在 path_auth 预校验先行硬拒,边界永不参与。
if !is_sub_agent { if !is_sub_agent {
if let Some(b) = &boundary { 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)); boundary_auto.push((draft, args, risk_level));
continue;
} else { } else {
let reason = if risk_level == RiskLevel::High { let reason = if risk_level == RiskLevel::High {
"工具风险 High 超出任务风险上限".to_string() "工具风险 High 超出任务风险上限".to_string()
@@ -1044,8 +1092,8 @@ pub(crate) async fn process_tool_calls(
session_arc, conv_id, &draft, tools_arc, &audit_repo, session_arc, conv_id, &draft, tools_arc, &audit_repo,
"boundary-risk", &reason, current_message_id, app_handle, "boundary-risk", &reason, current_message_id, app_handle,
).await; ).await;
continue;
} }
continue;
} }
} }
// AC-2 ③:run_command 目录列举绕行检测(最保守版,只警告不硬拒)。 // AC-2 ③:run_command 目录列举绕行检测(最保守版,只警告不硬拒)。
@@ -1079,13 +1127,16 @@ pub(crate) async fn process_tool_calls(
// 阻塞同会话 IPC);改 join_all 并行多工具减少总阻塞时间。P0 重构后锁外并行,根本消除阻塞。 // 阻塞同会话 IPC);改 join_all 并行多工具减少总阻塞时间。P0 重构后锁外并行,根本消除阻塞。
// join_all 保序——结果顺序 = trust_hits 输入顺序 = tc_list 原始 index 顺序,不额外 sort // join_all 保序——结果顺序 = trust_hits 输入顺序 = tc_list 原始 index 顺序,不额外 sort
if !trust_hits.is_empty() { 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)| { futures::future::join_all(trust_hits.into_iter().map(|(draft, args, _dir_label, risk_level)| {
let tools = tools_arc.clone(); let tools = tools_arc.clone();
let app_clone = app_handle.clone(); let app_clone = app_handle.clone();
let conv_clone = conv_id.to_string(); let conv_clone = conv_id.to_string();
async move { 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_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 { match exec_result {
Ok(val) => { Ok(val) => {
let content = val.to_string(); let content = val.to_string();
@@ -1099,7 +1150,7 @@ pub(crate) async fn process_tool_calls(
// 数据变更类工具执行成功后 emit df-data-changed 联动刷新 // 数据变更类工具执行成功后 emit df-data-changed 联动刷新
// (write_file/run_command 不在 data_change_for_tool 映射内,emit_data_changed 内部 None 即 noop) // (write_file/run_command 不在 data_change_for_tool 映射内,emit_data_changed 内部 None 即 noop)
emit_data_changed(&app_clone, &draft.name); emit_data_changed(&app_clone, &draft.name);
(draft, risk_level, Ok(content)) (draft, risk_level, Ok(content), exec_ms)
} }
Err(e) => { Err(e) => {
let content = e.to_string(); let content = e.to_string();
@@ -1110,7 +1161,7 @@ pub(crate) async fn process_tool_calls(
conversation_id: Some(conv_clone), conversation_id: Some(conv_clone),
}; };
emit_ai_chat_event(&app_clone, ev); 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) // 串行回填 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 { let (status, content) = match outcome {
Ok(c) => ("completed", c), Ok(c) => ("completed", c),
Err(c) => ("failed", c), Err(c) => ("failed", c),
@@ -1146,7 +1197,7 @@ pub(crate) async fn process_tool_calls(
} }
} }
// 锁外:审计(trust 放行仍记一条,decided_by=auto_trust),留痕可追溯 // 锁外:审计(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_tool_call(
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"completed", risk_level, Some(warned_content), Some("cache_hit"), "completed", risk_level, Some(warned_content), Some("cache_hit"),
current_message_id, current_message_id, None,
).await; ).await;
tracing::info!( tracing::info!(
conv_id = %conv_id, conv_id = %conv_id,
@@ -1225,13 +1276,16 @@ pub(crate) async fn process_tool_calls(
} }
let low_risk = low_risk_uncached; let low_risk = low_risk_uncached;
// 携带 args + 原始 JSON result(create_idea source 补全需解析 result.id + args.source)。 // 携带 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)| { futures::future::join_all(low_risk.into_iter().map(|(draft, args, risk_level)| {
let tools = tools_arc.clone(); let tools = tools_arc.clone();
let app_clone = app_handle.clone(); let app_clone = app_handle.clone();
let conv_clone = conv_id.to_string(); let conv_clone = conv_id.to_string();
async move { 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 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 { match result {
Ok(val) => { Ok(val) => {
// L3 emit 双写:Low 风险工具执行成功 emit Completed 双路发布。 // L3 emit 双写:Low 风险工具执行成功 emit Completed 双路发布。
@@ -1244,7 +1298,7 @@ pub(crate) async fn process_tool_calls(
// AR-11(方案A):数据变更工具执行成功后 emit df-data-changed, // AR-11(方案A):数据变更工具执行成功后 emit df-data-changed,
// 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。 // 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。
emit_data_changed(&app_clone, &draft.name); 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) => { Err(e) => {
// AR-6(定向B):Low 工具失败不 emit AiError。 // AR-6(定向B):Low 工具失败不 emit AiError。
@@ -1260,7 +1314,7 @@ pub(crate) async fn process_tool_calls(
conversation_id: Some(conv_clone), conversation_id: Some(conv_clone),
}; };
emit_ai_chat_event(&app_clone, ev); 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)。审计失败不阻断主流程(记日志)。 // 砍 N 次串行 INSERT 尾巴(治 aichat 效率走查 T1-1)。审计失败不阻断主流程(记日志)。
let mut audit_records: Vec<df_storage::models::AiToolExecutionRecord> = let mut audit_records: Vec<df_storage::models::AiToolExecutionRecord> =
Vec::with_capacity(results.len()); 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 { let (status, content) = match outcome {
Ok(c) => ("completed", c), Ok(c) => ("completed", c),
Err(c) => ("failed", c), Err(c) => ("failed", c),
@@ -1323,7 +1377,7 @@ pub(crate) async fn process_tool_calls(
} }
audit_records.push(build_audit_record( audit_records.push(build_audit_record(
conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, 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 次串行尾巴;失败仅记日志不阻断)。 // 单事务批量 INSERT(一次 spawn_blocking,砍 N 次串行尾巴;失败仅记日志不阻断)。
@@ -1375,7 +1429,7 @@ pub(crate) async fn process_tool_calls(
dupe_audit_records.push(build_audit_record( dupe_audit_records.push(build_audit_record(
conv_id, &draft.id, &draft.name, &draft.args, conv_id, &draft.id, &draft.name, &draft.args,
"completed", risk_level, Some(warned_content), Some("cache_hit"), "completed", risk_level, Some(warned_content), Some("cache_hit"),
current_message_id, current_message_id, None,
)); ));
} }
if !dupe_audit_records.is_empty() { if !dupe_audit_records.is_empty() {
@@ -1403,13 +1457,17 @@ pub(crate) async fn process_tool_calls(
"[ai] 任务边界放行 {} 个工具进入并行执行(decided_by=boundary_auto)", "[ai] 任务边界放行 {} 个工具进入并行执行(decided_by=boundary_auto)",
boundary_auto.len() 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)| { futures::future::join_all(boundary_auto.into_iter().map(|(draft, args, risk_level)| {
let tools = tools_arc.clone(); let tools = tools_arc.clone();
let app_clone = app_handle.clone(); let app_clone = app_handle.clone();
let conv_clone = conv_id.to_string(); let conv_clone = conv_id.to_string();
async move { 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) => { Ok(val) => {
// L3 emit 双写:边界放行工具执行成功 emit Completed 双路发布。 // L3 emit 双写:边界放行工具执行成功 emit Completed 双路发布。
let ev = AiChatEvent::AiToolCallCompleted { let ev = AiChatEvent::AiToolCallCompleted {
@@ -1419,7 +1477,7 @@ pub(crate) async fn process_tool_calls(
}; };
emit_ai_chat_event(&app_clone, ev); emit_ai_chat_event(&app_clone, ev);
emit_data_changed(&app_clone, &draft.name); 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) => { Err(e) => {
// AR-6 同款:失败包进 tool_result 让 LLM 自纠,不 emit AiError 不断轮。 // AR-6 同款:失败包进 tool_result 让 LLM 自纠,不 emit AiError 不断轮。
@@ -1430,13 +1488,13 @@ pub(crate) async fn process_tool_calls(
conversation_id: Some(conv_clone), conversation_id: Some(conv_clone),
}; };
emit_ai_chat_event(&app_clone, ev); emit_ai_chat_event(&app_clone, ev);
(draft, risk_level, Err(err_msg)) (draft, risk_level, Err(err_msg), exec_ms)
} }
} }
} }
})).await; })).await;
for (draft, risk_level, outcome) in results { for (draft, risk_level, outcome, exec_ms) in results {
let (status, content) = match outcome { let (status, content) = match outcome {
Ok(c) => ("completed", c), Ok(c) => ("completed", c),
Err(c) => ("failed", c), Err(c) => ("failed", c),
@@ -1460,7 +1518,7 @@ pub(crate) async fn process_tool_calls(
// 锁外审计:risk 真实等级透传(F-#97 防失真),decided_by=boundary_auto // 锁外审计:risk 真实等级透传(F-#97 防失真),decided_by=boundary_auto
// 标注授权来自任务边界(无人值守事后可全量追溯)。 // 标注授权来自任务边界(无人值守事后可全量追溯)。
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
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>, result: Option<String>,
decided_by: Option<&str>, decided_by: Option<&str>,
message_id: Option<&str>, message_id: Option<&str>,
duration_ms: Option<i64>,
) -> AiToolExecutionRecord { ) -> AiToolExecutionRecord {
let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None }; let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None };
// D2a 结果外置:超 8KB 的审计 result 落盘留句柄,治 DB 膨胀(devflow.db 193.7MB 实证)。 // D2a 结果外置:超 8KB 的审计 result 落盘留句柄,治 DB 膨胀(devflow.db 193.7MB 实证)。
@@ -66,6 +67,10 @@ pub(crate) fn build_audit_record(
requested_at: now_millis(), requested_at: now_millis(),
executed_at, executed_at,
decided_by: decided_by.map(|s| s.to_string()), 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>, result: Option<String>,
decided_by: Option<&str>, decided_by: Option<&str>,
message_id: Option<&str>, message_id: Option<&str>,
duration_ms: Option<i64>,
) { ) {
let record = build_audit_record( let record = build_audit_record(
conv_id, tool_call_id, tool_name, arguments, 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 { if let Err(e) = repo.insert(record).await {
tracing::error!( tracing::error!(
@@ -119,7 +125,8 @@ pub(crate) async fn record_audit(
decided_by: Option<&str>, decided_by: Option<&str>,
message_id: 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 倒序)。 /// 查询审计历史记录(分页,按 requested_at 倒序)。
@@ -159,6 +166,10 @@ pub struct ToolExecutionDto {
pub executed_at: Option<String>, pub executed_at: Option<String>,
/// human/auto,None 表示尚未决策 /// human/auto,None 表示尚未决策
pub decided_by: Option<String>, 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, requested_at: r.requested_at,
executed_at: r.executed_at, executed_at: r.executed_at,
decided_by: r.decided_by, decided_by: r.decided_by,
duration_ms: r.duration_ms,
}) })
.collect(); .collect();
let has_more = (offset as i64 + items.len() as i64) < total; 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)) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -512,6 +534,7 @@ mod tests {
requested_at: t.to_string(), requested_at: t.to_string(),
executed_at: None, executed_at: None,
decided_by: None, decided_by: None,
duration_ms: None,
}) })
.await .await
.expect("测试数据插入应成功"); .expect("测试数据插入应成功");
@@ -581,6 +604,7 @@ mod tests {
requested_at: t.to_string(), requested_at: t.to_string(),
executed_at: Some(t.to_string()), executed_at: Some(t.to_string()),
decided_by: Some("auto".to_string()), decided_by: Some("auto".to_string()),
duration_ms: None,
}) })
.await .await
.expect("测试数据插入应成功"); .expect("测试数据插入应成功");
@@ -608,6 +632,7 @@ mod tests {
origin: origin.map(|s| s.to_string()), origin: origin.map(|s| s.to_string()),
boundary_json: None, boundary_json: None,
loop_state: None, loop_state: None,
status: "active".to_string(),
}) })
.await .await
.expect("测试会话插入应成功"); .expect("测试会话插入应成功");
+104 -26
View File
@@ -27,7 +27,7 @@ use crate::state::{AppState, log_slow_lock};
use crate::commands::{err_str, now_millis}; use crate::commands::{err_str, now_millis};
// chat.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。 // 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; use super::super::event_bus::emit_ai_chat_event;
#[cfg(test)] #[cfg(test)]
use super::super::agentic::GOAL_MAX_CHARS; use super::super::agentic::GOAL_MAX_CHARS;
@@ -695,6 +695,7 @@ pub async fn ai_chat_send(
parts, parts,
mention_spans, mention_spans,
true, true,
None, // #958:标题/内部路径无外部来源
) )
.await .await
.map(|_| "ok".to_string()) .map(|_| "ok".to_string())
@@ -772,6 +773,8 @@ async fn reply_status_summary(
prompt_cache_miss_tokens: 0, prompt_cache_miss_tokens: 0,
reasoning_tokens: 0, reasoning_tokens: 0,
is_estimated: false, is_estimated: false,
// 零 token 收敛信号,非截断(看板 953;枚举新字段,Rust 字面量强制显式赋值)。
truncated_by_max_tokens: false,
incomplete: None, incomplete: None,
incomplete_reason: None, incomplete_reason: None,
conversation_id: Some(target), conversation_id: Some(target),
@@ -804,6 +807,8 @@ pub(crate) async fn spawn_agentic_session(
parts: Option<Vec<ContentPart>>, parts: Option<Vec<ContentPart>>,
mention_spans: Option<Vec<MentionSpanDto>>, mention_spans: Option<Vec<MentionSpanDto>>,
pin_active: bool, pin_active: bool,
// #958 溯源:外部渠道(飞书等)原始消息定位,None=桌面本端。随 user 消息落库
source: Option<df_ai::provider::MessageSource>,
) -> Result<String, String> { ) -> Result<String, String> {
// 获取活跃提供商(只读,失败可直接返回,不影响生成标志) // 获取活跃提供商(只读,失败可直接返回,不影响生成标志)
let provider_config = super::super::prompt::get_active_provider(state).await?; let provider_config = super::super::prompt::get_active_provider(state).await?;
@@ -820,6 +825,19 @@ pub(crate) async fn spawn_agentic_session(
other => (other, false), 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 调用)。此处直接拒绝发送,错误经前端 // 会被当 user 消息喂模型(回复跑偏且浪费一轮 LLM 调用)。此处直接拒绝发送,错误经前端
// 发送失败通道透出;图文混合(有文本/技能)维持占位后缀现状。置于锁与消息 push 之前, // 发送失败通道透出;图文混合(有文本/技能)维持占位后缀现状。置于锁与消息 push 之前,
@@ -866,6 +884,17 @@ pub(crate) async fn spawn_agentic_session(
return Err("AI 正在生成中,请等待完成".to_string()); return Err("AI 正在生成中,请等待完成".to_string());
} }
let conv = session.conv(&target); 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 入口迁移设置(不再手动赋值)。 // 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口迁移设置(不再手动赋值)。
conv.stop_flag.store(false, Ordering::SeqCst); conv.stop_flag.store(false, Ordering::SeqCst);
conv.agent_language = language.clone(); 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()(纯文本零回归)。 // F-260614-05 Phase 2c: parts 非空走 user_parts(多模态),空/None 走 user()(纯文本零回归)。
// user_parts 内部把 parts 挂到 ChatMessage.parts,provider 在 has_image() 为真时转 vision 端点。 // user_parts 内部把 parts 挂到 ChatMessage.parts,provider 在 has_image() 为真时转 vision 端点。
if let Some(ps) = parts.as_ref().filter(|p| !p.is_empty()) { 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 { } 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 同量级)。 // 新用户轮把本请求钉扎为 WorkingContext 常驻目标(截断 200 字符,与 agentic G1 目标钉扎 GOAL_MAX_CHARS 同量级)。
// 喂原文 message 而非落库变换文本 user_content:begin_turn 契约是"本条用户请求", // 喂原文 message 而非落库变换文本 user_content:begin_turn 契约是"本条用户请求",
@@ -1094,6 +1127,7 @@ pub(crate) fn orchestration_session_spawner() -> df_nodes::ai_agent_node::SpawnF
None, None,
None, None,
false, false,
None, // #958:编排路径无外部来源
) )
.await .await
{ {
@@ -1256,7 +1290,7 @@ pub async fn ai_approve(
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await; save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
} }
// 审计:拒绝(决策者=human) // 审计:拒绝(决策者=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 透传 // F-260616-11 决策 a: 审批续跑 iteration 累计(不重置)——读 per_conv.iteration_used 透传
// 作 start_iteration,防多次审批反复跑满 max_iterations 致 token 失控。 // 作 start_iteration,防多次审批反复跑满 max_iterations 致 token 失控。
// F-09 B 批4:per_conv 唯一真相源(conv_id 来源 approval.conversation_id);无 conv_id 时 0。 // 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 终态 // 仅分支 run_workflow,其余工具审批路径完全不变(含 AE-04 trust_key_for 写 trust / audit 终态
// 回填 / emit_data_changed / save / try_continue)。workflow 联动任务推进由 run_workflow 内部 // 回填 / emit_data_changed / save / try_continue)。workflow 联动任务推进由 run_workflow 内部
// 按 task_id+target_status 自动处理(workflow.rs:270-308),此处不重复推进。 // 按 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" { let exec_result: anyhow::Result<serde_json::Value> = if approval.tool_name == "run_workflow" {
super::super::execute_run_workflow_for_tool(&app, &state, &args, conv_id.as_deref()).await super::super::execute_run_workflow_for_tool(&app, &state, &args, conv_id.as_deref()).await
} else { } else {
@@ -1329,6 +1365,7 @@ pub async fn ai_approve(
&app, conv_id.as_deref().unwrap_or_default(), &id, &app, conv_id.as_deref().unwrap_or_default(), &id,
).await ).await
}; };
let exec_ms = exec_started.elapsed().as_millis() as i64;
// 工具失败不 return Err:把错误包成 tool_result,落库 + emit completed + 续循环全走通。 // 工具失败不 return Err:把错误包成 tool_result,落库 + emit completed + 续循环全走通。
// 否则前端 approveToolCall 的 catch 会回滚 pending_approval,审批按钮卡死无法消除。 // 否则前端 approveToolCall 的 catch 会回滚 pending_approval,审批按钮卡死无法消除。
let (audit_status, result_val) = match &exec_result { 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())), Err(e) => ("failed", serde_json::Value::String(e.to_string())),
}; };
// 审计:人工审批后无论成败回填(决策者=human) // 审计:人工审批后无论成败回填(决策者=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, // AR-11(方案A):人工审批通过且工具执行成功(非 failed)后 emit df-data-changed,
// 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。 // 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。
@@ -1607,7 +1644,7 @@ pub async fn ai_authorize_dir(
conversation_id: conv_id.clone(), conversation_id: conv_id.clone(),
}; };
emit_ai_chat_event(&app, ev_approval); 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 { if let Some(ref cid) = conv_id {
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await; 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 前"); tracing::debug!(tool = %approval.tool_name, "[AI-AUTHZ] execute 前");
// 防工具卡死:超时兜底已收敛进 execute_with_heartbeat 的 outer_secs 分档, // 防工具卡死:超时兜底已收敛进 execute_with_heartbeat 的 outer_secs 分档,
// execute 不返回时超时返 Err → 走 failed 分支 finalize+emit+try_continue,IPC 必返回不卡。 // 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" { let exec_result: anyhow::Result<serde_json::Value> = if approval.tool_name == "run_workflow" {
super::super::execute_run_workflow_for_tool(&app, &state, &args, conv_id.as_deref()).await super::super::execute_run_workflow_for_tool(&app, &state, &args, conv_id.as_deref()).await
} else { } else {
@@ -1668,6 +1707,7 @@ pub async fn ai_authorize_dir(
&app, conv_id.as_deref().unwrap_or_default(), &tool_call_id, &app, conv_id.as_deref().unwrap_or_default(), &tool_call_id,
).await ).await
}; };
let exec_ms = exec_started.elapsed().as_millis() as i64;
tracing::debug!( tracing::debug!(
ok = exec_result.is_ok(), ok = exec_result.is_ok(),
status = if exec_result.is_ok() { "completed" } else { "failed" }, 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) { if let Some(note) = cross_drive_hint(&approval.tool_name, &req.raw_paths) {
result_val = append_cross_drive_note(result_val, &note); 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() { if exec_result.is_ok() {
emit_data_changed(&app, &approval.tool_name); emit_data_changed(&app, &approval.tool_name);
// MED-1(审查): path 绑定工具(Medium)审批执行后 reload 白名单。 // MED-1(审查): path 绑定工具(Medium)审批执行后 reload 白名单。
@@ -2250,6 +2290,16 @@ pub async fn ai_chat_force_send(
other => (other, false), 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 的拒绝闸(占位串不喂模型), // 纯发图(无文本无技能)且模型非视觉:同 spawn_agentic_session 的拒绝闸(占位串不喂模型),
// 置于锁与消息 push 之前,拒绝零副作用。 // 置于锁与消息 push 之前,拒绝零副作用。
if degraded && message.trim().is_empty() && skill.is_none() { 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); 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 注入 + 知识注入顺序一致。 // system prompt 构建(照 ai_chat_send):augmentation 注入 + 知识注入顺序一致。
let _tool_defs = state.ai_tools.tool_definitions(); let _tool_defs = state.ai_tools.tool_definitions();
let lang = language.unwrap_or_else(|| "zh-CN".to_string()); 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()) 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 { 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; 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()) 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, prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0,
// 零 token 收敛信号,非估算(无真实 LLM 调用)。 // 零 token 收敛信号,非估算(无真实 LLM 调用)。
is_estimated: false, is_estimated: false,
// 零 token 收敛信号,非截断(看板 953;枚举新字段,Rust 字面量强制显式赋值)。
truncated_by_max_tokens: false,
incomplete: None, incomplete: None,
incomplete_reason: None, incomplete_reason: None,
conversation_id: Some(target.clone()), conversation_id: Some(target.clone()),
@@ -2649,6 +2723,8 @@ pub async fn ai_chat_stop(
reasoning_tokens: 0, reasoning_tokens: 0,
// 零 token 收敛信号,非估算(无真实 LLM 调用)。 // 零 token 收敛信号,非估算(无真实 LLM 调用)。
is_estimated: false, is_estimated: false,
// 零 token 收敛信号,非截断(看板 953;枚举新字段,Rust 字面量强制显式赋值)。
truncated_by_max_tokens: false,
incomplete: None, incomplete: None,
incomplete_reason: None, incomplete_reason: None,
conversation_id: conv_id.clone(), conversation_id: conv_id.clone(),
@@ -2847,6 +2923,8 @@ pub async fn ai_stop_loop(
reasoning_tokens: 0, reasoning_tokens: 0,
// 零 token 收敛信号,非估算(无真实 LLM 调用)。 // 零 token 收敛信号,非估算(无真实 LLM 调用)。
is_estimated: false, is_estimated: false,
// 零 token 收敛信号,非截断(看板 953;枚举新字段,Rust 字面量强制显式赋值)。
truncated_by_max_tokens: false,
incomplete: None, incomplete: None,
incomplete_reason: None, incomplete_reason: None,
conversation_id: Some(conversation_id), conversation_id: Some(conversation_id),
@@ -79,6 +79,11 @@ pub fn record_to_message(rec: &AiMessageRecord) -> ChatMessage {
.as_deref() .as_deref()
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.and_then(|s| serde_json::from_str(s).ok()); .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 let tool_calls = rec
.tool_calls .tool_calls
.as_deref() .as_deref()
@@ -107,6 +112,7 @@ pub fn record_to_message(rec: &AiMessageRecord) -> ChatMessage {
prompt_cache_miss_tokens: rec.prompt_cache_miss_tokens, prompt_cache_miss_tokens: rec.prompt_cache_miss_tokens,
reasoning_tokens: rec.reasoning_tokens, reasoning_tokens: rec.reasoning_tokens,
is_estimated: rec.is_estimated, is_estimated: rec.is_estimated,
source,
} }
} }
@@ -137,6 +143,10 @@ pub fn message_to_record(
.as_ref() .as_ref()
.map(|s| s.as_db_str().to_string()) .map(|s| s.as_db_str().to_string())
.unwrap_or_else(|| "active".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 { AiMessageRecord {
id, id,
conversation_id: conversation_id.to_string(), conversation_id: conversation_id.to_string(),
@@ -157,6 +167,7 @@ pub fn message_to_record(
prompt_cache_miss_tokens: msg.prompt_cache_miss_tokens, prompt_cache_miss_tokens: msg.prompt_cache_miss_tokens,
reasoning_tokens: msg.reasoning_tokens, reasoning_tokens: msg.reasoning_tokens,
is_estimated: msg.is_estimated, is_estimated: msg.is_estimated,
source,
} }
} }
@@ -348,6 +359,11 @@ pub(crate) async fn recover_parked_inner(
return Err("该会话正在生成中,无需恢复".to_string()); return Err("该会话正在生成中,无需恢复".to_string());
} }
let message = PARKED_RECOVERY_MESSAGE.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( spawn_agentic_session(
app, app,
state, state,
@@ -359,6 +375,7 @@ pub(crate) async fn recover_parked_inner(
None, // parts:纯文本 None, // parts:纯文本
None, // mention_spans None, // mention_spans
true, // pin_active:恢复动作即用户当前意图 true, // pin_active:恢复动作即用户当前意图
None, // #958:恢复路径无外部来源
) )
.await .await
} }
@@ -917,6 +934,8 @@ pub async fn ai_conversation_delete(
let zero_usage = df_ai::provider::TokenUsage::default(); let zero_usage = df_ai::provider::TokenUsage::default();
crate::commands::ai::agentic::emit_ai_completed_once( crate::commands::ai::agentic::emit_ai_completed_once(
&app, &conversation_id, &zero_usage, false, &app, &conversation_id, &zero_usage, false,
// 会话删除终态补发,非截断(看板 953)。
false,
Some(true), None, Some("deleted"), true, Some(true), None, Some("deleted"), true,
&[], false, &[], false,
).await; ).await;
@@ -1171,6 +1190,7 @@ mod tests {
id: Some("msg_test_1".into()), id: Some("msg_test_1".into()),
role: MessageRole::User, role: MessageRole::User,
content: "你好".into(), content: "你好".into(),
source: None,
parts: None, parts: None,
tool_call_id: None, tool_call_id: None,
tool_calls: None, tool_calls: None,
@@ -1573,6 +1593,7 @@ mod tests {
origin: origin.map(|s| s.to_string()), origin: origin.map(|s| s.to_string()),
boundary_json: None, boundary_json: None,
loop_state: None, loop_state: None,
status: "active".to_string(),
}) })
.await .await
.expect("测试会话插入应成功"); .expect("测试会话插入应成功");
+92 -4
View File
@@ -14,7 +14,7 @@ use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
// 摘要任务,选 Standard 智力保摘要质量;不传 tools(纯文本出,避免 LLM 调工具跑偏)。 // 摘要任务,选 Standard 智力保摘要质量;不传 tools(纯文本出,避免 LLM 调工具跑偏)。
// 池空/无匹配兜底 default_model(与 title.rs 一致,行为可预期)。 // 池空/无匹配兜底 default_model(与 title.rs 一致,行为可预期)。
use df_ai::router::{ 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; 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); let active_msgs = df_ai::context::ContextManager::sanitize_messages(active_msgs);
// 路由选模型:无 tool_use(纯文本摘要)。 // 路由选模型:无 tool_use(纯文本摘要)。
// None(池空/无匹配)→ 兜底 default_model(行为不变)。 // None(池空/无匹配)→ 兜底 default_model,但须校验其 enabled(停用则回落池内启用模型,
// 见 resolve_fallback_model);池内无任何启用模型 → Err 让调用方降级,不再静默用停用模型。
let compress_req = TaskRequirements { let compress_req = TaskRequirements {
modalities: vec![Modality::Text], modalities: vec![Modality::Text],
needs_tool_use: false, needs_tool_use: false,
estimated_context: 0, estimated_context: 0,
tier: None, tier: None,
}; };
let model = select_model_id(&compress_req, &provider_config.model_configs) let model = match select_model_id(&compress_req, &provider_config.model_configs) {
.unwrap_or_else(|| provider_config.default_model.clone()); Some(m) => m,
None => {
resolve_fallback_model(&provider_config.model_configs, &provider_config.default_model)?
}
};
// 防御:选中/兜底模型是图像模型时返 Err(走关键词摘要兜底,避免文本任务 404 刷屏)。 // 防御:选中/兜底模型是图像模型时返 Err(走关键词摘要兜底,避免文本任务 404 刷屏)。
if is_likely_image_model(&model, &provider_config.model_configs) { if is_likely_image_model(&model, &provider_config.model_configs) {
return Err(format!( 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。 /// 阶段2(占位配对完整性):检测压缩段是否含未闭合占位 tool_result。
/// ///
/// 占位 tool_result(content 带 __PENDING__ 标记,审批挂起)若无对应 tool_call 头(头被裁/丢) /// 占位 tool_result(content 带 __PENDING__ 标记,审批挂起)若无对应 tool_call 头(头被裁/丢)
@@ -445,4 +490,47 @@ mod tests {
]; ];
assert!(!compress_segment_has_unclosed_placeholder(&msgs)); 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()), .filter(|s| !s.is_empty()),
// #10:懒创建继承 loop running(时序根治——spawn 先置标后落库的 UPDATE 0 行丢失) // #10:懒创建继承 loop running(时序根治——spawn 先置标后落库的 UPDATE 0 行丢失)
loop_state: if conv_is_active { Some("running".to_string()) } else { None }, loop_state: if conv_is_active { Some("running".to_string()) } else { None },
// V50 终局态:新落库默认 active(进行中);终局由收尾路径 set_status 覆写。
status: "active".to_string(),
}; };
if let Err(e) = conv_repo.insert(rec).await { if let Err(e) = conv_repo.insert(rec).await {
tracing::warn!("落库对话失败 {conv_id}: {e}"); tracing::warn!("落库对话失败 {conv_id}: {e}");
@@ -575,17 +577,27 @@ async fn save_conversation_inner(
.advance_persisted_count(total_len, needs_full_rewrite); .advance_persisted_count(total_len, needs_full_rewrite);
} }
// 自动 checkpoint(每 20 轮或总 token > 150k 时创建) // 自动 checkpoint(每 20 条消息为一个快照点)
{ {
let total_tokens: i64 = persist_msgs.len() as i64; let msg_count: i64 = persist_msgs.len() as i64;
if total_tokens > 0 && total_tokens % 20 == 0 { if msg_count > 0 && msg_count % 20 == 0 {
let snapshot = serde_json::to_string(&persist_msgs).unwrap_or_default(); let snapshot = serde_json::to_string(&persist_msgs).unwrap_or_default();
if !snapshot.is_empty() && snapshot.len() < 1_000_000 { 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(); let ck_now = now_millis().to_string();
// 参数化绑定(走 AiConversationRepo::insert_checkpoint),替代原 format! 拼 SQL, // 参数化绑定(走 AiConversationRepo::insert_checkpoint),替代原 format! 拼 SQL,
// 防 snapshot 含引号/特殊字符致注入或损坏。 // 防 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}"); tracing::warn!("checkpoint 写入失败 {conv_id}: {e}");
} }
} }
@@ -776,6 +788,7 @@ mod tests {
id: None, id: None,
role, role,
content: content.to_string(), content: content.to_string(),
source: None,
parts: None, parts: None,
tool_call_id: None, tool_call_id: None,
tool_calls: None, tool_calls: None,
+119
View File
@@ -84,10 +84,69 @@ pub const DEFAULT_BUS_CAPACITY: usize = 256;
/// 事件值语义:emit 与 publish 收到**同一份事件值**(内部 clone),对齐原手抄 `ev.clone()` 两行式。 /// 事件值语义:emit 与 publish 收到**同一份事件值**(内部 clone),对齐原手抄 `ev.clone()` 两行式。
/// 消费 ev(调用方无需再 clone)。 /// 消费 ev(调用方无需再 clone)。
pub(crate) fn emit_ai_chat_event(app: &AppHandle, ev: AiChatEvent) { 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.emit("ai-chat-event", ev.clone());
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev); 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) // EventBus — 基于 tokio::sync::broadcast 的 pub-sub 总线(载荷 serde_json::Value)
// //
@@ -337,4 +396,64 @@ mod tests {
assert_eq!(received, expected.clone(), "每个变体应原样透传(全透传不丢)"); 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(); let started = Instant::now();
// ── 检测 obscura ── // ── 检测 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, Some(p) => p,
None => { None => {
// 未装:回退静态 + hint。 // 未装:回退静态 + hint。

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