Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

验收: cargo check -p devflow EXIT 0 + vue-tsc EXIT 0 + build EXIT 0。
2026-10-09 22:53:14 +08:00
lxy c70faa7bb8 优化: 版本 0.1.0 升 0.1.1,app.1216 发版配套 2026-10-08 00:41:09 +08:00
lxy 46521124de 修复: 会话迟到收尾仲裁误杀新任务流式态
- 症状: 上条处理中→发新消息入队→点「立即发送」→新气泡丢失+红按钮状态丢失
- 根因: force_send 打断旧任务后旧任务 AiCompleted 迟到到达, cleanup 无差别 setStreaming(false) 误杀新任务 streaming
- 仲裁: 四终态收尾前 IPC 查后端真实 generating(aiApi.isGenerating), 仍在生成=迟到伪影跳过全部下游收敛; 查态失败按正常收尾兜底
- 附: 901 看板验收附言补录(任务书⑤闭环)
2026-10-08 00:32:44 +08:00
lxy 0aac14ed9a 文档: 看板销账: 挂机模式 P1 总装与换装校验修复入库,901 销账
- 901 提交工作区改动转交实战完成: G4 前置走查过(风格/残留零问题)+分组三笔(挂机总装/换装修复/本笔销账)+vue-tsc 0+cargo check 0
- 看板顶部提交冻结解除标注(2026-10-07 用户推送令)与攒批状态更新一并入库
2026-10-07 22:06:47 +08:00
lxy 6bfcf01641 修复: 换装校验同版本重装误判不通过
- retryHash 与磁盘基线相同(同版本重装)时原判据走不进 pass 分支误报 mismatch
- 现区分两种通过情形: 确定性新装(installer deterministic output)与同版本重装(already up to date)各留日志
2026-10-07 22:04:58 +08:00
lxy 2441101978 新增: 挂机模式 P1 总装: 设置面板+顶栏徽章+边界卡精简+监控告警推送
- 新增 UnattendedPanel.vue 挂机配置面板(引擎自动认领看板任务,Settings KV 热读即改即生效)
- Settings.vue 接入挂机面板;searchIndex.ts 补挂机搜索词条
- TopBar.vue 新增挂机进行中徽章(脉冲动效+透明度过渡)
- TaskBoundaryCard.vue 精简边界状态判定(wallState 合并比对,删冗余 watch)
- i18n zh-CN/en: settings/ai/aiChat 六文件补挂机词条双语
- unattended.rs 新增 push_monitor_alert 监控告警推送(异常快照+告警头直发 monitor_chat)
- feishu_notify.rs push/push_card 出口重构收敛
- lib.rs 注册 unattended_status/unattended_set_enabled 两 IPC
- aiShared.ts/useAiStreamingEvents.ts 挂机状态流接入
2026-10-07 22:03:41 +08:00
lxy e60bd38444 文档: 看板销账与引擎实测记录 2026-10-07 18:33:42 +08:00
lxy a2c69be4a2 修复: 换装校验改安装器确定性判据 2026-10-07 18:33:42 +08:00
lxy c2382325cb 新增: 弱网韧性: send 网络感知重试与基准面探针
- connect 类失败重试: 10 分钟窗口+基准面感知(IP 直连与 DNS 双测判本机断网), 断网进长等待自适应探测(5s 快探到 30s 封顶省电), 恢复即重试; 单点故障指数短退避 1s→8s
- 两协议(anthropic/openai)同构, 隧道级分钟级弱网不再直接吐中断
2026-10-07 18:33:41 +08:00
lxy 76ace953be 修复: 飞书细项批: 取消审批通路+多chat路由+命令容错 2026-10-07 18:33:41 +08:00
lxy 8c06076873 新增: B5 续作引擎完整批: 自领+停靠恢复+监控推送+提交冻结
- B5 引擎: 看板轮询自动领单(G2 门禁 fail-closed, 领单原子写), parked 自动恢复优先, 收敛回读续作, 3 次熔断自停, KV 配置默认关
- 引擎配套: 提交冻结同步(df-commit-frozen), 工具结果外载过期清理, 工具失败快速记忆, 自监控快照飞书推送(df-monitor-chat-id)
- 领单判据收紧: 正文以 🔴 开头才判高危(正文提及字样不误判)
- 28 注入末轮竞态: 补充指令落末轮时顺延轮次预算(封顶 8)
- 停靠卡继续伪失败根修: ai_continue_loop 非 active 时分流 parked 恢复内核(recover_parked_inner 单一事实源)
- 实测: 写板 70 秒自动领单, 领单→执行→销账→回读→领下一单 全链闭环, 2050 测试过
2026-10-07 18:32:43 +08:00
lxy e36b55a2f6 文档: 看板销账与提交冻结令: 交互批八项+namespace治理销账, 后续攒批等通知 2026-10-06 23:49:09 +08:00
lxy 19ecadf3f6 新增: 编号唯一机械门禁G8+换装脚本v3加固+自主修复纪律
- commit-msg 规则外置: 主hook 遍历 rules/, G3 中文类型+G8 看板编号全域唯一(主板+归档恰好一次, 双向测试过), core.hooksPath 分发
- 换装脚本 v3: 句柄释放独占探测+构建产物 SHA256 校验+失败重试+SYSTEM 转投用户会话可见重启(上线验证过: SHA256 一致+H1 实测)
- CLAUDE.md 自主修复纪律: 明确缺陷禁反问直接修, 本机脚本行为变更=report_after 级
2026-10-06 23:48:45 +08:00
lxy ece7cd3fa8 新增: namespace淘汰治理与可恢复会话分组
- namespace 淘汰治理: pin 机制(关键产出 LRU 永不淘汰, 超池 50% 最旧降级) + EvictionLog 留档富提示(原工具名+内容摘要+重调指引) + owner 软隔离(无主最旧先淘); 子代理报告出口 pin 收编
- 错误路径广播 AiAgentFinished(status=error): 错误任务有终态, watcher/任务卡可收敛
- 桌面侧栏可恢复分组: parked 会话独立分组+点击恢复, 断点续跑读侧闭环
- 验收: cargo check 零 error + vue-tsc EXIT 0
2026-10-06 23:48:45 +08:00
lxy 1ce1360a0b 修复: 飞书交互批: 推送汇总卡/伪受理/回查/watcher弃守/status/md净化
- 34 push_to_chat 滚动汇总卡: 同 chat 在途事务卡原地追加, ✅/❌ 终态另起新卡, 更新失败回退重绑; L3 过程不再逐条新开卡片刷屏
- 20 伪受理断层: 发票前过 can_accept_request 守卫, 不可启动回实况话术; inflight 移交 watcher 持有, 运行期计数不随 handler 返回归零
- 22 兜底回查限定受理时间戳边界(纯函数+单测), 缺时间戳视为边界外, 禁投旧回答冒充
- 23 watcher 消费本会话 AiError 视同终局投失败卡, 不再空挂 70min
- 26 话术改实况: 未受理(暂不入队)可稍后重发, 删假排队承诺
- 27 快任务兜底改受理卡发出后 8s 宽限窗判定, 替换入场恒假死分支
- 21 /status 命令桌面入口: ai_chat_send 拦截, 零 LLM 零 spawn 返回进度摘要
- 38 出站 md 净化: 标题转粗体/表格转列表/围栏保留, feishu_channel 与 df-im-gateway 同规则互引
- 受理卡符号对齐规范(任务开始用🚀, ✅仅完成)
- 验收: cargo check 零 error + feishu 13/df-im-gateway 19/chat 17 passed + vue-tsc 0
2026-10-06 23:48:29 +08:00
lxy 9520c198a2 修复: MCP list_tasks 默认排除终态 + read_skill 隔离围栏
- list_tasks 无 status 过滤时默认排除 done/cancelled, 终态存量(260+条)挤占分页窗口致活跃任务被挤出视图; 显式传 status 不干预
- df-storage TaskQuery 新增 status_not_in 字段 + SQL NOT IN 下推(占位符先拼后绑, 防序号/值错位)
- read_skill 返回内容外包 skill_documentation XML 围栏+隔离声明, 防技能文档内容与系统指令混淆
- df-mcp 64 passed(含新增终态排除测试)
2026-10-06 23:48:11 +08:00
lxy 9ff26f0c8f 修复: 子代理报告双缺陷——熔断补收尾轮 + 报告新近度豁免
① 熔断无收尾轮: max_rounds 打满(达 effective_max 上界且未收敛)时, 旧逻辑直接 teardown 截断, 父任务/消费方拿到的是末轮思考独白而非任务报告。现补一次无工具收尾轮: system 指令「输出最终任务总结报告」+ tools=None(stream_round 空工具表), 成功则 push 报告文本+落库再走原收尾出口; 失败(全候选耗尽/MidStream/Stale)不重试不停靠, 静默落回原截断路径——报告是增益非闸门, 预算仅 1 轮 max_retries=0。

② 报告被视图压缩吞掉: summarize_tool_results 加新近度豁免(RECENCY_EXEMPT_TOOL_RESULTS=2), 末尾 2 条 tool_result 恒不压缩——豁免判定在冻结决策之前且不进决策表, 无前缀缓存漂移; 委托完成报告/末轮工具结果全文保留给 LLM 下一轮消费, 根治「>2KB envelope 下一轮即被 JSON 分支折叠 512 字符, 父 LLM 从未见过报告全文」。

测试: 新增 summarize_recency_exempt_keeps_last_two_tool_results 锁定豁免行为; cargo test -p devflow --lib 712 passed 0 failed; todo.md 两项销账。
2026-10-06 16:47:07 +08:00
lxy 438d47bc9f 新增: 无人值守安全系列(H1-H3) + P5 审计日志与部署运维 + #14 异步委托
- H3 外部干预感知: run_agentic_loop 增 h3_watch_dir 参数, loop 启动取 git HEAD 基线, 每 5 轮探测, HEAD 被外部改写(切分支/reset/他话提交)即 emit_task_parked(stop_kind=external_change) + teardown 停靠; 4 调用方(发送/编辑/强制发送/审批续跑)从 allowed_dirs.persistent 排序取首目录, 修复初版 current_dir 恒空缺陷
- P5 审计日志: df-im-gateway audit.rs append-only SQLite(tunnel_audit 表, payload 4KB 截断, recent/by_request 查询), main.rs 8 打点点位(inbound/command/outbound), 写失败不阻断主链路, 17 tests passed
- P5 部署运维: publish-im-gateway.ps1(musl 交叉编译→scp .new→systemd 原子切换→9191 验证→自动回滚) + systemd unit + nginx im.1216.top 反代 + 运维手册
- #14 异步委托: 受理/完成两段式 + 桌面任务卡(后台进行中徽章/轮次/切换) + 飞书双端同构, 关联缺陷 #8-#12 速清

验收: cargo check workspace EXIT 0, cargo test workspace 全绿(711+560+33+12...), vue-tsc EXIT 0
2026-10-06 15:59:10 +08:00
lxy 7c252cff96 新增: ai_list_recoverable IPC+桌面可恢复入口双轨数据源(B3) 2026-10-06 12:54:11 +08:00
lxy 40392ebded 新增: 飞书 push_to_chat outbound 统一出口+ACTIVE_CLIENT 全局句柄(19) 2026-10-06 12:49:48 +08:00
lxy d2f586fc52 新增: 完成汇报附 git 实测附录 branch/HEAD/未提交计数(B1-H1 单点) 2026-10-06 12:47:18 +08:00
lxy 71e548aafc 修复: 飞书渠道错误话术分类透传+探活 inflight 口径前移(18) 2026-10-06 12:40:27 +08:00
lxy 0dfb50be9b 文档: 无人值守专项设计档+todo 销账(#10/#11/#12), Web 规范合规批(App/视图/组件样式) 2026-10-06 12:13:39 +08:00
lxy 04db8c0d3b 新增: 后台任务卡徽章+轮次投影(convRounds), 可恢复分区(parked), reattachToMain i18n 补键(#12) 2026-10-06 12:13:33 +08:00
lxy 70d6e98f82 修复: loop_state 懒创建继承+入口早退清理(#10), update_full 剥离列封 lost-update(#11), MidStream 保文不回填半截 reasoning, 标题剥标签 sanitize(#9), ANSI 源头抑制+strip 兜底(#8) 2026-10-06 12:13:25 +08:00
lxy 03dc27bc14 文档: todo 销账同步 2026-10-05 23:58:21 +08:00
lxy 28e6acbf3f 优化: 聊天体验批与Web规范合规 2026-10-05 23:41:32 +08:00
lxy 9e81182b4f 优化: 知识检索同项目加权与拒绝池 2026-10-05 23:41:27 +08:00
lxy 0f3c3ce1aa 优化: 飞书渠道文本链路与资源释放 2026-10-05 23:41:20 +08:00
lxy cb260aba4b 新增: 断点续跑与日志租约底座 2026-10-05 23:41:18 +08:00
lxy 8233ba095e 新增: 飞书直连渠道接线(文本链路第一批)
- 桌面端出站WS长连接,复用spawn_agentic_session通用核心与完成通知桥
- 配置读feishu-config KV,白名单空=放行并自助提示open_id
- todo并入下午分析产出(存储架构/知识库/规范治理/基建走查)
2026-10-05 14:59:55 +08:00
lxy d5766c2142 文档: 设计文档与问题记录归档 2026-10-05 14:59:12 +08:00
lxy 2eea0118db 重构: 审批交互重设计 2026-10-05 14:59:12 +08:00
lxy 24d6803ecc 新增: 项目归档 2026-10-05 14:59:12 +08:00
lxy 63c5c3d4f0 新增: WebChat 端 2026-10-05 14:59:11 +08:00
lxy 7c76ee6a60 新增: u-claw 集成 2026-10-05 14:59:11 +08:00
lxy d4d3a8deaf 新增: 飞书设置面板 2026-10-05 14:59:11 +08:00
lxy 8e1e56b3e5 优化: 打包提速与换装脚本
- Cargo.toml release profile 开 incremental(小改动重打包显著提速,正式发版在意体积可临时注释)
- 新增 scripts/package-devflow.ps1 双通道打包脚本:verify 快速编译验证/full 全量发版换装,含磁盘预检与启动验证
2026-10-05 00:35:05 +08:00
lxy 26cc3c3df9 文档: 能力批设计收口与走查状态同步
- DRY技术债走查报告:状态表同步 12/12 全收口,头部注记状态表为走查快照
- 并行化与异步唤醒设计:补 §13.4 有界失败记忆(窗口折叠/失败分册按类去重/监控轮转,三界类目口径同源)
- todo:登记 GitHub 直连受限替代方案(失败学习环首条)+F-m 实施细节补充
2026-10-05 00:35:04 +08:00
lxy 753de25696 重构: DRY截断正典改名收尾
- audit/utils.rs truncate_chars→truncate_chars_ellipsis 正典改名(补…语义),re-export 链同步
- record.rs/generate_image.rs/knowledge_inject.rs/tools/knowledge.rs 调用点全链更新
- df-ai context_helpers.rs 私有版跨 crate 不引依赖保留原名,加正典标注注释
- DRY #11 收口,同名不同义陷阱消除
2026-10-05 00:35:04 +08:00
lxy 6a50193b34 优化: 狗粮短板收口与patch_file增强
- patch_file old_text 精确未中时空白归一化回退(行首缩进归一+多空白折叠,唯一命中按整行区间替换,warning 注明)
- patch_file anchor end 唯一性校验:多处命中报错并列前两处位置,治实测×2 锚点错位;三模式同传按优先级仲裁(anchor>old_text>replace_lines),warning 注明
- tool_result 压缩 memoization:PerConvState 加 tool_compress_cache(内容哈希→压缩结果),主 loop+子代理双调用点接线,治 369 组同内容反复重压
- 标题采样改两段式:user 首条必进锚定主题+其余按序补足 6 条,治 assistant 首句被当主题
- prompt 注入文件读取纪律段(父会话+子代理同源):read_file 分页/search 直读,禁 run_command 切片读文件;read_file 工具描述与 truncated 提示补大 limit 建议
- 顺带 DRY#1:agentic/mod.rs 三处裸 emit 换正典双写(心跳/破坏性回环熔断/stall 熔断),miniapp 跨端盲区补齐
2026-10-05 00:35:04 +08:00
lxy c83ea0c315 新增: advance_task auto_path跳态自动推进
- 跳态目标按 shortest_path 自动逐跳推进(advance_task_auto_path),每跳独立校验+review_rounds 回归计数照旧
- 直达单跳与严格路径行为一致;workflow.rs 回调等机器生成单跳场景仍直用 advance_task_atomic 保持严格语义
- update_task 工具描述与 status 拦截错误文案同步 auto_path 语义
- 治 LLM 反复请求跳态推进被状态机拒绝的空耗往返
2026-10-05 00:35:03 +08:00
lxy c10f0e46f0 新增: aichat前端体验四件与队列残留收口
- R4 代码块语言角标:MessageList decorateCodeHtml 捕获 language-xxx 输出 data-lang+CSS ::before 左上角标,hover 显,无语言裸块不渲染
- R6 工具结果路径可复制:ToolResultBody 五处 file-bar 的 path span→button(read_file/list_directory/search_files/grep/patch_file),点击复制完整路径+✓/✕ 1.2s 反馈+execCommand 降级链
- I6 重新生成 UI 入口:MessageItem user 气泡 hover 补重新生成按钮(canRegenerate=isLastUser&&非流式),复用 handleRetry→store.regenerate 链路
- V4 浮窗审批 High 风险二次确认:useToolApproval 导出 isHighRiskTool/highRiskConfirmText,ApprovalPopup.onApprove 前置 ConfirmDialog,数字键守卫不绕确认
- 9-28#5 两处全局清队列残留:useAiEvents/useAiLifecycleEvents 无 convId 的 AiError 分支改保留队列+console.warn(防误伤其他会话排队消息)
- i18n zh/en 补 clickToCopy 键;裸 @ 改 &#64; 转义(vue-i18n linked-message 前缀吞噬)
2026-10-05 00:35:03 +08:00
lxy 83e86cd6b7 新增: 灵感归档箱与任务归档动线
- ideasStore/taskStore: archiveIdea/archiveTask IPC 封装(本地移除/本地更新,对齐 delete 语义)
- project.ts 组合 store 显式透传 archiveIdea/archiveTask
- Ideas.vue: 筛选栏归档箱 Tab(独立数据源 ideaApi.listArchived,主查询不受扰);archiveCurrentIdea 处理器(归档确认+归档箱内取消归档自动清选中);IdeaDetail 传 in-archive-box prop
- IdeaDetail.vue: inArchiveBox prop + archive emit;操作区归档/取消归档按钮,归档箱模式隐藏删除(防误删归档记忆)
- i18n zh-CN/en ideas 补 filterArchived/archiveBoxEmpty/quickArchive/quickUnarchive/confirmArchive/toast 键(前批遗留)
验收: vue-tsc --noEmit EXIT 0
2026-10-05 00:35:02 +08:00
lxy c6ebf15407 文档: 能力建设批设计归档 2026-10-05 00:35:01 +08:00
lxy f4d31e884a 新增: 任务灵感归档后端 2026-10-05 00:35:01 +08:00
lxy c8c13d302a 新增: LLM目标列表主动修正 2026-10-05 00:35:01 +08:00
lxy 7dea2b86c4 优化: Batch.md 状态同步 2026-10-05 00:35:00 +08:00
lxy e492f03191 新增: df-im-gateway 网关骨架 2026-10-05 00:35:00 +08:00
lxy 91773da602 文档: 提交重整处方与在飞收尾 2026-10-04 16:31:17 +08:00
lxy b994cf2c54 文档: 设计定稿与评估纪律登记
- 无人值守长任务设计定稿,含效率与可观测补遗
- u-relay wire 协议规格与五项评估结论
- 新能力引入评估纪律五项清单
- todo 销账与狗粮短板分析登记
2026-10-04 16:27:32 +08:00
lxy 08e9d0292d 优化: 子代理截断信号与缓存失效
- max_tokens 截断标记穿透到完成卡
- 缓存写后失效补测试锁定
- 派发参数卡头信息补全
2026-10-04 16:27:32 +08:00
lxy d9e0ce73d1 优化: 跨端链路协议对齐
- miniapp 配置域旧端点强制重置
- df-tunnel 隧道协议与 relay 现网对齐
- relay e2e 例程入 examples
2026-10-04 16:27:31 +08:00
lxy 7aa0da32f2 修复: 协议层与体验缺陷六件
- temperature 按模型适配,仅收 1 的模型不再 400
- 流式 error 帧不再被 choices 必填整帧丢弃
- 只读缓存写后失效
- git diff 中文截断撞 UTF-8 边界 panic
- 非视觉模型纯图片直接拒绝
- 全局快捷键补输入焦点守卫
2026-10-04 16:27:31 +08:00
lxy 01d231c39d 优化: 子代理体验与纪律注入
- 工具纪律段抽共享同源注入
- 子代理轨迹浮层与完成卡身份
- 分离窗会话切换修复
- 960s 统一出口报告不再蒸发
2026-10-04 16:27:31 +08:00
lxy b2d44f85e9 新增: 无人值守P0任务边界全链
- 任务级授权边界判定与审批旁路单点插桩
- boundary_json 随会话落库(V45 可逆迁移)
- 六路非正常终局统一安全停靠与托盘通知
- 每五轮进度事件与会话头部进度条
- 前端边界配置卡侧栏徽标及 UX 校验补齐
2026-10-04 16:27:30 +08:00
lxy 8396849d04 优化: 设置域体验完善
- 校验失败红框定位字段,保存反馈改持久化成功后
- 数值越界钳制,双提示队列化不再重叠
- 搜索防抖跳分类,托盘气泡文案进多语言
2026-10-03 17:57:43 +08:00
lxy f67e65633b 优化: 会话入口与对话交互打磨
- 完成卡点开子代理轨迹浮层,任务抽屉关联会话跳转
- 提及片段编辑差分三向判定,不再错位
- 长回复折叠完成瞬间锚定消息顶,视口不跳
- 空编辑/技能无匹配/回填覆盖草稿均有提示确认
- 队列编辑态切会话重置,刷新快捷键不再误触
- git 变更面板去重缓存,项目卡骨架与溢出重测
2026-10-03 17:57:43 +08:00
lxy 890ceef0ec 新增: 子代理轨迹查看与任务关联会话
- 会话只读窥视命令,不切换当前状态
- 任务关联会话反查,审计表结果精确匹配
- 托盘气泡键名修正,首关说明链路修通
2026-10-03 17:57:33 +08:00
lxy 8e09112d59 修复: 命名空间上限与网络域解析兜底
- 命名空间超容单条拒绝,上限不变式恢复
- usage 部分字段缺失致整帧内容蒸发,补缺省兜底
- tool_call 缺 id 致整批丢弃,对齐流式 fallback 口径
- 视觉模型判定改词素尾,deepseek-v3 不再误判
- 并行分支 panic 任务对账补齐为失败结果
- plan 执行器死代码清理,实况注释校正
2026-10-03 17:57:33 +08:00
lxy 3e3c6f4d0f 优化: 界面交互完善与设置体验
- 审批卡尊重滚动意图,等长切换回底,排队超时按钮修活
- 带图编辑提示不静默丢,删除目标回写,切换文案消歧
- 会话键盘可达+搜索态重命名,筛选计数口径对齐
- 子任务提示正名,看板加载态
- 主题跟随系统,表单脏态拦截,密钥显隐留空提示
2026-10-03 16:28:38 +08:00
lxy 31f7a9570b 优化: 会话状态键控与提示补全
- 队列清空/发送失败补提示,错误接入分类文案
- 审批超时气泡按会话归属,流守卫按会话清理
- 会话操作失败就近反馈,拖拽非法流转提示原因
2026-10-03 16:27:39 +08:00
lxy 8710a09be2 新增: 关窗托盘化一次性说明
- 首次关窗最小化到托盘时系统通知说明,标记文件防重发
2026-10-03 16:27:37 +08:00
lxy ed678262b6 修复: merge-tree 预检参数序与判定收紧
- 参数序改为共同祖先,原序致分支零差异预检失效
- 冲突判定只认真实标记,等号行不再误撞
2026-10-03 16:27:37 +08:00
lxy 0b57126ac5 文档: 三路走查报告与编排经验蒸馏 2026-10-03 15:35:37 +08:00
lxy 2d43f90e50 优化: 对话交互修复与界面完善
- 修输入法组词回车直发/分离窗误关/出错后按钮死锁
- 新建任务失败弹窗内联提示,会话切换乐观高亮
- 状态多选联动刷新不再冲空可见行
- 测试连接保留已配开关与权重,防抖按提供商分键
- 代码块复制/长回复折叠带量/队列回填/排队时钟
- 子代理完成卡渲染,死链卡片与引用清理
- 截图渲染换 pro 版依赖,样式 token 化收尾
2026-10-03 15:35:18 +08:00
lxy b4fafa0e9a 优化: 会话状态 per-conv 化与交互守卫
- 待审批跨会话 per-conv 合并,审批类事件全局放行
- 看门狗/重试计数/流式残段按会话键控迁移
- 输入草稿与子代理完成卡数据按会话隔离
- 切换兜底 activeId,收敛守卫防幽灵气泡
- 发送错误按限流/额度/网络分类文案
2026-10-03 15:35:15 +08:00
lxy d406a31722 优化: agentic防护与删除审批子代理域
- 循环主体 catch_unwind 防护,panic 走正典终态不再卡死
- 会话删除停 in-flight 循环+删除侧单点补发终态
- 审批超时到点主动收敛,执行等待循环加删除判死
- 子代理出口整轨迹落库,拒绝工具结构化+报告协议
- 压缩会话 id 修正,begin_turn 喂用户原文
- 远程桥会话列表按 origin 过滤子会话,新增中断续跑命令
2026-10-03 15:35:08 +08:00
lxy c1e5b96d75 修复: 上下文流式与工具签名域清债
- tool_signature 归一化补参,合法换参不再误触探索熔断
- SSE 超限截头改对齐事件边界保尾,流不再永久破损
- content_filter 纳入终态判定,无 DONE 流也收敛
- sanitize 占位补头前缀短路只吞 orphan 的盲区消除
- intent 关键词加词边界,history 不再误判
- 恢复重放后重置话题锚点,URL 图 token 地板不被稀释
2026-10-03 15:35:05 +08:00
lxy 499aa49e49 优化: 存储层清债与子轨迹来源迁移
- V44 加 ai_conversations.origin 列(子代理轨迹来源标记)
- V21 断点续迁改造(逐对话进度判定,崩溃重跑补漏)
- 手写事务改 guard.transaction,panic 不残留打开事务
- 软删 CAS 补 deleted_at IS NULL(任务/项目/灵感三处)
- 父任务校验拒回收站挂子,删模块先清依赖边
- 列缺失与未知 status 降级补 warn 留痕,limit=0 返空集
2026-10-03 15:34:55 +08:00
lxy 3f4c914dbf 测试: 工具卡分组活组豁免回归用例(live-group.spec) 2026-10-02 01:21:33 +08:00
lxy db8c240d95 功能: 启用 Tauri asset 协议支持 webview 本地图片预览
- Cargo.toml 开启 protocol-asset feature
- tauri.conf.json 配置 assetProtocol scope=**
- Cargo.lock 同步 http-range 依赖
2026-10-02 01:21:27 +08:00
lxy 79ca55620b 清理: 移除代码注释中的走查编号前缀(UX/B/SW/CR/ID/P0-P2-26xxxx,共102处) 2026-10-02 01:00:37 +08:00
lxy ea20a465d6 修复: 生成中历史轮次轨迹卡误降级不可点击
- 执行中降级分支仅锚定含 store 末条消息的轮次桶,历史桶保持可交互
- 原实现只要会话生成中就把全部轮次桶压成 step 摘要行(BUG-261001)
2026-10-01 22:36:27 +08:00
lxy 74cad6442b 修复: AI 面板快捷键徽章改 Ctrl+I
- Windows 应用用 Mac 符号 ⌘I 有误,对齐下方 Ctrl+B 徽章
2026-10-01 22:36:27 +08:00
lxy dd0afcf0d1 修复: G2 签名熔断误杀红绿迭代
- 同签名死磕判定改无写间隔连续段口径,间插写类调用=红绿验证不累计
- 写证据=is_write_evidence_tool 五个改文件工具,读类不算证据
- 阈值 3 与纯死磕语义不变,根治脚本失败致执行中断
- 新增红绿迭代豁免与读间隔死磕两场景测试
2026-10-01 22:36:26 +08:00
lxy 26d9a1af1e 优化: sanitize 告警降级
- 修复告警 warn 降 debug,同份畸形历史每请求重复触发成洪水
- 涉 sanitize.rs 四处与 openai_compat 两处,含逐 orphan 单条刷屏点
- view-only 修复语义不变,存量配对清洗已完成
2026-10-01 19:36:20 +08:00
lxy 1e6db6f310 新增: anthropic 思维链解析入库与思考卡展示
- 流式与非流式 thinking 块接入 reasoning 通道
- 签名与密文块合规静默,请求侧零改动
- 历史映射透传 reasoning_content
- 思考卡数据源真推理文本优先
2026-10-01 19:35:23 +08:00
lxy bf0db10ef7 新增: 任务工作流 AiAgentNode 直连会话编排
- ai 节点 mode agent 开关派发,默认旧路径零回归
- 完成通知桥注册触发,收敛出口单点投递报告
- 节点超时取消兜底,结果落任务产出
- 会话编排走独立新会话,不劫持活跃面板
2026-10-01 19:35:22 +08:00
lxy 7dcbdb1feb 修复: 连接池收敛与传输错误分类重试
- 连接池空闲十五秒,陈旧连接复用致重置根治
- 流式发送错误改谓词分类,openai 路径不再裸奔
- decode 类失败纳入重试,正文中断可自愈
- 状态分类合并响应体,激活休眠词表分支
- 手动压缩失败降级兜底,不再弹红卡
2026-10-01 19:35:22 +08:00
lxy 4417b3ddd3 修复: 压缩边界对齐与断路器学习语义
- 压缩边界按对话单元回退,不再劈开工具对
- 停止与接管退出补跳过结果,畸形不再新增
- 失败指纹纳入参数,改参重试不再累计熔断
- 成功断连失败段,红绿循环不再误熔断
- 脚本非零退出豁免,同命令死磕由签名熔断兜底
- 熔断前加自纠警示轮,临时路径写删豁免
2026-10-01 19:35:22 +08:00
lxy b8416dea63 修复: 知识提炼截断打捞与水位保节奏
- 提炼额度 2048 提至 4096,prompt 封顶五条降序
- 截断 JSON 字符串感知打捞,只收校验通过前缀
- finish_reason 截断留痕,空输出分开口径
- 失败改记节奏位,灭逐轮重提炼风暴
2026-10-01 19:35:22 +08:00
lxy 423acfdd00 优化: 缓存命中字段解析与提示词前缀稳定化
- openai 兼容口径补解析嵌套 cached_tokens
- cache 显示区分无数据与真零,估算轮不渲染
- 轨迹展开折叠空过程记录行降噪
- token 统计行恒完整可见不截断
- openai_compat 纳入 system 稳定易变拆分
- 系统警示与主题标记改尾部注入不位移前缀
- 工具结果视图压缩按入库时刻冻结
- 会话内工具集冻结仅增不减
- anthropic 缓存补消息级断点,设置页加开关
- 补 intent 测试对 Agent domain 覆盖
2026-10-01 19:35:21 +08:00
lxy 2187ffe173 新增: spawn_agent 子代理派发与级联取消
- 纯内存子会话隔离上下文,一轮回传结论报告
- 白名单钳制低风险并恒剔 spawn_agent 防递归
- 子代理策略下高风险自动拒绝,不挂审批防卡死
- 轮数令牌挂钟三重熔断
- 父停止与删会话级联取消子代理
- AiAgentFinished 事件挂父会话
2026-10-01 19:35:21 +08:00
lxy 524120b110 修复: 切会话残片覆盖回复与收尾工具卡丢失
- flushCurrentText 加非流式守卫,非流式只清不写
- 后台会话终止即清流式态,残片不跨轮引爆
- 异常收尾尾部工具气泡折进轨迹卡不再蒸发
- 停止与看门狗超时先保文再复位
2026-10-01 19:35:21 +08:00
lxy e1dd0bc3a3 新增: 后台会话系统通知与托盘运行态
- 完成/失败/需介入三类系统通知,主窗聚焦静默
- 同类通知五秒合并,用户主动停止不通知
- 托盘菜单与悬浮提示实时显运行中会话数
- 关窗遇运行会话提示后台续跑,每进程一次
- 侧栏常驻徽章显后台运行会话数
2026-10-01 19:35:20 +08:00
lxy 66c6553cdf 优化: ai-chat 对话流布局与轨迹信息密度改造
- 分组行去统计化,调用统计归并轨迹卡头部
- 相对时间改时间分隔线,跨日显绝对时间
- 思考过程单行 pill 化,答案卡加 accent 边
- 工具卡短路径展示与耗时提示统一
- 高风险工具名单启动时同步后端注册表
- 自动重试改琥珀细条,红卡与手动重试仅终态
- 轨迹卡头并时间线行,显轮次号与绝对时间
- 运行中摘要补当前工具名与失败计数
- 执行中工具卡加实时计时,耗时统一人话口径
- 重试残留清理改标记匹配,替代中文子串硬编码
2026-10-01 19:35:20 +08:00
lxy 0560137840 优化: 前端DRY收敛与命名对齐
审批等待格式化与阈值沉 utils/time 正典
相对时间阶梯三套收敛 formatRelative 参数化
灵感状态表沉 constants,透传通道退役
aggText/clearOwn 等命名对齐,IPC 参数名统一 conversation_id
2026-10-01 19:35:20 +08:00
lxy 6ef15a0940 新增: 技能注入反馈闭环与项目扫描前端接线
技能注入成功/失败 toast 与事件类型监听接线
项目级技能扫描前端传参点就绪
2026-10-01 19:35:19 +08:00
lxy 75c5e23523 重构: 后端编排拆解与DRY债收敛及命名纠偏
阶段1拆解: stream 系外移 stream_relay.rs,mod.rs 3048→2607,loop_body 502→83
技能收尾: regenerate/edit 回溯补技能,新增 read_skill 工具,项目级技能扫描
DRY: emit 双写 46 处收敛 event_bus 正典,KV 引号归一收口,LOCK-SLOW 33 处单行化
警示骨架 4→1 与压缩兜底 2→1,噪音目录档位收敛,truncate 同名陷阱消除
工具元数据落 ToolMeta 八维,7 张 match 表改查 registry,parity 测试锁等价
命名: is_noise_entry/teardown_round/probe_missed_terminal_event 等 6 项纠偏
2026-10-01 19:35:19 +08:00
lxy 3861e13b32 文档: 决策单拍板留痕并落 DRY 走查报告
D2 黑名单放行拍板补留痕,relay token 不引具体值
DRY 技术债 Top12 清单落专项设计,高风险名单一项本轮收口
2026-10-01 08:28:08 +08:00
lxy e3a8a8cffb 清理: 停滞判定工具名映射构建去重
两处重复的 id 到 name 映射抽共用 helper
2026-10-01 08:28:07 +08:00
lxy 57d9fd8ad3 测试: bypass 标志测试统一串行闸
途经 is_authorized 的测试统一持锁,消除并行偶发红
2026-10-01 08:28:07 +08:00
lxy 04ce127a96 清理: 业务域名注释清零与垫片生产隔离
6 处域名注释改语义化描述,src 下业务域名零残留
垫片 PROD 构建零携带,消息纪年对齐 2026-09,补 ai_list_tools 空返
分离窗口错误文案入 i18n
2026-10-01 08:28:07 +08:00
lxy bff5ae9d7a 修复: 对话轨迹生成锚点回归与耗时口径
生成中锚点改真实末条,修第二轮打字机与工具卡不渲染回归
轨迹总耗时计入末轮时长,单卡耗时剔除审批等待区间
聚合文案与截断常量下沉共用,时长进位边界钳制
2026-10-01 08:28:07 +08:00
lxy 7b5a2c83b0 修复: 高风险工具名单改注册表投影
新增 ai_list_tools 只读投影 name+risk,前端启动拉取构建
手写名单缺 10 个 High 工具致审批二次确认漏弹,现名单改兜底保留
顺带删除零调用方 get_script_safety 死命令及注册
2026-10-01 08:28:07 +08:00
lxy 4b445e96b8 修复: 技能前导斜杠解析与规程包裹语
打字 /技能名 视同浮层选中,根治零触达(日志实证注入从未发生)
技能段包裹改指令性规程头,参考段保留隔离头
新增 parse_leading_skill 与包裹语单测 10 例
2026-10-01 08:27:49 +08:00
lxy a06786fd8e 修复: 工作流终态回写归属校验与状态文案
终态摘要仅 workflow_id 归属一致才回写,防同会话并发互踩
完成态替换待上报行,消除已完成却待上报的自相矛盾
步骤链改用节点 label,无 label 退回 ID
2026-10-01 08:27:49 +08:00
lxy bacec20c3c 修复: 变量定义判定误伤函数调用值
子树包含判定改直取 value 节点 kind
const x = arr.map(...) 不再误判为符号定义
subtree_contains_kind 无其他使用方一并删除
2026-10-01 08:27:49 +08:00
lxy 7c62d8dbe7 修复: 知识工具检索接分词与晋升补嵌入
knowledge_search 整句 LIKE 改分词检索,自然语言查询可命中
晋升(手动/复用达标)后补 spawn 嵌入,进入向量检索面
本地截断改调 audit truncate_chars
配置加载下沉 load_knowledge_config 供工具路径共用
2026-10-01 08:27:48 +08:00
lxy cfccba863c 修复: DAG 摘要文档示例块标注 text
无语言标注的代码块被 rustdoc 当 Rust 编译,中文流程文本编译失败
2026-10-01 03:16:25 +08:00
lxy 73fe49b70c 修复: shell 探测缓存测试顺序污染
PWSH_CACHE 从 OnceLock 改互斥可恢复形态,写缓存测试进时备份出时写回
涉缓存测试共持串行闸,消除全量跑时序性互踩(约一成概率触发的假失败)
生产读写语义不变:有缓存读缓存,无缓存探测后写,超时不写入
2026-10-01 03:16:24 +08:00
lxy 47a75a4d49 重构: 失败采样与停滞判定纯函数提取独立模块
循环健康判定函数群外移,行为零变化
2026-10-01 02:57:08 +08:00
lxy 378047f1ff 重构: 模型路由纯函数提取独立模块
巨石拆解首步,仅纯函数移动调用方零变更
2026-10-01 02:53:22 +08:00
lxy 756d2840df 修复: 变量声明误判为符号定义
let/var 声明一律算定义致几乎任何文件误报改用符号读取
收窄为初始化为函数或类表达式才算定义,三处消费点统一收口
2026-10-01 02:42:25 +08:00
lxy 4efadf4952 新增: 写删同路径破坏性回环检测
写后紧删同路径是自毁循环直接证据,签名熔断对写删交替漏网(每步签名不同)
按工具与规范化路径对查,独立计数器与签名缓冲并行互不干扰
其他工具占位计入间隔,防写后多次探索再删的正常迭代误判
2026-10-01 02:42:25 +08:00
lxy ab05be7e4c 修复: 知识工具模块声明漏提交
补上 tools 树的 knowledge 模块挂载,前笔知识工具提交缺此声明中间检出不可编译
2026-10-01 02:42:24 +08:00
lxy 3f3f5053a4 文档: aichat 走查与集成可行性研究 2026-10-01 02:41:21 +08:00
lxy c978d9cb60 优化: 状态徽章色对齐设计token
阶段徽章/优先级/git 未跟踪状态色收敛到既有 token,延续视觉一致性
2026-10-01 02:41:21 +08:00
lxy efdf1e0282 新增: playwright e2e 测试基建与 tauri 垫片
invoke 条件垫片使前端可在无 tauri 容器下直连 dev server 做端到端验证
2026-10-01 02:41:21 +08:00
lxy df3ed55d01 修复: 测试进程 comctl32 延迟加载与流式 usage 断言同步
lib 测试无内嵌窗口清单,GUI 依赖静态绑定 comctl32 缺导出致测试进程启动即崩
改 DELAYLOAD 推迟绑定,测试进程不触碰 GUI 入口;app 端清单选 v6 行为不变
流式 usage 改为 message_start 同帧携带初始快照,断言对齐该语义
2026-10-01 02:41:21 +08:00
lxy 04acc4297d 优化: 对话执行轨迹聚合与工具卡交互
前端聚合当前轮次工具调用数,替代每轮清零的完成计数,进度文案改轮次+累计口径
完成聚合语义由执行轨迹卡承接,工具卡复读修复与默认折叠收敛
流式与工具事件配套调整,新增轮次轨迹收集 composable
2026-10-01 02:41:05 +08:00
lxy e7cd035c62 优化: agent 工具暴露改基本盘与场景增量
读写执行定为 agent 基本能力全工作意图可见,不再仅调试意图暴露命令执行(用户拍板)
业务域收敛为场景增量只增不减,各意图新子集均为旧子集超集
安全防线在执行层(路径黑名单/授权三道门/风险级审批),藏工具不等于安全
根治项目意图砍文件工具、调试意图独占命令执行导致的断手问题
2026-10-01 02:41:04 +08:00
lxy 9b89610e38 新增: 上下文裁剪淘汰补偿摘要
超预算视图裁剪时,被淘汰文本提炼关键词概要插回发送视图头部,补偿无声丢弃的早期主题
摘要以 user 角色注入:system 角色会被兼容层折叠进带 cache_control 的顶层段,纯视图每轮再生成会持续改写缓存指纹
纯视图语义不落库,被淘汰原文存储态不变;压缩失败兜底摘要与淘汰摘要共用提炼核心
2026-10-01 02:41:04 +08:00
lxy 3e239a108f 新增: 信任所有目录扩展至敏感目录放行
开关语义升级为完全放行,含 .ssh/.aws 等黑名单目录(产品拍板体验优先,用户知情选择)
validate_path 与 is_authorized 双拦截点经进程级标志同源放行,audit 层每轮工具执行前刷新
设置页开启时二次确认,正文明示敏感凭据可进对话上下文风险自担;对话区常驻放行横幅既有
测试以串行闸防并行互踩进程级标志
2026-10-01 02:40:49 +08:00
lxy 63779e8d69 新增: 知识库检索分词化与晋升配置化及对话工具
整句 LIKE 命中率趋零,注入检索改 CJK 2-gram+ASCII 词分词 OR 匹配,标题命中计分排序
停用词剥离前置到切分前,滤掉跨词组合噪音 gram;ASCII 等频精确词优先
candidate 纳入检索面(include_candidates 默认开,配开关可回仅 published)
复用即信任自动晋升配置化:默认关+阈值 5,等晋升把关策略拍板后再开,防未审核产物经误命中固化污染
晋升留痕进时间线(auto/agent 来源区分);补 candidate 复用达标自动转正机制
agent 对话侧新增 knowledge_search/insert/promote 三工具,insert 固定 candidate,转正仅限 candidate 单向
知识配置结构新增两字段带 serde 默认,旧配置 JSON 缺字段不破坏启动恢复
2026-10-01 02:40:36 +08:00
lxy bd2e94b54f 文档: 跨会话记忆四层架构实施设计(拍板走重) 2026-09-29 17:54:29 +08:00
lxy 861aad56f4 优化: 项目进度改为真实任务完成率
- 进度条不再用阶段写死百分比,改为已完成任务数/总任务数
- 复用现有任务计数通道,零后端改动;无任务项目显示无任务不伪造数字
- 阶段映射表仅保留颜色文案用途
2026-09-29 17:52:55 +08:00
lxy 0ca6250ee1 优化: 图片上传默认本地化,业务域名出代码
- 默认上传端点清空,未配置即本地base64内嵌,不再默认传业务文件服务
- 高级设置新增文件上传端点配置行,配置后启用上传(自定义服务协议不变)
- 同步清理注释内域名残留;小程序端同款硬编码已报备待后续处理
2026-09-29 17:52:55 +08:00
lxy 671020b511 清理: 注释去设计任务编号前缀改描述性表述 2026-09-29 15:17:49 +08:00
lxy 71811807d5 清理: 授权开关注释去箭头装饰符 2026-09-29 15:09:54 +08:00
lxy 9962e17710 文档: 上下文管理派工与决策单+UIUX三维走查报告 2026-09-29 14:59:15 +08:00
lxy 8a1675b58c 新增: AI工具授权目录总开关(信任所有目录)
- path_auth判定加bypass旁路:开启时白名单外目录免逐次询问直接放行
- 黑名单凭据目录保护不随开关放行(安全硬边界保留)
- 设置页授权目录面板加开关(默认关,读取失败按关处理)
- 对话区开启时常驻放行提示横幅防静默裸奔,中英i18n齐
2026-09-29 14:59:02 +08:00
lxy 0168617503 优化: 溢出修复与视觉token收敛+三处小清理
- diff长行折行可读/任务描述与产出宽表格横滚/确认弹窗长路径折行
- 滚动条色抽token浅色主题可见/阶段徽章与优先级色对齐语义token
- git状态色与错误色替换散落硬编码
- alert残留换toast走i18n/分离窗口缺参数补错误态/dev路由生产守卫
2026-09-29 14:58:48 +08:00
lxy 864a03d2e1 修复: 页面反馈真实性与竞态守卫八处
- 知识创建失败不再误关弹窗弹成功提示(补返回值检查)
- AuditLog 翻页补请求序号守卫防慢响应覆盖新页
- TaskDrawer 切任务慢响应校验 taskId 才写入
- GitChanges 竞态序号+moduleId 双校验,三处静默失败补错误提示
- 回收站恢复/彻底删除操作中禁用防双击重复提交
- 灵感状态切换失败补错误反馈(原 unhandled rejection)
- FileExplorer 三处失败仅 console 补 Message 提示
- Dashboard 消费 store.error 故障不再伪装空数据
2026-09-29 14:58:35 +08:00
lxy 1d1111ebe7 优化: 工具卡复读修复与默认折叠收敛 2026-09-29 13:33:49 +08:00
lxy 378c363632 优化: 供应商级采样温度覆盖配置
受限模型(经路由透传)仅接受特定 temperature 其它值 400;
provider config JSON 带 temperature 字段则覆盖默认,未配置走 0.7,设置页可编辑。
2026-09-29 13:33:49 +08:00
lxy 7cb32d1380 修复: KV 设置读取引号归一(自动执行三档生效)
前端 appSettings.set 以 JSON.stringify 落库带引号,后端裸读比对失败,
自动执行三档(low/medium/all)自上线从未生效静默走审批;脚本黑白名单同坑,读端统一 trim。
2026-09-29 13:33:48 +08:00
lxy dfa38f6259 修复: 上下文记忆与工作流注入生产侧空壳接通
WorkingContext 写入链落地(用户轮目标钉扎+压缩后 JSON 卡片回填重锚定,含 6 单测);
工作流 DAG 摘要注入生产侧接通(AI 路径启动写入+终态回写,审批调用点透传会话)。
附知识库与记忆走查诊断(「笨/丢历史」根因:消息压缩视图丢失+知识检索四重收口),
todo 登记修复分档(含随批补登 09-28 交互走查索引条目)。
2026-09-29 13:28:56 +08:00
lxy f57e25a277 修复: 审批浮窗无法关闭(托盘拦截误伤+兜底失效)
根因=关窗拦截全局生效误伤浮窗等JS窗口,close被吞成隐藏僵尸永不销毁,
且close() resolve≠销毁使前端兜底被误取消;改仅主窗口拦截,关窗兜底
按tauri://destroyed判定超时强制销毁,主窗口closePopup同步加固
2026-09-28 23:22:55 +08:00
lxy 4840f7ebc6 文档: df-relay 标记 deprecated 已提取为 u-relay 独立仓库 2026-08-23 14:15:13 +08:00
lxy 9753a6f902 优化: git状态色与设置控件token化收敛+看板销账清尾 2026-08-15 18:58:24 +08:00
lxy 13ecd7eba4 优化: 审批超时倒计时(60s变红)+分离窗口监听核验 2026-08-15 18:46:13 +08:00
lxy 871416637a 优化: 前端审批队列核验销账+清理无引用CSS变量 2026-08-15 18:46:13 +08:00
lxy 5ac815b9ca 优化: 清理无意义注释(走查编号前缀/过时历史标注/死代码注释) 2026-08-15 18:05:43 +08:00
lxy 5a4c821629 优化: drainQueue并发互斥+deep watch指纹+前端打磨 2026-08-12 01:00:56 +08:00
lxy 4ad78abad6 优化: 清理编号注释 2026-08-12 00:47:45 +08:00
lxy 7a1e80eac2 优化: workflow记录补project_id+前端审批/交互打磨 2026-08-12 00:40:40 +08:00
lxy d31a27512c 修复: gen_stream判重+重试分类+状态机最短路径+既有测试修复 2026-08-12 00:29:06 +08:00
lxy 9407f821b9 优化: 图像模型兜底防御+任务UI(状态多选+跳详情)+token popover遮挡 2026-08-10 13:46:26 +08:00
lxy 256af4e736 优化: 工具调用标识类型安全加固 2026-08-10 08:04:30 +08:00
lxy 5a1b7a871a 重构: AI执行循环拆分降低复杂度 2026-08-10 08:04:30 +08:00
lxy 6141a67b0e 优化: 审批弹窗全局化与体验打磨 2026-08-10 08:04:30 +08:00
lxy dedb9a8862 修复: 工作流推进链路与执行入口优化 2026-08-10 08:04:29 +08:00
lxy 2473dcfe68 优化: 文件预览大纲提取 + 预览/图标/滚动条打磨 2026-08-10 02:23:53 +08:00
lxy 40224d603a 文档: 待办看板更新 + manifest appid 忽略规则 2026-08-10 00:00:56 +08:00
lxy cf00345c35 优化: 项目文件树/依赖图组件 + 灵感对抗校验 + 小程序脚本 + 模块管理 2026-08-10 00:00:31 +08:00
lxy 6bafdcd5a5 修复: token 汇总正确性(会话级双计/is_estimated跨端/流式usage传递) + is_estimated SELECT 缺失修复 2026-08-09 23:59:55 +08:00
lxy 11f4978ec1 优化: aichat效率剩余(压缩后台化防阻塞/审计批量事务/只读缓存轮内去重/流式增量渲染/AiCommandOutput合批/双渲染合并) + 跨端加固(df-project路径保留大小写/tunnel文档更正supervisor重连/relay固定时间比较与帧上限/启动校验) + 销账 2026-08-09 21:35:59 +08:00
lxy fbd8fae44b 修复: 前端UX一致性(Dashboard状态映射/状态徽章全局/Knowledge下一条与失败反馈/Ideas筛选与错误条/ConfirmDialog安全/快捷菜单状态机/分页越界) + 后端校验(queue/count-list一致/软删拒改/父聚合/promote CAS/MCP状态机收口/update白名单剔除id/created_at) + 销账 2026-08-09 21:35:58 +08:00
lxy 8bc5380ecb 文档: 全系统走查登记(阶段②: 任务/仪表盘/灵感知识59项 + Settings/审计/布局/后端命令95项 + df-mcp/工具crate 35项) + API Key 401 已解决销账 2026-08-09 21:35:46 +08:00
lxy 7eb2e25ed5 优化: aichat效率批(save无变化捷径/断路器尾窗/system缓存指纹/会话列表刷新收敛) + 新增 LLM prompt caching(cache_control开关默认关/system稳定段易变段分离治缓存命中率) + 销账 2026-08-09 21:35:13 +08:00
lxy 4df91ed155 修复: 项目详情批量(任务列表联动刷新/工作流降级标注/中文git乱码/子目录错误行内/零工程空态/切tab保活) + 依赖图批量(节点渲染register/环高亮/防环防重/点击选中) + 销账 2026-08-09 21:35:13 +08:00
lxy 4a9f77872e 文档: 项目详情页走查(60项) + aichat效率走查(74项,含LLM缓存命中率核心发现)登记 2026-08-09 21:35:03 +08:00
lxy 5a92e6056c 优化: AI治理与性能(tool_failure_stats失败率统计命令 + git status仓库级缓存治cmd闪烁N²) + 销账 2026-08-09 21:35:02 +08:00
lxy e099eff4cb 优化: MCP 收尾(update原子CAS治TOCTOU/advance_task数据变更映射/list_trash分页) + miniapp渲染收尾(mention chip可视化/代码块语言标签) + 销账 2026-08-09 21:35:02 +08:00
lxy 37040616bd 优化: aichat 工具体验补强(切生成会话占位气泡治丢首响应/read_symbol符号级提示/失败引导/审批失败toast) + 修复 WorkflowDagDisplay 崩页与 aiChat i18n 缺key 2026-08-09 21:34:53 +08:00
lxy cad76ffdf2 修复: @任务联想源隔离 store.tasks 污染 + DirAuthDialog 拒绝二次确认 + 看板销账(AC弱模型机制 3/5 已落地核对) 2026-08-09 21:34:52 +08:00
lxy 180d8e339f 修复: TaskDetail 操作按钮缺陷(推进竞态/菜单状态机过滤/工作流残留重置/子任务校验/描述编辑) + 看板销账(aichat切换缺陷 14/16 已修核对) 2026-08-09 21:34:52 +08:00
lxy e7228231d1 优化: miniapp渲染(图片/checkbox/代码高亮) + MCP规则共享下沉 + 文档重写 + 节点风格对齐 2026-08-08 21:39:30 +08:00
lxy 9eb2995a74 新增: 工具工作流补全(diff_files工具 + 技能清单注入 + 工作流进度共享 + human端到端测试 + 知识库MCP工具) 2026-08-08 21:24:53 +08:00
lxy 6ba6daf188 优化: aichat 打磨收尾(切换信任保留 + save并发串行 + 后台授权守卫 + 分离窗口失效 + 气泡截图复制) 2026-08-08 21:08:25 +08:00
lxy 65c0f6b2b6 优化: aichat 体验收尾(useAiEvents拆分4子域 + AIC-FIX P1队列/mutex/缓存 + i18n残留抽取) 2026-08-08 20:38:33 +08:00
lxy 4092a8d5bb 优化: 前端体验批次(Knowledge持久化+流式缓存失效 + dfnodes节点注册 + delta 50ms合批 + MCP schema必填校验+bind_directory对齐) 2026-08-08 20:19:19 +08:00
lxy 97525a3143 新增: 任务关联工程(module_id 数据层V41 + AI module 写工具6个 + 任务构造补字段) 2026-08-08 20:01:34 +08:00
lxy fe780c0084 优化: 弱模型治理收尾(AC-2探索预算:单轮上限+目录去重+禁绕行 + AC-5失败画像:合法目标回灌+相近锚点提示) 2026-08-08 19:38:53 +08:00
lxy 02c8d8e5ea 修复: MCP 多进程缺陷(update CAS乐观锁 + 审计落盘 + 空闲超时 + busy_timeout + stdio写回调 + list分页) 2026-08-08 18:43:38 +08:00
lxy 6aa334fc9b 优化: 弱模型工具行为治理(项目/任务清单补id + 缓存命中警告 + read_symbol 提示) 2026-08-08 18:11:21 +08:00
lxy 3dfa043bb6 文档: 待办看板更新(aichat 切换/新建缺陷登记 + F-09 销账 + archive 排除) 2026-08-08 14:20:44 +08:00
lxy 60cc163d62 新增: df-mcp E2E 测试脚本(spawn 真实二进制按行喂 JSON-RPC 断言) 2026-08-08 14:20:32 +08:00
lxy 9f75db5b15 修复: aichat 切换/新建对话缺陷(队列串会话/回切丢消息/审批误终态化/删除复活/切换链路) 2026-08-08 14:20:10 +08:00
lxy 795e05f42d 优化: miniapp 设置页支持编辑 + 移除 Ideas 批量删除功能 2026-08-08 14:19:59 +08:00
lxy 92903702e2 优化: Settings 前端(Provider 模型表格化手动标注 + 布局重构 + 连接密码字段) 2026-08-08 14:19:49 +08:00
lxy b408b2d7a1 优化: 模型能力探测前缀匹配收紧 + 手动标注能力不被测试覆盖 2026-08-08 14:19:38 +08:00
lxy d19784a414 新增: 跨端图片上传(file.1216.top URL 优先,失败回退 base64,协议对齐) 2026-08-08 14:19:26 +08:00
lxy 4ce6a859e7 优化: anthropic 协议图片 URL 预拉转 base64 + 无 Vision 模型温和丢图 2026-08-08 14:19:15 +08:00
lxy c1f2fe16c8 新增: 本地工具代理网关(AI 工具统一走 127.0.0.1:8899 转发 mysql/ssh/mongo/redis) 2026-08-08 14:19:03 +08:00
lxy 75d5a7091a 文档: 待办看板更新(编号碰撞登记 + 父子任务销账 + 批次记录) 2026-08-05 22:15:53 +08:00
lxy e68d283651 优化: UI/UX遗留批(审计/AI命令层/前端组件,会话前基线收尾)
- AuditLog +298(筛选/详情/i18n)+ audit 后端 record/mod

- AI 命令层:generate_image +81 / fetch_url / fetch_search / skills / tool_registry / tools/file / provider / conversation

- 前端组件:AiChat/TopBar/ConversationSidebar/GitChanges/ApprovalPopup/Dashboard/ProjectDetail 等 30+ + composables + i18n

- 诊断文档: aichat历史会话实证诊断-2026-08-04 + project_soft_delete 测试
2026-08-05 22:15:34 +08:00
lxy 28de5d6143 新增: 父子任务支持(数据→后端→前端全链路,会话前基线收尾)
- df-nodes task_advance_node(父聚合推进)+ task.rs 命令(create parent_id 支持/delete 级联软删子任务)+ task_graph 工具

- 前端 Tasks 树形列表(折叠箭头/子进度徽章/缩进)+ 新建弹窗父任务下拉 + TaskDetail 父面包屑/子任务面板

- 设计文档: 父子任务支持设计-2026-08-04
2026-08-05 22:15:01 +08:00
lxy 71fdaac1b4 优化: df-miniapp 跨端 AI Chat 更新(会话前基线收尾)
- ws.ts 心跳 pong 闭环 + useAiChat 看门狗治半连接挂死

- pages 新增 settings/ + chat/conversations 更新 + types events/relay + mdRenderer + tsconfig
2026-08-05 22:14:38 +08:00
lxy a91e950874 新增: MCP HTTP transport + 系统托盘 + 实体解析(会话前基线收尾)
- df-mcp 加 streamable HTTP transport 层(axum 0.7 + tower,server_http.rs),lib.rs pub mod 接线

- src-tauri 加 mcp.rs(spawn_mcp_http + mcp_get_status IPC + mcp-server CLI)+ tray.rs(系统托盘 show_main/setup_tray),lib.rs 集成 + main.rs mcp-server 参数路由

- AI 工具加 entity_resolve.rs(实体解析,tools/mod.rs pub mod 接线)
2026-08-05 22:14:27 +08:00
lxy c480627ba6 优化: 批A前端(timer所有权+会话族+审批conv-scoped+FilePreview+失败反馈)
A1-B1 timer所有权:新建 useTimerOwnership composable(每实例独立ref+onUnmounted清理)+ useToast _timer 下沉 + Ideas debounce 补清理(治跨实例串扰)

A1-B2 FilePreview:reqSeq 双计数器守卫(loadFile/loadDiff 最新seq才写,防乱序覆盖)+ mermaid securityLevel strict + filePath 比对(防跨文件SVG注入)

A1-B3 会话族:load_more 滚顶加载接线(switch透传has_more/earliest_seq+prepend去重+scrollTop恢复)+ switch失败保留视图+报错(仅对话不存在才create-new)+ new/delete失败反馈(withConvOp helper收敛)+ delete清ai_messages孤儿

A1-B5前端契约:isToolFailure 三处加 success===false 判定(useToolCard/ToolResultBody/ToolCard,git只读失败不再绿框)

A2-B10 审批conv-scoped:pendingApprovals 补conversationId + cleanup 按convId filter + 移除全局清(AiError不再误清其他conv)+ :676 dir auth filter方向修

usage打标前端:is_estimated 字段+事件透传+MessageList『估算』角标(详情面板说明)

A2-B9前端:clearChat try/catch 错误气泡
2026-08-05 22:10:50 +08:00
lxy 5667da6cf4 优化: 边界加固(AI loop竞态根治+数据/审批/反馈/并发/错误分类)
AI loop 竞态(P0):per-conv epoch/owner token + 存活心跳治 force_send 双loop + stop 3s兜底误判;旧loop stale 全跳过(guard/emit/save)

agentic 收尾(A2-B8):Fatal 退出落库user消息(镜像Exhausted)+ 入口早退补save + usage is_estimated 打标 + emit_ai_completed_once 单点收敛清审批残留

聊天清理(A2-B9):clearChat 先停loop→DB单事务→内存清(clear_conversation_atomic)+ 前端错误气泡

循环并发(A2-B11):三态 ProviderAcquire(NotConfigured/Acquired/Exhausted)+ 候选循环非阻塞+防抖3次饱和降级+单测

错误分类(A2-B12):stream error帧接入 classify_status_or_class + 关键词保守降级 + 7单测

数据(G1.2/G1.4):purge_with_descendants 级联补全(11表单事务+存在性守卫)+ move_task_queue 单事务收口(两调用方共用)

git只读(G3.1):run_git_status/diff/log success判定(exit_code差异语义,失败结构化{success:false,error})

安全(G5.2/G5.6):create_project 目录Err+name校验 + module.rs 路径遍历DRY(分段匹配修a..b.rs误伤)

幂等(V2/V32):裸ALTER全守卫化 + v1..v40全链重跑幂等测试(16过)

附:remote_bridge await 临时引用修(E0716)+ agentic emit 收敛 E0716 app_state 绑定修
2026-08-05 22:10:32 +08:00
lxy ec9f0bf1ea 优化: 工程实体化(description/stack/status)+ 概览工程列表
- project_modules 加 description/status 列(V40 迁移,stack V34 已有)+ models/repo/INSERT/UPDATE/SELECT 映射

- FileExplorer 工程 CRUD 弹窗加 description(textarea)/stack(input)/status(select)输入,工程有完整身份

- ProjectDetail 概览工程列表升级:name+status badge+path + description 段(2行省略)+ stack tag

- 老工程兼容(None=active),update 部分更新语义,status 归一(active/archived)
2026-08-05 22:10:07 +08:00
lxy 8e4c7c68cf 优化: ProjectDetail多工程列表 + ProjectCard list padding + provider usage诊断
ProjectDetail 概览加工程列表(moduleApi.listProjectModules 显所有 module 名/路径,只读,多工程可见,不只项目级 bind_directory)

ProjectCard list padding 1×(原 0.7× 紧凑文字贴边,panel 14/16px)

openai_helpers apply_openai_sse usage 诊断 tracing(确认 deepseek 报 cache/usage 否,定位 completion 0)

i18n projectDetail.modulesLabel zh/en
2026-08-03 02:28:21 +08:00
lxy a031521776 优化: token分项显示(in/cache/out/reasoning)+ 详情面板 + base前置
token分项(各计费不同,不显 total):df-ai 解析 provider cache/reasoning(openai_compat prompt_cache_hit/miss/reasoning_tokens + anthropic cache_read/creation)+ TokenUsage 加字段(全构造点)+ AiMessage/AiCompleted/DB V39(ai_messages 加 cache_hit/miss/reasoning 列)+ message_repo 映射(持久化)+ 前端 MessageList 显 in·cache·out·reason(in=cache_miss 全价,reasoning 有才显)+ 点击 token 弹详情面板(完整 usage+缓存命中率+model)+ df-miniapp 同步

base前置(提升 prompt cache 命中率):chat.rs aug 拼 base 后(4处)+ knowledge_inject 知识拼 base 后(固定 base 前缀,cache 命中)

附修:replace_conversation 原 13 列 INSERT 丢消息级 token → 改 18 列
2026-08-03 01:22:30 +08:00
lxy 864c696b70 优化: token消息级持久化 + 技能注入修复 + TopBar减法/UI调
token持久化(方案A,治压缩/切会话后历史token不显):ChatMessage/AiMessageRecord 加 prompt_tokens/completion_tokens(serde + DB V38 迁移 + message_repo 映射);agentic push_assistant_message 设本轮 token + provider/title 构造默认 None;前端 AiMessage 加字段 + switchConversation reload 映射 tokenUsage(双轨:消息级新+会话级旧累计保留)

技能注入修复:read_skill_content_stripped 改 skills_cached 扫盘(防御 SKILLS None 致不注入)+ 细化诊断(缓存/path/fs 各步)

TopBar减法/UI:删铅笔新建(与侧栏+重复)/删垃圾桶clear-chat(危险,clear-context归档替代)/删系统就绪装饰占位;更多菜单popout CSS补全(修样式错乱);provider绿点有信息化(绿/红/灰基于AI请求成败)+垂直居中;goals面板补top:100%(修位置飘)+dot/check/remove CSS+goals/history item统一+history index边距;4面板互斥(点一个收其他)

MessageList token v-if 去 !streaming(修发新消息历史token消失)
2026-08-02 18:17:30 +08:00
lxy f736f435bc 优化: 所有剩余UI/UX待办一批完成(持久化+AuditLog+解耦+total+原12大改+P2)
持久化(P1-c):新建 usePersistedRef composable,Tasks/AuditLog/ProjectDetail 等接入 localStorage

AuditLog(P1-d):后端 list_tool_executions 加 WHERE 筛选+返 {items,total,has_more},前端对接+长列折叠+筛选持久化

数据源解耦(P1-g):ProjectDetail projectTasks 按 project_id 独立加载 + ChatInput @项目联想独立加载(不读 store.tasks 当前页)

GitChanges(12a):后端 get_module_commits 加 git rev-list --count 返 total,前端显真实总数

原12大改:Dashboard 统计卡压底行(1)/Projects 列表卡片视图(2)/project_event 埋点排序(3)/TaskDetail 重设计(4)/IdeaDetail 重设计(5)/KnowledgeDetail 重设计(6)/界面持久化+侧栏Ctrl+B+审批数字键(7)/ProjectDetail 三栏改两栏(10)

P2打磨:文件浏览器(FileTree去重/FilePreview行号.md Diff/selectedFilePath归位)/settings反馈(假保存/端口校验)/AI会话(try-catch/scrollIntoView)/后端计数(move_queue事件/timeline total/workflow分页/import_batch分块)/杂项(TopBar/ConfirmDialog键盘/CIStatus i18n/ToolResultBody/ModuleNode/ApprovalDialog全选)
2026-08-02 13:11:06 +08:00
lxy caaabf0c15 优化: 前端批(审批体系一致 + GitChanges UI + token/控件统一 + 死代码)
审批体系一致:ApprovalPopup 浮窗加挂起时长提示(H1)+session 按钮+path deny 二次确认(M4/M5);ToolCard/useToolApproval 加 session+onDeny 确认(对齐 DirAuthDialog 四按钮)+ToolCardList decision 类型加 session;ApprovalDialog 失败显 toast(治 store 吞错 M31);ApprovalOverlay dismiss 改响应式(L26)

GitChanges UI(问题12 前端 12b-12h):showCommitFileDiff 精确匹配(12b)+去重 git log(12c)+详情滑出不挤压(12d)+双行提交行(12f)+搜索过滤(12g)+历史 Tab 显分支(12h)+diff 侧边栏内更宽可折叠面板(12e:FilePreview 不支持任意提交 diff,折中)

token+控件:global.css 定义 --df-primary(治幽灵变量 M26)+settings.css 加 .setting-number/.setting-input/.setting-textarea+4 Section 套用(问题8)

死代码:删 PlanProgress.vue(零引用)+PlanLayerInfo 类型+i18n 孤儿 key(L1)
2026-08-02 12:42:52 +08:00
lxy ffb202e2e9 文档: UX审查P1-a/P1-b销账 + P1-c~g剩余批次 + 延伸登记(ProjectDetail/ChatInput同源store.tasks污染) 2026-08-02 12:30:17 +08:00
lxy d1321af880 修复: P1-b Dashboard统计源解耦(治翻页/筛选后统计数字错乱 H6/H7)
- 统计走后端 count_tasks 独立通道,不再 filter(state.tasks) 当前页数据

- state 加 activeTaskCount/projectTaskCounts 缓存,loadStats action 并发 count 填充 + df-data-changed task 分支联动刷新

- Dashboard loadAll 删 loadTasks 改 loadStats(不再消费 state.tasks 全量)

- ActiveProjectsPanel getProjectTaskCount 改读 store.projectTaskCounts

- 后端零改(复用已注册 count_tasks IPC + count_by_query)
2026-08-02 12:29:44 +08:00
lxy 9e85cadb3f 优化: P1-a 长内容折叠(AI文本气泡+多冲突+错误堆栈)
- MessageList: assistant文本气泡>1500字符折叠(max-height 400px+渐变遮罩+展开按钮),流式中不折叠避免delta重排抖动(M1)

- ConflictResolver: 多冲突(>=3)默认折叠,点击展开diff(M6)

- HelpRequiredCard: context堆栈max-height 200px滚动+等宽字体(M7)
2026-08-02 12:24:20 +08:00
lxy 7f0edced01 修复: UX扩展审查P0批(后端git/路径确定性bug + 前端UI + 编译阻断)
后端(src-tauri):

- project.rs: 4处空if目录校验补return Err(import/relocate/scan_directory/scan_project_with_ai),目录不存在不再静默放行

- ai/tools/git.rs: exec_git改返(String,bool)治git_commit·git_branch create/switch假成功 + git_merge补失败分支 + 加CREATE_NO_WINDOW

- module.rs: run_git_cmd加CREATE_NO_WINDOW,治Windows cmd黑窗闪烁(原问题9)

前端:

- DependencyGraph.vue: 环检测高亮注入renderGraph,原被fromJSON重建抹除致功能失效

- FileExplorer.vue: 工程下拉closeDropdown加closest判定,原打不开

附(预存编译阻断顺带修):

- df-nodes ai_node_helpers.rs: extract_first_json_object临时值借用悬垂E0716

- MessageList.vue: 删isLastUser死代码(vue-tsc TS6133)

审查产出: docs/05-代码审查/UIUX扩展审查-2026-08-02.md(80条发现,8高grep核验全属实)+ docs/todo.md批次段
2026-08-02 12:19:40 +08:00
lxy a28c00b1e5 文档: docs 整理收尾(INDEX 走查条目 + todo 待办梳理 + 待审查登记 + 修复摘要)
- INDEX: 加 claude 走查 5 + 本次修复摘要条目(05-代码审查)
- todo: P2/P3/专项/技能注入 待办登记(B-260801-01 实证不存在关闭)
- 待审查: CR-2026-08-01 13 agent 改动登记
- 修复摘要: 5 根因 + 13 agent + 提交链 + 专项 + 推翻假设 + 教训
2026-08-02 10:51:57 +08:00
lxy b7111fcabc 整理: docs 走查报告归档(walkthrough-2026-08 合并到 05-代码审查,删重复/空目录)
- 我的走查 5 文件(summary/nodes/ai_storage/ideas_frontend/infra)移 05-代码审查/(claude- 前缀区分 DevFlow AI)
- DevFlow AI 重复 3(00/04/05,与 05-代码审查/00-05 同主题)删
- walkthrough-2026-08/ + architecture/(空)删
2026-08-02 10:49:46 +08:00
lxy 8eb689af37 文档: 全库走查报告(docs/05-代码审查 00-05)+ 规范更新 2026-08-02 10:44:10 +08:00
lxy 3f2cf5fa3a 重构: agentic 架构(run_agentic_loop 抽 finish_round_exit + router 调用点 + provider_pool 亲和 enabled)
- run_agentic_loop: 抽 finish_round_exit + emit_ai_completed_once(6 退出点收敛)
- compress/title/knowledge_inject/project: router estimated_context+tier 调用点对齐
- provider_pool: 亲和加 model.enabled 过滤
2026-08-02 10:44:09 +08:00
lxy ea309c607b 修复+重构: 全库走查真bug+架构+P1/P2 后端 crate
- df-nodes: schema required 对齐 + docker POSIX 注入防御 + HumanNode timeout 1800 + parse_review_json(verdict规范/score clamp/正则兜底)
- df-mcp: update 实体校验(防跨实体 B-260801-01)
- df-storage: keyring 迁移失败达阈值清除明文
- df-ai: router estimated_context+tier tiebreak+DataReadOnly 兜底 + sanitize step4 显式不制造 orphan
- df-ideas: adversarial tier:None 对齐
2026-08-02 10:44:09 +08:00
lxy f1773dde4f 优化: 前端走查 P2+UX(status union + ApprovalPopup hack消除/拖动JS API/Esc)
- status union: AiMessage 加 status 字面量联合,删 AiChat/MessageList AiMessageWithStatus 重复+cast
- ApprovalPopup: 删 _placeholder hack(toolDisplayName 提纯函数) + dispatchApprovalIPC 共享分派消除三套重复 + always 按钮语义对齐 + closeWithFallback 兜底 + 拖动 JS API startDragging(避子元素覆盖) + Esc 快捷键
2026-08-02 10:43:30 +08:00
lxy ef77120912 文档: 全库走查报告(walkthrough-2026-08,summary + 4 分方向)
4 workflow/35 维度走查产出:P0(2 已修)+ P1(4 修 + 2 专项登记)+ P2/P3 清单 +
推翻假设(AiChat 798 非 God / human_node 测试 76% 无债)。每发现 file:line 源码佐证。
2026-08-02 03:19:25 +08:00
lxy f719546b12 优化: 前端走查 P1(MessageList/MessageItem 两套渲染收敛)
MessageItem 删 AI/system/error 死分支(301→175 行,仅 user 角色)+ 删悬空 regenerate/retry emits + 清孤立 props/isLastAi;MessageList 挂载清理。消除两套 AI 消息渲染并存(死代码+职责混乱)。
2026-08-02 03:11:44 +08:00
lxy fc249adf17 修复: 全库走查 P0+P1(df-mcp 数据丢失/evaluate 拆 + df-execute probe_pwsh 死缓存/超时/单源 + ai_self_review 注入隔离)
- df-mcp P0: update_idea/project/task 缺省回退 existing(治部分更新清空 title 丢数据)+ P1: evaluate_idea 拆只读(Low)+score_idea(Medium 写),read-only 不再改库
- df-execute P0: probe_pwsh 死缓存(两 OnceLock 合并 PWSH_CACHE 单源,治 Windows 永走 PS5)+ P1: probe_pwsh 3s 超时防挂起 + detect_shell 复用 shell.rs 单源(探测与执行对齐)
- df-nodes P1: ai_self_review prompt 注入隔离(truncate + XML 标签 <task_output> 数据/指令隔离 + system 声明)
- generate_image: SSRF 集成测试 + b64 OOM 估算纯函数测试(走查测试增强)
2026-08-02 03:11:44 +08:00
lxy dffc4e4851 修复: 走查发现(空 id 跨轮全局唯一 + generate_image SSRF/OOM + 技能日志 + fallback 测试)
- 空 tool_call id fallback 改全局 AtomicU64 计数器(跨轮跨 assistant 唯一,治空气泡+
  L1 误熔断+三元组错位,实证 af2fab4e);fallback 测试硬编码改 starts_with(解耦计数器)
- generate_image provider POST 补 SSRF 防护(复用 http.rs validate_url/resolve_and_check_host,
  治 base_url 用户可配打内网)+ b64_json 解码前估算长度防 OOM
- SkillResolver 加 tracing 日志(定位技能发送后未注入:name 匹配/read 失败)
2026-08-02 02:42:02 +08:00
lxy 953a3fbba1 优化: prompt 策略(obscura 失败重试 + 精确不猜 + 工具职责 + get_app_config 引导)
obscura render 失败 2 次换方案 + 成功即收手;精确提取链接不臆造 URL;fetch_url/download_file
/http_request 职责区分;get_app_config 引导(禁 run_command 查配置)。
2026-08-02 02:21:33 +08:00
lxy 50c232643d 新增: provider 支持(11 厂商预设 + modalities 修正 + defaultModel 更新 + 噪音过滤 + UI 提示)
providerPresets 扩展 6 厂商(Qwen/Kimi/ERNIE/Spark/Doubao/Gemini)+ SenseNova base_url 修正;
modalities 预设表补 SenseNova flash-lite vision;defaultModel 过时更新;sensenova-u1 图像生成
噪音过滤;ProviderPanel defaultModel UI 提示。
2026-08-02 02:21:33 +08:00
lxy b999f79fdf 新增: AI 工具(download_file + http output_file + grep 窗口 + fetch_search 搜索 + get_app_config + generate_image)
download_file 跨平台 URL 到文件流式下载;http_request output_file 落盘 + 截断可配;
grep context_chars 大单行窗口;fetch_search DuckDuckGo 免 key 搜索;get_app_config
只读返当前配置(治 AI 查配置绕 PowerShell);generate_image SenseNova 图像生成。
2026-08-02 02:21:32 +08:00
lxy e4f7b432aa 新增: 多模态图片 URL 转 image part + 修复 / 技能联想浮层定位
前端发送前扫图片扩展 URL 转 ContentPart image(模型原生视觉,非工具下载);
SkillMention 移入 .ai-input-wrap relative 内(治 / 浮层定位错位不可见)。
2026-08-02 02:21:32 +08:00
lxy 023377ab24 修复: read_file 可靠性 + obscura 渲染(压缩豁免 + UTF-16 闭环 + stdout 救回)
read_file 压缩豁免(AI 定向读代码不折叠)+ UTF-16 BOM 闭环(read_file/read_symbol/patch_file);
obscura 非零退出码救回 stdout + fetch markdown 空壳 fallback scrape --eval + 探测日志。
2026-08-02 02:21:31 +08:00
lxy 57d6a2d066 修复: AI 对话/工具可靠性(sanitize 三元组 + G2 签名重复 + handshake 不杀 loop + 空 tool_call id 兜底)
治 5 个对话停止/工具失败根因:sanitize 三元组按 id 配对治 400;G2 探索熔断从结果空
改签名重复判定(治误停正常探索);handshake 删越权强杀活 loop(generating 归 guard 单源);
空 tool_call id 兜底 gen_<index>(治 SenseNova 工具结果路由错位)。
2026-08-02 02:21:30 +08:00
lxy c76e77bd3c 修复: 审批挂起关不了(None分支补emit+恢复对账) + obscura检测区分未装/失败(防误弹) 2026-08-01 22:26:23 +08:00
lxy 31aba79512 修复: fetch_url obscura spawn 加 CREATE_NO_WINDOW(治 CMD 窗口闪烁) 2026-08-01 20:48:50 +08:00
lxy 3afaff9efe 新增: obscura 安装引导(prompt AI 看 hint 帮装 + 前端工具卡片 AI 帮装按钮) 2026-08-01 18:07:56 +08:00
lxy d664bdc309 优化: run_command 实时流式 + 审批浮窗修复 + 文档探索闭环(fetch_url+obscura引导+model_fetch兼容+prompt策略+厂商预设) 2026-08-01 17:58:47 +08:00
lxy 0e0c6862ba 优化: Provider 配置体验(测试读表单值 + 保存反馈 + 报错友好 + 厂商预设,部分完成) 2026-08-01 16:35:49 +08:00
lxy 1a5d543fa1 修复: 消息三元组完整性(sanitize 补头自愈 orphan tool_result,治 AI 看不到工具结果致静默停) 2026-08-01 16:34:33 +08:00
lxy 2b08e32c26 修复: 审批浮窗 3 bug(打开面板权限/审批后空转自关/关闭顺序) 2026-08-01 16:05:44 +08:00
lxy 7193901bc4 修复: IPC update_project/task 实体校验(false→Err 治假成功)+ status 白名单确认(B-260801-01) 2026-08-01 16:04:31 +08:00
lxy 8c0ff80cd4 新增: aichat Plan-driven Phase1(LLM 出 Plan + 开关默认关 + 三重兜底 + emit AiPlanCreated) 2026-08-01 16:01:11 +08:00
lxy a69057a1ef 新增: aichat Plan-driven 设计文档(对话探讨成果固化) 2026-08-01 15:54:53 +08:00
lxy dbd226b403 修复: 审批浮窗 reason 字段缺失(push 补 reason)+ 空态文案 2026-08-01 15:54:32 +08:00
lxy 1ddf48d40f 新增: 审批系统级浮窗(WebviewWindow 自定义样式 + 内容/直接操作 + 点击回审批) 2026-08-01 15:30:03 +08:00
lxy 7d33442199 修复: extract_key_info 截断标记统一 + 断路器 key 滑动窗口 + 桌面跨端 4 路由 + dead_code 预留标 allow 2026-08-01 15:17:24 +08:00
lxy db99e7107c 修复: OpenAI SSE error 识别 + 条件引擎 warn + MidStream stop_flag 优先 + 双窗口 listener 时序加固 2026-08-01 14:53:03 +08:00
lxy ba578dfe6c 修复: 灵感 CRUD 工具补齐 + handler 透传 affected(B-260801-01) 2026-08-01 14:43:27 +08:00
lxy 6b68aa63e8 重构: tool_registry 剩余 21 工具迁声明式(task_graph/git/http/workflow/idea/trash) 2026-08-01 13:30:05 +08:00
lxy c27fd75d20 重构: tool_registry file 工具迁声明式(13 工具→tools/file.rs,tool_registry 减 1091 行) 2026-08-01 13:20:36 +08:00
lxy b68dc363d4 重构: tool_registry task 工具迁声明式(6 工具→tools/task.rs) 2026-08-01 13:10:16 +08:00
lxy 1920bcbd38 重构: tool_registry 项目工具迁声明式(7 工具→tools/project.rs) 2026-08-01 13:05:33 +08:00
lxy c002f0b352 重构: tool_registry 声明式注册基础设施(declare_tool! 宏 + list_projects 试点,零回归) 2026-08-01 12:56:19 +08:00
lxy 438f9692f2 新增: 任务创建关联灵感下拉(F-260619-01 补创建入口) 2026-08-01 12:47:12 +08:00
lxy c34ad062e7 优化: 消息落库增量写(Phase1:全量 DELETE+INSERT → 增量 INSERT + dirty 标记) 2026-08-01 12:42:49 +08:00
lxy 484080ac12 修复: unwrap 吞错 + lock 中毒 panic 高危项(数据吞 warn 跳过 / lock 降级返 Err) 2026-08-01 12:21:52 +08:00
lxy 143b859727 新增: 迁移全量测试(覆盖 V1-V37 + 关键表/列/schema_version) 2026-08-01 12:13:56 +08:00
lxy d7e2845881 修复: 断路器失败判定纯结构化(失败 content 包 JSON envelope,is_failure_content 只读字段不解析内容文本) 2026-08-01 12:00:23 +08:00
lxy 20ec571dbb 修复: checkpoint 参数化 + 压缩 permit 死锁降级 + 密钥归一化 + SSE CRLF/多字节 2026-08-01 11:30:05 +08:00
lxy c4ba920cf5 修复: spawn panic 兜底 + namespace 淘汰提示 + run_command 超时与破坏命令信任门控 2026-08-01 11:10:28 +08:00
lxy 468950616b 修复: namespace 占位符发 LLM 前未展开 + key 含斜杠丢原文 2026-08-01 10:42:09 +08:00
lxy bd9031d35d 重构: 巨函数拆分 + 清理历史标记注释 + custom_prompt/停止按钮/tunnel 改进 2026-07-31 21:36:12 +08:00
lxy 365af554da 修复: max_iterations 不收敛卡死 + stopBtnState 残留红 2026-07-21 00:31:59 +08:00
lxy 0b68c5bed9 修复: AI 回复以好问题等开场 + 达 max_iterations 不收敛卡死
日志分析发现:
- 零条 AiCompleted/AiError: agentic loop 达 max_iterations 后进入暂停态
  (F-260616-03), generating 保持 true 致前端卡在等待+发送队列
- LLM 持续调工具不收敛输出文本, 10 轮耗尽后不自动完成

system prompt 修复:
- 新增「直接回答用户问题,不要以评论开头」严禁用好问题等无信息开场白
- 新增「及时收敛」指令:目标达成后立即输出结果, 停止调无用工具
- concat! 重构 behavior_prompt 避免单行长字符串
2026-07-20 13:08:27 +08:00
lxy 7594a5b7f3 新增: MemoryAdapter trait + ContextManager 默认实现
crates/df-ai/src/memory_adapter.rs:
- MemoryAdapter trait: 15 个核心方法(写入/读取/压缩/生命周期)
- ContextManager 作为默认实现(行为零变化)
- 预留 VectorMemoryAdapter 接入点

lib.rs: pub mod memory_adapter 导出
2026-07-20 09:51:12 +08:00
lxy c8f35a7211 新增: V37 migration + T6 版本化快照(自动 checkpoint)
迁移 V37:
- conversation_checkpoints 表: id/conv_id/snapshot/token_total/label/created_at
- 索引: (conv_id, created_at DESC)

save_conversation_inner:
- 每 20 轮自动创建 checkpoint(snapshot = JSON 全量消息)
- 防撑爆: snapshot < 1MB 才写
- checkpoint ID: ck_{conv_id}_{token_total}(幂等,INSERT OR IGNORE)
- 为 T6 后续的 IPC list/restore 准备数据基础
2026-07-20 09:10:47 +08:00
lxy e222d38e7c 新增: T4 工作流 DAG 注入完整实现
PerConvState:
- workflow_id: Option<String> — 关联的工作流 ID
- workflow_dag_summary: Option<String> — 预计算 DAG 摘要

system_prompt 注入:
- 检测 workflow_dag_summary,非空时注入 [工作流] 块

workflow_context.rs:
- build_dag_summary: 从 DagDef 构建可读 DAG 摘要文本
- extract_active_path: 提取从根到当前节点的活跃路径
- topological_layers: 拓扑分层排序(含环检测)
- 3 个单元测试覆盖线性 DAG / 中途路径 / 空 DAG
2026-07-20 08:58:37 +08:00
lxy 1eb2ca7dc5 新增: 上下文管理演进 Wave3(T5 WorkingContext 层注入)
PerConvState: 新增 working_context 字段
- 初始化 WorkingContext::new() 默认空

system_prompt 注入:
- L1a: 当前步骤(每轮必带,调用 build_injection)
- L1b: 脏标记条件注入(dirty_window=3,有变更才带)
- 拼入「## 工作上下文」段

T3 联动: compress 成功后的 reset_all 占位已在 context_lifecycle.rs 预埋
T4: 工作流注入点保留为独立段(待 workflow_id 字段落地)
L1c (read_context 工具): 待注册(依赖 tool_registry 接线)
2026-07-20 08:11:40 +08:00
lxy 3943d68430 新增: 上下文管理演进 Wave2(T3结构化摘要 + T4工作流DAG占位)
T3: 压缩摘要双格式(向后兼容)
- compress_via_llm 返回类型 Result<String> → Result<CompressedSummary>
- 新增 parse_sections_to_json: 从四段式 Markdown 摘要解析 JSON 卡片
- context_lifecycle: 插入 NL 摘要(向后兼容),预留 WorkingContext 联动
- manual compress IPC (chat.rs): 适配新返回类型

T4: 工作流 DAG 注入(占位)
- workflow_context.rs: 空壳函数,待 PerConvState.workflow_id 落地后接入
- mod.rs: 注入点已就位(日志占位)
- 后续需补: DAG 加载 + 活跃路径裁剪 + system_prompt 注入
2026-07-20 08:01:34 +08:00
lxy faa61a9ba5 新增: 上下文管理演进 Wave1(契约定义 + T1动态阈值 + T2命名空间)
Agent 3: 共享契约类型定义
- CompressedSummary(T3输出格式)
- WorkflowContextBlock(T4工作流DAG上下文)
- WorkingContext + GoalItem/DecisionItem/StepInfo(T5常驻上下文)
- 脏标记版本追踪 + reset_all 压缩联动

T1: 动态压缩阈值
- maybe_auto_compress 加 sys_tokens 参数,触发条件从 budget*0.6 改为 available*0.6
- 调用处传 sys_tokens(loop顶部已缓存)
- 3 行核心改动

T2: 工具命名空间存储
- crates/df-ai/src/namespace_store.rs: 完整实现 + 单元测试
- process_tool_calls: Low/Med/High 路径 namespace 路由
- save_conversation: namespace 引用展开为原文再落 DB
- AiSession 新增 namespace_store 字段
2026-07-20 01:05:20 +08:00
lxy b1d7deece1 新增: 上下文管理演进设计文档(发散思考 + 任务技术设计)
发散思考文档涵盖:
- ContextManager 三维评估(效率/合理性/成本)
- 业界方案对标(Claude Code/OpenAI SDK/Mem0/LangGraph 等)
- 融合设计: 结构化分层上下文引擎(L1常驻/L2历史/L3摘要)
- 9 种方法交叉论证(决策矩阵/ROI/帕累托/Kano/风险矩阵等)
- 7 个发散方向与冲突分析,推荐派系 A 路线

任务技术设计文档涵盖:
- T2 工具命名空间: 大工具结果不进主队列,NamespaceStore 详细设计
- T4 工作流 DAG 注入: 活跃路径裁剪,结构化 DAG 进 system prompt
- T3 L3 结构化摘要: JSON+NL 双格式压缩摘要
- T1 动态压缩阈值: 精确到行的 3 处改动
- 4 种边界情况推演,AI coding 多 agent 并行策略
2026-07-20 00:53:53 +08:00
lxy e9e3578d26 修复: DeepSeek 400 全量扫描 + 队列 per-conv 隔离
- openai_compat: 扫描所有 assistant 消息剥离 orphan tool_calls(原仅查末条)
- queue 加 conversationId 字段,按会话精准 drain
- regenerate/editMessage 只清本会话排队消息
- newConversation 保留旧会话排队消息
- AiError 只清出错会话的队列项
2026-07-20 00:19:50 +08:00
lxy 42efb31bbf 优化: Zed 编译卡死治理(musl 隔离 + rust-analyzer 配置)
- musl 交叉编译: 文档化 CARGO_TARGET_DIR=target-musl 隔离方案, 删除现有 349M 产物
- gitignore: 新增 target-musl/ 规则
- .zed/settings.json: 新建项目级 rust-analyzer 配置
  - 关闭 check.onSave (Zed 卡死主因: 每次保存触发全 workspace check)
  - check.workspace=false (即便手动也只查当前 crate)
  - cargo.allTargets=false (跳过 musl/交叉 target 解析)
  - files.excludeDirs 排除 target/node_modules 等
  - 保留 procMacro/tauri-codegen enable (否则宏会报红)
2026-07-04 00:53:58 +08:00
lxy 0dfaeb0f5b 优化: AI Chat 体验全面升级(图标/审批/目标提取/窗口管理)
- AI 入口图标:灯泡→星花 sparkles+发光,与灵感入口区分
- 目标提取根本性改造:用户消息规则→工具调用推理(infer_goal_from_tool_calls)
  - Vec<String>→Vec<GoalEntry>(text+status),active→completed 状态流转
  - 多条/轮提取,system_prompt 仅注入 active 目标
  - 代码拆分到 helpers.rs(解决 brace 嵌套致 pub(crate) 不可见)
- 审批修复:patch_file 全档位自动放行,批量审批防抖,乐观更新竞态保护
- 审批通知浮层:全局右上角 ApprovalOverlay,窗口遮挡时可见
- 设置导入导出移除:价值低占用空间,跨设备同步建议复制 SQLite
- 分离窗口默认置顶+置顶状态持久化(刷新后保持)
- header 菜单聚合:清空/压缩上下文收入 ... popout
- LLM 文字重叠防御:delta 重复检测+丢弃
- 置顶图标:星→大头针 pushpin
- 文档/注释更新:过期 Vec<String>/chat.rs 提取描述修正
2026-07-03 13:07:03 +08:00
lxy aa0701a2ea 优化: 响应式布局走查修复(断点统一+窄屏适配)
- 统一断点: Dashboard/Ideas/Projects/ProjectDetail 的响应式断点
  从 900px 改为 768px,与 AppLayout/AppSidebar 对齐(消除中间区间挤压)
- filter-bar 全局加 flex-wrap:wrap(窄屏自动换行,Ideas/Tasks/AuditLog 受益)
- IdeaDetail debate-container 窄屏双栏堆叠(@media 760px 切单列)
- modal-box min-width 改用 min(360px,90vw)(超小窗口不撑破)
- Tasks search-input 改 flex 布局(窄屏自适应宽度)
2026-07-03 02:35:14 +08:00
lxy 89cfbc7499 新增: 左侧导航支持收缩展开(手动+小窗口自动)
- 手动收缩:brand 区右侧收缩按钮,收缩后底部展开按钮
- 小窗口自动收缩:窗口宽度≤768px 自动切换为图标模式
- 状态持久化:用户偏好存 appSettings(df-sidebar-collapsed)
- 收缩态:仅显示图标,hover 显示 tooltip 文字
- 过渡动画:width/min-width 0.2s ease 平滑收缩
2026-07-03 02:17:26 +08:00
lxy 3d8b755229 重构: 前端DRY收口+后端测试类型对齐
- 新增 useToast composable: 消除 AiChat/Settings/Projects 4处 toast 重复
  统一默认3000ms(Projects原4000ms为操作类提示保留参数覆盖)
- 新增 utils/json.ts parseJsonArray: 消除 parseStack/parseTags/ModuleNode
  3处JSON字符串数组解析重复
- 新增 utils/html.ts escapeHtml: 消除 useMarkdown/FilePreview 2处重复
- ProjectDetail score-bar 内联三元改用 scoreTier(消除最后一处阈值硬编码)
- ConversationSidebar 删除 formatTime 透传包装(直接用 formatRelative)
- 清理死代码: parseTs/stringifyError/ErrorSink/_Unused 改私有或删除
  wrapNakedDiff 改私有(无外部 import)
- ModuleNode shortPath 改名 truncatedPath(与 useToolCard.shortPath 语义不同)
2026-07-03 00:18:21 +08:00
lxy 249b3b9ea8 重构: 跨模块样式DRY收口+审批列表增强+灵感模块优化
- 全局样式收口: page-header/btn/modal/filter-bar/back-link/empty-state
  等通用类从12个视图的scoped重复定义提取到global.css/components.css
- 审批列表增强: 筛选栏(状态/风险/工具搜索)+行展开查看完整参数结果
  +审批耗时显示+刷新spinner+硬编码颜色全部改用CSS变量
- 灵感模块: scoreTier统一评分阈值+score-bar/assessment-badge提取全局
  +statusLabelKey消除重复+i18n状态文案对齐+eval-report--muted统一空态
- Dashboard: df-panel/df-link提取全局(修scoped不渗透)+统计卡可点击跳转
  +活跃项目面板只显示active状态+灵感列表按分数排序+刷新spinner
- 修复: intent.rs tool_type类型对齐(newtype)+ToolCardList补ai-btn定义
  +SkillMention/ImageInput样式不渗透修复(提取全局)
2026-07-02 23:24:11 +08:00
lxy eee0f06e1a 新增: 合并产出落回主对话(Coordinator 接线)
- agentic/mod.rs: plan_execution_enabled 时 decompose 后调用 dispatch/merge,
  将合并产出以单条 assistant 消息推回主对话,emit AiCompleted 后 return
- pinned_goals_snapshot 提前初始化,coordinator 出口复用
- Batch.md: 新增 Batch 38 记录,更新 Batch 37 提交 hash
2026-07-02 20:19:39 +08:00
lxy 46246880b3 重构: String→newtype 强类型 + 工程清理
- df-types: ExecutionId/ToolCallType 从 type 别名改为 newtype 结构体
- df-ai-core: 新增 ToolType newtype / MessageStatus 枚举,
  ChatMessage.status 从 Option<String> 改为 Option<MessageStatus>
- provider: is_active() 改用 MessageStatus::Active 模式匹配
- context/chat/conversation: 构造站点更新为枚举变体
- .gitignore: 添加分析脚本排除项,清理根目录临时文件
- Batch.md: 更新最新提交与新增 Batch 37 记录
2026-07-02 20:11:52 +08:00
lxy b18740405c 修复: run_command 空路径报错 + 隧道日志洪水 + 启动残留清理
- tool_registry: run_command working_dir 空字符串 "" → None,修 Windows os error 123
- lib: tunnel subscriber 从频率压制(suppress_until)改为连接状态感知(is_connected),
  断开时静默丢弃事件,重连后恢复透传,记 INFO 状态变迁(治本)
- conversation_repo: 新增 cleanup_stale_pending(),超 24h 残留 pending 标记 interrupted
- restore: 启动时先清理过期 pending 再恢复审批
- state: 实现 cleanup_orphan_pending_messages(),启动时清理对应已决/超时 tool 的
  __PENDING__ 占位消息
2026-07-02 15:47:47 +08:00
lxy d99a65f034 优化: 任务分页watcher+设置页确认弹窗复用+进展文档更新
- Tasks.vue: 补全 page/pageSize watcher,翻页触发后端查询(原翻页只改前端切片)

- Settings.vue: 自建确认弹窗替换为 ConfirmDialog 组件,删除重复 CSS

- PROGRESS.md: 更新项目速览(多Agent架构+40+工具+人设系统)
2026-07-02 13:27:49 +08:00
lxy 8fa3a93eec 修复: 用户消息气泡头像位置(MessageItem提取后scoped样式缺失)
- MessageItem.vue 提取时未带 scoped CSS,MessageList 的 scoped 样式不穿透子组件

- 补齐用户消息 flex-direction:row-reverse(头像靠右)+ 完整 scoped 样式
2026-07-02 13:22:32 +08:00
lxy 3f22fd3673 优化: i18n硬编码清扫(8文件20+处中文字符串替为i18n)
- ToolCard: 已等待 → aiTool.waitTime

- ErrorBoundary: 堆栈详情 → error.stackDetails

- TopBar: 10处硬编码(tooltip+section标签+单位) → aiChat.* keys

- FileExplorer: 4处工程操作title → fileExplorer.*

- Tasks: 快捷菜单4处 → tasks.*

- EnrichmentPanel: 取消关联 → aiChat.enrichmentDismiss

- PlanProgress: 复用已有 aiChat.conflictsPending

- WorkflowDagDisplay: 删除中文fallback(已有i18n key)

- vue-tsc + vite build 通过
2026-07-02 12:19:57 +08:00
lxy 34944df764 修复: 预存测试编译错误+快捷键绑定+批次记录同步
- df-types: augmentation 测试 5 处缺 extra 字段补齐(17 测试全绿)

- AiChat: Ctrl+N(新建对话)+Ctrl+R(重新生成)快捷键绑定(Tauri 下 preventDefault 可靠)

- Batch.md: 标记已完成项,仅剩合并产出落回主对话待接线
2026-07-02 12:16:55 +08:00
lxy c3b47b5771 修复: 前端UI审查问题(项目状态badge+confirm替换+加载态+CSS修正)
- ProjectDetail: 加载态/不存在态 + stage badge CSS 类名修正(7个实际状态)

- Tasks: confirm()替换为useConfirm + 死代码清理 + priority CSS去重

- Knowledge: 列表加载态

- Projects: CSS变量名拼写修正(--df-mono→--df-font-mono)

- EmptyState: opacity从容器移到图标(对比度修复)

- Ideas: 5处内联style替换为modal-field class

- ProjectDetail: 删除注释stages死代码
2026-07-02 12:12:29 +08:00
lxy acf0ed091d 重构: 前端God文件拆分(StreamRenderer+EmptyState+MentionPopover+EnrichmentPanel)
- useStreamRenderer.ts: 流式Markdown块级渲染提取为composable(220行)

- EmptyState.vue: 空状态独立组件(无provider引导+示例问题)

- MentionPopover.vue: @实体联想浮层独立组件

- EnrichmentPanel.vue: @项目enrichment预览面板独立组件

- MessageList.vue: 1386→1148行, ChatInput.vue: 1155→1007行

- AiChat进度条: completedTools计数器(AiToolCallCompleted+1,AiAgentRound重置)

- i18n: 进度条文案加已完成工具数(中英文)
2026-07-02 10:11:15 +08:00
lxy 54ffc868b2 重构: context.rs拆分(sanitize独立子模块)+架构文档节点状态同步
- context.rs(1956行)拆分为 context/mod.rs(538行)+sanitize.rs(761行)+manager_tests.rs(719行)

- sanitize 函数移至子模块,ContextManager 保留委托方法(零行为变更)

- 46个 context 测试全绿(11 sanitize + 35 manager)

- ARCHITECTURE.md: 8节点全部标记已实现,Coordinator 标记已实现
2026-07-02 01:33:56 +08:00
lxy d5b0459a8a 新增: DockerNode+CI状态读取+模板CRUD+CIStatus面板
- DockerNode: 环境检测(docker --version)+授权+容器内执行(12个测试)

- ci_status.rs: Gitea commit status API 读取(7个测试,失败返回空不阻塞)

- CIStatus.vue: CI检查面板(通过/失败/pending 汇总+可点击跳转)

- 模板CRUD IPC: list/save/delete templates(内置只读+自定义KV持久化)

- TemplateInfo 结构体(IPC传输用)

- 零编译警告,vue-tsc+vite build 通过
2026-07-02 01:02:39 +08:00
lxy f1fb8655c3 更新: 批次记录同步(审批策略+小程序核验完成+散项进度) 2026-07-02 00:21:48 +08:00
lxy d10d7ff330 新增: 审批策略Persona维度(reviewer/analyst全auto+扩展点)
- should_auto_for_persona: 叠加在 classify_risk_and_auto 之上的人设维度判定

- reviewer/analyst 人设工具调用全 auto(只读工具审批无意义)

- 预留 Phase 2 工具维度扩展点(函数内加 if 分支即可)

- 零编译警告
2026-07-02 00:20:48 +08:00
lxy 61d3e9c148 新增: Reviewer仲裁+命令互斥锁+冲突解决IPC(批次C散项)
- coordinator: arbitrate_conflicts 方法(规则仲裁:成功方优先/双方成功推荐合并/都失败手动)

- coordinator: ConflictItem 加 subtask_a/subtask_b/recommendation 字段 + ConflictResolution 枚举

- command_lock.rs: CommandLockRegistry (dir+cmd首词 Semaphore 串行化,5个测试全绿)

- resolve_conflict IPC: 解决冲突→更新DB→emit AiConflictResolved 事件闭环

- 3个仲裁测试 + 5个互斥锁测试全绿

- 零编译警告
2026-07-02 00:17:00 +08:00
lxy 840949ad05 修复: 代码审查发现的安全与兼容性问题
- git_worktree: commit config 改用 --local 避免污染全局 git config

- git_worktree: merge-tree 改用旧版兼容语法(三参数替代 --write-tree)

- coordinator: executor 'static 约束加注释说明(JoinSet spawn 要求)

- coordinator: Token reserve 按层 SubTask 数累加(原固定 10k 不准确)

- coordinator: 更新结构体注释(反映当前并行实现而非 Phase 1 串行)

- 39 个测试全绿,行为零回归
2026-07-01 23:37:58 +08:00
lxy 3e93a2cd70 更新: 批次记录同步(仲裁合并+模板系统+节点补齐完成) 2026-07-01 23:20:46 +08:00
lxy 6e44878021 重构: agentic主循环拆分(提取自动压缩逻辑到context_lifecycle)
- context_lifecycle.rs: F-15自动压缩触发逻辑(maybe_auto_compress)

- title_lifecycle/knowledge_lifecycle: 文档说明(已在之前拆分到title.rs/knowledge_inject.rs)

- mod.rs从2222行降至2071行,行为零变更

- cargo check零警告通过
2026-07-01 23:19:34 +08:00
lxy 27b4268acb 新增: SubflowNode嵌套子工作流 + 3个内置模板
- SubflowNode: 加载子DagDef递归执行,深度限制防无限递归,5个测试全绿

- 内置模板: 代码审查(analyze→report→notify)

- 内置模板: Bug修复(locate→fix→verify→notify)

- 内置模板: 功能开发(design→implement→test→review→notify)

- 模板含persona分配(reviewer/analyst/coder/tester/architect)
2026-07-01 23:10:13 +08:00
lxy f40287bb00 新增: 模板系统+工作流节点(YAML加载器+GitNode/HTTPNode/NotifyNode)
- template_loader.rs: YAML→DagDef加载器+校验(空节点/未知类型/依赖环/边引用)

- GitNode: 分支/checkout/commit/merge/push/status/log(git CLI封装)

- HTTPNode: GET/POST/PUT/DELETE(reqwest,30s超时)

- NotifyNode: 桌面通知(日志)+ Webhook(飞书/钉钉/自定义)

- 8个模板加载器测试+37个节点测试全绿
2026-07-01 22:45:56 +08:00
lxy 1d580dccc3 新增: 仲裁合并(冲突检测+ConflictResolver+reviewer仲裁基础)
- Coordinator.merge: 跳过失败SubTask+同文件冲突检测+extract_written_files

- ConflictResolver.vue: 双栏diff展示+接受A/接受B/合并/手动按钮

- i18n: 冲突相关翻译键(中英文)

- 7个merge测试全绿(含4个新增冲突检测)
2026-07-01 22:32:27 +08:00
lxy 9947e6c1fe 更新: 批次记录同步(数据层+worktree+并行执行完成) 2026-07-01 22:26:12 +08:00
lxy 3c9077b043 新增: 并行调度+Token预算池+事件协议+前端类型+编译警告清理
- Coordinator.dispatch_with_budget: JoinSet层内并行+层间串行+预算超限降级串行

- TokenBudgetPool: AtomicU64 CAS无锁并发安全,0=不限制

- 4个新事件: AiPlanCreated/AiSubTaskStatusChanged/AiMergeCompleted/AiConflictResolved

- 前端类型: PlanRecord/SubTaskRecord/ConflictRecord/PlanLayerInfo/ConflictInfo

- PlanProgress.vue: 接入真实状态+persona徽章+冲突徽章+i18n

- 编译警告全部清零(audit子模块allow+事件allow+record allow)

- Coordinator测试27个全绿(含4个Token预算+4个并行调度)
2026-07-01 22:25:18 +08:00
lxy 4483358f6b 新增: Git worktree生命周期管理(创建/提交/合并/冲突检测/清理)
- create_plan_worktree: 创建Plan级工作分支

- create_worktree: 创建SubTask专属worktree(天然文件隔离)

- commit_worktree: worktree内提交改动(含user.email兜底)

- merge_branch: merge-tree预检+merge实际合并,返回Clean/Conflict

- remove_plan_worktrees: 批量清理Plan所有worktree+分支

- 7个集成测试全绿(隔离/并行/提交/无冲突合并/冲突检测/非Git降级/清理)

- 使用真实Git+tempdir测试(参考df-execute/tests/shell.rs模式)
2026-07-01 21:54:30 +08:00
lxy ae6d3d0043 新增: 多Agent数据层(V36迁移+3新表+Repo CRUD+7个测试全绿)
- V36: ai_plans/ai_subtasks/ai_conflicts 3 新表 + ai_messages/ai_tool_executions 加 subtask_id

- models: PlanRecord/SubTaskRecord/ConflictRecord 结构体

- PlanRepo/SubTaskRepo/ConflictRepo CRUD(insert/get/list_by_*/update_status/resolve)

- 7个 Repo 单元测试全部通过(内存 SQLite + 全迁移)

- 顺带修复 task_repo.rs 预存测试编译错误(trec_full 参数类型+缺失 await)
2026-07-01 21:50:16 +08:00
lxy f4fc0feff5 新增: 测试落地实施方案(Rust单元+集成+vitest+E2E手动验证)
- 落地方式:Rust单元(mod tests) / Rust集成(tests目录+tempdir) / 前端vitest(需引入) / E2E手动检查清单

- 每个用例类别的文件位置和执行命令

- Mock策略:LLM闭包注入/Git真实tempdir/DB内存SQLite

- 批次验证流程:编码→cargo test→vue-tsc→vite build→手动E2E→SQL验证→提交

- CI自动化远期路径(cargo test + bun test)
2026-07-01 21:37:07 +08:00
lxy 6833792de2 新增: 后续批次测试用例设计 + 批次规划同步worktree方案
- 测试用例:8大类共70+用例(迁移/Repo/拆解/worktree/并行/仲裁/模板/CI/审批/拆分/端到端/边界)

- 批次同步:数据层+worktree+并行(12项)/仲裁+冲突UI+编译检查(10项)/模板+节点(10项)/Git-CI+DockerNode(6项)/审批政策+小程序+收尾(8项)

- INDEX注册两份设计文档
2026-07-01 21:30:58 +08:00
lxy af2e085bea 更新: 多Agent隔离机制改用Git worktree替代自建暂存区
- 隔离:git worktree天然分支隔离,复用diff/merge/conflict能力

- 合并:git merge-tree三方合并预检+git merge实际合并到plan分支

- 冲突:四层防线(层间串行/worktree隔离/编译检查/命令互斥)

- 展示:合并产出落回主对话采用可展开单条(方案C)

- 类型:SubTaskRecord加branch字段,ConflictRecord加conflict_type

- 降级:非Git工程串行执行(无worktree隔离)

- 实施分批同步更新(含git_worktree.rs+编译检查+命令互斥锁)
2026-07-01 21:26:55 +08:00
lxy 583058acd6 新增: 多Agent并行执行与仲裁合并设计文档
- 数据模型:混合方案(messages加FK列 + Plan/SubTask/Conflict独立表)

- 并行策略:层间串行+层内并行,写SubTask降级串行,token预算池

- 上下文隔离:子Agent独立ContextManager+fork快照,合并产出落回主对话

- 事件协议:4个新事件(Plan创建/SubTask状态/合并完成/冲突解决)

- UI交互:PlanProgress发送即展示+工具卡分组+冲突徽章非阻塞

- 状态机:Plan/SubTask/Conflict各自状态流转图

- 前端类型:PlanRecord/SubTaskRecord/ConflictRecord TypeScript定义

- INDEX.md注册 + 批次记录同步明细
2026-07-01 17:08:46 +08:00
lxy f04d47b09d 更新: 批次推进记录同步(Coordinator+audit拆分) 2026-07-01 14:29:53 +08:00
lxy fa410e6843 重构: Coordinator 接入 agentic loop + audit 拆分(approval/record)
- audit/approval.rs: 审批门控(风险分类+自动执行+挂起审批)

- audit/record.rs: 审计记录写入+历史查询

- coordinator.rs: 移除 deprecated,正式作为 P0 骨架可用

- run_agentic_loop: plan_execution_enabled 时 Coordinator 分解意图→记录 Plan

- audit/mod.rs 从 ~500 行精简至 ~200 行(编排层)
2026-07-01 14:29:34 +08:00
lxy a9ee9b1f74 更新: 批次推进记录同步(人设系统完成) 2026-07-01 13:11:15 +08:00
lxy 0a65ea2c5a 新增: 人设系统(P0 AgentPersona+PersonaRegistry+5内置人设)
- AgentPersona 数据结构(能力/工具白名单/prompt模板)

- PersonaRegistry 含5内置人设:coder/reviewer/architect/tester/analyst

- 按意图关键词推荐人设(review→reviewer,设计→architect等)

- 10个单元测试覆盖注册/默认/推荐/白名单/build_prompt
2026-07-01 13:10:16 +08:00
lxy 91d80841ea 新增: 多 ReAct 执行骨架(PlanExecutor 运行时门控+DAG 进度组件) - PLAN_EXECUTION_ENABLED 改为 AtomicBool 运行时开关 - 新增 set/get_plan_execution IPC 前后端通路 - 新增 PlanProgress.vue 层状执行进度展示组件 - feature flag 默认关, 翻 true 后 process_tool_calls 可并行执行同层工具 2026-07-01 12:59:37 +08:00
lxy c89742cb9a 更新: 批次推进记录同步(搜索索引/编译警告/i18n核验/类型重构/MessageList拆分/ChatInput拆分/白名单打通) 2026-07-01 12:50:12 +08:00
lxy 2af24f0db8 新增: ScriptNode 白名单跨 crate 配置打通(IPC+OnceLock) - 新增 set_script_safety / get_script_safety IPC - ScriptNode.check_command_policy 优先读运行时配置 - 前端设置页保存时同步注入后端运行时 - 核验确认小程序心跳/文件树刷新/项目分页三项已就绪 2026-07-01 12:38:21 +08:00
lxy 535525c2f6 新增: AI Native 方向与路线图文档
- 定义 AI Native vs 传统范式的差异(人从操作者→决策者)
- 优先级矩阵:P0人设+多Agent → P1模板+Git/CI → P2审批+算法
- 明确不做方向(全自动/MCP/跨端)及理由
- Agent架构说明添加前向引用
- 注册文档到架构 INDEX
2026-07-01 12:34:44 +08:00
lxy 3abdfb489f 重构: ChatInput 拆分(提取 SkillMention+ImageInput, 1183->1155行)
- 提取 SkillMention.vue(/ 技能联想浮层+已选 chip)

- 提取 ImageInput.vue(待发送图片预览+移除)

- ChatInput 模板内联技能浮层替换为子组件
2026-07-01 12:27:58 +08:00
lxy 2c2b2d710e 重构: MessageList 拆分(提取 MessageItem+useMessageScroll, 1552->1386行)
- 提取 useMessageScroll.ts(滚动跟随/锁存/回底按钮)

- 提取 MessageItem.vue(按 role 渲染单条消息)

- 清理 MessageList.vue 中已移至子组件/ composable 的死代码
2026-07-01 12:21:40 +08:00
lxy 6771d396f0 优化: 代码质量收尾(搜索索引补全+编译警告清理+i18n核验+String替newtype) 2026-07-01 12:11:35 +08:00
lxy 0338210ba2 更新: 批次推进记录同步至 Batch 29
- 补全 Batch 14-29 全部提交记录
- 新增后续规划批次(Batch 30-34)
2026-07-01 11:37:42 +08:00
lxy 36ea090d9b 新增: 依赖图环形检测 + PNG 导出 + 任务列表真实总数
- 后端 detect_module_cycles IPC(DFS 三色标记法检测环形依赖)
- 前端环检测按钮:高亮参与环的节点(红色边框)
- 图导出 PNG(X6 toPNG 回调模式)
- 后端 count_tasks IPC + 前端 taskApi.count()
- 任务列表所有筛选/搜索/排序/翻页均拉真实 total
2026-07-01 11:30:19 +08:00
lxy 89912155a6 优化: 架构债收尾(ScriptNode 注释更新 + 双监听器核查)
- ScriptNode 白/黑名单注释清理(移除误导性'待后续',标注实际读取方式)
- 双监听器核查结论:df-data-changed 多监听器是正常 pub-sub(project store + FileExplorer + TaskDetail),非 bug
- God 文件拆分(MessageList/ChatInput)标记为独立技术债,需完整测试覆盖后推进
2026-07-01 01:21:46 +08:00
lxy dd0e059365 新增: 依赖图增强(添加依赖弹窗 + dagre 自动布局)
- 添加依赖入口:工具栏"+ 依赖"按钮,弹窗选择源/目标工程 + 依赖类型
- dagre 层次布局替换网格布局(LR 方向,自动排布工程节点)
- 依赖边按类型着色(library/api/mq/shared/custom)
- 小地图导航 + 缩放/适应内容
2026-07-01 01:17:44 +08:00
lxy bce2fea4e5 修复: 设置页和分离窗口渲染崩溃(TDZ + 窗口权限通配符)
- AdvancedSection clampApprovalTimeout 常量移到 reactive 之前(打包后 const 初始化顺序 TDZ)
- @/i18n 去掉对 @/stores/appSettings 的 import,改直读 localStorage
- Tauri capability 加 ai-detached-* / fe-detached-* 通配符(分离窗口 listen 权限)
2026-07-01 01:09:26 +08:00
lxy 3758bea0b5 新增: 工程依赖图数据层 + 依赖边渲染 + 小地图
- 后端 module_dependencies 表(V35 迁移)+ ModuleDependencyRepo CRUD
- IPC:add/remove/list_module_dependencies
- 前端 API 封装 + DependencyGraph 接入真实边数据
- 依赖类型颜色区分(library/api/mq/shared/custom)
- 小地图插件(MiniMap)大图概览导航
- 点击节点跳转项目详情
- 任务列表后端真分页 count_by_query 方法
2026-07-01 00:20:51 +08:00
lxy 3c2fa91fc6 优化: 任务列表后端真分页(limit/offset 下推 + count 查询)
- 后端新增 count_by_query 方法(复用 list_by_query 的 WHERE 逻辑)
- 前端 buildTaskQuery 传 limit/offset 到后端(SQL 下推分页)
- 去除前端客户端 slice(后端已分页,直接分组)
- Paginator total 用 totalTasks(当前用当前页条数,后续接 count IPC)
2026-07-01 00:10:41 +08:00
lxy 78ef5dcc44 优化: 变更区完善(文件 Diff 预览 + 分支只读列表)
- 变更文件选中后展开 Diff 红绿预览(原 selectFile 拉了 diffContent 但未渲染)
- 分支切换按钮实现只读列表弹窗(拉取分支列表 + 当前分支标记)
- 分支切换提示走 AI 助手(git_branch 工具)
2026-07-01 00:01:25 +08:00
lxy 976ce29ad9 新增: 依赖关系图(AntV X6 v3 + Vue 自定义节点)
- DependencyGraph.vue: X6 画布初始化 + 插件(框选/对齐线/撤销/滚动) + 工程节点渲染
- ModuleNode.vue: Vue 自定义节点组件(工程名/路径/技术栈标签)
- 项目详情新增依赖图Tab,与概览/文件并列
- 网格布局排列工程节点,支持缩放/拖拽/框选
- Phase 0 验证 demo(X6Demo.vue)保留在 /dev/x6 路由
2026-06-30 23:57:43 +08:00
lxy 4dac9fe5f1 新增: Plan DAG 分层执行器(按层级调度子任务,层间串行/层内并行)
- PlanExecutor 按 Plan::to_layers 层间串行/层内并行调度子任务
- feature flag 门控(默认关,未接入主 loop)
- 3 个单元测试覆盖:线性链/并行层/失败容错
2026-06-30 23:35:10 +08:00
lxy 76aec62d36 修复: stores/ai 模块顶层 __bindMessages TDZ 防御
- __bindMessages 包装 try/catch,aiShared 未就绪时延迟到微任务重试
- 防 ES module 循环初始化顺序导致的 Cannot access before initialization
2026-06-30 23:26:40 +08:00
lxy 27eb0a9f6b 新增: 任务行 hover 快捷操作菜单(改状态/优先级/删除)
- 任务行 hover 显示齿轮按钮,点击展开快捷菜单
- 快捷改状态(6 态子菜单)/改优先级(4 级)/删除(二次确认)
- 点击外部自动关闭菜单
- 补 i18n confirmDelete 翻译
2026-06-30 22:08:43 +08:00
lxy 948d6e644b 重构: 删除 coordinator.rs 零调用空壳占位
- 删除 crates/df-ai/src/coordinator.rs(零调用的 deprecated 空壳)
- lib.rs 移除 pub mod coordinator 声明
- 消除编译 deprecated 警告
- 多 Agent 协作未来从 planner.rs 的 Plan DAG 执行器重新实现
2026-06-30 22:01:44 +08:00
lxy aa4d5213eb 新增: 项目卡片显示工程数(异步拉取 module 列表)
- ProjectCard 底部新增工程数统计(📁 N),异步拉取不阻塞卡片渲染
- 老项目无工程记录显示 0(不报错,与 list_project_modules 自动补建兼容)
2026-06-30 22:00:14 +08:00
lxy 2780f6affa 优化: 任务管理界面 UX 重构(紧凑布局/搜索/排序/分页/快捷键)
- 筛选改下拉:项目/状态从多按钮改为 select,节省垂直空间
- 新增搜索框:标题/描述关键词搜索,300ms 防抖
- 新增排序选择器:更新/创建时间/优先级/状态
- 分组可折叠:点击组头切换,记忆状态到 localStorage
- 分页默认开启:pageSize=20(原 0=全量)
- 紧凑布局:任务行密度优化,显示总条数
- 空状态引导:图标 + 新建按钮
- 模态框样式收敛:去内联 style,统一 modal-field/modal-input 类
- 桌面快捷键:Ctrl+N 新建,Ctrl+F 聚焦搜索
2026-06-30 21:58:11 +08:00
lxy 773ebb6ef6 新增: 小程序审批超时计时器(5min 自动拒绝兜底)
- 对齐桌面端 aiShared.startApprovalTimer,补齐小程序缺失的审批超时机制
- AiApprovalRequired/AiDirAuthRequired 触发 startApprovalTimer(每条独立计时)
- AiToolCallCompleted/AiApprovalResult 触发 clearApprovalTimer(审批落定)
- 切会话/新建/断连/重连/终态触发 clearAllApprovalTimers(防跨会话污染)
- 超时自动拒绝(approve(id,false))+ toast 提示用户
- 5min 阈值符合移动端使用习惯(桌面端 15min)
2026-06-30 21:54:21 +08:00
lxy 15eaa0c637 新增: Git 能力闭环(Diff 行号解析/分支只读展示/提交详情/写入后自动刷新变更视图)
- Diff 行号修复:解析 @@ -a,b +c,d @@ 头,计算旧/新行号(原恒空字符串)
- 提交详情增强:显示父提交哈希/作者/日期/完整消息(git show -s --format=%P\t%an)
- 分支只读展示:list_branches IPC + 变更面板顶部下拉查看分支(切换走 AI 工具)
- AI 工具写入后自动跳转变更视图:监听 df-data-changed entity=file 切 Tab + 刷新
2026-06-30 21:50:25 +08:00
lxy f5f101d88a 修复: 设置页和 AI 独立窗口渲染崩溃(循环依赖 TDZ)
- ProviderPanel 改静态 import useAiStore 为动态 import,打破循环依赖链
- ErrorBoundary 增强显示具体错误信息和可折叠堆栈详情
2026-06-30 21:45:31 +08:00
lxy b19b0f3679 新增: Git 提交历史显示作者 + 提交详情含作者/时间
- 后端 git log format 加 %an 作者名(状态查询+分页查询)
- 提交列表每行显示 hash/作者/主题/时间
- 提交详情头部补充作者显示
- 分支切换按钮占位(后续接入分支列表 IPC)
2026-06-30 21:45:31 +08:00
lxy abc6880936 新增: 多工程管理(编辑/删除确认/记忆选中/自动扫描子仓库)
- 工程编辑入口:工具栏编辑按钮+弹窗(名称/路径/Git地址)
- 工程删除二次确认:防误删
- 记住上次选中工程:localStorage 持久化
- 自动扫描子仓库:下拉菜单加扫描入口,发现 .git 子目录自动建工程
2026-06-30 21:45:31 +08:00
lxy 6b131f3dc1 新增: 文件浏览器收尾(滚动条/阶段进度条/空状态/骨架屏/工程下拉)
- 提交之前未提交的滚动条与布局修复
- 移除阶段进度条死代码(模板+CSS+常量)
- 文件预览空状态加图标引导
- 变更视图加载骨架屏
- 工程选择器改为自定义下拉(显示工程路径)
2026-06-30 21:45:31 +08:00
lxy 78bd8d4344 新增: Zed 编辑器规则文件(提交规范+开发规则) 2026-06-29 22:22:34 +08:00
lxy 093fe172f8 新增: 项目状态机分层+任务状态枚举合并
真分层(df-project):
- ProjectManager 加 can_transition/transition/is_terminal 状态机
- 创建加 EmptyName 校验,返回 Result 而非裸实体
- 新增 ProjectTransitionError 三种错误变体
- 调用方(idea.rs promote_idea)适配 Result 传播

双源合并:
- TaskStatus::as_str 改为 const fn(允许 const 上下文调用)
- task_state_machine 字符串常量从 TaskStatus enum 派生
- IdeaStatus/ProjectStatus as_str 同步改为 const fn(一致性)

架构债设计方案:
- #8 IPC 错误结构化迁移路径(IpcError enum + 前端消费)
- #15 AI 状态机抽离 df-ai-session(4 阶段)
- #18 配置四源统一(AppConfig 单例 + 环境变量覆盖)
- #19 SQLite 连接池(r2d2)
2026-06-29 22:16:45 +08:00
lxy ff3f153d45 修复: 安全加固+DRY 收敛+文档同步+测试补齐
安全:
- ScriptNode 默认黑名单兜底(rm/del/format/shutdown/mkfs/dd)
- bind_directory 分段 .. 检测替代 contains 子串(对齐 tool_registry)
- ai_providers 白名单移除 api_key(防 update_field 旁路写明文)

DRY:
- useAiEvents 抽 cleanupTerminatedConversation 统一三分支收尾
- 新增 useStoreAction 工具,4 个 store 替换 38 处 try/catch 样板

文档:
- df-core → df-types 批量替换(ARCHITECTURE/PROGRESS/SQLite-CRUD)
- INDEX 补齐 9 漏列文档(单对话并行多轮/跑题试验/工程系统设计等)
- Agent架构说明 死链修复(../构想审查/)
- AI对话引擎工具清单改为数量+按风险分组(不再用固定数字)
- ARCH 状态标签 设计阶段 → Phase 2 验证

测试:
- df-relay 新增 registry_test: ConnRegistry 路由 + RelayState + 16 项单测
2026-06-29 21:57:07 +08:00
lxy dfed588b54 新增: 文件浏览器增强(窗口分离/Git 变更/行号/Diff/分页提交历史)
- 窗口分离: FileExplorer 可弹出独立 Tauri 窗口
- 行号显示: 文件预览左侧显示行号列
- 按后缀图标: rs/ts/js/vue/css/html 等 20+ 类型彩色图标
- Diff 视图: 有 Git 变更的文件可切换 diff 红绿视图
- Git 变更面板: 变更文件列表(按目录缩进分组)+提交历史(分页加载)
- 提交详情: 点击提交查看变更文件列表及文件级 diff
- 时间显示: 提交历史显示相对时间/具体日期
- 面包屑导航不丢预览: 导航时不改变当前预览文件
- 刷新保留选中文件: 只清树缓存不清预览
- 中文编码修复: git 命令注入 LANG/LC_ALL 环境变量
- 自适应布局: 弹性 flex 布局,窄窗口适配
- 滚动条优化: 内容区内部滚动,不溢出页面层
- 多工程管理: 工具栏添加工程按钮+弹窗表单
- 审批加载超时缩短: 130s 降至 30s
- 文件变更自动刷新: write_file/patch_file 触发 df-data-changed
2026-06-29 19:31:55 +08:00
lxy 63936e3016 修复: 审批按钮永久转圈和等待计数不停
根因:审批 IPC 成功返回后,前端等待后端事件(AiApprovalResult/
AiToolCallCompleted)来更新工具卡片状态和复位 loading。但如果
后端事件因时序问题未及时到达,或 findToolCall 找不到工具卡片,
loading 永不复位,按钮一直转圈,等待计数也不停。

修复:审批 IPC 成功返回后立即乐观更新状态:
- 通过/批准:工具卡片改为执行中(后端正在执行工具)
- 拒绝:工具卡片改为已拒绝
- 同时从待审批列表移除(不等后端事件来移)

后端事件仍会到达作为二次确认(幂等覆盖,无副作用)。
2026-06-29 12:59:45 +08:00
lxy 7a2a2e13a2 修复: 工具卡片永远显示执行中
根因:aiShared 的消息引用在会话切换时失效。

switchConversation 和 newConversation 会整体替换 state.messages
(赋值新数组),但 aiShared 缓存的是旧数组引用。切换后
findToolCall 在旧数组中找不到新消息的工具卡片,AiToolCallCompleted
事件无法更新卡片状态,卡片永远停在 running。

修复:缓存数组引用改为 getter 函数,每次调用实时从 state 读取
最新数组,确保切换会话后仍能找到工具卡片。
2026-06-29 12:33:57 +08:00
lxy 49c6f3360a 新增: 批次推进记录文档 2026-06-29 11:14:10 +08:00
lxy 9b6353c561 更新: 工程系统设计文档状态同步为已落地 2026-06-29 01:55:35 +08:00
lxy 0cfea6da22 新增: Git 写操作 AI 工具(提交/分支/合并)
AI 现在能操作代码仓库的提交、分支和合并,闭环任务推进链。
三个工具按风险分级审批:

- git_commit(中等风险):提交工作区改动
  提交前检查敏感文件(.env/.key/.pem 等),拒绝提交
  支持自定义提交信息和选择性暂存
- git_branch(中等风险):分支管理
  列表所有分支、创建新分支、切换分支
- git_merge(高风险):合并分支
  合并后自动检测冲突,返回冲突文件列表

安全边界:不暴露 push/force/reset --hard(推送远程是
用户决策,避免 AI 自主推到远程或丢失改动)。
2026-06-29 01:55:01 +08:00
lxy 7b5b62d3ae 新增: Git 只读 AI 工具(状态/改动/历史)
AI 现在能自主查看工程代码仓库的 Git 状态,无需用户审批。
三个低风险只读工具,在工程目录(module.path)执行:

- git_status:查看工作区状态,返回当前分支和改动文件列表
  (结构化输出,每文件含路径和状态标记 M/A/D/??)
- git_diff:查看未提交的代码改动,返回统计和 patch 内容
  (patch 截断到 8000 字符防止 token 爆炸)
- git_log:查看提交历史,返回最近提交的结构化信息
  (哈希/作者/消息/日期,默认最近 20 条,最多 100 条)

所有 git 命令 10 秒超时,无 Git 仓库返回空状态。
AI 据此了解代码变更情况,为后续提交/分支操作做准备。
2026-06-29 01:51:38 +08:00
lxy 50aad375eb 新增: 项目文件浏览器(文件树+内容预览+Git 状态标记)
项目详情页新增文件标签页,用户可在 DevFlow 内浏览项目
文件,查看文件内容和 Git 改动状态,无需切换外部编辑器。

后端接口:
- 文件树查询:列工程目录,合并 Git 状态到每个文件
  (噪音过滤 node_modules/target/.git 等,路径穿越防御)
- 文件读取:支持文本/图片/二进制检测,1MB 上限

前端组件:
- 文件树:递归树形展示,懒加载子目录,Git 状态标记
  (橙色=已修改/绿色=已新增/灰色=未跟踪)
- 文件预览:代码文本/图片/二进制三分支
- 主容器:工程选择(单工程隐藏)+ 面包屑导航 + 刷新

界面文案中英文同步补齐。
2026-06-29 01:48:52 +08:00
lxy b72df78462 新增: 项目多工程系统(工程表+Git 状态查询+AI 工具)
一个项目可含多个工程(Module),每个工程是独立代码仓库,
有自己的目录、Git 地址和技术栈。单仓库项目退化:项目下
只有一个工程,用户无感工程概念。

数据层:
- project_modules 表(V34 迁移):工程名/目录/Git 地址/技术栈
- ProjectModuleRepo:完整 CRUD + 按项目列表(排序)

后端接口:
- 工程 CRUD:添加/更新/删除/列表
- Git 状态查询:实时跑 git 命令返回当前分支/改动文件/最近提交
  (10 秒超时,无 Git 仓库返回空状态)

创建项目适配:
- 新建项目时自动创建一个工程(目录=绑定路径,技术栈=探测结果)

AI 工具:
- list_project_modules:列出项目工程列表(AI 了解项目结构)

同时新增工程系统设计方案文档。
2026-06-29 01:39:42 +08:00
lxy 3c18dea45b 新增: 消息分页懒加载与事件总线空转优化
后端基础设施两项改进:

一、历史消息分页懒加载(治长对话渲染卡顿)
- 消息仓库新增分页查询方法(从尾部取最近 N 条,支持游标)
- 切换对话时后端保留全量消息供模型上下文用,但只返回最近
  50 条给前端渲染,附带是否有更多历史和游标位置
- 新增加载更多消息的接口,前端滚顶时按游标分页拉取

二、事件总线空转优化(消除无消费者时的序列化开销)
- 发布事件前先检查订阅者数量,无订阅者直接跳过序列化
- 当前隧道订阅方未接入,20 余个发射点双写不再空转浪费

同时核验确认对话上下文透明化三项均已落地(目标可见/
项目增强预览/完整上下文面板),关闭对应待办。
2026-06-29 00:44:10 +08:00
lxy 8d18918e39 更新: 架构设计文档状态同步代码核验结果
经代码核验发现 9 份设计文档的状态标注严重滞后(标'待实施'但
实际已完整落地),本次批量同步:

已落地(核验确认):
- 局部编辑工具:三层防御+三模式完整,仅文本不支持二进制
- 密钥迁移健壮性:空 key 不覆盖+即时迁移补密钥+阻断保存
- AST 符号解析:符号读取工具已注册+基线测试守护
- 查询能力补全:任务/项目/灵感均多维动态查询
- 条件表达式引擎:手写求值器+JSON Path+执行器集成+前端入口
- 工作流脚本边界:命令白名单/黑名单+危险关键词告警
- 消息拆分存储:消息表+全量迁移+读写全部切换
- 消息级溯源:消息 ID+四场景溯源+切读全部完成

部分落地:
- 全局事件总线:基建+20 余个发射点就位,消费者未接(空转)

归档不实施:
- 规格契约自检:核心价值已被求助协议+自审闸门覆盖,过度设计
2026-06-29 00:16:39 +08:00
lxy cb7e1fce82 新增: 小程序配置持久化与心跳闭环 + 文档同步
小程序(mobile client)三项改进:

一、配置持久化(分发前置)
- 运行时配置(relay 地址/设备 ID/token/心跳间隔)接入 storage
- 懒加载:首次读取从 storage 恢复,合并默认值兼容旧字段
- 新增重置函数:清除 storage + 回到默认占位值
- 读写失败降级不崩溃

二、心跳 pong 闭环(移动网络可靠性)
- 中继服务识别小程序心跳 ping,直接回 pong(不经桌面端透传)
- 小程序收到 pong 更新入站时间,半连接看门狗据此判活
- 治移动网络 TCP 半连接挂死(socket 仍开但无响应)

三、文档同步关闭已完成项
- 条件表达式引擎:求值器+执行器+前端编辑入口+翻译均已落地
- 流式状态按会话隔离:accessor 委派 per-conv Map 已落地
- 灵感录入增强:表单已含来源/标签/优先级字段
2026-06-28 23:55:51 +08:00
lxy cc3ef5bc1d 修复: 设置页面崩溃(模块循环依赖致初始化失败)
共享层模块顶层导入状态仓库,而状态仓库又导入共享层符号,形成
循环引用。打包后运行时触发 TDZ 崩溃,设置页面无法打开。

修复方案:

- 共享层删除模块顶层的状态仓库导入和全局 store 实例
- 改为由状态仓库在状态就绪后主动注入消息数组引用
- 共享层函数运行时惰性获取消息引用,不再编译期依赖
- store 实例改为函数内局部获取(运行时 store 已就绪)

零行为变更,纯依赖方向调整。
2026-06-28 23:39:48 +08:00
lxy 996f1d9e5f 重构: 应用状态拆分+事件分发统一+路径授权治本
三项治本性架构重构,解决长期遗留的技术债:

一、应用状态文件拆分(原 1416 行单文件 → 按职责分模块)
- 文件授权白名单(AllowedDirs/路径校验/黑名单)独立成模块
- 知识库行为配置(ExtractTrigger/KnowledgeConfig/KV 常量)独立成模块
- LLM 并发控制(LlmConcurrency 双层信号量)独立成模块
- 主文件仅保留 AppState 结构体与初始化逻辑

二、事件分发器统一(消除双监听器架构隐患)
- 压缩/清空等 5 类生命周期事件纳入主事件联合类型
- 删除上下文管理模块的独立第二监听器
- 改为由主事件分发器统一处理,经回调注入副作用
- 架构保证:一个事件源 → 一个监听器 → 一个分发器

三、路径授权去除编译期硬编码(分发适配治本)
- 启动时不再硬塞编译机路径到授权白名单
- 工程根授权完全靠项目绑定目录自动授权 + 设置页手动配置
- 分发到其他机器后不再有脏白名单数据
2026-06-28 23:35:38 +08:00
lxy 696e34407c 更新: 关闭已完成的若干待办项
经代码核验,五项候选待办实际上都已实现或已满足决策,本次仅
同步文档关闭:

- 工具卡片分组标题:已实现文件路径摘要展示(跨轮自动收起保留
  作为低优先增量)
- 重试持锁问题:多会话并发改造后全局锁已移除,重试仅持单会话锁
  (同循环内合理),原说法已过时
- 对抗式评估批量命令:单项评估接口已接入,批量评估无前端消费方
  暂不做(避免过度设计)
- 残留诊断代码:聊天命令已无硬编码绝对路径,调试输出已降级为
  普通日志
- 工程内路径授权政策:用户决策确认为白名单制(工程外走三档授权
  + 系统目录黑名单兜底),已落地
2026-06-28 19:06:02 +08:00
lxy 19b647e650 修复: 知识提炼改读消息表,不再依赖旧 JSON 列
消息存储拆分改造早前已覆盖主要读写路径(保存写消息表 / 切换 /
导出 / 跨端同步读消息表),但知识提炼仍在读旧的消息 JSON 列,
与改造目标不一致。

本次补全:
- 知识提炼改为优先读消息表
- 表空时回退旧 JSON 列(老数据库兼容,与其他读路径一致)
- 复用既有的记录转消息映射函数,与其他读路径同源

至此消息存储拆分全部完成,所有读写路径统一走消息表,旧 JSON
列仅作老库兼容回退,不再写入。
2026-06-28 18:47:21 +08:00
lxy 10629f88e8 新增: 工作流条件表达式 UI 补齐中英文翻译
条件表达式引擎和执行器集成早已完成,工作流可视化组件也提供边
条件编辑入口。但前端用「中文兜底」说明翻译缺失,本次补齐:

中英文同步新增:
- 工作流结构标题 / 层标签 / 空态提示
- 边条件编辑器入口 / 无条件标签 / 输入提示

核验确认条件引擎全部链路(引擎/特性开关/执行器集成/UI/翻译)
均已落地,多会话并发架构的会话级隔离改造也已完成。
2026-06-28 18:34:22 +08:00
lxy b5e159f190 更新: 关闭已完成的工具卡片拆分等待办
代码核验发现工具卡片和大文件重构任务实际已完成,文档标记滞后,
本次同步关闭对应待办:

工具卡片拆分(原 1527 行 → 373 行):
- 审批状态机独立为单独文件
- 头部逻辑 / 渲染逻辑 / 通用工具函数各自独立

大型文件拆分:
- 主聊天组件抽子组件后大幅瘦身
- 命令注册按域拆为多个函数
- 通用 CRUD 拆为按表独立的仓库文件

类型强类型化重构评估为低优先级纯重构,留待后续技术债窗口处理。
2026-06-28 18:31:34 +08:00
lxy 55542abf41 新增: 审批超时后端兜底,覆盖重启恢复场景
前端已有审批超时定时器(设置页可选 0/5/15/30/60 分钟),负责会话
内运行的审批超时检测。但应用重启后前端定时器丢失,从数据库恢复
的待审批无人计时,会永久卡死会话。

本次补充后端兜底机制:

- 待审批记录创建时刻,用于超时判定
- 在审批恢复入口检测超时,超时自动取消并写入审计
- 应用启动时从设置恢复超时配置(默认 15 分钟,0 为禁用)
- 新增读写超时配置的接口,设置页可改
- 与前端共用同一个配置项,保证单一真相源

同时更新待办文档,把已实现项标记为完成。
2026-06-28 18:16:34 +08:00
lxy 3fac0a618d 修复: system_prompt 追加行为准则,减少 LLM 写脚本偏好 2026-06-28 17:21:35 +08:00
lxy 9839935782 新增: AI 上下文信息面板(系统提示/增强上下文可见) 2026-06-28 14:13:20 +08:00
lxy 4a396f4f88 新增: EnvSnapshot 环境感知注入 system_prompt
- EnvSnapshot 结构: OS/shell/工具版本/编码/路径分隔符
- OnceLock 全局缓存,启动时 spawn_blocking 探测一次
- to_prompt() 生成环境摘要注入 agentic loop system_prompt
- 探测: Windows 注册表/macOS sw_vers/Linux os-release + chcp 编码
- 工具版本: python/node/rust/go/docker/git 逐个探测,失败不阻塞
- 8 个单测覆盖版本号解析 + 平台 fallback
2026-06-28 14:05:16 +08:00
lxy 7adaf97377 修复: PowerShell 路径转义 + shell 失败提示 + 标题合并同 role + L0 防抖
- run_command 路径反斜杠传递修正
- 命令失败追加 PowerShell 适配提示引导 LLM 改正
- 标题生成前合并连续同 role 消息避免 Anthropic 1214
- V33 迁移追加 workflow_executions.updated_at 列
- ai-client-ready 3 秒防抖避免重复握手
2026-06-28 13:57:53 +08:00
lxy 6ffcba7e4d 新增: 审批超时可配置 + ScriptNode 命令白/黑名单
- 审批超时: Settings 下拉选择(不限时/5/15/30/60min),默认 15min
- getApprovalTimeoutMs() 从 KV 读取,startApprovalTimer 动态取值
- ScriptNode: 黑名单 > 白名单策略,从环境变量读取
- Settings 加命令执行安全面板(白/黑名单文本框)
- i18n 中英文案 + 搜索索引补全
2026-06-28 13:49:19 +08:00
lxy b157bc9077 修复: relay token 改环境变量 + api_key 脱敏 + 路径规范化 + create_project 自动建目录
- relay token 必设 DF_RELAY_TOKEN 环境变量,移除硬编码默认
- AiProviderRecord Debug 脱敏 api_key/model_configs/config
- ScriptNode 危险命令告警(rm -rf/DROP TABLE 等)
- bind_directory 路径规范化,拒绝含 .. 的原始路径
- create_project 目录不存在时自动创建
- probe_pwsh OnceLock 全局缓存,异步探测不阻塞 tokio
- retry jitter 改 rand,范围扩大到 ±50%
- EventBus send 错误改 tracing::warn
- StateMachine 文档警告不得在 await 期间持锁
2026-06-28 13:39:22 +08:00
lxy f776336eb1 更新: 对话诊断待办项
- 最新对话 d9aef24f 分析: 审批死锁 + Windows 兼容 + 标题失败
- P0: pending 超时自动取消 + create_project auto_create_dir
- P1: run_command shell 适配提示 + PowerShell 反斜杠转义
- P2: 标题摘要合并连续同 role 防 GLM 1214
- P3: migration V33 + L0-handshake 防抖
2026-06-28 13:14:54 +08:00
lxy ad1821bc14 修复: 审批不超时+重启恢复 + 其他小改
- APPROVAL_TIMEOUT_MS=Infinity(已决策:审批不做超时)
- V33 迁移: ai_conversations 加 pending_approvals 列
- save_conversation 持久化 pending_approvals
- ai_conversation_switch 从 DB 恢复 pending_approvals
- switchConversation 加 try/catch(对话不存在→建新)
- @项目 关联添加取消按钮(×)
- TopBar 底部无标题时图标右对齐
- TaskDetail.vue 删除重复 case
2026-06-28 13:09:55 +08:00
lxy c4b02b5370 修复: 消息溯源P2切读确认完成 + 走查报告高优项
- P2 切读方案文档更新(确认批次B已上线)
- query LIKE 通配符转义(project/idea/task/knowledge 4 repo)
- dag.rs deep_merge null 覆盖全局配置
- dag.rs 通配 match 展开显式变体
- INDEX.md 补漏 10 个文档索引
- ARCHITECTURE.md 删除与新文档逐字重复
- coordinator.rs 加 #[deprecated] 编译守卫
- AiChat.vue 空值传播加 console.warn
2026-06-28 05:06:51 +08:00
lxy fee856129c 新增: 全量走查报告 — 代码/前端/文档体系
- Rust 后端 12 crate: 锁/LIKE 通配符/jitter/路径穿越 等 15 项
- Vue 3 前端: Arco 残留/confirm 重复/类型退化 等 13 项
- 文档体系: 工具清单过时/INDEX 漏列 9 文档/死链/SSOT 违反 等 9 项
2026-06-28 04:53:33 +08:00
lxy ead490783f 修复: AiCompleted 携带 pinned_goals 根治目标面板刷新 2026-06-28 04:51:08 +08:00
lxy dcc3f0d230 新增: 三层模型设计文档 — 模板/工作流/人设体系
- ARCHITECTURE.md 补充执行层级模型 + 重写 §七 三层模型章节
- 新建专项设计: 流程模板 YAML 规范/AgentPersona 数据结构/实例化流程
- 注册新文档到架构设计 INDEX
- Agent架构说明补充前向关联引用
2026-06-28 04:44:01 +08:00
lxy d1b9488853 修复: status→enum + type alias(SMELL-P1-6) 2026-06-28 04:42:59 +08:00
lxy 48c966f6f7 更新: 归档今日决策 + 标记已解决项 + 执行计划 2026-06-28 04:12:16 +08:00
lxy 7e3fe1961a 修复: execution_id String→ExecutionId 类型别名 + 更新待决策状态 2026-06-28 03:50:25 +08:00
lxy 8723075360 重构: TopBar 布局 — header 中间区 + 底部工具区(标题/目标/历史/摘要) 2026-06-28 03:31:41 +08:00
lxy 53e1c1da77 重构: 对话透明化 L1 + ③.2 审批拆分 + ⑥.4 @展开 + DAG 展示
修复 tool_result 压缩零效果(BUG-260628-01):单行/少行/JSON 大字符
串字段逃逸压缩的问题,基于实测数据(53/94 次迭代零节省)调参,
增加字符级保底截断,压缩有效率从 8% 提升至 ~85%+
2026-06-28 03:31:40 +08:00
lxy e4ceb0015b 新增: Conditions 启用 + 工作流 DAG 展示 + 决策记录
- 开启 conditions-eval feature flag(df-workflow 默认启用)
- 新增 WorkflowDagDisplay.vue 展示工作流节点/边/条件
- TaskDetail 工作流推进后自动获取并显示 DAG 结构
- 记录 2026-06-27 讨论决策到待决策.md
- 记录 Inbox 设计想法到 todo.md
2026-06-28 01:17:36 +08:00
lxy 22362a77b8 新增: 审批挂起时长提示(BUG-260624-02 方案 C)
背景:AI 调用需审批的工具后,ToolCard 弹审批卡常驻直到用户批/拒,
用户走开忘了批则 AI loop 卡死,且卡片无任何时长提示。

方案选型(A/B/C 三选一,选 C):
  A 不做 - 挂起审批容易被遗忘
  B 加超时+倒计时 - 超时后行为难定义(自动拒丢改动/自动批危险)
  C 显示挂起时长(本方案) - 纯前端展示,不动后端,不改审批行为

实现:
- ToolCard pending_approval 态显示已等待 mm:ss
- >2分钟橙色脉冲,>5分钟红色脉冲
- 注释中标注方案选型理由,便于后人理解决策上下文
2026-06-28 00:41:00 +08:00
lxy cf223fc08b 修复: .trash WARN 降级 + ToolCard 分组标题增强
- .trash 跨盘迁移从 WARN 降为 DEBUG(预期行为,非异常)
- ToolCard 分组标题增加文件路径摘要(取前3个文件名),
  收起态也能看到影响了哪些文件
2026-06-28 00:25:37 +08:00
lxy b7b004dd68 新增: Phase 4 注入 — @[项目] 关联任务/灵感 + 三个工具 path 可选
- Augmentation 新增 extra 字段(项目关联信息/任务关联信息/灵感关联信息)
- ProjectResolver: @[项目] 时自动查前5条进行中任务+前3条待评估灵感
- TaskResolver/IdeaResolver: extra 预留(后续按需追加)
- render_one 渲染 extra 行
- search_files/grep/list_directory path 改为可选,不传时返回引导提示
2026-06-28 00:19:13 +08:00
lxy b7b54eb2a6 修复: patch_file 小改动自动放行 + tool_result JSON 压缩 + prompt 项目路径
- C-260627: patch_file ≤5 行自动放行,不阻塞 AI 工作流
- tool_result 压缩: JSON 数组截断到 10 条 + _truncated 标记
- system_prompt: 项目清单增加「目录:」路径信息
2026-06-27 23:59:07 +08:00
lxy 6212002270 修复: read_symbol 搜不到 const 声明的符号
- 新增 variable_declarator 到 DEFINITION_KINDS
- Vue 3 + TS 大量使用 const fn = () => {} 箭头函数
  AST 节点是 variable_declarator,原本不在列表中
  导致 read_symbol 对 Vue 文件频繁 fallback 回 read_file
2026-06-27 23:42:14 +08:00
lxy 7cd833b1e5 修复: 文件日志 + G1 日志增强 + 清理死代码
- 文件日志: tracing-subscriber + tracing-appender 写入 %TEMP%/devflow-trace.log
- G1 日志: 增加 first_goal 字段显示首条目标内容
- 清理: 删 workspace_root_str / data_dir_path 死代码
- 标注: default_with_root 加 cfg_attr 抑制 non-test 编译警告
2026-06-27 23:27:31 +08:00
lxy 5c539fe764 修复: 前端 TS 阻断 + G1/G2 单测 + 归档已完成项
- 修复 vue-tsc 3 阻断: i18n 重复 err key 删除 + store 补 relateIdeas
- G1: extract_pinned_goal 单测 10 条(mention剥离/截断/空输入/全角冒号)
- G2: is_empty_tool_result 单测 11 条(JSON标记/中文/英文/非空判定)
- 归档: todo.md/docs/todo.md/待决策.md/待审查.md 已完成项迁入归档
2026-06-27 22:35:37 +08:00
lxy 6e1485e4f9 修复: BUG-260620-05 工程内路径弹窗 + G1 多目标累积 + 测试编译债
- BUG-260620-05: reload_allowed_dirs 无条件插入 workspace_root,工程内路径免授权
- G1: pinned_goal → pinned_goals Vec<String>,多目标追加去重 + MAX_GOALS=5
- tool_registry.rs 测试 4 处补 data_dir 形参
- 更新 todo.md / docs/todo.md 状态
2026-06-27 22:02:41 +08:00
639 changed files with 142264 additions and 21984 deletions
+16
View File
@@ -1,5 +1,21 @@
# musl linux 交叉编译(Windows 主机无 cc/musl-gcc,用 rust-lld self-contained link) # musl linux 交叉编译(Windows 主机无 cc/musl-gcc,用 rust-lld self-contained link)
# df-relay 云端部署:本地交叉产 static linux binary → scp 测试机普通运行 # df-relay 云端部署:本地交叉产 static linux binary → scp 测试机普通运行
#
# ⚠️ 默认不要跑交叉编译,产物体积大且会污染 target/ 影响日常 build/rust-analyzer 性能。
# 需要部署 df-relay 时再显式执行,并把产物隔离到独立 target dir:
#
# CARGO_TARGET_DIR=target-musl cargo build --release --target x86_64-unknown-linux-musl -p df-relay
#
# 这样 musl 产物落到 target-musl/(已 gitignore),与主 target/ 完全隔离。
[target.x86_64-unknown-linux-musl] [target.x86_64-unknown-linux-musl]
linker = "rust-lld" linker = "rust-lld"
rustflags = ["-C", "link-self-contained=y"] rustflags = ["-C", "link-self-contained=y"]
# ── 主机编译加速(Windows MSVC) ────────────────────────────
# rust-lld 比 MSVC link.exe 快 3-5x,且 Rust 自带零安装
[target.x86_64-pc-windows-msvc]
linker = "rust-lld.exe"
# 并行编译单元,留部分核心给 Zed UI 和系统
[build]
jobs = 8
-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
+48
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/
@@ -25,12 +28,17 @@ unpackage/
# Rust / Cargo # Rust / Cargo
target/ target/
target-musl/ # df-relay 交叉编译产物隔离目录(见 .cargo/config.toml)
!Cargo.lock !Cargo.lock
# Tauri # Tauri
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/
@@ -56,10 +64,50 @@ coverage/
tmp/ tmp/
.temp/ .temp/
# 临时杂项(2026-08-05 误入的开发机临时物:网易爬虫/播放脚本/mp3,勿提交)
archive/
# AI 编排脚本(Claude Code Workflow 临时产物,非产品代码) # AI 编排脚本(Claude Code Workflow 临时产物,非产品代码)
workflows/ workflows/
# 临时分析脚本(团队分析/报告生成,非产品代码根目录级)
/analysis_*.txt
/analysis_*.py
/core_domains.txt
/core_domains.py
/final_report.txt
/grand_output.txt
/im_live_verify.txt
/liang_deep.py
/liangxianyou_deep.txt
/member_profiles.py
/read_all.py
/report_p2.txt
/report_wall.txt
/team_analysis.py
/team_deep_analysis.py
/team_member_profiles.txt
/team_report_final.txt
/tmp_*.py
/tmp_*.txt
/verify_im_live*.py
# MCP/SQLite 测试库(mcp_test2 --db 残留) # MCP/SQLite 测试库(mcp_test2 --db 残留)
devflow-dev.db devflow-dev.db
devflow-dev.db-shm devflow-dev.db-shm
devflow-dev.db-wal devflow-dev.db-wal
# 微信小程序 appid(真实 appid 本地保留,不提交仓库)
apps/df-miniapp/src/manifest.json
# 微信小程序 appid(真实 appid 本地保留,不提交仓库)
temp/
# 本地工具链缓存(protoc 等,不入库)
.tools/
# df-im-gateway 运行时数据(冒烟测试残留)
crates/df-im-gateway/data/
# Playwright 运行产物
test-results/
playwright-report/
+48
View File
@@ -0,0 +1,48 @@
# DevFlow 提交规范
## 提交注释格式
```
<动词>: <简短描述>
<详细说明(可选,仅在需要补充上下文时)>
```
## 动词词表
| 动词 | 使用场景 |
|------|---------|
| `新增` | 新功能、新特性、新文件 |
| `修复` | Bug 修复、问题修正 |
| `更新` | 文档更新、配置变更、依赖升级、非功能性的已有内容修改 |
| `重构` | 代码重构(行为不变的结构调整) |
| `删除` | 移除功能、删除文件 |
| `优化` | 性能优化、体验改进 |
| `合并` | 分支合并 |
## 规范要求
1. **动词 + 冒号 + 空格**:`修复: 审批按钮永久转圈`
2. **主题行不超过 72 字符**
3. **主语用中文**,描述清楚改动内容
4. **不要用内部代号**:禁止 Batch/P1/P2/L1/F-XX 等代号
5. **不要用分隔线**:禁止 `---` 或 `===` 等装饰线
6. **不要写无意义提交**:如"更新"、"修复"、"提交"等无法看出内容的单字提交
7. **改动较多的提交**:主题行概括后,空一行用列表展开具体条目
8. **文档和代码合并提交**:文档更新和对应代码改动放在同一个提交中,不分散
## 示例
```
修复: 审批按钮永久转圈和等待计数不停
```
```
新增: 文件浏览器增强(窗口分离/Git 变更/行号/Diff/分页提交历史)
- 窗口分离: FileExplorer 可弹出独立 Tauri 窗口
- 行号显示: 文件预览左侧显示行号列
- Diff 视图: 有 Git 变更的文件可切换 diff 红绿视图
- 提交详情: 点击提交查看变更文件列表及文件级 diff
- 中文编码修复: git 命令注入 LC_ALL 环境变量
```
+29
View File
@@ -0,0 +1,29 @@
# DevFlow 开发规则
## 技术栈
- 桌面端: Tauri v2 + Vue 3 + TypeScript + Pinia
- 后端: Rust Workspace (8 crate) / SQLite
- 包管理: bun (前端) / cargo (Rust)
- 路径: `E:/wk-lab/devflow`
## 架构原则
1. **治本不治标**:修复问题时找到根因,不做表面补丁
2. **DRY 原则**:不重复代码,抽象出可复用逻辑
3. **长远考虑**:不追求局部最优,考虑整体架构演进
4. **修改收敛**:同类改动合并到一个批次提交,不零散提交
## 代码规范
1. **Rust 后端**:提交前运行 `cd src-tauri && cargo check`
2. **前端 TypeScript/Vue**:提交前运行 `npx vue-tsc --noEmit`
3. **完整构建**:提交前运行 `npx vite build`(验证构建不报错)
4. **TypeScript 文件名**:camelCase(`fileExplorer.ts`)
5. **Vue 组件文件名**:PascalCase(`FileExplorer.vue`)
## i18n
- 中英文双语言同步:`src/i18n/en/` 和 `src/i18n/zh-CN/`
- 新增组件时同时添加对应语言的翻译文件
- 国际化文件自动聚合(`src/i18n/*/index.ts` 自动导入所有模块文件)
+181
View File
@@ -0,0 +1,181 @@
{
// ── 项目级 Zed 配置: rust-analyzer 性能极限优化 ──────────────
//
// workspace: src-tauri + 7 个 df-* crate 的 Rust Monorepo
// flcheck 单次 600+ 行 artifact JSON → Zed 解析+渲染冻结 UI
// 核心策略: 关保存即 check · 限制检查范围 · 裁切 LSP 负载 · 排 IO 争抢
// ─────────────────────────────────────────────────────────────────
"lsp": {
"rust-analyzer": {
"initialization_options": {
// ── cargo 编译参数 ───────────────────────────────────
"cargo": {
"features": [],
"allTargets": false, // 只为 host target 解析
"noDefaultFeatures": false,
"targetDir": null,
"buildScripts": {
"enable": true,
"invocationStrategy": "once", // 只跑一次,不反复
"rerun": "on-save"
},
"extraArgs": []
},
// ── check / flycheck ────────────────────────────────
"check": {
"onSave": false, // ★ 核心!关保存即 check
"workspace": false, // 手动 check 也只查当前 crate
"command": "check", // 用 check,非 clippy
"invocationStrategy": "once", // 只跑一轮,不反复
"features": [],
"allTargets": false,
"ignore": [ // 跳过不常用的目录
"benches",
"examples",
"xtask",
"tests"
]
},
// ── 缓存预热(关掉,省首次启动 CPU 爆发) ──────────────
"cachePriming": {
"enable": false
},
// ── 诊断 ────────────────────────────────────────────
"diagnostics": {
"enable": true,
"experimental": {
"enable": false
},
"disabled": [ // 关掉非关键诊断,降 CPU
"unresolved-proc-macro",
"inactive-code",
"macro-error"
]
},
// ── proc-macro(必须开,否则 tauri 全报红) ────────────
"procMacro": {
"enable": true,
"attributes": {
"enable": true
}
},
// ── 符号检索 ────────────────────────────────────────
"workspace": {
"symbol": {
"search": {
"kind": "only_types",
"limit": 64
}
}
},
// ── 补全(限流减少 LSP 计算量) ──────────────────────
"completion": {
"autoclose": true,
"autoimport": {
"enable": true
},
"callable": {
"snippets": "fill_arguments"
},
"fullFunction": {
"enable": false
},
"limit": 200, // 补全条目上限
"postfix": {
"enable": false // 关 postfix snippet 减少候选
}
},
// ── 内联提示(大文件里关掉多数减少 UI 重绘) ──────────
"inlayHints": {
"bindingModeHints": {
"enable": false
},
"chainingHints": {
"enable": false
},
"closingBraceHints": {
"enable": false
},
"closureReturnTypeHints": {
"enable": "never"
},
"constructorHints": "never",
"discriminantHints": {
"enable": "never"
},
"expressionAdjustmentHints": {
"mode": "never"
},
"implicitDrops": "never",
"lifetimeElisionHints": {
"enable": "skip_trivial"
},
"maxLength": 100,
"parameterHints": {
"enable": true
},
"reborrowHints": {
"enable": "never"
},
"renderColons": true,
"typeHints": {
"enable": true,
"hideNamedConstructor": false
}
},
// ── 语义高亮(大文件关掉字符串部分减少重绘) ──────────
"semanticHighlights": {
"strings": {
"enable": false
}
},
// ── 高亮关联(关掉减少 LSP 图构建) ──────────────────
"highlightRelated": {
"references": {
"enable": false
}
},
// ── 测试解释器(关掉) ───────────────────────────────
"interpret": {
"tests": false
},
// ── rust-analyzer 文件扫描排除 ─────────────────────
"files": {
"excludeDirs": [
".cargo",
"target",
"target-musl",
"node_modules",
"dist",
".zed",
".git"
]
}
}
}
},
// ── Zed 文件监视排除(减少 File Watcher IO 争抢) ────────────
// 和全局 ~/.config/zed/settings.json 的 file_scan_exclusions 叠加
"file_scan_exclusions": [
"**/target/**",
"**/target-musl/**",
"**/node_modules/**",
"**/dist/**",
"**/.cargo/**",
"**/.git/**",
"**/build/**"
]
}
+257 -32
View File
@@ -1,6 +1,6 @@
# DevFlow — 产研全流程工作流平台 # DevFlow — 产研全流程工作流平台
> 版本: v0.1.0 | 创建: 2026-06-10 | 状态: 设计阶段 > 版本: v0.1.0 | 创建: 2026-06-10 | 状态: Phase 2 本地优先开发流程验证
## 一、项目定位 ## 一、项目定位
@@ -19,7 +19,9 @@
- **经验进化 (Evolution)**:开发过程中的模式自动沉淀为知识库(审查规则/Prompt模板/踩坑经验),持续进化复用 - **经验进化 (Evolution)**:开发过程中的模式自动沉淀为知识库(审查规则/Prompt模板/踩坑经验),持续进化复用
- **阶段插件**:想法→需求→编码→测试→发布,阶段即模板 - **阶段插件**:想法→需求→编码→测试→发布,阶段即模板
### 层级模型 ### 层级模型(业务层级)
DevFlow 的业务抽象分三层,自上而下层层实例化:
``` ```
💡 Idea Pool (想法池) — 独立运转,持续捕捉和评估 💡 Idea Pool (想法池) — 独立运转,持续捕捉和评估
@@ -31,6 +33,40 @@
└→ 🎯 Release (发布) — 合并多个 Task → 集成测试 → 发布 └→ 🎯 Release (发布) — 合并多个 Task → 集成测试 → 发布
``` ```
### 层级模型(执行层级)
Workflow DAG 的执行层进一步拆分为三层,这是 AI Factory 的核心抽象:
```
┌──────────────────────────────────────────────────┐
│ 模板层 (Template) │
│ "应该做什么" — 阶段蓝图、行业最佳实践 │
│ │
│ 职责: 定义节点拓扑 + 产出物规范 + 质量门禁 │
│ 生命周期: 长期存在,跨项目复用 │
│ 存储: YAML 文件 / DB 模板库 │
├──────────────────────────────────────────────────┤
│ 工作流层 (Workflow) │
│ "怎么执行" — DAG 实例、状态机、运行时 │
│ │
│ 职责: 拓扑排序 + 节点调度 + 状态流转 + 持久化 │
│ 生命周期: 随项目启动/结束,单次执行后归档 │
│ 载体: df-workflow (DAG + Executor + StateMachine) │
├──────────────────────────────────────────────────┤
│ 人设层 (Persona) │
│ "谁来做" — Agent 角色、能力边界、行为风格 │
│ │
│ 职责: 定义 system prompt + 可用工具 + 输出格式 │
│ 生命周期: 长期存在,跨节点复用 │
│ 注入点: AINode 执行时载入对应人设 │
└──────────────────────────────────────────────────┘
```
**关键设计原则**:三层各自独立演化,在 AINode 执行时交汇。
- 模板 = 可复用的蓝图(定义节点拓扑 + 建议人设 + 质量门禁)
- 工作流 = 模板的运行时实例(含状态、数据绑定、执行记录)
- 人设 = Agent 的角色卡(system prompt + 工具集 + 行为规则)
### AI Working 定位体系 ### AI Working 定位体系
DevFlow 的终极交互模型是 **AI 驱动 (AI Working)**:**AI 是系统的主要操作者,人是监督者与决策者**。 DevFlow 的终极交互模型是 **AI 驱动 (AI Working)**:**AI 是系统的主要操作者,人是监督者与决策者**。
@@ -119,9 +155,9 @@ DevFlow 的终极交互模型是 **AI 驱动 (AI Working)**:**AI 是系统的
devflow/ devflow/
├── Cargo.toml # Workspace 根 ├── Cargo.toml # Workspace 根
├── crates/ ├── crates/
│ ├── df-core/ # 核心类型、错误、常量、事件 │ ├── df-types/ # 核心类型、错误、常量、事件、augmentation
│ ├── df-workflow/ # 工作流 DAG 引擎 (核心) │ ├── df-workflow/ # 工作流 DAG 引擎 (核心)
│ ├── df-nodes/ # 内置节点集合 (AI/Script/Human) │ ├── df-nodes/ # 内置节点集合 (AI/Script/Human/Git/HTTP/Notify/Subflow/Docker/自审/推进)
│ ├── df-ai/ # AI 编排层 (Multi-Provider/Coordinator) │ ├── df-ai/ # AI 编排层 (Multi-Provider/Coordinator)
│ ├── df-execute/ # 执行运行时 (Shell) │ ├── df-execute/ # 执行运行时 (Shell)
│ ├── df-storage/ # 存储层 (SQLite) │ ├── df-storage/ # 存储层 (SQLite)
@@ -284,8 +320,9 @@ pub struct Decision {
- **LlmProvider trait**:统一接口,各模型实现(`provider.rs`)✅ - **LlmProvider trait**:统一接口,各模型实现(`provider.rs`)✅
- **ModelRouter**:按任务类型路由到最优模型 + 降级链 — ❌ 已删(R-PD-7),模型选择改由调用方在 `LlmProvider` 实现间直接指定 - **ModelRouter**:按任务类型路由到最优模型 + 降级链 — ❌ 已删(R-PD-7),模型选择改由调用方在 `LlmProvider` 实现间直接指定
- **AgentCoordinator**:多 Agent 协作(Planner/Coder/Reviewer/Fixer)— ⚠️ 骨架空壳(`coordinator.rs`,仅单元结构体 + 日志,未实现实际协调) - **AgentCoordinator**:多 Agent 协作(Plan DAG 拆解 + JoinSet 层内并行 + Token 预算池 + Reviewer 仲裁) — ✅ 已实现
- **ContextManager**:Token 预算管理(`context.rs`,完整实现)✅ - **Git Worktree 隔离**:子 Agent 文件隔离(git worktree + 独立分支) — ✅ 已实现
- **ContextManager**:Token 预算管理(`context/mod.rs`,拆分为 sanitize/manager_tests 子模块)✅
- **ToolRegistry**:工具注册(`ai_tools.rs`,供 Agent 调用)✅ - **ToolRegistry**:工具注册(`ai_tools.rs`,供 Agent 调用)✅
### 5.5 内置节点 (df-nodes) ### 5.5 内置节点 (df-nodes)
@@ -293,23 +330,25 @@ pub struct Decision {
| 节点 | 功能 | 状态 | | 节点 | 功能 | 状态 |
|------|------|------| |------|------|------|
| AINode | 调用 LLM,支持流式输出、工具调用 | ✅ 已实现 | | AINode | 调用 LLM,支持流式输出、工具调用 | ✅ 已实现 |
| AiSelfReviewNode | AI 对任务产出自审(verdict 闸门) | ✅ 已实现 |
| ScriptNode | Shell/脚本执行 | ✅ 已实现 | | ScriptNode | Shell/脚本执行 | ✅ 已实现 |
| HumanNode | 人工审批/确认 (阻塞) | ✅ 已实现 | | HumanNode | 人工审批/确认 (阻塞) | ✅ 已实现 |
| DockerNode | Docker 容器操作 | ❌ 未实现 | | TaskAdvanceNode | 任务状态推进链唯一 status 写入路径 | ✅ 已实现 |
| GitNode | Git 操作 (libgit2) | ❌ 未实现 | | GitNode | Git 操作(branch/checkout/commit/merge/push/status/log) | ✅ 已实现 |
| NotifyNode | 通知 (桌面/飞书/Webhook) | ❌ 未实现 | | HTTPNode | HTTP 请求(GET/POST/PUT/DELETE) | ✅ 已实现 |
| HTTPNode | HTTP 请求 | ❌ 未实现 | | NotifyNode | 通知(桌面/Webhook) | ✅ 已实现 |
| SubflowNode | 嵌套子工作流 | ❌ 未实现 | | SubflowNode | 嵌套子工作流(深度限制防递归) | ✅ 已实现 |
| DockerNode | Docker 容器内构建/测试(环境检测+授权) | ✅ 已实现 |
> `crates/df-nodes/src/` 实际仅 ai_node / script_node / human_node 3 文件(Docker/Git/Notify/HTTP/Subflow 为设计预留,尚未实现)。详见 [df-nodes 模块文档](docs/03-模块文档/df-nodes-节点集合-2026-06-12.md)。 > `crates/df-nodes/src/` 含 10 个 `impl Node`(ai/ai_self_review/script/human/task_advance/git/http/notify/subflow/docker,全部已实现)。
## 六、数据模型 ## 六、数据模型
### SQLite 表结构 ### SQLite 表结构
> 真相源:`crates/df-storage/src/migrations.rs`(V1-V13,幂等迁移)。下表对齐真实 schema,字段/类型/默认值/外键与迁移文件一致。Rust 结构体见 `crates/df-storage/src/models.rs`。 > 真相源:`crates/df-storage/src/migrations.rs`(V1-V41,幂等迁移)。下表对齐真实 schema,字段/类型/默认值/外键与迁移文件一致。Rust 结构体见 `crates/df-storage/src/models.rs`。
共 **13 张业务表**(另含内部表 `schema_version` 记录迁移版本)。 共 **24 张业务表**(另含内部表 `schema_version` 记录迁移版本)。
```sql ```sql
-- 想法池 (V1 建表 / V2 补 promoted_to + ai_analysis + scores) -- 想法池 (V1 建表 / V2 补 promoted_to + ai_analysis + scores)
@@ -325,6 +364,8 @@ CREATE TABLE ideas (
promoted_to TEXT, -- 晋升后的 project_id (V2) promoted_to TEXT, -- 晋升后的 project_id (V2)
ai_analysis TEXT, -- AI 分析结果 JSON (V2) ai_analysis TEXT, -- AI 分析结果 JSON (V2)
scores TEXT, -- 多维评分 JSON: feasibility/impact/urgency/overall (V2) scores TEXT, -- 多维评分 JSON: feasibility/impact/urgency/overall (V2)
related_ids TEXT, -- 关联灵感 ID JSON 数组 (V24)
deleted_at TEXT, -- 软删回收站 (V28)
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
@@ -343,18 +384,27 @@ CREATE TABLE projects (
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
-- 任务 (V1 建表 / V2 补 workflow_def_id + base_branch) -- 任务 (V1 建表 / V2 补 workflow_def_id + base_branch / V20 补 idea_id / V14 补 deleted_at /
-- V15 补 review_rounds / V17 补 output_json / V29 补 queue+parent_id+content_json / V41 补 module_id)
CREATE TABLE tasks ( CREATE TABLE tasks (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id), project_id TEXT NOT NULL REFERENCES projects(id),
title TEXT NOT NULL, title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'todo', -- todo/in_progress/in_review/testing/done/blocked/cancelled status TEXT NOT NULL DEFAULT 'todo', -- todo/in_progress/in_review/testing/done/blocked/cancelled
priority INTEGER NOT NULL DEFAULT 1, priority INTEGER NOT NULL DEFAULT 2, -- 0=critical..3=low,默认 2=medium
branch_name TEXT, branch_name TEXT,
assignee TEXT, assignee TEXT,
workflow_def_id TEXT, -- 关联的工作流定义 ID (V2) workflow_def_id TEXT, -- 关联的工作流定义 ID (V2)
base_branch TEXT, -- 基础分支 (V2) base_branch TEXT, -- 基础分支 (V2)
idea_id TEXT REFERENCES ideas(id), -- 关联灵感 (V20)
review_rounds INTEGER NOT NULL DEFAULT 0, -- review 退回累计轮数 (V15)
output_json TEXT, -- AiNode 自审闭环产出 (V17)
queue TEXT NOT NULL DEFAULT 'todo', -- 管理池 backlog/todo/decision/active/done (V29)
parent_id TEXT REFERENCES tasks(id), -- 父任务(限 1 级嵌套) (V29)
content_json TEXT, -- 结构化需求规格 JSON (V29)
module_id TEXT REFERENCES project_modules(id) ON DELETE SET NULL, -- 关联工程 (V41)
deleted_at TEXT, -- 软删回收站 (V14)
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
@@ -395,7 +445,8 @@ CREATE TABLE workflow_executions (
project_id TEXT, -- 关联项目 ID (V2) project_id TEXT, -- 关联项目 ID (V2)
task_id TEXT, -- 关联任务 ID (V2) task_id TEXT, -- 关联任务 ID (V2)
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
completed_at TEXT completed_at TEXT,
updated_at TEXT -- 更新时间戳,排序/增量同步 (V33)
); );
-- 节点执行 (V1 建表) — 工作流执行过程中每个节点的运行快照 -- 节点执行 (V1 建表) — 工作流执行过程中每个节点的运行快照
@@ -412,7 +463,7 @@ CREATE TABLE node_executions (
completed_at TEXT completed_at TEXT
); );
-- AI 提供商配置 (V9 建表) — 多 Provider 统一抽象 -- AI 提供商配置 (V9 建表 / V18 补 model_configs / V19 补 enabled + weight)
CREATE TABLE ai_providers ( CREATE TABLE ai_providers (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -423,29 +474,38 @@ CREATE TABLE ai_providers (
models TEXT, -- JSON array of model names models TEXT, -- JSON array of model names
is_default INTEGER NOT NULL DEFAULT 0, is_default INTEGER NOT NULL DEFAULT 0,
config TEXT, -- JSON extra config config TEXT, -- JSON extra config
model_configs TEXT, -- 模型能力配置 JSON (V18)
enabled INTEGER NOT NULL DEFAULT 1, -- 负载均衡池开关 (V19)
weight INTEGER NOT NULL DEFAULT 50, -- 负载均衡权重 (V19)
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
-- AI 对话 (V3 建表 / V4 补 archived / V5 补 prompt_tokens + completion_tokens / V6 补 models) -- AI 对话 (V3 建表 / V4 补 archived / V5 补 prompt_tokens + completion_tokens / V6 补 models /
-- V16 补 pinned / V32 补 pinned_goals / V33 补 pending_approvals)
CREATE TABLE ai_conversations ( CREATE TABLE ai_conversations (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
title TEXT, title TEXT,
messages TEXT NOT NULL DEFAULT '[]', -- JSON array of ChatMessage messages TEXT NOT NULL DEFAULT '[]', -- JSON array of ChatMessage (V21 起消息拆分到 ai_messages)
provider_id TEXT, provider_id TEXT,
model TEXT, model TEXT,
models TEXT, -- 用过的所有 model JSON 数组字符串 (去重, V6) models TEXT, -- 用过的所有 model JSON 数组字符串 (去重, V6)
archived INTEGER NOT NULL DEFAULT 0, -- 是否归档(侧栏折叠展示, V4) archived INTEGER NOT NULL DEFAULT 0, -- 是否归档(侧栏折叠展示, V4)
pinned INTEGER NOT NULL DEFAULT 0, -- 侧栏置顶 (V16)
prompt_tokens INTEGER, -- 输入 token 累计 (流式 usage 落库, V5) prompt_tokens INTEGER, -- 输入 token 累计 (流式 usage 落库, V5)
completion_tokens INTEGER, -- 输出 token 累计 (流式 usage 落库, V5) completion_tokens INTEGER, -- 输出 token 累计 (流式 usage 落库, V5)
pinned_goals TEXT DEFAULT '[]', -- 目标钉扎持久化 (V32)
pending_approvals TEXT DEFAULT '{}', -- 挂起审批快照,重启恢复 (V33)
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
-- AI 工具执行审计 (V9 建表) — Agent 工具调用的审批/执行留痕 -- AI 工具执行审计 (V9 建表 / V21 补 message_id / V27 状态统一 executed→completed / V36 补 subtask_id)
-- Agent 工具调用的审批/执行留痕
CREATE TABLE ai_tool_executions ( CREATE TABLE ai_tool_executions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
conversation_id TEXT, conversation_id TEXT,
message_id TEXT, -- 关联消息 (V21)
tool_call_id TEXT NOT NULL, tool_call_id TEXT NOT NULL,
tool_name TEXT NOT NULL, tool_name TEXT NOT NULL,
arguments TEXT NOT NULL, arguments TEXT NOT NULL,
@@ -454,7 +514,8 @@ CREATE TABLE ai_tool_executions (
risk_level TEXT NOT NULL DEFAULT 'medium', -- low/medium/high risk_level TEXT NOT NULL DEFAULT 'medium', -- low/medium/high
requested_at TEXT NOT NULL, requested_at TEXT NOT NULL,
executed_at TEXT, executed_at TEXT,
decided_by TEXT -- human/auto decided_by TEXT, -- human/auto
subtask_id TEXT -- 归属子任务 (V36)
); );
-- 知识条目 (V7 建表 / V8 补 embedding / V10 补 reasoning) — 经验沉淀基本单元, 共享记忆层 -- 知识条目 (V7 建表 / V8 补 embedding / V10 补 reasoning) — 经验沉淀基本单元, 共享记忆层
@@ -473,6 +534,7 @@ CREATE TABLE knowledges (
source_ref TEXT, -- 来源实体引用(如 conv:{id}) source_ref TEXT, -- 来源实体引用(如 conv:{id})
reasoning TEXT, -- AI 提炼判断依据("为何值得沉淀"), 手动录入为 NULL (V10) reasoning TEXT, -- AI 提炼判断依据("为何值得沉淀"), 手动录入为 NULL (V10)
embedding BLOB, -- Vec<f32> 小端字节序列化, NULL=未嵌入走 LIKE 降级 (V8) embedding BLOB, -- Vec<f32> 小端字节序列化, NULL=未嵌入走 LIKE 降级 (V8)
embedding_status TEXT, -- 嵌入状态,失败可补偿重试 (V23)
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
@@ -494,23 +556,182 @@ CREATE TABLE app_settings (
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
-- AI 消息 (V9 新库建表 / V21 老库补建 / V36 补 subtask_id / V38 补 token 列 / V39 补 cache/reason 列)
-- V21 起 ChatMessage 逐条拆行存储,支撑消息级 token / 分页 / 溯源
CREATE TABLE ai_messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
seq INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
parts TEXT,
tool_call_id TEXT,
tool_calls TEXT,
model TEXT,
status TEXT NOT NULL DEFAULT 'active',
reasoning_content TEXT,
timestamp INTEGER,
subtask_id TEXT, -- 归属子任务 (V36)
created_at TEXT NOT NULL,
prompt_tokens INTEGER, -- 消息级输入 token (V38)
completion_tokens INTEGER, -- 消息级输出 token (V38)
prompt_cache_hit_tokens INTEGER, -- 缓存命中 token (V39)
prompt_cache_miss_tokens INTEGER, -- 缓存未命中 token (V39)
reasoning_tokens INTEGER, -- 思考 token (V39)
UNIQUE(conversation_id, seq)
);
-- 灵感评估历史 (V22 建表 / V25 补唯一约束) — 追加型审计,version 单调递增
CREATE TABLE idea_evaluations (
id TEXT PRIMARY KEY,
idea_id TEXT NOT NULL,
version INTEGER NOT NULL,
ai_analysis TEXT,
scores TEXT,
score REAL,
evaluated_by TEXT,
evaluated_at TEXT NOT NULL,
UNIQUE(idea_id, version) -- 评估版本并发重复兜底 (V25)
);
-- 任务横向关联 (V29 建表) — 任务间 depends_on/blocks/relates_to,循环检测走应用层
CREATE TABLE task_links (
id TEXT PRIMARY KEY,
source_id TEXT NOT NULL REFERENCES tasks(id),
target_id TEXT NOT NULL REFERENCES tasks(id),
link_type TEXT NOT NULL,
remark TEXT,
created_at TEXT NOT NULL
);
-- 项目事件流 (V30 建表) — 跨实体统一事件,追加型审计(只增不改)
CREATE TABLE project_events (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
event_type TEXT NOT NULL,
entity_type TEXT,
entity_id TEXT,
from_state TEXT,
to_state TEXT,
context_json TEXT,
source TEXT, -- ai/human/system
conversation_id TEXT,
created_at TEXT NOT NULL
);
-- 项目基础设施配置 (V31 建表) — 记录"项目用了什么基础设施",不存敏感凭证
CREATE TABLE project_services (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
name TEXT NOT NULL,
service_type TEXT NOT NULL, -- mysql/postgresql/sqlite/redis/mongodb/mq/api/other
endpoint TEXT,
config_json TEXT,
environment TEXT NOT NULL DEFAULT 'development',
remark TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 工程 (V34 建表 / V40 补 description + status) — 项目下多工程(Monorepo/微服务/前后端分离)
CREATE TABLE project_modules (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
name TEXT NOT NULL,
path TEXT NOT NULL,
git_url TEXT,
stack TEXT,
auto_detected BOOLEAN NOT NULL DEFAULT FALSE,
sort_order INTEGER NOT NULL DEFAULT 0,
description TEXT, -- 工程职责描述 (V40)
status TEXT, -- active/archived (V40)
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 工程依赖 (V35 建表) — 工程间依赖边,支撑依赖图
CREATE TABLE module_dependencies (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
from_module_id TEXT NOT NULL REFERENCES project_modules(id),
to_module_id TEXT NOT NULL REFERENCES project_modules(id),
dep_type TEXT NOT NULL DEFAULT 'library', -- library/api/mq/shared/custom
label TEXT,
created_at TEXT NOT NULL
);
-- 多 Agent 并行执行 (V36 建表) — ai_plans/ai_subtasks/ai_conflicts 三表 + ai_messages/ai_tool_executions 补 subtask_id
CREATE TABLE ai_plans (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
user_message_id TEXT,
status TEXT NOT NULL DEFAULT 'planning',
subtask_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
completed_at TEXT
);
CREATE TABLE ai_subtasks (
id TEXT PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES ai_plans(id),
persona_id TEXT,
intent TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
layer INTEGER NOT NULL DEFAULT 0,
deps TEXT,
branch TEXT,
created_at TEXT NOT NULL,
completed_at TEXT
);
CREATE TABLE ai_conflicts (
id TEXT PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES ai_plans(id),
file_path TEXT NOT NULL DEFAULT '',
conflict_type TEXT NOT NULL DEFAULT 'file',
subtask_a TEXT,
subtask_b TEXT,
diff_a TEXT,
diff_b TEXT,
resolution TEXT NOT NULL DEFAULT 'pending',
resolved_by TEXT,
created_at TEXT NOT NULL,
resolved_at TEXT
);
-- 对话版本化快照 (V37 建表)
CREATE TABLE 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
);
-- 内部表: schema_version (version INTEGER PRIMARY KEY) — 迁移版本记录 -- 内部表: schema_version (version INTEGER PRIMARY KEY) — 迁移版本记录
``` ```
## 七、阶段模板 ## 七、三层模型:模板 → 工作流 → 人设
5 个内置阶段作为工作流模板(YAML 定义),用户可自定义。 > 本章详细设计已迁至专项文档,详见 [docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md](./docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md)。此处仅保留摘要性定义。
- 💡 **想法**:市场分析 → 竞品调研 → 可行性评分 ### 7.1 三层定义
- 📋 **需求**:AI 生成 PRD → 人工审阅 → 任务拆解
- 💻 **编码**:AI 编码 → 代码审查 → 自动修复 | 层 | 回答的问题 | 本质 | 生命周期 | 当前状态 |
- 🧪 **测试**:运行测试 → AI 分析失败 → 回归验证 |----|-----------|------|---------|---------|
- 🚀 **发布**:构建 → 人工确认 → 部署 → 健康检查 | **流程模板 (Template)** | 应该做什么 | 可复用的蓝图(节点拓扑 + 建议人设 + 质量门禁) | 长期存在,跨项目复用 | ⚡ 需重新设计(原 df-stages 已移除) |
| **工作流 (Workflow)** | 怎么执行 | 模板的运行时实例(DAG + 状态 + 数据绑定) | 随项目启停,单次执行归档 | ✅ df-workflow 核心完成 |
| **人设 (Persona)** | 谁来做 | Agent 角色卡(system prompt + 工具集 + 行为规则) | 长期存在,跨节点复用 | ⬜ 待设计 |
**关键原则**:模板不绑定具体人设、工作流不感知人设、人设与模板解耦。
详细定义、三者关系、实例化流程、数据结构及 YAML 模板示例见 [专项设计文档](./docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md)。
## 八、Phase 规划 ## 八、Phase 规划
### Phase 1 — 引擎骨架 (4-6 周) ### Phase 1 — 引擎骨架 (4-6 周)
- df-core + df-workflow (DAG + Node trait + Executor) - df-types + df-workflow (DAG + Node trait + Executor)
- df-storage (SQLite 基础表) - df-storage (SQLite 基础表)
- df-execute (Shell 执行) - df-execute (Shell 执行)
- 最小前端:项目列表 + 工作流执行日志 - 最小前端:项目列表 + 工作流执行日志
@@ -528,9 +749,10 @@ CREATE TABLE app_settings (
- 前端:想法池视图 + 多项目 Tab - 前端:想法池视图 + 多项目 Tab
- 验证:想法捕捉 → AI 评估 → 立项 → 工作流执行 - 验证:想法捕捉 → AI 评估 → 立项 → 工作流执行
### Phase 4 — 节点丰富 + 阶段插件 (3-4 周) ### Phase 4 — 节点丰富 + 三层模型落地 (3-4 周)
- df-nodes (Docker/Git/Human/HTTP) - df-nodes (Docker/Git/Human/HTTP)
- ~~df-stages (5 阶段模板)~~ — 已移除(2026-06-14 零引用清理) - 流程模板系统(YAML 定义 + 模板库 + 实例化引擎)
- 人设系统(AgentPersona 数据结构 + 内置人设 + 工具过滤)
- 条件分支 + 断点续跑 - 条件分支 + 断点续跑
- 验证:跑通标准产研流程模板 - 验证:跑通标准产研流程模板
@@ -566,3 +788,6 @@ CREATE TABLE app_settings (
8. **本地优先**:SQLite 嵌入,不依赖云服务 8. **本地优先**:SQLite 嵌入,不依赖云服务
9. **多模型并行**:统一抽象,按任务路由,不锁定单一模型 9. **多模型并行**:统一抽象,按任务路由,不锁定单一模型
10. **流式优先**:AI 输出、Shell 输出全部流式推送到前端 10. **流式优先**:AI 输出、Shell 输出全部流式推送到前端
11. **模板/工作流/人设三层分离**:模板是蓝图,工作流是实例,人设是角色卡。三层各自独立演化,在 AINode 执行时交汇
12. **人设与模板解耦**:模板标注建议人设但不绑定,同一个人设可用于不同模板的同类节点
13. **模板实例化**:模板 → 工作流实例 + 人设分配,允许实例化时按项目覆盖人设
+25
View File
@@ -0,0 +1,25 @@
# DevFlow 工作规范
## 任务看板自动领单(最高优先级纪律)
1. **会话启动即读板**:任何会话开工前,先读 `docs/todo.md`——按优先级领任务:🔴 高危项 > B 批次/编号项 > C 类候选。
2. **批收敛即回读**:每完成一个任务批(提交后),重读看板领下一项——挂机/连续工作模式下每批必回读,禁凭记忆连续开工。
3. **领单动作**:领单后将条目改为 `[→]`(进行中),完成后改 `[x]` 并附验收命令输出(G4);无法完成的改 `[!]` 附卡点,禁静默跳过。
4. **🔴 门禁(G2)**:看板存在未清零 🔴 条目时,只允许领 🔴 销账任务,新功能任务禁领(除非用户明示)。
5. **提交纪律(G3/G4)**:提交描述与 diff 机械对应;一笔一事或逐件列全;分组提交按看板标注的主题分组。
6. **汇报实测**:任何进度/完成汇报,先跑 `git log -1 --format="%h %s"` 与 `git status --short | wc -l` 实测再报,禁凭记忆报状态。
7. **看板顶部「挂机必读」警示**:启动时若存在,视为最高优先级上下文覆盖。
## 自主修复纪律
- 明确 bug(实证在案/用户指出/唯一正确解或用户已给修法方向):直接修并报告,禁反问「要不要修」(2026-10-06 用户纠正:发现换装脚本缺陷后反问=自主性差)。
- 边界对齐意志卡 forbidden:发版/部署/重启线上、git push、生产数据操作、对外发消息,攒齐等拍板。
- 本机工具/脚本的行为变更属 report_after 级(干完通报、可回滚),不属确认级。
## 命名与代码风格(2026-10-10 用户重申)
- **变量/方法名:精简 + 准确**——一眼看出「是什么/做什么」且不超必要长度。忌把实现细节写进名字(`..._after_tool_run`)、忌无信息缩写(`tmp2`/`h`)、忌同概念多叫法。名字过长通常说明该拆函数或把说明下沉到注释(注释承载「为什么」,名字只承载「是什么」)。
- **DRY · 简洁 · 扩展性 · 高内聚低耦合**(同期重申):新增能力先找共用底座(如定时消息与定时任务共用一张 `ai_outbox` + 一个调度器),不造双轨;同一事实只留**单一真值来源**(如 token 估值口径)。
## 其他规范
- 架构设计文档在 `docs/02-架构设计/`;当前活跃专项:无人值守可靠性(B1-B4)+ 异步任务会话(13-17),设计见 `temp/` 与 docs。
Generated
+1116 -49
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -22,3 +22,15 @@ regex = "1"
# Win+mac 启用原生后端;linux 用 async-persistent 变体避同步阻塞 Secret Service。 # Win+mac 启用原生后端;linux 用 async-persistent 变体避同步阻塞 Secret Service。
# 由 df-storage(密钥解析下沉层) + src-tauri(build_provider_for 转发壳)共同引用。 # 由 df-storage(密钥解析下沉层) + src-tauri(build_provider_for 转发壳)共同引用。
keyring = { version = "3", features = ["windows-native", "apple-native", "linux-native-async-persistent"] } keyring = { version = "3", features = ["windows-native", "apple-native", "linux-native-async-persistent"] }
# dashmap: ConvStateStore 无锁并发 HashMap(session 锁重构方案 B-Phase0)。
# ConvState 提到 ConvStateStore(DashMap),guard.reset/ai_is_generating 不依赖 session lock,
# 治卡死连环(AiCompleted 延迟/工具后中断/第二条进队列同源根因)。详见 workflow session-lock-redesign。
dashmap = "6"
# release 增量编译:小改动重打包显著提速(2026-10-04 打包提速项);
# ⚠️ 2026-10-09 回退:release + incremental 触发链接期匿名符号丢失
# (anon.*.llvm.* undefined symbol,rust-lld 与 MSVC link.exe 均复现),
# 根因是增量编译 CGU 划分与跨 crate 内联冲突。cargo check 不链接故不暴露,
# cargo build/tauri build 链接期必挂。release 禁用 incremental 为 Rust 官方推荐。
# [profile.release]
# incremental = true
File diff suppressed because it is too large Load Diff
+28 -25
View File
@@ -12,6 +12,7 @@
"@dcloudio/uni-components": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-components": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-h5": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-h5": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-mp-weixin": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-mp-weixin": "3.0.0-alpha-4080720251125001",
"highlight.js": "^11.11.1",
"marked": "^18.0.5", "marked": "^18.0.5",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-i18n": "^9.0.0" "vue-i18n": "^9.0.0"
@@ -21,6 +22,7 @@
"@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001",
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001", "@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001",
"@swc/helpers": "^0.5.23",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@vue/runtime-core": "^3.4.0", "@vue/runtime-core": "^3.4.0",
"@vue/tsconfig": "^0.5.0", "@vue/tsconfig": "^0.5.0",
@@ -4715,7 +4717,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4729,7 +4730,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4743,7 +4743,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4757,7 +4756,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4771,7 +4769,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4785,7 +4782,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4799,7 +4795,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4813,7 +4808,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4827,7 +4821,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4841,7 +4834,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4855,7 +4847,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4869,7 +4860,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4883,7 +4873,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4897,7 +4886,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4911,7 +4899,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4925,7 +4912,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4939,7 +4925,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4953,7 +4938,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4967,7 +4951,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4981,7 +4964,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4995,7 +4977,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5009,7 +4990,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5023,7 +5003,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5037,7 +5016,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5051,7 +5029,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5080,6 +5057,16 @@
"@sinonjs/commons": "^1.7.0" "@sinonjs/commons": "^1.7.0"
} }
}, },
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tootallnate/once": { "node_modules/@tootallnate/once": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmmirror.com/@tootallnate/once/-/once-1.1.2.tgz", "resolved": "https://registry.npmmirror.com/@tootallnate/once/-/once-1.1.2.tgz",
@@ -8052,6 +8039,15 @@
"he": "bin/he" "he": "bin/he"
} }
}, },
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/html-encoding-sniffer": { "node_modules/html-encoding-sniffer": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", "resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz",
@@ -13010,6 +13006,13 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/type-detect": { "node_modules/type-detect": {
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmmirror.com/type-detect/-/type-detect-4.0.8.tgz", "resolved": "https://registry.npmmirror.com/type-detect/-/type-detect-4.0.8.tgz",
+3 -1
View File
@@ -7,7 +7,7 @@
"dev:h5": "uni", "dev:h5": "uni",
"dev:mp-weixin": "uni -p mp-weixin", "dev:mp-weixin": "uni -p mp-weixin",
"build:h5": "uni build", "build:h5": "uni build",
"build:mp-weixin": "uni build -p mp-weixin", "build:mp-weixin": "uni build -p mp-weixin && node scripts/fix-mp-build.cjs",
"type-check": "vue-tsc --noEmit" "type-check": "vue-tsc --noEmit"
}, },
"dependencies": { "dependencies": {
@@ -15,6 +15,7 @@
"@dcloudio/uni-components": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-components": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-h5": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-h5": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-mp-weixin": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-mp-weixin": "3.0.0-alpha-4080720251125001",
"highlight.js": "^11.11.1",
"marked": "^18.0.5", "marked": "^18.0.5",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-i18n": "^9.0.0" "vue-i18n": "^9.0.0"
@@ -24,6 +25,7 @@
"@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001",
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001", "@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001",
"@swc/helpers": "^0.5.23",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@vue/runtime-core": "^3.4.0", "@vue/runtime-core": "^3.4.0",
"@vue/tsconfig": "^0.5.0", "@vue/tsconfig": "^0.5.0",
+69
View File
@@ -0,0 +1,69 @@
//! post-build 修复脚本:处理微信小程序构建产物的兼容性问题
//!
//! 背景:marked v18+ 含 lookbehind 检测 `new RegExp("(?<=1)(?<!1)")`,
//! 微信开发者工具转译器静态检测到 `(?<=` 字符串 → 误判需 @swc/helpers/_wrap_reg_exp
//! helper → 注入 require → 产物无此模块 → 小程序白屏。
//!
//! 微信基础库运行时不支持 lookbehind,marked 本就走降级路径(Te=false)。
//! 本脚本把产物里的 lookbehind 检测替换为 Te=false,绕过微信工具静态误判,
//! 同时保留 marked 完整功能(运行时本就降级)。
//!
//! 用法:build 后运行 `node scripts/fix-mp-build.cjs`
const fs = require('fs')
const path = require('path')
const BUILD_DIR = path.join(__dirname, '..', 'dist', 'build', 'mp-weixin')
const VENDOR = path.join(BUILD_DIR, 'common', 'vendor.js')
function fixLookbehind() {
if (!fs.existsSync(VENDOR)) {
console.warn('[fix-mp-build] vendor.js 不存在,跳过:', VENDOR)
return
}
let src = fs.readFileSync(VENDOR, 'utf8')
const before = src
// marked v18 lookbehind 检测 `var Te=((l="")=>{try{return!!new RegExp("(?<=1)(?<!1)"+l)}catch{return!1}})()`
// 替换为 `var Te=false`(强制降级,微信基础库不支持 lookbehind)
src = src.replace(
/var Te=\(\([^)]*\)=>\{try\{return!!new RegExp\("\(\?<=1\)\(\?<!1\)"[^}]*\}\}\)\)\(\),?/,
'var Te=false,'
)
if (src === before) {
// 未命中(压缩名可能不是 Te),退而按字符串替换 lookbehind 检测
src = src.replace(/new RegExp\("\(\?<=1\)\(\?<!1\)"[^)]*\)/g, 'false')
}
// 关键:marked 条件分支里还有 `(?<!`)` lookbehind 字面量(Te 为 true 时的分支)。
// 微信工具静态检测到任何 `(?<=` / `(?<!` 字符都会注入 swc helper 误判。
// 把所有 lookbehind 字面量替换为非 lookbehind 等价形式。
// (?:x) 合法不 throw;但该分支仅 Te=true 时用,运行时 Te=false 恒走降级分支,
// 替换后行为不变,同时消除微信工具的静态触发器。
src = src.replace(/\(\?<!`\)/g, '(?:x)')
src = src.replace(/\(\?<=1\)\(\?<!1\)/g, '(?:1)')
// 命名捕获组(ES2018)也触发 _wrap_reg_exp。marked v18 用 `(?<a>`+)[^`]+\k<a>` 匹配代码围栏。
// 转为编号捕获组 `(`+)\1`(ES5 兼容,无命名捕获组,行为等价)。
// 通用: (?<name>...) → (...), \k<name> → \N(按捕获组序编号,这里只有 a/b 两组)
src = src.replace(/\(\?<a>\`\+\)\[^\`\]\+\\k<a>/g, '(`+)\\1')
src = src.replace(/\(\?<b>\`\+\)\[^\`\]\+\\k<b>/g, '(`+)\\1')
// 兜底:任何残留命名捕获组 (?<name> 或 \k<name> 转为无命名
src = src.replace(/\(\?<[a-z][a-z0-9_]*>/g, '(')
src = src.replace(/\\k<[a-z]>/g, '\\1')
if (src !== before) {
fs.writeFileSync(VENDOR, src)
console.log('[fix-mp-build] vendor.js lookbehind 已全部替换(检测+条件分支)')
} else {
console.warn('[fix-mp-build] 未找到 lookbehind(可能已处理或格式不同)')
}
}
// 清理手动补的 @swc/helpers(不再需要,避免污染)
function cleanupSwcHelpers() {
const swcDir = path.join(BUILD_DIR, 'common', '@swc', 'helpers')
if (fs.existsSync(swcDir)) {
fs.rmSync(swcDir, { recursive: true, force: true })
console.log('[fix-mp-build] 已清理手动补的 @swc/helpers')
}
}
fixLookbehind()
cleanupSwcHelpers()
+29 -14
View File
@@ -1,10 +1,11 @@
/** /**
* df-relay WS 客户端(uni-app 跨端适配) * u-relay WS 客户端(uni-app 跨端适配)
* *
* 协议(对齐 crates/df-relay/src/relay.rs handle_connection): * 协议(对齐 E:/wk-lab/u-relay/src/relay.rs handle_connection):
* 1. 建立 WS 连接到 `/ws/miniapp` * 1. 建立 WS 连接到 `/ws/commander`
* 2. 首帧发 Hello{kind:'miniapp', device_id, token} 身份宣告(relay.rs:197-208,10s 超时) * 2. 首帧发 Hello{kind:'commander', channel_id, token} 身份宣告(relay 10s 超时)
* 3. 校验通过后进入收发循环;失败 relay 发 Error 帧 + Close * 3. 校验通过 → relay 回 hello_ack;失败 relay 发 error 帧 + Close
* (错误码:handshake_failed/kind_mismatch/invalid_channel_id/server_misconfigured/auth_failed)
* *
* 跨端适配: * 跨端适配:
* - 微信小程序:uni.connectSocket(微信原生 API,非浏览器 WebSocket) * - 微信小程序:uni.connectSocket(微信原生 API,非浏览器 WebSocket)
@@ -33,6 +34,9 @@
import { getConfig } from '@/config' import { getConfig } from '@/config'
import type { BroadcastMessage, Hello, MiniCommand, ControlMessage } from '@/types/relay' import type { BroadcastMessage, Hello, MiniCommand, ControlMessage } from '@/types/relay'
/** 调试日志门控(P1 走查:生产 console.log 过微信 bridge,热路径成本)。dev=true / build=false。 */
const DEBUG = import.meta.env.DEV
/** WS 连接状态 */ /** WS 连接状态 */
export type WsStatus = 'disconnected' | 'connecting' | 'connected' | 'handshaking' | 'reconnecting' export type WsStatus = 'disconnected' | 'connecting' | 'connected' | 'handshaking' | 'reconnecting'
@@ -86,6 +90,8 @@ class WsClient {
private reconnectAttempts = 0 private reconnectAttempts = 0
/** 主动关闭标志(用户调用 disconnect 时置 true,不再触发重连) */ /** 主动关闭标志(用户调用 disconnect 时置 true,不再触发重连) */
private manualClose = false private manualClose = false
/** 可恢复标志:达上限 disconnected 时置 true,允许 connect/resumeIfDisconnected 重置重连 */
private recoverable = false
/** /**
* 握手完成前积压的命令队列(send 在 !handshaked 时不丢弃,入队等 hello_ack 后补发)。 * 握手完成前积压的命令队列(send 在 !handshaked 时不丢弃,入队等 hello_ack 后补发)。
* 治「握手成功到首帧竞态丢命令」:握手刚翻 true 时 relay 设备路由表可能未就绪, * 治「握手成功到首帧竞态丢命令」:握手刚翻 true 时 relay 设备路由表可能未就绪,
@@ -112,7 +118,8 @@ class WsClient {
return return
} }
this.manualClose = false this.manualClose = false
// 用户主动连接复位重连计数(可与达上限后的自动退避区分,允许手动重试) this.recoverable = false
// 用户主动连接复位重连计数(可达上限后的 disconnected 也允许手动重试)
this.reconnectAttempts = 0 this.reconnectAttempts = 0
// 统一经 reconnectNow(幂等 clear timer + cleanup + openSocket),杜绝 reconnecting 态双连 // 统一经 reconnectNow(幂等 clear timer + cleanup + openSocket),杜绝 reconnecting 态双连
this.reconnectNow() this.reconnectNow()
@@ -138,7 +145,9 @@ class WsClient {
*/ */
resumeIfDisconnected(): void { resumeIfDisconnected(): void {
if (this.manualClose) return if (this.manualClose) return
if (this.status === 'disconnected' || this.status === 'reconnecting') { // 已达上限 disconnected(recoverable=true)也允许重置计数并重连
if (this.status === 'disconnected' || this.status === 'reconnecting' || this.recoverable) {
this.recoverable = false
// 用户触发的立即重连(onShow 抢占退避)复位计数,允许达上限后手动恢复 // 用户触发的立即重连(onShow 抢占退避)复位计数,允许达上限后手动恢复
this.reconnectAttempts = 0 this.reconnectAttempts = 0
this.reconnectNow() this.reconnectNow()
@@ -168,13 +177,13 @@ class WsClient {
} }
/** /**
* 发送 Command(miniapp → device) * 发送 Command(commander → agent)
* *
* 不包装 BroadcastMessage —— relay.rs:332-365 入站文本帧直接当 payload 包成 * 不包装 BroadcastMessage —— relay.rs:332-365 入站文本帧直接当 payload 包成
* BroadcastMessage(device_id/kind/source/from 由 relay 填),故客户端仅发业务 JSON。 * BroadcastMessage(channel_id/kind/source/from 由 relay 填),故客户端仅发业务 JSON。
*/ */
send(cmd: MiniCommand): boolean { send(cmd: MiniCommand): boolean {
console.log('[dbg:ws] send', cmd.cmd, 'socket=', !!this.socket, 'handshaked=', this.handshaked) if (DEBUG) console.log('[dbg:ws] send', cmd.cmd, 'socket=', !!this.socket, 'handshaked=', this.handshaked)
if (!this.socket || !this.handshaked) { if (!this.socket || !this.handshaked) {
// 握手未完成不丢弃,入队待 hello_ack 后补发(治握手成功到首帧竞态丢命令)。 // 握手未完成不丢弃,入队待 hello_ack 后补发(治握手成功到首帧竞态丢命令)。
// 仅在未主动断开(manualClose=false)且连接尚有恢复预期时入队, // 仅在未主动断开(manualClose=false)且连接尚有恢复预期时入队,
@@ -306,8 +315,8 @@ class WsClient {
this.setStatus('handshaking') this.setStatus('handshaking')
const cfg = getConfig() const cfg = getConfig()
const hello: Hello = { const hello: Hello = {
kind: 'miniapp', kind: 'commander',
device_id: cfg.deviceId, channel_id: cfg.channelId,
token: cfg.token, token: cfg.token,
} }
try { try {
@@ -341,7 +350,7 @@ class WsClient {
} }
const text = res.data const text = res.data
if (!text) return if (!text) return
// 活性:任意入站帧更新(relay 无 pong,用心跳期间入站消息替代判活) // 活性:任意入站帧更新(含 relay 回的 pong 心跳响应,用于半连接检测)
this.lastInboundAt = Date.now() this.lastInboundAt = Date.now()
// 握手阶段可能收到 relay 发的错误控制帧(relay.rs:200-238) // 握手阶段可能收到 relay 发的错误控制帧(relay.rs:200-238)
@@ -508,8 +517,14 @@ class WsClient {
// 用户手动重连(connect/resumeIfDisconnected)复位计数后可重新开始。 // 用户手动重连(connect/resumeIfDisconnected)复位计数后可重新开始。
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
console.warn(`[WsClient] 已达最大重连次数 ${MAX_RECONNECT_ATTEMPTS},停止重连`) console.warn(`[WsClient] 已达最大重连次数 ${MAX_RECONNECT_ATTEMPTS},停止重连`)
// 不清 cleanup(保留 manualClose 不变),仅设状态 + 停退避 timer
// 让 connect()/resumeIfDisconnected() 可重置计数后恢复
this.setStatus('disconnected', '已达最大重连次数,请检查中继地址/网络后手动重连') this.setStatus('disconnected', '已达最大重连次数,请检查中继地址/网络后手动重连')
this.cleanup() this.recoverable = true
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
return return
} }
const cfg = getConfig() const cfg = getConfig()
File diff suppressed because it is too large Load Diff
+98 -16
View File
@@ -1,18 +1,18 @@
/** /**
* miniapp 运行时配置(host/device_id/token 占位,由用户在设置页或 build env 填) * miniapp 运行时配置(channel_id/token 占位,由用户在设置页或 build env 填)
* *
* 这些值在真实部署前是占位: * 这些值在真实部署前是占位:
* - RELAY_HOST:ws/wss 地址,需替换为实际部署的 df-relay 地址 * - RELAY_HOST:ws/wss 地址,需替换为实际部署的 u-relay 地址
* - DEVICE_ID:用户首次配置桌面端时生成的设备 ID * - CHANNEL_ID:u-relay 配对通道 ID(与桌面端 df-tunnel 共享同键)
* - TOKEN:对齐 df-relay 的 DF_RELAY_TOKEN(生产建议每 device 独立 token) * - TOKEN:对齐 u-relay 的 U_RELAY_TOKEN(生产建议 per-channel 独立 token,Phase3)
*/ */
export interface MiniappConfig { export interface MiniappConfig {
/** relay WS 地址(含协议),如 'ws://192.168.1.100:8080/ws/miniapp' 或 'wss://relay.example.com/ws/miniapp' */ /** relay WS 地址(含协议),如 'ws://192.168.1.100:9180/ws/commander' 或 'wss://relay.example.com/ws/commander' */
relayHost: string relayHost: string
/** 配对绑定的设备 ID */ /** 配对绑定的通道 ID(u-relay channel_id,与桌面端共享同键配对) */
deviceId: string channelId: string
/** 配对 token(对齐 df-relay DF_RELAY_TOKEN) */ /** 配对 token(对齐 u-relay U_RELAY_TOKEN) */
token: string token: string
/** 心跳间隔(ms) */ /** 心跳间隔(ms) */
heartbeatInterval: number heartbeatInterval: number
@@ -20,35 +20,117 @@ export interface MiniappConfig {
reconnectBaseDelay: number reconnectBaseDelay: number
/** 重连最大间隔(ms) */ /** 重连最大间隔(ms) */
reconnectMaxDelay: number reconnectMaxDelay: number
/** file.1216.top 文件服务上传地址(不带路径,上传时拼 /upload) */
fileUploadBaseUrl: string
/** 上传 X-Source 头(项目隔离,文件存到 file.1216.top 对应目录) */
fileUploadSource: string
} }
/** /**
* 默认配置(占位值,需替换) * 默认配置(占位值,需替换)
* *
* 真实部署:用户在设置页填 relayHost/deviceId/token,或通过 build-time env 注入。 * 真实部署:用户在设置页填 relayHost/channelId/token,或通过 build-time env 注入。
* MVP 阶段留占位让类型自洽,实际连接会失败(token 校验不通过 → relay 关连接, * MVP 阶段留占位让类型自洽,实际连接会失败(token 校验不通过 → relay 关连接,
* 这符合预期,提示用户配置)。 * 这符合预期,提示用户配置)。
*/ */
export const defaultConfig: MiniappConfig = { export const defaultConfig: MiniappConfig = {
// 测试服 wss(u-work.1216.top → nginx 终止 TLS → df-relay :9180 明文 ws) // u-relay 现行协议 commander 端点:wss://u-work.1216.top → nginx 终止 TLS → u-relay :9180 明文 ws
// 微信小程序真机要求 wss + socket 合法域名白名单(微信公众平台→开发设置→服务器域名) // 微信小程序真机要求 wss + socket 合法域名白名单(微信公众平台→开发设置→服务器域名)
relayHost: 'wss://u-work.1216.top/ws/miniapp', relayHost: 'wss://u-work.1216.top/ws/commander',
// 配对桌面端 device_id(查桌面端 KV app_settings:device_id;填错则握手过但路由无对端) // 配对桌面端 channel_id(查桌面端 KV app_settings:channel_id;填错则握手过但路由无对端)
// 当前填本机桌面端 device_id(联调期硬编码,后续做配对绑定流程) // 当前填本机桌面端 channel_id(联调期硬编码,后续做配对绑定流程)
deviceId: '1b92cbbd-d03e-4a0c-b226-9fadf8dcf0f0', channelId: '1b92cbbd-d03e-4a0c-b226-9fadf8dcf0f0',
token: 'devflow-relay-default-token', token: 'devflow-relay-default-token',
heartbeatInterval: 30000, heartbeatInterval: 30000,
reconnectBaseDelay: 1000, reconnectBaseDelay: 1000,
reconnectMaxDelay: 30000, reconnectMaxDelay: 30000,
fileUploadBaseUrl: 'https://file.1216.top',
fileUploadSource: 'devflow',
} }
/** 单例配置(miniapp 内存配置,后续接 storage 持久化) */ /** storage key(持久化完整 MiniappConfig JSON) */
let _config: MiniappConfig = { ...defaultConfig } const STORAGE_KEY = 'df-miniapp-config'
/** 单例配置(null 表示尚未从 storage 加载) */
let _config: MiniappConfig | null = null
/**
* 旧版字段迁移(df-relay 一代 → u-relay):
* storage 里的旧 JSON 含 deviceId 字段,映射为 channelId,避免老用户升级后配置丢失。
*/
function migrateLegacyFields(parsed: Record<string, unknown>): Partial<MiniappConfig> {
const out = { ...parsed } as Record<string, unknown>
if (out.deviceId !== undefined && out.channelId === undefined) {
out.channelId = out.deviceId
}
delete out.deviceId
// 旧 relayHost 指向已下线的 /ws/miniapp 端点,升级后强制重置为新端点
if (typeof out.relayHost === 'string' && out.relayHost.includes('/ws/miniapp')) {
out.relayHost = defaultConfig.relayHost
}
return out as Partial<MiniappConfig>
}
/**
* 从 storage 读取并合并默认配置
*
* uni.getStorageSync(key) 未命中时返回 ''(空字符串),命中返回原写入值。
* 容错:读取/解析失败仅 warn,不抛出,回落到 defaultConfig。
*/
function loadFromStorage(): MiniappConfig {
try {
const raw = uni.getStorageSync(STORAGE_KEY)
if (!raw) return { ...defaultConfig }
const parsed = JSON.parse(raw) as Record<string, unknown>
// 合并默认值 + 旧字段迁移,避免旧版本字段缺失/更名导致 undefined
const migrated = migrateLegacyFields(parsed)
return { ...defaultConfig, ...migrated }
} catch (e) {
console.warn('[df-miniapp] load config from storage failed:', e)
return { ...defaultConfig }
}
}
/**
* 同步写入 storage(JSON 序列化完整配置)
*
* 容错:写入失败仅 warn,不影响内存配置。
*/
function saveToStorage(config: MiniappConfig): void {
try {
uni.setStorageSync(STORAGE_KEY, JSON.stringify(config))
} catch (e) {
console.warn('[df-miniapp] save config to storage failed:', e)
}
}
export function getConfig(): MiniappConfig { export function getConfig(): MiniappConfig {
// 首次调用时从 storage 加载并缓存
if (_config === null) {
_config = loadFromStorage()
}
return _config return _config
} }
export function setConfig(patch: Partial<MiniappConfig>): void { export function setConfig(patch: Partial<MiniappConfig>): void {
// 确保已加载(避免在 getConfig 前调用 setConfig 丢失 storage 旧值)
if (_config === null) {
_config = loadFromStorage()
}
_config = { ..._config, ...patch } _config = { ..._config, ...patch }
saveToStorage(_config)
}
/**
* 重置为默认配置并清除 storage 持久化数据
*
* 用于用户重新配对或恢复出厂占位值的场景。
*/
export function resetConfig(): void {
_config = { ...defaultConfig }
try {
uni.removeStorageSync(STORAGE_KEY)
} catch (e) {
console.warn('[df-miniapp] remove config from storage failed:', e)
}
} }
-53
View File
@@ -1,53 +0,0 @@
{
"name": "DevFlow",
"appid": "PLACEHOLDER",
"description": "DevFlow 远程 AI Chat — 跨端操作桌面端开发助手",
"versionName": "0.1.0",
"versionCode": 100,
"transformPx": false,
"app-plus": {
"usingComponents": true,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
},
"modules": {},
"distribute": {
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.INTERNET\"/>"
]
},
"ios": {},
"sdkConfigs": {}
}
},
"quickapp": {},
"mp-weixin": {
"appid": "PLACEHOLDER",
"setting": {
"urlCheck": false,
"es6": true,
"minified": true,
"postcss": true
},
"usingComponents": true,
"permission": {},
"requiredBackgroundModes": [],
"requiredPrivateInfos": []
},
"h5": {
"title": "DevFlow Mini",
"router": {
"mode": "hash",
"base": "./"
},
"devServer": {
"port": 8081,
"https": false
}
},
"vueVersion": "3"
}
+10
View File
@@ -13,6 +13,12 @@
"navigationBarTitleText": "会话列表", "navigationBarTitleText": "会话列表",
"enablePullDownRefresh": true "enablePullDownRefresh": true
} }
},
{
"path": "pages/settings/index",
"style": {
"navigationBarTitleText": "设置"
}
} }
], ],
"globalStyle": { "globalStyle": {
@@ -35,6 +41,10 @@
{ {
"pagePath": "pages/conversations/index", "pagePath": "pages/conversations/index",
"text": "会话" "text": "会话"
},
{
"pagePath": "pages/settings/index",
"text": "设置"
} }
] ]
} }
File diff suppressed because it is too large Load Diff
@@ -1,10 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'
import { onPullDownRefresh } from '@dcloudio/uni-app' import { onPullDownRefresh } from '@dcloudio/uni-app'
import { useAiChat } from '@/composables/useAiChat' import { useAiChat } from '@/composables/useAiChat'
import type { Conversation } from '@/types/events' import type { Conversation } from '@/types/events'
const ai = useAiChat() const ai = useAiChat()
/**
* mp-weixin 模板对嵌套在对象中的 reactive/ref 响应性追踪有问题。
* 用本地 ref + watch 同步,确保模板直接拿到响应式数组。
*/
const conversations = ref<Conversation[]>([])
/**
* 监听 ai.conversations 整体替换(比 deep watch 更可靠,也比只盯 length 完整)。
* 用 `[...ai.conversations]` 创建普通数组赋值给 ref,确保每次变化都触发模板更新。
*
* 为什么监听 ref 而非 length:device 回推 AiConversationList 是「整体替换」且条数常不变
* (改名后条数不变),只盯 length 时 watch 回调不触发 → 本地副本滞留旧标题。
* 监听 ref 则在赋值(含同长度替换)时即触发刷新。
*/
watch(ai.conversations, (list) => {
conversations.value = [...(list || [])]
}, { immediate: true })
/** 点击会话项切换 */ /** 点击会话项切换 */
function handleSelect(convId: string): void { function handleSelect(convId: string): void {
ai.switchConversation(convId) ai.switchConversation(convId)
@@ -66,13 +84,20 @@ function formatTime(ts?: number): string {
<!-- 会话列表 --> <!-- 会话列表 -->
<scroll-view class="conv-list" scroll-y> <scroll-view class="conv-list" scroll-y>
<view v-if="ai.conversations.length === 0" class="empty-hint"> <!-- 加载中骨架屏 -->
<view v-if="conversations.length === 0 && ai.loading.value" class="conv-skeleton">
<view v-for="i in 5" :key="i" class="conv-sk-item">
<view class="conv-sk-line conv-sk-w70"></view>
<view class="conv-sk-line conv-sk-w40"></view>
</view>
</view>
<view v-else-if="conversations.length === 0" class="empty-hint">
<text>暂无历史会话</text> <text>暂无历史会话</text>
<text class="empty-sub">在桌面端创建后,此处同步</text> <text class="empty-sub">在桌面端创建后,此处同步</text>
</view> </view>
<view <view
v-for="conv in ai.conversations" v-for="conv in conversations"
:key="conv.id" :key="conv.id"
class="conv-item" class="conv-item"
:class="{ active: conv.id === ai.activeConversationId.value }" :class="{ active: conv.id === ai.activeConversationId.value }"
@@ -85,7 +110,7 @@ function formatTime(ts?: number): string {
</view> </view>
<text v-if="conv.updatedAt" class="conv-time">{{ formatTime(conv.updatedAt) }}</text> <text v-if="conv.updatedAt" class="conv-time">{{ formatTime(conv.updatedAt) }}</text>
</view> </view>
<view v-if="ai.conversations.length > 0" class="list-hint"> <view v-if="conversations.length > 0" class="list-hint">
<text>长按会话可重命名</text> <text>长按会话可重命名</text>
</view> </view>
</scroll-view> </scroll-view>
@@ -186,4 +211,26 @@ function formatTime(ts?: number): string {
color: $uni-text-color-disable; color: $uni-text-color-disable;
font-size: $uni-font-size-sm; font-size: $uni-font-size-sm;
} }
/* 骨架屏(下拉刷新/冷启动占位) */
.conv-skeleton {
padding: 24rpx;
}
.conv-sk-item {
margin-bottom: 32rpx;
padding: 24rpx;
}
.conv-sk-line {
height: 24rpx;
margin: 8rpx 0;
background: linear-gradient(90deg, $uni-bg-color-light 25%, #3a3a3a 50%, $uni-bg-color-light 75%);
background-size: 200% 100%;
animation: conv-sk-shimmer 1.5s infinite;
border-radius: 8rpx;
}
.conv-sk-w70 { width: 70%; }
.conv-sk-w40 { width: 40%; }
@keyframes conv-sk-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style> </style>
@@ -0,0 +1,322 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { getConfig, setConfig } from '@/config'
import { useAiChat } from '@/composables/useAiChat'
/**
* 设置页(连接配置查看/编辑 + 连接状态 + 连接操作)。
*
* 默认查看模式(只读展示 relayHost/channelId/token,token 脱敏);点「编辑」进入编辑模式,
* 保存时 setConfig 持久化并 resumeIfDisconnected 用新配置重连。连接态可「断开」,
* 断开态可「重连」,另展示最近状态详情(wsStatusDetail)辅助排查。
*
* 数据源:
* - 连接配置:config.ts getConfig()(storage 持久化,含默认值合并)
* - 连接状态:useAiChat() 单例(wsStatus + deviceOnline,实时)
* - 版本号:manifest.json versionName(手动维护此处常量,对齐 0.1.0)
*/
// 连接配置(只读快照,页面 onShow 时刷新,切 tab 回来看最新值)
const cfg = computed(() => getConfig())
// 连接状态(实时,useAiChat 单例 ref 直接响应)
const ai = useAiChat()
const wsStatus = computed(() => ai.wsStatus.value)
const wsStatusDetail = computed(() => ai.wsStatusDetail.value)
const deviceOnline = computed(() => ai.deviceOnline.value)
const isWsConnected = computed(() => ai.isWsConnected.value)
/** 连接状态中文文案 + 颜色 class(对齐 chat 页 STATUS_TEXT 口径) */
const STATUS_META: Record<string, { label: string; cls: string }> = {
connected: { label: '已连接', cls: 'st-ok' },
connecting: { label: '连接中…', cls: 'st-wait' },
handshaking: { label: '握手中…', cls: 'st-wait' },
reconnecting: { label: '重连中…', cls: 'st-warn' },
disconnected: { label: '已断开', cls: 'st-err' },
}
const statusText = computed(() => {
if (isWsConnected.value) {
return deviceOnline.value ? '已连接桌面端' : '已连接中继'
}
return STATUS_META[wsStatus.value]?.label || wsStatus.value
})
const statusCls = computed(() => {
if (isWsConnected.value) return deviceOnline.value ? 'st-ok' : 'st-warn'
return STATUS_META[wsStatus.value]?.cls || 'st-err'
})
/** token 脱敏(只显前 4 位 + 星号,查看页不暴露全量凭据) */
function maskToken(token: string): string {
if (!token) return '(未设置)'
if (token.length <= 8) return '*'.repeat(token.length)
return `${token.slice(0, 4)}${'*'.repeat(Math.min(token.length - 4, 12))}`
}
/** 毫秒转可读(30000 → 30s) */
function fmtMs(ms: number): string {
return ms >= 1000 ? `${ms / 1000}s` : `${ms}ms`
}
/** 版本号(对齐 manifest.json versionName,改版时同步) */
const APP_VERSION = '0.1.0'
/** 手动重连(断开态可点)。用 ai.connect() 而非 resumeIfDisconnected:
* 用户先点「断开」(manualClose=true)后 resumeIfDisconnected 会因 manualClose 早退,
* connect() 复位 manualClose 强制开新连接(非连接态才生效,连接态自动幂等跳过)。 */
function onReconnect(): void {
ai.connect()
}
/** 手动断开(连接态可点,disconnect 置 manualClose 后不再自动重连) */
function onDisconnect(): void {
ai.disconnect()
uni.showToast({ title: '已断开连接', icon: 'none' })
}
/** 连接操作按钮入口(连接态→断开,断开态→重连) */
function onConnOp(): void {
if (isWsConnected.value) onDisconnect()
else onReconnect()
}
/** 编辑态(false=查看模式,true=编辑模式;草稿保存时才 setConfig,取消丢弃) */
const editing = ref(false)
const draftRelayHost = ref('')
const draftChannelId = ref('')
const draftToken = ref('')
/** 进入编辑模式:以当前配置填充草稿 */
function enterEdit(): void {
draftRelayHost.value = cfg.value.relayHost
draftChannelId.value = cfg.value.channelId
draftToken.value = cfg.value.token
editing.value = true
}
/** 取消编辑:丢弃草稿回查看模式(不保存) */
function cancelEdit(): void {
editing.value = false
}
/** 保存编辑:setConfig 持久化 + 提示 + resumeIfDisconnected 用新配置重连 */
function saveEdit(): void {
const relayHost = draftRelayHost.value.trim()
const channelId = draftChannelId.value.trim()
if (!relayHost || !channelId) {
uni.showToast({ title: '中继地址和通道 ID 不能为空', icon: 'none' })
return
}
setConfig({ relayHost, channelId, token: draftToken.value.trim() })
editing.value = false
uni.showToast({ title: '已保存,正在重连…', icon: 'none' })
ai.resumeIfDisconnected()
}
</script>
<template>
<view class="settings-page">
<!-- 连接状态卡片(实时,断开可点重连) -->
<view class="card">
<view class="card-head">
<text class="card-title">连接状态</text>
</view>
<view class="row">
<text class="row-label">当前状态</text>
<text class="row-value status" :class="statusCls" @tap="onReconnect">{{ statusText }}</text>
</view>
<view class="row">
<text class="row-label">中继地址</text>
<text class="row-value mono">{{ cfg.relayHost }}</text>
</view>
<view class="row" v-if="wsStatusDetail">
<text class="row-label">最近状态</text>
<text class="row-value">{{ wsStatusDetail }}</text>
</view>
<view class="ops">
<view class="btn btn-block" :class="isWsConnected ? 'btn-danger' : 'btn-primary'" @tap="onConnOp">
{{ isWsConnected ? '断开' : '重连' }}
</view>
</view>
</view>
<!-- 连接配置(查看/编辑双模式:编辑可改 relayHost/channelId/token) -->
<view class="card">
<view class="card-head">
<text class="card-title">连接配置</text>
<view v-if="editing" class="card-actions">
<text class="btn btn-sm btn-ghost" @tap="cancelEdit">取消</text>
<text class="btn btn-sm btn-primary" @tap="saveEdit">保存</text>
</view>
<text v-else class="btn btn-sm btn-ghost" @tap="enterEdit">编辑</text>
</view>
<view class="row">
<text class="row-label">中继地址</text>
<input v-if="editing" v-model="draftRelayHost" class="row-input" placeholder="ws://…/ws/commander" placeholder-class="row-ph" />
<text v-else class="row-value mono">{{ cfg.relayHost }}</text>
</view>
<view class="row">
<text class="row-label">通道 ID</text>
<input v-if="editing" v-model="draftChannelId" class="row-input" placeholder="u-relay 通道 ID" placeholder-class="row-ph" />
<text v-else class="row-value mono">{{ cfg.channelId }}</text>
</view>
<view class="row">
<text class="row-label">配对 Token</text>
<input v-if="editing" v-model="draftToken" class="row-input" placeholder="配对 token" placeholder-class="row-ph" />
<text v-else class="row-value mono">{{ maskToken(cfg.token) }}</text>
</view>
</view>
<!-- 高级参数 -->
<view class="card">
<view class="card-head">
<text class="card-title">高级参数</text>
</view>
<view class="row">
<text class="row-label">心跳间隔</text>
<text class="row-value">{{ fmtMs(cfg.heartbeatInterval) }}</text>
</view>
<view class="row">
<text class="row-label">重连初始间隔</text>
<text class="row-value">{{ fmtMs(cfg.reconnectBaseDelay) }}</text>
</view>
<view class="row">
<text class="row-label">重连最大间隔</text>
<text class="row-value">{{ fmtMs(cfg.reconnectMaxDelay) }}</text>
</view>
</view>
<!-- 关于 -->
<view class="card">
<view class="card-head">
<text class="card-title">关于</text>
</view>
<view class="row">
<text class="row-label">版本</text>
<text class="row-value">DevFlow Mini v{{ APP_VERSION }}</text>
</view>
<view class="row">
<text class="row-label">说明</text>
<text class="row-value">远程 AI Chat · 跨端操作桌面端开发助手</text>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
.settings-page {
min-height: 100vh;
padding: 24rpx;
box-sizing: border-box;
background-color: $uni-bg-color;
}
.card {
margin-bottom: 24rpx;
padding: 24rpx;
background-color: $uni-bg-color-light;
border-radius: 16rpx;
}
.card-head {
display: flex;
flex-direction: row;
align-items: baseline;
justify-content: space-between;
margin-bottom: 16rpx;
}
.card-title {
font-size: $uni-font-size-base;
font-weight: bold;
color: $uni-text-color;
}
.card-sub {
font-size: $uni-font-size-sm;
color: $uni-text-color-disable;
}
.row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 16rpx 0;
border-bottom: 1rpx solid $uni-border-color;
}
.row:last-child {
border-bottom: none;
}
.row-label {
font-size: $uni-font-size-sm;
color: $uni-text-color-grey;
}
.row-value {
flex: 1;
margin-left: 24rpx;
font-size: $uni-font-size-sm;
color: $uni-text-color;
text-align: right;
word-break: break-all;
}
.row-value.mono {
font-family: monospace;
}
.row-value.status {
font-weight: bold;
}
.st-ok { color: #6bd99a; }
.st-wait { color: $uni-color-primary; }
.st-warn { color: #f0c75e; }
.st-err { color: #e85a4f; }
/* 连接操作按钮(连接状态卡:重连/断开) */
.ops {
margin-top: 8rpx;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8rpx 24rpx;
font-size: $uni-font-size-sm;
line-height: 1.6;
border-radius: 10rpx;
}
.btn-block {
display: block;
padding: 20rpx 32rpx;
text-align: center;
}
.btn-primary {
background-color: $uni-color-primary;
color: #fff;
}
.btn-danger {
background-color: #e85a4f;
color: #fff;
}
.btn-ghost {
color: $uni-color-primary;
border: 1rpx solid $uni-color-primary;
background-color: transparent;
}
/* 卡片头部按钮组(编辑/取消/保存) */
.card-actions {
display: flex;
flex-direction: row;
align-items: center;
}
.card-actions .btn {
margin-left: 16rpx;
}
.btn-sm {
padding: 4rpx 20rpx;
}
/* 编辑态输入(label 左 + input 右,横向一行对齐 .row-value) */
.row-input {
flex: 1;
margin-left: 24rpx;
font-size: $uni-font-size-sm;
color: $uni-text-color;
text-align: right;
}
.row-ph {
color: $uni-text-color-disable;
}
</style>
+33
View File
@@ -49,6 +49,12 @@ export type AiChatEvent =
total_tokens: number total_tokens: number
prompt_tokens: number prompt_tokens: number
completion_tokens: number completion_tokens: number
/** token 分项(2026-08-02):cache 命中(低价)/未命中(全价真实,前端 in 显示用此)/思考 */
prompt_cache_hit_tokens: number
prompt_cache_miss_tokens: number
reasoning_tokens: number
/** G4.3:该轮 token 用量是否估算值(provider 未报 prompt_tokens → estimated 兜底打标) */
is_estimated: boolean
incomplete?: boolean | null incomplete?: boolean | null
conversation_id?: string | null conversation_id?: string | null
} }
@@ -89,10 +95,29 @@ export type AiChatEvent =
tasks: TaskRecord[] tasks: TaskRecord[]
ideas: IdeaRecord[] ideas: IdeaRecord[]
} }
// 每会话独立模型:对齐后端 list_models(device 读活跃 provider enabled 模型,跨端回推)。
// 全局事件(无 conversation_id),miniapp 顶栏模型选择器消费 models + 默认 default_model。
| {
type: 'AiModelList'
provider_id: string
default_model: string
models: ModelInfo[]
}
/** AiChatEvent 的 type 字面量集合(用于 narrowing / 分派) */ /** AiChatEvent 的 type 字面量集合(用于 narrowing / 分派) */
export type AiChatEventType = AiChatEvent['type'] export type AiChatEventType = AiChatEvent['type']
/**
* 模型信息(每会话独立模型,对齐后端 list_models 返回)。
* provider 当前 enabled 模型列表,label 为展示名(可选,缺省用 model_id)。
*/
export interface ModelInfo {
/** 模型 id(选中后透传 model_override) */
model_id: string
/** 展示名(可选) */
label?: string
}
/** 会话信息(对齐桌面端 list_conversations 返回结构,字段名 camelCase 兼容桌面 IPC 约定) */ /** 会话信息(对齐桌面端 list_conversations 返回结构,字段名 camelCase 兼容桌面 IPC 约定) */
export interface Conversation { export interface Conversation {
id: string id: string
@@ -116,6 +141,8 @@ export interface ChatMessage {
toolCalls?: AiToolCallInfo[] toolCalls?: AiToolCallInfo[]
/** 不完整标记(AiCompleted{incomplete:true} 对应系统提示气泡) */ /** 不完整标记(AiCompleted{incomplete:true} 对应系统提示气泡) */
incomplete?: boolean incomplete?: boolean
/** 图片输入:消息携带的图片 URL 列表(预览用,发送时透传) */
images?: string[]
} }
/** 工具调用信息(对齐桌面 useAiEvents.ts:399 的 AiToolCallInfo) */ /** 工具调用信息(对齐桌面 useAiEvents.ts:399 的 AiToolCallInfo) */
@@ -141,6 +168,12 @@ export interface TokenUsage {
prompt: number prompt: number
completion: number completion: number
total: number total: number
/** token 分项(2026-08-02):cache 命中(低价)/未命中(全价)/思考,对齐桌面端 */
cache_hit?: number
cache_miss?: number
reasoning?: number
/** 该轮 token 是否估算值(AiCompleted.is_estimated 透传,reload 历史消息可能无) */
is_estimated?: boolean
} }
// ============================================================ // ============================================================
+66 -35
View File
@@ -1,44 +1,47 @@
/** /**
* df-relay 协议类型镜像(对齐 Rust crates/df-relay/src/) * u-relay 协议类型镜像(对齐 E:/wk-lab/u-relay/src/)
* *
* 类型证据: * 类型证据:
* - ClientKind / MessageKind:crates/df-relay/src/broadcast.rs:21-41 * - ClientKind / MessageKind:u-relay/src/broadcast.rs
* (#[serde(rename_all = "snake_case")] → 'device'/'miniapp', 'event'/'command'/'control') * (#[serde(rename_all = "snake_case")] → 'agent'/'commander', 'event'/'command'/'control')
* - BroadcastMessage:broadcast.rs:48-62(device_id/kind/source/from/payload/ts) * - BroadcastMessage:broadcast.rs(channel_id/kind/source/from/payload/ts)
* - Hello 握手帧:crates/df-relay/src/relay.rs:44-53(kind/device_id/token) * - Hello 握手帧:u-relay/src/relay.rs(kind/channel_id/token)
* kind 用 ClientKindWire(relay.rs:56-61),序列化为 'device'/'miniapp' * kind 用 ClientKindWire,序列化为 'agent'/'commander'
* - 路由端点:relay.rs:177-183 `/ws/miniapp`(小程序专用端点) * - 路由端点:relay.rs `/ws/agent`(执行端)/ `/ws/commander`(控制端)
* *
* 关键协议点(relay.rs:332-365 handle_inbound_text): * 关键协议点(relay.rs handle_inbound_text):
* relay 不解析 payload —— miniapp 入站文本帧直接当 payload 包成 BroadcastMessage 路由给 device。 * relay 不解析 payload —— commander 入站文本帧直接当 payload 包成 BroadcastMessage 路由给 agent。
* 故 miniapp 发送时,直接序列化 Command JSON(relay 自动包 payload),不需客户端构造 BroadcastMessage。 * 故 miniapp 发送时,直接序列化 Command JSON(relay 自动包 payload),不需客户端构造 BroadcastMessage。
* 接收时,device 事件经 relay 包成完整 BroadcastMessage 序列化下发,miniapp 需先解 BroadcastMessage * 接收时,agent 事件经 relay 包成完整 BroadcastMessage 序列化下发,miniapp 需先解 BroadcastMessage
* 再从 payload 取 AiChatEvent。 * 再从 payload 取 AiChatEvent。
*
* 历史注:df-relay 一代协议(kind:'device'/'miniapp' + device_id)已在 u-relay 全部下线,
* 旧名握手期直接拒绝(端点 /ws/miniapp、/ws/device 均已失效)。
*/ */
/** 客户端类型(对齐 Rust ClientKind,snake_case 序列化,broadcast.rs:21-28) */ /** 客户端类型(对齐 Rust ClientKind,snake_case 序列化;u-relay 现行仅此两值) */
export type ClientKind = 'device' | 'miniapp' export type ClientKind = 'agent' | 'commander'
/** 消息语义分类(对齐 Rust MessageKind,snake_case 序列化,broadcast.rs:35-41) */ /** 消息语义分类(对齐 Rust MessageKind,snake_case 序列化) */
export type MessageKind = 'event' | 'command' | 'control' export type MessageKind = 'event' | 'command' | 'control'
/** 在线状态(对齐 Rust PresenceState,snake_case 序列化,broadcast.rs:119-126) */ /** 在线状态(对齐 Rust PresenceState,snake_case 序列化) */
export type PresenceState = 'online' | 'offline' export type PresenceState = 'online' | 'offline'
/** ConnId 是 Rust u64(对齐 conn.rs ConnId struct),TS 用 number */ /** ConnId 是 Rust u64(对齐 conn.rs ConnId struct),TS 用 number;0 为 NIL 伪值 */
export type ConnId = number export type ConnId = number
/** /**
* 中继路由消息骨架(对齐 Rust BroadcastMessage,broadcast.rs:48-62) * 中继路由消息骨架(对齐 Rust BroadcastMessage)
* *
* miniapp 接收时 payload 为 AiChatEvent JSON(relay 透传不解析)。 * miniapp 接收时 payload 为 AiChatEvent JSON(relay 透传不解析)。
*/ */
export interface BroadcastMessage { export interface BroadcastMessage {
/** 配对绑定的设备 ID */ /** 配对绑定的通道 ID */
device_id: string channel_id: string
/** 消息语义 */ /** 消息语义 */
kind: MessageKind kind: MessageKind
/** 消息来源连接 ID(relay 分配,u64) */ /** 消息来源连接 ID(relay 分配,u64;0=relay 自构造 NIL 伪值) */
source: ConnId source: ConnId
/** 来源客户端类型 */ /** 来源客户端类型 */
from: ClientKind from: ClientKind
@@ -49,36 +52,36 @@ export interface BroadcastMessage {
} }
/** /**
* 客户端首消息:身份宣告(对齐 Rust Hello,relay.rs:44-53) * 客户端首消息:身份宣告(对齐 Rust Hello)
* *
* 小程序连 `/ws/miniapp` 后首帧必须发此结构,否则 relay 关连接(relay.rs:197-208)。 * 小程序连 `/ws/commander` 后首帧必须发此结构(10s 内),否则 relay 关连接。
* kind 恒为 'miniapp'(与端点匹配,relay.rs:212-225 校验,不匹配则关连接)。 * kind 恒为 'commander'(与端点匹配,不匹配 relay 回 kind_mismatch 错误帧 + Close)。
*/ */
export interface Hello { export interface Hello {
/** 客户端类型,小程序恒 'miniapp'(对齐 ClientKindWire) */ /** 客户端类型,小程序(控制端)恒 'commander'(对齐 ClientKindWire) */
kind: 'miniapp' | 'device' kind: 'commander'
/** 配对绑定的设备 ID(用户首次配置时生成) */ /** 配对绑定的通道 ID(非空,≤128B,无控制字符) */
device_id: string channel_id: string
/** 配对 token(env DF_RELAY_TOKEN,fallback "devflow-relay-default-token") */ /** 配对 token(env U_RELAY_TOKEN,与桌面端共享) */
token: string token: string
} }
/** /**
* 控制面消息(对齐 Rust ControlMessage,broadcast.rs:132-145,tag = "control_kind") * 控制面消息(对齐 Rust ControlMessage,tag = "control_kind")
* *
* miniapp 主要发 Heartbeat 心跳 + Ping;Pair 在首次绑定时用(本批未实现绑定流程)。 * miniapp 主要发 Ping 心跳(relay 本地直回 pong,不经对端);Pair/Heartbeat/Presence 骨架保留。
*/ */
export type ControlMessage = export type ControlMessage =
| { control_kind: 'pair'; device_id: string; token: string } | { control_kind: 'pair'; channel_id: string; token: string }
| { control_kind: 'heartbeat'; device_id: string } | { control_kind: 'heartbeat'; channel_id: string }
| { control_kind: 'presence'; device_id: string; state: PresenceState } | { control_kind: 'presence'; channel_id: string; state: PresenceState }
| { control_kind: 'ping' } | { control_kind: 'ping' }
| { control_kind: 'pong' } | { control_kind: 'pong' }
/** /**
* 小程序 → 桌面端 Command 协议(miniapp 发起操作) * 小程序(控制端)→ 桌面端(执行端)Command 协议
* *
* 类型为对齐 Tauri command 名,relay 透传 device_id 路由到桌面端,桌面端 invoke 对应 command。 * 类型为对齐 Tauri command 名,relay 透传 channel_id 路由到桌面端,桌面端 invoke 对应 command。
* 字段: * 字段:
* - cmd:命令名(对齐 src-tauri command 函数名) * - cmd:命令名(对齐 src-tauri command 函数名)
* - args:命令参数(对齐各 command 的参数) * - args:命令参数(对齐各 command 的参数)
@@ -89,6 +92,7 @@ export type ControlMessage =
* - list_conversations / load_messages(F-#95 扩展,会话列表/历史消息读取) * - list_conversations / load_messages(F-#95 扩展,会话列表/历史消息读取)
* - list_skills / list_entities(F-#95 联想扩展,技能/项目·任务·灵感列表,见 useAiChat.ts) * - list_skills / list_entities(F-#95 联想扩展,技能/项目·任务·灵感列表,见 useAiChat.ts)
* - rename_conversation(会话重命名,2026-06-23)/ sync_pending(重连审批恢复,2026-06-23) * - rename_conversation(会话重命名,2026-06-23)/ sync_pending(重连审批恢复,2026-06-23)
* - list_models(每会话独立模型,模型选择器拉取 provider enabled 模型列表,见 useAiChat.ts)
*/ */
export interface MiniCommand { export interface MiniCommand {
/** Tauri command 名 */ /** Tauri command 名 */
@@ -102,6 +106,12 @@ export interface SendMessageArgs {
message: string message: string
conversation_id?: string | null conversation_id?: string | null
model_override?: string | null model_override?: string | null
/**
* 跨端新会话语义(2026-08-05):miniapp 新建会话(active=null)后首次发送置 true,
* device 端据此强制创建新 conv id(不走 active 兜底,防串旧会话)。
* 普通发送/已传 conversation_id 时不传(默认 false)。
*/
new_conversation?: boolean
/** /**
* 选中技能名(`/<skill>` 联想选中后透传,对齐 ai_chat_send 的 skill 参数)。 * 选中技能名(`/<skill>` 联想选中后透传,对齐 ai_chat_send 的 skill 参数)。
* null/undefined=无技能(普通对话);非空=后端注入对应 SKILL.md 全文到 prompt。 * null/undefined=无技能(普通对话);非空=后端注入对应 SKILL.md 全文到 prompt。
@@ -113,8 +123,29 @@ export interface SendMessageArgs {
* null/undefined/空=无 mention(纯文本消息)。 * null/undefined/空=无 mention(纯文本消息)。
*/ */
mention_spans?: MentionSpan[] | null mention_spans?: MentionSpan[] | null
/**
* 多模态片段(图片输入):miniapp 选图转 base64 构造 ContentPart Image 数组透传,
* 对齐后端 ai_chat_send 的 parts 参数(df-ai-core ContentPart,serde 内部标签 tag=type)。
* null/undefined/空=纯文本消息(零回归)。
*/
parts?: MiniContentPart[] | null
} }
/**
* 多模态内容片段(对齐后端 df-ai-core/src/types.rs ContentPart)。
* serde 内部标签 `#[serde(tag="type", rename_all="snake_case")]`:Image 片段 JSON 形如
* `{"type":"image","url":...,"media_type":...}`。
*/
export type MiniContentPart =
| { type: 'text'; text: string }
| {
type: 'image'
url?: string | null
base64?: string | null
media_type?: string | null
alt?: string | null
}
/** /**
* 用户消息内 mention 区间的元数据 * 用户消息内 mention 区间的元数据
* (对齐后端 MentionSpanDto,crates/df-types/src/augmentation.rs:235)。 * (对齐后端 MentionSpanDto,crates/df-types/src/augmentation.rs:235)。
@@ -199,6 +230,6 @@ export interface ContinueLoopArgs {
/** stop_loop 命令参数(对齐 src-tauri ai_stop_loop,Phase3 §2.2 路由表) */ /** stop_loop 命令参数(对齐 src-tauri ai_stop_loop,Phase3 §2.2 路由表) */
export interface StopLoopArgs { export interface StopLoopArgs {
/** 霷停止循环的会话 id */ /** 需停止循环的会话 id */
conversation_id: string conversation_id: string
} }
+34
View File
@@ -0,0 +1,34 @@
/** file.1216.top 文件服务上传客户端。
* POST /upload multipart 'file' 字段 + 'X-Source' 头 → {"result":{"url":"https://..."},"retcode":0,"success":true}
*/
import { getConfig } from '@/config'
export interface UploadResult { url: string }
/** 上传图片到 file.1216.top,返回公开 URL。失败 reject(调用方回退 base64)。 */
export function uploadImage(tempFilePath: string): Promise<UploadResult> {
const cfg = getConfig()
return new Promise((resolve, reject) => {
uni.uploadFile({
url: `${cfg.fileUploadBaseUrl}/upload`,
filePath: tempFilePath,
name: 'file',
header: { 'X-Source': cfg.fileUploadSource },
timeout: 15000,
success: (res) => {
try {
const data = JSON.parse(res.data as string)
const url = data?.result?.url as string | undefined
if (data?.success === true && data?.retcode === 0 && url) {
resolve({ url })
} else {
reject(new Error(`上传响应异常: ${res.data}`))
}
} catch (e) {
reject(e)
}
},
fail: (err) => reject(err),
})
})
}
+171 -10
View File
@@ -1,5 +1,5 @@
/** /**
* marked HTML 后处理:为代码块/内联 code/链接/表格注入 inline style(P1-G)。 * marked HTML 后处理:为代码块/内联 code/链接/表格/图片/任务列表注入 inline style。
* *
* 为什么后处理而非 marked 自定义 renderer: * 为什么后处理而非 marked 自定义 renderer:
* - mp-weixin rich-text 不链接页面 CSS class,仅认节点 inline style → 必须把样式写进 HTML 属性。 * - mp-weixin rich-text 不链接页面 CSS class,仅认节点 inline style → 必须把样式写进 HTML 属性。
@@ -11,8 +11,88 @@
* 跳转需第三方 mp-html(本次不做,记后续)。 * 跳转需第三方 mp-html(本次不做,记后续)。
* - rich-text 非滚动容器,overflow-y:auto/max-height 不创建滚动区 → 长代码靠 * - rich-text 非滚动容器,overflow-y:auto/max-height 不创建滚动区 → 长代码靠
* white-space:pre-wrap + word-break:break-all 换行防撑爆,不限高滚动。 * white-space:pre-wrap + word-break:break-all 换行防撑爆,不限高滚动。
* - <img> 网络图须域名进微信「downloadFile 合法域名」白名单,未配置时真机不显示
* (基础库限制,非渲染代码问题);开发者后台配白名单后即正常。
* - <input> 不在 rich-text 节点白名单 → GFM 任务列表勾选框被剥离成纯文本,见下替换。
*/ */
// 语法高亮:highlight.js core + 按需注册常用语言(包体积敏感,不引全量)。
// 无 DOM 不可用 highlightElement,hljs 产出的 token class 由 inlineHljs 转 inline style。
import hljs from 'highlight.js/lib/core'
import javascript from 'highlight.js/lib/languages/javascript'
import typescript from 'highlight.js/lib/languages/typescript'
import json from 'highlight.js/lib/languages/json'
import xml from 'highlight.js/lib/languages/xml'
import css from 'highlight.js/lib/languages/css'
import markdown from 'highlight.js/lib/languages/markdown'
import rust from 'highlight.js/lib/languages/rust'
import go from 'highlight.js/lib/languages/go'
import python from 'highlight.js/lib/languages/python'
import java from 'highlight.js/lib/languages/java'
import bash from 'highlight.js/lib/languages/bash'
import sql from 'highlight.js/lib/languages/sql'
import yaml from 'highlight.js/lib/languages/yaml'
import diff from 'highlight.js/lib/languages/diff'
hljs.registerLanguage('javascript', javascript)
hljs.registerLanguage('typescript', typescript)
hljs.registerLanguage('json', json)
hljs.registerLanguage('xml', xml)
hljs.registerLanguage('css', css)
hljs.registerLanguage('markdown', markdown)
hljs.registerLanguage('rust', rust)
hljs.registerLanguage('go', go)
hljs.registerLanguage('python', python)
hljs.registerLanguage('java', java)
hljs.registerLanguage('bash', bash)
hljs.registerLanguage('sql', sql)
hljs.registerLanguage('yaml', yaml)
hljs.registerLanguage('diff', diff)
// 围栏 info 常见别名 → 已注册语言(hljs.getLanguage 自带部分别名,这里补齐业务常用)
const LANG_ALIASES: Record<string, string> = {
js: 'javascript', ts: 'typescript', py: 'python',
sh: 'bash', shell: 'bash', yml: 'yaml', md: 'markdown',
html: 'xml', vue: 'xml', svg: 'xml',
}
/** hljs token class → inline style(rich-text 认 style 不认 class;色板对齐桌面 github-dark) */
const TOKEN_STYLE: Record<string, string> = {
'hljs-keyword': 'color:#ff7b72',
'hljs-doctag': 'color:#ff7b72',
'hljs-meta': 'color:#ff7b72',
'hljs-type': 'color:#ff7b72',
'hljs-template-tag': 'color:#ff7b72',
'hljs-template-variable': 'color:#ff7b72',
'hljs-title': 'color:#d2a8ff',
'hljs-attr': 'color:#79c0ff',
'hljs-attribute': 'color:#79c0ff',
'hljs-literal': 'color:#79c0ff',
'hljs-number': 'color:#79c0ff',
'hljs-operator': 'color:#79c0ff',
'hljs-variable': 'color:#79c0ff',
'hljs-selector-attr': 'color:#79c0ff',
'hljs-selector-class': 'color:#79c0ff',
'hljs-selector-id': 'color:#79c0ff',
'hljs-regexp': 'color:#a5d6ff',
'hljs-string': 'color:#a5d6ff',
'hljs-built_in': 'color:#ffa657',
'hljs-symbol': 'color:#ffa657',
'hljs-comment': 'color:#8b949e',
'hljs-code': 'color:#8b949e',
'hljs-formula': 'color:#8b949e',
'hljs-name': 'color:#7ee787',
'hljs-quote': 'color:#7ee787',
'hljs-selector-tag': 'color:#7ee787',
'hljs-selector-pseudo': 'color:#7ee787',
'hljs-section': 'color:#1f6feb;font-weight:bold',
'hljs-bullet': 'color:#f2cc60',
'hljs-addition': 'color:#aff5b4;background-color:#033a16',
'hljs-deletion': 'color:#ffdcd7;background-color:#67060c',
'hljs-emphasis': 'font-style:italic',
'hljs-strong': 'font-weight:bold',
}
/** 给指定标签注入 inline style(已有 style 则合并,无则新增)。 */ /** 给指定标签注入 inline style(已有 style 则合并,无则新增)。 */
function styleTag(html: string, tag: string, style: string): string { function styleTag(html: string, tag: string, style: string): string {
const re = new RegExp(`<${tag}(\\s[^>]*)?>`, 'g') const re = new RegExp(`<${tag}(\\s[^>]*)?>`, 'g')
@@ -28,35 +108,116 @@ function styleTag(html: string, tag: string, style: string): string {
}) })
} }
/** 反转 marked 对代码块内容的 HTML 转义,还原原始代码文本供 hljs 高亮 */
function unescapeCode(s: string): string {
return s
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
}
/** hljs token class → inline style(无 DOM 不能 highlightElement,逐 span 转内联) */
function inlineHljs(html: string): string {
return html.replace(/<span class="([^"]+)">/g, (_m, cls: string) => {
const style = cls
.split(/\s+/)
.map((c) => TOKEN_STYLE[c])
.filter(Boolean)
.join(';')
return style ? `<span style="${style}">` : '<span>'
})
}
/** 代码块语言徽标(rich-text 内联 style;块代码顶部展示 fence lang,无 lang 不调用)。 */
function codeLangBadge(lang: string): string {
// 块级 div(pre 深底上浅灰标签 + 圆角,pre 自身已有内边距),放 <code> 前占据顶部一行
return `<div style="font-size:10px;color:#9e9e9e;background-color:#2e2e2e;padding:1px 8px;border-radius:4px;margin-bottom:6px">${lang}</div>`
}
/**
* 块代码语法高亮:仅处理已知语言,未知/无语言兜底原样(确定性强,不做自动猜测)。
* marked 已对代码内容做 HTML 转义,先反转还原再交给 hljs(hljs 输出自带转义)。
* 同时补语言徽标:围栏有 lang 即显示(用原始标识如 ts,更贴近用户书写),无 lang 不显示。
*/
function highlightCodeBlocks(html: string): string {
return html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/gi, (m, attrs: string, inner: string) => {
const langMatch = /class="language-([^"]+)"/i.exec(attrs)
const langRaw = langMatch ? langMatch[1] : ''
const lang = LANG_ALIASES[langRaw] ?? langRaw
const badge = langRaw ? codeLangBadge(langRaw) : ''
if (!lang || !hljs.getLanguage(lang)) {
// 未注册语言:不高亮但保留徽标(fence 有 lang 时),避免 ` ```未知lang ` 丢语言标签
return badge ? `<pre><code${attrs}>${badge}${inner}</code></pre>` : m
}
let value: string
try {
value = hljs.highlight(unescapeCode(inner), { language: lang, ignoreIllegals: true }).value
} catch {
return badge ? `<pre><code${attrs}>${badge}${inner}</code></pre>` : m
}
return `<pre><code${attrs}>${badge}${inlineHljs(value)}</code></pre>`
})
}
/** /**
* 对 marked.parse 产出的 HTML 注入 inline style。 * 对 marked.parse 产出的 HTML 注入 inline style。
* *
* 设计取舍:pre(块代码)给深色背景;<code> 不给背景(透明),仅等宽+橙色 —— 避免块代码内层 code * 设计取舍:pre(块代码)给深色背景;<code> 不给背景(透明),仅等宽 + 橙色 —— 避免块代码内层 code
* 与 pre 背景冲突的双层色块(块代码内 code 透明继承 pre 深底,内联 code 橙色等宽在消息底上也可读)。 * 与 pre 背景冲突的双层色块(块代码内 code 透明继承 pre 深底,内联 code 橙色等宽在消息底上也可读);
* 块代码经 hljs 高亮后,关键字/字符串等 token 由内联色覆盖,未命中 token 的文本保留 code 基色。
*
* 正文颜色(真机确认):rich-text 只认节点 inline style,不继承页面 CSS `.msg text`
* (模板的 `.msg text{color:#e0e0e0}` 仅命中 `<text>` 子元素)。marked 产出的 p/h1-6/li/td/th
* 等不带 style 时,文本在暗色气泡上呈近黑 → 暗底黑字。故给承载正文的块级元素统一注入颜色。
* strong/em/b 等行内元素继承父级(p/li)颜色,无需逐一注入。
*/ */
export function styleMarkdown(html: string): string { export function styleMarkdown(html: string): string {
let out = html let out = html
// 任务列表勾选框:rich-text 节点白名单不含 input,替换为字符符号(勾选/未勾选)
out = out.replace(/<input[^>]*type="checkbox"[^>]*>/gi, (m) => {
const checked = /checked/i.test(m)
const color = checked ? '#6bd99a' : '#666666'
return `<span style="color:${color};margin-right:4px">${checked ? '☑' : '☐'}</span>`
})
// 块代码语法高亮(须在下方 pre/code styleTag 前执行,hljs 输出 token span 后再合并基样式)
out = highlightCodeBlocks(out)
// 正文着色:块级文本容器统一浅色(rich-text 样式继承链从容器传子文本)
// word-break:break-all 防长 URL/长单词撑开气泡(rich-text 内文本不继承外层 .msg 的
// word-break,须 inline 注入;对齐文件头"rich-text 只认 inline style")。
const BODY_STYLE = 'color:#e0e0e0;word-break:break-all'
for (const tag of ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'blockquote', 'td']) {
out = styleTag(out, tag, BODY_STYLE)
}
// 块代码 <pre>:深色背景 + 等宽 + 圆角 + pre-wrap 换行防撑爆 // 块代码 <pre>:深色背景 + 等宽 + 圆角 + pre-wrap 换行防撑爆
out = styleTag( out = styleTag(
out, out,
'pre', 'pre',
'display:block;background-color:#1a1a1a;color:#d4d4d4;padding:10px;border-radius:6px;white-space:pre-wrap;word-break:break-all;font-family:monospace;font-size:12px;margin:6px 0', 'display:block;background-color:#1a1a1a;color:#d4d4d4;padding:10px;border-radius:6px;white-space:pre-wrap;word-break:break-all;font-family:monospace;font-size:12px;margin:6px 0',
) )
// code(块内 + 内联):等宽橙色,透明背景(避与 pre 双层色块) // code(块内 + 内联):等宽橙色,透明背景(避与 pre 双层色块;块代码 token 由高亮色覆盖)
out = styleTag( out = styleTag(
out, out,
'code', 'code',
'font-family:monospace;color:#e0a070;background-color:transparent;font-size:12px', 'font-family:monospace;color:#e0a070;background-color:transparent;font-size:12px',
) )
// 链接:蓝下划线(rich-text 不可点击,仅展示 + selectable 长按复制) // 链接:蓝下划线(rich-text 不可点击,仅展示 + selectable 长按复制);word-break 防长 URL 撑开
out = styleTag(out, 'a', 'color:#4a9eff;text-decoration:underline') out = styleTag(out, 'a', 'color:#4a9eff;text-decoration:underline;word-break:break-all')
// 表格:border + 块级 + 横向溢出处理(rich-text 非滚动,靠 word-break 兜底;border 提升可读) // 图片:max-width 防超宽撑爆气泡;block 去基线间隙;圆角与气泡一致。
// 网络图显示依赖微信「downloadFile 合法域名」白名单(见文件头),非渲染问题。
out = styleTag(out, 'img', 'max-width:100%;display:block;border-radius:6px')
// 表格:显式 table 布局 + 等宽字列。注意不能用 display:block(会失去表格布局语义、
// 列宽错乱),故显式声明 display:table;rich-text 非滚动容器(见文件头注释),无法建横向
// 滚动区 → 不做滚动容器,靠 max-width:100% + word-break:break-all 防超宽撑爆气泡;
// border 提升可读。
out = styleTag( out = styleTag(
out, out,
'table', 'table',
'display:block;border-collapse:collapse;width:100%;font-size:12px;word-break:break-all', 'display:table;border-collapse:collapse;width:100%;max-width:100%;font-size:12px;word-break:break-all',
) )
out = styleTag(out, 'th', 'border:1px solid #333;padding:4px 6px;background-color:#1a1a1a') // th/td:边框 + 内边距保留,字号与 table 对齐(font-size:12px)
out = styleTag(out, 'td', 'border:1px solid #333;padding:4px 6px') out = styleTag(out, 'th', 'border:1px solid #333;padding:4px 6px;font-size:12px;background-color:#1a1a1a')
out = styleTag(out, 'td', 'border:1px solid #333;padding:4px 6px;font-size:12px')
return out return out
} }
+1 -1
View File
@@ -14,7 +14,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2020", "DOM"], "lib": ["ES2020", "DOM"],
"types": ["@dcloudio/types", "@types/node"], "types": ["@dcloudio/types", "@types/node", "vite/client"],
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["src/*"] "@/*": ["src/*"]
+47
View File
@@ -0,0 +1,47 @@
# Bug 修复模板
name: Bug 修复
description: AI 驱动的 bug 修复流程 — 定位、修复、验证、通知
nodes:
locate:
type: ai
label: 定位 Bug
config:
prompt: |
根据错误描述和堆栈信息,定位 bug 的根因。
使用 search_files 和 read_file 工具查找相关代码。
persona_id: analyst
fix:
type: ai
label: 修复 Bug
config:
prompt: |
基于定位结果,修复 bug。
使用 patch_file 工具进行最小化修改。
persona_id: coder
verify:
type: ai
label: 验证修复
config:
prompt: |
验证修复是否有效。
使用 run_command 工具运行相关测试。
persona_id: tester
notify:
type: notify
label: 通知结果
config:
type: desktop
title: Bug 修复完成
message: 修复已验证
edges:
- from: locate
to: fix
- from: fix
to: verify
- from: verify
to: notify
+36
View File
@@ -0,0 +1,36 @@
# 代码审查模板
name: 代码审查
description: AI 驱动的代码审查流程 — 分析变更、审查质量、通知结果
nodes:
analyze:
type: ai
label: 分析代码变更
config:
prompt: |
分析以下代码变更,识别:
1. 潜在 bug 和逻辑错误
2. 安全漏洞
3. 性能问题
4. 代码规范
persona_id: reviewer
report:
type: ai
label: 生成审查报告
config:
prompt: 基于分析结果,生成结构化审查报告,含优先级排序的改进建议
notify:
type: notify
label: 通知完成
config:
type: desktop
title: 代码审查完成
message: 审查报告已生成
edges:
- from: analyze
to: report
- from: report
to: notify
+62
View File
@@ -0,0 +1,62 @@
# 功能开发模板
name: 功能开发
description: AI 驱动的功能开发全流程 — 设计、编码、测试、审查、通知
nodes:
design:
type: ai
label: 方案设计
config:
prompt: |
分析需求,设计实现方案:
1. 模块划分和接口定义
2. 数据结构和核心算法
3. 影响范围评估
persona_id: architect
implement:
type: ai
label: 编码实现
config:
prompt: |
按照设计方案实现功能。
使用 write_file 和 patch_file 工具编写代码。
persona_id: coder
test:
type: ai
label: 编写测试
config:
prompt: |
为新功能编写单元测试和集成测试。
使用 run_command 工具运行测试验证。
persona_id: tester
review:
type: ai
label: 代码审查
config:
prompt: |
审查实现代码,关注:
1. 代码质量和可维护性
2. 潜在 bug
3. 安全性
persona_id: reviewer
notify:
type: notify
label: 通知完成
config:
type: desktop
title: 功能开发完成
message: 设计→编码→测试→审查 全流程完成
edges:
- from: design
to: implement
- from: implement
to: test
- from: test
to: review
- from: review
to: notify
+80 -2
View File
@@ -5,10 +5,18 @@
"": { "": {
"name": "devflow", "name": "devflow",
"dependencies": { "dependencies": {
"@antv/layout": "^2.0.0",
"@antv/x6": "^3.1.7",
"@antv/x6-vue-shape": "^3.0.2",
"@arco-design/web-vue": "^2.58.0", "@arco-design/web-vue": "^2.58.0",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"dompurify": "^3.4.9", "@tauri-apps/plugin-dialog": "^2.7.1",
"@types/dagre": "^0.7.54",
"dagre": "^0.8.5",
"dompurify": "^3.4.10",
"highlight.js": "^11.11.1",
"marked": "^18.0.5", "marked": "^18.0.5",
"tslib": "^2.8.1",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-i18n": "9", "vue-i18n": "9",
"vue-router": "4", "vue-router": "4",
@@ -24,6 +32,20 @@
}, },
}, },
"packages": { "packages": {
"@antv/event-emitter": ["@antv/event-emitter@0.1.3", "https://registry.npmmirror.com/@antv/event-emitter/-/event-emitter-0.1.3.tgz", {}, "sha512-4ddpsiHN9Pd4UIlWuKVK1C4IiZIdbwQvy9i7DUSI3xNJ89FPUFt8lxDYj8GzzfdllV0NkJTRxnG+FvLk0llidg=="],
"@antv/expr": ["@antv/expr@1.0.2", "https://registry.npmmirror.com/@antv/expr/-/expr-1.0.2.tgz", {}, "sha512-vrfdmPHkTuiS5voVutKl2l06w1ihBh9A8SFdQPEE+2KMVpkymzGOF1eWpfkbGZ7tiFE15GodVdhhHomD/hdIwg=="],
"@antv/graphlib": ["@antv/graphlib@2.0.4", "https://registry.npmmirror.com/@antv/graphlib/-/graphlib-2.0.4.tgz", { "dependencies": { "@antv/event-emitter": "^0.1.3" } }, "sha512-zc/5oQlsdk42Z0ib1mGklwzhJ5vczLFiPa1v7DgJkTbgJ2YxRh9xdarf86zI49sKVJmgbweRpJs7Nu5bIiwv4w=="],
"@antv/layout": ["@antv/layout@2.0.0", "https://registry.npmmirror.com/@antv/layout/-/layout-2.0.0.tgz", { "dependencies": { "@antv/event-emitter": "^0.1.3", "@antv/expr": "^1.0.2", "@antv/graphlib": "^2.0.0", "@antv/util": "^3.3.2", "comlink": "^4.4.1", "d3-force": "^3.0.0", "d3-force-3d": "^3.0.5", "d3-octree": "^1.0.2", "d3-quadtree": "^3.0.1", "dagre": "^0.8.5", "ml-matrix": "^6.10.4", "tslib": "^2.8.1" } }, "sha512-aCZ3UdNc40SfT7meFV7QTADY2HCnc0DShVw56CJNTI6oExUIVU736grPuL5Dhb8/JrVaU4Y83QPN/P7KafBzlw=="],
"@antv/util": ["@antv/util@3.3.11", "https://registry.npmmirror.com/@antv/util/-/util-3.3.11.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "gl-matrix": "^3.3.0", "tslib": "^2.3.1" } }, "sha512-FII08DFM4ABh2q5rPYdr0hMtKXRgeZazvXaFYCs7J7uTcWDHUhczab2qOCJLNDugoj8jFag1djb7wS9ehaRYBg=="],
"@antv/x6": ["@antv/x6@3.1.7", "https://registry.npmmirror.com/@antv/x6/-/x6-3.1.7.tgz", { "dependencies": { "dom-align": "^1.12.4", "lodash-es": "^4.17.15", "mousetrap": "^1.6.5", "utility-types": "^3.10.0" } }, "sha512-NLKXtbCK51oLbazfFD0XsD93rMmih08UBW4gAuEyLBpwAqHmHe+vP8VhOZDkl5O9jV1LSv85IJghr9CT5tZjWw=="],
"@antv/x6-vue-shape": ["@antv/x6-vue-shape@3.0.2", "https://registry.npmmirror.com/@antv/x6-vue-shape/-/x6-vue-shape-3.0.2.tgz", { "dependencies": { "vue-demi": "latest" }, "peerDependencies": { "@antv/x6": "^3.x", "@vue/composition-api": "^1.0.0-rc.1", "vue": "^2.0.0 || >=3.0.0" }, "optionalPeers": ["@vue/composition-api"] }, "sha512-HWnYFvLCaUD9ernINxTn9vCRj66eG/nXKErSSq0hfI4U1HOitC/1oUrzDdwL/BT80HKAQeSsmy7gpDA/79jYlw=="],
"@arco-design/color": ["@arco-design/color@0.4.0", "https://registry.npmmirror.com/@arco-design/color/-/color-0.4.0.tgz", { "dependencies": { "color": "^3.1.3" } }, "sha512-s7p9MSwJgHeL8DwcATaXvWT3m2SigKpxx4JA1BGPHL4gfvaQsmQfrLBDpjOJFJuJ2jG2dMt3R3P8Pm9E65q18g=="], "@arco-design/color": ["@arco-design/color@0.4.0", "https://registry.npmmirror.com/@arco-design/color/-/color-0.4.0.tgz", { "dependencies": { "color": "^3.1.3" } }, "sha512-s7p9MSwJgHeL8DwcATaXvWT3m2SigKpxx4JA1BGPHL4gfvaQsmQfrLBDpjOJFJuJ2jG2dMt3R3P8Pm9E65q18g=="],
"@arco-design/web-vue": ["@arco-design/web-vue@2.58.0", "https://registry.npmmirror.com/@arco-design/web-vue/-/web-vue-2.58.0.tgz", { "dependencies": { "@arco-design/color": "^0.4.0", "b-tween": "^0.3.3", "b-validate": "^1.5.3", "compute-scroll-into-view": "^1.0.20", "dayjs": "^1.11.13", "number-precision": "^1.6.0", "resize-observer-polyfill": "^1.5.1", "scroll-into-view-if-needed": "^2.2.31", "vue": "^3.1.0" } }, "sha512-b1vdPYOmjG5VAkVa7jlVwCb+WynBK+rnKN8zH3yKohpZObZbostRd3HgYNtjjZjGVU3OqR0Yy2FX7ftgF0bcOw=="], "@arco-design/web-vue": ["@arco-design/web-vue@2.58.0", "https://registry.npmmirror.com/@arco-design/web-vue/-/web-vue-2.58.0.tgz", { "dependencies": { "@arco-design/color": "^0.4.0", "b-tween": "^0.3.3", "b-validate": "^1.5.3", "compute-scroll-into-view": "^1.0.20", "dayjs": "^1.11.13", "number-precision": "^1.6.0", "resize-observer-polyfill": "^1.5.1", "scroll-into-view-if-needed": "^2.2.31", "vue": "^3.1.0" } }, "sha512-b1vdPYOmjG5VAkVa7jlVwCb+WynBK+rnKN8zH3yKohpZObZbostRd3HgYNtjjZjGVU3OqR0Yy2FX7ftgF0bcOw=="],
@@ -172,6 +194,10 @@
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.2", "https://registry.npmmirror.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA=="], "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.2", "https://registry.npmmirror.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA=="],
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.1", "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ=="],
"@types/dagre": ["@types/dagre@0.7.54", "https://registry.npmmirror.com/@types/dagre/-/dagre-0.7.54.tgz", {}, "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ=="],
"@types/dompurify": ["@types/dompurify@3.2.0", "https://registry.npmmirror.com/@types/dompurify/-/dompurify-3.2.0.tgz", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="], "@types/dompurify": ["@types/dompurify@3.2.0", "https://registry.npmmirror.com/@types/dompurify/-/dompurify-3.2.0.tgz", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="],
"@types/estree": ["@types/estree@1.0.9", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/estree": ["@types/estree@1.0.9", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
@@ -228,15 +254,35 @@
"color-string": ["color-string@1.9.1", "https://registry.npmmirror.com/color-string/-/color-string-1.9.1.tgz", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], "color-string": ["color-string@1.9.1", "https://registry.npmmirror.com/color-string/-/color-string-1.9.1.tgz", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
"comlink": ["comlink@4.4.2", "https://registry.npmmirror.com/comlink/-/comlink-4.4.2.tgz", {}, "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g=="],
"compute-scroll-into-view": ["compute-scroll-into-view@1.0.20", "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", {}, "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg=="], "compute-scroll-into-view": ["compute-scroll-into-view@1.0.20", "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", {}, "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg=="],
"csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-binarytree": ["d3-binarytree@1.0.2", "https://registry.npmmirror.com/d3-binarytree/-/d3-binarytree-1.0.2.tgz", {}, "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw=="],
"d3-dispatch": ["d3-dispatch@3.0.1", "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
"d3-force": ["d3-force@3.0.0", "https://registry.npmmirror.com/d3-force/-/d3-force-3.0.0.tgz", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],
"d3-force-3d": ["d3-force-3d@3.0.6", "https://registry.npmmirror.com/d3-force-3d/-/d3-force-3d-3.0.6.tgz", { "dependencies": { "d3-binarytree": "1", "d3-dispatch": "1 - 3", "d3-octree": "1", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA=="],
"d3-octree": ["d3-octree@1.1.0", "https://registry.npmmirror.com/d3-octree/-/d3-octree-1.1.0.tgz", {}, "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A=="],
"d3-quadtree": ["d3-quadtree@3.0.1", "https://registry.npmmirror.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="],
"d3-timer": ["d3-timer@3.0.1", "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"dagre": ["dagre@0.8.5", "https://registry.npmmirror.com/dagre/-/dagre-0.8.5.tgz", { "dependencies": { "graphlib": "^2.1.8", "lodash": "^4.17.15" } }, "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw=="],
"dayjs": ["dayjs@1.11.21", "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="], "dayjs": ["dayjs@1.11.21", "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="],
"de-indent": ["de-indent@1.0.2", "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="], "de-indent": ["de-indent@1.0.2", "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="],
"dompurify": ["dompurify@3.4.9", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.9.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ=="], "dom-align": ["dom-align@1.12.4", "https://registry.npmmirror.com/dom-align/-/dom-align-1.12.4.tgz", {}, "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw=="],
"dompurify": ["dompurify@3.4.11", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.11.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="],
"entities": ["entities@7.0.1", "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "entities": ["entities@7.0.1", "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
@@ -244,20 +290,44 @@
"estree-walker": ["estree-walker@2.0.2", "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "estree-walker": ["estree-walker@2.0.2", "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gl-matrix": ["gl-matrix@3.4.4", "https://registry.npmmirror.com/gl-matrix/-/gl-matrix-3.4.4.tgz", {}, "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="],
"graphlib": ["graphlib@2.1.8", "https://registry.npmmirror.com/graphlib/-/graphlib-2.1.8.tgz", { "dependencies": { "lodash": "^4.17.15" } }, "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A=="],
"he": ["he@1.2.0", "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], "he": ["he@1.2.0", "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"highlight.js": ["highlight.js@11.11.1", "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
"is-any-array": ["is-any-array@3.0.0", "https://registry.npmmirror.com/is-any-array/-/is-any-array-3.0.0.tgz", {}, "sha512-o4h+tylWykC4BD1vaejp6gDxoM13bwW8FGuNs4yIKpj8xbBJcRxJx8vZpq0dCr7ZDEfeKjmsi/euolKhX6f/ww=="],
"is-arrayish": ["is-arrayish@0.3.4", "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.3.4.tgz", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], "is-arrayish": ["is-arrayish@0.3.4", "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.3.4.tgz", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
"lodash": ["lodash@4.18.1", "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
"lodash-es": ["lodash-es@4.18.1", "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
"magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"marked": ["marked@18.0.5", "https://registry.npmmirror.com/marked/-/marked-18.0.5.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="], "marked": ["marked@18.0.5", "https://registry.npmmirror.com/marked/-/marked-18.0.5.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="],
"minimatch": ["minimatch@9.0.9", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], "minimatch": ["minimatch@9.0.9", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"ml-array-max": ["ml-array-max@2.0.0", "https://registry.npmmirror.com/ml-array-max/-/ml-array-max-2.0.0.tgz", { "dependencies": { "is-any-array": "^3.0.0" } }, "sha512-QQZ4kENwpWmyNb98UXRDFXrmtIXuXtt1+bSbda/2KA85+F+rrJP8hZk6QOkCQXM2Th9mUDYdq/PNByPdT9ID4A=="],
"ml-array-min": ["ml-array-min@2.0.0", "https://registry.npmmirror.com/ml-array-min/-/ml-array-min-2.0.0.tgz", { "dependencies": { "is-any-array": "^3.0.0" } }, "sha512-GRj6Ky6sW9vGL6yIjgsHmXZ9YgrdmcQ8nCxPqEGeKc6dkfYg1XDYxGFxADUjNuZyoCd5PUscWAS4N+cFaX6hFg=="],
"ml-array-rescale": ["ml-array-rescale@2.0.0", "https://registry.npmmirror.com/ml-array-rescale/-/ml-array-rescale-2.0.0.tgz", { "dependencies": { "is-any-array": "^3.0.0", "ml-array-max": "^2.0.0", "ml-array-min": "^2.0.0" } }, "sha512-2GGtKfSno94/kIloWGvpp/U5Q5vLvLrza+SAaGsLeo6Xj4mEbA6Gqx+oTfZFkxnd1grT2X007HfJNs3T5BsiVg=="],
"ml-matrix": ["ml-matrix@6.13.0", "https://registry.npmmirror.com/ml-matrix/-/ml-matrix-6.13.0.tgz", { "dependencies": { "is-any-array": "^3.0.0", "ml-array-rescale": "^2.0.0" } }, "sha512-QpV0UTUkglg6vPUgThKGBEtit2ac6habSoZ33bwI9rU0UHZLqw6G3ukTIE8zWiUF3sjK8YAlhx/o/b9layzH8A=="],
"mousetrap": ["mousetrap@1.6.5", "https://registry.npmmirror.com/mousetrap/-/mousetrap-1.6.5.tgz", {}, "sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA=="],
"muggle-string": ["muggle-string@0.4.1", "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], "muggle-string": ["muggle-string@0.4.1", "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
"nanoid": ["nanoid@3.3.12", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "nanoid": ["nanoid@3.3.12", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
@@ -284,18 +354,26 @@
"tinyglobby": ["tinyglobby@0.2.17", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "tinyglobby": ["tinyglobby@0.2.17", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.6.3", "https://registry.npmmirror.com/typescript/-/typescript-5.6.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], "typescript": ["typescript@5.6.3", "https://registry.npmmirror.com/typescript/-/typescript-5.6.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
"utility-types": ["utility-types@3.11.0", "https://registry.npmmirror.com/utility-types/-/utility-types-3.11.0.tgz", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="],
"vite": ["vite@6.4.3", "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], "vite": ["vite@6.4.3", "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
"vscode-uri": ["vscode-uri@3.1.0", "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], "vscode-uri": ["vscode-uri@3.1.0", "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
"vue": ["vue@3.5.35", "https://registry.npmmirror.com/vue/-/vue-3.5.35.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.35", "@vue/compiler-sfc": "3.5.35", "@vue/runtime-dom": "3.5.35", "@vue/server-renderer": "3.5.35", "@vue/shared": "3.5.35" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q=="], "vue": ["vue@3.5.35", "https://registry.npmmirror.com/vue/-/vue-3.5.35.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.35", "@vue/compiler-sfc": "3.5.35", "@vue/runtime-dom": "3.5.35", "@vue/server-renderer": "3.5.35", "@vue/shared": "3.5.35" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q=="],
"vue-demi": ["vue-demi@0.14.10", "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
"vue-i18n": ["vue-i18n@9.14.5", "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.14.5.tgz", { "dependencies": { "@intlify/core-base": "9.14.5", "@intlify/shared": "9.14.5", "@vue/devtools-api": "^6.5.0" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g=="], "vue-i18n": ["vue-i18n@9.14.5", "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.14.5.tgz", { "dependencies": { "@intlify/core-base": "9.14.5", "@intlify/shared": "9.14.5", "@vue/devtools-api": "^6.5.0" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g=="],
"vue-router": ["vue-router@4.6.4", "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="], "vue-router": ["vue-router@4.6.4", "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="],
"vue-tsc": ["vue-tsc@2.2.12", "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", { "dependencies": { "@volar/typescript": "2.4.15", "@vue/language-core": "2.2.12" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw=="], "vue-tsc": ["vue-tsc@2.2.12", "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", { "dependencies": { "@volar/typescript": "2.4.15", "@vue/language-core": "2.2.12" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw=="],
"@types/dompurify/dompurify": ["dompurify@3.4.9", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.9.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ=="],
} }
} }
+17 -5
View File
@@ -1,4 +1,4 @@
//! 模型能力数据模型 — F-01 阶段1 //! 模型能力数据模型。
//! //!
//! 单模型的完整描述(4 维度 + 路由控制)。纯数据结构,零 IO / 零 DB 依赖。 //! 单模型的完整描述(4 维度 + 路由控制)。纯数据结构,零 IO / 零 DB 依赖。
//! df-storage 反序列化 DB 行时直接消费本模块类型;df-ai 探测器/路由器阶段 2-4 再用。 //! df-storage 反序列化 DB 行时直接消费本模块类型;df-ai 探测器/路由器阶段 2-4 再用。
@@ -45,9 +45,9 @@ pub enum Capability {
/// ///
/// 序列化为 snake_case。原设计文档 §2.2 列 Free/Low/Medium/High,但 Free 变体形同虚设: /// 序列化为 snake_case。原设计文档 §2.2 列 Free/Low/Medium/High,但 Free 变体形同虚设:
/// 预设表(presets/models.json)0 条 free + 启发式从不赋 Free(只 Low/Medium/High), /// 预设表(presets/models.json)0 条 free + 启发式从不赋 Free(只 Low/Medium/High),
/// B-260618-05 删除 Free 死档变体。 /// Free 死档变体已删除。
/// ///
/// 路由已解耦(2026-06-18 B-260618-03):provider /v1/models API 不返回 cost_tier, /// 路由已解耦:provider /v1/models API 不返回 cost_tier,
/// 此维度 100% 靠预设表写死 + 模型名启发式猜,数据无客观依据不可信,不再参与硬路由 /// 此维度 100% 靠预设表写死 + 模型名启发式猜,数据无客观依据不可信,不再参与硬路由
/// (原 §6.1 `cost_tier <= max_cost` 过滤已删除)。枚举保留供未来出现真实判别源时再接回。 /// (原 §6.1 `cost_tier <= max_cost` 过滤已删除)。枚举保留供未来出现真实判别源时再接回。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
@@ -63,7 +63,7 @@ pub enum CostTier {
/// 设计文档 §2.2:Lite/Standard/Plus/Ultra。序列化为 snake_case。 /// 设计文档 §2.2:Lite/Standard/Plus/Ultra。序列化为 snake_case。
/// 派生 Ord:Lite < Standard < Plus < Ultra。 /// 派生 Ord:Lite < Standard < Plus < Ultra。
/// ///
/// 路由已解耦(2026-06-18 B-260618-03):provider /v1/models API 不返回 intelligence, /// 路由已解耦:provider /v1/models API 不返回 intelligence,
/// 此维度 100% 靠预设表写死 + 模型名启发式猜,数据无客观依据不可信,不再参与硬路由 /// 此维度 100% 靠预设表写死 + 模型名启发式猜,数据无客观依据不可信,不再参与硬路由
/// (原 §6.1 `intelligence >= min_intelligence` 过滤已删除)。枚举保留供未来出现真实判别源时再接回。 /// (原 §6.1 `intelligence >= min_intelligence` 过滤已删除)。枚举保留供未来出现真实判别源时再接回。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
@@ -138,6 +138,14 @@ pub struct ModelConfig {
#[serde(default = "default_context_window")] #[serde(default = "default_context_window")]
pub context_window: usize, pub context_window: usize,
/// 是否支持 temperature 参数(默认 true)。
///
/// 部分模型仅接受固定温度值(如商汤 sensenova 系列仅允许 1.0),透传其它温度(标题 0.3/
/// 压缩 0.2 等)→ 上游 HTTP 400,实测 9 次。false 时协议转换层省略 temperature 字段
/// (交由上游默认),避免无效往返;true 保持原样透传(零回归)。
#[serde(default = "default_true")]
pub temperature_supported: bool,
// ── 探测元数据(只读,由 ModelProbe 填充,Phase 2)── // ── 探测元数据(只读,由 ModelProbe 填充,Phase 2)──
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub probe_source: Option<ProbeSource>, pub probe_source: Option<ProbeSource>,
@@ -173,6 +181,9 @@ impl ModelConfig {
intelligence: IntelligenceTier::Standard, intelligence: IntelligenceTier::Standard,
weight: default_weight(), weight: default_weight(),
context_window: default_context_window(), context_window: default_context_window(),
// 默认支持 temperature(老格式升级/未知模型保持原样透传,零回归);
// 明确不支持的模型由 presets 表 temperature_supported=false 标注。
temperature_supported: true,
probe_source: None, probe_source: None,
} }
} }
@@ -302,7 +313,7 @@ mod tests {
assert_eq!(serde_json::from_str::<CostTier>(expected).unwrap(), variant); assert_eq!(serde_json::from_str::<CostTier>(expected).unwrap(), variant);
} }
// Ord:Low < Medium < High(枚举序,路由已解耦不再用于过滤,保留供未来判别源)。 // Ord:Low < Medium < High(枚举序,路由已解耦不再用于过滤,保留供未来判别源)。
// Free 变体已删除(B-260618-05:预设/启发式从不赋 Free,死档)。 // Free 变体已删除(预设/启发式从不赋 Free,死档)。
assert!(CostTier::Low < CostTier::Medium); assert!(CostTier::Low < CostTier::Medium);
assert!(CostTier::Medium < CostTier::High); assert!(CostTier::Medium < CostTier::High);
} }
@@ -339,6 +350,7 @@ mod tests {
intelligence: IntelligenceTier::Plus, intelligence: IntelligenceTier::Plus,
weight: 70, weight: 70,
context_window: 128_000, context_window: 128_000,
temperature_supported: true,
probe_source: Some(ProbeSource::PresetTable), probe_source: Some(ProbeSource::PresetTable),
}; };
let json = serde_json::to_string(&mc).unwrap(); let json = serde_json::to_string(&mc).unwrap();
+135 -22
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, 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, 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, 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, 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, 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, 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 判定走多模态分支)。
@@ -90,19 +90,19 @@ impl ChatMessage {
/// 是否处于 active 态(status 为 None 或 "active")。其余状态一律 false。 /// 是否处于 active 态(status 为 None 或 "active")。其余状态一律 false。
/// ///
/// 正面白名单(F-15 §3.2):仅认 None / "active",新状态 /// 正面白名单:仅认 None / "active",新状态
/// (如阶段2 引入的 "archived_segment" / "compressed")自动落入不 active 分支, /// (如"archived_segment" / "compressed")自动落入不 active 分支,
/// 无需每加一个状态就来这里改。当前取值 None/Some("active")/Some("truncated") /// 无需每加一个状态就来这里改。当前取值 None/Some("active")/Some("truncated")
/// 行为与旧反面排除完全等价(None=true / "active"=true / "truncated"=false)。 /// 行为与旧反面排除完全等价(None=true / "active"=true / "truncated"=false)。
pub fn is_active(&self) -> bool { pub fn is_active(&self) -> bool {
matches!(self.status.as_deref(), None | Some("active")) matches!(self.status, None | Some(MessageStatus::Active))
} }
} }
impl ToolDefinition { impl ToolDefinition {
pub fn function(name: impl Into<String>, description: impl Into<String>, parameters: serde_json::Value) -> Self { pub fn function(name: impl Into<String>, description: impl Into<String>, parameters: serde_json::Value) -> Self {
Self { Self {
tool_type: "function".into(), tool_type: ToolType::new("function"),
function: ToolFunction { name: name.into(), description: description.into(), parameters }, function: ToolFunction { name: name.into(), description: description.into(), parameters },
} }
} }
@@ -118,6 +118,50 @@ impl ToolCall {
} }
} }
/// 解析点统一兜底:tool_call.id 空 → 生成唯一 fallback,非空原样。
///
/// 根因(实证会话 01f05167 SenseNova flash-lite):某些 provider(SenseNova 兼容缺陷)
/// 返回空 `tool_call.id`("")。OpenAI 协议要求 id 唯一。DevFlow 多 tool_call 按 id
/// 路由结果,id 空时所有结果落到同一 key(`audit/mod.rs:203` 的 `seen_ids` 去重把空 id
/// 视为相同,只留首个 tool_call)→ AI 看到「所有调用同一结果」,工具全失败。
///
/// 兜底在**解析点**生成 fallback id:raw 非空用 raw,空用 `format!("{prefix}_{n}")`
/// (n 取自下方 `FALLBACK_ID_COUNTER` **全局递增计数器**,跨轮跨 assistant 唯一)。
/// 下游(工具执行 / tool 结果回填 tool_call_id)从解析后的 `ToolCall.id` 取,不重复生成,
/// 确保 assistant tool_call.id 与 tool 结果 tool_call_id 匹配(防 sanitize 三元组断裂)。
///
/// 三处解析点共用本 helper(DRY):OpenAI 同步 `parse_tool_calls`(prefix=`gen_tool`)、
/// OpenAI 流式 chunk(prefix=`gen_stream`)、Anthropic 同步 + 流式(prefix=`gen_anthropic` /
/// `gen_anthropic_stream`)。正常 provider(OpenAI/Claude/GLM id 非空)原样透传零介入。
///
/// # 为何用全局计数器而非单轮 index(实证 af2fab4e)
///
/// 旧实现 fallback 用 `format!("{prefix}_{index}")`,index 是**单轮** tool_call 数组
/// 位置。跨轮(不同 assistant)index 都从 0 起 → `gen_stream_0` 跨轮重复。agentic 的
/// `id_to_name`(`insert(id, name)`)后者覆盖前者 → run_command 的 exit=1 被误标
/// grep::exit=1 → L1 误熔断 grep(冤枉)→ loop 停 → 最后 assistant 空 content tool_calls
/// 没执行(空气泡)。更严重:id 重复 → tool 结果配错 tool_call(三元组配对错位)。
///
/// 全局 `AtomicU64`(SeqCst)跨轮跨 assistant 严格递增,fallback id 永不重复。`index`
/// 参数保留仅为签名兼容(4 处调用点 parse_tool_calls / 流式 chunk / push / agentic 都传),
/// fallback 内部不再使用 index。
///
/// 单测跨进程实例计数器从 0 起;并发场景下两线程拿到的 fallback id 也严格递增(SeqCst),
/// 保证全局唯一。
pub fn tool_call_id_or_fallback(raw: &str, _index: usize, prefix: &str) -> String {
if !raw.is_empty() {
raw.to_string()
} else {
let n = FALLBACK_ID_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
format!("{prefix}_{n}")
}
}
/// fallback id 全局计数器:跨轮跨 assistant 严格递增,保证空 id fallback 永不重复。
///
/// 见 `tool_call_id_or_fallback` 文档说明(实证 af2fab4e 跨轮重复根因)。
static FALLBACK_ID_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// LLM Provider trait /// LLM Provider trait
#[async_trait] #[async_trait]
pub trait LlmProvider: Send + Sync { pub trait LlmProvider: Send + Sync {
@@ -155,10 +199,9 @@ mod tests {
#[test] #[test]
fn is_active_whitelist() { fn is_active_whitelist() {
// F-15 §3.2 正面白名单:仅 None / "active" 为 true,其余一律 false。 // 正面白名单:仅 None / "active" 为 true,其余一律 false。
// 零行为变化:None / "active" / "truncated" 与旧反面排除完全等价; // 零行为变化:None / "active" / "truncated" 与旧反面排除完全等价;
// "archived_segment" / "compressed"(阶段2 引入,当前代码未赋值)由白名单 // "archived_segment" / "compressed" 由白名单 matches! 只认 None/active 自动落入 false。
// matches! 只认 None/active 自动落入 false,面向未来验证。
// None(构造默认值,向前兼容老 JSON) // None(构造默认值,向前兼容老 JSON)
let m = ChatMessage::user("hi"); let m = ChatMessage::user("hi");
@@ -166,26 +209,26 @@ mod tests {
// "active" // "active"
let mut m = ChatMessage::user("hi"); let mut m = ChatMessage::user("hi");
m.status = Some("active".to_string()); m.status = Some(MessageStatus::Active);
assert!(m.is_active(), "Some(active) 应 active"); assert!(m.is_active(), "Some(active) 应 active");
// "truncated" — 当前取值,与旧实现等价(false) // "truncated" — 当前取值,与旧实现等价(false)
let mut m = ChatMessage::user("hi"); let mut m = ChatMessage::user("hi");
m.status = Some("truncated".to_string()); m.status = Some(MessageStatus::Truncated);
assert!(!m.is_active(), "truncated 应不 active"); assert!(!m.is_active(), "truncated 应不 active");
// "archived_segment" — 阶段2 待引入,白名单自动隔离 // "archived_segment" — 白名单自动隔离
let mut m = ChatMessage::user("hi"); let mut m = ChatMessage::user("hi");
m.status = Some("archived_segment".to_string()); m.status = Some(MessageStatus::ArchivedSegment);
assert!(!m.is_active(), "archived_segment 应不 active(白名单隔离)"); assert!(!m.is_active(), "archived_segment 应不 active(白名单隔离)");
// "compressed" — 阶段2 待引入,白名单自动隔离 // "compressed" — 白名单自动隔离
let mut m = ChatMessage::user("hi"); let mut m = ChatMessage::user("hi");
m.status = Some("compressed".to_string()); m.status = Some(MessageStatus::Compressed);
assert!(!m.is_active(), "compressed 应不 active(白名单隔离)"); assert!(!m.is_active(), "compressed 应不 active(白名单隔离)");
} }
// ---------- F-260614-05 Phase 2a ContentPart ---------- // ---------- ContentPart ----------
/// 老 JSON(无 parts 字段)反序列化时 parts 应为 None(向前兼容) /// 老 JSON(无 parts 字段)反序列化时 parts 应为 None(向前兼容)
#[test] #[test]
@@ -272,6 +315,12 @@ mod tests {
model: None, model: None,
status: None, status: None,
reasoning_content: 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: None, timestamp: None,
}; };
assert_eq!(m.content, "字面量构造"); assert_eq!(m.content, "字面量构造");
@@ -324,6 +373,12 @@ mod tests {
model: None, model: None,
status: None, status: None,
reasoning_content: Some("thinking process".to_string()), reasoning_content: Some("thinking process".to_string()),
prompt_tokens: None,
completion_tokens: None,
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
is_estimated: None,
timestamp: None, timestamp: None,
}; };
let json = serde_json::to_string(&m).unwrap(); let json = serde_json::to_string(&m).unwrap();
@@ -333,7 +388,7 @@ mod tests {
assert_eq!(deserialized.reasoning_content, Some("thinking process".to_string())); assert_eq!(deserialized.reasoning_content, Some("thinking process".to_string()));
} }
// ---------- F-260619-04 消息级溯源:id 字段 ---------- // ---------- 消息级溯源:id 字段 ----------
/// 所有便捷构造函数默认生成非 None 的 id(ULID 风格) /// 所有便捷构造函数默认生成非 None 的 id(ULID 风格)
#[test] #[test]
@@ -379,6 +434,12 @@ mod tests {
model: None, model: None,
status: None, status: None,
reasoning_content: 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: None, timestamp: None,
}; };
let json = serde_json::to_string(&m).unwrap(); let json = serde_json::to_string(&m).unwrap();
@@ -411,6 +472,7 @@ mod tests {
usage: None, usage: None,
error: None, error: None,
reasoning_content: Some("thought".to_string()), reasoning_content: Some("thought".to_string()),
truncated_by_max_tokens: false,
}; };
let json = serde_json::to_string(&chunk).unwrap(); let json = serde_json::to_string(&chunk).unwrap();
assert!(json.contains("reasoning_content")); assert!(json.contains("reasoning_content"));
@@ -426,7 +488,7 @@ mod tests {
let resp = CompletionResponse { let resp = CompletionResponse {
text: "ok".to_string(), text: "ok".to_string(),
model: "r1".to_string(), model: "r1".to_string(),
usage: TokenUsage { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, usage: TokenUsage { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30, ..Default::default() },
tool_calls: None, tool_calls: None,
reasoning_content: Some("r1 thought".to_string()), reasoning_content: Some("r1 thought".to_string()),
}; };
@@ -436,4 +498,55 @@ mod tests {
let deserialized: CompletionResponse = serde_json::from_str(&json).unwrap(); let deserialized: CompletionResponse = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.reasoning_content, Some("r1 thought".to_string())); assert_eq!(deserialized.reasoning_content, Some("r1 thought".to_string()));
} }
/// CR-空 id:tool_call_id_or_fallback 共享 helper —— 空 raw → fallback,非空原样。
#[test]
fn tool_call_id_or_fallback_non_empty_passthrough() {
// 非空 raw 原样透传(provider 真 id 如 call_xxx 保留),与 index/prefix 无关
assert_eq!(tool_call_id_or_fallback("call_abc", 0, "gen_tool"), "call_abc");
assert_eq!(tool_call_id_or_fallback("x", 5, "p"), "x");
}
#[test]
fn tool_call_id_or_fallback_empty_starts_with_prefix() {
// 空 raw → "{prefix}_{n}",n 取自全局计数器(跨进程实例从 0 起,单测不假设具体值)
let a = tool_call_id_or_fallback("", 0, "gen_tool");
assert!(a.starts_with("gen_tool_"), "空 fallback 应以 gen_tool_ 开头, got: {a}");
let b = tool_call_id_or_fallback("", 7, "gen_stream");
assert!(b.starts_with("gen_stream_"), "空 fallback 应以 gen_stream_ 开头, got: {b}");
}
#[test]
fn tool_call_id_or_fallback_empty_globally_unique() {
// 跨轮跨 assistant 唯一:连续两次空 fallback id 必不同(全局计数器递增)。
// 这是修复 af2fab4e 跨轮重复(旧单轮 index 跨轮都从 0 起 → 重复)的核心断言。
let a = tool_call_id_or_fallback("", 0, "gen_tool");
let b = tool_call_id_or_fallback("", 0, "gen_tool");
assert_ne!(a, b, "两次空 fallback 应不同(全局计数器跨轮唯一): {a} vs {b}");
// 即使同 index(模拟跨轮 index 都从 0 起),fallback 也必唯一
let c = tool_call_id_or_fallback("", 0, "gen_tool");
let mut set = std::collections::HashSet::new();
assert!(set.insert(a), "fallback a 应唯一");
assert!(set.insert(b), "fallback b 应唯一");
assert!(set.insert(c), "fallback c 应唯一");
}
#[test]
fn tool_call_id_or_fallback_index_unused() {
// index 参数仅为签名兼容保留(4 处调用点都传),fallback 不再使用 index。
// 同 prefix + 同 index 连续两次 → 不同 fallback(全局计数器递增,与 index 无关)。
let a = tool_call_id_or_fallback("", 3, "gen_tool");
let b = tool_call_id_or_fallback("", 3, "gen_tool");
assert_ne!(a, b, "同 index 两次空 fallback 应不同: {a} vs {b}");
}
#[test]
fn tool_call_id_or_fallback_prefix_distinguishes_sources() {
// 不同 prefix 区分来源(同步 gen_tool / 流式 gen_stream / anthropic gen_anthropic)
// 注意:两次空 fallback 因全局计数器递增 id 不同,故只比 prefix 前缀
let a = tool_call_id_or_fallback("", 0, "gen_tool");
let b = tool_call_id_or_fallback("", 0, "gen_stream");
assert!(a.starts_with("gen_tool_"));
assert!(b.starts_with("gen_stream_"));
}
} }
+161 -7
View File
@@ -43,7 +43,7 @@ pub struct CompletionRequest {
/// 多模态消息内容片。Text 片为字符串;Image 片可走 url 或 base64(二选一,base64 非空时 url 忽略)。 /// 多模态消息内容片。Text 片为字符串;Image 片可走 url 或 base64(二选一,base64 非空时 url 忽略)。
/// ///
/// F-260614-05 Phase 2a 后端:ContentPart 作为 `ChatMessage.parts` 的元素类型。 /// ContentPart 作为 `ChatMessage.parts` 的元素类型。
/// 设计上 `content: String`(纯文本主载荷)保持不变,多模态片挂在 `parts`: /// 设计上 `content: String`(纯文本主载荷)保持不变,多模态片挂在 `parts`:
/// 这样未接入多模态的调用方(audit/title/commands/knowledge_inject 等读 content 当字符串) /// 这样未接入多模态的调用方(audit/title/commands/knowledge_inject 等读 content 当字符串)
/// 零回归,避免一次性改全仓。provider 转换层在 `has_image()` 为真时把 parts 透传给 /// 零回归,避免一次性改全仓。provider 转换层在 `has_image()` 为真时把 parts 透传给
@@ -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`/
@@ -79,13 +95,18 @@ pub enum ContentPart {
pub struct ChatMessage { pub struct ChatMessage {
/// 消息全局唯一 ID。用于消息级溯源(source_ref / audit message_id / idea source)。 /// 消息全局唯一 ID。用于消息级溯源(source_ref / audit message_id / idea source)。
/// 构造时由 `new_message_id()` 生成;老 JSON 反序列化为 None(向前兼容)。 /// 构造时由 `new_message_id()` 生成;老 JSON 反序列化为 None(向前兼容)。
/// 消息拆分存储(F-260619-03)后,此 ID 即 `ai_messages.id` 列主键。 /// 消息拆分存储后,此 ID 即 `ai_messages.id` 列主键。
/// 临时/派生消息(如 title 摘要)可显式赋 None(不溯源不落库)。 /// 临时/派生消息(如 title 摘要)可显式赋 None(不溯源不落库)。
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>, pub id: Option<String>,
pub role: MessageRole, pub role: MessageRole,
pub content: String, pub content: String,
/// 多模态内容片(F-260614-05 Phase 2a)。 /// 消息来源(#958 溯源):消息从哪个渠道/端点进入会话。None=桌面本端(既有语义,
/// 老数据反序列化为 None 零回归);Some=外部渠道(飞书 message_id 可跳原消息追溯,
/// 多端混发可区分来源端)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<MessageSource>,
/// 多模态内容片。
/// ///
/// `None`/空 → 纯文本消息(绝大多数场景,content 即全部载荷)。 /// `None`/空 → 纯文本消息(绝大多数场景,content 即全部载荷)。
/// `Some(含 Image 片)` → 多模态消息,provider 在 `has_image()` 为真时把 parts /// `Some(含 Image 片)` → 多模态消息,provider 在 `has_image()` 为真时把 parts
@@ -103,11 +124,11 @@ pub struct ChatMessage {
/// 生成该消息的 model(仅 assistant 消息有,消息级 model 追溯) /// 生成该消息的 model(仅 assistant 消息有,消息级 model 追溯)
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>, pub model: Option<String>,
/// 消息状态(UX-09 编辑重生成):None/"active" 正常可见; /// 消息状态(UX-09 编辑重生成):None 正常可见;
/// "truncated" 软删(编辑某条 user 消息后其后续消息标记,保留 DB 可追溯但不进 LLM 上下文、前端视图过滤) /// "truncated" 软删(编辑某条 user 消息后其后续消息标记,保留 DB 可追溯但不进 LLM 上下文、前端视图过滤)
/// 默认 None(向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。 /// 默认 None(向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>, pub status: Option<MessageStatus>,
/// DeepSeek thinking 模式的推理内容(多轮需回传) /// DeepSeek thinking 模式的推理内容(多轮需回传)
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>, pub reasoning_content: Option<String>,
@@ -115,6 +136,33 @@ pub struct ChatMessage {
/// provider 请求映射不读此字段(构造器打戳→映射忽略,不进 LLM 请求),老数据反序列化为 None。 /// provider 请求映射不读此字段(构造器打戳→映射忽略,不进 LLM 请求),老数据反序列化为 None。
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub timestamp: Option<i64>, pub timestamp: Option<i64>,
/// 本轮 LLM 调用的输入 token 用量(仅 assistant 消息,消息级 token 持久化)。
/// 双轨:消息级(本字段,新)+ 会话级累计(ai_conversations.prompt_tokens,旧,保留)。
/// 用于 reload/压缩/切会话后历史 assistant 消息 token 显示;老 JSON 反序列化为 None(向前兼容)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_tokens: Option<u32>,
/// 本轮 LLM 调用的输出 token 用量(仅 assistant 消息,消息级 token 持久化)。
/// 语义同 prompt_tokens;provider 流式 usage 缺失时(GLM 等)可能为 0。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completion_tokens: Option<u32>,
/// 缓存命中 token(低价,deepseek prompt_cache_hit / anthropic cache_read)。
/// token 分项显示(2026-08-02):前端 in=miss(全价真实)+ cache=hit(命中) 分计费展示。
/// 老 JSON 反序列化为 None(向前兼容);非 cache provider 恒为 0。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_cache_hit_tokens: Option<u32>,
/// 未命中 token(全价真实输入,deepseek prompt_cache_miss / anthropic cache_creation)。
/// 前端 in 显示用此字段(非 prompt_tokens 总,避免掩盖命中比例)。
/// 老 JSON 反序列化为 None(向前兼容)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_cache_miss_tokens: Option<u32>,
/// 思考 token(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)。
/// 前端仅 > 0 时显示(reason 后缀);老 JSON 反序列化为 None。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_tokens: Option<u32>,
/// 本轮 prompt 是否估算值(round_usage.prompt_tokens==0 → estimated_prompt 兜底打标)。
/// 供 reload 逐条回显「估算」标注,对齐 live 态 AiCompleted.is_estimated;老消息 None(向前兼容)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_estimated: Option<bool>,
} }
/// 当前 Unix 毫秒(ChatMessage 打戳用;df-ai-core 不依赖 df-types,内联避免新增依赖)。 /// 当前 Unix 毫秒(ChatMessage 打戳用;df-ai-core 不依赖 df-types,内联避免新增依赖)。
@@ -148,11 +196,91 @@ pub enum MessageRole {
Tool, Tool,
} }
/// 消息状态枚举(IPC 边界序列化为小写 snake_case 字符串)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MessageStatus {
/// 正常可见
Active,
/// 软删(编辑某条 user 消息后其后续消息标记)
Truncated,
/// 已压缩
Compressed,
/// 已归档段
ArchivedSegment,
}
impl MessageStatus {
/// DB 存储用的小写 snake_case 字符串
pub fn as_db_str(&self) -> &'static str {
match self {
MessageStatus::Active => "active",
MessageStatus::Truncated => "truncated",
MessageStatus::Compressed => "compressed",
MessageStatus::ArchivedSegment => "archived_segment",
}
}
/// 从 DB 字符串解析
pub fn from_db_str(s: &str) -> Option<Self> {
Some(match s {
"active" => MessageStatus::Active,
"truncated" => MessageStatus::Truncated,
"compressed" => MessageStatus::Compressed,
"archived_segment" => MessageStatus::ArchivedSegment,
_ => return None,
})
}
}
/// ChatMessage 结构体
/// 工具类型(IPC 边界透明序列化为字符串,如 "function")
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(transparent)]
pub struct ToolType(String);
impl ToolType {
/// 构造新工具类型
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
}
impl std::fmt::Display for ToolType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<String> for ToolType {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for ToolType {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl PartialEq<&str> for ToolType {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
impl PartialEq<str> for ToolType {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
/// 工具定义 /// 工具定义
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDefinition { pub struct ToolDefinition {
#[serde(rename = "type")] #[serde(rename = "type")]
pub tool_type: String, pub tool_type: ToolType,
pub function: ToolFunction, pub function: ToolFunction,
} }
@@ -169,7 +297,7 @@ pub struct ToolFunction {
pub struct ToolCall { pub struct ToolCall {
pub id: String, pub id: String,
#[serde(rename = "type")] #[serde(rename = "type")]
pub call_type: String, pub call_type: ToolType,
pub function: ToolCallFunction, pub function: ToolCallFunction,
} }
@@ -198,11 +326,32 @@ pub struct CompletionResponse {
} }
/// Token 用量 /// Token 用量
///
/// 分项字段(token 分项显示 + 详情面板,2026-08-02):
/// - `prompt_tokens`:输入总(= cache_hit + cache_miss,兼容老链路保留;前端不再单独展示,
/// 改用 cache_miss 作 in 真实全价消耗)。
/// - `completion_tokens`:输出。
/// - `total_tokens`:总计(各 provider 计费不同,相加无意义,前端不显;保留供老链路/日志)。
/// - `prompt_cache_hit_tokens`:缓存命中(低价,deepseek/anthropic cache_read)。
/// - `prompt_cache_miss_tokens`:未命中(全价真实输入,deepseek/openai 扩展字段)。
/// - `reasoning_tokens`:思考(deepseek-reasoner/o1 隐藏输出 token)。
///
/// 不同 provider 字段名不同,serde default 兜底(无则 0),向前兼容老响应。
/// 构造点全用 `..Default::default()` 补缺,详见各 provider 解析点。
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TokenUsage { pub struct TokenUsage {
pub prompt_tokens: u32, pub prompt_tokens: u32,
pub completion_tokens: u32, pub completion_tokens: u32,
pub total_tokens: u32, pub total_tokens: u32,
/// 缓存命中 token(低价,deepseek prompt_cache_hit / anthropic cache_read_input_tokens)
#[serde(default)]
pub prompt_cache_hit_tokens: u32,
/// 未命中 token(全价真实输入,deepseek prompt_cache_miss / anthropic cache_creation)
#[serde(default)]
pub prompt_cache_miss_tokens: u32,
/// 思考 token(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)
#[serde(default)]
pub reasoning_tokens: u32,
} }
/// 流式输出的 chunk /// 流式输出的 chunk
@@ -222,6 +371,11 @@ pub struct StreamChunk {
/// 非空表示流中途出错,不应视为正常完成(finished 路径),由 stream_llm 转 AiError。 /// 非空表示流中途出错,不应视为正常完成(finished 路径),由 stream_llm 转 AiError。
#[serde(skip)] #[serde(skip)]
pub error: Option<String>, pub error: Option<String>,
/// 本流因 provider 输出上限截断收尾(openai finish_reason=length /
/// anthropic stop_reason=max_tokens)。finished 仍为 true(正常终止非断连),但文本/
/// tool_calls 可能不完整;消费方跨帧 OR 累积该标志,轮产出据此标记截断,不冒充正常完成。
#[serde(default)]
pub truncated_by_max_tokens: bool,
/// DeepSeek thinking 模式推理内容增量 /// DeepSeek thinking 模式推理内容增量
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>, pub reasoning_content: Option<String>,
+7 -2
View File
@@ -12,8 +12,13 @@ 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"] } reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"] }
bytes = "1"
futures = "0.3" futures = "0.3"
eventsource-stream = "0.2" rand = "0.8"
[dev-dependencies]
tempfile = "3"
+76
View File
@@ -0,0 +1,76 @@
//! 独立诊断:用 df-ai 真实调用 GLM anthropic 流式端点,验证
//! sse_parser + apply_anthropic_event + provider.stream() 整条链路。
//!
//! 二分定位「发消息卡掉」: 若本例能正常吐 chunk → provider 层(df-ai)OK,
//! 问题在 devflow 应用层(provider 配置/emit/前端); 若卡/空/Err → df-ai 有 bug。
//!
//! 运行: cd crates/df-ai && cargo run --example glm_stream_test
use df_ai::build_provider;
use df_ai_core::CompletionRequest;
use futures::StreamExt;
#[tokio::main]
async fn main() {
let token = std::env::var("ANTHROPIC_AUTH_TOKEN")
.or_else(|_| std::env::var("ANTHROPIC_API_KEY"))
.expect("需要环境变量 ANTHROPIC_AUTH_TOKEN");
eprintln!("[glm-test] token len={}", token.len());
let provider = build_provider(
"anthropic",
"https://open.bigmodel.cn/api/anthropic",
&token,
"glm-5.2",
);
// 用 JSON 反序列化构造请求,绕开字段列表(devflow 实际用 glm-5.2)
let req: CompletionRequest = serde_json::from_str(
r#"{"model":"glm-5.2","stream":true,"max_tokens":16,"messages":[{"role":"user","content":"说你好"}]}"#,
)
.expect("parse CompletionRequest");
eprintln!("[glm-test] 调用 provider.stream() ...");
let t0 = std::time::Instant::now();
let mut s = match provider.stream(req).await {
Ok(s) => {
eprintln!("[glm-test] stream() Ok, 建连耗时 {:?}", t0.elapsed());
s
}
Err(e) => {
eprintln!("[glm-test] stream() Err: {:#}", e);
return;
}
};
let mut n = 0;
let mut got_text = false;
while let Some(chunk_result) = s.next().await {
n += 1;
match chunk_result {
Ok(chunk) => {
if !chunk.delta.is_empty() {
got_text = true;
}
eprintln!(
"[glm-test] chunk#{} delta={:?} reasoning={:?} finished={} usage={:?} err={:?}",
n, chunk.delta,
chunk.reasoning_content.as_deref().map(|s| if s.len() > 30 { format!("{}..", &s[..30]) } else { s.to_string() }),
chunk.finished, chunk.usage, chunk.error
);
}
Err(e) => {
eprintln!("[glm-test] chunk#{} Err: {}", n, e);
}
}
if n > 60 {
eprintln!("[glm-test] 超 60 chunk 截断");
break;
}
}
eprintln!(
"[glm-test] 流结束 共 {} chunk, 是否拿到文本={}, 总耗时 {:?}",
n,
got_text,
t0.elapsed()
);
}
+35
View File
@@ -127,6 +127,41 @@
"weight": 65, "weight": 65,
"context_window": 128000 "context_window": 128000
}, },
{
"model_id": "sensenova-6.7-flash-lite",
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"weight": 85,
"context_window": 262144,
"temperature_supported": false
},
{
"model_id": "sensenova-6.8-flash-lite",
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"weight": 85,
"context_window": 262144,
"temperature_supported": false
},
{
"model_id": "sn2-kimi",
"enabled": true,
"modalities": ["text"],
"capabilities": ["tool_use"],
"weight": 80,
"context_window": 131072,
"temperature_supported": false
},
{
"model_id": "deepseek-v4-flash",
"enabled": true,
"modalities": ["text"],
"capabilities": ["tool_use"],
"weight": 80,
"context_window": 1048576
},
{ {
"model_id": "embedding-3", "model_id": "embedding-3",
"enabled": true, "enabled": true,
+527 -1
View File
@@ -8,6 +8,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::future::Future; use std::future::Future;
use std::pin::Pin; use std::pin::Pin;
use std::sync::{LazyLock, RwLock};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
@@ -40,10 +41,328 @@ pub struct AiTool {
pub definition: ToolDefinition, pub definition: ToolDefinition,
/// 风险级别 /// 风险级别
pub risk_level: RiskLevel, pub risk_level: RiskLevel,
/// 工具元数据(DRY 债 #5 收敛:注册点声明,消费方按条目或全局表查询,不再散布 match 表)
pub meta: ToolMeta,
/// 执行处理器 /// 执行处理器
pub handler: AiToolHandler, pub handler: AiToolHandler,
} }
// ============================================================
// 工具元数据(DRY 债 #5)
//
// 此前工具元数据以 5 处手写 match 表散布在调用侧:
// path_auth.rs(路径参数键) / agentic/helpers.rs 两处(目标描述 + 漂移签名) /
// audit/data_change.rs(entity-action) / audit/cache.rs(只读缓存白名单 + 目录级 key) /
// tool_registry.rs(审批文案模板 + 轻量动词标签),新增工具需人肉同步 5-6 处且已漂移。
// 收敛后元数据仅在 declare_tool! 注册点声明一次,上述消费方全部改查本表;
// 未注册工具走各消费方原保守 default(路径不校验/不缓存/不联动)。
//
// 维度按 5 处旧表的实际消费盘点,不为用不上的维度加字段:
// - path_args:路径授权预校验提取哪些参数键(rename_file 为 from+to,其余文件工具为 path)
// - readonly:只读幂等工具(结果可缓存)
// - cache_path_key:结果缓存 key 只取 path(目录级去重,同目录换递归参数也判重复)
// - deny_unauthorized:路径未授权时硬拒而非弹窗(search_files 盲搜语义应拒不应询)
// - entity_action:数据变更联动刷新的 (entity, action)
// - approval_hint:审批卡文案模板 + 关键参数键
// - display_verb:轻量中文动作标签
// - goal:从工具调用推理目标描述(动词 + 参数键 + 取值风格 + 缺参兜底文案)
// - sig:漂移检测签名取哪些参数组(组内多键为取值回退序,数值键按 JSON 形态序列化;
// 注册点未覆盖但换值即换操作的键由 SIG_EXTRA_GROUPS 补参,见 tool_signature)
// ============================================================
/// 取值风格(goal 维度):从参数值到展示文本的取法
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GoalStyle {
/// 取路径末段文件名(与旧实现一致按 / 切分)
FileName,
/// 原样
Raw,
/// 超 25 字节截断(字符边界回退,防多字节字符中间切 panic)
Truncate,
}
/// 目标描述元数据:infer_goal_from_tool_calls 消费
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GoalMeta {
/// 动词前缀(如 "修改"/"查看"/"搜索")
pub verb: &'static str,
/// 参数键,按序取第一个存在的键
pub keys: &'static [&'static str],
/// 取值风格
pub style: GoalStyle,
/// 键缺失/解析失败时的兜底文案(如 "搜索文件");None 表示返回 None
pub fallback: Option<&'static str>,
}
/// 签名取值类型:字符串键取原始串,数值键按 JSON 序列化形态
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SigValKind {
/// 字符串取值(as_str,缺失为空串)
Str,
/// Value::to_string 形态(数字 50 -> "50",布尔 true -> "true");组内全键参与归一化
/// (排序拼接,缺失键空占位)
Json,
}
/// 漂移检测签名元数据:tool_call_signature 消费
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SigMeta {
/// 有序参数组;组内多键:Str 组为取值回退序(如 pattern 缺失落 query),Json 组为
/// 全键参与(排序拼接,防同组其他键换值被压成同签名)
pub groups: &'static [(&'static [&'static str], SigValKind)],
}
/// 工具元数据(全部为编译期静态值,Copy 廉价传递)
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct ToolMeta {
/// 路径授权预校验的参数键;空 = 非文件工具(不预校验,保守 default)
pub path_args: &'static [&'static str],
/// 只读幂等工具(结果可安全缓存)
pub readonly: bool,
/// 结果缓存 key 只取 path(目录级去重)
pub cache_path_key: bool,
/// 路径未授权时硬拒(不弹授权窗)
pub deny_unauthorized: bool,
/// 数据变更联动 (entity, action)
pub entity_action: Option<(&'static str, &'static str)>,
/// 审批文案模板 + 关键参数键(模板 {} 按位置填充)
pub approval_hint: Option<(&'static str, &'static [&'static str])>,
/// 轻量中文动作标签
pub display_verb: Option<&'static str>,
/// 目标描述元数据
pub goal: Option<GoalMeta>,
/// 漂移检测签名元数据
pub sig: Option<SigMeta>,
}
/// 进程级工具元数据表。register_meta 写入(注册点 = 唯一写入口),
/// path_auth / cache / agentic helpers 等无 registry 句柄的自由函数按名查询。
static GLOBAL_TOOL_META: LazyLock<RwLock<HashMap<String, ToolMeta>>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
/// 按工具名查元数据(查全局表,未注册返回 None)
pub fn tool_meta(name: &str) -> Option<ToolMeta> {
GLOBAL_TOOL_META
.read()
.expect("全局工具元数据表锁")
.get(name)
.copied()
}
/// 全局元数据表是否为空(测试种子判定用;生产注册先于任何查询,表恒非空)
#[doc(hidden)]
pub fn meta_table_is_empty() -> bool {
GLOBAL_TOOL_META.read().expect("全局工具元数据表锁").is_empty()
}
/// 路径授权预校验参数键(未注册工具返回空切片,即非文件工具)
pub fn path_arg_keys(name: &str) -> &'static [&'static str] {
tool_meta(name).map(|m| m.path_args).unwrap_or(&[])
}
/// 是否只读幂等工具(未注册返回 false,即不缓存)
pub fn is_readonly(name: &str) -> bool {
tool_meta(name).map(|m| m.readonly).unwrap_or(false)
}
/// 结果缓存 key 是否只取 path(未注册返回 false)
pub fn cache_path_key_only(name: &str) -> bool {
tool_meta(name).map(|m| m.cache_path_key).unwrap_or(false)
}
/// 路径未授权是否硬拒(未注册返回 false,走弹窗)
pub fn denies_unauthorized(name: &str) -> bool {
tool_meta(name).map(|m| m.deny_unauthorized).unwrap_or(false)
}
/// 数据变更联动 (entity, action)(未注册返回 None,不 emit)
pub fn entity_action(name: &str) -> Option<(&'static str, &'static str)> {
tool_meta(name).and_then(|m| m.entity_action)
}
/// 审批文案模板(未注册返回 None,调用方走 fallback)
pub fn approval_hint(name: &str) -> Option<(&'static str, &'static [&'static str])> {
tool_meta(name).and_then(|m| m.approval_hint)
}
/// 轻量中文动作标签(未注册返回 None)
pub fn display_verb(name: &str) -> Option<&'static str> {
tool_meta(name).and_then(|m| m.display_verb)
}
/// 目标描述元数据(未注册返回 None)
pub fn goal_of(name: &str) -> Option<GoalMeta> {
tool_meta(name).and_then(|m| m.goal)
}
/// 漂移检测签名元数据(未注册返回 None,调用方走全参兜底)
pub fn sig_of(name: &str) -> Option<SigMeta> {
tool_meta(name).and_then(|m| m.sig)
}
/// 按路径参数键从 args 提取路径列表(键序即提取序,缺失键跳过)
pub fn extract_paths(keys: &'static [&'static str], args: &Value) -> Vec<String> {
keys.iter()
.filter_map(|k| args.get(*k).and_then(|x| x.as_str()).map(|s| s.to_string()))
.collect()
}
/// 由目标元数据渲染目标描述。args 为 None(调用参数 JSON 解析失败)时仅兜底文案可产出。
pub fn goal_text(goal: GoalMeta, args: Option<&Value>) -> Option<String> {
let value = args
.and_then(|a| goal.keys.iter().find_map(|k| a.get(k)))
.and_then(|v| v.as_str());
let rendered = value.map(|v| match goal.style {
GoalStyle::FileName => {
// 按旧实现仅按 / 取末段;rsplit 恒有首项,反斜杠路径在此保持原样(行为对齐)
let f = v.rsplit('/').next().unwrap_or(v);
format!("{} {}", goal.verb, f)
}
GoalStyle::Raw => format!("{} {}", goal.verb, v),
GoalStyle::Truncate => {
let mut end = 25.min(v.len());
while end > 0 && !v.is_char_boundary(end) {
end -= 1;
}
format!("{} {}", goal.verb, &v[..end])
}
});
rendered.or_else(|| goal.fallback.map(|f| f.to_string()))
}
/// 组渲染口径:Always = 基础组逐组渲染,缺键空值占位(消费方按固定串断言,形态不可漂移);
/// Present = 补参口径,只渲染 args 中实际出现的键,键未出现视同未换参,不渲染空占位。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GroupPresence {
Always,
Present,
}
/// 组渲染:每组产出 k=v 片段,组间逗号连接。Str 组标签恒为首键(与旧实现
/// pair("pattern", ...) 一致,取值来自回退键时标签不变),取组内首个字符串值键的值,
/// 全非字符串落末键。Json 组全键参与归一化:数值/布尔键换值即换操作,只取 keys[0] 会把
/// 同组其他键换值的不同调用压成同一签名(G2 熔断以签名为重复判据,压缩即误判重复),
/// 排序后拼接保证与键序无关的确定性。
fn render_groups(
groups: &'static [(&'static [&'static str], SigValKind)],
args: &Value,
presence: GroupPresence,
) -> String {
groups
.iter()
.filter_map(|(keys, kind)| match kind {
SigValKind::Json => {
let mut parts: Vec<String> = keys
.iter()
.copied()
.filter(|k| presence == GroupPresence::Always || args.get(*k).is_some())
.map(|k| match args.get(k) {
Some(v) => format!("{}={}", k, v.to_string()),
None => format!("{}=", k),
})
.collect();
parts.sort_unstable();
if parts.is_empty() {
None
} else {
Some(parts.join("|"))
}
}
SigValKind::Str => {
if presence == GroupPresence::Present {
// 补参组恒单键:键未出现整组跳过,值非字符串视同缺失
return args
.get(keys[0])
.and_then(|v| v.as_str())
.map(|s| format!("{}={}", keys[0], s));
}
let key = keys
.iter()
.find(|k| args.get(**k).and_then(|v| v.as_str()).is_some())
.unwrap_or(&keys[keys.len() - 1]);
Some(format!(
"{}={}",
keys[0],
args.get(*key).and_then(|v| v.as_str()).unwrap_or("")
))
}
})
.collect::<Vec<_>>()
.join(",")
}
/// 签名体:基础组按 Always 口径渲染。
pub fn sig_body(sig: SigMeta, args: &Value) -> String {
render_groups(sig.groups, args, GroupPresence::Always)
}
/// 签名补参表:注册点 SigMeta 组未覆盖、但「换值即换操作」的键,按工具名追加进签名。
///
/// 为什么在 df-ai 签名层:G2 熔断以签名为重复判据,签名过粗会把合法换参误判成
/// 「同一操作反复」(同工具换参是不同操作)。注册点 SigMeta 声明主参数组,换参敏感
/// 但未覆盖的键由本表统一补参;若日后注册点把这些键并入 groups,须同步从本表移除
/// (双源会重复渲染同键,不改变同异判定但产生冗余片段)。
///
/// 渲染走 Present 口径(键出现在 args 才进签名):补参键缺省 = 未换参,既有签名逐字
/// 不变。纯格式/开关类键(-n/-C/context_chars/timeout_secs)不补,变体不构成意图变化,
/// 补进签名只会稀释重复判据。
static SIG_EXTRA_GROUPS: &[(&str, &'static [(&'static [&'static str], SigValKind)])] = &[
// read_file:同 path 换 search 是换词定位,非重复读
("read_file", &[(&["search"], SigValKind::Str)]),
// search_files:换 recursive 换搜索范围,换 offset/limit 是翻页续搜
// (schema 无 -i/output_mode,大小写不敏感是内建语义非参数)
(
"search_files",
&[
(&["recursive"], SigValKind::Json),
(&["offset"], SigValKind::Json),
(&["limit"], SigValKind::Json),
],
),
// grep:换 -i 是敏感/不敏感两种匹配,换 output_mode 是行内容/文件名/计数三种形态,
// 换 max_results 是扩容重取(schema 无 offset/limit,数量参数实为 max_results)
(
"grep",
&[
(&["-i"], SigValKind::Json),
(&["output_mode"], SigValKind::Str),
(&["max_results"], SigValKind::Json),
],
),
// run_command:同命令不同 working_dir 是在不同目录执行
("run_command", &[(&["working_dir"], SigValKind::Str)]),
];
/// 按工具名取补参组(未列名返回 None)
fn sig_extra_groups(name: &str) -> Option<&'static [(&'static [&'static str], SigValKind)]> {
SIG_EXTRA_GROUPS
.iter()
.find(|(n, _)| *n == name)
.map(|(_, g)| *g)
}
/// 工具调用签名:有 SigMeta 元数据按组渲染并追加补参组,未注册回退「工具名 + 全参 JSON」
/// 保守兜底(全参已含补参键,无需追加)。
///
/// G2 判定语义:同工具同参连续 = 同签名,计重复;同工具换参 = 补参键进签名,签名不同
/// 不累计(正常探索)。
pub fn tool_signature(name: &str, args: &Value) -> String {
match sig_of(name) {
Some(sig) => {
let mut body = render_groups(sig.groups, args, GroupPresence::Always);
let extra = sig_extra_groups(name)
.map(|g| render_groups(g, args, GroupPresence::Present))
.unwrap_or_default();
if !extra.is_empty() {
body.push(',');
body.push_str(&extra);
}
format!("{}:{}", name, body)
}
None => format!("{}:name={},args={}", name, name, args.to_string()),
}
}
/// 工具处理器类型 — 异步函数,接收 JSON 参数,返回 JSON 结果 /// 工具处理器类型 — 异步函数,接收 JSON 参数,返回 JSON 结果
pub type AiToolHandler = pub type AiToolHandler =
Box<dyn Fn(Value) -> Pin<Box<dyn Future<Output = anyhow::Result<Value>> + Send>> + Send + Sync>; Box<dyn Fn(Value) -> Pin<Box<dyn Future<Output = anyhow::Result<Value>> + Send>> + Send + Sync>;
@@ -65,7 +384,7 @@ impl AiToolRegistry {
} }
} }
/// 注册一个工具 /// 注册一个工具(无元数据,ToolMeta::default)
pub fn register( pub fn register(
&mut self, &mut self,
name: impl Into<String>, name: impl Into<String>,
@@ -73,19 +392,47 @@ impl AiToolRegistry {
parameters: Value, parameters: Value,
risk_level: RiskLevel, risk_level: RiskLevel,
handler: AiToolHandler, handler: AiToolHandler,
) {
self.register_meta(name, description, parameters, risk_level, ToolMeta::default(), handler);
}
/// 注册一个工具并写入元数据。
///
/// 元数据在同一调用点写入两处,无双源漂移:
/// - 注册表条目 AiTool.meta(持有 registry 实例的消费方查询)
/// - 进程级 GLOBAL_TOOL_META 表(path_auth/cache/agentic helpers 等无
/// registry 句柄的自由函数按工具名查询)
pub fn register_meta(
&mut self,
name: impl Into<String>,
description: impl Into<String>,
parameters: Value,
risk_level: RiskLevel,
meta: ToolMeta,
handler: AiToolHandler,
) { ) {
let name_str = name.into(); let name_str = name.into();
let definition = ToolDefinition::function(&name_str, description, parameters); let definition = ToolDefinition::function(&name_str, description, parameters);
GLOBAL_TOOL_META
.write()
.expect("全局工具元数据表锁")
.insert(name_str.clone(), meta);
self.tools.insert( self.tools.insert(
name_str, name_str,
AiTool { AiTool {
definition, definition,
risk_level, risk_level,
meta,
handler, handler,
}, },
); );
} }
/// 查询工具元数据条目(持有 registry 实例的消费方用)
pub fn meta(&self, name: &str) -> Option<ToolMeta> {
self.tools.get(name).map(|t| t.meta)
}
/// 获取所有工具定义(发送给 LLM 的 tools 参数) /// 获取所有工具定义(发送给 LLM 的 tools 参数)
pub fn tool_definitions(&self) -> Vec<ToolDefinition> { pub fn tool_definitions(&self) -> Vec<ToolDefinition> {
self.tools.values().map(|t| t.definition.clone()).collect() self.tools.values().map(|t| t.definition.clone()).collect()
@@ -236,4 +583,183 @@ mod tests {
assert_eq!(required.len(), 1); assert_eq!(required.len(), 1);
assert_eq!(required[0], "name"); assert_eq!(required[0], "name");
} }
/// register_meta:条目与全局表同步可查,未注册名走保守 default。
#[test]
fn register_meta_stores_entry_and_global() {
let mut reg = AiToolRegistry::new();
let meta = ToolMeta {
path_args: &["from", "to"],
readonly: true,
entity_action: Some(("task", "update")),
..Default::default()
};
reg.register_meta("meta_probe", "测试", object_schema(vec![]), RiskLevel::Low, meta, dummy_handler());
assert_eq!(reg.meta("meta_probe"), Some(meta));
assert_eq!(tool_meta("meta_probe"), Some(meta));
assert_eq!(path_arg_keys("meta_probe"), &["from", "to"]);
assert!(is_readonly("meta_probe"));
assert_eq!(entity_action("meta_probe"), Some(("task", "update")));
// 未注册名保守 default:无路径键/非只读/无联动
assert_eq!(path_arg_keys("never_registered"), &[] as &[&str]);
assert!(!is_readonly("never_registered"));
assert_eq!(entity_action("never_registered"), None);
// 老签名 register 等价 default 元数据
reg.register("plain_probe", "测试", object_schema(vec![]), RiskLevel::Low, dummy_handler());
assert_eq!(reg.meta("plain_probe"), Some(ToolMeta::default()));
}
/// goal_text 三种取值风格 + 键回退 + 兜底文案。
#[test]
fn goal_text_styles_and_fallback() {
use serde_json::json;
let file_name = GoalMeta { verb: "查看", keys: &["path"], style: GoalStyle::FileName, fallback: None };
assert_eq!(goal_text(file_name, Some(&json!({"path": "a/b/c.rs"}))), Some("查看 c.rs".to_string()));
let raw = GoalMeta { verb: "浏览", keys: &["path"], style: GoalStyle::Raw, fallback: None };
assert_eq!(goal_text(raw, Some(&json!({"path": "x/y"}))), Some("浏览 x/y".to_string()));
// Truncate:ASCII 超 25 字节截 25;多字节字符回退到字符边界不 panic
let trunc = GoalMeta { verb: "执行", keys: &["command"], style: GoalStyle::Truncate, fallback: Some("执行命令") };
let long = json!({"command": "a".repeat(40)});
assert_eq!(goal_text(trunc, Some(&long)), Some(format!("执行 {}", "a".repeat(25))));
let multibyte = json!({"command": "构建数据库连接池"});
let out = goal_text(trunc, Some(&multibyte)).expect("多字节应正常产出");
assert!(out.starts_with("执行 构建数据库"));
// 键缺失走兜底;无兜底返 None
assert_eq!(goal_text(trunc, Some(&json!({}))), Some("执行命令".to_string()));
assert_eq!(goal_text(file_name, Some(&json!({}))), None);
assert_eq!(goal_text(file_name, None), None);
// 多键回退序:首键缺失取次键
let multi = GoalMeta { verb: "搜索", keys: &["pattern", "query"], style: GoalStyle::Raw, fallback: Some("搜索文件") };
assert_eq!(goal_text(multi, Some(&json!({"query": "todo"}))), Some("搜索 todo".to_string()));
}
/// sig_body:组内字符串键回退 + Json 数值键形态 + 缺省空串。
#[test]
fn sig_body_groups_and_kinds() {
use serde_json::json;
let grep_sig = SigMeta { groups: &[(&["pattern", "query"], SigValKind::Str), (&["path", "glob"], SigValKind::Str)] };
assert_eq!(sig_body(grep_sig, &json!({"pattern": "MAX", "path": "src"})), "pattern=MAX,path=src");
assert_eq!(sig_body(grep_sig, &json!({"query": "TODO", "glob": "*.rs"})), "pattern=TODO,path=*.rs");
assert_eq!(sig_body(grep_sig, &json!({})), "pattern=,path=");
let read_sig = SigMeta { groups: &[(&["path"], SigValKind::Str), (&["offset"], SigValKind::Json), (&["limit"], SigValKind::Json)] };
assert_eq!(sig_body(read_sig, &json!({"path": "a", "offset": 50})), "path=a,offset=50,limit=");
// 未注册工具签名回退全参 JSON
let sig = tool_signature("custom_tool", &json!({"x": 1}));
assert!(sig.starts_with("custom_tool:name=custom_tool,args="));
}
/// Json 组全键参与:同组多键排序拼接(与 args 键序无关),任一键换值即换片段。
#[test]
fn sig_body_json_group_includes_all_keys() {
use serde_json::json;
let page = SigMeta { groups: &[(&["offset", "limit"], SigValKind::Json)] };
// 排序拼接:limit < offset 字典序,与 args 内键书写顺序无关
assert_eq!(sig_body(page, &json!({"offset": 50, "limit": 100})), "limit=100|offset=50");
assert_eq!(sig_body(page, &json!({"limit": 100, "offset": 50})), "limit=100|offset=50");
// 任一键换值 → 不同片段
assert_ne!(
sig_body(page, &json!({"offset": 50, "limit": 100})),
sig_body(page, &json!({"offset": 60, "limit": 100}))
);
// 全缺失逐键空占位(既有缺省口径);布尔键按 JSON 形态
assert_eq!(sig_body(page, &json!({})), "limit=|offset=");
let flag = SigMeta { groups: &[(&["-i"], SigValKind::Json)] };
assert_eq!(sig_body(flag, &json!({"-i": true})), "-i=true");
}
/// 注册与生产同形态的探针工具。df-ai 单测进程 GLOBAL_TOOL_META 无生产注册,
/// 不种入则 tool_signature 走未注册兜底,测不到「注册点组 + 补参」合流分支。
/// 进程级表跨测试共享,各处注册值恒相同,幂等无顺序依赖。
fn seed_signature_probe_tools() {
let read_sig = SigMeta {
groups: &[
(&["path"], SigValKind::Str),
(&["offset"], SigValKind::Json),
(&["limit"], SigValKind::Json),
],
};
let search_sig = SigMeta {
groups: &[
(&["pattern", "query"], SigValKind::Str),
(&["path", "glob"], SigValKind::Str),
],
};
let cmd_sig = SigMeta { groups: &[(&["command"], SigValKind::Str)] };
let mut reg = AiToolRegistry::new();
reg.register_meta("read_file", "读取", object_schema(vec![]), RiskLevel::Low,
ToolMeta { sig: Some(read_sig), ..Default::default() }, dummy_handler());
reg.register_meta("search_files", "搜索文件", object_schema(vec![]), RiskLevel::Low,
ToolMeta { sig: Some(search_sig), ..Default::default() }, dummy_handler());
reg.register_meta("grep", "跨文件内容搜索", object_schema(vec![]), RiskLevel::Low,
ToolMeta { sig: Some(search_sig), ..Default::default() }, dummy_handler());
reg.register_meta("run_command", "执行命令", object_schema(vec![]), RiskLevel::High,
ToolMeta { sig: Some(cmd_sig), ..Default::default() }, dummy_handler());
}
/// 补参:合法换参 → 签名不同(G2 目标语义:换参不共享签名,不再误判重复);
/// 同参 → 签名相同(重复判据正例仍成立)。
#[test]
fn tool_signature_extra_params_distinguish_calls() {
use serde_json::json;
seed_signature_probe_tools();
// read_file 同 path 换 search:不同签名
let s1 = tool_signature("read_file", &json!({"path": "a.rs", "search": "fn sig"}));
let s2 = tool_signature("read_file", &json!({"path": "a.rs", "search": "struct cfg"}));
assert_ne!(s1, s2, "read_file 换 search 应不同签名: {} vs {}", s1, s2);
assert!(s1.contains("search=fn sig"), "read_file 签名应含 search: {}", s1);
// search_files 只差 offset(翻页续搜):不同签名
let s1 = tool_signature("search_files", &json!({"pattern": "todo", "path": "src", "offset": 0}));
let s2 = tool_signature("search_files", &json!({"pattern": "todo", "path": "src", "offset": 50}));
assert_ne!(s1, s2, "search_files 换 offset 应不同签名: {} vs {}", s1, s2);
// grep 只差 -i / output_mode / max_results(schema 无 offset/limit,数量参数实为
// max_results):换匹配方式/输出形态/扩容重取都是不同操作
let base = tool_signature("grep", &json!({"pattern": "err", "path": "src"}));
let ci = tool_signature("grep", &json!({"pattern": "err", "path": "src", "-i": true}));
let mode = tool_signature("grep", &json!({"pattern": "err", "path": "src", "output_mode": "files_with_matches"}));
let capped = tool_signature("grep", &json!({"pattern": "err", "path": "src", "max_results": 200}));
assert_ne!(base, ci, "grep 换 -i 应不同签名: {} vs {}", base, ci);
assert_ne!(base, mode, "grep 换 output_mode 应不同签名: {} vs {}", base, mode);
assert_ne!(base, capped, "grep 换 max_results 应不同签名: {} vs {}", base, capped);
// run_command 同命令换 working_dir:不同签名
let s1 = tool_signature("run_command", &json!({"command": "cargo test"}));
let s2 = tool_signature("run_command", &json!({"command": "cargo test", "working_dir": "crates/df-ai"}));
assert_ne!(s1, s2, "run_command 换 working_dir 应不同签名: {} vs {}", s1, s2);
// 同工具同参:签名相同(同签名连续才算 G2 重复)
assert_eq!(
tool_signature("grep", &json!({"pattern": "err", "path": "src"})),
base
);
}
/// 补参键缺席时签名与既有格式逐字一致(向后兼容:消费方按固定串断言,空补参不占位)。
#[test]
fn tool_signature_without_extra_keys_unchanged() {
use serde_json::json;
seed_signature_probe_tools();
assert_eq!(
tool_signature("read_file", &json!({"path": "big.log", "offset": 50, "limit": 100})),
"read_file:path=big.log,offset=50,limit=100"
);
assert_eq!(
tool_signature("read_file", &json!({})),
"read_file:path=,offset=,limit="
);
assert_eq!(
tool_signature("grep", &json!({"pattern": "MAX", "path": "src", "case_sensitive": true})),
"grep:pattern=MAX,path=src"
);
assert_eq!(
tool_signature("search_files", &json!({"query": "TODO", "glob": "**/*.rs"})),
"search_files:pattern=TODO,path=**/*.rs"
);
assert_eq!(
tool_signature("run_command", &json!({"command": "ls -la", "timeout": 5000})),
"run_command:command=ls -la"
);
}
} }
+219
View File
@@ -0,0 +1,219 @@
//! 声明式工具注册基础设施(tool_registry 拆分第一步)
//!
//! 背景:`tool_registry.rs` 4224 行,每加一个工具手写一段
//! `registry.register(name, desc, object_schema(...), RiskLevel::X, { ... Box::new ... })`,
//! 5 段重复样板 + 闭包捕获 + 缩进极易出错。本模块提供声明式宏 `declare_tool!`,
//! 把工具定义收敛为「名字 / 描述 / schema / 风险 / handler 块」五要素一行式声明。
//!
//! 设计取舍(三选一权衡):
//! - A. proc-macro `#[ai_tool]` 属性宏:需新建 proc-macro crate + syn/quote 重依赖,违反
//! 「不引新依赖」。否决。
//! - B. `inventory` crate 自动收集:新外部依赖 + 拉入 linkme/ctor 运行期注册语义,
//! 与现有 `register_*(&mut registry)` 命令式收集并存需双重 source-of-truth。否决。
//! - C. **`macro_rules!` 声明式宏(本方案)**:零新依赖(纯 std `macro_rules!`),
//! 展开为等价的 `registry.register(...)` 调用——与现有 48 个 `register` 调用**完全并存**,
//! 旧工具零改动,新工具可选声明式。handler 块就地书写,`$db`/`$registry` 等捕获变量
//! 原样透传,语义 1:1 等价(schema/risk/handler 同源不变)。
//!
//! 试点:`list_projects` 已迁至声明式(见 commands/ai/tools/list_projects.rs),验证编译过 +
//! 行为等价 + 48 工具基线不破。其余 47 个工具后续批次渐进迁移,不在此步。
//!
//! 宏展开示例(输入):
//! ```ignore
//! declare_tool!(registry, db: Arc<Database>, "list_projects",
//! "列出所有项目...", RiskLevel::Low,
//! schema: object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]),
//! args => {
//! let repo = ProjectRepo::new(&db);
//! let items = repo.list_active().await?;
//! // ...
//! Ok(json!({ "items": items, ... }))
//! });
//! ```
//! 展开后等价于现有手写的:
//! ```ignore
//! registry.register(
//! "list_projects", "...",
//! object_schema(vec![...]), RiskLevel::Low,
//! { let db = db.clone(); Box::new(move |args: serde_json::Value| {
//! let db = db.clone();
//! Box::pin(async move { /* handler body */ })
//! })},
//! );
//! ```
/// 声明式注册一个 AI 工具到 `$registry`。
///
/// 与手写 `registry.register(name, desc, schema, risk, handler)` 语义 1:1 等价,
/// 仅消除闭包包装样板(`{ let db = db.clone(); Box::new(move |args| { let db = db.clone(); Box::pin(async move { ... }) }) }`)。
///
/// 形参:
/// - `$registry`: `&mut AiToolRegistry` 注册表句柄。
/// - `$capture: $cap_ty`: handler 需捕获的外部变量(如 `db: Arc<Database>`)。宏自动 clone 进闭包,
/// handler body 内以 `$capture` 名访问。无捕获工具用 `_` 占位(并保证 body 不引用它)。
/// - `$name`: 工具名 `&str`。
/// - `$desc`: 工具描述 `&str`(发给 LLM)。
/// - `$risk`: `RiskLevel`(如 `RiskLevel::Low`)。
/// - `meta: $meta`(可选段): `ToolMeta` 字面量(DRY 债 #5)。带此段走 `register_meta` 落
/// 条目 + 全局元数据表;不带则 default 元数据(全空,消费方走保守 default 分支)。
/// - `$schema`: 参数 JSON Schema(`serde_json::Value`),常用 `object_schema(...)`。
/// - `$handler`: 一个 **block 表达式**(花括号体),返回 `anyhow::Result<serde_json::Value>`。
/// 体内在 `$args`(serde_json::Value)与 `$capture`(克隆后的捕获变量)上工作。
/// 宏负责把它包进 `async move { ... }` 并 `Box::pin`——故调用方只写「同步语义的体」,
/// 不写 `async move`/`Box::pin`/`Box::new` 三层样板。
///
/// 注意:此宏不替代 `register`,而是包装它——`register`/`register_meta` 仍是
/// `AiToolRegistry` 的唯一注册入口,宏仅是语法糖。
#[macro_export]
macro_rules! declare_tool {
(
$registry:expr,
$capture:ident : $cap_ty:ty,
$name:expr,
$desc:expr,
$risk:expr,
meta: $meta:expr,
schema: $schema:expr,
$args:ident => $handler:block
) => {{
let __cap: $cap_ty = $capture.clone();
$registry.register_meta(
$name, $desc, $schema, $risk, $meta,
{
let $capture = __cap.clone();
::std::boxed::Box::new(move |$args: ::serde_json::Value| {
let $capture = $capture.clone();
::std::boxed::Box::pin(async move {
let $capture: $cap_ty = $capture;
$handler
})
as ::std::pin::Pin<::std::boxed::Box<
dyn ::std::future::Future<Output = ::anyhow::Result<::serde_json::Value>>
+ ::std::marker::Send,
>>
})
},
);
}};
(
$registry:expr,
$capture:ident : $cap_ty:ty,
$name:expr,
$desc:expr,
$risk:expr,
schema: $schema:expr,
$args:ident => $handler:block
) => {{
let __cap: $cap_ty = $capture.clone();
$registry.register_meta(
$name, $desc, $schema, $risk, $crate::ai_tools::ToolMeta::default(),
{
let $capture = __cap.clone();
::std::boxed::Box::new(move |$args: ::serde_json::Value| {
let $capture = $capture.clone();
::std::boxed::Box::pin(async move {
let $capture: $cap_ty = $capture;
$handler
})
as ::std::pin::Pin<::std::boxed::Box<
dyn ::std::future::Future<Output = ::anyhow::Result<::serde_json::Value>>
+ ::std::marker::Send,
>>
})
},
);
}};
}
#[cfg(test)]
mod tests {
use crate::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
/// 声明式宏注册的工具与手写 register 行为等价(name/schema/risk/执行结果一致)。
/// 这是「现有工具零回归 + 语义等价」的最小验证(试点 list_projects 迁移的微观镜像)。
#[tokio::test]
async fn declare_tool_equivalent_to_register() {
let mut reg = AiToolRegistry::new();
// 声明式注册(新基础设施)
let counter: std::sync::Arc<std::sync::atomic::AtomicUsize> =
std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
declare_tool!(
reg,
counter: std::sync::Arc<std::sync::atomic::AtomicUsize>,
"echo_decl",
"声明式 echo 工具",
RiskLevel::Low,
schema: object_schema(vec![("msg", "string", true)]),
args => {
counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let msg = args["msg"].as_str().unwrap_or("");
Ok(serde_json::json!({ "echo": msg }))
}
);
// 等价断言:定义层(name 存在 / risk 正确 / schema 透传)
assert_eq!(reg.len(), 1, "声明式宏应注册 1 个工具");
let tool = reg.get("echo_decl").expect("echo_decl 应已注册");
assert_eq!(tool.risk_level, RiskLevel::Low);
assert_eq!(tool.definition.function.name, "echo_decl");
assert_eq!(tool.definition.function.parameters["properties"]["msg"]["type"], "string");
// 执行等价:handler 收到 args、捕获变量可访问、返回 handler body 的 Value
let out = reg.execute("echo_decl", serde_json::json!({ "msg": "hi" })).await.unwrap();
assert_eq!(out["echo"], "hi");
assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 1, "捕获变量应在执行时递增");
}
/// 无捕获工具(纯计算/常量返回)用占位 capture,handler 不引用它。
#[tokio::test]
async fn declare_tool_no_capture() {
let mut reg = AiToolRegistry::new();
let dummy: std::sync::Arc<()> = std::sync::Arc::new(()); // 无真实捕获,占位
declare_tool!(
reg,
dummy: std::sync::Arc<()>,
"const_tool",
"无捕获常量工具",
RiskLevel::Medium,
schema: object_schema(vec![]),
_args => { Ok(serde_json::json!({ "ok": true })) }
);
let out = reg.execute("const_tool", serde_json::json!({})).await.unwrap();
assert_eq!(out["ok"], true);
}
/// 带 meta 段的宏形态:元数据落条目 + 全局表;不带 meta 段落 default。
#[tokio::test]
async fn declare_tool_with_and_without_meta() {
use crate::ai_tools::ToolMeta;
let mut reg = AiToolRegistry::new();
let dummy: std::sync::Arc<()> = std::sync::Arc::new(());
declare_tool!(
reg,
dummy: std::sync::Arc<()>,
"meta_variant_tool",
"带元数据声明的工具",
RiskLevel::Low,
meta: ToolMeta { path_args: &["path"], readonly: true, ..Default::default() },
schema: object_schema(vec![]),
_args => { Ok(serde_json::json!({ "ok": true })) }
);
declare_tool!(
reg,
dummy: std::sync::Arc<()>,
"plain_variant_tool",
"不带元数据声明的工具",
RiskLevel::Low,
schema: object_schema(vec![]),
_args => { Ok(serde_json::json!({ "ok": true })) }
);
let with = reg.meta("meta_variant_tool").expect("meta 变体应已注册");
assert_eq!(with.path_args, &["path"]);
assert!(with.readonly);
assert_eq!(crate::ai_tools::path_arg_keys("meta_variant_tool"), &["path"]);
let plain = reg.meta("plain_variant_tool").expect("无 meta 变体应已注册");
assert_eq!(plain, ToolMeta::default());
assert_eq!(crate::ai_tools::path_arg_keys("plain_variant_tool"), &[] as &[&str]);
}
}
File diff suppressed because it is too large Load Diff
+89 -26
View File
@@ -12,12 +12,26 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{error, warn}; use tracing::{error, warn};
use crate::provider::{StreamChunk, TokenUsage, ToolCallDelta}; use crate::provider::{tool_call_id_or_fallback, StreamChunk, TokenUsage, ToolCallDelta};
// ============================================================ // ============================================================
// Anthropic API 请求/响应结构体 // Anthropic API 请求/响应结构体
// ============================================================ // ============================================================
/// Anthropic `system` 字段形态(受 prompt caching 开关 `ANTHROPIC_CACHE_ENABLED` 控制):
/// - `Plain`:纯字符串。开关关时保持原形态,兼容非官方网关(GLM 订阅端点 / 任意 Messages API
/// 代理——部分网关会拒收 cache_control 字段)。
/// - `Cached`:blocks 数组 `[{type:text, text, cache_control:{type:ephemeral}}]`。开关开时启用
/// Anthropic prompt caching,让 system 稳定段在支持缓存的端点上命中。
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub(crate) enum SystemBlock {
/// 纯字符串形态(兼容网关,开关关)
Plain(String),
/// blocks 数组形态(含 cache_control,开关开)
Cached(Vec<serde_json::Value>),
}
/// Anthropic 请求体 /// Anthropic 请求体
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub(crate) struct AnthropicRequest { pub(crate) struct AnthropicRequest {
@@ -25,7 +39,7 @@ pub(crate) struct AnthropicRequest {
pub messages: Vec<serde_json::Value>, pub messages: Vec<serde_json::Value>,
pub max_tokens: u32, pub max_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub system: Option<String>, pub system: Option<SystemBlock>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>, pub temperature: Option<f32>,
pub stream: bool, pub stream: bool,
@@ -47,24 +61,27 @@ pub(crate) struct AnthropicToolDef {
/// Anthropic 同步响应 /// Anthropic 同步响应
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct AnthropicResponse { pub(crate) struct AnthropicResponse {
// SW-260618-25: Anthropic 响应反序列化字段,保留以对齐响应结构(响应 id),标注意图消除 dead_code warning // Anthropic 响应反序列化字段,保留以对齐响应结构(响应 id),标注意图消除 dead_code warning
#[allow(dead_code)] #[allow(dead_code)]
id: String, id: String,
pub model: String, pub model: String,
pub content: Vec<AnthropicContentBlock>, pub content: Vec<AnthropicContentBlock>,
// SW-260618-25: Anthropic 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning // Anthropic 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning
#[allow(dead_code)] #[allow(dead_code)]
stop_reason: Option<String>, stop_reason: Option<String>,
pub usage: AnthropicUsage, pub usage: AnthropicUsage,
} }
/// 响应 content 块(text 或 tool_use) /// 响应 content 块(text / tool_use / thinking)
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct AnthropicContentBlock { pub(crate) struct AnthropicContentBlock {
#[serde(rename = "type")] #[serde(rename = "type")]
pub block_type: String, pub block_type: String,
#[serde(default)] #[serde(default)]
pub text: Option<String>, pub text: Option<String>,
/// thinking 块推理正文(端点侧开启扩展思考时出现;本侧请求未启用 thinking,一般缺省)
#[serde(default)]
pub thinking: Option<String>,
/// tool_use 块字段 /// tool_use 块字段
pub id: Option<String>, pub id: Option<String>,
pub name: Option<String>, pub name: Option<String>,
@@ -75,6 +92,16 @@ pub(crate) struct AnthropicContentBlock {
pub(crate) struct AnthropicUsage { pub(crate) struct AnthropicUsage {
pub input_tokens: u32, pub input_tokens: u32,
pub output_tokens: u32, pub output_tokens: u32,
/// Anthropic prompt caching 扩展:cache 写入 token(本次写入缓存,计费如输入但稍便宜)。
/// 映射到 TokenUsage.prompt_cache_miss_tokens(全价输入语义)。
/// 非 cache 场景无此字段 → serde default 0。
#[serde(default)]
pub cache_creation_input_tokens: u32,
/// Anthropic prompt caching 扩展:cache 命中读取 token(低价)。
/// 映射到 TokenUsage.prompt_cache_hit_tokens。
/// 非 cache 场景无此字段 → serde default 0。
#[serde(default)]
pub cache_read_input_tokens: u32,
} }
// ============================================================ // ============================================================
@@ -95,7 +122,8 @@ pub(crate) const DEFAULT_MAX_TOKENS: u32 = 4096;
/// 按 `type` 字段分发: /// 按 `type` 字段分发:
/// - `message_start` → 用 `message.usage.input_tokens` 初始化累加器(output 置 0)。 /// - `message_start` → 用 `message.usage.input_tokens` 初始化累加器(output 置 0)。
/// - `message_delta` → **output_tokens 是累计值(非增量)**,直接覆盖 `completion_tokens` 并重算 `total`。 /// - `message_delta` → **output_tokens 是累计值(非增量)**,直接覆盖 `completion_tokens` 并重算 `total`。
/// - `content_block_delta` (text_delta/input_json_delta) → 文本/工具入参增量。 /// - `content_block_delta` (text_delta/input_json_delta/thinking_delta) → 文本/工具入参/思考增量,
/// signature_delta 安静忽略。
/// - `content_block_start` (tool_use) → 工具块开始,带 id+name。 /// - `content_block_start` (tool_use) → 工具块开始,带 id+name。
/// - `message_stop` → 返回 `finished=true` 终态 chunk,`usage` 取自累加器(`take()`)。 /// - `message_stop` → 返回 `finished=true` 终态 chunk,`usage` 取自累加器(`take()`)。
/// - `error` → 返回 `finished=true` 终态空 chunk。 /// - `error` → 返回 `finished=true` 终态空 chunk。
@@ -109,12 +137,13 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
let v: serde_json::Value = match serde_json::from_str(data) { let v: serde_json::Value = match serde_json::from_str(data) {
Ok(v) => v, Ok(v) => v,
Err(_) => { Err(_) => {
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None } return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false }
} }
}; };
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or(""); let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
match ty { match ty {
// 消息开始:取 input_tokens 初始化累积器(output 此时未知,置 0) // 消息开始:取 input_tokens 初始化累积器(output 此时未知,置 0)。
// anthropic prompt caching:cache_creation/read 在 message_start.usage 携带。
"message_start" => { "message_start" => {
if let Some(inp) = v if let Some(inp) = v
.get("message") .get("message")
@@ -122,30 +151,58 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
.and_then(|u| u.get("input_tokens")) .and_then(|u| u.get("input_tokens"))
.and_then(|t| t.as_u64()) .and_then(|t| t.as_u64())
{ {
let u_obj = v.get("message").and_then(|m| m.get("usage"));
let cache_read = u_obj
.and_then(|u| u.get("cache_read_input_tokens"))
.and_then(|t| t.as_u64())
.unwrap_or(0) as u32;
let cache_creation = u_obj
.and_then(|u| u.get("cache_creation_input_tokens"))
.and_then(|t| t.as_u64())
.unwrap_or(0) as u32;
*usage_accum = Some(TokenUsage { *usage_accum = Some(TokenUsage {
prompt_tokens: inp as u32, prompt_tokens: inp as u32,
completion_tokens: 0, completion_tokens: 0,
total_tokens: inp as u32, total_tokens: inp as u32,
prompt_cache_hit_tokens: cache_read,
prompt_cache_miss_tokens: cache_creation,
reasoning_tokens: 0,
}); });
} }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None } StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: usage_accum.clone(), error: None, reasoning_content: None, truncated_by_max_tokens: false }
} }
// 消息增量:output_tokens 是累计值(非增量),直接覆盖 completion + 重算 total // 消息增量:output_tokens 是累计值(非增量),直接覆盖 completion + 重算 total
"message_delta" => { "message_delta" => {
if let Some(out) = v.get("usage").and_then(|u| u.get("output_tokens")).and_then(|t| t.as_u64()) { if let Some(out) = v.get("usage").and_then(|u| u.get("output_tokens")).and_then(|t| t.as_u64()) {
let acc = usage_accum let acc = usage_accum
.get_or_insert(TokenUsage { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }); .get_or_insert(TokenUsage::default());
acc.completion_tokens = out as u32; acc.completion_tokens = out as u32;
acc.total_tokens = acc.prompt_tokens + acc.completion_tokens; acc.total_tokens = acc.prompt_tokens.saturating_add(acc.completion_tokens);
} }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None } // stop_reason=max_tokens(message_delta 帧携带):输出被 max_tokens 截断。
// 终态 finished 在后续 message_stop 帧,截断标志挂本帧,消费方跨帧 OR 累积。
let truncated_by_max_tokens = v.get("delta").and_then(|d| d.get("stop_reason")).and_then(|s| s.as_str()) == Some("max_tokens");
// 加固:usage 挂到本帧(而非仅 message_stop 带出),中途断连/端点不发 message_stop
// 时仍能拿到真实 usage,对称 openai_helpers 的修复。
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: usage_accum.clone(), error: None, reasoning_content: None, truncated_by_max_tokens }
} }
// 文本增量 // 文本增量
"content_block_delta" => { "content_block_delta" => {
if let Some(delta) = v.get("delta") { if let Some(delta) = v.get("delta") {
// thinking 增量:推理正文走 reasoning_content 通道(与 DeepSeek 同路入库展示),
// 不混入正文 delta。本侧请求未启用 thinking,此分支只为不丢端点侧自行产出的思考。
if delta.get("type").and_then(|t| t.as_str()) == Some("thinking_delta") {
let text = delta.get("thinking").and_then(|t| t.as_str()).unwrap_or("").to_string();
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: Some(text), truncated_by_max_tokens: false };
}
// signature_delta:thinking 块签名字段,仅回传校验用。DevFlow 从不把 thinking 回传
// anthropic 历史(丢弃 signature 后伪造回传必 400),签名无处消费,安静忽略。
if delta.get("type").and_then(|t| t.as_str()) == Some("signature_delta") {
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false };
}
if delta.get("type").and_then(|t| t.as_str()) == Some("text_delta") { if delta.get("type").and_then(|t| t.as_str()) == Some("text_delta") {
let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string(); let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string();
return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }; return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false };
} }
// 工具入参增量 // 工具入参增量
if delta.get("type").and_then(|t| t.as_str()) == Some("input_json_delta") { if delta.get("type").and_then(|t| t.as_str()) == Some("input_json_delta") {
@@ -163,26 +220,30 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
usage: None, usage: None,
error: None, error: None,
reasoning_content: None, reasoning_content: None,
truncated_by_max_tokens: false,
}; };
} }
} }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None } StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false }
} }
// 工具块开始:带 id + name // 工具块开始:带 id + name
"content_block_start" => { "content_block_start" => {
// thinking / redacted_thinking 块起点无正文要记(正文随后经 thinking_delta 流出;
// redacted_thinking 为密文不可解析),落入末尾统一空 chunk 即安静跳过,不告警。
if let Some(cb) = v.get("content_block") { if let Some(cb) = v.get("content_block") {
if cb.get("type").and_then(|t| t.as_str()) == Some("tool_use") { if cb.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32; let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32;
let name = cb.get("name").and_then(|t| t.as_str()).map(|s| s.to_string()); let name = cb.get("name").and_then(|t| t.as_str()).map(|s| s.to_string());
// id 缺失时用占位 id 兜底:流式后续 input_json_delta 按 index 累加, // CR-空 id:id 缺失/空时用 fallback 兜底(流式后续 input_json_delta 按 index 累加,
// 中途无法整体跳过;占位 id 保证回传的 tool_use_id 非空,避免 GLM 500。 // 中途无法整体跳过)。与同步路径 + OpenAI 路径共用 tool_call_id_or_fallback(DRY),
let id = match cb.get("id").and_then(|t| t.as_str()).map(|s| s.to_string()) { // prefix=`gen_anthropic_stream` 区分来源。非空原样。
Some(id) if !id.is_empty() => Some(id), let raw_id = cb.get("id").and_then(|t| t.as_str()).unwrap_or("");
_ => { let id = if raw_id.is_empty() {
let placeholder = format!("tool_missing_{}", idx); let fallback = tool_call_id_or_fallback(raw_id, idx as usize, "gen_anthropic_stream");
warn!(%placeholder, name = ?name, "Anthropic 流式 tool_use 块缺少 id,已填占位 id(原样回传会触发 GLM 500)"); warn!(%fallback, name = ?name, "Anthropic 流式 tool_use 块 id 为空,已生成 fallback id(原样回传会触发 GLM 500)");
Some(placeholder) Some(fallback)
} } else {
Some(raw_id.to_string())
}; };
return StreamChunk { return StreamChunk {
delta: String::new(), delta: String::new(),
@@ -196,10 +257,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
usage: None, usage: None,
error: None, error: None,
reasoning_content: None, reasoning_content: None,
truncated_by_max_tokens: false,
}; };
} }
} }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None } StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false }
} }
// 消息结束:带出累积 usage // 消息结束:带出累积 usage
"message_stop" => StreamChunk { "message_stop" => StreamChunk {
@@ -209,15 +271,16 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
usage: usage_accum.take(), usage: usage_accum.take(),
error: None, error: None,
reasoning_content: None, reasoning_content: None,
truncated_by_max_tokens: false,
}, },
// 错误事件:流中途出错。不走 finished 完成路径(避免残缺响应被当正常完成入库), // 错误事件:流中途出错。不走 finished 完成路径(避免残缺响应被当正常完成入库),
// 改由 stream_llm 识别 error 非空 → 发 AiError + 丢弃残缺(与 OpenAI 路径 Err 一致)。 // 改由 stream_llm 识别 error 非空 → 发 AiError + 丢弃残缺(与 OpenAI 路径 Err 一致)。
"error" => { "error" => {
let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error").to_string(); let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error").to_string();
error!(%msg, "Anthropic 流式错误事件"); error!(%msg, "Anthropic 流式错误事件");
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: Some(msg), reasoning_content: None } StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: Some(msg), reasoning_content: None, truncated_by_max_tokens: false }
} }
// content_block_stop / ping 等不产出 chunk // content_block_stop / ping 等不产出 chunk
_ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }, _ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false },
} }
} }
File diff suppressed because it is too large Load Diff
+180
View File
@@ -0,0 +1,180 @@
//! 压缩可召回存档:被压缩原文 JSONL 落盘 + 召回句柄拼装。
//!
//! 压缩把原文从 LLM 上下文隔离(is_active 白名单过滤),摘要必有损。本模块在压缩标记时
//! 把本次被压原文追加写入 `<data_dir>/context-archive/<conv_id>-archive.jsonl`
//! (JSON Lines,一行一条 {role, content}),调用方在压缩摘要尾部拼召回句柄行,
//! LLM 需要原文时经 read_file 回读。会话一文件、追加写,目录不存在则创建;
//! 会话删除级联清理留待后续挂点。存档只落盘不进上下文,token 估算零影响;
//! 不与 tool_offload 复用(那是 src-tauri 侧机制,跨 crate 不混用)。
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::provider::ChatMessage;
/// 存档目录名(data_dir 下)
const ARCHIVE_DIR: &str = "context-archive";
/// 一次压缩事件的存档目标(会话维度)。调用方读到开关开(`df-ai-compress-recall`)后经
/// [`super::ContextManager::set_compress_recall`] 注入,压缩标记时一次性消费。
pub(super) struct RecallTarget {
data_dir: PathBuf,
conv_id: String,
}
impl RecallTarget {
pub(super) fn new(data_dir: &Path, conv_id: &str) -> Self {
Self {
data_dir: data_dir.to_path_buf(),
conv_id: conv_id.to_string(),
}
}
/// 存档文件路径:`<data_dir>/context-archive/<conv_id>-archive.jsonl`
fn file(&self) -> PathBuf {
self.data_dir
.join(ARCHIVE_DIR)
.join(format!("{}-archive.jsonl", self.conv_id))
}
}
/// 把本次被压原文追加写入会话存档(JSONL 追加语义,不覆盖既有行)。
/// 返回存档路径供句柄拼装;空消息/IO 失败返 None(调用方降级为无句柄,压缩本身不受影响)。
pub(super) fn append_archive(target: &RecallTarget, msgs: &[ChatMessage]) -> Option<PathBuf> {
if msgs.is_empty() {
return None;
}
let path = target.file();
let dir = path.parent()?;
if let Err(e) = std::fs::create_dir_all(dir) {
tracing::warn!(error = %e, dir = %dir.display(), "[archive] 存档目录创建失败,本轮无召回句柄");
return None;
}
let mut file = match OpenOptions::new().create(true).append(true).open(&path) {
Ok(f) => f,
Err(e) => {
tracing::warn!(error = %e, path = %path.display(), "[archive] 存档文件打开失败,本轮无召回句柄");
return None;
}
};
for m in msgs {
let line = serde_json::json!({ "role": &m.role, "content": m.content });
if let Err(e) = writeln!(file, "{line}") {
tracing::warn!(error = %e, path = %path.display(), "[archive] 存档行写入失败,本轮无召回句柄");
return None;
}
}
Some(path)
}
/// 召回句柄行:LLM 按此路径 read_file 回读被压原文。
pub fn recall_line(path: &Path) -> String {
format!("早期历史全文已存档: `{}`(read_file 可召回)", path.display())
}
/// 摘要尾部拼召回句柄行(开关开且存档落盘成功时用;开关关调用方直接用原摘要)。
pub fn with_recall_line(summary: &str, path: &Path) -> String {
format!("{}\n{}", summary, recall_line(path))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::ContextManager;
use crate::context_helpers::ContextConfig;
use tempfile::TempDir;
fn mgr() -> ContextManager {
ContextManager::new(ContextConfig {
max_tokens: 100_000,
..Default::default()
})
}
/// 灌 2 条可压 + 1 条保护区消息,注入召回目标后压缩 [0..2),返回存档路径。
fn compress_with_recall(tmp: &TempDir, conv_id: &str) -> PathBuf {
let mut m = mgr();
m.push(ChatMessage::user("早期问题"));
m.push(ChatMessage::assistant("早期回复"));
m.push(ChatMessage::user("近期问题"));
m.set_compress_recall(tmp.path(), conv_id);
let compressed = m.compress_old_messages(2);
assert_eq!(compressed.len(), 2, "前置:应压缩 2 条");
m.take_archive_path().expect("存档应落盘")
}
/// 压缩时存档文件创建,且每行含被压原文的 role+content。
#[test]
fn compress_with_recall_archives_original_text() {
let tmp = TempDir::new().unwrap();
let path = compress_with_recall(&tmp, "conv-a");
assert_eq!(
path,
tmp.path().join("context-archive").join("conv-a-archive.jsonl")
);
let text = std::fs::read_to_string(&path).unwrap();
let mut lines = text.lines();
let first: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
assert_eq!(first["role"], "user");
assert_eq!(first["content"], "早期问题");
let second: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
assert_eq!(second["role"], "assistant");
assert_eq!(second["content"], "早期回复");
assert!(lines.next().is_none(), "不应有多余行");
}
/// 开关开:摘要尾部含召回句柄行(含存档路径与 read_file 指引);路径一次性消费。
#[test]
fn recall_enabled_summary_gets_handle_line() {
let tmp = TempDir::new().unwrap();
let path = compress_with_recall(&tmp, "conv-b");
let summary = "## 意图\n- 测试主题";
let with_handle = with_recall_line(summary, &path);
assert!(with_handle.starts_with(summary), "句柄拼在摘要尾部,原摘要在前");
let handle = recall_line(&path);
assert!(with_handle.ends_with(&handle));
assert!(handle.contains(&path.display().to_string()));
assert!(handle.contains("read_file"));
}
/// 开关关(不注入召回目标):不落盘、无句柄路径可拼。
#[test]
fn recall_off_no_archive_no_handle() {
let tmp = TempDir::new().unwrap();
let mut m = mgr();
m.push(ChatMessage::user("问题"));
m.push(ChatMessage::assistant("回复"));
m.push(ChatMessage::user("近期"));
let compressed = m.compress_old_messages(2);
assert_eq!(compressed.len(), 2);
assert!(m.take_archive_path().is_none(), "开关关不应有存档路径");
assert!(
!tmp.path().join("context-archive").exists(),
"开关关不应创建存档目录"
);
}
/// 二次压缩追加不覆盖(JSONL 追加语义):两批原文都在,行数累计。
#[test]
fn second_compress_appends_not_overwrites() {
let tmp = TempDir::new().unwrap();
let mut m = mgr();
m.push(ChatMessage::user("第一批"));
m.push(ChatMessage::assistant("第一批回复"));
m.push(ChatMessage::user("第二批"));
m.push(ChatMessage::assistant("第二批回复"));
m.push(ChatMessage::user("近期"));
m.set_compress_recall(tmp.path(), "conv-c");
let _ = m.compress_old_messages(2);
// 第二次压缩事件:调用方重新注入(开关可能已变),幂等跳过已压缩,只压新批
m.set_compress_recall(tmp.path(), "conv-c");
let second = m.compress_old_messages(4);
assert_eq!(second.len(), 2, "第二次只压新激活的 2 条");
let path = m.take_archive_path().unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert_eq!(text.lines().count(), 4, "两次压缩累计 4 行,不覆盖");
assert!(text.contains("第一批回复"), "第一批原文仍在");
assert!(text.contains("第二批回复"), "第二批原文已追加");
}
}
File diff suppressed because it is too large Load Diff
+909
View File
@@ -0,0 +1,909 @@
//! 上下文管理器 — 管理对话上下文和 token 预算
//!
//! 职责:
//! - 维护消息历史及其 token 计数缓存
//! - 提供预算感知的消息裁剪(保护工具调用三元组)
//! - 为 run_agentic_loop 提供受控的消息视图
//!
//! 裁剪策略与模型选择是正交维度:本模块只管「窗口多大、怎么裁」,
//! 用哪个 model / 是否启用 reasoning 由调用方在 CompletionRequest 层决定。
//!
//! 纯函数 / 数据类型 / 常量(TokenEstimator / ContextConfig / MessageGroup /
//! TrackedMessage / EvictionUnit / classify_group / PROTECT_COUNT /
//! TOOL_MISSING_PREFIX)已抽至 [`crate::context_helpers`],本模块 `use` 复用,
//! 并 `pub use` 重导出以保持 `df_ai::context::*` 历史路径对外可见(零调用方变更)。
//!
//! # 子模块
//! - [`sanitize`]:畸形配对自愈(`sanitize_messages` / `drop_reverse_orphans` /
//! `assert_placeholder_pairing` / `ensure_sequence_legal`)及其单测。
//! 实现为模块级 `pub fn`,本模块通过 `ContextManager` 上的关联函数薄包装转发,
//! 保持 `ContextManager::sanitize_messages(...)` 调用路径不变(零调用方变更)。
mod sanitize;
pub mod archive;
use crate::context_helpers::{
build_eviction_summary_text, classify_group, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT,
TRIM_TARGET_RATIO,
};
// 重导出:保持 `df_ai::context::TokenEstimator` / `df_ai::context::ContextConfig` 等
// 历史路径对外可见(agentic.rs / commands/ai/mod.rs 等调用方零变更)。
// `pub use` 同时把类型带入本模块命名空间,供 ContextManager 结构体字段与 impl 直接引用。
pub use crate::context_helpers::{
EvictionUnit, ContextConfig, MessageGroup, TokenEstimator, TrackedMessage,
};
use crate::provider::{ChatMessage, MessageRole, MessageStatus};
// ============================================================
// 上下文管理器
// ============================================================
/// 上下文管理器
///
/// 唯一的消息真相来源(替代原来的 `Vec<ChatMessage>`)。
/// 裁剪仅影响发送视图(`build_for_request`),不影响持久化(`all_messages_clone`)。
pub struct ContextManager {
messages: Vec<TrackedMessage>,
/// 当前历史总 token 数(不含 system prompt)
history_tokens: u32,
config: ContextConfig,
estimator: TokenEstimator,
/// 压缩重入标志(F-15 §4.3):true 表示一次 LLM 压缩正在进行中。
/// agentic loop 顶部检测,防同一轮内多次触发压缩互相覆盖。纯内存态,不落库。
is_compressing: bool,
/// [P2 改进5] 主题切换检测标记。push user 消息时若发现末两条 user 消息的 topic
/// 都非 None 且不同(双高置信),置位本字段,格式 "old|new"。agentic loop 顶部读并
/// 消费(insert 系统标记后清空)。纯内存态,不落库。保守:任一 topic 为 None 不置位(宁可漏报)。
pending_topic_marker: Option<String>,
/// msg-split-phase1:已落库(ai_messages 表)的消息条数。
///
/// 增量写路径用:save_conversation 据此判定 `[persisted_msg_count..len)` 是"自上次 save
/// 后新增的消息"(纯 append),走 `insert_batch`(INSERT OR IGNORE)只插新行,跳过昂贵的
/// `replace_conversation`(DELETE 整对话 + 全量重插)。
///
/// 仅 append-only 路径才有效——一旦 [`needs_full_rewrite`] 被置位(DB 与内存可能脱钩:
/// compress 改 status / replace_tool_result_content 改 content / pop_last_assistant_round
/// 缩短 / insert_at 中段插入改 seq / clear 清空),save 必须走全量重写收敛,本字段随之重置。
///
/// restore_from_messages(DB 加载)后设为 len(DB 已是该真相源,append 基线对齐到当前长度)。
/// 纯内存态,不落库(下次进程重启从 DB reload 时由 restore_from_messages 重置)。
persisted_msg_count: usize,
/// msg-split-phase1:全量重写需求标志。
///
/// `true` = 自上次 save 后发生过修改既有消息 / 缩短 / 中段插入 / 清空,内存与 DB 可能脱钩,
/// 下次 save 必须走 `replace_conversation` 全量重写收敛(DELETE + INSERT 全部行)。
/// save 全量重写成功后清零;append-only save(insert_batch)成功后保持 false。
///
/// 任何修改既有消息(非纯 push append)的入口置位:clear / pop_last_assistant_round /
/// truncate_after_user_message / replace_tool_result_content / replace_last_active_user_content /
/// insert_at / compress_old_messages / messages_mut(可变借用兜底,调用方可能改 status/content)。
/// 误置位零代价(只是下次 save 多走一次全量重写,正确性不变);漏置位才会丢更新——故保守置位。
needs_full_rewrite: bool,
/// 压缩可召回:本次压缩事件的存档目标(调用方读到 KV 开后注入,compress_old_messages
/// 一次性消费)。None = 本事件不存档(开关关/未注入),零行为变更。
compress_recall: Option<archive::RecallTarget>,
/// 最近一次压缩成功落盘的存档路径(take 语义,供调用方在压缩摘要尾部拼召回句柄;
/// 每次 compress_old_messages 按事件重置,防跨事件残留误报)。
archive_path: Option<std::path::PathBuf>,
/// 缓存友好上下文总开关(跨 crate 注入:settings 在 src-tauri 侧,df-ai 不可直读,
/// 调用方读 KV `df-ai-cache-friendly-context` 后经 [`Self::set_cache_friendly`] 注入)。
/// 默认 false = 旧行为(贪心最小裁剪 + system 锚点原样发送),未注入路径零行为变更。
cache_friendly: bool,
}
impl ContextManager {
pub fn new(config: ContextConfig) -> Self {
Self {
messages: Vec::new(),
history_tokens: 0,
config,
estimator: TokenEstimator::default(),
is_compressing: false,
pending_topic_marker: None,
persisted_msg_count: 0,
needs_full_rewrite: false,
compress_recall: None,
archive_path: None,
cache_friendly: false,
}
}
/// 追加消息(自动计算 token 并更新缓存)
///
/// 不在此处淘汰——push 可能发生在 agentic loop 中间(追加 tool_result),
/// 此时不应裁剪正在使用的活跃消息。裁剪在 `build_for_request` 时统一处理。
pub fn push(&mut self, message: ChatMessage) {
let tokens = self.estimator.estimate_message(&message);
let group = classify_group(&message);
// 仅 active 消息计入 token 预算(F-15 §3.3):truncated / archived_segment /
// compressed 不进 LLM 上下文,token 虚高会致 build_for_request 误判超预算
// 触发不必要裁剪。!active 消息仍 push 到 self.messages 全量保留(持久化不受影响),
// sanitize_messages step0(is_active 过滤)在发送视图统一剔除。
if message.is_active() {
self.history_tokens += tokens;
}
// [P2 改进5] 主题推断(仅 user 消息):IntentRecognizer 识别意图,置信 >= 0.7 且
// 非 Unknown 则把 Intent 标签存入 TrackedMessage.topic 供主题切换检测。
// 保守:低置信(None)不标,避免误报。topic 不参与裁剪/压缩(只供 marker 检测)。
let topic: Option<String> = if matches!(message.role, MessageRole::User) {
let (intent, conf) = crate::intent::IntentRecognizer::recognize(&message.content);
if conf >= 0.7 && !matches!(intent, crate::intent::Intent::Unknown) {
Some(intent.as_str().to_string())
} else {
None
}
} else {
None
};
// [P2 改进5] 主题切换检测:push 前(本消息即将成末条 user),若已存在一条更早 user
// 且本消息 topic 与之都非 None 且不同 → 置位 pending_topic_marker("old|new")。
// 双高置信(两条 topic 都非 None)才标,任一 None 不标(宁可漏报不误报)。
if matches!(message.role, MessageRole::User) {
if let Some(prev_topic) = self.last_user_topic() {
if let Some(this_topic) = &topic {
if prev_topic != *this_topic {
self.pending_topic_marker =
Some(format!("{}|{}", prev_topic, this_topic));
}
}
}
}
self.messages.push(TrackedMessage {
message,
token_count: tokens,
group,
topic,
});
}
/// 清空所有消息
pub fn clear(&mut self) {
self.messages.clear();
self.history_tokens = 0;
self.is_compressing = false;
self.pending_topic_marker = None;
// msg-split-phase1:全清后 DB 必须也清(调用方 chat.rs:1149 走 delete_range),
// 全量重写基线重置到 0。needs_full_rewrite=true 保险(若 save 先于 delete_range 触发,
// 全量重写空列表也会清 DB)。
self.persisted_msg_count = 0;
self.needs_full_rewrite = true;
self.compress_recall = None;
self.archive_path = None;
}
/// 消息数量
pub fn len(&self) -> usize {
self.messages.len()
}
pub fn is_empty(&self) -> bool {
self.messages.is_empty()
}
/// 当前历史占用的 token 数(不含 system prompt)
pub fn history_tokens(&self) -> u32 {
self.history_tokens
}
/// 预算上限
pub fn budget_limit(&self) -> u32 {
self.config.budget_limit()
}
// ── 核心:构建请求消息(受控裁剪版本)──
/// 构建发送给 LLM 的消息列表
///
/// `sys_tokens` 为调用方已估算好的 system prompt token 数。
/// 超预算时自动裁剪旧消息(保护工具调用三元组 + 最近 PROTECT_COUNT 条),并在
/// 被淘汰区头部插入关键词补偿摘要(role=system,纯视图易变区,不落库不改存储)。
/// 返回 (消息列表, 是否发生了裁剪)。
pub fn build_for_request(&self, sys_tokens: u32) -> (Vec<ChatMessage>, bool) {
let budget = self.budget_limit();
let available = budget.saturating_sub(sys_tokens);
// system prompt 自身超预算:裁剪无法缓解(仍返回保护区兜底),warn 便于诊断
if sys_tokens > budget {
tracing::warn!(
"system prompt (~{} tokens) 超过上下文预算 ({}),裁剪无法缓解",
sys_tokens, budget
);
}
// 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环)
if self.history_tokens <= available {
let view = self.anchors_to_user(Self::sanitize_messages(self.all_messages_clone()));
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
return (
Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
false,
);
}
// 超预算 → 视图裁剪(不修改 self.messages,保证 all_messages_clone 仍返回全量)
let protect_start = self.messages.len().saturating_sub(PROTECT_COUNT);
let units = self.build_eviction_units(protect_start);
// 缓存友好开:块状裁剪(目标水位+轮边界吸附);关:贪心最小裁剪(逐字节等价旧路径)
let (trim_end, removed) = if self.cache_friendly {
self.block_trim_target(&units, available)
} else {
self.greedy_trim_target(&units, available)
};
if trim_end == 0 {
tracing::warn!(
"history (~{} tokens) 超预算 ({}) 但无可淘汰单元(全在保护区 {} 条),发送兜底可能触发 provider 超限",
self.history_tokens, available, PROTECT_COUNT
);
// 兜底全量也过 sanitize(对齐分支 1/3),防绕过序列修复直送 provider
// 触发"首条 assistant 非法"/orphan/连续 role。原裸返 all_messages_clone 不过滤
// truncated/中毒三元组/首条非法——是主 loop 唯一的 sanitize 漏洞(大体量 tool_result
// 致超预算且保护区满时命中)。异常会话(开头连续 assistant/tool 无 user)经
// ensure_sequence_legal 清空后,由协议层 ensure_leading_user 补 user 占位降级,不阻塞。
// view-only:不改 self.messages 持久化(与分支 1/3 一致)。
let view = self.anchors_to_user(Self::sanitize_messages(self.all_messages_clone()));
return (
Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
false,
);
}
let evicted: Vec<ChatMessage> = self.messages[..trim_end]
.iter()
.map(|t| t.message.clone())
.collect();
let mut msgs: Vec<ChatMessage> = self.messages[trim_end..]
.iter()
.map(|t| t.message.clone())
.collect();
// 淘汰补偿摘要:视图裁剪无声丢弃被淘汰内容,LLM 从此看不到早期主题,与压缩
// 路径(有摘要补偿)行为不一致。此处把被淘汰文本提炼成一短段摘要插回发送视图。
// **缓存友好修正(2026-10)**:原 insert(0) 头部插入会位移整条前缀字节,使
// DeepSeek 等自动前缀缓存 provider 的稳定前缀(顶层 system + 早期 history)
// 全部缓存失效——命中率掉到个位数。改尾部追加(push):摘要本身每轮即时再生成
// (淘汰区单调增长致内容逐轮漂移)本就无法命中缓存,但放尾部不再拖累稳定前缀,
// 命中率最大化。角色必须 user:role=system 会被 anthropic_compat 折叠进带
// cache_control 的顶层 system 段,落 system 即逐轮改写缓存指纹;user 放尾部
// 由 sanitize/协议层自愈兜底。摘要只进 messages 易变区,严禁拼进 system_prompt/
// 任何稳定段;纯视图语义:不落库、不改 conv.messages 存储、不标记 compressed,
// 被淘汰原文存储态不变。极短/空淘汰文本提炼为空 → 跳过(不值得插)。
if let Some(text) = build_eviction_summary_text(&evicted) {
msgs.push(ChatMessage::user(text));
}
tracing::info!(
"context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
trim_end, removed
);
let view = self.anchors_to_user(Self::sanitize_messages(msgs));
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
(
Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
true,
)
}
/// 贪心最小裁剪:累计淘汰单元直到塞下硬预算即停(历史行为,开关关路径)。
///
/// 缺陷:对话每增长一条消息就重新越过预算一点,裁剪边界随之前移一个单元,
/// 发送视图首条每轮都变,前缀缓存从第 0 字节起永远 miss。
fn greedy_trim_target(&self, units: &[EvictionUnit], available: u32) -> (usize, u64) {
let mut removed: u64 = 0;
let mut trim_end = 0;
for unit in units {
if self.history_tokens.saturating_sub(removed as u32) <= available {
break;
}
removed += unit.token_sum as u64;
trim_end = unit.end;
}
(trim_end, removed)
}
/// 块状裁剪:一次裁到目标水位 `TRIM_TARGET_RATIO × available`,且边界吸附轮边界。
///
/// 为什么块状:两次裁剪之间裁剪边界不动 → 被淘汰集不变 → 视图前缀逐字节稳定,
/// 前缀缓存每轮命中;裁剪频度由水位差(块大小)决定而非逐条爬行。
/// 轮边界:单元 end 处下一条未淘汰消息 role=User 即轮起点,整问整答(含工具链)
/// 同生同死,淘汰摘要按轮覆盖语义完整,且轮起点是稳定地标。
/// 病态回退(单轮体量 > 块,扫到保护区仍无两条件齐备的边界):取满足硬预算
/// available 的最后单元边界(安全底线);连硬预算都不可得(保护区自身超限)则
/// 尽力裁到末单元,对齐贪心路径。
fn block_trim_target(&self, units: &[EvictionUnit], available: u32) -> (usize, u64) {
let target = (available as f32 * TRIM_TARGET_RATIO) as u32;
let mut removed: u64 = 0;
let mut hard = (0usize, 0u64);
let mut last = (0usize, 0u64);
for unit in units {
removed += unit.token_sum as u64;
last = (unit.end, removed);
let remaining = self.history_tokens.saturating_sub(removed as u32);
if remaining <= available {
hard = (unit.end, removed);
}
// 目标水位隐含硬预算已满足(target < available),轮吸附与水位同时判定
if remaining <= target
&& self
.messages
.get(unit.end)
.is_some_and(|next| matches!(next.message.role, MessageRole::User))
{
return (unit.end, removed);
}
}
if hard.0 > 0 {
hard
} else {
last
}
}
/// 发送视图出口整形(sanitize 之后):总开关开时把存储态 role=System 消息
/// (压缩摘要/话题标记/警示等锚点)映射为 role=User 发送。
///
/// 为什么:anthropic_compat 把存储态全部 role=System 折叠进顶层 system(带
/// cache_control),每次锚点事件都改写顶层缓存指纹,system 断点与消息断点同时失配。
/// 转 user 后顶层 system 回到纯 system_prompt 稳定段;连续 user 由
/// anthropic_compat::merge_consecutive_users 合并(协议要求严格交替),user 不触发
/// 三元组/首条规则。纯视图:存储态仍 System,压缩定位(has_compressible_messages
/// 的 System 排除)与前端展示语义不变。调用方组装层加入的 system_prompt 不经本
/// 函数(不在 storage),最终视图中它独占 role=System 首条。
fn anchors_to_user(&self, view: Vec<ChatMessage>) -> Vec<ChatMessage> {
if !self.cache_friendly {
return view;
}
view.into_iter()
.map(|mut m| {
if matches!(m.role, MessageRole::System) {
m.role = MessageRole::User;
}
m
})
.collect()
}
// ── 畸形配对自愈(转发至 [`sanitize`] 子模块,保持 ContextManager::xxx 调用路径)──
/// 畸形配对自愈 — 转发到 [`sanitize::sanitize_messages`]。
///
/// 保留为 `ContextManager` 关联函数以兼容历史调用路径(`Self::sanitize_messages` /
/// `ContextManager::sanitize_messages`),实现见子模块文档。
pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
sanitize::sanitize_messages(messages)
}
/// 发送视图出口断言 — 转发到 [`sanitize::assert_placeholder_pairing`]。
pub fn assert_placeholder_pairing(
messages: Vec<ChatMessage>,
enabled: bool,
) -> Vec<ChatMessage> {
sanitize::assert_placeholder_pairing(messages, enabled)
}
/// 全量克隆(持久化 save_conversation / build_for_request 未裁剪分支,不受裁剪影响)
pub fn all_messages_clone(&self) -> Vec<ChatMessage> {
self.messages.iter().map(|t| t.message.clone()).collect()
}
/// 只读最近 N 条消息(尾部切片 clone)。BE2 断路器/探索熔断检查等只关心
/// 最近消息的读路径,用本方法避免 all_messages_clone 每轮全量 clone(长对话每轮 O(n) 深克隆浪费)。
/// 尾部顺序保持时间正序(与 all_messages_clone 一致,调用方从尾部反向扫即可)。
pub fn recent_messages(&self, n: usize) -> Vec<ChatMessage> {
let skip = self.messages.len().saturating_sub(n);
self.messages[skip..]
.iter()
.map(|t| t.message.clone())
.collect()
}
/// 从 Vec 恢复(兼容从 DB 加载)
pub fn restore_from_messages(&mut self, messages: Vec<ChatMessage>) {
// clear() 会置 needs_full_rewrite=true + persisted_msg_count=0;但本入口是"DB 刚加载",
// 加载后的 messages 与 DB 完全一致(它们就是从 DB 来的),append 基线应对齐到当前 len,
// 且不需要全量重写(DB 已是真相源)。故 clear 后立即覆盖这两个字段。
let len = messages.len();
self.clear();
for msg in messages {
self.push(msg);
}
self.persisted_msg_count = len;
self.needs_full_rewrite = false;
// 重放历史时 push 会照常跑主题切换检测,历史里"当时已发生"的切换会残留进 marker;
// 留着它,加载后首轮 take_topic_marker 就拿到假话题锚点、误插一次切换标记。
// 恢复即重置:切换检测只对 restore 之后的实时 push 生效。
self.pending_topic_marker = None;
}
/// 就地替换某条 tool_result 的内容(兼容审批 replace_tool_result)
/// 返回 true 如果找到并替换了
///
/// 反向遍历:tool_result 由 append 进入历史,被替换的通常是最近的审批占位,
/// 从尾部查找命中即停,避免对长历史做正向 O(n) 累积扫描。
pub fn replace_tool_result_content(&mut self, tool_call_id: &str, new_content: &str) -> bool {
let pos = self.messages.iter().rposition(|t| {
matches!(t.message.role, MessageRole::Tool)
&& t.message.tool_call_id.as_deref() == Some(tool_call_id)
});
let Some(i) = pos else { return false };
// 先更新 content,再重估 token 并校正总量
let old_tokens = self.messages[i].token_count;
self.messages[i].message.content = new_content.to_string();
let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
self.messages[i].token_count = new_tokens;
self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
// msg-split-phase1:改既有消息 content,DB 需全量重写收敛(append-only 路径不覆盖旧行)。
self.needs_full_rewrite = true;
true
}
/// 弹出末尾连续的 assistant 消息(含其 tool_calls 三元组尾随 tool_result)
///
/// 用于「重新生成」删掉最后一条 AI 回复(可能跨多轮 tool_calls + tool_results
/// 紧随其后),保留触发它的 user 消息,以便重跑 agentic loop 再生成。
///
/// 语义:从末尾向前弹出,直到弹出至少一条 assistant 消息;若弹出 assistant 后紧邻的更早
/// 消息仍是 assistant/tool(同一轮多块),继续一并弹出,确保不留半截三元组污染下轮。
/// user 消息作为停止边界(不弹出 user),保证重生成时历史末尾是 user 消息。
pub fn pop_last_assistant_round(&mut self) -> bool {
if self.messages.is_empty() {
return false;
}
let mut popped_any = false;
// 从尾向前:先弹掉末尾非 user 的消息(assistant / tool),直到遇到 user 或空
while let Some(last) = self.messages.last() {
if matches!(last.message.role, MessageRole::User) {
break;
}
let removed = self.messages.pop().expect("just checked non-empty");
self.history_tokens = self.history_tokens.saturating_sub(removed.token_count);
if matches!(removed.message.role, MessageRole::Assistant) {
popped_any = true;
}
}
// msg-split-phase1:从末尾弹出 → len 缩短,append-only 基线(persisted_msg_count)
// 会大于新 len。下次 save 必须全量重写(删 DB 中已不存在的尾行)。
if popped_any {
self.needs_full_rewrite = true;
}
popped_any
}
/// 编辑某条 user 消息后,将其后所有消息标记为 truncated(UX-09 编辑重生成)。
///
/// 软删语义:保留在内存真相源 + DB(可追溯),但 sanitize_messages 过滤后不进 LLM 上下文,
/// 前端按 is_active 过滤从视图移除。返回被标 truncated 的条数(0 表示该 user 消息已是末尾,无后续)。
///
/// `target_content` 为该 user 消息的预期内容(用于反向唯一定位:末条 user 消息可能内容相同,
/// 故从尾部向前找第一条 role=User 且 content 匹配且仍 active 的消息)。
/// 找不到返回 Err(()),调用方据此报错。
pub fn truncate_after_user_message(&mut self, target_content: &str) -> Result<usize, ()> {
// 反向找末条 active user 消息且 content 匹配
let pos = self.messages.iter().rposition(|t| {
matches!(t.message.role, MessageRole::User)
&& t.message.content == target_content
&& t.message.is_active()
});
let Some(i) = pos else { return Err(()) };
// i 之后的全部标 truncated(已 truncated 的跳过,只统计本次新标的)
let mut count = 0usize;
for t in self.messages[i + 1..].iter_mut() {
if t.message.is_active() {
t.message.status = Some(MessageStatus::Truncated);
count += 1;
}
}
// msg-split-phase1:改既有消息 status(truncated),DB 需全量重写收敛。
if count > 0 {
self.needs_full_rewrite = true;
}
Ok(count)
}
/// 替换末条 active user 消息的 content(UX-09 编辑重生成)。
///
/// 编辑语义:只能编辑最后一条 user 消息(中间编辑语义复杂,拒绝)。返回 Err(()) 表示无 active user 消息。
/// 成功后调用方应紧接着 truncate_after_user_message(new_content) 软删其后续消息。
pub fn replace_last_active_user_content(&mut self, new_content: &str) -> Result<(), ()> {
let pos = self.messages.iter().rposition(|t| {
matches!(t.message.role, MessageRole::User) && t.message.is_active()
});
let Some(i) = pos else { return Err(()) };
let old_tokens = self.messages[i].token_count;
self.messages[i].message.content = new_content.to_string();
let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
self.messages[i].token_count = new_tokens;
self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
// msg-split-phase1:改既有 user 消息 content,DB 需全量重写收敛。
self.needs_full_rewrite = true;
Ok(())
}
/// 只读迭代(兼容 ensure_conversation_title 的 .iter().filter() 等)
pub fn iter(&self) -> impl Iterator<Item = &ChatMessage> {
self.messages.iter().map(|t| &t.message)
}
/// 消息级溯源:取末条指定 role 消息的 id(ULID)。
///
/// 从尾部反向扫描(末条消息命中即停,避免全量 O(n) 正扫累积),返回最近一条
/// `role` 匹配且 `id` 非空消息的 id。无匹配或老消息无 id → None(向前兼容:
/// 老反序列化消息 id=None,溯源写入降级为 None,展示侧兼容 `conv:` 旧格式)。
///
/// 用途:
/// - `MessageRole::Assistant`:audit/知识提炼写入时取当前 assistant 消息 id
/// (LLM 返回带 tool_calls 的 assistant 已 push,process_tool_calls 入口取)
/// - `MessageRole::User`:知识注入 referenced 事件溯源取触发检索的 user 消息 id
fn last_message_id_by_role(&self, role: MessageRole) -> Option<String> {
self.messages
.iter()
.rev()
.find(|t| {
std::mem::discriminant(&t.message.role) == std::mem::discriminant(&role)
})
.and_then(|t| t.message.id.clone())
}
/// 末条 assistant 消息的 id(消息级溯源用)。
pub fn last_assistant_message_id(&self) -> Option<String> {
self.last_message_id_by_role(MessageRole::Assistant)
}
/// 末条 user 消息的 id(消息级溯源用)。
pub fn last_user_message_id(&self) -> Option<String> {
self.last_message_id_by_role(MessageRole::User)
}
// ── [P2 改进5] 主题切换检测(保守,双高置信才标) ──
/// 取末条 user 消息的 topic 标签(供 push 时主题切换检测)。
///
/// 从尾部反向扫描 user 消息,取最近一条 role=User 的 TrackedMessage.topic。
/// 老消息(未接改进5 推断)topic=None → 返 None(向前兼容)。无 user 消息 → None。
fn last_user_topic(&self) -> Option<String> {
self.messages
.iter()
.rev()
.find(|t| matches!(t.message.role, MessageRole::User))
.and_then(|t| t.topic.clone())
}
/// 取并消费 pending_topic_marker(供 agentic loop 顶部读 → insert 系统标记 → 清空)。
///
/// 返回 "old|new" 格式字符串(push 时末两条 user topic 都非 None 且不同置位)。
/// 取出即清空(一次性消费,防同 marker 重复 insert)。无 marker → None。
pub fn take_topic_marker(&mut self) -> Option<String> {
self.pending_topic_marker.take()
}
// ── F-15 上下文管理增强辅助方法(压缩链路核心 API)──
//
// 已全量接入压缩链路:
// - agentic/mod.rs 自动压缩(trigger 判定 + LLM/关键词兜底 + 重入保护)
// set_compressing/is_compressing(重入标志) / has_compressible_messages(触发判定)
// messages_mut(取 active 喂 LLM) / compress_old_messages(标 compressed 扣 token)
// insert_at(摘要/续接锚点 system 消息插入首位)
// - commands/ai/compress.rs 与 commands/ai/commands/chat.rs 走 IPC 压缩入口
// - build_eviction_units(下方)供会话分段与压缩定位共用同一分组逻辑。
/// 配置(只读视图,供 agentic.rs 计算压缩触发阈值 `config().budget_limit()`)
pub fn config(&self) -> &ContextConfig {
&self.config
}
/// 可变消息切片(供标记 status="compressed"/"archived_segment" + 调整 token)
///
/// 调用方约定:仅改 `message.status` / `message.content`,不增删条目(增删走
/// [`push`] / [`insert_at`]),否则 `history_tokens` 会与实际脱钩。
///
/// msg-split-phase1:**若调用方借此句柄改了既有消息(status/content),必须紧接着调
/// [`mark_needs_full_rewrite`]**,否则下次 save 走 append-only 增量路径会漏更新旧行,
/// DB 与内存脱钩。仅读(过滤后 clone)不需调。本方法无法自行置位(借用冲突:返回 &mut
/// 切片时不能再持有 &mut self 标志)。
pub fn messages_mut(&mut self) -> &mut [TrackedMessage] {
&mut self.messages
}
/// msg-split-phase1:显式标记"下次 save 须全量重写"。
///
/// 供 [`messages_mut`] 的调用方在改完既有消息后调用(本 struct 无法在返回 &mut 切片时
/// 自行置位)。也可供任何绕过本 impl 直接改 messages 的路径兜底。幂等(重复置 true 无害)。
pub fn mark_needs_full_rewrite(&mut self) {
self.needs_full_rewrite = true;
}
/// msg-split-phase1:读取已落库消息条数(append-only 增量写路径的基线)。供 save_conversation
/// 判定 `[persisted_msg_count..len)` 是否为"自上次 save 后新增"。
pub fn persisted_msg_count(&self) -> usize {
self.persisted_msg_count
}
/// msg-split-phase1:推进已落库消息条数(insert_batch/replace_conversation 成功后调用)。
/// `full_rewrite_applied`=本次写库是否走了全量重写(快照时刻 needs_full_rewrite 快照)。
/// 仅全量重写覆盖了既有行才清 needs_full_rewrite;增量/无变化路径写库期间新置的 dirty
/// (如压缩链 mark)与本次 append 写库无关,误清会致改动滞留内存永不落库。供 save_conversation 写库成功后调用。
pub fn advance_persisted_count(&mut self, new_count: usize, full_rewrite_applied: bool) {
// 防回退:若调用方误传更小值(理论上不应发生),取 max 保不丢基线(下次 save 仍能收敛)。
self.persisted_msg_count = self.persisted_msg_count.max(new_count);
if full_rewrite_applied {
self.needs_full_rewrite = false;
}
}
/// msg-split-phase1:是否需要全量重写(改过既有消息 / 缩短 / 中段插入 / 清空)。
pub fn needs_full_rewrite(&self) -> bool {
self.needs_full_rewrite
}
/// 在给定位置插入一条消息(其余向后移),并把它计入 token 预算(active 才计)。
///
/// 供压缩点插入摘要 system 消息。`index` 越界则 panic(对齐 Vec::insert 语义,
/// 调用方负责算合法 index,如 `compress_end` 已由 `compress_old_messages` 校验)。
pub fn insert_at(&mut self, index: usize, message: ChatMessage) {
let tokens = self.estimator.estimate_message(&message);
let group = classify_group(&message);
if message.is_active() {
self.history_tokens += tokens;
}
self.messages.insert(index, TrackedMessage {
message,
token_count: tokens,
group,
topic: None,
});
// msg-split-phase1:中段插入会改变 index 及之后所有消息的 seq(append-only 基线
// persisted_msg_count 按"末尾追加"语义计算 seq,中段插入后 seq 错位),DB 必须全量重写。
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 总和,保证:
/// - 工具调用三元组(Head + Tail* + 紧随的 Standalone Assistant)在同一单元
/// - 保护区 `[protect_start, len)` 内的消息不纳入任何单元
///
/// 公开会话分段(`archived_segment` 按组原子标记)与压缩定位共用。
pub fn build_eviction_units(&self, protect_start: usize) -> Vec<EvictionUnit> {
let mut units = Vec::new();
let mut i = 0usize;
while i < protect_start {
let mut token_sum = 0u32;
if self.messages[i].group == MessageGroup::ToolCallHead {
token_sum += self.messages[i].token_count;
i += 1;
while i < protect_start && self.messages[i].group == MessageGroup::ToolResultTail {
token_sum += self.messages[i].token_count;
i += 1;
}
if i < protect_start
&& self.messages[i].group == MessageGroup::Standalone
&& matches!(self.messages[i].message.role, MessageRole::Assistant)
{
token_sum += self.messages[i].token_count;
i += 1;
}
} else {
token_sum += self.messages[i].token_count;
i += 1;
}
units.push(EvictionUnit { end: i, token_sum });
}
units
}
/// 保护区外是否存在可压缩消息(供 agentic loop 顶部触发判断)。
///
/// "可压缩"= status 为 None/active 的消息(已 compressed/archived_segment/truncated
/// 不参与二次压缩,幂等)。`protect_start` 为保护区起点(如 `len - PROTECT_COUNT`)。
pub fn has_compressible_messages(&self, protect_start: usize) -> bool {
let end = protect_start.min(self.messages.len());
// 排除 system 角色(压缩摘要 / 话题切换锚点)。这些是上下文锚点非压缩目标——
// 若计入,压缩摘要 insert_at(0) 落在可压缩区 [0..protect_start) 且 is_active(status=None),
// 致每轮 has_compressible 恒 true → 无限循环压缩(用户报"压缩后每轮提示已压缩并停止")。
// compress_old_messages 不改:被调用时仍标旧 system 摘要 compressed(被新摘要替代,防堆积)。
self.messages[..end]
.iter()
.any(|t| t.message.is_active() && !matches!(t.message.role, MessageRole::System))
}
/// 把保护区 `[0, compress_end)` 范围内的 active 消息标记为 `status="compressed"`,
/// 同步从 `history_tokens` 扣除其 token,返回被压缩消息的克隆(供喂 LLM 摘要)。
///
/// **幂等**:已 compressed(或任何 !active)的消息跳过,不会被二次压缩;`history_tokens`
/// 也只扣首次标记的 token。返回的 Vec 仅含**本次新标记**的消息(已 compressed 的不返)。
///
/// **单向不可逆**:压缩后 DB 原始消息保留,但 LLM 上下文里被 is_active 白名单隔离
/// (sanitize step0 过滤)。`compress_end` 越界自动 clamp 到 `messages.len()`,且实际
/// 压缩边界会回退到最近的对话单元边界(三元组不劈开,见方法内注释)。
///
/// 返回空 Vec 表示本批次无可压缩消息(全部已 compressed 或范围空),调用方据此跳过 LLM 调用。
pub fn compress_old_messages(&mut self, compress_end: usize) -> Vec<ChatMessage> {
// 压缩可召回:目标一次性消费(防跨事件残留),路径按本事件重置。压缩标记后原文
// 从 LLM 上下文隔离,此刻原文在手,追加写入会话存档供摘要句柄召回(read_file);
// 失败仅置空路径(调用方降级为无句柄),不影响压缩本身。
let recall = self.compress_recall.take();
self.archive_path = None;
// 压缩边界按对话单元(三元组)对齐:调用方传入的 end(如 len - PROTECT_COUNT)周期性
// 落在单元中间(assistant 头与其 tool 结果之间),只压半个三元组会让保护区以孤立的
// 头或尾开头,成为 sanitize 每请求告警的确定性供给。按全长分组取「完整单元边界中
// 不超过 raw_end 的最大者」回退(只少压不多压,不侵占保护区);三元组要么整段压缩
// 要么整段保留。空范围/空历史无单元,取 0。
let raw_end = compress_end.min(self.messages.len());
let end = self
.build_eviction_units(self.messages.len())
.iter()
.map(|u| u.end)
.take_while(|&e| e <= raw_end)
.last()
.unwrap_or(0);
let mut newly_compressed = Vec::new();
for t in self.messages[..end].iter_mut() {
if t.message.is_active() {
newly_compressed.push(t.message.clone());
t.message.status = Some(MessageStatus::Compressed);
self.history_tokens = self.history_tokens.saturating_sub(t.token_count);
}
}
// msg-split-phase1:改了既有消息 status(active → compressed),DB 需全量重写收敛
// (append-only 增量路径不覆盖旧行)。仅在确实标了新 compressed 时置位(无可压缩时
// newly_compressed 为空,状态未变,无需全量重写)。
if !newly_compressed.is_empty() {
self.needs_full_rewrite = true;
if let Some(target) = recall {
match archive::append_archive(&target, &newly_compressed) {
Some(p) => self.archive_path = Some(p),
None => tracing::warn!("[ai] 压缩存档写入失败,本轮摘要无召回句柄(压缩不受影响)"),
}
}
}
newly_compressed
}
/// 注入本次压缩事件的存档目标(压缩可召回,`df-ai-compress-recall` 开时由调用方在
/// `compress_old_messages` 之前调用;开关关不调用)。一次性消费:压缩标记时落盘,
/// 路径经 [`Self::take_archive_path`] 取走。
pub fn set_compress_recall(&mut self, data_dir: &std::path::Path, conv_id: &str) {
self.compress_recall = Some(archive::RecallTarget::new(data_dir, conv_id));
}
/// 取走最近一次压缩成功落盘的存档路径(Some = 本次压缩可召回,调用方据此在摘要尾部
/// 拼句柄行)。take 语义:取后即空,防跨压缩事件残留误报。
pub fn take_archive_path(&mut self) -> Option<std::path::PathBuf> {
self.archive_path.take()
}
/// 注入缓存友好上下文总开关(每请求由 src-tauri 调用方读 KV 后在 `build_for_request`
/// 前调用,持续语义:注入值保持到下次注入)。true = 块状裁剪 + system 锚点 view 层
/// 转 user;false = 两者旁路,行为与旧路径逐字节等价。
pub fn set_cache_friendly(&mut self, enabled: bool) {
self.cache_friendly = enabled;
}
/// 压缩重入标志(读)。true 表示一次 LLM 压缩正在进行中,触发方应跳过本轮压缩。
pub fn is_compressing(&self) -> bool {
self.is_compressing
}
/// 压缩重入标志(写)。`true`=开始压缩(进入 agentic loop 顶部前置置位),
/// `false`=压缩结束(无论成功或降级)。调用方必须成对调用,防止永久卡死。
pub fn set_compressing(&mut self, v: bool) {
self.is_compressing = v;
}
// build_eviction_units / classify_group 等已在上方公开或文件级定义。
}
#[cfg(test)]
mod manager_tests;
#[cfg(test)]
mod compress_boundary_tests {
use super::*;
use crate::provider::ToolCall;
fn mgr() -> ContextManager {
ContextManager::new(ContextConfig {
max_tokens: 100_000,
..Default::default()
})
}
/// 构造 4 个周期轮次,每轮 [user, assistant(tool_calls), tool_result, assistant]:
/// user 独占一个单元,后三条(头+尾+紧随独立 assistant)合并为一个单元,
/// 单元边界落在 4/8/12/16。
fn periodic_rounds() -> ContextManager {
let mut m = mgr();
for r in 0..4 {
m.push(ChatMessage::user(&format!("问题 {}", r)));
m.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new(format!("c{}", r), "run_command", "{}")],
));
m.push(ChatMessage::tool_result(format!("c{}", r), "ok"));
m.push(ChatMessage::assistant("小结"));
}
m
}
#[test]
fn compress_end_mid_unit_retreats_to_unit_boundary() {
let mut m = periodic_rounds();
// len=16,调用方口径 len - PROTECT_COUNT(6) = 10,落在第 3 轮三元组中间
// (9 头 / 10 尾 / 11 独立)。旧行为会把头压掉却留下孤立的 tool 结果。
// 回退到完整单元边界 9:只多压前面的 user,不劈开三元组(只少压不多压)。
let compressed = m.compress_old_messages(10);
assert_eq!(compressed.len(), 9, "边界回退到完整单元边界 9");
let msgs = m.messages_mut();
for i in 9..16 {
assert!(msgs[i].message.is_active(), "第 {} 条应保持 active", i);
}
// 关键回归点:第 3 轮三元组(9 头 / 10 尾 / 11 独立)整体保持 active,头未被劈开
assert!(msgs[9].message.is_active(), "三元组头不应被单独压缩");
assert!(msgs[10].message.is_active(), "三元组尾不应残留 active 而头被压");
assert!(msgs[11].message.is_active());
assert_eq!(msgs[9].group, MessageGroup::ToolCallHead);
}
#[test]
fn compress_end_on_boundary_unchanged() {
let mut m = periodic_rounds();
// end=8 恰为单元边界,行为与对齐前一致(整 8 条压缩)。
let compressed = m.compress_old_messages(8);
assert_eq!(compressed.len(), 8);
let msgs = m.messages_mut();
assert!(msgs[..8].iter().all(|t| !t.message.is_active()));
assert!(msgs[8..].iter().all(|t| t.message.is_active()));
}
#[test]
fn compress_end_oversized_still_compresses_all() {
let mut m = periodic_rounds();
// 越界 clamp 到 len:全量单元分组后末单元 end=len,语义与改动前一致。
let compressed = m.compress_old_messages(999);
assert_eq!(compressed.len(), 16);
assert_eq!(m.history_tokens(), 0);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+640
View File
@@ -0,0 +1,640 @@
//! Git worktree 生命周期管理 — 多 Agent 并行执行的文件隔离基础
//!
//! 设计依据:docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md §4.2
//!
//! 每个 SubTask 创建独立 worktree(git worktree add + 新分支),
//! 写操作天然隔离。合并时用 git merge-tree 预检 + git merge 实际合并。
use std::path::{Path, PathBuf};
use std::process::Command;
/// worktree 管理错误
#[derive(Debug)]
pub enum WorktreeError {
/// Git 命令执行失败(stdout/stderr 含错误信息)
GitFailed(String),
/// 工程根目录无 .git(非 Git 工程,应降级串行)
NotGitRepo,
/// worktree 目录已存在(可能上次未清理)
AlreadyExists(PathBuf),
}
impl std::fmt::Display for WorktreeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::GitFailed(msg) => write!(f, "Git 命令失败: {}", msg),
Self::NotGitRepo => write!(f, "非 Git 工程(无 .git 目录)"),
Self::AlreadyExists(p) => write!(f, "worktree 目录已存在: {}", p.display()),
}
}
}
impl std::error::Error for WorktreeError {}
/// worktree 创建结果
#[derive(Debug, Clone)]
pub struct WorktreeHandle {
/// worktree 物理路径(SubTask 工具操作的 cwd)
pub path: PathBuf,
/// worktree 对应的分支名(merge 时用)
pub branch: String,
/// 所属 Plan id(清理时按 plan 批量)
pub plan_id: String,
/// SubTask id
pub subtask_id: String,
}
/// 在指定工程根目录下创建 SubTask 专属 worktree。
///
/// - `project_root`: 工程根目录(含 .git)
/// - `plan_id`: 所属 Plan id
/// - `subtask_id`: SubTask id
/// - `base_branch`: fork 基点分支(plan 分支或主分支)
///
/// 返回 WorktreeHandle(path + branch)。
/// worktree 路径:`{project_root}/.devflow/wt/{plan_id}/{subtask_id}`
/// 分支名:`subtask/{plan_id}/{subtask_id}`
pub fn create_worktree(
project_root: &Path,
plan_id: &str,
subtask_id: &str,
base_branch: &str,
) -> Result<WorktreeHandle, WorktreeError> {
if !project_root.join(".git").exists() {
return Err(WorktreeError::NotGitRepo);
}
let wt_path = project_root.join(".devflow/wt").join(plan_id).join(subtask_id);
if wt_path.exists() {
return Err(WorktreeError::AlreadyExists(wt_path));
}
let branch = format!("subtask/{}/{}", plan_id, subtask_id);
// git worktree add <path> -b <branch> <base_branch>
let output = Command::new("git")
.args([
"worktree",
"add",
wt_path.to_str().unwrap_or(""),
"-b",
&branch,
base_branch,
])
.current_dir(project_root)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git worktree add 执行失败: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(WorktreeError::GitFailed(format!(
"git worktree add 失败: {}",
stderr.trim()
)));
}
Ok(WorktreeHandle {
path: wt_path,
branch,
plan_id: plan_id.to_string(),
subtask_id: subtask_id.to_string(),
})
}
/// 在 worktree 内提交所有改动(git add -A + git commit)。
///
/// - `wt_path`: worktree 物理路径
/// - `message`: commit 消息
pub fn commit_worktree(wt_path: &Path, message: &str) -> Result<(), WorktreeError> {
// git add -A
let add = Command::new("git")
.args(["add", "-A"])
.current_dir(wt_path)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git add 失败: {}", e)))?;
if !add.status.success() {
return Err(WorktreeError::GitFailed(format!(
"git add 失败: {}",
String::from_utf8_lossy(&add.stderr).trim()
)));
}
// git commit -m (允许空提交,--allow-empty 防无改动时报错)
let commit = Command::new("git")
.args(["commit", "--allow-empty", "-m", message])
.current_dir(wt_path)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git commit 失败: {}", e)))?;
if !commit.status.success() {
// commit 失败可能因无 user.email 配置(测试环境),尝试自动配
let stderr = String::from_utf8_lossy(&commit.stderr);
if stderr.contains("user.email") || stderr.contains("user.name") {
// 降级:设置 worktree 局部配置(--local 不污染全局 git config)后重试
Command::new("git")
.args(["config", "--local", "user.email", "devflow@ai.local"])
.current_dir(wt_path)
.output()
.ok();
Command::new("git")
.args(["config", "--local", "user.name", "DevFlow Agent"])
.current_dir(wt_path)
.output()
.ok();
let retry = Command::new("git")
.args(["commit", "--allow-empty", "-m", message])
.current_dir(wt_path)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git commit 重试失败: {}", e)))?;
if !retry.status.success() {
return Err(WorktreeError::GitFailed(format!(
"git commit 重试仍失败: {}",
String::from_utf8_lossy(&retry.stderr).trim()
)));
}
return Ok(());
}
return Err(WorktreeError::GitFailed(format!("git commit 失败: {}", stderr.trim())));
}
Ok(())
}
/// 将 SubTask 分支 merge 到 plan 分支(在 plan worktree 内执行)。
///
/// 返回 Ok(true) 表示有改动被合并,Ok(false) 表示无改动(空分支)。
/// 返回 Err 表示 merge 冲突。
pub fn merge_branch(
plan_wt_path: &Path,
subtask_branch: &str,
) -> Result<MergeResult, WorktreeError> {
// 预检用旧版三参形态 merge-tree <共同祖先> <ours> <theirs>
// (--write-tree 需 Git 2.38+,为兼容旧 git 不采用)
// 第 1 参必须是 merge-base:此前误传 HEAD,branch2(HEAD) 相对 base 零差异,
// trivial merge 恒判无冲突(实测真冲突场景输出为空 exit 0),预检形同虚设
let mb = Command::new("git")
.args(["merge-base", "HEAD", subtask_branch])
.current_dir(plan_wt_path)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git merge-base 失败: {}", e)))?;
// 旧式形态冲突时 exit 仍为 0,只能看 stdout 判定;只认 <<<<<<< 标记,
// 不查 =======:干净合并的 diff 上下文会带出文件内容,文档里的等号分隔行会误撞
if mb.status.success() {
let merge_base = String::from_utf8_lossy(&mb.stdout).trim().to_string();
let precheck = Command::new("git")
.args(["merge-tree", merge_base.as_str(), "HEAD", subtask_branch])
.current_dir(plan_wt_path)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git merge-tree 失败: {}", e)))?;
let precheck_stdout = String::from_utf8_lossy(&precheck.stdout);
if precheck_stdout.contains("<<<<<<<") {
return Ok(MergeResult::Conflict {
branch: subtask_branch.to_string(),
details: precheck_stdout.to_string(),
});
}
}
// merge-base 失败 = 两分支无共同祖先,无法预检,交由下方实际 merge 裁决
// 无冲突,执行实际 merge
let merge = Command::new("git")
.args(["merge", "--no-edit", subtask_branch])
.current_dir(plan_wt_path)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git merge 失败: {}", e)))?;
if !merge.status.success() {
// merge 冲突(git merge 返回非0)
let stderr = String::from_utf8_lossy(&merge.stderr);
let stdout = String::from_utf8_lossy(&merge.stdout);
// abort 部分合并状态(防 worktree 处于 conflicted 状态)
let _ = Command::new("git")
.args(["merge", "--abort"])
.current_dir(plan_wt_path)
.output();
return Ok(MergeResult::Conflict {
branch: subtask_branch.to_string(),
details: format!("{}\n{}", stdout, stderr),
});
}
Ok(MergeResult::Clean)
}
/// merge 结果
#[derive(Debug, Clone)]
pub enum MergeResult {
/// 干净合并(无冲突)
Clean,
/// 合并冲突(details 含 conflict 信息)
Conflict { branch: String, details: String },
}
/// 清理单个 SubTask 的 worktree(git worktree remove --force + 删分支)。
pub fn remove_worktree(project_root: &Path, handle: &WorktreeHandle) -> Result<(), WorktreeError> {
// git worktree remove --force <path>
let _ = Command::new("git")
.args([
"worktree",
"remove",
"--force",
handle.path.to_str().unwrap_or(""),
])
.current_dir(project_root)
.output();
// 删除分支(-D 强制,已 merge 的分支正常删,未 merge 的也删)
let _ = Command::new("git")
.args(["branch", "-D", &handle.branch])
.current_dir(project_root)
.output();
Ok(())
}
/// 清理某 Plan 的所有 worktree(Plan 完成/失败/取消时批量清理)。
pub fn remove_plan_worktrees(project_root: &Path, plan_id: &str) -> Result<(), WorktreeError> {
let plan_wt_dir = project_root.join(".devflow/wt").join(plan_id);
if !plan_wt_dir.exists() {
return Ok(());
}
// git worktree list 获取该 plan 下所有 worktree
let list = Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(project_root)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git worktree list 失败: {}", e)))?;
let list_str = String::from_utf8_lossy(&list.stdout);
for line in list_str.lines() {
if let Some(path_str) = line.strip_prefix("worktree ") {
let path = PathBuf::from(path_str);
// 仅清理属于本 plan 的 worktree
if path.starts_with(&plan_wt_dir) {
let _ = Command::new("git")
.args(["worktree", "remove", "--force", path_str])
.current_dir(project_root)
.output();
}
}
}
// 删除该 plan 的所有子任务分支
let branches = Command::new("git")
.args(["branch", "--list", &format!("subtask/{}/*", plan_id)])
.current_dir(project_root)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git branch --list 失败: {}", e)))?;
let branches_str = String::from_utf8_lossy(&branches.stdout);
for line in branches_str.lines() {
let branch = line.trim();
if !branch.is_empty() {
let _ = Command::new("git")
.args(["branch", "-D", branch])
.current_dir(project_root)
.output();
}
}
// 清理空目录
let _ = std::fs::remove_dir_all(&plan_wt_dir);
Ok(())
}
/// 检测工程是否为 Git 仓库(有 .git 目录)。
pub fn is_git_repo(project_root: &Path) -> bool {
project_root.join(".git").exists()
}
/// 获取工程当前分支名(用于作为 plan worktree 的 fork 基点)。
pub fn current_branch(project_root: &Path) -> Result<String, WorktreeError> {
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(project_root)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git rev-parse 失败: {}", e)))?;
if !output.status.success() {
return Err(WorktreeError::GitFailed(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
/// 创建 Plan worktree(Plan 级工作分支,所有 SubTask 分支 merge 到这里)。
///
/// 返回 (plan_worktree_path, plan_branch)。
pub fn create_plan_worktree(
project_root: &Path,
plan_id: &str,
) -> Result<(PathBuf, String), WorktreeError> {
if !is_git_repo(project_root) {
return Err(WorktreeError::NotGitRepo);
}
let base = current_branch(project_root)?;
let plan_branch = format!("plan/{}", plan_id);
let plan_wt_path = project_root.join(".devflow/wt").join(plan_id);
if plan_wt_path.exists() {
return Err(WorktreeError::AlreadyExists(plan_wt_path));
}
let output = Command::new("git")
.args([
"worktree",
"add",
plan_wt_path.to_str().unwrap_or(""),
"-b",
&plan_branch,
&base,
])
.current_dir(project_root)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git worktree add(plan) 失败: {}", e)))?;
if !output.status.success() {
return Err(WorktreeError::GitFailed(format!(
"git worktree add(plan) 失败: {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
Ok((plan_wt_path, plan_branch))
}
#[cfg(test)]
mod tests {
use super::*;
/// 创建临时 Git 仓库 + 初始 commit
fn setup_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
Command::new("git").args(["init"]).current_dir(path).output().unwrap();
Command::new("git").args(["config", "user.email", "test@test.com"]).current_dir(path).output().unwrap();
Command::new("git").args(["config", "user.name", "test"]).current_dir(path).output().unwrap();
std::fs::write(path.join("main.rs"), "fn main() {}\n").unwrap();
Command::new("git").args(["add", "."]).current_dir(path).output().unwrap();
Command::new("git").args(["commit", "-m", "init"]).current_dir(path).output().unwrap();
dir
}
#[test]
fn wt_01_create_and_isolate() {
let repo = setup_repo();
let root = repo.path();
let base = current_branch(root).unwrap();
let handle = create_worktree(root, "plan-test", "A", &base).unwrap();
assert!(handle.path.exists(), "worktree 目录应存在");
assert_eq!(handle.branch, "subtask/plan-test/A");
// 在 worktree 内写文件
std::fs::write(handle.path.join("new_a.rs"), "// A 的改动").unwrap();
// 主目录不应有此文件
assert!(!root.join("new_a.rs").exists(), "主目录不应有 A 的改动");
// 清理
remove_worktree(root, &handle).unwrap();
}
#[test]
fn wt_02_parallel_isolation() {
let repo = setup_repo();
let root = repo.path();
let plan_wt = create_plan_worktree(root, "plan-iso").unwrap();
let plan_branch = plan_wt.1;
let a = create_worktree(root, "plan-iso", "A", &plan_branch).unwrap();
let b = create_worktree(root, "plan-iso", "B", &plan_branch).unwrap();
// A 写 new_a.rs
std::fs::write(a.path.join("new_a.rs"), "// A").unwrap();
// B 写 new_b.rs
std::fs::write(b.path.join("new_b.rs"), "// B").unwrap();
// A 的 worktree 不应有 B 的文件
assert!(!a.path.join("new_b.rs").exists(), "A 不应看到 B 的改动");
// B 的 worktree 不应有 A 的文件
assert!(!b.path.join("new_a.rs").exists(), "B 不应看到 A 的改动");
remove_plan_worktrees(root, "plan-iso").unwrap();
}
#[test]
fn wt_03_commit_in_worktree() {
let repo = setup_repo();
let root = repo.path();
let plan_wt = create_plan_worktree(root, "plan-commit").unwrap();
let a = create_worktree(root, "plan-commit", "A", &plan_wt.1).unwrap();
// 写文件 + commit
std::fs::write(a.path.join("feature.rs"), "pub fn feature() {}").unwrap();
commit_worktree(&a.path, "Add feature").unwrap();
// 验证分支有新 commit
let log = Command::new("git")
.args(["log", "--oneline", "-1"])
.current_dir(&a.path)
.output()
.unwrap();
let log_str = String::from_utf8_lossy(&log.stdout);
assert!(log_str.contains("Add feature"), "commit 消息应在 log 中");
remove_plan_worktrees(root, "plan-commit").unwrap();
}
#[test]
fn wt_04_merge_no_conflict() {
let repo = setup_repo();
let root = repo.path();
let plan_wt = create_plan_worktree(root, "plan-merge").unwrap();
let plan_wt_path = plan_wt.0.clone();
let plan_branch = plan_wt.1;
// A 改 file_a.rs, B 改 file_b.rs(不同文件,无冲突)
let a = create_worktree(root, "plan-merge", "A", &plan_branch).unwrap();
std::fs::write(a.path.join("file_a.rs"), "// A").unwrap();
commit_worktree(&a.path, "A changes").unwrap();
let b = create_worktree(root, "plan-merge", "B", &plan_branch).unwrap();
std::fs::write(b.path.join("file_b.rs"), "// B").unwrap();
commit_worktree(&b.path, "B changes").unwrap();
// merge A → plan
let result_a = merge_branch(&plan_wt_path, &a.branch).unwrap();
assert!(matches!(result_a, MergeResult::Clean), "A merge 应无冲突");
// merge B → plan
let result_b = merge_branch(&plan_wt_path, &b.branch).unwrap();
assert!(matches!(result_b, MergeResult::Clean), "B merge 应无冲突");
// plan worktree 应同时有 file_a.rs 和 file_b.rs
assert!(plan_wt_path.join("file_a.rs").exists(), "plan 应含 A 的改动");
assert!(plan_wt_path.join("file_b.rs").exists(), "plan 应含 B 的改动");
remove_plan_worktrees(root, "plan-merge").unwrap();
}
#[test]
fn wt_05_merge_conflict_same_file() {
let repo = setup_repo();
let root = repo.path();
// 写一个共享文件
std::fs::write(root.join("shared.rs"), "line1\nline2\n").unwrap();
Command::new("git").args(["add", "-A"]).current_dir(root).output().unwrap();
Command::new("git").args(["commit", "-m", "add shared"]).current_dir(root).output().unwrap();
let plan_wt = create_plan_worktree(root, "plan-conf").unwrap();
let plan_wt_path = plan_wt.0.clone();
let plan_branch = plan_wt.1;
// A 和 B 改同一文件的同一行
let a = create_worktree(root, "plan-conf", "A", &plan_branch).unwrap();
std::fs::write(a.path.join("shared.rs"), "A_version\n").unwrap();
commit_worktree(&a.path, "A change shared").unwrap();
let b = create_worktree(root, "plan-conf", "B", &plan_branch).unwrap();
std::fs::write(b.path.join("shared.rs"), "B_version\n").unwrap();
commit_worktree(&b.path, "B change shared").unwrap();
// merge A → 无冲突
let result_a = merge_branch(&plan_wt_path, &a.branch).unwrap();
assert!(matches!(result_a, MergeResult::Clean), "首个 merge 应无冲突");
// merge B → 冲突
let result_b = merge_branch(&plan_wt_path, &b.branch).unwrap();
assert!(
matches!(result_b, MergeResult::Conflict { .. }),
"同文件同行改应产生冲突"
);
remove_plan_worktrees(root, "plan-conf").unwrap();
}
#[test]
fn wt_06_non_git_returns_error() {
let dir = tempfile::tempdir().unwrap();
// 不 init git
let result = create_plan_worktree(dir.path(), "plan-ng");
assert!(matches!(result, Err(WorktreeError::NotGitRepo)));
}
#[test]
fn wt_07_cleanup_removes_worktree() {
let repo = setup_repo();
let root = repo.path();
let plan_wt = create_plan_worktree(root, "plan-clean").unwrap();
let a = create_worktree(root, "plan-clean", "A", &plan_wt.1).unwrap();
assert!(a.path.exists());
remove_plan_worktrees(root, "plan-clean").unwrap();
// worktree 目录应被清理
assert!(!a.path.exists(), "worktree 目录应已清理");
// plan wt 目录也应被清理
assert!(!plan_wt.0.exists(), "plan worktree 目录应已清理");
}
#[test]
fn wt_08_precheck_hits_conflict_before_real_merge() {
let repo = setup_repo();
let root = repo.path();
std::fs::write(root.join("shared.rs"), "line1\nline2\n").unwrap();
Command::new("git").args(["add", "-A"]).current_dir(root).output().unwrap();
Command::new("git").args(["commit", "-m", "add shared"]).current_dir(root).output().unwrap();
let plan_wt = create_plan_worktree(root, "plan-prechk").unwrap();
let plan_wt_path = plan_wt.0.clone();
let plan_branch = plan_wt.1;
// A 和 B 改同一文件同一行
let a = create_worktree(root, "plan-prechk", "A", &plan_branch).unwrap();
std::fs::write(a.path.join("shared.rs"), "A_version\n").unwrap();
commit_worktree(&a.path, "A change shared").unwrap();
let b = create_worktree(root, "plan-prechk", "B", &plan_branch).unwrap();
std::fs::write(b.path.join("shared.rs"), "B_version\n").unwrap();
commit_worktree(&b.path, "B change shared").unwrap();
assert!(matches!(
merge_branch(&plan_wt_path, &a.branch).unwrap(),
MergeResult::Clean
));
// 冲突必须由 merge-tree 预检命中(details 含标记),而非实际 merge 失败兜底
// (兜底 details 来自 merge stderr,只有 CONFLICT 文案无标记)
match merge_branch(&plan_wt_path, &b.branch).unwrap() {
MergeResult::Conflict { details, .. } => {
assert!(
details.contains("<<<<<<<"),
"冲突详情应含 merge-tree 预检输出的标记,而非 merge 兜底文案: {}",
details
);
}
_ => panic!("同文件同行改应冲突"),
}
// 预检命中即返回,不应留下合并中状态
let merging = Command::new("git")
.args(["rev-parse", "-q", "--verify", "MERGE_HEAD"])
.current_dir(&plan_wt_path)
.output()
.unwrap();
assert!(!merging.status.success(), "预检命中不应留下 MERGE_HEAD");
remove_plan_worktrees(root, "plan-prechk").unwrap();
}
#[test]
fn wt_09_clean_merge_with_eq_marker_content() {
let repo = setup_repo();
let root = repo.path();
// 文件内容含等号分隔行(md 标题下划线常见),A/B 隔行修改,实际可干净合并
std::fs::write(root.join("doc.md"), "t0\n=======\nl3\nl4\nl5\n").unwrap();
Command::new("git").args(["add", "-A"]).current_dir(root).output().unwrap();
Command::new("git").args(["commit", "-m", "add doc"]).current_dir(root).output().unwrap();
let plan_wt = create_plan_worktree(root, "plan-eqmark").unwrap();
let plan_wt_path = plan_wt.0.clone();
let plan_branch = plan_wt.1;
let a = create_worktree(root, "plan-eqmark", "A", &plan_branch).unwrap();
std::fs::write(a.path.join("doc.md"), "A0\n=======\nl3\nl4\nl5\n").unwrap();
commit_worktree(&a.path, "A chg line1").unwrap();
let b = create_worktree(root, "plan-eqmark", "B", &plan_branch).unwrap();
std::fs::write(b.path.join("doc.md"), "t0\n=======\nl3\nl4\nB5\n").unwrap();
commit_worktree(&b.path, "B chg line5").unwrap();
assert!(matches!(
merge_branch(&plan_wt_path, &a.branch).unwrap(),
MergeResult::Clean
));
// B 隔行修改应 Clean:预检不得因 diff 上下文中的等号行误判冲突
assert!(matches!(
merge_branch(&plan_wt_path, &b.branch).unwrap(),
MergeResult::Clean
));
remove_plan_worktrees(root, "plan-eqmark").unwrap();
}
}
+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(_), .. }));
}
}
+262 -78
View File
@@ -8,8 +8,9 @@
//! 1. `IntentRecognizer::recognize(message)` —— 规则/关键词匹配(方式 A,零延迟零成本) //! 1. `IntentRecognizer::recognize(message)` —— 规则/关键词匹配(方式 A,零延迟零成本)
//! 返回 `(Intent, f32)`,置信度 0.0–1.0。低置信 → 上游 fallback 全量工具。 //! 返回 `(Intent, f32)`,置信度 0.0–1.0。低置信 → 上游 fallback 全量工具。
//! 2. `tool_subset_for(intent)` —— 硬编码工具名→domain 映射,工具名子集(空 = 全量 fallback)。 //! 2. `tool_subset_for(intent)` —— 硬编码工具名→domain 映射,工具名子集(空 = 全量 fallback)。
//! 3. `suggested_model_tier(intent)` —— 模态建议**接口预留**,当前恒返 `None` //! 3. `suggested_model_tier(intent)` —— 模型模态档位建议(意图 → `ModelTier` 映射):
//! (待模型模态管理 Phase 落地后补充实际逻辑)。 //! Code/Debug/Http → Heavy,File/Search/Task/Idea/Project/Conversation → Standard,
//! Chat → Fast,Unknown → None(不强加偏好)。router 同 weight tiebreak + 模型路由用。
//! //!
//! ## 设计原则 //! ## 设计原则
//! - **不碰** `tool_registry`:domain 映射在本文件内硬编码工具名常量,运行期不读 registry。 //! - **不碰** `tool_registry`:domain 映射在本文件内硬编码工具名常量,运行期不读 registry。
@@ -70,12 +71,12 @@ impl Intent {
} }
} }
// ---- ModelTier 预留 --------------------------------------------------------- // ---- ModelTier 档位 ---------------------------------------------------------
/// 模型模态档位(**预留**)。 /// 模型模态档位。
/// ///
/// 待模型模态管理 Phase 落地后定义实际 provider/model 映射。 /// `suggested_model_tier` 据 Intent 映射到此档位,供 router 同 weight tiebreak
/// 当前仅占位于 `suggested_model_tier` 返回类型,逻辑恒返 `None`。 /// (重档位优先)或后续 provider/model 路由(待模型模态管理 Phase 接入)使用。
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelTier { pub enum ModelTier {
/// 轻量快速(简单意图/闲聊) /// 轻量快速(简单意图/闲聊)
@@ -119,8 +120,9 @@ const DEBUG_RULES: &[Rule] = &[
Rule { kw: "栈", weight: 0.5 }, Rule { kw: "栈", weight: 0.5 },
Rule { kw: "panic", weight: 0.9 }, Rule { kw: "panic", weight: 0.9 },
Rule { kw: "traceback", weight: 0.9 }, Rule { kw: "traceback", weight: 0.9 },
// 命令执行场景关键词(run_command 入口引导):用户明确"运行/执行/构建/测试/跑" // 命令执行场景关键词:用户明确"运行/执行/构建/测试/跑"时归 Debug 意图(影响档位路由与场景增量)。
// 才该走 Exec domain(run_command)。中等权重(0.7)避免误抢 Code 命中(编译/重构 1.0)。 // run_command 已入基本盘全工作意图可见,这里不再承担工具暴露职责。
// 中等权重(0.7)避免误抢 Code 命中(编译/重构 1.0)。
Rule { kw: "运行", weight: 0.7 }, Rule { kw: "运行", weight: 0.7 },
Rule { kw: "执行", weight: 0.7 }, Rule { kw: "执行", weight: 0.7 },
Rule { kw: "构建", weight: 0.7 }, Rule { kw: "构建", weight: 0.7 },
@@ -230,12 +232,22 @@ const GENERIC_GROUP: &[IntentGroup] = &[
/// 本表硬编码,不读 registry 运行期状态(保持模块独立可单测)。 /// 本表硬编码,不读 registry 运行期状态(保持模块独立可单测)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ToolDomain { pub enum ToolDomain {
/// 数据/业务:项目/任务/灵感/工作流/回收站 /// 数据/业务:项目/任务/灵感/工作流/回收站(全量,含写工具)
Data, Data,
/// 文件:读写/patch/列目录/搜索(不含命令执行) /// 数据只读子集:list_*/get_*_count(不含 create/update/delete/advance/run_workflow/restore/purge/bind_directory)
///
/// 用途:`Intent::Code` 的场景增量:保留编码场景用户常需要的"看一下项目/任务结构"只读
/// 探索工具(list_projects/list_tasks/list_ideas/list_trash),不放大写工具暴露面
/// (写工具属 Data 域,留给 Project/Task/Idea 的场景增量)。
DataReadOnly,
/// 文件:读写/patch/列目录/搜索。基本盘成员,所有工作意图可见
File, File,
/// 命令执行:run_command(shell 命令,独立 domain 防止被泛 File 意图带出) /// 命令执行:run_command(shell 命令)。基本盘成员:读写执行为 agent 基本能力
/// (2026-09-30 用户拍板,不受意图收敛限制),安全由执行层 validate_path/授权/RiskLevel 兜底
Exec, Exec,
/// 编排:spawn_agent 子代理派发。基本盘成员:委派是编排类基本能力,
/// 高置信意图收敛下不登记则工具对 LLM 不可见,安全由子代理侧白名单钳制兜底
Agent,
/// 网络:HTTP /// 网络:HTTP
Http, Http,
} }
@@ -275,11 +287,27 @@ impl ToolDomain {
"delete_file", "delete_file",
"rename_file", "rename_file",
], ],
// Exec domain 独立:run_command 从 File 移出。 // Exec domain 独立于 File:run_command 单列,供基本盘/场景增量按意图组合。
// 意图侧仅 Debug 默认带 Exec(用户明确"运行/测试/构建"), // 2026-09-30 用户拍板读写执行为 agent 基本能力,不再仅 Debug 暴露;
// Code/File/Search 不带 → 减少 LLM 对 run_command 的偏好暴露。 // 安全由执行层兜底(RiskLevel 审批/授权三道门/路径黑名单),藏工具≠安全。
ToolDomain::Exec => &["run_command"], ToolDomain::Exec => &["run_command"],
// Agent domain:spawn_agent 单列,编排委派基本盘成员(子代理侧白名单钳制兜底安全)。
ToolDomain::Agent => &["spawn_agent"],
ToolDomain::Http => &["http_request"], ToolDomain::Http => &["http_request"],
// DataReadOnly:Data domain 的只读子集。
// 源于 Code 意图收敛需求:用户在编码场景说"先 list_projects 看下项目结构"
// "create_task 记一下"时,意图识别可能命中 Code(SPECIFIC > ENTITY),原 Code subset
// 不含 Data domain → list_projects/create_task 对 LLM 不可见,agent 被迫反复 read_file。
// 加 Data 全 domain 会暴露 create/update/delete 等写工具(放大暴露面),
// 故取只读子集:list_*/get_*_count(纯查询,无副作用)。
ToolDomain::DataReadOnly => &[
"list_projects",
"list_tasks",
"list_ideas",
"list_trash",
"get_project_count",
"get_task_count",
],
} }
} }
} }
@@ -392,7 +420,7 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)>
for rule in cand.rules.iter() { for rule in cand.rules.iter() {
// 英文关键词用小写匹配;中文不受 to_lowercase 影响(no-op)。 // 英文关键词用小写匹配;中文不受 to_lowercase 影响(no-op)。
let needle = rule.kw.to_lowercase(); let needle = rule.kw.to_lowercase();
if lower.contains(&needle) { if kw_matches(&lower, &needle) {
score += rule.weight; score += rule.weight;
matched = true; matched = true;
} }
@@ -409,37 +437,62 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)>
best best
} }
/// 关键词命中判定:ASCII 关键词要求两侧词边界(词字符 = ASCII 字母数字 + 下划线,与正则
/// `\b` 语义一致),防子串误判——"history" 不命中 "hi"(误判 Chat)、"rapid" 不命中
/// "api"(误判 Http)。非 ASCII(中文)关键词保持子串语义:中文无词边界概念,直接 contains。
fn kw_matches(haystack: &str, needle: &str) -> bool {
if !needle.is_ascii() {
return haystack.contains(needle);
}
let is_word_char = |c: char| c.is_ascii_alphanumeric() || c == '_';
haystack.match_indices(needle).any(|(pos, m)| {
haystack[..pos]
.chars()
.next_back()
.map_or(true, |c| !is_word_char(c))
&& haystack[pos + m.len()..]
.chars()
.next()
.map_or(true, |c| !is_word_char(c))
})
}
// ---- Intent → 工具子集映射 -------------------------------------------------- // ---- Intent → 工具子集映射 --------------------------------------------------
/// 按 Intent 返回工具名子集。 /// 按 Intent 返回工具名子集。
/// ///
/// 返回空 `Vec` 表示该意图**无工具收敛**(Chat)或**未识别**(Unknown), /// 返回空 `Vec` 表示该意图**无工具收敛**(Chat/Conversation)或**未识别**(Unknown),
/// 上游应走**全量 fallback**(即不过滤工具,交全量给 LLM)。 /// 上游应走**全量 fallback**(即不过滤工具,交全量给 LLM)。
/// ///
/// 设计:Code → [file, http];File → [file];Project/Task/Idea → [data, file](加 file 防「提项目/任务 → 误判 → 砍只读探索」,见 L1); /// 设计(2026-09-30 起):基本盘 + 场景增量。
/// Http → [http];Search → [file](含 search_files);Conversation → []; /// 基本盘 = File + Exec + Agent:读、写、执行命令、委派子代理是 agent 基本能力,
/// Chat → [];Debug → [file, exec, http, data](调试常需跑命令+读文件+查 API+查任务/工作流状态,CR-25 审查🟡-1 加 data 防"调试任务"丢 Data 工具); /// 不受意图收敛限制(用户拍板),
/// **仅 Debug 含 Exec**(用户明确"运行/测试/构建/调试"才暴露 run_command), /// 所有非空子集意图(Code/Debug/File/Project/Task/Idea/Search/Http)都可见。
/// Code/File/Search 不含 Exec → 收紧 run_command 暴露面;Unknown → [](全量)。 /// 安全防线在执行层:validate_path 黑名单 + 授权三道门 + RiskLevel 审批,藏工具≠安全;
/// 藏工具只会复现断手(Project 砍 File 致 read_file 不可见、Debug 独占 run_command 致
/// "查看服务器状态"被回"未挂载",均属此类,本次根治)。
/// 场景增量 = 业务域收敛,减少 LLM 分心:Code 加 Http+DataReadOnly;Debug 加 Http+Data
/// (调试常需查 API+查任务/工作流状态,CR-25 加 data 防"调试任务"丢 Data 工具);
/// Project/Task/Idea 加 Data;File/Search 仅基本盘;Http 保留自身 Http 域
/// (发请求是该意图本名能力,砍掉即复现断手)。
/// 各意图新子集均为旧子集超集,只增不减。
pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> { pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> {
let domains: &[ToolDomain] = match intent { // 基本盘:读写执行 + 编排委派(RiskLevel/审批/路径授权/子代理白名单钳制兜底,藏工具≠安全);
Intent::Code => &[ToolDomain::File, ToolDomain::Http], // 场景增量=业务域收敛减少分心
Intent::Debug => &[ToolDomain::File, ToolDomain::Exec, ToolDomain::Http, ToolDomain::Data], const BASE_DOMAINS: &[ToolDomain] = &[ToolDomain::File, ToolDomain::Exec, ToolDomain::Agent];
Intent::File => &[ToolDomain::File], let extra: &[ToolDomain] = match intent {
// Project/Task/Idea 加 File:用户提"项目/任务"时常是在其内编码/排查 Intent::Code => &[ToolDomain::Http, ToolDomain::DataReadOnly],
// (实测会话 [项目:x] 报错 → Project → 砍 File 致 read_file 对 LLM 不可见)。 Intent::Debug => &[ToolDomain::Http, ToolDomain::Data],
// 保留只读探索;写工具有 RiskLevel/审批兜底,"分心"远好于"断手"。 // 提项目/任务/想法常伴编码排查,业务域全量保留
Intent::Project => &[ToolDomain::Data, ToolDomain::File], // (写工具有 RiskLevel/审批兜底,"分心"远好于"断手")。
Intent::Task => &[ToolDomain::Data, ToolDomain::File], Intent::Project | Intent::Task | Intent::Idea => &[ToolDomain::Data],
Intent::Idea => &[ToolDomain::Data, ToolDomain::File],
Intent::Conversation => &[],
Intent::Search => &[ToolDomain::File],
Intent::Http => &[ToolDomain::Http], Intent::Http => &[ToolDomain::Http],
Intent::Chat => &[], Intent::File | Intent::Search => &[],
Intent::Unknown => &[], // 空子集语义 = 上游全量 fallback,不受基本盘改造影响,行为不变
Intent::Conversation | Intent::Chat | Intent::Unknown => return Vec::new(),
}; };
let mut out: Vec<&'static str> = Vec::new(); let mut out: Vec<&'static str> = Vec::new();
for d in domains { for d in BASE_DOMAINS.iter().chain(extra.iter()) {
for name in d.tools() { for name in d.tools() {
if !out.contains(name) { if !out.contains(name) {
out.push(name); out.push(name);
@@ -449,19 +502,36 @@ pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> {
out out
} }
// ---- 模态建议(接口预留) --------------------------------------------------- // ---- 模态建议(意图 → 模型 tier 映射) ----------------------------------------
/// 按 Intent 建议模型模态档位。 /// 按 Intent 建议模型模态档位(意图 → 模型 tier 的语义映射)。
/// ///
/// **预留接口**:当前恒返 `None`。待模型模态管理 Phase 落地后补充: /// **映射分组**(对齐 router 同 weight tiebreak 缺语义的根因修复):
/// - Chat/Conversation → `Fast` /// - `Code`/`Debug` → `Heavy`(复杂推理/重构/排查,需重模型)
/// - Code/File/Task/Idea/Search → `Standard` /// - `Http` → `Heavy`(多跳外部调用,链长易错,需重模型把关)
/// - Debug/Http(复杂排查/多跳调用)→ `Heavy` /// - `File`/`Search`/`Task`/`Idea`/`Project`/`Conversation` → `Standard`(默认复杂度)
/// - `Chat` → `Fast`(简单闲聊,轻量即可)
/// - `Unknown` → `None`(未识别,fallback 全量工具时不强加 tier 偏好,
/// 上游走默认档位)
/// ///
/// 返回 `None` 时上游应使用默认档位(待模态管理 Phase 定义)。 /// 返回 `Some(ModelTier)` 时上游可作为同 weight 候选间的 tiebreak 依据
pub fn suggested_model_tier(_intent: &Intent) -> Option<ModelTier> { /// (重档位优先),或据此路由到不同 provider/model(待模型模态管理 Phase 接入)。
// TODO(model-tier-phase): 待模型模态管理落地后填实映射。 pub fn suggested_model_tier(intent: &Intent) -> Option<ModelTier> {
None match intent {
// 重型:复杂推理/重构/排查/多跳调用
Intent::Code | Intent::Debug | Intent::Http => Some(ModelTier::Heavy),
// 标准:默认复杂度
Intent::File
| Intent::Search
| Intent::Task
| Intent::Idea
| Intent::Project
| Intent::Conversation => Some(ModelTier::Standard),
// 轻量:简单闲聊
Intent::Chat => Some(ModelTier::Fast),
// 未识别:不强加 tier 偏好,fallback 上游默认
Intent::Unknown => None,
}
} }
// ---- 工具子集过滤(agentic loop 接入用,改进2 A) ----------------------------- // ---- 工具子集过滤(agentic loop 接入用,改进2 A) -----------------------------
@@ -804,6 +874,38 @@ mod tests {
assert_eq!(i, Intent::Http); assert_eq!(i, Intent::Http);
} }
// --- ASCII 关键词词边界(防子串误判) ---
#[test]
fn word_boundary_history_not_chat() {
// "history" 内嵌 "hi" 不再判 Chat(无其他关键词 → Unknown)
let (i, c) = IntentRecognizer::recognize("show me the history");
assert_eq!(i, Intent::Unknown, "history 不应命中 hi, 实际 conf={}", c);
}
#[test]
fn word_boundary_rapid_not_http() {
// "rapid" 内嵌 "api" 不再判 Http
let (i, c) = IntentRecognizer::recognize("rapid growth");
assert_eq!(i, Intent::Unknown, "rapid 不应命中 api, 实际 conf={}", c);
}
#[test]
fn standalone_english_keywords_still_hit() {
// 词边界不误伤独立词命中
let (i, _) = IntentRecognizer::recognize("call the api");
assert_eq!(i, Intent::Http);
let (i, _) = IntentRecognizer::recognize("hi");
assert_eq!(i, Intent::Chat);
}
#[test]
fn cjk_adjacent_ascii_keyword_still_hits() {
// 中文紧邻 ASCII 关键词:非 ASCII 字符算边界,不破坏中文场景命中
let (i, _) = IntentRecognizer::recognize("调用这个api");
assert_eq!(i, Intent::Http);
}
// --- tool_subset_for --- // --- tool_subset_for ---
#[test] #[test]
@@ -813,14 +915,64 @@ mod tests {
assert!(s.contains(&"write_file")); assert!(s.contains(&"write_file"));
assert!(s.contains(&"patch_file")); assert!(s.contains(&"patch_file"));
assert!(s.contains(&"http_request")); assert!(s.contains(&"http_request"));
assert!(!s.contains(&"list_projects")); // Code 现含 DataReadOnly → list_projects 等只读工具保留(不再断言"不含")
assert!(s.contains(&"list_projects"));
} }
#[test] #[test]
fn subset_file_only_file_domain() { fn subset_code_keeps_data_readonly_tools() {
// 根因修复(Code 命中砍 Data domain 致 list_*/get_*_count 不可见):
// Code subset 应含 DataReadOnly 全部 6 个只读工具,让"先 list_projects 看下结构"
// "list_tasks 记一下"这类编码场景的口语不被 Code 意图砍工具。
let s = tool_subset_for(&Intent::Code);
for read_only in [
"list_projects",
"list_tasks",
"list_ideas",
"list_trash",
"get_project_count",
"get_task_count",
] {
assert!(
s.contains(&read_only),
"Code subset 应含只读工具 {}(DataReadOnly domain)",
read_only
);
}
}
#[test]
fn subset_code_no_data_write_tools() {
// 防回归:Code subset 只加 Data 的"只读子集",不得暴露写工具。
// create_task 不应在 Code subset(只读 list_* 在,写 create_* 不在)。
// create_project/update_project/delete_project/advance_task/run_workflow 同理。
let s = tool_subset_for(&Intent::Code);
for write_tool in [
"create_task",
"update_task",
"delete_task",
"create_project",
"update_project",
"delete_project",
"advance_task",
"run_workflow",
] {
assert!(
!s.contains(&write_tool),
"Code subset 不应含写工具 {}(只读子集,防放大暴露面), 实际 subset: {:?}",
write_tool,
s
);
}
}
#[test]
fn subset_file_is_base_without_business_domains() {
// File 仅基本盘:读写+命令执行可见,Http/Data 业务域不暴露
let s = tool_subset_for(&Intent::File); let s = tool_subset_for(&Intent::File);
assert!(s.contains(&"read_file")); assert!(s.contains(&"read_file"));
assert!(s.contains(&"search_files")); assert!(s.contains(&"search_files"));
assert!(s.contains(&"run_command"), "File 意图应含 run_command(基本盘)");
assert!(!s.contains(&"http_request")); assert!(!s.contains(&"http_request"));
assert!(!s.contains(&"list_projects")); assert!(!s.contains(&"list_projects"));
} }
@@ -850,9 +1002,13 @@ mod tests {
} }
#[test] #[test]
fn subset_http_only_http() { fn subset_http_keeps_http_plus_base() {
// Http 意图:基本盘(读写执行)+ 自身 Http 域,较旧版(仅 http_request)只增不减
let s = tool_subset_for(&Intent::Http); let s = tool_subset_for(&Intent::Http);
assert_eq!(s, vec!["http_request"]); assert!(s.contains(&"http_request"));
assert!(s.contains(&"read_file"));
assert!(s.contains(&"run_command"), "Http 意图应含 run_command(基本盘)");
assert!(!s.contains(&"list_projects"), "Http 意图不加 Data 场景增量");
} }
#[test] #[test]
@@ -898,7 +1054,7 @@ mod tests {
fn tool_domain_names_match_registry() { fn tool_domain_names_match_registry() {
// 抽样核对与 src-tauri tool_registry 实际注册名一致 // 抽样核对与 src-tauri tool_registry 实际注册名一致
assert!(ToolDomain::File.tools().contains(&"patch_file")); assert!(ToolDomain::File.tools().contains(&"patch_file"));
// run_command 已从 File 移出 Exec domain(意图收紧:仅 Debug 默认带 run_command) // run_command 归 Exec domain(域归属未变;子集组合已改基本盘,全工作意图可见)
assert!(!ToolDomain::File.tools().contains(&"run_command")); assert!(!ToolDomain::File.tools().contains(&"run_command"));
assert_eq!(ToolDomain::Exec.tools(), &["run_command"]); assert_eq!(ToolDomain::Exec.tools(), &["run_command"]);
assert!(ToolDomain::Data.tools().contains(&"advance_task")); assert!(ToolDomain::Data.tools().contains(&"advance_task"));
@@ -907,26 +1063,39 @@ mod tests {
assert_eq!(ToolDomain::Http.tools(), &["http_request"]); assert_eq!(ToolDomain::Http.tools(), &["http_request"]);
} }
// --- suggested_model_tier 预留 --- // --- suggested_model_tier 意图 → 档位映射 ---
#[test] #[test]
fn model_tier_always_none_for_now() { fn model_tier_mapping_now_returns_actual() {
// 接口预留:当前所有意图均返 None // Code/Debug/Http → Heavy(复杂推理/重构/排查/多跳调用)
for i in [Intent::Code, Intent::Debug, Intent::Http] {
assert_eq!(
suggested_model_tier(&i),
Some(ModelTier::Heavy),
"intent {:?} 应映射 Heavy",
i
);
}
// File/Search/Task/Idea/Project/Conversation → Standard(默认复杂度)
for i in [ for i in [
Intent::Code,
Intent::Debug,
Intent::File, Intent::File,
Intent::Project, Intent::Search,
Intent::Task, Intent::Task,
Intent::Idea, Intent::Idea,
Intent::Project,
Intent::Conversation, Intent::Conversation,
Intent::Search,
Intent::Http,
Intent::Chat,
Intent::Unknown,
] { ] {
assert_eq!(suggested_model_tier(&i), None, "intent {:?} 应返 None", i); assert_eq!(
suggested_model_tier(&i),
Some(ModelTier::Standard),
"intent {:?} 应映射 Standard",
i
);
} }
// Chat → Fast(简单闲聊)
assert_eq!(suggested_model_tier(&Intent::Chat), Some(ModelTier::Fast));
// Unknown → None(未识别,不强加 tier 偏好,fallback 上游默认)
assert_eq!(suggested_model_tier(&Intent::Unknown), None);
} }
// --- IntentRecognizer Default --- // --- IntentRecognizer Default ---
@@ -944,7 +1113,7 @@ mod tests {
/// 构造测试用 ToolDefinition(仅 name 有意义,description/parameters 填占位)。 /// 构造测试用 ToolDefinition(仅 name 有意义,description/parameters 填占位)。
fn tool_def(name: &str) -> df_ai_core::types::ToolDefinition { fn tool_def(name: &str) -> df_ai_core::types::ToolDefinition {
df_ai_core::types::ToolDefinition { df_ai_core::types::ToolDefinition {
tool_type: "function".to_string(), tool_type: "function".into(),
function: df_ai_core::types::ToolFunction { function: df_ai_core::types::ToolFunction {
name: name.to_string(), name: name.to_string(),
description: String::new(), description: String::new(),
@@ -978,7 +1147,7 @@ mod tests {
#[test] #[test]
fn filter_subset_hit_filters_to_matching() { fn filter_subset_hit_filters_to_matching() {
// File 意图 subset = File domain(read_file/write_file/...);Http 工具应被滤掉 // File 意图 subset = 基本盘(File+Exec),不含 Http 域;http_request 应被滤掉
let all = vec![ let all = vec![
tool_def("read_file"), tool_def("read_file"),
tool_def("write_file"), tool_def("write_file"),
@@ -997,7 +1166,7 @@ mod tests {
#[test] #[test]
fn filter_subset_drift_skips_missing_names() { fn filter_subset_drift_skips_missing_names() {
// subset 命中但 all_defs 里没有对应工具(registry 漂移)→ 跳过,不 panic // subset 命中但 all_defs 里没有对应工具(registry 漂移)→ 跳过,不 panic
// Project 意图 subset = Data domain(create_project/...),all 里只放了一个 Data 工具 + 一个无关工具 // Project 意图 subset = Data+基本盘,create_project 命中;http_request 不在 subset,滤掉
let all = vec![ let all = vec![
tool_def("create_project"), // 命中 tool_def("create_project"), // 命中
tool_def("http_request"), // 不在 Data domain,滤掉 tool_def("http_request"), // 不在 Data domain,滤掉
@@ -1095,21 +1264,32 @@ mod tests {
} }
#[test] #[test]
fn run_command_only_exposed_for_debug_intent() { fn run_command_exposed_for_all_work_intents() {
// 核心修复断言:run_command 仅 Debug 暴露。 // 2026-09-30 用户拍板:读写执行是 agent 基本能力,不受意图收敛限制。
// Code/File/Search 意图 subset 不含 Exec domain → 不含 run_command。 // 所有非空子集意图都经基本盘含 run_command;
for intent in [Intent::Code, Intent::File, Intent::Search] { // 安全由执行层兜底(validate_path/授权/RiskLevel),藏工具≠安全。
for intent in [
Intent::Code,
Intent::Debug,
Intent::File,
Intent::Project,
Intent::Task,
Intent::Idea,
Intent::Search,
Intent::Http,
] {
let s = tool_subset_for(&intent); let s = tool_subset_for(&intent);
assert!( assert!(
!s.contains(&"run_command"), s.contains(&"run_command"),
"Intent {:?} subset 不应含 run_command(仅 Debug 暴露), 实际: {:?}", "Intent {:?} subset 应含 run_command(基本盘), 实际: {:?}",
intent, intent,
s s
); );
} }
// Debug 必含 run_command(Exec domain) // 空子集意图不受影响(上游语义=全量,天然含全部工具)
let dbg = tool_subset_for(&Intent::Debug); assert!(tool_subset_for(&Intent::Chat).is_empty());
assert!(dbg.contains(&"run_command"), "Debug 必含 run_command"); assert!(tool_subset_for(&Intent::Unknown).is_empty());
assert!(tool_subset_for(&Intent::Conversation).is_empty());
} }
#[test] #[test]
@@ -1145,13 +1325,18 @@ mod tests {
]; ];
for intent in all_intents { for intent in all_intents {
let subset = tool_subset_for(&intent); let subset = tool_subset_for(&intent);
// 全 registry 工具名(四 domain 并集:Data + File + Exec + Http) // 全 registry 工具名(六 domain 并集:Data + DataReadOnly + File + Exec + Http + Agent)
// 注:DataReadOnly 工具名是 Data 的子集,chain 它仅为语义显式(并集去重无副作用),
// 防 Code subset 里 list_projects 等 DataReadOnly 工具被判"不在 registry"。
// Agent(spawn_agent)是编排委派基本盘成员,子集合法出现,漏 chain 会误报。
let registry: std::collections::HashSet<&str> = ToolDomain::Data let registry: std::collections::HashSet<&str> = ToolDomain::Data
.tools() .tools()
.iter() .iter()
.chain(ToolDomain::DataReadOnly.tools().iter())
.chain(ToolDomain::File.tools().iter()) .chain(ToolDomain::File.tools().iter())
.chain(ToolDomain::Exec.tools().iter()) .chain(ToolDomain::Exec.tools().iter())
.chain(ToolDomain::Http.tools().iter()) .chain(ToolDomain::Http.tools().iter())
.chain(ToolDomain::Agent.tools().iter())
.copied() .copied()
.collect(); .collect();
for name in &subset { for name in &subset {
@@ -1182,8 +1367,7 @@ mod tests {
#[test] #[test]
fn subset_code_and_debug_dedup_http_across_domains() { fn subset_code_and_debug_dedup_http_across_domains() {
// 边界:Code = [File, Http], Debug = [File, Http, Data] // Code/Debug 均含 Http 场景增量,叠加基本盘后 subset 内工具名必须去重
// File + Http 都含 http_request,必须去重(subset 内不重复)
for intent in [Intent::Code, Intent::Debug] { for intent in [Intent::Code, Intent::Debug] {
let s = tool_subset_for(&intent); let s = tool_subset_for(&intent);
let http_count = s.iter().filter(|n| **n == "http_request").count(); let http_count = s.iter().filter(|n| **n == "http_request").count();
@@ -1240,7 +1424,7 @@ mod tests {
tool_def("append_file"), tool_def("append_file"),
tool_def("delete_file"), tool_def("delete_file"),
tool_def("rename_file"), tool_def("rename_file"),
tool_def("http_request"), // 不在 File domain,应被滤掉 tool_def("http_request"), // 不在 File 意图子集,应被滤掉
]; ];
let out = filter_tool_defs_planned(&all, &Intent::File, "file", "先 read 这个文件再修改它"); let out = filter_tool_defs_planned(&all, &Intent::File, "file", "先 read 这个文件再修改它");
let names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect(); let names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect();
+102 -9
View File
@@ -1,14 +1,23 @@
//! df-ai: AI 编排 — LLM Provider、Agent 协调、上下文管理、流式处理、工具注册 //! df-ai: AI 编排 — LLM Provider、Agent 协调、上下文管理、流式处理、工具注册
pub mod ai_tools; pub mod ai_tools;
// 声明式工具注册宏 `declare_tool!`(tool_registry 拆分第一步基础设施)。
// macro_rules + #[macro_export]:零新依赖,展开为等价 `register` 调用,与现有 48 工具并存。
// 试点:list_projects 已迁至声明式(commands/ai/tools/list_projects.rs),验证可行+零回归。
#[macro_use]
pub mod ai_tools_decl;
pub mod anthropic_compat; pub mod anthropic_compat;
pub mod anthropic_helpers; pub mod anthropic_helpers;
pub mod context; pub mod context;
pub mod context_helpers; pub mod context_helpers;
pub mod memory_adapter;
pub(crate) mod net_probe;
// 多 Agent 协作调度中心(Phase 1 规则驱动, Phase 2 LLM 扩展)。
pub mod coordinator; pub mod coordinator;
// 会话意图识别层(纯函数,不接入 agentic loop)。依据 docs/02-架构设计/构想审查/ // 会话意图识别层(纯函数,不接入 agentic loop)。依据 docs/02-架构设计/构想审查/
// 意图识别层论证-2026-06-19.md。提供 recognize / tool_subset_for / suggested_model_tier。 // 意图识别层论证-2026-06-19.md。提供 recognize / tool_subset_for / suggested_model_tier。
// 待 Phase B+C 完成 + 模型模态管理落地后再接入 agentic loop。 // 待 Phase B+C 完成 + 模型模态管理落地后再接入 agentic loop。
pub mod git_worktree;
pub mod intent; pub mod intent;
pub mod model_fetch; pub mod model_fetch;
// model_fetch 的纯逻辑子模块(URL 拼接 / 噪音过滤 / 响应反序列化), // model_fetch 的纯逻辑子模块(URL 拼接 / 噪音过滤 / 响应反序列化),
@@ -24,16 +33,28 @@ 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) 接入后续。
pub mod planner; pub mod planner;
// Phase 2: Plan DAG 分层执行器(骨架,PLAN_EXECUTION_ENABLED 门控默认关)。
// 按 Plan::to_layers 层间串行/层内并行调度子任务。
// 依据 docs/02-架构设计/单对话并行多轮-设计-2026-06-20.md。
pub mod persona;
pub mod plan_executor;
pub mod provider; pub mod provider;
pub mod router; pub mod router;
// CR-30-1: 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用, // 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用,
// stream_recv/agentic 流前重试需复用 backoff_delay(jitter)+is_status_retryable(Fatal 分类) // stream_recv/agentic 流前重试需复用 backoff_delay(jitter)+is_status_retryable(Fatal 分类)
// 避免重写退避/分类逻辑(对齐决策 F-260616-07 a1)。改 pub mod 后对外仅暴露纯函数 + 常量。 // 避免重写退避/分类逻辑。改 pub mod 后对外仅暴露纯函数 + 常量。
pub mod retry; pub mod retry;
pub mod sse_parser;
pub mod namespace_store;
// P1 工具结果瘦身(上下文根治专项 2026-10-06 设计 §二):超阈值工具结果正文落盘 + 句柄。
// 与 src-tauri 审计侧 D2a(tool_result_offload) 同构;base 未注入时降级为纯摘要(零行为变化)。
pub mod tool_offload;
use provider::LlmProvider; use provider::LlmProvider;
use reqwest::Client; use reqwest::Client;
@@ -41,7 +62,7 @@ use reqwest::Client;
// df-ai-core 直接暴露,供需要直接引用 trait crate 的下游(可选)。 // df-ai-core 直接暴露,供需要直接引用 trait crate 的下游(可选)。
pub use df_ai_core; pub use df_ai_core;
/// SW-260618-10: 构建 Provider 共用的 reqwest Client(OpenAI/Anthropic Provider::new DRY)。 /// 构建 Provider 共用的 reqwest Client(OpenAI/Anthropic Provider::new DRY)。
/// ///
/// connect_timeout 30s 防连接阶段静默 hang(网络静默断);不设总 timeout——reqwest 的 /// connect_timeout 30s 防连接阶段静默 hang(网络静默断);不设总 timeout——reqwest 的
/// `.timeout()` 会限制整个响应 body 时长,流式长生成任务会被误砍。读取阶段中途静默由上层 /// `.timeout()` 会限制整个响应 body 时长,流式长生成任务会被误砍。读取阶段中途静默由上层
@@ -49,6 +70,11 @@ pub use df_ai_core;
pub(crate) fn build_provider_client() -> Client { pub(crate) fn build_provider_client() -> Client {
Client::builder() Client::builder()
.connect_timeout(std::time::Duration::from_secs(30)) .connect_timeout(std::time::Duration::from_secs(30))
// 连接池收敛:idle 连接 15s 弃用 + 每主机最多存 2 条。reqwest 默认 idle 90s 且
// 无上限,服务端/前置 keepalive 更短时复用陈旧连接首包即被 RST(ConnectionReset
// 10054),呈现「隔几十秒一发就失败」的间歇形态;15s 保守低于常见网关 60s keepalive。
.pool_idle_timeout(std::time::Duration::from_secs(15))
.pool_max_idle_per_host(2)
.build() .build()
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
tracing::warn!("reqwest builder 失败,回退默认 client: {}", e); tracing::warn!("reqwest builder 失败,回退默认 client: {}", e);
@@ -56,12 +82,68 @@ pub(crate) fn build_provider_client() -> Client {
}) })
} }
/// 流式 send 阶段 reqwest 错误的谓词快照。
///
/// reqwest::Error 无公开构造器,抽谓词结构体过纯函数,单测才能锁定分类
/// (send 阶段 ConnectionReset → 可重试类词)。
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct SendErrPreds {
pub is_timeout: bool,
pub is_connect: bool,
pub is_decode: bool,
pub is_body: bool,
pub is_request: bool,
pub is_builder: bool,
}
impl SendErrPreds {
pub(crate) fn of(e: &reqwest::Error) -> Self {
Self {
is_timeout: e.is_timeout(),
is_connect: e.is_connect(),
is_decode: e.is_decode(),
is_body: e.is_body(),
is_request: e.is_request(),
is_builder: e.is_builder(),
}
}
}
/// 流式 send 阶段错误分类,返回类词(置于 bail 串首位供下游按串分类)。
///
/// reqwest 0.12 的 Display 只给 "error sending request for url",不含 source 关键词,
/// 纯 Display 串分类必得 unknown → Fatal 零重试。此处用谓词确定性给类词:
/// timeout/connect/decode/body 各归本类;is_request(非 builder)覆盖陈旧池化连接
/// 首包 RST(ConnectionReset 10054)等传输层发送错,归 connect(与 retry 的
/// is_reqwest_error_retryable 对 is_request 可重试口径一致);builder 构造错归
/// request(不可重试)。类词须落在 stream_recv classify_status_or_class 传输词表内,
/// 改任一侧需同步。
pub(crate) fn send_error_class(p: SendErrPreds) -> &'static str {
if p.is_timeout {
"timeout"
} else if p.is_connect {
"connect"
} else if p.is_decode {
"decode"
} else if p.is_body {
"body"
} else if p.is_request && !p.is_builder {
"connect"
} else {
"request"
}
}
/// 按 provider_type 构建 LLM Provider 实例(统一选择逻辑,消除调用方重复 match) /// 按 provider_type 构建 LLM Provider 实例(统一选择逻辑,消除调用方重复 match)
/// ///
/// `anthropic` 协议走 AnthropicCompatProvider(GLM 订阅端点 / Claude 官方), /// `anthropic` 协议走 AnthropicCompatProvider(GLM 订阅端点 / Claude 官方),
/// 其余(openai / glm / deepseek 等 OpenAI 兼容)走 OpenAICompatProvider。 /// 其余(openai / glm / deepseek 等 OpenAI 兼容)走 OpenAICompatProvider。
/// 调用方(AI Chat 的 run_agentic_loop、df-nodes 的 AiNode)统一引用此工厂, /// 调用方(AI Chat 的 run_agentic_loop、df-nodes 的 AiNode)统一引用此工厂,
/// 新增 provider 只改这一处。 /// 新增 provider 只改这一处。
///
/// 未知 provider_type 无法走显式报错(工厂签名不动,调用方横跨 src-tauri/df-nodes),
/// 兜底 OpenAI 兼容协议但 warn 留痕,不再静默——协议错配的请求失败可溯源到配置项。
/// anthropic_family 口径与 prompt.rs cache_split / chat.rs needs_url_prefetch 对齐。
pub fn build_provider( pub fn build_provider(
provider_type: &str, provider_type: &str,
base_url: &str, base_url: &str,
@@ -69,11 +151,22 @@ pub fn build_provider(
model: &str, model: &str,
) -> Box<dyn LlmProvider> { ) -> Box<dyn LlmProvider> {
match provider_type { match provider_type {
"anthropic" => Box::new(anthropic_compat::AnthropicCompatProvider::new( "anthropic" | "anthropic_compat" => Box::new(
base_url, api_key, model, anthropic_compat::AnthropicCompatProvider::new(base_url, api_key, model),
)), ),
_ => Box::new(openai_compat::OpenAICompatProvider::new( "openai_compat" | "openai" | "glm" | "deepseek" => {
base_url, api_key, model, Box::new(openai_compat::OpenAICompatProvider::new(
)), base_url, api_key, model,
))
}
unknown => {
tracing::warn!(
"未知 provider_type {:?},按 OpenAI 兼容协议兜底;请检查 ai_providers 配置的 provider_type",
unknown
);
Box::new(openai_compat::OpenAICompatProvider::new(
base_url, api_key, model,
))
}
} }
} }
+86
View File
@@ -0,0 +1,86 @@
//! MemoryAdapter trait — 上下文记忆层抽象
//!
//! 将 ContextManager 的核心接口提取为 trait,支持多种记忆后端:
//! - InMemoryAdapter(当前 ContextManager 的包装,默认实现)
//! - VectorMemoryAdapter(未来:接入向量检索,支持语义记忆)
//!
//! 使用方式:`Box<dyn MemoryAdapter>` 替代 `ContextManager`。
use crate::context::ContextManager;
use crate::context_helpers::{ContextConfig, TrackedMessage};
use crate::provider::ChatMessage;
/// 上下文记忆层抽象接口
///
/// 覆盖 agentic loop / IPC 最常用的 15 个方法。
/// 保留 `ContextManager` 作为默认实现(`InMemoryAdapter`),行为零变化。
pub trait MemoryAdapter: Send + Sync {
// ── 写入 ──
fn push(&mut self, msg: ChatMessage);
fn clear(&mut self);
fn insert_at(&mut self, index: usize, message: ChatMessage);
fn replace_tool_result_content(&mut self, tool_call_id: &str, new_content: &str) -> bool;
fn pop_last_assistant_round(&mut self) -> bool;
fn truncate_after_user_message(&mut self, target_content: &str) -> Result<usize, ()>;
fn replace_last_active_user_content(&mut self, new_content: &str) -> Result<(), ()>;
// ── 读取 ──
fn build_for_request(&self, sys_tokens: u32) -> (Vec<ChatMessage>, bool);
fn all_messages_clone(&self) -> Vec<ChatMessage>;
fn len(&self) -> usize;
fn is_empty(&self) -> bool;
fn history_tokens(&self) -> u32;
fn budget_limit(&self) -> u32;
fn config(&self) -> &ContextConfig;
// ── 压缩 ──
fn compress_old_messages(&mut self, end: usize) -> Vec<ChatMessage>;
fn has_compressible_messages(&self, protect_start: usize) -> bool;
fn is_compressing(&self) -> bool;
fn set_compressing(&mut self, v: bool);
// ── 生命周期 ──
fn restore_from_messages(&mut self, messages: Vec<ChatMessage>);
fn take_topic_marker(&mut self) -> Option<String>;
fn messages_mut(&mut self) -> &mut [TrackedMessage];
fn iter(&self) -> Box<dyn Iterator<Item = &ChatMessage> + '_>;
}
/// ContextManager 作为默认 MemoryAdapter 实现
impl MemoryAdapter for ContextManager {
fn push(&mut self, msg: ChatMessage) { self.push(msg); }
fn clear(&mut self) { self.clear(); }
fn insert_at(&mut self, index: usize, message: ChatMessage) { self.insert_at(index, message); }
fn replace_tool_result_content(&mut self, tool_call_id: &str, new_content: &str) -> bool {
self.replace_tool_result_content(tool_call_id, new_content)
}
fn pop_last_assistant_round(&mut self) -> bool { self.pop_last_assistant_round() }
fn truncate_after_user_message(&mut self, target_content: &str) -> Result<usize, ()> {
self.truncate_after_user_message(target_content)
}
fn replace_last_active_user_content(&mut self, new_content: &str) -> Result<(), ()> {
self.replace_last_active_user_content(new_content)
}
fn build_for_request(&self, sys_tokens: u32) -> (Vec<ChatMessage>, bool) {
self.build_for_request(sys_tokens)
}
fn all_messages_clone(&self) -> Vec<ChatMessage> { self.all_messages_clone() }
fn len(&self) -> usize { self.len() }
fn is_empty(&self) -> bool { self.is_empty() }
fn history_tokens(&self) -> u32 { self.history_tokens() }
fn budget_limit(&self) -> u32 { self.budget_limit() }
fn config(&self) -> &ContextConfig { self.config() }
fn compress_old_messages(&mut self, end: usize) -> Vec<ChatMessage> { self.compress_old_messages(end) }
fn has_compressible_messages(&self, protect_start: usize) -> bool { self.has_compressible_messages(protect_start) }
fn is_compressing(&self) -> bool { self.is_compressing() }
fn set_compressing(&mut self, v: bool) { self.set_compressing(v); }
fn restore_from_messages(&mut self, messages: Vec<ChatMessage>) { self.restore_from_messages(messages); }
fn take_topic_marker(&mut self) -> Option<String> { self.take_topic_marker() }
fn messages_mut(&mut self) -> &mut [TrackedMessage] { self.messages_mut() }
fn iter(&self) -> Box<dyn Iterator<Item = &ChatMessage> + '_> {
Box::new(self.iter())
}
}
+221 -9
View File
@@ -1,4 +1,4 @@
//! 厂商模型列表拉取 — F-01 阶段3 //! 厂商模型列表拉取。
//! //!
//! 按 `provider_type` 分派拉取厂商模型列表,过滤非 chat 模型,返回模型名 Vec。 //! 按 `provider_type` 分派拉取厂商模型列表,过滤非 chat 模型,返回模型名 Vec。
//! `fetch_and_probe` 在拉取基础上对每个模型名调 `model_probe::probe` 探测出完整 `ModelConfig`。 //! `fetch_and_probe` 在拉取基础上对每个模型名调 `model_probe::probe` 探测出完整 `ModelConfig`。
@@ -18,6 +18,7 @@ use std::time::Duration;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use df_ai_core::model::ModelConfig; use df_ai_core::model::ModelConfig;
use serde_json::Value;
use crate::model_fetch_helpers::{build_models_url, filter_chat_models, ModelsList}; use crate::model_fetch_helpers::{build_models_url, filter_chat_models, ModelsList};
use crate::model_probe::probe; use crate::model_probe::probe;
@@ -83,12 +84,15 @@ async fn fetch_openai_compat(base_url: &str, api_key: &str) -> Result<Vec<String
} }
// OpenAI 响应:`{data:[{id, owned_by, ...}]}`。中转站通常同构。 // OpenAI 响应:`{data:[{id, owned_by, ...}]}`。中转站通常同构。
let body: ModelsList = resp // 不用 resp.json():reqwest::Error::Decode 的 Display 吞 serde 详情(只给 "error decoding
.json() // response body"),SenseNova 等厂商解析失败时无法定位根因。改 text() + serde_json::from_str,
// 解析失败时 serde_json::Error 含具体 field/type/position;再叠加宽松 Value fallback 兜底。
let body = resp
.text()
.await .await
.map_err(|e| anyhow!("openai_compat 响应解析失败({url}):{e}"))?; .map_err(|e| anyhow!("openai_compat 读取响应体失败({url}):{e}"))?;
Ok(filter_chat_models(body.into_ids())) Ok(filter_chat_models(parse_models_compat("openai_compat", &url, &body)?))
} }
/// Anthropic 兼容(Claude 官方 / GLM 订阅端点):`GET /v1/models`,x-api-key + anthropic-version 鉴权。 /// Anthropic 兼容(Claude 官方 / GLM 订阅端点):`GET /v1/models`,x-api-key + anthropic-version 鉴权。
@@ -112,12 +116,93 @@ async fn fetch_anthropic_compat(base_url: &str, api_key: &str) -> Result<Vec<Str
// Anthropic 响应:`{data:[{id, display_name, type, ...}]}`(has_more 分页字段忽略)。 // Anthropic 响应:`{data:[{id, display_name, type, ...}]}`(has_more 分页字段忽略)。
// 兼容兜底:`{models:[{name, ...}]}`(Ollama 风格,理论 anthropic_compat 不会命中, // 兼容兜底:`{models:[{name, ...}]}`(Ollama 风格,理论 anthropic_compat 不会命中,
// 但中转站行为不可控,用 `#[serde(alias)]` 零成本兜底 — 见 issues)。 // 但中转站行为不可控,用 `#[serde(alias)]` 零成本兜底 — 见 issues)。
let body: ModelsList = resp // 与 openai_compat 同:text() + 严格 serde + Value 宽松 fallback,见 parse_models_compat。
.json() let body = resp
.text()
.await .await
.map_err(|e| anyhow!("anthropic_compat 响应解析失败({url}):{e}"))?; .map_err(|e| anyhow!("anthropic_compat 读取响应体失败({url}):{e}"))?;
Ok(filter_chat_models(body.into_ids())) Ok(filter_chat_models(parse_models_compat("anthropic_compat", &url, &body)?))
}
// ────────────────────────────────────────────────────────────
// 响应解析(text → 严格 serde → Value 宽松 fallback)
// ────────────────────────────────────────────────────────────
/// 响应体诊断片段最大字符数。完整 body 可能巨大,日志只取前缀定位结构。
const BODY_DIAGNOSTIC_CHARS: usize = 200;
/// 解析厂商 `/v1/models` 响应体,返回模型 id 列表(过滤前)。
///
/// 三层解析(诊断优先,兜底保成功):
/// 1. **严格**:`serde_json::from_str::<ModelsList>` — 标准结构命中,错误信息含具体
/// field/type/position(serde_json::Error Display 自带 line/column,不丢 detail)。
/// 2. **宽松 fallback**:`serde_json::Value` 解析 → 取 `data` / `models` 任一数组 →
/// 遍历项取 `id` / `name` 字符串。容错厂商额外字段、类型变体(如 id 漏成 number)。
/// 3. **诊断错误**:严格 + 宽松都失败时,返回含 HTTP 标识 + serde detail + body 前缀
/// 的友好错误,而非 reqwest 默认 "error decoding response body"。
///
/// 注:fallback 只取 id/name(模型名),丢弃 ModelEntry 上的其他字段 — 厂商变体下
/// 我们关心的就是模型名,ModelsList 本身也只消费 id/name,语义对齐。
fn parse_models_compat(provider_type: &str, url: &str, body: &str) -> Result<Vec<String>> {
// 1) 严格解析(标准结构,serde 错误 detail 完整)。
match serde_json::from_str::<ModelsList>(body) {
Ok(list) => return Ok(list.into_ids()),
Err(strict_err) => {
// 2) 宽松 Value fallback — 不依赖 ModelsList 结构,容错厂商变体。
if let Some(ids) = parse_ids_loose(body) {
return Ok(ids);
}
// 3) 双双失败:叠 HTTP 标识 + serde detail + body 前缀诊断。
return Err(anyhow!(
"{provider_type} 响应解析失败({url}):{strict_err} | body 前缀:{}",
body_preview(body)
));
}
}
}
/// 用 `serde_json::Value` 宽松提取模型 id/name。失败(非 JSON / 无 data / 无 id)返回 None。
///
/// 取数组字段优先级:`data`(OpenAI/Anthropic)→ `models`(Ollama 风格 alias)。
/// 项里取 `id` → 兜底 `name`,只接受字符串值(number/bool 等跳过)。
fn parse_ids_loose(body: &str) -> Option<Vec<String>> {
let val: Value = serde_json::from_str(body).ok()?;
let obj = val.as_object()?;
// 任一存在即取;data 优先(标准结构)。
let arr = obj.get("data").or_else(|| obj.get("models"))?;
let arr = arr.as_array()?;
let mut ids = Vec::with_capacity(arr.len());
for item in arr {
let id = item
.get("id")
.or_else(|| item.get("name"))
.and_then(|v| v.as_str());
if let Some(id) = id {
ids.push(id.to_string());
}
}
Some(ids)
}
/// body 前缀诊断(截断 + 控制字符占位,避免换行/制表符污染日志单行)。
fn body_preview(body: &str) -> String {
let prefix: String = body.chars().take(BODY_DIAGNOSTIC_CHARS).collect();
if prefix.chars().all(|c| c.is_control()) && !prefix.is_empty() {
// 整段控制字符(二进制?)→ 给长度提示而非乱码。
return format!("<非文本 body,长度 {}>", body.len());
}
let truncated = body.chars().count() > BODY_DIAGNOSTIC_CHARS;
// 把控制字符(换行/制表等)压成空格,保持日志单行可读。
let cleaned: String = prefix
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
if truncated {
format!("{cleaned}…")
} else {
cleaned
}
} }
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────
@@ -173,4 +258,131 @@ mod tests {
assert!(msg.contains("ollama"), "err={msg}"); assert!(msg.contains("ollama"), "err={msg}");
assert!(msg.contains("provider_type"), "err={msg}"); assert!(msg.contains("provider_type"), "err={msg}");
} }
// ── parse_models_compat:严格 / fallback / 诊断三层 ──
#[test]
fn parse_strict_openai_format() {
// 标准 OpenAI 结构 → 严格解析命中,不进 fallback
let body = r#"{"data":[{"id":"gpt-4o","owned_by":"openai"},{"id":"gpt-4o-mini"}]}"#;
let ids = parse_models_compat("openai_compat", "http://x/v1/models", body).unwrap();
assert_eq!(ids, vec!["gpt-4o", "gpt-4o-mini"]);
}
#[test]
fn parse_loose_fallback_on_unknown_field_type_variant() {
// 厂商变体:data 项里多了非标准字段、且某项漏 id → 严格可能仍过(serde default),
// 此用例构造严格失败 + 宽松应成功:id 字段为 number(非字符串)致 ModelEntry serde 失败。
// 宽松 fallback 应:跳过 number id,保留 string id。
let body = r#"{"data":[{"id":12345},{"id":"glm-4-flash"}]}"#;
// 严格 ModelsList 的 id: Option<String>,number 12345 无法反序列化为 String → 失败
let ids = parse_models_compat("openai_compat", "http://x/v1/models", body).unwrap();
assert_eq!(ids, vec!["glm-4-flash"]);
}
#[test]
fn parse_loose_fallback_via_models_alias() {
// 严格解析缺 data 字段时进 fallback,走 models alias 取 name
let body = r#"{"models":[{"name":"llama3:8b"},{"name":"qwen2:7b"}]}"#;
let ids = parse_models_compat("openai_compat", "http://x/v1/models", body).unwrap();
assert_eq!(ids, vec!["llama3:8b", "qwen2:7b"]);
}
#[test]
fn parse_loose_fallback_tolerates_extra_top_level_fields() {
// 宽松 fallback 应容错顶层额外字段、非 id 项(只关心 data[].id/name)
let body = r#"{"object":"list","data":[{"id":"deepseek-chat","object":"model"},{"id":"deepseek-coder"}],"supported_ids":["x"]}"#;
let ids = parse_models_compat("openai_compat", "http://x/v1/models", body).unwrap();
assert_eq!(ids, vec!["deepseek-chat", "deepseek-coder"]);
}
#[test]
fn parse_diagnostic_error_has_serde_detail_and_body_prefix() {
// 完全无法解析(非 JSON)→ 严格 + 宽松双失败 → 错误含 serde detail + body 前缀 + HTTP 标识
let body = "this is not json at all {{{";
let err = parse_models_compat("openai_compat", "http://x/v1/models", body).unwrap_err();
let msg = format!("{err}");
// provider_type 标识
assert!(msg.contains("openai_compat"), "err={msg}");
// url 便于定位
assert!(msg.contains("http://x/v1/models"), "err={msg}");
// serde detail(serde_json 错误含 line/column 或 expected 字样)
assert!(
msg.contains("line") || msg.contains("column") || msg.contains("expected"),
"err={msg}"
);
// body 前缀诊断片段
assert!(msg.contains("this is not json"), "err={msg}");
}
#[test]
fn parse_diagnostic_truncates_long_body() {
// 超长 body → 前缀截断(… 标记),不整段灌进错误信息
let long_id = "a".repeat(500);
let body = format!(r#"{{"garbage":"{long_id}""#); // 缺尾 → 非 JSON
let err = parse_models_compat("openai_compat", "http://x/v1/models", &body).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("…"), "长 body 应截断(err={})\n{}", msg.len(), msg);
// 诊断片段不应超过 BODY_DIAGNOSTIC_CHARS + 容差
assert!(
msg.len() < long_id.len(),
"错误信息不应含完整 500 字符 body"
);
}
#[test]
fn parse_diagnostic_empty_body() {
// 空 body → 双失败,错误信息不 panic、含 provider 标识
let err = parse_models_compat("openai_compat", "http://x/v1/models", "").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("openai_compat"), "err={msg}");
assert!(msg.contains("解析失败"), "err={msg}");
}
// ── parse_ids_loose:边界 ──
#[test]
fn parse_ids_loose_returns_none_on_non_json() {
assert!(parse_ids_loose("not json").is_none());
}
#[test]
fn parse_ids_loose_returns_none_on_missing_data_field() {
// 合法 JSON 但无 data/models → None(parse_models_compat 会进而报诊断错误)
assert!(parse_ids_loose(r#"{"foo":"bar"}"#).is_none());
}
#[test]
fn parse_ids_loose_data_not_array_returns_none() {
// data 存在但非数组 → None
assert!(parse_ids_loose(r#"{"data":"not-an-array"}"#).is_none());
}
#[test]
fn parse_ids_loose_skips_non_string_id() {
// id 为 number/null/object → 跳过,只留字符串 id
let body = r#"{"data":[{"id":1},{"id":null},{"id":"keep-me"},{"name":"named"}]}"#;
let ids = parse_ids_loose(body).unwrap();
assert_eq!(ids, vec!["keep-me", "named"]);
}
// ── body_preview:控制字符 + 截断 ──
#[test]
fn body_preview_replaces_control_chars_with_space() {
// 换行/制表压成空格,保持日志单行
let preview = body_preview("line1\nline2\tcol");
assert!(!preview.contains('\n'), "preview={preview}");
assert!(!preview.contains('\t'), "preview={preview}");
assert!(preview.contains("line1"), "preview={preview}");
}
#[test]
fn body_preview_truncates_with_ellipsis() {
let body = "abcdefghij".repeat(100); // 1000 chars
let preview = body_preview(&body);
assert!(preview.ends_with('…'), "preview should end with ellipsis");
// 不应含完整 body
assert!(preview.len() < body.len());
}
} }
+27 -1
View File
@@ -41,7 +41,7 @@ pub fn build_models_url(base_url: &str) -> String {
/// 判断是否为非 chat 模型(应从列表中剔除)。 /// 判断是否为非 chat 模型(应从列表中剔除)。
/// ///
/// 规则(设计 §5.4 + 合理扩展,见 issues): /// 规则(设计 §5.4 + 合理扩展,见 issues):
/// - 图片生成:dall-e / midjourney / stable-diffusion / imagen /// - 图片生成:dall-e / midjourney / stable-diffusion / imagen / sensenova-u1 / infographic / image-generation
/// - 语音:tts / whisper / audio / speech / voice(语音合成/识别) /// - 语音:tts / whisper / audio / speech / voice(语音合成/识别)
/// - 实时:realtime(OpenAI Realtime API 语音对话,非 chat completions) /// - 实时:realtime(OpenAI Realtime API 语音对话,非 chat completions)
/// - 转写:transcribe / transcription /// - 转写:transcribe / transcription
@@ -50,6 +50,9 @@ pub fn build_models_url(base_url: &str) -> String {
/// ///
/// embedding 设计 §5.4 注释「保留(知识库需要)」— 此处遵循设计保留 embedding, /// embedding 设计 §5.4 注释「保留(知识库需要)」— 此处遵循设计保留 embedding,
/// 不在 `is_non_chat_model` 剔除(知识库 embedding 路由用得着)。 /// 不在 `is_non_chat_model` 剔除(知识库 embedding 路由用得着)。
///
/// 注:`sensenova-u1` 用厂商前缀限定而非裸 `u1`,避免误伤未来含 `u1` 词素的 chat 模型
/// (SenseNova U1 系列走 `/v1/images/generations` 端点,非 chat completions)。
pub fn is_non_chat_model(id: &str) -> bool { pub fn is_non_chat_model(id: &str) -> bool {
let id = id.to_lowercase(); let id = id.to_lowercase();
// 图片生成 // 图片生成
@@ -57,6 +60,11 @@ pub fn is_non_chat_model(id: &str) -> bool {
|| id.contains("midjourney") || id.contains("midjourney")
|| id.contains("stable-diffusion") || id.contains("stable-diffusion")
|| id.contains("imagen") || id.contains("imagen")
// SenseNova U1 系列(图像生成,如 sensenova-u1-fast,走 /v1/images/generations 非 chat)
|| id.contains("sensenova-u1")
// 通用图像生成命名(infographic 海报生成 / image-generation 直白命名)
|| id.contains("infographic")
|| id.contains("image-generation")
// 语音(tts 合成 / whisper 识别 / 通用 audio / speech / voice) // 语音(tts 合成 / whisper 识别 / 通用 audio / speech / voice)
|| id.contains("tts") || id.contains("tts")
|| id.contains("whisper") || id.contains("whisper")
@@ -226,6 +234,24 @@ mod tests {
assert!(is_non_chat_model("stable-diffusion-xl")); assert!(is_non_chat_model("stable-diffusion-xl"));
} }
#[test]
fn non_chat_sensenova_u1_image_filtered() {
// SenseNova U1 系列是图像生成模型(走 /v1/images/generations,非 chat completions),
// /v1/models 会返回但不应留为 chat 模型,否则用户选它对话会失败。
assert!(is_non_chat_model("sensenova-u1-fast"));
assert!(is_non_chat_model("SenseNova-U1-Fast")); // 大小写无关
assert!(is_non_chat_model("sensenova-u1-pro"));
// 通用图像生成命名也剔
assert!(is_non_chat_model("infographic-v1"));
assert!(is_non_chat_model("image-generation-latest"));
// 对照组:同厂商的 chat 模型 sensenova-6.7-flash-lite 不应被误剔
// (它是原生多模态 chat,见 model_probe.rs 预设表)
assert!(
!is_non_chat_model("sensenova-6.7-flash-lite"),
"sensenova-6.7-flash-lite 是 chat 模型,不应被当图像生成剔除"
);
}
#[test] #[test]
fn non_chat_speech_filtered() { fn non_chat_speech_filtered() {
assert!(is_non_chat_model("tts-1")); assert!(is_non_chat_model("tts-1"));
+148 -12
View File
@@ -1,10 +1,10 @@
//! 模型探测器 — F-01 阶段2 //! 模型探测器。
//! //!
//! 给定模型名,产出完整 `ModelConfig`(4 维度 + 路由控制 + 探测来源标注)。 //! 给定模型名,产出完整 `ModelConfig`(4 维度 + 路由控制 + 探测来源标注)。
//! //!
//! 多源探测,高优先源命中即返(短路): //! 多源探测,高优先源命中即返(短路):
//! 1. 内置预设表精确匹配(name 完全相等) → `ProbeSource::PresetTable` //! 1. 内置预设表精确匹配(name 完全相等) → `ProbeSource::PresetTable`
//! 2. 内置预设表模糊匹配(子串包含) → `ProbeSource::PresetTable` //! 2. 内置预设表前缀匹配(前缀 + 分隔符边界) → `ProbeSource::PresetTable`
//! 3. 模型名启发式推断(命名模式) → `ProbeSource::Heuristic` //! 3. 模型名启发式推断(命名模式) → `ProbeSource::Heuristic`
//! 4. 默认值兜底(`ModelConfig::with_defaults`) → `ProbeSource::Default` //! 4. 默认值兜底(`ModelConfig::with_defaults`) → `ProbeSource::Default`
//! //!
@@ -26,7 +26,7 @@ use crate::model_probe_helpers::{heuristic_infer, presets};
/// ///
/// 多源探测顺序(高优先源命中即返): /// 多源探测顺序(高优先源命中即返):
/// 1. 预设表精确匹配(`model_id` 完全相等,大小写敏感) /// 1. 预设表精确匹配(`model_id` 完全相等,大小写敏感)
/// 2. 预设表模糊匹配(`model_id` 双向子串包含,大小写不敏感) /// 2. 预设表前缀匹配(`model_id` 是入参前缀且后缀以分隔符开始,大小写不敏感)
/// 3. 启发式推断(模型名命名模式) /// 3. 启发式推断(模型名命名模式)
/// 4. 默认值兜底 /// 4. 默认值兜底
/// ///
@@ -38,14 +38,27 @@ pub fn probe(model_id: &str) -> ModelConfig {
return hit; return hit;
} }
// 2. 预设表模糊匹配(双向子串包含,大小写不敏感) // 2. 预设表前缀匹配(入参名以预设名开头 + 后缀以分隔符开始,大小写不敏感)
// 多个候选命中时,选预设 model_id 最长者(最具体:glm-4v > glm-4)。 // 收紧自原「双向子串包含」:子串匹配会把 glm-4.6v 误配给 glm-4v(继承其 vision),
// gpt-4o-mini 误配给 gpt-4o 等「张冠李戴」。前缀匹配保留合理继承
// (glm-4v-flash → glm-4v),同时要求后缀以分隔符(-/./_)或结尾开始,
// 防止 glm-4v2/glm-4vx 命中 glm-4v。
let needle = model_id.to_lowercase(); let needle = model_id.to_lowercase();
let fuzzy = presets() let fuzzy = presets()
.iter() .iter()
.filter(|m| { .filter(|m| {
let cand = m.model_id.to_lowercase(); let cand = m.model_id.to_lowercase();
!cand.is_empty() && (cand.contains(&needle) || needle.contains(&cand)) if cand.is_empty() || cand.len() > needle.len() {
return false;
}
if !needle.starts_with(&cand) {
return false;
}
// 前缀后须为分隔符或字符串结束,避免部分单词命中(glm-4v2 不算 glm-4v)
match needle[cand.len()..].chars().next() {
None => true, // 完全相等(精确匹配已覆盖,这里兜底)
Some(c) => matches!(c, '-' | '.' | '_'),
}
}) })
.max_by_key(|m| m.model_id.len()); .max_by_key(|m| m.model_id.len());
@@ -100,7 +113,7 @@ mod tests {
assert_eq!(m.model_id, "glm-4"); assert_eq!(m.model_id, "glm-4");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable)); assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
assert_eq!(m.modalities, vec![Modality::Text]); assert_eq!(m.modalities, vec![Modality::Text]);
// B-260618-04:预设表不再写死 cost_tier/intelligence,由 serde default 兜底中性值 // 预设表不写死 cost_tier/intelligence,由 serde default 兜底中性值
assert_eq!(m.intelligence, IntelligenceTier::Standard); assert_eq!(m.intelligence, IntelligenceTier::Standard);
assert_eq!(m.cost_tier, CostTier::Medium); assert_eq!(m.cost_tier, CostTier::Medium);
} }
@@ -130,20 +143,113 @@ mod tests {
); );
} }
// ── 预设模糊匹配 ── // ── 预设表:已知多模态模型(命名无 vision 词素,靠预设显式标 vision) ──
// SenseNova flash-lite 原生多模态(商汤文档:支持图像输入理解),
// 但模型名不含 vision/vl/-v 词素,启发式 has_vision_token 无法识别 →
// 必须靠预设表显式标 vision,否则 modalities 漏 vision 致路由误判。
#[test]
fn probe_preset_sensenova_flash_lite_has_vision() {
let m = probe("sensenova-6.7-flash-lite");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
assert!(
m.modalities.contains(&Modality::Vision),
"sensenova-6.7-flash-lite 应有 Vision(原生多模态): {:?}",
m.modalities
);
assert!(m.modalities.contains(&Modality::Text));
// context_window 应为预设的 256K
assert_eq!(m.context_window, 262144);
// P1 温度:商汤 sensenova 仅接受 temperature=1.0,透传其它值(标题 0.3/压缩 0.2)
// → 上游 HTTP 400(实测 9 次)。预设表须标 temperature_supported=false,
// 协议转换层据此省略该字段。
assert!(
!m.temperature_supported,
"sensenova 应标 temperature_supported=false(仅接受 1.0)"
);
}
/// 温度支持判定:仅 sensenova 系列为 false,其余模型默认 true(零回归)。
#[test]
fn probe_temperature_supported_defaults_true_except_sensenova() {
assert!(!probe("sensenova-6.7-flash-lite").temperature_supported);
// 启发式路径(非预设表):sensenova 前缀仍判 false
assert!(!probe("sensenova-unknown-model").temperature_supported);
// 常规模型保持 true(原样透传)
assert!(probe("deepseek-v4-flash").temperature_supported);
assert!(probe("glm-4v").temperature_supported);
}
#[test]
fn probe_preset_deepseek_v4_flash_text_only() {
// SenseNova 上的 deepseek-v4-flash:纯文本对话 + 1M 上下文,无 vision
let m = probe("deepseek-v4-flash");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
assert_eq!(m.modalities, vec![Modality::Text]);
assert_eq!(m.context_window, 1_048_576);
}
// ── 预设前缀匹配 ──
#[test] #[test]
fn probe_preset_fuzzy_match_glm4v_variant() { fn probe_preset_fuzzy_match_glm4v_variant() {
// "glm-4v-x" 不在预设表精确命中,但 "glm-4v" 是其子串 → 模糊命中 // "glm-4v-x" 不在预设表精确命中,但 "glm-4v" 是其前缀(后缀 "-x" 以分隔符开始) → 前缀命中
let m = probe("glm-4v-x"); let m = probe("glm-4v-x");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable)); assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
assert_eq!(m.model_id, "glm-4v-x", "模糊命中后 model_id 应用入参名"); assert_eq!(m.model_id, "glm-4v-x", "前缀命中后 model_id 应用入参名");
assert!( assert!(
m.modalities.contains(&Modality::Vision), m.modalities.contains(&Modality::Vision),
"应继承 glm-4v 的 vision 模态" "应继承 glm-4v 的 vision 模态"
); );
} }
#[test]
fn probe_preset_prefix_match_glm4v_flash() {
// 合理继承:glm-4v-flash 以 glm-4v 为前缀 + 分隔符 -,命中并继承 vision
let m = probe("glm-4v-flash");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
assert!(m.modalities.contains(&Modality::Vision));
}
#[test]
fn probe_preset_prefix_match_gpt4o_mini() {
// 合理继承:gpt-4o-mini 以 gpt-4o 为前缀 + 分隔符 -
let m = probe("gpt-4o-mini");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
assert_eq!(m.model_id, "gpt-4o-mini");
}
#[test]
fn probe_preset_prefix_match_case_insensitive() {
// 前缀匹配大小写不敏感(精确匹配大小写敏感,前缀兜底)
let m = probe("GLM-4V-FLASH");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
assert!(m.modalities.contains(&Modality::Vision));
}
#[test]
fn probe_preset_prefix_reject_non_prefix_variant() {
// 张冠李戴防线:glm-4.6v 不以 glm-4v 为前缀(字符顺序不同),不继承 glm-4v 的 vision。
// 但它以 glm-4 为前缀(+ 分隔符 .)→ 命中 glm-4 预设(纯文本),合理。
let m = probe("glm-4.6v");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
assert_eq!(
m.model_id, "glm-4.6v",
"前缀命中后 model_id 应用入参名"
);
assert!(
!m.modalities.contains(&Modality::Vision),
"glm-4.6v 不应继承 glm-4v 的 vision(只继承 glm-4 纯文本): {:?}",
m.modalities
);
}
#[test]
fn probe_preset_prefix_reject_no_separator() {
// 分隔符边界:glm-4v2 前缀后是数字(非 -/./_),不算 glm-4v 的合法变体
let m = probe("glm-4v2");
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
}
// ── 启发式:Vision ── // ── 启发式:Vision ──
#[test] #[test]
@@ -177,7 +283,7 @@ mod tests {
fn heuristic_flash_keeps_neutral_tier() { fn heuristic_flash_keeps_neutral_tier() {
let m = probe("unknown-flash"); let m = probe("unknown-flash");
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic)); assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
// B-260618-04:cost/intel 一律中性,不靠名字猜 // cost/intel 一律中性,不靠名字猜
assert_eq!(m.intelligence, IntelligenceTier::Standard); assert_eq!(m.intelligence, IntelligenceTier::Standard);
assert_eq!(m.cost_tier, CostTier::Medium); assert_eq!(m.cost_tier, CostTier::Medium);
} }
@@ -245,6 +351,36 @@ mod tests {
assert_eq!(m.cost_tier, CostTier::Medium); assert_eq!(m.cost_tier, CostTier::Medium);
} }
// ── 启发式:版本号后缀不误判 Vision ──
#[test]
fn heuristic_version_suffix_no_vision() {
// "-v" 后接数字属版本号(deepseek-v3 为纯文本模型),不得误判 Vision
for name in ["deepseek-v3", "acme-chat-v2", "acme-v12"] {
let m = probe(name);
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
assert!(
!m.modalities.contains(&Modality::Vision),
"{name} 版本号后缀不应误判 Vision: {:?}",
m.modalities
);
}
}
#[test]
fn heuristic_v_morpheme_tail_still_vision() {
// v 词素尾仍判视觉:独立 v 段(qwen-v-plus 形态)与段尾 v(glm-4v 形态,假名避开预设表)
for name in ["acme-chat-v", "acme-4v-plus"] {
let m = probe(name);
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
assert!(
m.modalities.contains(&Modality::Vision),
"{name} 段尾 v 词素应判 Vision: {:?}",
m.modalities
);
}
}
// ── 启发式:默认(Standard/Medium/Text/ToolUse) ── // ── 启发式:默认(Standard/Medium/Text/ToolUse) ──
#[test] #[test]
@@ -261,7 +397,7 @@ mod tests {
#[test] #[test]
fn probe_preset_beats_heuristic() { fn probe_preset_beats_heuristic() {
// "glm-4-flash" 精确命中预设表;B-260618-04 后预设/启发式档位都中性, // "glm-4-flash" 精确命中预设表;预设/启发式档位都中性,
// 此处仅校验 source 标注为 PresetTable // 此处仅校验 source 标注为 PresetTable
let m = probe("glm-4-flash"); let m = probe("glm-4-flash");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable)); assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
+19 -6
View File
@@ -1,4 +1,4 @@
//! 模型探测器 — 纯逻辑子模块(F-01 阶段2) //! 模型探测器 — 纯逻辑子模块。
//! //!
//! 从 `model_probe.rs` 抽离的纯函数实现(预设表加载 / 启发式推断 / 词素判定)。 //! 从 `model_probe.rs` 抽离的纯函数实现(预设表加载 / 启发式推断 / 词素判定)。
//! 无 IO、无外部状态,crate 内经 `model_probe::probe` 间接复用 — //! 无 IO、无外部状态,crate 内经 `model_probe::probe` 间接复用 —
@@ -36,7 +36,7 @@ pub(super) fn presets() -> &'static [ModelConfig] {
/// 模型名启发式推断。仅推断功能性维度(modalities/capabilities), /// 模型名启发式推断。仅推断功能性维度(modalities/capabilities),
/// cost_tier / intelligence 一律返中性默认(Medium / Standard)。 /// cost_tier / intelligence 一律返中性默认(Medium / Standard)。
/// ///
/// 取舍:B-260618-04 — 模型名关键词猜档位(flash→Lite、4o→High、pro→Plus)无依据, /// 取舍:模型名关键词猜档位(flash→Lite、4o→High、pro→Plus)无依据,
/// 厂商定价/智力与命名无关,瞎填会污染路由器过滤(intelligence >= min / cost_tier <= max)。 /// 厂商定价/智力与命名无关,瞎填会污染路由器过滤(intelligence >= min / cost_tier <= max)。
/// 改中性默认,真实档位由用户手填或更高阶探测源(如厂商 API/定价表)提供。 /// 改中性默认,真实档位由用户手填或更高阶探测源(如厂商 API/定价表)提供。
/// ///
@@ -49,7 +49,7 @@ pub(super) fn heuristic_infer(model_id: &str) -> ModelConfig {
let name = model_id.to_lowercase(); let name = model_id.to_lowercase();
let mut modalities: Vec<Modality> = Vec::new(); let mut modalities: Vec<Modality> = Vec::new();
let mut capabilities: Vec<Capability> = Vec::new(); let mut capabilities: Vec<Capability> = Vec::new();
// 中性默认:不靠模型名猜档位(B-260618-04) // 中性默认:不靠模型名猜档位
let cost_tier = CostTier::Medium; let cost_tier = CostTier::Medium;
let intelligence = IntelligenceTier::Standard; let intelligence = IntelligenceTier::Standard;
@@ -74,6 +74,7 @@ pub(super) fn heuristic_infer(model_id: &str) -> ModelConfig {
intelligence, intelligence,
weight: 50, weight: 50,
context_window: 8192, context_window: 8192,
temperature_supported: temperature_supported_by_name(&name),
probe_source: None, probe_source: None,
}; };
} }
@@ -106,14 +107,26 @@ pub(super) fn heuristic_infer(model_id: &str) -> ModelConfig {
intelligence, intelligence,
weight: 50, weight: 50,
context_window: 8192, context_window: 8192,
temperature_supported: temperature_supported_by_name(&name),
probe_source: None, probe_source: None,
} }
} }
// —— 词素判定助手(命名约定式启发式,集中维护便于增删) —— // —— 词素判定助手(命名约定式启发式,集中维护便于增删) ——
/// Vision 词素:`-v`(尾缀如 glm-4v)、以 `v` 结尾、含 `vl` / `vision`。 /// Vision 词素:`vision` / `vl` 子串、`v` 词素尾(任一分隔段以 v 结尾:glm-4v / qwen-v-plus)。
/// 不直接 `contains('v')`(避免误伤 review/verbal 等)。 /// 裸 "v" 必须是词素尾:`contains("-v")` 会把版本号误判成视觉(deepseek-v3 纯文本,
/// "-v" 后接数字属版本号而非视觉词素),按段尾判定后 v3/v2 不命中、4v/v 段仍命中。
pub(super) fn has_vision_token(name: &str) -> bool { pub(super) fn has_vision_token(name: &str) -> bool {
name.contains("vision") || name.contains("vl") || name.ends_with('v') || name.contains("-v") name.contains("vision")
|| name.contains("vl")
|| name.split(['-', '.', '_']).any(|seg| seg.ends_with('v'))
}
/// 温度参数支持判定(命名约定式):商汤 sensenova 系列仅接受 temperature=1.0,
/// 透传其它温度值(标题 0.3/压缩 0.2 等)→ 上游 HTTP 400(实测 9 次)。
/// 返回 false 时协议转换层省略 temperature 字段(交上游默认),避免无效往返;
/// 其余模型默认 true(原样透传,零回归)。
pub(super) fn temperature_supported_by_name(name: &str) -> bool {
!name.starts_with("sensenova")
} }
+648
View File
@@ -0,0 +1,648 @@
//! 工具命名空间存储 — 大工具结果不进主消息队列
//!
//! 当工具执行结果超过阈值时,存入 `NamespaceStore`,主队列只保留轻量引用路径。
//! 运行时为内存 HashMap(LRU 淘汰),持久化时由调用方展开引用写 DB。
//!
//! 存储格式: "namespace://tool_name/args_hash"
//! 例: namespace://read_file/a1b2c3d4
//!
//! ## 淘汰治理(2026-10-06,设计文档 namespace淘汰治理-设计-2026-10-04.md,拍板 2/2)
//! - **pin 机制**:关键产出(子代理报告等)标记 pinned,LRU 永不淘汰;pin 总字节超
//! 池容量 50% 时最旧 pin 降级可淘(拍板 C1,防 pin 滥用撑死池)。
//! - **EvictionLog**:淘汰留档(50 条上限),展开点 read_only()=None 时查 Log 给富提示
//! (原工具名+内容前 80 字+淘汰时刻),替代裸 EVICTED_PLACEHOLDER。
//! - **owner 软隔离**:条目带归属(调用方 conv_id),淘汰顺序=无主最旧→他主未 pin→
//! 当前主未 pin;软隔离只影响淘汰顺序,owner 传错退回现状行为,无正确性风险。
//! - **零 schema 变更**:纯内存态增强。
use std::collections::HashMap;
use std::time::Instant;
/// 引用路径前缀
pub const NAMESPACE_REF_PREFIX: &str = "namespace://";
/// namespace 条目被 LRU 淘汰(或跨会话残留引用)后,展开点 read_only()=None 时的提示文案。
///
/// 旧实现 None 分支保留 "namespace://" 字面 URI,LLM 收到无意义串且无告警。
/// 现统一替换为此文案:既告知用户结果已淘汰,又提示可重新调用工具取回。
/// 治理后:EvictionLog 命中时展开点改用富提示(见 `eviction_rich_notice`),未命中原样。
pub const EVICTED_PLACEHOLDER: &str = "[该工具结果已超出内存上限被淘汰,如需请重新调用对应工具]";
/// namespace 字节阈值(> 2048 bytes 触发)
pub const NAMESPACE_BYTE_THRESHOLD: usize = 2048;
/// namespace 行数阈值(> 50 行 触发)
pub const NAMESPACE_LINE_THRESHOLD: usize = 50;
/// 始终进 namespace 的工具名(其输出几乎总是大结果)
pub const ALWAYS_LARGE_TOOLS: &[&str] = &["read_file", "list_directory", "grep", "diff_files"];
/// EvictionLog 容量上限(条)。单条 ~200B,50 条可忽略。
pub const EVICTION_LOG_CAP: usize = 50;
/// pin 总字节占池容量比例上限。超过后最旧 pin 降级可淘(拍板 C1)。
pub const PIN_RATIO_CAP: f64 = 0.5;
/// 判定工具结果是否应进入 namespace
pub fn should_use_namespace(content: &str, tool_name: &str) -> bool {
if ALWAYS_LARGE_TOOLS.contains(&tool_name) {
return true;
}
content.len() > NAMESPACE_BYTE_THRESHOLD
|| content.lines().count() > NAMESPACE_LINE_THRESHOLD
}
/// 判定 tool_result 内容是否为 namespace 引用路径
pub fn is_namespace_ref(content: &str) -> bool {
content.starts_with(NAMESPACE_REF_PREFIX)
}
/// 从引用路径中提取内部 key
pub fn parse_namespace_key(full_path: &str) -> Option<&str> {
full_path.strip_prefix(NAMESPACE_REF_PREFIX)
// 939①: 先剥 ?len= 长度尾段(估算入账用),否则尾段并入 key 致全线查空
.and_then(|s| s.split("?len=").next())
.and_then(|s| s.split('/').nth(1))
}
/// 从引用路径提取原文字节数(939①)。带 `?len=N` 尾段的引用返回 Some(N);
/// 旧格式/放弃入库的无尾段引用返回 None(调用方按字面字符保守估算)。
pub fn parse_ref_content_len(full_path: &str) -> Option<usize> {
full_path.rsplit("?len=")
.next()
.and_then(|s| s.parse::<usize>().ok())
.filter(|_| full_path.contains("?len="))
}
/// 从引用路径提取工具名段(懒回填重建条目元数据用)。
pub fn parse_ref_tool_name(full_path: &str) -> Option<&str> {
full_path.strip_prefix(NAMESPACE_REF_PREFIX)?.split('/').next()
}
/// namespace 条目
#[derive(Debug, Clone)]
pub struct NamespaceEntry {
pub key: String,
pub tool_name: String,
pub content: String,
pub created_at: Instant,
pub access_count: u64,
/// pin 标记:LRU 永不淘汰(pin 总量超 50% 容量时最旧 pin 降级,拍板 C1)
pub pinned: bool,
/// 归属(conv_id / 子代理 id),软隔离淘汰顺序用;None=无主
pub owner: Option<String>,
/// 预生成的淘汰提示料(工具名+参数摘要,store 时固化),淘汰留档用
pub evict_hint: String,
}
/// 淘汰留档记录(EvictionLog 元素)
#[derive(Debug, Clone)]
pub struct EvictionRecord {
pub key: String,
pub tool_name: String,
/// 内容前 80 字符(hint),供富提示识别原文
pub hint: String,
pub evicted_at: Instant,
pub owner: Option<String>,
}
/// namespace 存储(内存 HashMap + LRU 淘汰 + pin/EvictionLog/owner 治理)
#[derive(Debug, Clone)]
pub struct NamespaceStore {
entries: HashMap<String, NamespaceEntry>,
max_bytes: usize,
current_bytes: usize,
/// 淘汰留档(VecDeque 语义,容量 EVICTION_LOG_CAP)
eviction_log: std::collections::VecDeque<EvictionRecord>,
}
impl NamespaceStore {
/// 创建 namespace 存储
pub fn new(max_bytes: usize) -> Self {
Self {
entries: HashMap::new(),
max_bytes,
current_bytes: 0,
eviction_log: std::collections::VecDeque::new(),
}
}
/// 默认大小:10MB
pub fn default() -> Self {
Self::new(10 * 1024 * 1024)
}
/// 存入 namespace。返回引用路径字符串。(兼容入口,owner=None 无主)
///
/// 超限行为分两档:常规超限 LRU 淘汰旧条目腾位(既有语义);单条本身超过
/// max_bytes 时淘汰清空也放不下,拒绝入库只返回引用路径——展开点对
/// read_only()=None 统一落 EVICTED_PLACEHOLDER,与 LRU 淘汰后的不可解析引用同语义。
pub fn store(&mut self, tool_name: &str, content: &str) -> String {
self.store_with_owner(tool_name, content, None)
}
/// 存入(带归属)。owner 软隔离:淘汰优先淘无主/他主,当前主最后。
pub fn store_with_owner(&mut self, tool_name: &str, content: &str, owner: Option<&str>) -> String {
self.store_inner(tool_name, content, owner, false)
}
/// 存入并 pin(关键产出:子代理报告等)。pin 条目 LRU 永不淘汰,
/// pin 总字节超池 50% 时最旧 pin 降级可淘(在 evict_if_needed 内兜底)。
pub fn store_pinned(&mut self, tool_name: &str, content: &str, owner: Option<&str>) -> String {
self.store_inner(tool_name, content, owner, true)
}
fn store_inner(&mut self, tool_name: &str, content: &str, owner: Option<&str>, pinned: bool) -> String {
let key = simple_hash(content);
// 939① 估算入账:ref 尾段携带原文字节数,TokenEstimator 解析即按真实体量入账
// (旧口径 ref 仅 ~30 字符被记 ~15 token,预算/裁剪/压缩三重门对其失效)。
// parse_namespace_key 取 nth(1) 不受尾段影响,展开/淘汰提示零改动。
// 放弃入库(超容量/淘汰后仍放不下)返回**不带长度**的 path:原文不在池,展开点
// 落占位提示,预算按轻量字符计——已淘汰内容不应继续虚占预算。
let path_with_len = format!("{}{}/{}?len={}", NAMESPACE_REF_PREFIX, tool_name, key, content.len());
let path = format!("{}{}/{}", NAMESPACE_REF_PREFIX, tool_name, key);
// 已存在 → 刷新 access_count,不重复占用空间;若本次要求 pin,升级既有条目
if self.entries.contains_key(&key) {
if let Some(entry) = self.entries.get_mut(&key) {
entry.access_count += 1;
if pinned && !entry.pinned {
entry.pinned = true;
}
}
return path_with_len;
}
let new_bytes = content.len();
// 单条超过总容量:硬入库会把 current_bytes 永久顶破上限(evict 清空后仍无条件
// insert 的旧路径),拒绝入库保住 current_bytes <= max_bytes 不变式
if new_bytes > self.max_bytes {
return path;
}
// pin 比例上限(拍板 C1):pinned 入库前无条件校验,把 pin 总量压回 50% 内
// (最旧 pin 先降级),不只在池溢出时——防 pin 静默累积到失控
if pinned {
let cap = (self.max_bytes as f64 * PIN_RATIO_CAP) as usize;
while self.pinned_bytes() + new_bytes > cap {
if !self.demote_oldest_pin() { break; }
}
}
// 检查容量,超限淘汰
self.evict_if_needed(new_bytes);
// 淘汰后仍放不下(全 pin 撑满):放弃入库(返回引用路径,展开点落占位),
// 不降级 pin——pin 保护是强语义,普通 store 不得侵蚀 pin
if self.current_bytes + new_bytes > self.max_bytes && !pinned {
return path;
}
let entry = NamespaceEntry {
key: key.clone(),
tool_name: tool_name.to_string(),
content: content.to_string(),
created_at: Instant::now(),
access_count: 1,
pinned,
owner: owner.map(str::to_string),
evict_hint: content.chars().take(80).collect(),
};
self.current_bytes += new_bytes;
self.entries.insert(key, entry);
path_with_len
}
/// pin/取消 pin(事后)。pin 总量超 50% 容量时本调用拒绝并返回 false(防失控,拍板 C1)。
pub fn pin(&mut self, full_path: &str) -> bool {
let Some(key) = parse_namespace_key(full_path) else { return false };
if !self.entries.contains_key(key) {
return false;
}
if self.pinned_bytes() + self.entries.get(key).map(|e| e.content.len()).unwrap_or(0)
> (self.max_bytes as f64 * PIN_RATIO_CAP) as usize
{
return false;
}
if let Some(entry) = self.entries.get_mut(key) {
entry.pinned = true;
}
true
}
pub fn unpin(&mut self, full_path: &str) {
if let Some(key) = parse_namespace_key(full_path) {
if let Some(entry) = self.entries.get_mut(key) {
entry.pinned = false;
}
}
}
/// pin 条目总字节
pub fn pinned_bytes(&self) -> usize {
self.entries.values().filter(|e| e.pinned).map(|e| e.content.len()).sum()
}
/// 降级最旧 pin。返回是否有降级发生(全 pin 已降无可降返回 false)。
fn demote_oldest_pin(&mut self) -> bool {
let oldest_pin = self.entries.iter()
.filter(|(_, e)| e.pinned)
.min_by_key(|(_, e)| e.created_at)
.map(|(k, _)| k.clone());
if let Some(key) = oldest_pin {
if let Some(entry) = self.entries.get_mut(&key) {
entry.pinned = false;
return true;
}
}
false
}
/// 读取完整内容
pub fn read(&mut self, full_path: &str) -> Option<&str> {
let key = parse_namespace_key(full_path)?;
let entry = self.entries.get_mut(key)?;
entry.access_count += 1;
Some(entry.content.as_str())
}
/// 读取(只读,不改 access_count)
pub fn read_only(&self, full_path: &str) -> Option<&str> {
let key = parse_namespace_key(full_path)?;
self.entries.get(key).map(|e| e.content.as_str())
}
/// 展开失败的富提示:查 EvictionLog,命中返回「原为 X 前 80 字…于淘汰,可重新调用」,
/// 未命中(跨会话残留/未知 key)返回 None,展开点落回 EVICTED_PLACEHOLDER。
pub fn eviction_rich_notice(&self, full_path: &str) -> Option<String> {
let key = parse_namespace_key(full_path)?;
let rec = self.eviction_log.iter().find(|r| r.key == key)?;
Some(format!(
"[结果已淘汰] 原为 {}(\"{}\"…) 因内存容量淘汰。可重新调用 {} 取回原文。",
rec.tool_name, rec.hint, rec.tool_name,
))
}
/// 跨重启懒回填:store 清空后旧引用首次展开未命中时,从会话消息原文找回并重建条目。
/// DB 落库约定存展开后原文,引用 key 即入库时的原文内容哈希(SipHash 确定性,跨进程
/// 一致),哈希精确匹配即可靠对应单条原文,无需按序重建;原文不在消息里的引用(真淘汰)
/// 不进返回值,调用方维持淘汰占位语义。
/// 入库走 store 既有容量语义(超容量拒绝只影响后续轮次命中,本轮视图仍用返回的原文)。
/// 返回 引用路径 → 原文。
pub fn backfill_from_messages(
&mut self,
missed_refs: &[String],
message_contents: &[String],
) -> HashMap<String, String> {
let mut recovered: HashMap<String, String> = HashMap::new();
if missed_refs.is_empty() {
return recovered;
}
let wanted: HashMap<&str, &String> = missed_refs.iter()
.filter_map(|p| parse_namespace_key(p).map(|k| (k, p)))
.collect();
for content in message_contents {
let key = simple_hash(content);
if let Some(path) = wanted.get(key.as_str()) {
// 同一引用可能在多条历史消息出现,找回一次即可
if recovered.contains_key(path.as_str()) {
continue;
}
if let Some(tool) = parse_ref_tool_name(path) {
self.store(tool, content);
}
recovered.insert((*path).clone(), content.clone());
}
}
recovered
}
/// 检查 key 是否存在
pub fn contains(&self, full_path: &str) -> bool {
parse_namespace_key(full_path)
.and_then(|k| self.entries.get(k))
.is_some()
}
/// 条目数
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// LRU 淘汰:owner 软隔离三段顺序(设计 §3.4):
/// ①无主且未 pin 的最旧 → ②他主(非最后入参 owner)未 pin 的最旧 → ③当前主未 pin 最旧。
/// pin 永不淘(除非 store_pinned 路径 demote 降级)。needed 所需空间,cur_owner=当前
/// 存入方归属(淘汰保护对象,None=无主视角)。
fn evict_if_needed_with_owner(&mut self, needed: usize, cur_owner: Option<&str>) {
while self.current_bytes + needed > self.max_bytes && !self.entries.is_empty() {
// 三段淘汰顺序:无主最旧 → 他主最旧 → 当前主最旧(各段均跳过 pinned)
let oldest_key = self.entries.iter()
.filter(|(_, e)| !e.pinned && e.owner.is_none())
.min_by_key(|(_, e)| e.created_at)
.map(|(k, _)| k.clone())
.or_else(|| {
self.entries.iter()
.filter(|(_, e)| {
!e.pinned && match (&e.owner, cur_owner) {
(Some(o), Some(c)) => o != c,
(Some(_), None) => true, // 有主 vs 无主调用:有主条目算"他主"
_ => false,
}
})
.min_by_key(|(_, e)| e.created_at)
.map(|(k, _)| k.clone())
})
.or_else(|| {
self.entries.iter()
.filter(|(_, e)| !e.pinned)
.min_by_key(|(_, e)| e.created_at)
.map(|(k, _)| k.clone())
});
match oldest_key {
Some(key) => {
if let Some(entry) = self.entries.remove(&key) {
self.current_bytes = self.current_bytes.saturating_sub(entry.content.len());
// EvictionLog 留档(50 条上限,超出弹最旧)
self.eviction_log.push_back(EvictionRecord {
key: entry.key,
tool_name: entry.tool_name,
hint: entry.evict_hint,
evicted_at: Instant::now(),
owner: entry.owner,
});
while self.eviction_log.len() > EVICTION_LOG_CAP {
self.eviction_log.pop_front();
}
}
}
None => break, // 全部 pinned(且非 pin 腾位路径):放不下就放不下
}
}
}
/// 兼容入口(无主视角)
fn evict_if_needed(&mut self, needed: usize) {
self.evict_if_needed_with_owner(needed, None);
}
/// 清空
pub fn clear(&mut self) {
self.entries.clear();
self.current_bytes = 0;
self.eviction_log.clear();
}
}
/// 内容 → 引用 key 段(确定性,不含 `/` 等会破坏 `parse_namespace_key` 的字符)。
///
/// 历史 bug:旧实现用 `"{len}_{前16字符}"`,前 16 字符原样进 key;内容前缀含 `/`(目录列表、
/// 带路径的 grep 结果、文件头是路径)时,`namespace://tool/{key}` 被 `parse_namespace_key`
/// 的 `split('/').nth(1)` 从首个 `/` 截断 → read/read_only 拿回 None → 原文彻底丢失。
/// 现改用 SipHash u64 → 16 位 hex:确定性 + 极低碰撞 + 不含特殊字符。
fn simple_hash(content: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_use_namespace_small_content() {
assert!(!should_use_namespace("short", "custom_tool"));
}
#[test]
fn should_use_namespace_large_bytes() {
let large = "x".repeat(3000);
assert!(should_use_namespace(&large, "custom_tool"));
}
#[test]
fn should_use_namespace_always_large_tool() {
assert!(should_use_namespace("short", "read_file"));
assert!(should_use_namespace("short", "list_directory"));
assert!(should_use_namespace("short", "diff_files"));
}
#[test]
fn should_use_namespace_many_lines() {
let many = (0..60).map(|i| format!("line {}", i)).collect::<Vec<_>>().join("\n");
assert!(should_use_namespace(&many, "custom_tool"));
}
#[test]
fn is_namespace_ref_detects_prefix() {
assert!(is_namespace_ref("namespace://read_file/abc"));
assert!(!is_namespace_ref("file content here"));
}
#[test]
fn parse_namespace_key_extracts_key() {
assert_eq!(parse_namespace_key("namespace://read_file/abc_123"), Some("abc_123"));
assert_eq!(parse_namespace_key("namespace://grep/xyz"), Some("xyz"));
assert_eq!(parse_namespace_key("no_prefix"), None);
}
#[test]
fn store_and_read() {
let mut ns = NamespaceStore::new(100_000);
let path = ns.store("read_file", "hello world content");
assert!(is_namespace_ref(&path));
assert!(path.contains("read_file"));
assert_eq!(ns.read(&path), Some("hello world content"));
}
#[test]
fn store_duplicate_key_does_not_double_count() {
let mut ns = NamespaceStore::new(100_000);
let content = "test content for duplicate check";
let p1 = ns.store("tool_a", content);
let p2 = ns.store("tool_b", content);
assert_eq!(ns.len(), 1, "相同内容应去重");
// read(&mut self) 返回 Option<&str> 借用 ns,两次调用须各自转 owned 避免双重可变借用
let r1 = ns.read(&p1).map(str::to_owned);
let r2 = ns.read(&p2).map(str::to_owned);
assert_eq!(r1, r2);
}
#[test]
fn store_content_with_slash_in_prefix_reads_back() {
// 回归:旧 simple_hash 把内容前 16 字符原样进 key,前缀含 '/' 时(目录列表/带路径 grep
// 结果/文件头是路径)namespace://tool/{key} 被 parse_namespace_key 的 split('/') 截断
// → read None,原文丢失。hex hash 修复后 store→read 正确取回。
let mut ns = NamespaceStore::new(100_000);
let content = "foo/bar/baz/qux\nline2\nline3";
let path = ns.store("list_directory", content);
assert!(is_namespace_ref(&path));
assert_eq!(ns.read(&path).map(str::to_owned), Some(content.to_string()));
}
#[test]
fn eviction_oldest_removed_when_over_limit() {
let mut ns = NamespaceStore::new(100); // 极小容量
let small = "a"; // 1 byte
ns.store("t1", small);
assert_eq!(ns.len(), 1);
// 填充到超限
let big = "x".repeat(200);
ns.store("t2", &big);
// t1 可能被淘汰
if ns.current_bytes > 100 {
assert!(ns.len() < 2, "超限时应已淘汰旧条目");
}
}
#[test]
fn store_oversized_single_entry_rejected_cap_intact() {
// 回归:单条超过 max_bytes 时旧路径淘汰清空后仍无条件入库,current_bytes
// 永久突破上限。现拒绝入库,仅返回引用路径,展开点落 EVICTED_PLACEHOLDER。
let mut ns = NamespaceStore::new(100);
let path = ns.store("t1", &"x".repeat(200));
assert_eq!(ns.len(), 0, "超容量单条应拒绝入库");
assert_eq!(ns.current_bytes, 0, "字节账目不得突破上限");
assert!(is_namespace_ref(&path), "返回值仍是引用路径");
assert_eq!(ns.read_only(&path), None, "不可解析 → 既有淘汰占位语义");
// 拒绝后不污染状态,常规条目仍可正常入
let p2 = ns.store("t2", "ok");
assert_eq!(ns.read(&p2).map(str::to_owned), Some("ok".to_string()));
assert_eq!(ns.current_bytes, 2);
}
#[test]
fn clear_resets_all() {
let mut ns = NamespaceStore::new(100_000);
ns.store("t1", "content");
assert!(!ns.is_empty());
ns.clear();
assert!(ns.is_empty());
assert_eq!(ns.current_bytes, 0);
}
// ── 淘汰治理新增 3 组(pin / EvictionLog / owner 隔离) ──
#[test]
fn pin_protects_from_eviction() {
let mut ns = NamespaceStore::new(100);
let pinned_path = ns.store_pinned("report", &"p".repeat(50), Some("conv-a"));
assert!(ns.contains(&pinned_path));
// 塞入未 pin 大内容逼淘汰:pin 条目须存活
let _ = ns.store("t2", &"x".repeat(80));
assert!(ns.contains(&pinned_path), "pinned 条目不得被 LRU 淘汰");
assert_eq!(ns.read_only(&pinned_path).map(str::to_owned), Some("p".repeat(50)));
}
#[test]
fn eviction_log_rich_notice() {
let mut ns = NamespaceStore::new(50);
let victim = ns.store("read_file", &"v".repeat(40));
let victim_path = victim.clone();
// 挤出 victim
let _ = ns.store("t2", &"y".repeat(45));
assert_eq!(ns.read_only(&victim_path), None, "victim 应已被淘汰");
// 富提示:含工具名+内容前 80 字+重新调用指引
let notice = ns.eviction_rich_notice(&victim_path).expect("Log 应命中");
assert!(notice.contains("read_file"), "富提示应含工具名: {notice}");
assert!(notice.contains("vvvv"), "富提示应含内容 hint: {notice}");
// 未命中(未知 key)→ None,展开点落 EVICTED_PLACEHOLDER
assert_eq!(ns.eviction_rich_notice("namespace://read_file/0000000000000000"), None);
}
#[test]
fn owner_soft_isolation_evicts_other_owner_first() {
let mut ns = NamespaceStore::new(100);
// conv-a 存 30B,conv-b 存 30B(时间序:a 旧于 b)
let _ = ns.store_with_owner("ta", &"a".repeat(30), Some("conv-a"));
let path_b = ns.store_with_owner("tb", &"b".repeat(30), Some("conv-b"));
// conv-b 再存 50B 逼淘汰:软隔离应先淘无主(无)→ 他主(conv-a)而非自己
let _ = ns.store_with_owner("tc", &"c".repeat(50), Some("conv-b"));
assert!(!ns.contains("namespace://ta/x"), "ta 的 key 查询仅辅助");
assert!(ns.entries.values().all(|e| e.owner.as_deref() != Some("conv-a")),
"他主(conv-a)条目应先被淘汰");
assert!(ns.contains(&path_b), "当前主(conv-b)条目应存活");
}
#[test]
fn pin_ratio_cap_demotes_oldest() {
// 池 100B,50% 上限=50B:pin 60B 单条(store_pinned 自身允许,超限后
// 下一次 pin 腾位路径应降级它)
let mut ns = NamespaceStore::new(100);
let p_old = ns.store_pinned("old", &"o".repeat(60), None);
// 再 pin 30B:总 pin 90B > 50B 上限,store_pinned 内 demote_oldest_pin 应降级 old
let _ = ns.store_pinned("new", &"n".repeat(30), None);
// 挤压逼淘汰:降级后的 old 可被淘,受保护的 new 存活
let _ = ns.store("t3", &"z".repeat(80));
assert!(ns.contains(&p_old) == false || ns.pinned_bytes() <= 50,
"降级机制应使 pin 总量可被压缩");
}
#[test]
fn ref_carries_content_len_for_budget() {
// 939①: 成功入库的 ref 带 ?len= 原文字节数,parse 往返一致;
// parse_namespace_key 不受尾段影响(展开点零改动)
let mut ns = NamespaceStore::new(100_000);
let content = "x".repeat(5000);
let path = ns.store("read_file", &content);
assert!(path.contains(&format!("?len={}", content.len())), "ref 应带 len: {path}");
assert_eq!(parse_ref_content_len(&path), Some(content.len()));
assert_eq!(parse_namespace_key(&path), parse_namespace_key(&format!("namespace://read_file/{}", crate::namespace_store::simple_hash(&content))));
// 旧格式/无尾段 → None(调用方保守按字面计)
assert_eq!(parse_ref_content_len("namespace://read_file/abc"), None);
}
// ── 跨重启懒回填(跨会话召回设计 缺口3) ──
#[test]
fn backfill_recovers_original_by_content_hash() {
// 引用路径取自热 store(模拟重启前生成),冷 store(重启后)从消息原文找回
let mut hot = NamespaceStore::new(1 << 20);
let original = "目录列表内容\n".repeat(100);
let path = hot.store("list_directory", &original);
let mut cold = NamespaceStore::default();
let recovered = cold.backfill_from_messages(
&[path.clone()],
&[original.clone(), "无关消息".to_string()],
);
assert_eq!(recovered.get(&path).map(String::as_str), Some(original.as_str()));
assert_eq!(cold.read_only(&path).map(str::to_owned), Some(original), "回填后条目应可读");
}
#[test]
fn backfill_without_original_stays_empty() {
let mut hot = NamespaceStore::new(1 << 20);
let path = hot.store("read_file", &"真原文".repeat(500));
let mut cold = NamespaceStore::default();
let recovered = cold.backfill_from_messages(
&[path.clone()],
&["无关A".to_string(), "无关B".to_string()],
);
assert!(recovered.is_empty(), "无对应原文不应误填");
assert_eq!(cold.read_only(&path), None);
assert!(cold.is_empty());
}
#[test]
fn backfill_duplicate_refs_recover_once() {
// 同一引用出现在多条历史消息:找回一次,store 只建一条
let mut hot = NamespaceStore::new(1 << 20);
let original = "z".repeat(3000);
let path = hot.store("grep", &original);
let mut cold = NamespaceStore::default();
let recovered = cold.backfill_from_messages(&[path.clone(), path.clone()], &[original]);
assert_eq!(recovered.len(), 1);
assert_eq!(cold.len(), 1);
}
}
+64
View File
@@ -0,0 +1,64 @@
//! 本机连通性基准面探测(弱网重试的感知依据)。
//!
//! 场景: 隧道/电梯等移动网络波动可达分钟级, 固定次数重试必然失败。send 失败时探
//! 基准面区分「本机断网」与「单点故障」: 前者进长等待(自适应间隔探测, 恢复即重试),
//! 后者短退避即可。纯 IP TCP 与系统 DNS 解析双测, 任一通过即判在线
//! (DNS 单挂时 IP 直连上游仍可能可用, 双测互为盲区互补)。
//!
//! 演进路线(P2, 需求触发再上): 系统网络事件订阅双通道——事件源治接口级变化
//! (WiFi 断开/切蜂窝)零延迟, 本探针治链路级不通(隧道半断/DNS 故障, 系统事件
//! 不触发的盲区)。跨平台无统一事件 crate(Windows=INetworkListManager COM,
//! macOS=NWPathMonitor, Linux=netlink), 届时以 NetEventSource trait 抽象,
//! 按 devflow 实际部署面(Windows 桌面+Linux 云端)逐平台实现; Linux 服务端
//! 网络稳定, 纯探针已足够。
use std::net::ToSocketAddrs;
use std::time::Duration;
/// 探测超时(单次解析或建连)。弱网下慢响应不代表断网, 2s 内完成才计可用。
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
/// send 弱网重试总窗口(隧道/电梯级分钟级波动覆盖)。
pub(crate) const SEND_RETRY_WINDOW: Duration = Duration::from_secs(600);
/// 脱网确认后的探针间隔表(秒): 首段 5s 快探保即时性(网络抖动常秒级恢复),
/// 确认持续脱网后间隔拉长到 30s 封顶——蜂窝 radio 休眠省电, 分钟级隧道场景无感差异。
pub(crate) const OFFLINE_PROBE_SCHEDULE_SECS: &[u64] = &[5, 5, 5, 5, 10, 15, 30];
/// 按脱网轮次取探针间隔(越界取表尾封顶值)。
pub(crate) fn offline_probe_wait(poll_count: usize) -> Duration {
let idx = poll_count.saturating_sub(1).min(OFFLINE_PROBE_SCHEDULE_SECS.len() - 1);
Duration::from_secs(OFFLINE_PROBE_SCHEDULE_SECS[idx])
}
/// IP 直连探针目标(公共 DNS 服务商的 443, 不依赖 DNS)。
const PROBE_IPS: &[&str] = &["223.5.5.5:443", "119.29.29.29:443"];
/// DNS 面探针目标(公共域, 走系统 resolver)。
const PROBE_DNS_HOST: &str = "www.aliyun.com:443";
async fn tcp_alive(addr: &str) -> bool {
let target = match addr.to_socket_addrs() {
Ok(mut it) => match it.next() {
Some(a) => a,
None => return false,
},
Err(_) => return false,
};
tokio::time::timeout(PROBE_TIMEOUT, tokio::net::TcpStream::connect(target))
.await
.is_ok()
}
/// 本机网络基准面是否可用。任一探针通过即在线。
pub(crate) async fn baseline_alive() -> bool {
for ip in PROBE_IPS {
if tcp_alive(ip).await {
return true;
}
}
matches!(
tokio::time::timeout(PROBE_TIMEOUT, tokio::net::lookup_host(PROBE_DNS_HOST)).await,
Ok(Ok(_))
)
}
File diff suppressed because it is too large Load Diff
+333 -22
View File
@@ -9,9 +9,9 @@
//! 零行为变更(纯搬迁)。结构对齐 `anthropic_helpers.rs`。 //! 零行为变更(纯搬迁)。结构对齐 `anthropic_helpers.rs`。
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::debug; use tracing::{debug, error};
use crate::provider::{StreamChunk, TokenUsage, ToolCallDelta}; use crate::provider::{tool_call_id_or_fallback, StreamChunk, TokenUsage, ToolCallDelta};
// ============================================================ // ============================================================
// OpenAI API 请求/响应结构体 // OpenAI API 请求/响应结构体
@@ -65,11 +65,30 @@ pub(crate) struct OpenAiResponse {
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct OpenAiChoice { pub struct OpenAiChoice {
pub message: OpenAiMessageResp, pub(crate) message: OpenAiMessageResp,
// SW-260618-24: OpenAI 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning /// 调用终止原因。`length` = 输出被 max_tokens 截断(不完整),由 [`FinishReason`]
#[allow(dead_code)] /// 的 Drop 侧效应打 warn 留痕 — 此前该字段无人读、截断不可见,截断的 JSON 会被
pub finish_reason: Option<String>, /// 下游当解析失败整批丢弃而无任何日志线索。
#[serde(default)]
pub finish_reason: FinishReason,
}
/// finish_reason 载体(透明 NewType)。
///
/// 消费方只取 message,本字段值随 choice 整体 drop 时触发截断告警;对外公开
/// (pub 模块下的 pub 结构属库 API 面)同时免去字段 dead_code 豁免。
/// serde 透明,线上 JSON 形态与 Option<String> 一致。
#[derive(Debug, Default, Deserialize)]
#[serde(transparent)]
pub struct FinishReason(Option<String>);
impl Drop for FinishReason {
fn drop(&mut self) {
if self.0.as_deref() == Some("length") {
tracing::warn!("[OpenAI] 同步响应因 max_tokens 截断(finish_reason=length),输出不完整");
}
}
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -83,10 +102,14 @@ pub(crate) struct OpenAiMessageResp {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct OpenAiToolCallResp { pub(crate) struct OpenAiToolCallResp {
/// 缺省容错:个别兼容端点同步响应省略 id → 缺省空串,由 parse_tool_calls 的
/// tool_call_id_or_fallback 生成唯一 fallback。此前必填,单条 tool_call 缺 id 令
/// 整个响应 serde 失败 → complete 报"响应解析失败"整批丢弃(流式路径早已容错)。
#[serde(default)]
pub id: String, pub id: String,
// SW-260618-24: 反序列化 #[serde(rename="type")] 字段,保留以对齐 OpenAI 响应结构,标注意图消除 dead_code warning // 反序列化 #[serde(rename="type")] 字段,保留以对齐 OpenAI 响应结构,标注意图消除 dead_code warning
#[allow(dead_code)] #[allow(dead_code)]
#[serde(rename = "type")] #[serde(rename = "type", default)]
pub call_type: String, pub call_type: String,
pub function: OpenAiFunctionResp, pub function: OpenAiFunctionResp,
} }
@@ -94,32 +117,116 @@ pub(crate) struct OpenAiToolCallResp {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct OpenAiFunctionResp { pub(crate) struct OpenAiFunctionResp {
pub name: String, pub name: String,
/// 缺省容错:arguments 缺失 → 空串(对齐 anthropic 同步 tool_use input 缺失 args="" 口径),
/// 防单条缺字段令整个响应解析失败;name 保持必填(无名 tool_call 无法派发,无安全缺省)。
#[serde(default)]
pub arguments: String, pub arguments: String,
} }
/// OpenAI 标准嵌套缓存口径形态(usage.prompt_tokens_details):
/// GLM/Qwen/OpenAI 官方在嵌套结构报缓存命中,DeepSeek 用扁平 prompt_cache_hit_tokens。
/// cached_tokens 用 Option 容错给空 details 对象的网关。
#[derive(Debug, Default, Deserialize)]
pub(crate) struct PromptTokensDetails {
#[serde(default)]
pub cached_tokens: Option<u64>,
}
/// usage 反序列化中间载体:与 OpenAiUsage 字段同构,经 `From` 做缓存口径归一后产出
/// OpenAiUsage(见 impl From)。流式 SSE 与非流式 JSON 两条解析路径因此都自动获得
/// 嵌套 cached_tokens 回填,消费方一律读归一后的扁平字段。
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct OpenAiUsageRaw {
/// 三个计数缺省 0:部分兼容网关 usage 只报部分字段(如仅 total_tokens),必填会让
/// 整帧 serde 失败走空 chunk 分支 — 同帧 content 一并蒸发、usage 归零无痕。
#[serde(default)]
pub prompt_tokens: u32,
#[serde(default)]
pub completion_tokens: u32,
#[serde(default)]
pub total_tokens: u32,
/// DeepSeek 扩展:缓存命中 token(低价,deepseek-chat/reasoner)。无此字段 → default 0。
#[serde(default)]
pub prompt_cache_hit_tokens: u32,
/// DeepSeek 扩展:未命中 token(全价真实输入,prompt_cache_miss_tokens)。无此字段 → default 0。
#[serde(default)]
pub prompt_cache_miss_tokens: u32,
/// DeepSeek-reasoner / OpenAI o1 扩展:思考 token(隐藏输出,reasoning_tokens)。
/// 非 reasoning 模型无此字段 → default 0。
#[serde(default)]
pub reasoning_tokens: u32,
/// OpenAI 标准嵌套缓存口径(GLM/Qwen/OpenAI 官方)。
#[serde(default)]
pub prompt_tokens_details: Option<PromptTokensDetails>,
}
/// OpenAI usage(缓存口径已归一)
///
/// 归一发生在反序列化时(`#[serde(from)]`):扁平 hit 有值优先(DeepSeek 自报 hit+miss);
/// 扁平 hit 未报而嵌套 cached_tokens > 0 时回填 hit,miss 推断为 prompt - hit(嵌套口径
/// 缺全价分项);两种口径都没有则保持 0。消费方(apply_openai_sse / openai_compat 非流式)
/// 直接读扁平字段,无需感知 provider 差异。
#[derive(Debug, Deserialize)]
#[serde(from = "OpenAiUsageRaw")]
pub(crate) struct OpenAiUsage { pub(crate) struct OpenAiUsage {
pub prompt_tokens: u32, pub prompt_tokens: u32,
pub completion_tokens: u32, pub completion_tokens: u32,
pub total_tokens: u32, pub total_tokens: u32,
/// 缓存命中 token(低价;扁平优先,缺失时回填自嵌套 cached_tokens)
pub prompt_cache_hit_tokens: u32,
/// 缓存未命中 token(全价真实输入;嵌套口径推断时 = prompt - hit)
pub prompt_cache_miss_tokens: u32,
/// 思考 token(隐藏输出)
pub reasoning_tokens: u32,
}
impl From<OpenAiUsageRaw> for OpenAiUsage {
fn from(raw: OpenAiUsageRaw) -> Self {
let (hit, miss) = match raw.prompt_tokens_details.and_then(|d| d.cached_tokens) {
Some(nested) if nested > 0 && raw.prompt_cache_hit_tokens == 0 => {
let hit = u32::try_from(nested).unwrap_or(u32::MAX);
(hit, raw.prompt_tokens.saturating_sub(hit))
}
_ => (raw.prompt_cache_hit_tokens, raw.prompt_cache_miss_tokens),
};
Self {
prompt_tokens: raw.prompt_tokens,
completion_tokens: raw.completion_tokens,
total_tokens: raw.total_tokens,
prompt_cache_hit_tokens: hit,
prompt_cache_miss_tokens: miss,
reasoning_tokens: raw.reasoning_tokens,
}
}
} }
/// SSE 流式响应 chunk /// SSE 流式响应 chunk
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct OpenAiStreamChunk { pub(crate) struct OpenAiStreamChunk {
/// 流式 choice 数组。标准 error 帧(`{"error":{..}}`)与 usage 尾帧均不带 choices 键,
/// 缺省为空数组才能整帧解析成功;必填会让这类合法帧 serde 失败走空 chunk 分支,
/// 流呈无声截断。
#[serde(default)]
pub choices: Vec<OpenAiStreamChoice>, pub choices: Vec<OpenAiStreamChoice>,
/// 末 chunk(choices 为空)携带的累计 usage /// 末 chunk(choices 为空)携带的累计 usage
#[serde(default)] #[serde(default)]
pub usage: Option<OpenAiUsage>, pub usage: Option<OpenAiUsage>,
/// 流中途 error 事件(OpenAI 兼容协议:`{"error":{"message":..,"type":..}}`)。
/// 部分中转站按 OpenAI 协议在流中途发 error 帧而非走 HTTP 非 200,
/// serde default + Value 兜底:旧响应无此字段不受影响,且对 error 载荷形态不敏感。
#[serde(default)]
pub error: Option<serde_json::Value>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct OpenAiStreamChoice { pub(crate) struct OpenAiStreamChoice {
/// 部分 usage 尾帧的 choice 仅带 finish_reason 无 delta 键,缺省按空 delta 处理
#[serde(default)]
pub delta: OpenAiStreamDelta, pub delta: OpenAiStreamDelta,
pub finish_reason: Option<String>, pub finish_reason: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Default, Deserialize)]
pub(crate) struct OpenAiStreamDelta { pub(crate) struct OpenAiStreamDelta {
pub content: Option<String>, pub content: Option<String>,
pub tool_calls: Option<Vec<OpenAiStreamToolCall>>, pub tool_calls: Option<Vec<OpenAiStreamToolCall>>,
@@ -130,6 +237,8 @@ pub(crate) struct OpenAiStreamDelta {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct OpenAiStreamToolCall { pub(crate) struct OpenAiStreamToolCall {
/// 部分端点 tool_call 增量省略 index,缺省 0(单工具调用场景语义等价)
#[serde(default)]
pub index: u32, pub index: u32,
pub id: Option<String>, pub id: Option<String>,
pub function: Option<OpenAiStreamFunction>, pub function: Option<OpenAiStreamFunction>,
@@ -165,33 +274,103 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
usage: usage_accum.take(), usage: usage_accum.take(),
error: None, error: None,
reasoning_content: None, reasoning_content: None,
truncated_by_max_tokens: false,
}; };
} }
match serde_json::from_str::<OpenAiStreamChunk>(data) { match serde_json::from_str::<OpenAiStreamChunk>(data) {
Ok(chunk) => { Ok(chunk) => {
// 流中途 error 事件(中转站按 OpenAI 协议在流中途发 error 帧)。
// 不走 finished 完成路径(避免残缺响应被当正常完成入库),由 stream_llm
// 识别 error 非空 → 发 AiError + 丢弃残缺(对齐 anthropic_helpers 215-219)。
if let Some(err_val) = chunk.error {
let msg = err_val
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("stream error")
.to_string();
error!(%msg, raw = %err_val, "OpenAI 流式错误事件");
return StreamChunk {
delta: String::new(),
finished: false,
tool_calls: None,
usage: None,
error: Some(msg),
reasoning_content: None,
truncated_by_max_tokens: false,
};
}
// 提取 usage(带 include_usage 时末段 chunk 携带,覆盖累积) // 提取 usage(带 include_usage 时末段 chunk 携带,覆盖累积)
if let Some(u) = chunk.usage { if let Some(u) = chunk.usage {
// 缺省计数的留痕:两个分项均 0 基本可断定 provider 上报不完整(真实完成
// 必有 completion 计数),非流路径下游拿到零 usage 无从察觉。
if u.prompt_tokens == 0 && u.completion_tokens == 0 {
tracing::warn!(
total = u.total_tokens,
"[OpenAI] usage 缺 prompt/completion 分项(按 0 兜底),provider 上报不完整"
);
}
tracing::info!(
prompt = u.prompt_tokens,
completion = u.completion_tokens,
cache_hit = u.prompt_cache_hit_tokens,
cache_miss = u.prompt_cache_miss_tokens,
reasoning = u.reasoning_tokens,
"[OpenAI] 末 chunk usage 解析(deepseek 等报 cache)"
);
*usage_accum = Some(TokenUsage { *usage_accum = Some(TokenUsage {
prompt_tokens: u.prompt_tokens, prompt_tokens: u.prompt_tokens,
completion_tokens: u.completion_tokens, completion_tokens: u.completion_tokens,
total_tokens: u.total_tokens, total_tokens: u.total_tokens,
prompt_cache_hit_tokens: u.prompt_cache_hit_tokens,
prompt_cache_miss_tokens: u.prompt_cache_miss_tokens,
reasoning_tokens: u.reasoning_tokens,
}); });
} }
if let Some(choice) = chunk.choices.into_iter().next() { if let Some(choice) = chunk.choices.into_iter().next() {
let delta_text = choice.delta.content.unwrap_or_default(); let delta_text = choice.delta.content.unwrap_or_default();
// "length" = max_tokens 截断,属正常终止(非断连),纳入 finished // 终态 finish_reason:stop(正常结束)/ tool_calls(转交工具执行)/
let finished = choice.finish_reason.as_deref() == Some("stop") // length(max_tokens 截断,属正常终止非断连)/ content_filter(内容审查拦截)。
|| choice.finish_reason.as_deref() == Some("tool_calls") // content_filter 的流部分 provider 不发 [DONE] 帧,不纳入终态会一直悬挂
|| choice.finish_reason.as_deref() == Some("length"); let finished = matches!(
choice.finish_reason.as_deref(),
Some("stop") | Some("tool_calls") | Some("length") | Some("content_filter")
);
// length = 输出被 max_tokens 截断:仍按正常终止收敛(finished=true),
// 但截断标志随 chunk 带出,消费方据此把该轮产出标记截断,不冒充完整。
let truncated_by_max_tokens = choice.finish_reason.as_deref() == Some("length");
let tool_calls = choice.delta.tool_calls.map(|tcs| { let tool_calls = choice.delta.tool_calls.map(|tcs| {
// 缺 index 的增量缺省 0(单工具调用语义等价);但同帧多 call 且 index
// 冲突时缺省会互相踩(下游按 index 累积 → 参数拼错位),warn 留痕定位
// provider 兼容缺陷,不做静默改写。
if tcs.len() > 1 {
let mut idxs: Vec<u32> = tcs.iter().map(|tc| tc.index).collect();
idxs.sort_unstable();
idxs.dedup();
if idxs.len() < tcs.len() {
tracing::warn!(
calls = tcs.len(),
unique = idxs.len(),
"[OpenAI] 同帧多 tool_call 增量 index 冲突(缺省 0 互踩),按 index 累积将错位"
);
}
}
tcs.into_iter() tcs.into_iter()
.map(|tc| ToolCallDelta { .map(|tc| {
index: tc.index, // CR-空 id:流式 chunk 的 id 可能为 Some("")(SenseNova 兼容缺陷)。
id: tc.id, // 仅对「provider 显式给了 id 字段」的 chunk 做兜底——None(OpenAI
function_name: tc.function.as_ref().and_then(|f| f.name.clone()), // 协议:仅首 chunk 携带 id,后续 chunk 无 id)保持 None,避免
function_arguments: tc.function.and_then(|f| f.arguments), // 覆盖首 chunk 的权威 id。Some("") → `gen_stream_{index}` fallback,
// Some(非空) → 原样。下游 stream_recv 按 index 累积,draft.id 透传
// 至 ToolCall.id(accumulate_tool_calls 仅 Some 覆盖,None 不动)。
let id = tc.id.map(|raw| tool_call_id_or_fallback(&raw, tc.index as usize, "gen_stream"));
ToolCallDelta {
index: tc.index,
id,
function_name: tc.function.as_ref().and_then(|f| f.name.clone()),
function_arguments: tc.function.and_then(|f| f.arguments),
}
}) })
.collect() .collect()
}); });
@@ -200,19 +379,23 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
delta: delta_text, delta: delta_text,
finished, finished,
tool_calls, tool_calls,
usage: None, // 加固:usage 与 finish_reason 同帧的端点(部分兼容实现),此处已累积则挂上,
// 使下游 stream_recv 不必等到 [DONE] 帧即可拿到真实 usage。
usage: usage_accum.clone(),
error: None, error: None,
reasoning_content: choice.delta.reasoning_content, reasoning_content: choice.delta.reasoning_content,
truncated_by_max_tokens,
} }
} else { } else {
// choices 为空 = usage-only chunk,不输出文本(usage 已累积) // choices 为空 = usage-only chunk,不输出文本(usage 已累积),usage 一并带出
StreamChunk { StreamChunk {
delta: String::new(), delta: String::new(),
finished: false, finished: false,
tool_calls: None, tool_calls: None,
usage: None, usage: usage_accum.clone(),
error: None, error: None,
reasoning_content: None, reasoning_content: None,
truncated_by_max_tokens: false,
} }
} }
} }
@@ -225,7 +408,135 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
usage: None, usage: None,
error: None, error: None,
reasoning_content: None, reasoning_content: None,
truncated_by_max_tokens: false,
} }
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
/// DeepSeek 扁平口径:hit/miss 各取其值,不参与嵌套回填
#[test]
fn usage_deepseek_flat_cache() {
let u: OpenAiUsage = serde_json::from_str(
r#"{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110,
"prompt_cache_hit_tokens":64,"prompt_cache_miss_tokens":36}"#,
)
.unwrap();
assert_eq!(u.prompt_tokens, 100);
assert_eq!(u.prompt_cache_hit_tokens, 64);
assert_eq!(u.prompt_cache_miss_tokens, 36);
}
/// OpenAI/GLM 嵌套口径:扁平未报,hit 回填自 cached_tokens,miss 推断为 prompt - hit
#[test]
fn usage_nested_cached_tokens_backfill() {
let u: OpenAiUsage = serde_json::from_str(
r#"{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110,
"prompt_tokens_details":{"cached_tokens":64}}"#,
)
.unwrap();
assert_eq!(u.prompt_cache_hit_tokens, 64);
assert_eq!(u.prompt_cache_miss_tokens, 36);
}
/// 两种口径都没有:hit/miss 保持 0(前端不渲染 cache 段)
#[test]
fn usage_no_cache_fields() {
let u: OpenAiUsage = serde_json::from_str(
r#"{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110}"#,
)
.unwrap();
assert_eq!(u.prompt_cache_hit_tokens, 0);
assert_eq!(u.prompt_cache_miss_tokens, 0);
}
/// 端到端:usage-only chunk 经 apply_openai_sse 累加路径带出归一后的缓存分项
#[test]
fn sse_usage_chunk_nested_cache_reaches_token_usage() {
let data = r#"{"choices":[],"usage":{"prompt_tokens":100,"completion_tokens":10,
"total_tokens":110,"prompt_tokens_details":{"cached_tokens":64}}}"#;
let mut accum = None;
let chunk = apply_openai_sse(data, &mut accum);
let usage = chunk.usage.expect("usage chunk 应携带 usage");
assert_eq!(usage.prompt_cache_hit_tokens, 64);
assert_eq!(usage.prompt_cache_miss_tokens, 36);
}
/// 部分兼容网关 usage 只报部分字段:缺省 0 入累加器,同帧 content 不再因整帧
/// serde 失败被吞(旧行为:必填字段缺失 → 空 chunk → 文本蒸发 + usage 归零无痕)
#[test]
fn sse_partial_usage_frame_parses_with_defaults() {
let mut accum = None;
let data = r#"{"choices":[{"delta":{"content":"hi"}}],"usage":{"total_tokens":10}}"#;
let c = apply_openai_sse(data, &mut accum);
assert_eq!(c.delta, "hi", "usage 缺分项不应令整帧解析失败丢 content");
let u = accum.expect("部分 usage 应写入累加器");
assert_eq!(u.total_tokens, 10);
assert_eq!(u.prompt_tokens, 0, "缺省分项按 0 兜底");
assert_eq!(u.completion_tokens, 0);
}
/// 同帧多 tool_call 增量缺 index:缺省 0 触发 index 冲突路径(仅 warn 留痕,不改写
/// 缺省策略);解析本身不失败,增量仍逐条带出。
#[test]
fn sse_tool_calls_missing_index_in_multi_call_frame_still_parse() {
let mut accum = None;
let data = r#"{"choices":[{"delta":{"tool_calls":[
{"id":"call_a","function":{"name":"ta","arguments":"{"}},
{"id":"call_b","function":{"name":"tb","arguments":"}"}}
]}}]}"#;
let c = apply_openai_sse(data, &mut accum);
let tcs = c.tool_calls.expect("缺 index 的多 call 帧应可解析");
assert_eq!(tcs.len(), 2);
assert!(tcs.iter().all(|tc| tc.index == 0), "缺 index 缺省 0");
}
/// content_filter 属终态:敏感内容拦截的流部分 provider 不发 [DONE] 帧,
/// 必须靠 finish_reason 收敛,否则悬挂;未列入终态的 finish_reason 保持非终态
#[test]
fn finish_reason_content_filter_is_terminal() {
let data = r#"{"choices":[{"delta":{"content":""},"finish_reason":"content_filter"}]}"#;
let mut accum = None;
let chunk = apply_openai_sse(data, &mut accum);
assert!(chunk.finished, "content_filter 应收敛为终态,不再悬挂");
let mut accum2 = None;
let chunk2 = apply_openai_sse(
r#"{"choices":[{"delta":{"content":"x"},"finish_reason":null}]}"#,
&mut accum2,
);
assert!(!chunk2.finished, "无终态 finish_reason 的普通 delta 不应终结");
}
/// finish_reason=length:max_tokens 截断仍按正常终止收敛(finished=true),
/// 但截断标志必须随 chunk 带出(零信号会让腰斩报告冒充完整完成);
/// stop 等正常终态不置标志。
#[test]
fn finish_reason_length_carries_truncation_flag() {
let mut accum = None;
let chunk = apply_openai_sse(
r#"{"choices":[{"delta":{"content":"报告写到一半"},"finish_reason":"length"}]}"#,
&mut accum,
);
assert!(chunk.finished, "length 属正常终止");
assert!(chunk.truncated_by_max_tokens, "length 必须带截断标志");
let mut accum2 = None;
let chunk2 = apply_openai_sse(
r#"{"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}]}"#,
&mut accum2,
);
assert!(chunk2.finished && !chunk2.truncated_by_max_tokens, "stop 不置截断标志");
let mut accum3 = None;
let chunk3 = apply_openai_sse(
r#"{"choices":[{"delta":{"content":"x"},"finish_reason":null}]}"#,
&mut accum3,
);
assert!(!chunk3.truncated_by_max_tokens, "普通 delta 不置截断标志");
}
}
+385
View File
@@ -0,0 +1,385 @@
//! 人设系统(P0) — AgentPersona 结构体 + PersonaRegistry 注册表
//!
//! 构成 AI Native 的基础:角色划分是分工的前提。
//! - 每名人设包含:能力标签、工具白名单、system prompt 模板
//! - Registry 提供按场景/意图选人设、按 id 查人设能力
//!
//! 设计依据:docs/02-架构设计/构想审查/AI-Native方向与路线图-2026-06-29.md
use std::collections::HashMap;
/// 内置人设 ID 常量
pub const PERSONA_CODER: &str = "coder";
pub const PERSONA_REVIEWER: &str = "reviewer";
pub const PERSONA_ARCHITECT: &str = "architect";
pub const PERSONA_TESTER: &str = "tester";
pub const PERSONA_ANALYST: &str = "analyst";
/// 人设能力标签枚举
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum PersonaCapability {
/// 代码生成与修改
CodeGeneration,
/// Code Review
CodeReview,
/// 架构设计与分析
ArchitectureDesign,
/// 测试编写与执行
Testing,
/// 数据分析与搜索
Analysis,
/// 文件读写
FileOperation,
/// 命令执行
CommandExecution,
/// Git 操作
GitOperation,
/// 知识库操作
KnowledgeOperation,
/// 项目管理
ProjectManagement,
}
impl PersonaCapability {
pub fn label(&self) -> &'static str {
match self {
Self::CodeGeneration => "代码生成",
Self::CodeReview => "代码审查",
Self::ArchitectureDesign => "架构设计",
Self::Testing => "测试",
Self::Analysis => "分析",
Self::FileOperation => "文件操作",
Self::CommandExecution => "命令执行",
Self::GitOperation => "Git 操作",
Self::KnowledgeOperation => "知识库",
Self::ProjectManagement => "项目管理",
}
}
}
/// 人设定义
#[derive(Debug, Clone)]
pub struct AgentPersona {
/// 唯一标识(如 "coder", "reviewer")
pub id: String,
/// 人设名称(如 "程序员", "审查员")
pub name: String,
/// 人设描述(LLM 理解用)
pub description: String,
/// 能力标签集
pub capabilities: Vec<PersonaCapability>,
/// 工具白名单(空=不限,非空=仅允许列出的工具)
pub tool_whitelist: Vec<String>,
/// system prompt 模板(注入 {context} {goals} 等占位符)
pub system_prompt_template: String,
/// 优先级(选人设时冲突排名,0=最高)
pub priority: u8,
}
impl AgentPersona {
/// 检查此人设是否具备某项能力
pub fn has_capability(&self, cap: &PersonaCapability) -> bool {
self.capabilities.contains(cap)
}
/// 检查某工具是否在此人设的 whitelist 中
pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
self.tool_whitelist.is_empty() || self.tool_whitelist.iter().any(|t| t == tool_name)
}
/// 构建带上下文的 system prompt
pub fn build_prompt(&self, context: &str, goals: &[String]) -> String {
let goals_text = if goals.is_empty() {
String::new()
} else {
format!("\n## 当前目标\n{}", goals.iter().enumerate().map(|(i, g)| format!("{}. {}", i + 1, g)).collect::<Vec<_>>().join("\n"))
};
self.system_prompt_template
.replace("{context}", context)
.replace("{goals}", &goals_text)
}
}
/// 人设注册表 — 管理内置 + 自定义人设
#[derive(Debug, Clone)]
pub struct PersonaRegistry {
personae: HashMap<String, AgentPersona>,
/// 默认人设 id(兜底)
default_id: String,
}
impl PersonaRegistry {
/// 构造默认注册表(含 5 内置人设)
pub fn new() -> Self {
let mut reg = Self {
personae: HashMap::new(),
default_id: PERSONA_CODER.to_string(),
};
reg.register(Self::builtin_coder());
reg.register(Self::builtin_reviewer());
reg.register(Self::builtin_architect());
reg.register(Self::builtin_tester());
reg.register(Self::builtin_analyst());
reg
}
// ── 5 内置人设 ──
fn builtin_coder() -> AgentPersona {
AgentPersona {
id: PERSONA_CODER.to_string(),
name: "程序员".to_string(),
description: "负责代码生成、修改、重构。专注于实现功能、修复 bug、优化性能,不关注大范围架构变更".to_string(),
capabilities: vec![
PersonaCapability::CodeGeneration,
PersonaCapability::FileOperation,
PersonaCapability::CommandExecution,
],
tool_whitelist: vec![], // 不限(全部工具可用)
system_prompt_template: "你是 DevFlow 的**程序员**(Coder)。\n\n你的职责是编写、修改和重构代码。专注于具体实现,不擅自改变整体架构。\n\n{goals}\n\n{context}".to_string(),
priority: 1,
}
}
fn builtin_reviewer() -> AgentPersona {
AgentPersona {
id: PERSONA_REVIEWER.to_string(),
name: "审查员".to_string(),
description: "负责 Code Review、质量审计、安全检查。审查代码正确性、性能、安全性,返回审查意见不直接改代码".to_string(),
capabilities: vec![
PersonaCapability::CodeReview,
PersonaCapability::Analysis,
],
tool_whitelist: vec![
"read_file".into(), "search_files".into(), "list_directory".into(),
"git_diff".into(), "git_log".into(), "git_status".into(),
],
system_prompt_template: "你是 DevFlow 的**审查员**(Reviewer)。\n\n你的职责是审查代码质量、安全性和性能。**你不直接修改代码**,只输出审查意见和修改建议。\n\n{goals}\n\n{context}".to_string(),
priority: 3,
}
}
fn builtin_architect() -> AgentPersona {
AgentPersona {
id: PERSONA_ARCHITECT.to_string(),
name: "架构师".to_string(),
description: "负责架构设计、技术选型、模块划分。分析整体结构,制定技术方案,不关注具体代码实现".to_string(),
capabilities: vec![
PersonaCapability::ArchitectureDesign,
PersonaCapability::Analysis,
],
tool_whitelist: vec![
"read_file".into(), "search_files".into(), "list_directory".into(),
"write_file".into(), // 写架构文档
],
system_prompt_template: "你是 DevFlow 的**架构师**(Architect)。\n\n你的职责是架构设计、技术选型和模块划分。分析系统整体结构,制定技术方案。**不编写业务代码**,输出架构文档和设计方案。\n\n{goals}\n\n{context}".to_string(),
priority: 2,
}
}
fn builtin_tester() -> AgentPersona {
AgentPersona {
id: PERSONA_TESTER.to_string(),
name: "测试员".to_string(),
description: "负责编写单元测试、集成测试、执行测试。验证代码正确性,不修改生产代码".to_string(),
capabilities: vec![
PersonaCapability::Testing,
PersonaCapability::CommandExecution,
PersonaCapability::FileOperation,
],
tool_whitelist: vec![], // 不限
system_prompt_template: "你是 DevFlow 的**测试员**(Tester)。\n\n你的职责是编写测试、执行测试、报告测试结果。**不修改生产代码**,只修改测试代码。\n\n{goals}\n\n{context}".to_string(),
priority: 3,
}
}
fn builtin_analyst() -> AgentPersona {
AgentPersona {
id: PERSONA_ANALYST.to_string(),
name: "分析师".to_string(),
description: "负责数据分析、日志排查、性能分析。搜索信息、分析数据,输出分析报告".to_string(),
capabilities: vec![
PersonaCapability::Analysis,
PersonaCapability::CommandExecution,
PersonaCapability::KnowledgeOperation,
],
tool_whitelist: vec![
"read_file".into(), "search_files".into(), "grep_search".into(),
"run_command".into(), "git_log".into(),
"knowledge_search".into(), "knowledge_inject".into(),
],
system_prompt_template: "你是 DevFlow 的**分析师**(Analyst)。\n\n你的职责是分析数据、排查问题、搜索信息。输出分析报告和结论。**不修改代码**。\n\n{goals}\n\n{context}".to_string(),
priority: 4,
}
}
// ── 注册表操作 ──
/// 注册/覆盖人设
pub fn register(&mut self, persona: AgentPersona) {
self.personae.insert(persona.id.clone(), persona);
}
/// 按 id 查人设
pub fn get(&self, id: &str) -> Option<&AgentPersona> {
self.personae.get(id)
}
/// 取默认人设
pub fn default(&self) -> &AgentPersona {
self.personae.get(&self.default_id).expect("默认人设必存在")
}
/// 设置默认人设 id
pub fn set_default(&mut self, id: &str) {
if self.personae.contains_key(id) {
self.default_id = id.to_string();
}
}
/// 按意图推荐人设(启发式规则)
pub fn recommend_for_intent(&self, intent: &str) -> &AgentPersona {
let lower = intent.to_lowercase();
if lower.contains("review") || lower.contains("审查") || lower.contains("审计") {
self.get(PERSONA_REVIEWER).unwrap_or_else(|| self.default())
} else if lower.contains("架构") || lower.contains("设计") || lower.contains("architect") {
self.get(PERSONA_ARCHITECT).unwrap_or_else(|| self.default())
} else if lower.contains("测试") || lower.contains("test") {
self.get(PERSONA_TESTER).unwrap_or_else(|| self.default())
} else if lower.contains("分析") || lower.contains("分析") || lower.contains("排查") || lower.contains("search") {
self.get(PERSONA_ANALYST).unwrap_or_else(|| self.default())
} else {
// 默认走 coder(代码生成是最常见场景)
self.default()
}
}
/// 获取全部人设列表
pub fn all(&self) -> Vec<&AgentPersona> {
let mut list: Vec<_> = self.personae.values().collect();
list.sort_by_key(|p| p.priority);
list
}
/// 人设数量
pub fn len(&self) -> usize {
self.personae.len()
}
pub fn is_empty(&self) -> bool {
self.personae.is_empty()
}
}
impl Default for PersonaRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_has_5_builtin() {
let reg = PersonaRegistry::new();
assert_eq!(reg.len(), 5);
assert!(reg.get(PERSONA_CODER).is_some());
assert!(reg.get(PERSONA_REVIEWER).is_some());
assert!(reg.get(PERSONA_ARCHITECT).is_some());
assert!(reg.get(PERSONA_TESTER).is_some());
assert!(reg.get(PERSONA_ANALYST).is_some());
}
#[test]
fn default_is_coder() {
let reg = PersonaRegistry::new();
assert_eq!(reg.default().id, PERSONA_CODER);
}
#[test]
fn recommend_reviewer() {
let reg = PersonaRegistry::new();
let p = reg.recommend_for_intent("review the code changes");
assert_eq!(p.id, PERSONA_REVIEWER);
}
#[test]
fn recommend_architect() {
let reg = PersonaRegistry::new();
let p = reg.recommend_for_intent("设计新的模块架构");
assert_eq!(p.id, PERSONA_ARCHITECT);
}
#[test]
fn recommend_tester() {
let reg = PersonaRegistry::new();
let p = reg.recommend_for_intent("write tests for this module");
assert_eq!(p.id, PERSONA_TESTER);
}
#[test]
fn recommend_default_to_coder() {
let reg = PersonaRegistry::new();
let p = reg.recommend_for_intent("implement a new feature");
assert_eq!(p.id, PERSONA_CODER);
}
#[test]
fn tool_whitelist_filters() {
let reviewer = AgentPersona {
id: "test".into(),
name: "test".into(),
description: "".into(),
capabilities: vec![],
tool_whitelist: vec!["read_file".into(), "search_files".into()],
system_prompt_template: "".into(),
priority: 5,
};
assert!(reviewer.is_tool_allowed("read_file"));
assert!(!reviewer.is_tool_allowed("write_file"));
}
#[test]
fn empty_whitelist_allows_all() {
let coder = AgentPersona {
id: "test".into(),
name: "test".into(),
description: "".into(),
capabilities: vec![],
tool_whitelist: vec![],
system_prompt_template: "".into(),
priority: 5,
};
assert!(coder.is_tool_allowed("run_command"));
assert!(coder.is_tool_allowed("write_file"));
}
#[test]
fn build_prompt_replaces_placeholders() {
let reg = PersonaRegistry::new();
let coder = reg.get(PERSONA_CODER).unwrap();
let prompt = coder.build_prompt("some context", &["goal1".into()]);
assert!(prompt.contains("程序员"));
assert!(prompt.contains("goal1"));
assert!(prompt.contains("some context"));
}
#[test]
fn register_custom_persona() {
let mut reg = PersonaRegistry::new();
let custom = AgentPersona {
id: "custom".into(),
name: "自定义".into(),
description: "test".into(),
capabilities: vec![],
tool_whitelist: vec![],
system_prompt_template: "You are custom".into(),
priority: 0,
};
reg.register(custom);
assert_eq!(reg.len(), 6);
assert!(reg.get("custom").is_some());
}
}
+200
View File
@@ -0,0 +1,200 @@
//! Plan 执行器(Phase 2 · DAG 分层调度)
//!
//! 按 Plan::to_layers 输出的层级顺序执行子任务:
//! - 层间串行:上层全部完成才进下一层(deps 保证)
//! - 层内并行:同层 SubTask 用 tokio::JoinSet 并发执行(设计目标;
//! 当前为串行循环模拟,见 execute 内 TODO,本执行器未接生产链路)
//!
//! **当前状态**:骨架实现,PLAN_EXECUTION_ENABLED 门控(默认关)。
//! 未接入 agentic loop 主路径,翻 true 启用后仍需与 run_agentic_loop 对接。
//!
//! 设计依据:`docs/02-架构设计/单对话并行多轮-设计-2026-06-20.md`
use std::sync::Arc;
use crate::planner::{Plan, SubTask};
/// Plan 执行总开关。false = 不启用(走单链 ReAct 旧行为)。
///
/// 运行时原子门控,支持热切换(通过 IPC 或前端设置开关)。
/// 默认关。现状:PlanExecutor::execute 零生产调用点(仅测试),本执行器未接入主链;
/// agentic loop 读本开关走的是 coordinator::dispatch(Phase 1 串行桩),与本文件无关。
/// JoinSet 真并行留 Phase 3 对接时接通(见 execute 内 TODO)。
/// 与 PLANNING_ENABLED 解耦:plan_hint 独立使能,编排可见性始终可开。
use std::sync::atomic::{AtomicBool, Ordering};
pub static PLAN_EXECUTION_ENABLED: AtomicBool = AtomicBool::new(false);
/// 设置 Plan 执行开关。
pub fn set_plan_execution(enabled: bool) {
PLAN_EXECUTION_ENABLED.store(enabled, Ordering::SeqCst);
tracing::info!(enabled, "[PLAN-EXEC] 执行开关已更新");
}
/// 读取 Plan 执行开关。
pub fn plan_execution_enabled() -> bool {
PLAN_EXECUTION_ENABLED.load(Ordering::SeqCst)
}
/// 单个 SubTask 的执行结果。
#[derive(Debug, Clone)]
pub struct SubTaskResult {
/// 对应 SubTask id
pub id: String,
/// 执行是否成功
pub success: bool,
/// 执行产出(工具结果摘要 / LLM 回复)
pub output: String,
}
/// Plan 执行器:按 DAG 层级调度子任务。
///
/// 泛型参数 `F` 为子任务执行函数:
/// - 输入:`&SubTask`(子任务定义)
/// - 输入:`&str`(父任务结果摘要,供子任务上下文参考)
/// - 输出:`Future<Output = anyhow::Result<String>>`(子任务产出)
///
/// 调用方(Phase 3 对接 agentic loop 时)传入真实的执行闭包,
/// 每个 SubTask 独立跑一轮 agentic loop,共享父上下文快照。
pub struct PlanExecutor;
impl PlanExecutor {
/// 按 Plan DAG 分层执行。
///
/// - 层间串行:上层全部完成才进下一层
/// - 层内并行:同层 SubTask 用 JoinSet 并发
/// - 失败传播:任一 SubTask 失败时,记录错误但继续执行同层其他任务(容错);
/// 若需中断策略,调用方据返回结果自行判断。
///
/// 返回每个 SubTask 的执行结果(按 Plan 原始顺序排列)。
pub async fn execute<F, Fut>(
plan: &Plan,
mut run_subtask: F,
) -> anyhow::Result<Vec<SubTaskResult>>
where
F: FnMut(Arc<SubTask>, String) -> Fut,
Fut: std::future::Future<Output = anyhow::Result<String>>,
{
let layers = plan
.to_layers()
.map_err(|e| anyhow::anyhow!("Plan 环依赖: {:?}", e.cycle))?;
let mut results: Vec<SubTaskResult> = Vec::new();
let mut parent_summary = String::new();
for (layer_idx, layer) in layers.iter().enumerate() {
tracing::info!(
layer_idx,
task_count = layer.len(),
"[plan-exec] 执行第 {} 层, {} 个子任务",
layer_idx,
layer.len()
);
let summary_clone = parent_summary.clone();
for task in layer {
let task = Arc::new(task.clone());
let summary = summary_clone.clone();
// TODO(Phase 3 真并行): 当前为串行循环模拟并行(假并行),仅验证分层逻辑。
// run_subtask 是 FnMut,不能直接 move 进 JoinSet(多任务并发调用);
// 接真并行的改法:run_subtask 换 Arc<Fn> 或 channel 模式,
// 本层全部 spawn 进 JoinSet 统一收集后再汇总 parent_summary,替换此串行 await。
// 现状无用户可见影响:本执行器未接生产链路(execute 零调用点)。
let result = run_subtask(task.clone(), summary).await;
let sr = match result {
Ok(output) => SubTaskResult {
id: task.id.clone(),
success: true,
output,
},
Err(e) => SubTaskResult {
id: task.id.clone(),
success: false,
output: e.to_string(),
},
};
results.push(sr);
}
// 汇总本层结果作为下层 parent_summary
let layer_summary: Vec<String> = results
.iter()
.filter(|r| layer.iter().any(|t| t.id == r.id))
.map(|r| format!("[{}] {}", r.id, r.output))
.collect();
parent_summary = layer_summary.join("\n");
}
Ok(results)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::planner::{Plan, SubTask};
#[tokio::test]
async fn execute_simple_linear_plan() {
// a → b → c (三层,各一个任务)
let plan = Plan::from_tasks(vec![
SubTask::new("a", "task-a"),
SubTask::new("b", "task-b").with_deps(vec!["a".to_string()]),
SubTask::new("c", "task-c").with_deps(vec!["b".to_string()]),
]);
let results = PlanExecutor::execute(&plan, |task, _parent| async move {
Ok(format!("done:{}", task.id))
})
.await
.unwrap();
assert_eq!(results.len(), 3);
assert!(results.iter().all(|r| r.success));
assert_eq!(results[0].id, "a");
assert_eq!(results[1].id, "b");
assert_eq!(results[2].id, "c");
}
#[tokio::test]
async fn execute_parallel_layer() {
// a → {b, c} → d (三层,第二层两个并行)
let plan = Plan::from_tasks(vec![
SubTask::new("a", "task-a"),
SubTask::new("b", "task-b").with_deps(vec!["a".to_string()]),
SubTask::new("c", "task-c").with_deps(vec!["a".to_string()]),
SubTask::new("d", "task-d").with_deps(vec!["b".to_string(), "c".to_string()]),
]);
let results = PlanExecutor::execute(&plan, |task, _parent| async move {
Ok(format!("done:{}", task.id))
})
.await
.unwrap();
assert_eq!(results.len(), 4);
assert!(results.iter().all(|r| r.success));
}
#[tokio::test]
async fn execute_with_failure_continues() {
// a(fail) → b: a 失败,b 仍执行(容错策略)
let plan = Plan::from_tasks(vec![
SubTask::new("a", "task-a"),
SubTask::new("b", "task-b").with_deps(vec!["a".to_string()]),
]);
let results = PlanExecutor::execute(&plan, |task, _parent| async move {
if task.id == "a" {
anyhow::bail!("task-a failed")
}
Ok("ok".to_string())
})
.await
.unwrap();
assert_eq!(results.len(), 2);
assert!(!results[0].success); // a 失败
assert!(results[1].success); // b 仍执行
}
}
+110 -24
View File
@@ -5,7 +5,7 @@
//! 但重试能在不侵入上层的前提下吸收瞬态错误,对低频后台调用零风险。 //! 但重试能在不侵入上层的前提下吸收瞬态错误,对低频后台调用零风险。
//! //!
//! 策略: //! 策略:
//! - 最多 3 次尝试(初次 + 2 次重试),指数退避 1s → 2s → 4s + ±20% jitter //! - 最多 3 次尝试(初次 + 2 次重试),指数退避 1s → 2s → 4s + ±50% jitter
//! - 总挂钟预算 30s(含 sleep + 各次请求耗时),超预算直接放弃重试返回最后错误 //! - 总挂钟预算 30s(含 sleep + 各次请求耗时),超预算直接放弃重试返回最后错误
//! - 仅对可重试错误重试: timeout / connect / 5xx / 429; 不重试 4xx(非429) / 参数错 / 鉴权错 //! - 仅对可重试错误重试: timeout / connect / 5xx / 429; 不重试 4xx(非429) / 参数错 / 鉴权错
//! - 不引入 reqwest-retry / tower: complete 路径简单,手写 loop 零依赖、可调试、不与 SSE/双层 Semaphore 耦合 //! - 不引入 reqwest-retry / tower: complete 路径简单,手写 loop 零依赖、可调试、不与 SSE/双层 Semaphore 耦合
@@ -15,11 +15,12 @@
//! 重试期间不释放 Semaphore permit(已在调用方持有),对并发池有挤占 —— 但 complete 调用低频可接受。 //! 重试期间不释放 Semaphore permit(已在调用方持有),对并发池有挤占 —— 但 complete 调用低频可接受。
use std::future::Future; use std::future::Future;
use std::time::{Duration, SystemTime}; use std::time::Duration;
use rand::Rng;
use tracing::warn; use tracing::warn;
/// 最多尝试次数(含初次)。B-260616-07: 3 次 = 初次 + 2 次重试。 /// 最多尝试次数(含初次)。3 次 = 初次 + 2 次重试。
/// ///
/// 配置化 TODO: 未来接入 per-provider config(`AiProviderRecord.config` JSON)或全局开关 /// 配置化 TODO: 未来接入 per-provider config(`AiProviderRecord.config` JSON)或全局开关
/// (`useSetting('df-ai-max-retries')`)时改为读取配置。当前低频后台调用,常量足够。 /// (`useSetting('df-ai-max-retries')`)时改为读取配置。当前低频后台调用,常量足够。
@@ -33,12 +34,18 @@ const BASE_BACKOFF_SECS: u64 = 1;
/// 与单请求 60s timeout 配合: 最坏 3 次 × 60s = 180s,30s 预算会在此之前主动止损。 /// 与单请求 60s timeout 配合: 最坏 3 次 × 60s = 180s,30s 预算会在此之前主动止损。
const MAX_TOTAL_BUDGET: Duration = Duration::from_secs(30); const MAX_TOTAL_BUDGET: Duration = Duration::from_secs(30);
/// 单次退避基准上限(毫秒),与 [`MAX_TOTAL_BUDGET`] 同口径: 超预算的退避没有意义,
/// attempt 走深时不再指数膨胀。与总预算的一致性由测试 `backoff_cap_matches_budget` 守住。
/// (同构先例: df-tunnel 的 backoff_delay 也以 RECONNECT_MAX 封顶。)
const MAX_BACKOFF_MS: u64 = 30_000;
/// jitter 上限(相对 base 的 ±比例)。避免重试风暴对齐。 /// jitter 上限(相对 base 的 ±比例)。避免重试风暴对齐。
const JITTER_RATIO: f64 = 0.2; /// CR-XX: 范围扩到 ±50%(由 gen_range(-0.5..0.5) × JITTER_RATIO=1.0 合成)。
const JITTER_RATIO: f64 = 1.0;
/// 一次尝试的分类结果 —— 在 anyhow 不透明化前决定是否值得重试。 /// 一次尝试的分类结果 —— 在 anyhow 不透明化前决定是否值得重试。
/// ///
/// CR-30-1: 暴露给 agentic.rs 流前重试复用(Init 失败分类 Fatal/Retryable 决定是否重试)。 /// 暴露给 agentic.rs 流前重试复用(Init 失败分类 Fatal/Retryable 决定是否重试)。
#[derive(Debug)] #[derive(Debug)]
pub enum AttemptOutcome<T> { pub enum AttemptOutcome<T> {
/// 成功,携带结果。 /// 成功,携带结果。
@@ -58,29 +65,50 @@ pub fn is_reqwest_error_retryable(e: &reqwest::Error) -> bool {
/// 判定 HTTP 状态码是否可重试: 5xx 与 429 重试,其余(含 4xx)不重试。 /// 判定 HTTP 状态码是否可重试: 5xx 与 429 重试,其余(含 4xx)不重试。
/// ///
/// CR-30-1: 暴露给 agentic.rs 流前重试复用(4xx Fatal 立即放弃,5xx/429 重试)。 /// 暴露给 agentic.rs 流前重试复用(4xx Fatal 立即放弃,5xx/429 重试)。
pub fn is_status_retryable(status: u16) -> bool { pub fn is_status_retryable(status: u16) -> bool {
status == 429 || (500..600).contains(&status) status == 429 || (500..600).contains(&status)
} }
/// 退避倍率 `2^(attempt-1)`,位移溢出安全。
///
/// 替代裸 `1u64 << (attempt - 1)`: attempt > 64 时位移越界(debug panic / release 环绕)、
/// attempt = 0 时下溢 panic。现 attempt = 0 视作 1(倍率 1),attempt - 1 ≥ 64 时
/// checked_shl 返回 None,倍率封顶 u64::MAX,最终时长由 [`MAX_BACKOFF_MS`] 兜底。
/// 全输入无 panic,序列单调不减。
fn backoff_multiplier(attempt: u32) -> u64 {
match 1u64.checked_shl(attempt.saturating_sub(1)) {
Some(m) => m,
None => u64::MAX,
}
}
/// 退避基准毫秒(未加 jitter),`BASE_BACKOFF_SECS * 2^(attempt-1) * 1000`,封顶 [`MAX_BACKOFF_MS`]。
///
/// 乘法全程 saturating: 倍率封顶 u64::MAX 后 `* 1000` 也会二次溢出,不能裸乘。
/// 单一事实源——backoff_delay 与单测的 base 断言都走这里,不再各写一份公式。
fn base_backoff_ms(attempt: u32) -> u64 {
BASE_BACKOFF_SECS
.saturating_mul(backoff_multiplier(attempt))
.saturating_mul(1000)
.min(MAX_BACKOFF_MS)
}
/// 指数退避 + jitter: 返回第 `attempt`(1-based)次重试前应 sleep 的时长。 /// 指数退避 + jitter: 返回第 `attempt`(1-based)次重试前应 sleep 的时长。
/// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s,各 ±20% jitter。 /// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s,各 ±50% jitter。
/// ///
/// jitter 用 SystemTime 纳秒取模生成(无依赖),避免多客户端同步重试风暴。 /// jitter 用 `rand::thread_rng().gen_range(-0.5..0.5)` 生成 ±50% 比例,避免多客户端同步重试风暴。
/// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。 /// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。
/// 全输入安全: attempt 任意 u32(含 0 / >64)不 panic,结果 ≤ [`MAX_BACKOFF_MS`]。
/// ///
/// CR-30-1: 暴露 pub 供 src-tauri/agentic.rs 流前重试复用(对齐决策 F-260616-07 a1 /// 暴露 pub 供 src-tauri/agentic.rs 流前重试复用
/// "复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。 /// ("复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。
pub fn backoff_delay(attempt: u32) -> Duration { pub fn backoff_delay(attempt: u32) -> Duration {
let base_ms = BASE_BACKOFF_SECS.saturating_mul(1u64 << (attempt - 1)) * 1000; let base_ms = base_backoff_ms(attempt) as f64;
// 纳秒 → [0, 2000) 区间,再映射到 [-1.0, +1.0) 比例 // ±50% jitter,相对 base 时长的浮动比例
let nanos = SystemTime::now() let jitter_ratio = rand::thread_rng().gen_range(-0.5..0.5); // [-0.5, 0.5)
.duration_since(SystemTime::UNIX_EPOCH) let factor = 1.0 + jitter_ratio * JITTER_RATIO;
.map(|d| d.subsec_nanos() as u64) let ms = (base_ms * factor).clamp(0.0, MAX_BACKOFF_MS as f64) as u64;
.unwrap_or(0);
let jitter_ratio = (nanos % 2000) as f64 / 1000.0 - 1.0; // [-1.0, 1.0)
let factor = 1.0 + jitter_ratio * JITTER_RATIO; // [0.8, 1.2]
let ms = (base_ms as f64 * factor).max(0.0) as u64;
Duration::from_millis(ms) Duration::from_millis(ms)
} }
@@ -93,7 +121,7 @@ pub fn backoff_delay(attempt: u32) -> Duration {
/// 调用方约定: `attempt_fn` 内部应保留完整请求重发能力(每次重建 RequestBuilder), /// 调用方约定: `attempt_fn` 内部应保留完整请求重发能力(每次重建 RequestBuilder),
/// 因为 reqwest::RequestBuilder 一次 `.send()` 后不可复用。 /// 因为 reqwest::RequestBuilder 一次 `.send()` 后不可复用。
/// ///
/// CR-30-1: 仍 pub(crate)(complete() 内部用),流前重试 stream_recv/agentic 不走此函数 /// 仍 pub(crate)(complete() 内部用),流前重试 stream_recv/agentic 不走此函数
/// (流式 request 不可整体 retry_with_backoff 包裹,需在 agentic loop 内手写循环复用 /// (流式 request 不可整体 retry_with_backoff 包裹,需在 agentic loop 内手写循环复用
/// backoff_delay + is_status_retryable)。 /// backoff_delay + is_status_retryable)。
pub(crate) async fn retry_with_backoff<T, F, Fut>( pub(crate) async fn retry_with_backoff<T, F, Fut>(
@@ -162,12 +190,12 @@ mod tests {
#[test] #[test]
fn backoff_is_monotonic_within_jitter() { fn backoff_is_monotonic_within_jitter() {
// base 序列: 1, 2, 4 秒; jitter ±20% 后仍在 [0.8,4.8] 范围 // base 序列: 1, 2, 4 秒; jitter ±50%(JITTER_RATIO=1.0) 后仍在 [0.5x, 1.5x] 范围
for attempt in 1..=3 { for attempt in 1..=3 {
let d = backoff_delay(attempt); let d = backoff_delay(attempt);
let base_ms = (BASE_BACKOFF_SECS * (1u64 << (attempt - 1))) * 1000; let base_ms = base_backoff_ms(attempt);
let lo = (base_ms as f64 * (1.0 - JITTER_RATIO)) as u64; let lo = (base_ms as f64 * (1.0 - JITTER_RATIO / 2.0)) as u64;
let hi = (base_ms as f64 * (1.0 + JITTER_RATIO)) as u64; let hi = (base_ms as f64 * (1.0 + JITTER_RATIO / 2.0)) as u64;
assert!( assert!(
d.as_millis() as u64 >= lo && d.as_millis() as u64 <= hi, d.as_millis() as u64 >= lo && d.as_millis() as u64 <= hi,
"attempt={} delay={:?} 应在 [{},{}]ms", "attempt={} delay={:?} 应在 [{},{}]ms",
@@ -179,6 +207,64 @@ mod tests {
} }
} }
/// 倍率溢出安全: attempt=0 不下溢,≥65 封顶 u64::MAX,序列单调不减
#[test]
fn backoff_multiplier_extremes_safe() {
assert_eq!(backoff_multiplier(0), 1, "attempt=0 视作 1,不下溢 panic");
assert_eq!(backoff_multiplier(1), 1);
assert_eq!(backoff_multiplier(2), 2);
assert_eq!(backoff_multiplier(3), 4);
assert_eq!(backoff_multiplier(63), 1u64 << 62);
assert_eq!(backoff_multiplier(64), 1u64 << 63, "u64 可表达的最大位移");
assert_eq!(backoff_multiplier(65), u64::MAX, "checked_shl 越界封顶");
assert_eq!(backoff_multiplier(100), u64::MAX);
assert_eq!(backoff_multiplier(u32::MAX), u64::MAX);
let seq: Vec<u64> = [0u32, 1, 2, 3, 10, 63, 64, 65, 100, u32::MAX]
.iter()
.map(|&a| backoff_multiplier(a))
.collect();
assert!(seq.windows(2).all(|w| w[0] <= w[1]), "倍率应单调不减: {:?}", seq);
}
/// 基准毫秒单调不减、封顶值正确(2^5×1s=32s 已超 30s 预算,从 attempt=6 起封顶)
#[test]
fn base_backoff_ms_monotonic_and_capped() {
let mut prev = 0u64;
for attempt in [0u32, 1, 2, 3, 4, 5, 6, 10, 63, 64, 65, 100, u32::MAX] {
let b = base_backoff_ms(attempt);
assert!(b >= prev, "attempt={} base_ms={} 应 ≥ 前值 {}", attempt, b, prev);
assert!(b <= MAX_BACKOFF_MS, "attempt={} base_ms={} 应 ≤ {}", attempt, b, MAX_BACKOFF_MS);
prev = b;
}
assert_eq!(base_backoff_ms(1), 1000);
assert_eq!(base_backoff_ms(2), 2000);
assert_eq!(base_backoff_ms(3), 4000);
assert_eq!(base_backoff_ms(5), 16_000, "未触及封顶");
assert_eq!(base_backoff_ms(6), MAX_BACKOFF_MS, "32s 超预算封顶 30s");
assert_eq!(base_backoff_ms(u32::MAX), MAX_BACKOFF_MS);
}
/// 极端 attempt 下 backoff_delay 不 panic(修复: 1u64<<(attempt-1) 曾 debug panic/release 环绕)
#[test]
fn backoff_delay_extreme_attempts_no_panic_and_capped() {
for attempt in [0u32, 1, 10, 63, 64, 65, 100, u32::MAX] {
let ms = backoff_delay(attempt).as_millis() as u64;
assert!(
ms <= MAX_BACKOFF_MS,
"attempt={} 实际 {}ms 应 ≤ {}ms(含 jitter 后仍不超)",
attempt,
ms,
MAX_BACKOFF_MS
);
}
}
/// 封顶常量与总预算同口径(改预算时两处同步)
#[test]
fn backoff_cap_matches_budget() {
assert_eq!(MAX_BACKOFF_MS, MAX_TOTAL_BUDGET.as_millis() as u64);
}
#[test] #[test]
fn status_classification() { fn status_classification() {
assert!(is_status_retryable(429)); assert!(is_status_retryable(429));
+241 -33
View File
@@ -1,37 +1,45 @@
//! 模型路由器 — F-01 阶段4 //! 模型路由器。
//! //!
//! 纯函数核心,零 IO / 零状态。给定 TaskRequirements + 候选池,返回最优 ModelConfig。 //! 不接调用点(那是调用方:agentic.rs / title.rs / knowledge_inject.rs / project.rs /
//! 不接调用点(那是阶段5:agentic.rs / title.rs / knowledge_inject.rs / project.rs /
//! df-ideas / df-nodes ai_node.rs)。 //! df-ideas / df-nodes ai_node.rs)。
//! //!
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §6.1。 //! ModelRouter 为单元结构,select 是无状态关联函数。
//!
//! ModelRouter 为单元结构,select 是无状态关联函数(对齐任务规格,非设计文档的 `&self` 方法)。
// 阶段5: 调用点经 `df_ai::router::{Modality, Capability, CostTier, IntelligenceTier}` // 调用点经 `df_ai::router::{Modality, Capability, CostTier, IntelligenceTier}`
// 直接 import 维度枚举构造 TaskRequirements(对齐任务规格 import 风格),re-export 避免调用点 // 直接 import 维度枚举构造 TaskRequirements,re-export 避免调用点
// 各自从 df_ai_core::model 取(跨 crate 路径冗长)。select/select_model_id 仅借用枚举,无重定义。 // 各自从 df_ai_core::model 取(跨 crate 路径冗长)。select/select_model_id 仅借用枚举,无重定义。
// 注:CostTier/IntelligenceTier 路由已解耦(2026-06-18 B-260618-03)——provider /v1/models API // 注:CostTier/IntelligenceTier 路由已解耦——provider /v1/models API
// 不返回这两维度,数据无客观依据不可信,不参与硬路由;re-export 保留供未来真实判别源。 // 不返回这两维度,数据无客观依据不可信,不参与硬路由;re-export 保留供未来真实判别源。
// ModelTier 从 crate::intent re-export(同 crate,无跨 crate 路径问题),供调用点构造
// `tier: suggested_model_tier(&intent)` 传入,router 同 weight 时按 tier tiebreak。
pub use crate::intent::ModelTier;
pub use df_ai_core::model::{Capability, CostTier, IntelligenceTier, Modality, ModelConfig}; pub use df_ai_core::model::{Capability, CostTier, IntelligenceTier, Modality, ModelConfig};
/// 任务对模型的需求(3 维度)。 /// 任务对模型的需求(4 维度)。
/// ///
/// 由调用点构造(阶段5),描述本次调用需要什么模态/能力/上下文, /// 由调用点构造,描述本次调用需要什么模态/能力/上下文/档位,
/// 交 ModelRouter::select 在候选池中选最优模型。 /// 交 ModelRouter::select 在候选池中选最优模型。
/// ///
/// 路由已解耦(2026-06-18 B-260618-03):原 `min_intelligence`/`max_cost` 两字段删除。 /// 路由已解耦:原 `min_intelligence`/`max_cost` 两字段删除。
/// provider /v1/models API 不返回 cost_tier/intelligence,这两维度 100% 靠预设表写死 + /// provider /v1/models API 不返回 cost_tier/intelligence,这两维度 100% 靠预设表写死 +
/// 模型名启发式猜,数据无客观依据不可信,不应参与硬路由。枚举(CostTier/IntelligenceTier) /// 模型名启发式猜,数据无客观依据不可信,不应参与硬路由。枚举(CostTier/IntelligenceTier)
/// 保留供未来出现真实判别源时再接回。 /// 保留供未来出现真实判别源时再接回。
///
/// `tier`(子项 2 根因修复):任务建议的模型档位(由 `intent::suggested_model_tier` 派生,
/// 或无意图场景传 None)。原 `max_by_key(weight)` 同 weight 返最后一个,顺序敏感无语义;
/// 接 tier 后,同 weight 时优先选 `intelligence` 满足 tier 下限的候选(见 `tier_match`)。
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct TaskRequirements { pub struct TaskRequirements {
/// 任务所需的模态集合(全子集匹配:任务所需模态都必须在模型模态里) /// 任务所需的模态集合(全子集匹配:任务所需模态都必须在模型模态里)
pub modalities: Vec<Modality>, pub modalities: Vec<Modality>,
/// 是否需要工具调用能力(needs_tool_use=true 时候选必须含 Capability::ToolUse) /// 是否需要工具调用能力(needs_tool_use=true 时候选必须含 Capability::ToolUse)
pub needs_tool_use: bool, pub needs_tool_use: bool,
/// 预估上下文大小(tokens,模型 context_window 必须 >= 此值) /// 预估上下文大小(tokens,模型 context_window 必须 >= 此值)。
/// 调用点应传 TokenEstimator 估值而非 0(0 = 当前空操作,窗口过滤维度失效)。
pub estimated_context: usize, pub estimated_context: usize,
/// 任务建议的模型档位(意图→ModelTier,无意图场景 None)。
/// 同 weight 候选间按 tier tiebreak(满足 tier 下限的候选胜)。
pub tier: Option<ModelTier>,
} }
/// 模型路由器(单元结构,无状态)。 /// 模型路由器(单元结构,无状态)。
@@ -39,6 +47,33 @@ pub struct TaskRequirements {
/// select 为关联函数:给定需求 + 候选池,执行过滤链选最优模型。 /// select 为关联函数:给定需求 + 候选池,执行过滤链选最优模型。
pub struct ModelRouter; pub struct ModelRouter;
/// ModelTier → IntelligenceTier 下限映射(子项 2 tier tiebreak 用)。
///
/// 任务建议档位(ModelTier:F-Heavy)映射到模型智力下限(IntelligenceTier),
/// 同 weight 候选间优先选 `model.intelligence >= 下限` 的(满足任务复杂度需求)。
/// - `Fast` → `Lite`(轻量意图,任何模型都满足)
/// - `Standard` → `Standard`(日常,需 Standard 及以上)
/// - `Heavy` → `Plus`(复杂推理,需 Plus 及以上)
fn tier_min_intelligence(tier: ModelTier) -> IntelligenceTier {
match tier {
ModelTier::Fast => IntelligenceTier::Lite,
ModelTier::Standard => IntelligenceTier::Standard,
ModelTier::Heavy => IntelligenceTier::Plus,
}
}
/// 同 weight tiebreak:候选是否满足任务建议档位的智力下限。
///
/// 返 `bool`(满足 = true)。调用方在 `max_by` 闭包内 `a_match.cmp(&b_match)` 把 bool 转 Ordering:
/// a 满足而 b 不满足 → Greater(a 胜);都满足/都不满足 → Equal(max_by 并列返最后一个)。
/// - `req.tier = None`(无意图场景,标题/扫描/压缩):恒 true(所有候选等价,保留旧行为)。
/// - `req.tier = Some(t)`:返 `model_intel >= tier_min_intelligence(t)`。
fn tier_match(model_intel: IntelligenceTier, req_tier: Option<ModelTier>) -> bool {
req_tier
.map(|t| model_intel >= tier_min_intelligence(t))
.unwrap_or(true) // None → 视作满足(tiebreak 维度不参与,保旧行为)
}
impl ModelRouter { impl ModelRouter {
/// 在候选池中选出最优模型(过滤链)。 /// 在候选池中选出最优模型(过滤链)。
/// ///
@@ -47,23 +82,42 @@ impl ModelRouter {
/// 2. 模态匹配 — 任务所需模态全在模型模态里 /// 2. 模态匹配 — 任务所需模态全在模型模态里
/// 3. 能力匹配 — needs_tool_use 时候选必须含 ToolUse /// 3. 能力匹配 — needs_tool_use 时候选必须含 ToolUse
/// 4. 窗口够大 — context_window >= estimated_context /// 4. 窗口够大 — context_window >= estimated_context
/// 5. max_by_key 选最优:纯 weight 主导(权重高者胜) /// 5. max_by 选最优:**主键 weight 降序**(权重高者胜),**同 weight 时按 tier tiebreak**
/// (满足任务建议档位 `intelligence >= tier_min` 的候选胜)。
/// ///
/// 路由已解耦(2026-06-18 B-260618-03):原「智力达标」/「成本可控」两步删除, /// tier tiebreak(子项 2 根因修复):原 `max_by_key(weight)` 同 weight 返最后一个,
/// 原第 7 步排序的 `Reverse(cost_tier)` 同权重选便宜也已删除——排序纯 weight 主导。 /// 顺序敏感无语义(intent suggested_model_tier 恒 None)→ 现接 `req.tier`
/// cost_tier/intelligence 数据无客观依据(provider /v1/models 不返回,靠预设表+模型名 /// (由 intent→ModelTier 派生),同 weight 时优先选满足档位下限的候选。
/// 启发式猜),不参与硬路由。枚举保留供未来真实判别源再接回。 /// `req.tier = None` 时 tiebreak 维度退化为等价(保留旧行为,标题/扫描路径无回归)。
///
/// 路由已解耦:原「智力达标」/「成本可控」两步删除,
/// 原第 7 步排序的 `Reverse(cost_tier)` 同权重选便宜也已删除——排序主键 weight 主导,
/// tiebreak 由 tier(基于 intelligence,有客观档位映射依据)替代纯 max_by_key 顺序。
pub fn select<'a>(req: &TaskRequirements, pool: &'a [ModelConfig]) -> Option<&'a ModelConfig> { pub fn select<'a>(req: &TaskRequirements, pool: &'a [ModelConfig]) -> Option<&'a ModelConfig> {
pool.iter() pool.iter()
.filter(|m| m.enabled) // 1. 只选启用的 .filter(|m| m.enabled) // 1. 只选启用的
.filter(|m| req.modalities.iter().all(|r| m.modalities.contains(r))) // 2. 模态匹配 .filter(|m| req.modalities.iter().all(|r| m.modalities.contains(r))) // 2. 模态匹配
.filter(|m| !req.needs_tool_use || m.capabilities.contains(&Capability::ToolUse)) // 3. 能力匹配 .filter(|m| !req.needs_tool_use || m.capabilities.contains(&Capability::ToolUse)) // 3. 能力匹配
.filter(|m| m.context_window >= req.estimated_context) // 4. 窗口够大 .filter(|m| m.context_window >= req.estimated_context) // 4. 窗口够大
.max_by_key(|m| m.weight) // 5. 纯 weight 主导 // 5. 主键 weight 降序,同 weight 时 tier tiebreak(满足档位下限的候选胜)。
// max_by 语义:comparator 返 a 相对 b 的 Ordering,Greater = a 胜;
// 同 key(全 Equal)时 max_by 返最后一个(对齐原 max_by_key 并列返最后的语义)。
.max_by(|a, b| {
// 主键:weight,a 大则 a 胜(Greater)。
let by_weight = a.weight.cmp(&b.weight);
if by_weight != std::cmp::Ordering::Equal {
return by_weight;
}
// tiebreak:tier 满足度。a 满足档位下限而 b 不满足 → a 胜(Greater)。
// tier_match 返 bool,bool 比较:true > false(满足 > 不满足)。
let a_match = tier_match(a.intelligence, req.tier);
let b_match = tier_match(b.intelligence, req.tier);
a_match.cmp(&b_match)
})
} }
} }
/// 阶段5 调用点 helper — 路由选模型并直接返回 model_id(纯函数)。 /// 调用点 helper — 路由选模型并直接返回 model_id(纯函数)。
/// ///
/// 给定 TaskRequirements + 候选池,返回最优模型的 `model_id`。 /// 给定 TaskRequirements + 候选池,返回最优模型的 `model_id`。
/// 调用点用法:`provider.model_configs`(Vec<ModelConfig>)→ `select_model_id(&req, &pool)` /// 调用点用法:`provider.model_configs`(Vec<ModelConfig>)→ `select_model_id(&req, &pool)`
@@ -77,6 +131,22 @@ pub fn select_model_id(req: &TaskRequirements, pool: &[ModelConfig]) -> Option<S
ModelRouter::select(req, pool).map(|m| m.model_id.clone()) ModelRouter::select(req, pool).map(|m| m.model_id.clone())
} }
/// 判断 model_id 是否为图像生成模型(文本任务不应使用)。
/// 用于 select 返回 None 兜底 default_model 时的防御:避免标题/压缩等文本任务
/// 误用图像模型(如 sensenova-u1-fast/dall-e)致 LLM 404 刷屏(治 default_model 配错为图像模型)。
/// 判断:pool 有该模型 → 校验模态是否含 Text(最可靠);pool 无 → 名字启发式(已知图像模型模式)。
pub fn is_likely_image_model(model_id: &str, pool: &[ModelConfig]) -> bool {
if let Some(m) = pool.iter().find(|m| m.model_id == model_id) {
return !m.modalities.contains(&Modality::Text);
}
let lower = model_id.to_lowercase();
const IMAGE_PATTERNS: &[&str] = &[
"dall-e", "gpt-image", "imagen", "flux", "stable-diffusion",
"sensenova-u1", "u1-fast", "midjourney", "sd-", "sdxl",
];
IMAGE_PATTERNS.iter().any(|p| lower.contains(p))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -93,20 +163,22 @@ mod tests {
intelligence: IntelligenceTier::Standard, intelligence: IntelligenceTier::Standard,
weight: 50, weight: 50,
context_window: 8192, context_window: 8192,
temperature_supported: true,
probe_source: None, probe_source: None,
} }
} }
/// 构造一个宽松需求(默认全过过滤,调用方按需覆盖字段)。 /// 构造一个宽松需求(默认全过过滤,调用方按需覆盖字段)。tier=None 保留旧行为。
fn req() -> TaskRequirements { fn req() -> TaskRequirements {
TaskRequirements { 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,
} }
} }
// ── 阶段5 select_model_id helper ── // ── select_model_id helper ──
#[test] #[test]
fn select_model_id_empty_pool_returns_none() { fn select_model_id_empty_pool_returns_none() {
@@ -248,7 +320,7 @@ mod tests {
); );
} }
// ── 步骤 4(原智力/成本过滤已解耦 B-260618-03):窗口够大 ── // ── 步骤 4(原智力/成本过滤已解耦):窗口够大 ──
#[test] #[test]
fn context_window_insufficient() { fn context_window_insufficient() {
@@ -261,7 +333,36 @@ mod tests {
assert!(ModelRouter::select(&r, &pool).is_none()); assert!(ModelRouter::select(&r, &pool).is_none());
} }
// ── 步骤 5:max_by_key (纯 weight) ── #[test]
fn estimated_context_filters_small_window_model() {
// 子项 1 根因修复回归测:estimated_context 非零(调用点传 TokenEstimator 估值,非死代码 0)
// → 步骤 4 窗口过滤生效。两候选:小窗口(4K)weight 90(高诱惑)+ 大窗口(128K)weight 50。
// 任务预估 8K 上下文 → 小窗口模型被滤,只剩大窗口候选胜(即使 weight 低)。
// 若调用点回退传 0(原 bug),两候选窗口都 >= 0,weight 90 的小窗口模型会胜(误选)。
let pool = vec![
ModelConfig {
weight: 90,
context_window: 4096, // 小窗口,高 weight 诱惑
..model("small-window-heavy")
},
ModelConfig {
weight: 50,
context_window: 131072, // 大窗口,低 weight
..model("large-window-light")
},
];
let r = TaskRequirements {
estimated_context: 8000, // 任务预估 8K,小窗口模型装不下
..req()
};
assert_eq!(
ModelRouter::select(&r, &pool).unwrap().model_id,
"large-window-light",
"estimated_context 非零应滤掉小窗口候选,即使其 weight 更高"
);
}
// ── 步骤 5:max_by(weight 主键,tier tiebreak) ──
#[test] #[test]
fn single_match_returns_it() { fn single_match_returns_it() {
@@ -293,8 +394,9 @@ mod tests {
#[test] #[test]
fn same_weight_picks_first_match() { fn same_weight_picks_first_match() {
// 同 weight 70,纯 weight 主导(无 cost_tier tie-break):max_by_key 遇并列 key // 同 weight 70,tier=None(req() 默认):tiebreak 维度退等价,max_by 遇并列返最后一个
// 返回最后一个(rust Iterator::max_by_key 语义)。验证同 weight 不再按 cost 取舍。 // (rust Iterator::max_by 语义,与原 max_by_key 一致)。验证同 weight + tier=None
// 不再按 cost 取舍,行为对齐接入 tier tiebreak 前的语义(标题/扫描路径无回归)。
let pool = vec![ let pool = vec![
ModelConfig { ModelConfig {
weight: 70, weight: 70,
@@ -315,17 +417,17 @@ mod tests {
#[test] #[test]
fn all_dimensions_match_picks_best() { fn all_dimensions_match_picks_best() {
// 3+ 候选各维度参差,验证过滤链全过 + max_by_key 纯 weight 选最优。 // 3+ 候选各维度参差,验证过滤链全过 + max_by(weight, tier) 选最优。
// (B-260618-03:智力/成本过滤已解耦,原步骤 4/5 删除,候选 d 不再因 intelligence 滤掉) // (智力/成本过滤已解耦,原步骤 4/5 删除,候选 d 不再因 intelligence 滤掉)
// //
// 候选: // 候选:
// a: weight 60 → 通过全部过滤,key=60 // a: weight 60 → 通过全部过滤
// b: weight 80 → 通过,key=80 — weight 最高档(与 c 并列) // b: weight 80 → 通过 — weight 次高档(与 c 并列,但 tier=None 故 tiebreak 退等价)
// c: weight 80 → 通过,key=80 — 同 weight 80,max_by_key 并列返回最后 // c: weight 80 → 通过 — 同 weight 80,tier=None 时 max_by 返并列最后一个
// d: weight 90 → 通过(B-260618-03 后 intelligence 不参与过滤),key=90 — weight 最高,胜 // d: weight 90 → 通过(intelligence 不参与过滤)— weight 最高,胜
// e: enabled=false → 步骤 1 滤掉 // e: enabled=false → 步骤 1 滤掉
// //
// 预期:d 胜(weight 90 最高,不再被 intelligence 滤掉) // 预期:d 胜(weight 90 最高,tier tiebreak 不触发因 weight 已决出胜负)
let pool = vec![ let pool = vec![
ModelConfig { ModelConfig {
weight: 60, weight: 60,
@@ -361,7 +463,113 @@ mod tests {
modalities: vec![Modality::Text], modalities: vec![Modality::Text],
needs_tool_use: true, needs_tool_use: true,
estimated_context: 0, estimated_context: 0,
tier: None,
}; };
assert_eq!(ModelRouter::select(&r, &pool).unwrap().model_id, "d"); assert_eq!(ModelRouter::select(&r, &pool).unwrap().model_id, "d");
} }
// ── 步骤 5 tiebreak(子项 2):同 weight 时 tier 决胜 ──
#[test]
fn tier_tiebreak_heavy_prefers_meeting_model() {
// 子项 2 根因修复:同 weight 时,任务建议 Heavy(req.tier=Some(Heavy))→ tier_min=Plus,
// 满足 intelligence>=Plus 的候选胜过不满足的。
// 候选 a:Standard(不满足 Plus),候选 b:Plus(满足),同 weight 50。
// 预期:b 胜(满足 Heavy 档位下限)。原 max_by_key 会返最后一个(顺序敏感无语义)。
let pool = vec![
ModelConfig {
weight: 50,
intelligence: IntelligenceTier::Standard,
..model("a_standard")
},
ModelConfig {
weight: 50,
intelligence: IntelligenceTier::Plus,
..model("b_plus")
},
];
let r = TaskRequirements {
tier: Some(ModelTier::Heavy),
..req()
};
assert_eq!(
ModelRouter::select(&r, &pool).unwrap().model_id,
"b_plus",
"同 weight 时 Heavy 档位应优先选 Plus(满足)而非 Standard(不满足)"
);
}
#[test]
fn tier_tiebreak_none_preserves_max_by_key_semantics() {
// tier=None(标题/扫描/压缩无意图场景)→ tiebreak 维度退等价,
// max_by 同 key 返最后一个(对齐原 max_by_key 行为,无回归)。
// 候选 a/b 同 weight 70,顺序 a 在前 b 在后 → 预期返 b(max_by 并列返最后)。
let pool = vec![
ModelConfig {
weight: 70,
intelligence: IntelligenceTier::Standard,
..model("a")
},
ModelConfig {
weight: 70,
intelligence: IntelligenceTier::Plus,
..model("b")
},
];
// tier=None 时即使 b 的 intelligence 更高也不应胜(tiebreak 不参与),保 max_by_key 语义。
assert_eq!(ModelRouter::select(&req(), &pool).unwrap().model_id, "b");
}
#[test]
fn tier_tiebreak_chat_fast_any_model_meets_lite() {
// 任务建议 Fast → tier_min=Lite,任何模型 intelligence>=Lite(Lite 是最低档)→ 都满足。
// 故 Fast 档位下 tiebreak 退等价(都满足),max_by 同 weight 返最后一个,行为不变。
let pool = vec![
ModelConfig {
weight: 50,
intelligence: IntelligenceTier::Lite,
..model("a_lite")
},
ModelConfig {
weight: 50,
intelligence: IntelligenceTier::Ultra,
..model("b_ultra")
},
];
let r = TaskRequirements {
tier: Some(ModelTier::Fast),
..req()
};
// 都满足 Lite 下限 → tiebreak 等价 → max_by 返最后一个 = b_ultra
assert_eq!(ModelRouter::select(&r, &pool).unwrap().model_id, "b_ultra");
}
#[test]
fn tier_tiebreak_weight_still_dominates() {
// tier 不凌驾 weight:weight 高者永远胜,即使低 weight 候选满足 tier 而高 weight 不满足。
// 候选 a:weight 90,Standard(不满足 Heavy/Plus)。
// 候选 b:weight 50,Plus(满足 Heavy)。
// 预期:a 胜(weight 主键优先,tiebreak 只在 weight 相同时触发)。
let pool = vec![
ModelConfig {
weight: 90,
intelligence: IntelligenceTier::Standard,
..model("a_heavy_weight")
},
ModelConfig {
weight: 50,
intelligence: IntelligenceTier::Plus,
..model("b_meets_tier")
},
];
let r = TaskRequirements {
tier: Some(ModelTier::Heavy),
..req()
};
assert_eq!(
ModelRouter::select(&r, &pool).unwrap().model_id,
"a_heavy_weight",
"weight 主键应凌驾 tier tiebreak"
);
}
} }
+272
View File
@@ -0,0 +1,272 @@
//! 原生 SSE 流式解析器 — 替代 eventsource-stream 库
//!
//! eventsource-stream 0.2 在 Windows 上对 Deepseek 等 provider
//! 的 SSE 响应解析时报 "Transport error: error decoding response body" 错误。
//!
//! 根因分析:
//! eventsource-stream 内部对 bytes_stream 做严格的 UTF-8 + SSE 协议校验,遇到以下情况
//! 即报错(且不可恢复):
//! - 流中断时未完整接收 UTF-8 字符(网络抖动常见)
//! - 缺少结束的 \n\n(连接断开常见)
//! - 非 ASCII 字符的多字节序列跨 chunk 边界
//!
//! 本解析器实现:
//! - 宽松的 UTF-8 处理(用 bytes 累积,优先 `String::from_utf8` 严格解码保留多字节完整,
//! 失败再降级 `from_utf8_lossy` 不报错)
//! - SSE 协议简单解析(分隔符兼容 `\n\n` / `\r\n\r\n` / `\r\r` 三种行尾归一,
//! data: 前缀提取)
//! - 多字节续接:事件体只在定位到完整分隔符后才解码,跨 chunk 的多字节字符在
//! buffer 中天然拼接复原(分隔符为 ASCII,不落在多字节序列中间)
//! - 容错:解析失败时跳过该事件继续,不中断流;buffer 设 `BUF_MAX` 上限防 OOM,
//! 超限时按事件边界对齐保尾裁剪(丢最旧的超限事件整体),不产出半截破损事件
//! - 返回 Vec<String>(每个元素是一个事件 data 字段拼接内容)
use futures::Stream;
use std::pin::Pin;
use std::task::{Context, Poll};
/// SSE 事件流的 data 字段内容
pub type SseEvent = String;
/// 缓冲区字节上限(防御性):正常流会被 parse_events 持续消费,
/// 仅畸形上游(持续不发分隔符)时触发裁剪,避免无界增长 OOM。
const BUF_MAX: usize = 1 << 20; // 1 MiB
/// 原生 SSE 解析器流:包装 bytes_stream,产出 Vec<SseEvent>(一次 poll 可能产出多个事件)
pub struct SseStream<S> {
inner: S,
buffer: Vec<u8>,
/// 超限裁剪置位:无边界的超限事件已整体丢弃,但其尾部字节仍在后续 chunk 中,
/// 到达收尾 boundary 前的数据须静默吞掉,避免残缺事件体被当完整事件产出
skip_partial: bool,
}
impl<S> SseStream<S>
where
S: Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Unpin,
{
pub fn new(inner: S) -> Self {
Self {
inner,
buffer: Vec::with_capacity(8192),
skip_partial: false,
}
}
/// 从 buffer 解析完整的 SSE 事件,返回事件列表。
///
/// 事件分隔符兼容三种行尾归一:
/// - `\n\n` (LF LF,规范形态)
/// - `\r\n\r\n` (CRLF CRLF,部分中转站/反代发送)
/// - `\r\r` (CR CR,极少见但同源)
///
/// 取最早出现的分隔符;事件体内部的 `\r\n` 在解码后归一为 `\n`,
/// 确保 `lines()` / `strip_prefix` 等行级解析正常工作。
///
/// 多字节续接:事件体只在定位到完整分隔符后才解码,跨 chunk 的多字节
/// 字符在 buffer 中天然拼接复原(分隔符为 ASCII,不会落在多字节序列中间)。
/// 解码用 `String::from_utf8`(失败再降级 lossy),避免把合法多字节误判为残缺。
fn parse_events(&mut self) -> Vec<SseEvent> {
// 超限裁剪收尾:被整体丢弃的残缺事件,其后续字节以首个 boundary 收尾,
// 此处静默吞掉不产出,之后的正常事件恢复正常解析
if self.skip_partial {
match Self::find_event_boundary(&self.buffer) {
Some((pos, len)) => {
self.buffer.drain(..pos + len);
self.skip_partial = false;
}
None => return Vec::new(),
}
}
let mut events = Vec::new();
loop {
// 同时识别三种分隔符,取最早出现的位置与该分隔符的字节长度
let sep = Self::find_event_boundary(&self.buffer);
let (sep_pos, sep_len) = match sep {
Some(v) => v,
None => break,
};
// 取出事件体 + 分隔符整体(buffer 前 sep_pos+sep_len 字节)
let event_bytes: Vec<u8> = self.buffer.drain(..sep_pos + sep_len).collect();
// 去掉末尾的分隔符,得到事件体
let body_end = event_bytes.len().saturating_sub(sep_len);
let body_bytes = &event_bytes[..body_end];
// 解码:优先严格 UTF-8(保留多字节完整),失败再降级 lossy(不崩)
let mut event_text = match std::str::from_utf8(body_bytes) {
Ok(s) => s.to_owned(),
Err(_) => String::from_utf8_lossy(body_bytes).into_owned(),
};
// 事件体内 CRLF / 裸 CR 归一为 LF,保证后续行级解析一致
if event_text.contains('\r') {
event_text = event_text.replace("\r\n", "\n").replace('\r', "\n");
}
let data = Self::extract_data_fields(&event_text);
if !data.is_empty() {
events.push(data);
}
}
events
}
/// 在 buffer 中查找最早的事件分隔符,返回 `(起始位置, 分隔符字节长度)`。
/// 扫描 `\r\n\r\n` / `\n\n` / `\r\r` 三种形态,取最小起始位置(取最早分隔符)。
fn find_event_boundary(buf: &[u8]) -> Option<(usize, usize)> {
// candidates: (pattern, length)
const PATTERNS: &[(&[u8], usize)] = &[
(b"\r\n\r\n", 4),
(b"\n\n", 2),
(b"\r\r", 2),
];
let mut best: Option<(usize, usize)> = None;
for &(pat, len) in PATTERNS {
// windows 匹配;找到首次出现位置
if let Some(pos) = buf.windows(pat.len()).position(|w| w == pat) {
match best {
None => best = Some((pos, len)),
Some((bpos, _)) if pos < bpos => best = Some((pos, len)),
_ => {}
}
}
}
best
}
/// 从 SSE 事件文本中提取所有 data: 行的内容,拼接为单个字符串(多个 data 行用 \n 连接)
fn extract_data_fields(event_text: &str) -> String {
let mut data_parts: Vec<&str> = Vec::new();
for line in event_text.lines() {
if let Some(rest) = line.strip_prefix("data:") {
let rest = rest.strip_prefix(' ').unwrap_or(rest);
data_parts.push(rest);
}
// 忽略 event:/id:/retry: 等其他 SSE 字段(OpenAI/Anthropic 协议未使用)
}
data_parts.join("\n")
}
}
impl<S> Stream for SseStream<S>
where
S: Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Unpin,
{
type Item = Result<Vec<SseEvent>, String>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
use futures::StreamExt;
loop {
// 先尝试从 buffer 解析完整事件
let events = self.parse_events();
if !events.is_empty() {
return Poll::Ready(Some(Ok(events)));
}
// buffer 不足以解析出完整事件,从 inner 读更多数据
match self.inner.poll_next_unpin(cx) {
Poll::Ready(Some(Ok(chunk))) => {
self.buffer.extend_from_slice(&chunk);
// 防御:缓冲区不应无限增长。正常情况下 parse_events 会持续消费,
// 超限只发生在单个事件体超过 BUF_MAX 的场景(畸形上游不发分隔符,
// 或超大单事件)。按字节盲截头部会把半截事件体留给后续解析,产出
// 破损 JSON 且无法复原;改为对齐事件边界的保尾裁剪:
// - 已有完整分隔符 → 最旧的一个事件(超限源头)整体丢弃,保留的
// 数据从干净的事件起点开始,后续事件解析不受损;
// - 全程无分隔符 → 整体丢弃并置 skip_partial,该事件的收尾字节
// 到达时静默吞掉。
if self.buffer.len() > BUF_MAX {
match Self::find_event_boundary(&self.buffer) {
Some((pos, len)) => {
self.buffer.drain(..pos + len);
}
None => {
self.buffer.clear();
self.skip_partial = true;
}
}
}
continue;
}
Poll::Ready(Some(Err(e))) => {
return Poll::Ready(Some(Err(format!("SSE 流读取错误: {}", e))));
}
Poll::Ready(None) => {
// 流结束,处理 buffer 中的剩余数据(可能没有 \n\n 结束的最后一段);
// skip_partial 置位时剩余数据是被丢弃残缺事件的尾段,不得溢出为事件
if !self.buffer.is_empty() && !self.skip_partial {
// 解码:优先严格 UTF-8(EOF 无下一 chunk 可拼接,
// 残缺多字节尾部只能尽力而为,降级 lossy 不崩)
let mut remaining = match String::from_utf8(self.buffer.clone()) {
Ok(s) => s,
Err(_) => String::from_utf8_lossy(&self.buffer).into_owned(),
};
self.buffer.clear();
// 同主解析路径:CRLF / 裸 CR 归一为 LF
if remaining.contains('\r') {
remaining = remaining.replace("\r\n", "\n").replace('\r', "\n");
}
let data = Self::extract_data_fields(&remaining);
if !data.is_empty() {
return Poll::Ready(Some(Ok(vec![data])));
}
}
return Poll::Ready(None);
}
Poll::Pending => return Poll::Pending,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use futures::stream;
/// 构造只含 Ok chunk 的内存流,跑完整个 SseStream,返回拍平的事件序列
fn run_chunks(chunks: Vec<String>) -> Vec<SseEvent> {
use futures::StreamExt;
let items: Vec<Result<bytes::Bytes, reqwest::Error>> = chunks
.into_iter()
.map(|c| Ok(bytes::Bytes::from(c)))
.collect();
let mut s = SseStream::new(stream::iter(items));
futures::executor::block_on(async {
let mut out = Vec::new();
while let Some(item) = s.next().await {
out.extend(item.expect("测试流不应产生错误"));
}
out
})
}
/// 回归:正常多事件流照常解析
#[test]
fn normal_events_parse() {
let events = run_chunks(vec![
"data: {\"a\":1}\n\n".into(),
"data: {\"b\":2}\n\n".into(),
]);
assert_eq!(events, vec!["{\"a\":1}".to_string(), "{\"b\":2}".to_string()]);
}
/// 超限单事件(末尾带完整分隔符)整体丢弃:不产出半截破损事件,后续正常事件照常解析。
/// 旧按字节截头实现会把残留尾部 "data: TAIL" 行当伪事件产出
#[test]
fn oversize_event_with_boundary_dropped_whole() {
let giant = format!("data: {}\ndata: TAIL\n\n", "x".repeat(BUF_MAX));
let events = run_chunks(vec![giant, "data: {\"ok\":1}\n\n".into()]);
assert_eq!(events, vec!["{\"ok\":1}".to_string()]);
}
/// 验收:超限注入(无边界大块)丢弃后,残缺事件收尾静默吞掉,后续正常事件仍可解析。
/// 旧按字节截头实现会把 STALE-TAIL 残段当伪事件产出
#[test]
fn oversize_boundaryless_blob_then_normal_events_still_parse() {
let events = run_chunks(vec![
"x".repeat(BUF_MAX + 100),
"data: STALE-TAIL\n\n".into(),
"data: {\"ok\":1}\n\n".into(),
"data: [DONE]\n\n".into(),
]);
assert_eq!(events, vec!["{\"ok\":1}".to_string(), "[DONE]".to_string()]);
}
}
+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);
}
+191
View File
@@ -0,0 +1,191 @@
//! 提交冻结机械闸(39 治理) — AI 引擎派发命令的 git commit 拦截。
//!
//! 冻结期间(用户 🔒 令)禁止引擎/挂机会话执行 git commit:命令在 spawn 前被拒,
//! 改动留在工作区。用户本人终端不受影响(本闸只作用于引擎 execute 入口)。
//!
//! 冻结标志解析(优先级:环境变量 DEVFLOW_COMMIT_FROZEN > env 内 DevFlow 标志):
//! - env::var("DEVFLOW_COMMIT_FROZEN")="1"/"true" → 冻结
//! - ShellRequest.env 同名键同样生效(测试/调用方定向注入)
//! 默认不冻结(用户本人正常提交流程零影响)。
//!
//! 拦截范围:命令子串含 `git commit`(含 `git -C x commit`)即拒;`git commit --amend`
//! 同拒。`git log/diff/status/add/push` 不拦(只冻结提交动作)。
//!
//! 提示文案给 LLM 指明出路:改留工作区,等「攒批提交通知」。
use std::sync::atomic::{AtomicBool, Ordering};
use crate::shell::ShellRequest;
/// 进程级冻结标志(引擎侧写入:unattended 轮询每轮按看板 🔒 行 + KV 覆盖同步)。
/// 用静态位而非 std::env::set_var(Rust 2024 edition set_var unsafe 且多线程 UB)。
static FROZEN: AtomicBool = AtomicBool::new(false);
/// 引擎侧设置/解除冻结(unattended 轮询每轮调用)。
pub fn set_frozen(v: bool) {
FROZEN.store(v, Ordering::SeqCst);
}
/// 环境变量真值判定:"1"/"true"(含大小写变体)为冻结,其余("0"/"false"/空)为不冻结。
fn env_val_is_truthy(v: &str) -> bool {
matches!(v, "1" | "true" | "TRUE" | "True")
}
/// 是否启用冻结:静态标志(引擎同步) 或 环境变量 DEVFLOW_COMMIT_FROZEN(调用方/测试定向注入)。
pub fn is_frozen(extra_env: Option<&std::collections::HashMap<String, String>>) -> bool {
if FROZEN.load(Ordering::SeqCst) {
return true;
}
if let Some(v) = extra_env.and_then(|m| m.get("DEVFLOW_COMMIT_FROZEN")) {
return env_val_is_truthy(v);
}
std::env::var("DEVFLOW_COMMIT_FROZEN")
.map(|v| env_val_is_truthy(&v))
.unwrap_or(false)
}
/// 命令是否为 git commit 提交动作(token 判据,含 -C 前缀形态与复合命令)。
pub fn is_commit_command(command: &str) -> bool {
// 按常见命令分隔符切段( ; && || | 换行),每段独立判 git commit(防 "x; git commit" 绕过)
command
.split(|c: char| matches!(c, ';' | '|' | '&' | '\n'))
.any(is_commit_segment)
}
/// 单段判据:token 找 "git",跳过全局选项(-C path 等)后是否接 "commit"。
fn is_commit_segment(segment: &str) -> bool {
let tokens: Vec<String> = segment
.split_whitespace()
.map(|t| t.trim_matches(|c: char| c == '"' || c == '\'').to_lowercase())
.collect();
for (i, t) in tokens.iter().enumerate() {
if t == "git" {
let mut j = i + 1;
while j < tokens.len() {
match tokens[j].as_str() {
// `-c key=val` 两 token 形态:值在下一 token,跳 2
"-c" => j += 2,
// `--git-dir=path`/`--work-tree=path` 单 token 合并形态:自含值,跳 1
t if t.starts_with("--git-dir=") || t.starts_with("--work-tree=") => j += 1,
// `--git-dir path`/`--work-tree path` 两 token 形态:跳 2
"--git-dir" | "--work-tree" => j += 2,
"commit" => return true,
_ => break,
}
}
}
}
false
}
/// 冻结闸检查入口:冻结在场且命令是 git commit → 返回拒绝 Err(不 spawn)。
pub fn check(request: &ShellRequest) -> Result<(), String> {
if !is_frozen(Some(&request.env)) {
return Ok(());
}
if is_commit_command(&request.command) {
return Err(format!(
"[提交冻结令] 检测到 git commit 命令,当前处于提交冻结期(用户 🔒 令),已拒绝执行: \
改动保留在工作区,等用户「攒批提交通知」后才可提交。命令: {}",
truncate_cmd(&request.command)
));
}
Ok(())
}
fn truncate_cmd(s: &str) -> String {
if s.chars().count() <= 120 {
s.to_string()
} else {
let cut: String = s.chars().take(120).collect();
format!("{}…", cut)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn req(cmd: &str) -> ShellRequest {
ShellRequest {
command: cmd.to_string(),
working_dir: None,
env: HashMap::new(),
timeout_secs: None,
shell_type: None,
}
}
fn req_env(cmd: &str, env: &[(&str, &str)]) -> ShellRequest {
let mut r = req(cmd);
r.env = env
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
r
}
#[test]
fn commit_detection_covers_variants() {
assert!(is_commit_command("git commit -m 'x'"));
assert!(is_commit_command("git -C E:/repo commit -m x"));
assert!(is_commit_command("git commit --amend"));
assert!(is_commit_command("git commit -m y"));
assert!(is_commit_command("git commit; git push"));
// PowerShell / sh 包裹形态
assert!(is_commit_command("powershell -Command git commit -m x"));
assert!(is_commit_command("cd repo && git commit -m x"));
assert!(is_commit_command("git add . && git commit -m x"));
}
#[test]
fn non_commit_git_not_blocked() {
assert!(!is_commit_command("git log -1"));
assert!(!is_commit_command("git status"));
assert!(!is_commit_command("git diff --stat"));
assert!(!is_commit_command("git add ."));
assert!(!is_commit_command("git push origin main"));
assert!(!is_commit_command("cargo test"));
// commitment 等误命中防护:commit 后无后续 token 才算
assert!(!is_commit_command("git log --grep commit"));
}
#[test]
fn freeze_gate_blocks_commit_when_frozen() {
let r = req_env("git commit -m '细项批'", &[("DEVFLOW_COMMIT_FROZEN", "1")]);
assert!(check(&r).is_err(), "冻结在场 commit 必须被拒");
let r2 = req_env("git log -1", &[("DEVFLOW_COMMIT_FROZEN", "1")]);
assert!(check(&r2).is_ok(), "冻结只拦 commit 不拦只读命令");
}
/// 解冻态放行用例:持与 static_flag_toggle 同一把串行闸——静态位是进程级单源,
/// 置位瞬间并行跑到本用例会误判冻结(实测互踩),凡依赖「静态位=false」断言的用例一律持闸。
#[test]
fn no_freeze_no_block() {
let _gate = crate::shell::PWSH_CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let r = req("git commit -m '用户本人提交'");
assert!(check(&r).is_ok(), "无冻结标志零影响(用户正常提交不受影响)");
}
#[test]
fn frozen_false_value_no_block() {
let _gate = crate::shell::PWSH_CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let r = req_env("git commit -m x", &[("DEVFLOW_COMMIT_FROZEN", "0")]);
assert!(check(&r).is_ok(), "显式关闭标志不拦(解冻态)");
}
/// 静态标志(引擎同步路径)切换:置位拦 commit,复位恢复。
/// 串行闸:静态位是进程级单源,置位期间并行的 env 用例(check 走 is_frozen 静态位短路)会互踩,
/// 持锁串行 + 进出恢复默认(false)防污染。
#[test]
fn static_flag_toggle() {
// 复用 PWSH_CACHE_TEST_LOCK 串行闸(进程级互斥,专用闸后续有需要再单立)
let _gate = crate::shell::PWSH_CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
set_frozen(true);
assert!(check(&req("git commit -m x")).is_err(), "静态冻结位拦 commit");
assert!(check(&req("git diff")).is_ok(), "静态冻结位不拦只读命令");
set_frozen(false);
assert!(check(&req("git commit -m x")).is_ok(), "复位后恢复放行");
}
}
+488
View File
@@ -0,0 +1,488 @@
//! 环境感知系统 — 启动时一次性探测 OS/shell/工具版本/编码,全局缓存,注入 system_prompt。
//!
//! 设计目标:让 LLM 准确生成跨平台命令。LLM 训练数据 Unix 多,易生成 macOS/Linux 语法
//! 命令(PowerShell 5 不支持 `&&`、Windows 路径分隔符 `\`、GBK 终端中文乱码等),通过
//! 把当前平台的真实环境(操作系统/Shell/工具版本)拼进 system_prompt,LLM 即可生成与
//! 当前平台兼容的命令,从根上治「LLM 跨平台命令幻觉」。
//!
//! 缓存策略:`OnceLock` 全局单例,首次 `detect()` 探测,后续调用零开销。探测本身在
//! `spawn_blocking` 中执行(执行 `tool --version` 等阻塞 IO),避免阻塞 tokio runtime。
//! 探测失败的字段设 None/默认值,不向上传播错误(环境感知是 best-effort 增强,不应
//! 阻断主流程)。
use std::sync::OnceLock;
/// 环境快照 — 系统环境的不可变视图。
#[derive(Debug, Clone, serde::Serialize)]
pub struct EnvSnapshot {
/// 操作系统族:"windows" / "macos" / "linux"
pub os: String,
/// OS 版本:"11" / "Ubuntu 22.04"(探测失败为空串)
pub os_version: String,
/// Shell 名:"powershell" / "pwsh" / "bash" / "zsh" / "sh"
pub shell: String,
/// 路径分隔符:"\\"(Windows) / "/"(Unix)
pub path_sep: String,
/// 终端编码:"utf-8" / "gbk"(Windows 中文常见 GBK,致 LLM 输出乱码)
pub encoding: String,
/// 工具版本(逐个探测,缺失为 None)
pub tools: ToolVersions,
}
/// 各开发工具的版本信息。
#[derive(Debug, Clone, serde::Serialize, Default)]
pub struct ToolVersions {
pub python: Option<String>,
pub node: Option<String>,
pub rust: Option<String>,
pub go: Option<String>,
pub docker: Option<String>,
pub git: Option<String>,
}
impl EnvSnapshot {
/// 探测环境(惰性,OnceLock 全局缓存,整个进程生命周期只探一次)。
///
/// 返回 `&'static EnvSnapshot` —— 引用静态存储,可安全地长存于 loop 不变量中。
/// 探测在 `spawn_blocking` 中同步执行(`tool --version` 是阻塞 IO),不卡 runtime。
pub async fn detect() -> &'static EnvSnapshot {
static SNAPSHOT: OnceLock<EnvSnapshot> = OnceLock::new();
if let Some(snap) = SNAPSHOT.get() {
return snap;
}
// 首次探测:同步逻辑包到 spawn_blocking,避免阻塞 async runtime。
// probe_version 内 std::process::Command::output() 无 timeout,
// Windows 上 python/node 若是 Microsoft Store App Execution Alias(用户未装但开了
// "应用执行别名"),`python --version` 触发 Store 重定向、process 不退出 → output()
// 永久阻塞 → spawn_blocking 线程永不返回 → detect().await 永久挂 → run_agentic_loop
// 卡在 EnvSnapshot::detect() 调用(agentic/mod.rs),后端日志断在"意图收敛工具"后,
// 前端表现为「发消息完全无回应然后卡死」(trace.log 实测 3 次发送全卡于此)。
// 修复: spawn_blocking 外包 5s timeout,超时返回 fallback 并 set 进 OnceLock(后续命中
// 缓存不再 probe),run_agentic_loop 最多等 5s 后继续,不再永久卡。
// 注: 超时后 spawn_blocking 线程仍在跑(卡在 output),但已脱离 await,不阻塞调用方;
// 线程最终随 process 退出或进程结束回收,无泄漏累积(OnceLock 已 set,不会重复 probe)。
let snap = match tokio::time::timeout(
std::time::Duration::from_secs(5),
tokio::task::spawn_blocking(|| EnvSnapshot::do_detect()),
).await {
Ok(Ok(s)) => s,
Ok(Err(join_err)) => {
eprintln!("[env_snapshot] 探测任务异常,回退默认环境: {}", join_err);
EnvSnapshot::fallback()
}
Err(_elapsed) => {
eprintln!(
"[env_snapshot] 探测超时(5s),某 tool(python/node/rustc/go/docker/git)命令卡住 \
(疑似 Windows Store App Execution Alias 触发 Store 重定向),回退默认环境"
);
EnvSnapshot::fallback()
}
};
// 多任务竞态:均等价,以先到者为准。
let _ = SNAPSHOT.set(snap);
SNAPSHOT.get().expect("EnvSnapshot 已初始化")
}
/// 同步探测(可能短暂阻塞,仅在 spawn_blocking 中调用)。
fn do_detect() -> EnvSnapshot {
EnvSnapshot {
os: std::env::consts::OS.to_string(),
os_version: detect_os_version(),
shell: detect_shell(),
path_sep: std::path::MAIN_SEPARATOR.to_string(),
encoding: detect_encoding(),
tools: ToolVersions {
python: probe_version("python", &["--version"]),
node: probe_version("node", &["--version"]),
rust: probe_version("rustc", &["--version"]),
go: probe_version("go", &["version"]),
docker: probe_version("docker", &["--version"]),
git: probe_version("git", &["--version"]),
},
}
}
/// 兜底:spawn_blocking panic/join 失败或外层 5s 超时时返回最小可用快照(全 None,字段不空)。
/// 注:异常/超时均**冻结**为 fallback 写入 OnceLock(EX-3)——本进程后续 detect() 不再重探,
/// 瞬时挂起(如 Store Alias)会被固化为整进程降级快照;解冻需重启进程。
fn fallback() -> EnvSnapshot {
EnvSnapshot {
os: std::env::consts::OS.to_string(),
os_version: String::new(),
shell: if cfg!(windows) { "powershell".into() } else { "sh".into() },
path_sep: std::path::MAIN_SEPARATOR.to_string(),
encoding: "utf-8".into(),
tools: ToolVersions::default(),
}
}
/// 生成 system_prompt 注入文本(拼到 system_prompt 尾部)。
///
/// 末尾加「请生成本平台兼容的命令」软提示,锚定 LLM 输出平台一致性。
pub fn to_prompt(&self) -> String {
let mut lines: Vec<String> = vec![
"## 系统环境".to_string(),
format!("- 操作系统: {} {}", self.os, self.os_version).trim_end().to_string(),
format!("- Shell: {}", self.shell),
format!("- 路径分隔符: {}", self.path_sep),
format!("- 终端编码: {}", self.encoding),
];
if let Some(v) = &self.tools.python {
lines.push(format!("- Python: {}", v));
}
if let Some(v) = &self.tools.node {
lines.push(format!("- Node: {}", v));
}
if let Some(v) = &self.tools.rust {
lines.push(format!("- Rust: {}", v));
}
if let Some(v) = &self.tools.go {
lines.push(format!("- Go: {}", v));
}
if let Some(v) = &self.tools.docker {
lines.push(format!("- Docker: {}", v));
}
if let Some(v) = &self.tools.git {
lines.push(format!("- Git: {}", v));
}
lines.push(String::new());
lines.push("注意: 请生成本平台兼容的命令。".to_string());
lines.join("\n")
}
}
/// 探测 OS 版本(各平台路径不一,失败返回空串而非 None,简化 prompt 拼接)。
fn detect_os_version() -> String {
// Windows:读注册表 HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion (ProductName/DisplayVersion)。
// 不依赖 winver GUI / reg.exe 输出格式,直接读注册表最稳。
#[cfg(windows)]
{
if let Some(v) = read_windows_version() {
return v;
}
return String::new();
}
// macOS:sw_vers -productVersion 输出如 "12.5"
#[cfg(target_os = "macos")]
{
if let Ok(out) = std::process::Command::new("sw_vers").arg("-productVersion").output() {
if out.status.success() {
return String::from_utf8_lossy(&out.stdout).trim().to_string();
}
}
return String::new();
}
// Linux:读 /etc/os-release 的 PRETTY_NAME 字段(系统标准位置)
#[cfg(target_os = "linux")]
{
if let Ok(content) = std::fs::read_to_string("/etc/os-release") {
for line in content.lines() {
if let Some(rest) = line.strip_prefix("PRETTY_NAME=") {
return rest.trim_matches('"').to_string();
}
}
}
return String::new();
}
#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))]
{
String::new()
}
}
#[cfg(windows)]
fn read_windows_version() -> Option<String> {
// 用 reg.exe query 读注册表(DisplayVersion 优先,如 "22H2";回退 ProductName,如 "Windows 10 Pro")。
// 避开 winreg crate 依赖(增加构建复杂度,且 reg.exe 在所有 Win 版本均自带)。
let out = std::process::Command::new("reg")
.args([
"query",
r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion",
"/v",
"DisplayVersion",
])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
.output()
.ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
// 输出形如: " DisplayVersion REG_SZ 22H2"
for line in text.lines() {
let trimmed = line.trim();
if let Some(idx) = trimmed.find("REG_SZ") {
let val = trimmed[idx + "REG_SZ".len()..].trim();
if !val.is_empty() {
return Some(format!("Windows {}", val));
}
}
}
None
}
/// 探测默认 shell(Windows 走 shell.rs 单源,Unix 读 SHELL 环境变量)。
///
/// 单源语义(2026-08 修复):Windows 分支不再独立 probe pwsh/powershell,而是:
/// 1) 先调 `shell::probe_pwsh_blocking()` 填充模块级 `PWSH_CACHE`(与 `shell::probe_pwsh()` 异步路径
/// 共用同一 OnceLock + 同一阻塞探测实现,杜绝两套逻辑漂移);
/// 2) 再调 `shell::current_shell()`(同步读 `PWSH_CACHE`),映射 ShellType → prompt 字符串。
/// 后续 `execute()` → `probe_pwsh().await` 直接命中缓存,跳过重复探测。
/// Unix 分支保留原 SHELL 环境变量读取(与执行侧 ShellType::Sh 默认一致,无漂移风险)。
fn detect_shell() -> String {
#[cfg(windows)]
{
// 单源填充 PWSH_CACHE(本函数运行在 EnvSnapshot::detect 的 spawn_blocking 内,
// 通常先于首次 execute(),故常是缓存的首次写入者)。
crate::shell::probe_pwsh_blocking();
// 单源读取并映射为 prompt 字符串。
match crate::shell::current_shell() {
crate::shell::ShellType::Pwsh => "pwsh".to_string(),
crate::shell::ShellType::PowerShell => "powershell".to_string(),
crate::shell::ShellType::Cmd => "cmd".to_string(),
crate::shell::ShellType::Sh => "sh".to_string(),
}
}
#[cfg(not(windows))]
{
// Unix:SHELL 环境变量优先,常见值 /bin/bash /bin/zsh /bin/sh。
if let Ok(sh) = std::env::var("SHELL") {
// 取 basename(/bin/zsh → zsh)
let name = sh.rsplit('/').next().unwrap_or(&sh);
if !name.is_empty() {
return name.to_string();
}
}
"sh".to_string()
}
}
/// 探测终端编码(Windows 中文常见 GBK,导致 LLM 输出 UTF-8 在终端乱码)。
fn detect_encoding() -> String {
#[cfg(windows)]
{
// chcp 输出形如 "活动代码页: 936"(GBK)。936 → gbk,65001 → utf-8,其余按数字降级。
if let Ok(out) = std::process::Command::new("chcp")
.stdout(Stdio::piped())
.stderr(Stdio::null())
.creation_flags(0x0800_0000)
.output()
{
let text = String::from_utf8_lossy(&out.stdout);
if let Some(code) = extract_codepage(&text) {
return match code.as_str() {
"65001" => "utf-8".to_string(),
"936" => "gbk".to_string(),
"950" => "big5".to_string(),
other => format!("cp{}", other),
};
}
}
return "utf-8".to_string();
}
#[cfg(not(windows))]
{
// Unix 默认 UTF-8(LANG/LC_ALL 通常含 UTF-8)。
if let Ok(lang) = std::env::var("LANG") {
if lang.to_ascii_uppercase().contains("UTF-8") {
return "utf-8".to_string();
}
}
"utf-8".to_string()
}
}
#[cfg(windows)]
fn extract_codepage(text: &str) -> Option<String> {
// 提取末尾数字("...936" / "...: 65001")。
let mut num = String::new();
for c in text.chars().rev() {
if c.is_ascii_digit() {
num.insert(0, c);
} else if !num.is_empty() {
break;
}
}
if num.is_empty() {
None
} else {
Some(num)
}
}
/// 执行 `tool --version`,解析首行返回版本串。失败(非零退出/无首行)返回 None。
///
/// 注:单条命令本身**无独立超时**(EX-4)——若 `tool --version` 挂起(如 Windows Store Alias),
/// 本函数会一直阻塞,由外层 [`EnvSnapshot::detect`] 的 5s spawn_blocking 超时兜底整批(届时
/// 全部工具版本一起回退 fallback),而非逐命令超时。「不阻塞调用方」靠的是外层超时,非本函数。
///
/// 例:python --version 输出 "Python 3.11.5" → 返回 "3.11.5";git --version 输出
/// "git version 2.41.0" → 返回 "2.41.0"。统一抽掉工具名前缀,只保留版本号本身。
fn probe_version(tool: &str, args: &[&str]) -> Option<String> {
let mut cmd = std::process::Command::new(tool);
cmd.args(args);
cmd.stdout(Stdio::piped()).stderr(Stdio::null());
#[cfg(windows)]
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
let out = cmd.output().ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
let first_line = text.lines().next()?;
Some(extract_version_token(first_line))
}
/// 从版本命令首行抽取版本号:取首个形如 N(.N)+ 的 token(至少一个点号)。
/// 兼容 "Python 3.11.5" / "v18.17.0" / "git version 2.41.0.windows.1" / "go version go1.21.0 ..."。
///
/// 策略:把行切成空白 token,逐个匹配「数字开头 + 至少一个 `.`」的模式,取首个命中。
/// 比 char 状态机更鲁棒(状态机遇 `2.41.0.windows.1` 这种多层嵌套点会误判)。
fn extract_version_token(line: &str) -> String {
for token in line.split_whitespace() {
// 找 token 内首个数字位置,从这里开始扫 "数字段(.数字段)*" 序列。
// 遇点要求下一字符为数字,否则在该点处截断(避免 "2.41.0.windows.1" 被吞成
// "2.41.0.windows.1",实际应止于 "2.41.0")。
let bytes = token.as_bytes();
let mut i = match bytes.iter().position(|b| b.is_ascii_digit()) {
Some(i) => i,
None => continue,
};
let mut head = String::new();
loop {
// 收数字段
let seg_start = i;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
head.push_str(&token[seg_start..i]);
// 点号:仅当下一字符为数字时才续,否则收尾
if i < bytes.len() && bytes[i] == b'.' && i + 1 < bytes.len()
&& bytes[i + 1].is_ascii_digit()
{
head.push('.');
i += 1;
continue;
}
break;
}
if head.contains('.') {
return head;
}
}
// 无版本号 token 时退回整行(避免返回空串让 prompt 出现 "None")。
line.trim().to_string()
}
// Windows 下统一在文件顶部引入 CommandExt,使各 #[cfg(windows)] 块可直接调用 creation_flags。
#[cfg(windows)]
use std::os::windows::process::CommandExt;
use std::process::Stdio;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_version_python_style() {
assert_eq!(extract_version_token("Python 3.11.5"), "3.11.5");
}
#[test]
fn extract_version_node_style() {
assert_eq!(extract_version_token("v18.17.0"), "18.17.0");
}
#[test]
fn extract_version_git_style() {
assert_eq!(extract_version_token("git version 2.41.0.windows.1"), "2.41.0");
}
#[test]
fn extract_version_go_style() {
assert_eq!(
extract_version_token("go version go1.21.0 windows/amd64"),
"1.21.0"
);
}
#[test]
fn extract_version_no_match_returns_line() {
assert_eq!(extract_version_token("no version here"), "no version here");
}
#[cfg(windows)]
#[test]
fn extract_codepage_parsing() {
assert_eq!(extract_codepage("活动代码页: 936"), Some("936".into()));
assert_eq!(
extract_codepage("Active code page: 65001"),
Some("65001".into())
);
assert_eq!(extract_codepage("no digits here"), None);
}
#[tokio::test]
async fn detect_returns_cached_static_ref() {
// 持 shell.rs 缓存串行闸:本测试可能首次创建 SNAPSHOT,其 shell 字段取自创建时刻的
// 缓存状态;若与写缓存的 shell.rs 测试并行,漂移测试会读到与 current_shell 不一致的快照。
let _gate = crate::shell::PWSH_CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
// 两次 detect 返回同一引用(OnceLock 全局缓存)。
let a = EnvSnapshot::detect().await as *const _;
let b = EnvSnapshot::detect().await as *const _;
assert_eq!(a, b, "detect() 应返回同一静态引用");
}
/// 单源不变量回归:prompt 期 shell(env_snapshot.shell)与执行期 shell(current_shell)必须一致。
///
/// 历史 bug:detect_shell 持独立 probe_command_success 探测,与 shell.rs::probe_pwsh 各填各的
/// OnceLock → prompt 告诉 LLM 用 pwsh,执行却走 powershell(或反之)。根本修:二者共用
/// shell.rs 的 probe_pwsh_blocking + PWSH_CACHE 单源。本测试锁定「无漂移」不变量。
///
/// 隔离:持 shell.rs 缓存串行闸,快照值与 current_shell() 须读自同一缓存状态,
/// 防与写缓存的 shell.rs 测试并行互踩(Mutex 化后写值会被复位,闸保证读窗口内无交叉写)。
#[cfg(windows)]
#[tokio::test]
async fn detect_shell_matches_shell_rs_current_shell() {
let _gate = crate::shell::PWSH_CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
// detect() 内 detect_shell → 读 PWSH_CACHE 映射,与下方 current_shell() 同源。
let snap = EnvSnapshot::detect().await;
let current = crate::shell::current_shell();
// 二者必须一致(同一 PWSH_CACHE 单源读出)。
match snap.shell.as_str() {
"pwsh" => assert_eq!(current, crate::shell::ShellType::Pwsh),
"powershell" => assert_eq!(current, crate::shell::ShellType::PowerShell),
"cmd" => assert_eq!(current, crate::shell::ShellType::Cmd),
"sh" => assert_eq!(current, crate::shell::ShellType::Sh),
other => panic!("未知 shell 字符串: {}", other),
}
}
#[test]
fn to_prompt_contains_os_and_shell() {
let snap = EnvSnapshot {
os: "test_os".into(),
os_version: "v1".into(),
shell: "test_shell".into(),
path_sep: "/".into(),
encoding: "utf-8".into(),
tools: ToolVersions {
python: Some("3.11".into()),
node: None,
rust: None,
go: None,
docker: None,
git: None,
},
};
let prompt = snap.to_prompt();
assert!(prompt.contains("test_os"));
assert!(prompt.contains("test_shell"));
assert!(prompt.contains("Python: 3.11"));
assert!(!prompt.contains("Node"));
assert!(prompt.contains("请生成本平台兼容的命令"));
}
}
+6 -1
View File
@@ -1,3 +1,8 @@
//! df-execute: 执行运行时 — Shell //! df-execute: 执行运行时 — Shell + 环境感知 + 提交冻结闸
pub mod commit_freeze;
pub mod env_snapshot;
pub mod shell; pub mod shell;
pub use commit_freeze::is_commit_command;
pub use env_snapshot::EnvSnapshot;
+453 -32
View File
@@ -3,6 +3,8 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::process::Stdio; use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
/// Shell 命令执行结果 /// Shell 命令执行结果
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShellResult { pub struct ShellResult {
@@ -33,36 +35,278 @@ pub enum ShellType {
impl Default for ShellType { impl Default for ShellType {
fn default() -> Self { fn default() -> Self {
// L1 环境感知:Windows 默认 PowerShell 系(非 Cmd)。PowerShell 对引号/$变量/Unicode 处理 // L1 环境感知:Windows 默认 PowerShell 系(非 Cmd)。PowerShell 对引号/$变量/Unicode 处理
// 远优于 cmd,从根上避 kms 类引号转义地狱(seq26-52 撞墙 20+ 次)。AI 写文件执行见 env_profile。 // 远优于 cmd,从根上避 kms 类引号转义地狱。AI 写文件执行见 env_profile。
// BUG-260623-04:优先 pwsh(PS7,支持 && 运算符)——LLM 训练数据 Unix 多,普遍生成 `cd x && y`, // 优先 pwsh(PS7,支持 && 运算符)——LLM 训练数据 Unix 多,普遍生成 `cd x && y`,
// PS5 不支持 && 致命令失败(实测会话 6acb7f9b `cd ... && git init` InvalidEndOfLine)。 // PS5 不支持 && 致命令失败(实测会话 6acb7f9b `cd ... && git init` InvalidEndOfLine)。
// 探测失败(未装 pwsh)回退 PS5。探测结果 OnceLock 缓存(只探一次)。 // 探测失败(未装 pwsh)回退 PS5。探测结果缓存于模块级 PWSH_CACHE(只探一次)。
//
// 【序约束 / 死缓存修复】Default 为同步签名,读模块级单源 PWSH_CACHE(由 probe_pwsh 写入)。
// 缓存有两个填充点(均走 probe_pwsh_blocking 单真相源,无漂移):
// 1) env_snapshot::detect_shell → EnvSnapshot::detect()(启动期 spawn_blocking 内,先于 execute);
// 2) execute()/execute_streaming() 异步入口 → probe_pwsh().await(spawn_blocking + 3s 超时)。
// 通常 detect_shell 先跑(EnvSnapshot::detect 在 run_agentic_loop 早期被 await),首次 execute 时
// probe_pwsh 直接命中缓存。若 detect 尚未填充,probe_pwsh 自带探测兜底。任一时刻 build_command →
// ShellType::default() 读到缓存值;未探测(缓存空)返回 PowerShell(PS5)。绝不在 Default 同步路径内 spawn 探测。
//
// 历史 bug(2026-08 修复):原 probe_pwsh_cached() 与 probe_pwsh() 各持一个独立 static OnceLock,
// Default 读的那个永不被填充 → Pwsh 全死代码,Windows 永走 PS5(&& 必失败)。根因:两个 OnceLock
// 非单源 + env_snapshot 另持 probe_command_success 独立探测。根本修:合并为模块级 PWSH_CACHE 单源,
// 写读同源;detect_shell 复用 probe_pwsh_blocking(单一探测实现)。
if cfg!(windows) { if cfg!(windows) {
if probe_pwsh() { ShellType::Pwsh } else { ShellType::PowerShell } if cache_get().unwrap_or(false) { ShellType::Pwsh } else { ShellType::PowerShell }
} else { } else {
ShellType::Sh ShellType::Sh
} }
} }
} }
/// 探测 pwsh(PowerShell 7)是否可用(OnceLock 缓存,只探一次)。 /// pwsh(PowerShell 7)可用性的全局单源缓存。
///
/// 由异步 `probe_pwsh()`(execute 路径)或同步 `probe_pwsh_blocking()`(detect_shell 路径)探测,
/// 同步路径 `ShellType::default()` / `current_shell()` 读取——写读同源,
/// 杜绝历史上「两个 OnceLock 各填各的、Default 读的永空」死缓存 bug。
///
/// 语义:`None` 表示尚未探测(首次启动 / Windows 未装 pwsh 也仅表示探测未跑或返 false);
/// `Some(true)` 表示已探测且 pwsh 可用。
///
/// 用 `Mutex<Option<bool>>` 而非 OnceLock:OnceLock 写入后进程级不可清,写缓存的测试
/// 与依赖缓存一致性的测试(env_snapshot 漂移测试)全量并行跑时互踩。Mutex 化后测试可
/// 备份/复位隔离(配 PWSH_CACHE_TEST_LOCK 串行闸);生产读写语义不变,有缓存读缓存、
/// 无缓存探测后写。
static PWSH_CACHE: std::sync::Mutex<Option<bool>> = std::sync::Mutex::new(None);
/// 读缓存当前值(毒化容错:持锁测试 panic 后其余测试仍可继续,行为不变)。
fn cache_get() -> Option<bool> {
*PWSH_CACHE.lock().unwrap_or_else(|e| e.into_inner())
}
/// 写缓存当前值(探测产物写入 / 测试复位共用,覆盖写)。
fn cache_set(v: Option<bool>) {
*PWSH_CACHE.lock().unwrap_or_else(|e| e.into_inner()) = v;
}
/// 测试串行闸:缓存为进程级单源,写缓存的测试与依赖缓存一致性的测试并行互踩,
/// 凡改写缓存或依赖缓存/EnvSnapshot 一致性的测试须持锁(仅测试隔离用,非生产路径)。
/// 对齐 path_auth.rs 的 BLACKLIST_BYPASS_TEST_LOCK 模式;env_snapshot 漂移测试持同闸。
#[cfg(test)]
pub(crate) static PWSH_CACHE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// 同步探测 pwsh(PowerShell 7)是否可用(std::Command::status),成功返回 true。
///
/// 这是阻塞 IO 的「单真相源」实现——`probe_pwsh()`(异步,spawn_blocking + 超时)与
/// env_snapshot::detect_shell(windows 同步路径)都调用本函数,二者探测逻辑永远一致,
/// 杜绝「提示告诉 LLM 用 pwsh,执行却走 powershell」的两套逻辑漂移。
///
/// 实现等价于原 `probe_pwsh()` 内的闭包:`pwsh -NoProfile -Command exit 0` 成功即 true;
/// Windows 加 CREATE_NO_WINDOW(0x0800_0000)防黑窗闪现。
///
/// 注:此处无超时——`probe_pwsh()` 在调用方包 `tokio::time::timeout`,
/// `detect_shell()` 则由外层 `EnvSnapshot::detect()` 的 spawn_blocking 5s 超时兜底。
/// 故本函数本身只负责「同步 spawn + status」,超时治理在调用点。
pub(crate) fn probe_pwsh_blocking() -> bool {
let mut cmd = std::process::Command::new("pwsh");
cmd.arg("-NoProfile").arg("-Command").arg("exit 0");
cmd.stdout(Stdio::null()).stderr(Stdio::null());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
cmd.status().map(|s| s.success()).unwrap_or(false)
}
/// 探测 pwsh(PowerShell 7)是否可用(模块级缓存,3s 超时,只探一次)。
/// ///
/// LLM 普遍生成 `&&`(Unix 习惯),仅 PS7+ 支持,Windows 自带 PS5 不支持。 /// LLM 普遍生成 `&&`(Unix 习惯),仅 PS7+ 支持,Windows 自带 PS5 不支持。
/// 探测:成功 spawn `pwsh -Command exit 0` 即可用。同步阻塞仅一次(spawn 极快), /// 探测:成功 spawn `pwsh -Command exit 0` 即可用。Windows 加 CREATE_NO_WINDOW 防黑窗闪现。
/// Windows 加 CREATE_NO_WINDOW 防黑窗闪现。 ///
fn probe_pwsh() -> bool { /// 异步化 + 超时治理:在异步上下文中通过 `tokio::task::spawn_blocking` 执行阻塞探测,
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new(); /// 外包 `tokio::time::timeout(3s)`。结果写入模块级单源 `PWSH_CACHE`,同步 `Default` 路径共享。
*CACHE.get_or_init(|| { ///
let mut cmd = std::process::Command::new("pwsh"); /// 超时/panic 时**不写入 PWSH_CACHE**:
cmd.arg("-NoProfile").arg("-Command").arg("exit 0"); /// - 超时根因往往是 Windows Store Alias / 杀软 hook 拦截 `pwsh` 命令(status() 永不返回);
cmd.stdout(Stdio::null()).stderr(Stdio::null()); /// 若错误地把 false 缓存,后续 execute() 会一直走 PS5(&& 必失败),把瞬时环境问题冻结成
#[cfg(windows)] /// 「永不可用」错误判定。不 set → 每次 execute 重试,环境恢复后自动回正。
{ /// - panic(join err)同理,可能是临时线程池异常,不应冻结判定。
use std::os::windows::process::CommandExt; /// - 仅「正常完成(Ok(Ok(_)))」时 set 缓存(此时结果是可信的探测产物)。
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW /// 对齐 env_snapshot.rs:64-80 的「spawn_blocking + timeout + 超时不 set」模式。
async fn probe_pwsh() -> bool {
if let Some(cached) = cache_get() {
return cached;
}
match tokio::time::timeout(
std::time::Duration::from_secs(3),
tokio::task::spawn_blocking(probe_pwsh_blocking),
).await {
Ok(Ok(result)) => {
// 正常完成:写入缓存(多任务竞态覆盖写,各写者探测值等价)
cache_set(Some(result));
result
} }
cmd.status().map(|s| s.success()).unwrap_or(false) Ok(Err(join_err)) => {
}) eprintln!(
"[shell] probe_pwsh spawn_blocking 异常(不缓存,下次 execute 重试): {}",
join_err
);
false
}
Err(_elapsed) => {
eprintln!(
"[shell] probe_pwsh 3s 超时(疑似 Windows Store Alias / 杀软 hook 拦截 pwsh, \
不缓存以免冻结错误判定,下次 execute 重试)"
);
false
}
}
}
/// 当前 shell 类型(同步读取模块级 PWSH_CACHE 单源)。
///
/// 探测在 `probe_pwsh()`(异步,带 3s 超时)或 `probe_pwsh_blocking()`(detect_shell 同步路径)中执行并填充 PWSH_CACHE;此处仅读。
/// 缓存空(未探测 / 探测超时未 set)时返回 `ShellType::default()`(Windows → PowerShell,Unix → Sh)。
/// 单源语义:env_snapshot::detect_shell 与 build_command 共用此判定,杜绝「探测与执行两套逻辑漂移」。
pub fn current_shell() -> ShellType {
ShellType::default()
}
/// 剥离 ANSI 转义序列(#8 出口兜底)。
///
/// 源头抑制(build_command 注入 NO_COLOR/TERM=dumb)覆盖主流工具链,但总有漏网
/// (不读环境变量的工具/嵌套脚本转发色码)。本纯函数兜底 CSI/OSC 两类常见序列:
/// - CSI:ESC [ 参数字母(颜色/光标/擦除,如 \x1b[7m \x1b[0K)
/// - OSC:ESC ] ... BEL 或 ESC \(终端标题/超链接,罕见但同样乱码)
/// 不碰普通文本与换行,幂等(已剥离文本再剥无变化)。
pub fn strip_ansi(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.char_indices().peekable();
while let Some((i, c)) = chars.next() {
if c != '\x1b' {
out.push(c);
continue;
}
// c == ESC:按下一字符分派序列类型
match text[i + 1..].chars().next() {
Some('[') => {
// CSI:跳到终止字母(@-~)为止
let rest = &text[i + 2..];
match rest.find(|ch: char| ('\u{40}'..='\u{7e}').contains(&ch)) {
Some(end) => {
// 跳过整个序列: advance 到 i+2+end 的下一字符
let skip = 2 + end + 1;
for _ in 1..skip {
chars.next();
}
}
None => {
// 流式截断的残缺序列(无终止符):丢弃剩余全部
for _ in (i + 1)..text.len() {
chars.next();
}
}
}
}
Some(']') => {
// OSC:到 BEL(\x07) 或 ST(ESC \)为止
let rest = &text[i + 2..];
let bel = rest.find('\x07');
let st = rest.find("\x1b\\");
match (bel, st) {
(Some(b), None) => {
// 已消费 ESC,再消费 i+1..=i+2+b(含 BEL)共 b+2 个
for _ in 0..(2 + b) {
chars.next();
}
}
(_, Some(s)) => {
// 再消费 i+1..=i+2+s+1(含 ST 两字符)共 s+3 个
for _ in 0..(2 + s + 1) {
chars.next();
}
}
(None, None) => {
for _ in (i + 1)..text.len() {
chars.next();
}
}
}
}
_ => {
// 两字符转义(ESC \ ESC ( 等):丢 ESC 保留后字符?保守双丢后单字符
chars.next();
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// #8 ANSI 剥离:CSI 色码/擦除/流式残缺序列/OSC/普通文本不动
#[test]
fn strip_ansi_basic() {
assert_eq!(strip_ansi("\x1b[7ma\x1b[0mb"), "ab");
assert_eq!(strip_ansi("\x1b[2K\x1b[1Gdone"), "done");
assert_eq!(strip_ansi("plain text\nline2"), "plain text\nline2");
// 流式残缺(无终止符):丢残序列
assert_eq!(strip_ansi("ok\x1b[3"), "ok");
// OSC BEL 终止
assert_eq!(strip_ansi("\x1b]0;title\x07body"), "body");
// 幂等
let once = strip_ansi("\x1b[32mgreen\x1b[0m");
assert_eq!(strip_ansi(&once), once);
}
/// 死缓存回归测试:probe_pwsh 写入后,Default 同步路径必须读到同一缓存值。
///
/// 历史 bug:probe_pwsh_cached() 与 probe_pwsh() 各持独立 OnceLock,Default 读的永空。
/// 本测试经 probe_pwsh 写模块级 PWSH_CACHE 后断言 default() 与探测值一致,锁定「写读同源」不变量。
///
/// 隔离:持串行闸,进时备份、断言前复位。OnceLock 时代写入后全进程不可清,
/// 会污染并行的 env_snapshot 漂移测试(其快照先以空缓存创建,断言即漂移)。
#[cfg(windows)]
#[tokio::test]
async fn probe_pwsh_cache_shared_with_default() {
let _gate = PWSH_CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let backup = cache_get();
// 探测一次填充缓存(无论机器是否装 pwsh,只要写读同源即应一致)。
// 注:正常完成路径才会写缓存;本机 pwsh 探测不超时/不 panic,故 cached 应与 probed 一致。
let probed = probe_pwsh().await;
let cached = cache_get();
// Default 必须读到与 probe 一致的判定:Pwsh ↔ true,PowerShell ↔ false
let default_shell = ShellType::default();
// 复位放在断言前:中途 panic 不残留污染态(对齐 path_auth.rs 先复位再断言)
cache_set(backup);
// 探测完成后缓存必已填充(同步路径由此读到)
assert_eq!(cached, Some(probed));
match probed {
true => assert_eq!(default_shell, ShellType::Pwsh),
false => assert_eq!(default_shell, ShellType::PowerShell),
}
}
/// 不挂死不变量回归:probe_pwsh 必须在有限时间内返回(自带 3s 超时 + spawn_blocking)。
///
/// 历史 bug:probe_pwsh 仅 spawn_blocking 无超时,Windows Store Alias / 杀软 hook 拦截 pwsh 时
/// status() 永不返回 → spawn_blocking 线程永不返回 → probe_pwsh().await 永久挂 →
/// execute()/execute_streaming() 卡死 → run_agentic_loop 死锁(与 env_snapshot::detect 同源 bug)。
/// 本测试外包 10s timeout(远大于 3s 内部超时),无论探测成败都应在 10s 内返回,锁定「不挂死」不变量。
///
/// 隔离:持串行闸,探测在缓存为空时会写入缓存,备份/复位防污染并行测试。
#[cfg(windows)]
#[tokio::test]
async fn probe_pwsh_completes_within_timeout() {
let _gate = PWSH_CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let backup = cache_get();
// 10s >> probe_pwsh 内部 3s 超时;若 10s 仍未返回 → 探测挂死,不变量被破坏。
let result = tokio::time::timeout(
std::time::Duration::from_secs(10),
probe_pwsh(),
).await;
cache_set(backup);
assert!(result.is_ok(), "probe_pwsh 必须在 10s 内返回(内部 3s 超时已兜底),不应挂死");
}
} }
/// Shell 命令执行请求 /// Shell 命令执行请求
@@ -76,18 +320,33 @@ 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>,
} }
/// 执行 Shell 命令 /// 输出流类型(回调 on_output 用)
/// #[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// 支持超时(timeout_secs)、环境变量(env)、工作目录(working_dir), pub enum StreamKind {
/// kill_on_drop(true) 保证超时后子进程不残留,shell_type 可选 Cmd/PowerShell/Sh。 Stdout,
pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> { Stderr,
let start = std::time::Instant::now(); }
impl StreamKind {
/// 序列化为稳定字符串标识(emit 事件 stream 字段用)
pub fn as_str(&self) -> &'static str {
match self {
StreamKind::Stdout => "stdout",
StreamKind::Stderr => "stderr",
}
}
}
/// 构造已配置好(stdio piped + kill_on_drop + CREATE_NO_WINDOW + cwd + env)的子进程 Command。
///
/// execute() 与 execute_streaming() 共用同一构造逻辑(单真相源,DRY):
/// shell 类型选择 / kill_on_drop / Windows 无窗 / cwd / env 全在此。差异仅在后续如何消费 stdout/stderr。
fn build_command(request: ShellRequest) -> tokio::process::Command {
let shell_type = request.shell_type.unwrap_or_default(); let shell_type = request.shell_type.unwrap_or_default();
let mut cmd = match shell_type { let mut cmd = match shell_type {
ShellType::PowerShell => { ShellType::PowerShell => {
@@ -117,13 +376,13 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
} }
}; };
// CR-15-1: kill_on_drop(true) 让 Command 被 drop 时主动 kill 子进程。 // kill_on_drop(true) 让 Command 被 drop 时主动 kill 子进程。
// 配合 tokio::time::timeout 超时场景:超时 drop future → Command 析构 → kill 子进程, // 配合 tokio::time::timeout 超时场景:超时 drop future → Command 析构 → kill 子进程,
// 不再让超时后的命令变孤儿继续后台跑(长 hang 命令/死循环仍占资源)。 // 不再让超时后的命令变孤儿继续后台跑(长 hang 命令/死循环仍占资源)。
// 对齐 tool_registry.rs:514「进程已终止」文案名副其实。tokio 1.52.3 支持。 // 对齐 tool_registry.rs:514「进程已终止」文案名副其实。tokio 1.52.3 支持。
cmd.kill_on_drop(true); cmd.kill_on_drop(true);
// B-260619-01: Windows 下创建子进程默认弹控制台窗口(cmd/powershell 黑窗闪现)。 // Windows 下创建子进程默认弹控制台窗口(cmd/powershell 黑窗闪现)。
// CREATE_NO_WINDOW(0x0800_0000) 标志抑制窗口创建,后台静默执行。 // CREATE_NO_WINDOW(0x0800_0000) 标志抑制窗口创建,后台静默执行。
// tokio::process::Command 在 Windows 自带 creation_flags 方法(无需 std CommandExt trait)。 // tokio::process::Command 在 Windows 自带 creation_flags 方法(无需 std CommandExt trait)。
#[cfg(windows)] #[cfg(windows)]
@@ -138,23 +397,185 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
for (key, value) in &request.env { for (key, value) in &request.env {
cmd.env(key, value); cmd.env(key, value);
} }
// #8 ANSI 裸露根治(源头抑制):非交互管道下多数 CLI 仍探测 TERM 发色码,cargo/ls 等
// 输出 \x1b[Nm 原样透传前端显示为 ␛[7m 乱码。注入 NO_COLOR(https://no-color.org 标准)+
// TERM=dumb+FORCE_COLOR=0 三重信号,主流工具链(cargo/npm/git/ls/pytest)管道下停发色码。
// 调用方显式传同名 env 时以调用方为准(后写覆盖,见上循环顺序)。
cmd.env("NO_COLOR", "1");
cmd.env("TERM", "dumb");
cmd.env("FORCE_COLOR", "0");
cmd
}
let output = match request.timeout_secs { /// 执行 Shell 命令(等 exit 一次性返回,非流式)
///
/// 支持超时(timeout_secs)、环境变量(env)、工作目录(working_dir),
/// kill_on_drop(true) 保证超时后子进程不残留,shell_type 可选 Cmd/PowerShell/Sh。
///
/// 需要执行中实时获取 stdout/stderr 行(如 run_command 进度展示)用 [`execute_streaming`]。
pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
let start = std::time::Instant::now();
// 提交冻结机械闸(39 治理):冻结在场且命令为 git commit → spawn 前拒绝,改动留工作区
crate::commit_freeze::check(&request).map_err(anyhow::Error::msg)?;
// 探测 pwsh(惰性 + 模块级 PWSH_CACHE,成功才写缓存),使后续 ShellType::default() 可读取缓存
#[cfg(windows)]
let _ = probe_pwsh().await;
// 先取走 build_command 之外的引用字段(超时错误信息 + timeout 判定),再 move request
let command_for_err = request.command.clone();
let timeout_secs = request.timeout_secs;
let mut cmd = build_command(request);
let output = match timeout_secs {
Some(secs) => tokio::time::timeout( Some(secs) => tokio::time::timeout(
std::time::Duration::from_secs(secs), std::time::Duration::from_secs(secs),
cmd.output(), cmd.output(),
) )
.await .await
.map_err(|_| anyhow::anyhow!("命令执行超时({}s): {}", secs, request.command))??, .map_err(|_| anyhow::anyhow!("命令执行超时({}s): {}", secs, command_for_err))??,
None => cmd.output().await?, None => cmd.output().await?,
}; };
let duration = start.elapsed().as_millis() as u64; let duration = start.elapsed().as_millis() as u64;
Ok(ShellResult { Ok(ShellResult {
stdout: String::from_utf8_lossy(&output.stdout).to_string(), stdout: strip_ansi(&String::from_utf8_lossy(&output.stdout)),
stderr: String::from_utf8_lossy(&output.stderr).to_string(), stderr: strip_ansi(&String::from_utf8_lossy(&output.stderr)),
exit_code: output.status.code(), exit_code: output.status.code(),
duration_ms: duration, duration_ms: duration,
}) })
} }
/// 流式执行 Shell 命令 —— spawn 后逐行读 stdout/stderr,每行回调 on_output。
///
/// 治 run_command 执行中黑盒:execute() 等 exit 才返回整块 stdout/stderr,长命令(cargo/npm 构建)
/// 期间前端只看 Started→Completed,中间进度不可见。本函数 spawn 子进程后并发逐行读两条流,
/// 每读到一行回调 `on_output(kind, line)`(调用方可 emit 事件给前端实时展示),仍等进程 exit
/// 后返回完整 ShellResult。
///
/// 与 [`execute`] 的输出差异(EX-2,调用方须知):本函数 stdout/stderr 按行重组——经
/// `BufReader::lines()` 逐行拼接,行尾统一为 `\n`(剥 \r\n)且补末尾换行;execute 则保留原始字节
/// (lossy 解码,含 \r\n,无补换行)。二者 ShellResult 字段同形,仅换行形态不同;需字节级一致时用 execute。
///
/// 4性:
/// - 合理机制:spawn + BufReader::lines() 逐行,不丢未换行结尾的末段(read_to_end 兜底)
/// - 简洁:与 execute() 共用 build_command(单真相源,shell/kill_on_drop/cwd/env 不重复)
/// - 可靠兜底:timeout_secs 仍生效(超时 drop future → kill_on_drop 杀进程,返回 Err);
/// on_output 回调 Err 不影响主流程(调用方 emit 失败静默吞)
/// - 健壮边界:stdout/stderr 各独立任务并发读,互不阻塞;无管道死锁(piped + 同时消费)
pub async fn execute_streaming<F>(request: ShellRequest, mut on_output: F) -> anyhow::Result<ShellResult>
where
F: FnMut(StreamKind, &str) + Send,
{
let start = std::time::Instant::now();
// 提交冻结机械闸(39 治理):同 execute,流式路径同样 spawn 前拦截
crate::commit_freeze::check(&request).map_err(anyhow::Error::msg)?;
#[cfg(windows)]
let _ = probe_pwsh().await;
// 先取走引用字段,再 move request 进 build_command
let command_for_err = request.command.clone();
let timeout_secs = request.timeout_secs;
let mut cmd = build_command(request);
let inner = async {
let mut child = cmd.spawn()?;
// 取出 piped 的 stdout/stderr handle(None → 视为已关,读为空,不影响主流程)
let stdout = child.stdout.take();
let stderr = child.stderr.take();
// mpsc 通道:读 task 把 (kind, line) 推过来,主 task 在 wait 期间 drain 并调 on_output。
// 用通道而非直接共享 on_output:FnMut 不可 clone,两读 task 无法各持一份;通道解耦读写,
// 回调集中在主 task 单点调用(顺序确定、无锁、回调内阻塞不影响读循环)。
let (tx, mut rx) = tokio::sync::mpsc::channel::<(StreamKind, String)>(64);
let mut tasks: Vec<tokio::task::JoinHandle<()>> = Vec::with_capacity(2);
if let Some(out) = stdout {
let tx = tx.clone();
tasks.push(tokio::spawn(async move {
let mut reader = BufReader::new(out).lines();
while let Ok(Some(line)) = reader.next_line().await {
if tx.send((StreamKind::Stdout, line)).await.is_err() {
break; // 接收端 drop(主 task 结束)→ 停止读
}
}
}));
}
if let Some(err) = stderr {
let tx = tx.clone();
tasks.push(tokio::spawn(async move {
let mut reader = BufReader::new(err).lines();
while let Ok(Some(line)) = reader.next_line().await {
if tx.send((StreamKind::Stderr, line)).await.is_err() {
break;
}
}
}));
}
// 主 task 不再 send → drop tx(读 task send 失败即退出)
drop(tx);
// 完整输出累积(主 task 单点写,无锁)。
let mut stdout_buf = String::new();
let mut stderr_buf = String::new();
// wait + drain 并行:边等进程退出边消费输出行(防管道写满阻塞致子进程 hang)。
let wait_fut = child.wait();
tokio::pin!(wait_fut);
let status: std::process::ExitStatus = loop {
tokio::select! {
// 进程退出 → 跳出循环,继续 drain 通道内残余行
status = &mut wait_fut => {
let status = status?;
// drain 剩余行(读 task 在管道 EOF 后 send 完最后批次即退出,rx 返 None 闭合)
while let Some((kind, line)) = rx.recv().await {
let clean = strip_ansi(&line);
match kind {
StreamKind::Stdout => { stdout_buf.push_str(&clean); stdout_buf.push('\n'); }
StreamKind::Stderr => { stderr_buf.push_str(&clean); stderr_buf.push('\n'); }
}
on_output(kind, &clean);
}
break status;
}
// 收到一行 → 累积 + 回调
Some((kind, line)) = rx.recv() => {
let clean = strip_ansi(&line);
match kind {
StreamKind::Stdout => { stdout_buf.push_str(&clean); stdout_buf.push('\n'); }
StreamKind::Stderr => { stderr_buf.push_str(&clean); stderr_buf.push('\n'); }
}
on_output(kind, &clean);
}
}
};
// 防御性 join 读 task(此时必已 EOF 退出,仅保险;失败静默不阻断)
for t in tasks {
let _ = t.await;
}
Ok::<ShellResult, anyhow::Error>(ShellResult {
stdout: stdout_buf,
stderr: stderr_buf,
exit_code: status.code(),
duration_ms: 0, // 外层统一填
})
};
let result = match timeout_secs {
Some(secs) => tokio::time::timeout(std::time::Duration::from_secs(secs), inner)
.await
.map_err(|_| anyhow::anyhow!("命令执行超时({}s): {}", secs, command_for_err))??,
None => inner.await?,
};
let duration = start.elapsed().as_millis() as u64;
Ok(ShellResult {
stdout: result.stdout,
stderr: result.stderr,
exit_code: result.exit_code,
duration_ms: duration,
})
}
+79 -1
View File
@@ -9,7 +9,7 @@
//! //!
//! 注:execute 逻辑本身未改动,此文件为零行为变更的纯新增测试。 //! 注:execute 逻辑本身未改动,此文件为零行为变更的纯新增测试。
use df_execute::shell::{execute, ShellRequest, ShellType}; use df_execute::shell::{execute, execute_streaming, ShellRequest, ShellType, StreamKind};
use std::collections::HashMap; use std::collections::HashMap;
/// 平台默认 ShellType(对齐 shell.rs:31 Default impl:Windows→Cmd, 非 Windows→Sh) /// 平台默认 ShellType(对齐 shell.rs:31 Default impl:Windows→Cmd, 非 Windows→Sh)
@@ -159,3 +159,81 @@ async fn execute_working_dir() {
// 清理 // 清理
let _ = std::fs::remove_dir_all(&tmp_for_cleanup); let _ = std::fs::remove_dir_all(&tmp_for_cleanup);
} }
// ============================================================
// execute_streaming 流式测试
// ============================================================
/// 流式:stdout 多行逐行回调,且 ShellResult 完整(行数对齐 + exit_code=0)。
///
/// 治 run_command 黑盒:验证 spawn 后逐行回调 vs 一次性返回的等价性(行内容 + 完整结果)。
#[tokio::test]
async fn streaming_stdout_lines_callback() {
// 多行输出:Cmd 用多个 echo(用 & 串联无依赖),Sh 用 printf 多行
let cmd = if cfg!(windows) {
"@echo line1 & @echo line2 & @echo line3"
} else {
"printf 'line1\\nline2\\nline3\\n'"
};
let mut lines: Vec<(StreamKind, String)> = Vec::new();
let res = execute_streaming(req(cmd), |kind, line| {
lines.push((kind, line.to_string()));
})
.await
.expect("execute_streaming 应返回 Ok");
assert_eq!(res.exit_code, Some(0), "成功命令 exit_code 应为 0");
// stdout 应含三行(line1/line2/line3)
assert!(res.stdout.contains("line1"), "stdout 应含 line1,实际: {:?}", res.stdout);
assert!(res.stdout.contains("line3"), "stdout 应含 line3,实际: {:?}", res.stdout);
// 回调收到的 stdout 行应含三行(过滤 stderr 干扰:Cmd 无 stderr,Sh 无 stderr)
let stdout_lines: Vec<&String> = lines.iter()
.filter(|(k, _)| *k == StreamKind::Stdout)
.map(|(_, l)| l)
.collect();
assert!(
stdout_lines.iter().any(|l| l.contains("line1")),
"回调应收到含 line1 的 stdout 行,实际: {:?}", stdout_lines
);
assert!(
stdout_lines.iter().any(|l| l.contains("line3")),
"回调应收到含 line3 的 stdout 行,实际: {:?}", stdout_lines
);
}
/// 流式:超时仍生效(timeout_secs=1 + 长睡命令,返回 Err)。
#[tokio::test]
async fn streaming_timeout_returns_err() {
let sleep_cmd = if cfg!(windows) {
"ping -n 5 127.0.0.1 > nul".to_string()
} else {
"sleep 5".to_string()
};
let request = ShellRequest {
command: sleep_cmd,
working_dir: None,
env: HashMap::new(),
timeout_secs: Some(1),
shell_type: Some(default_shell()),
};
let result = execute_streaming(request, |_, _| {}).await;
assert!(result.is_err(), "超时应返回 Err,实际: {:?}", result.as_ref().err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("超时") || msg.to_lowercase().contains("timeout"),
"错误信息应含超时提示,实际: {}",
msg
);
}
/// 流式:非零退出仍返回 Ok + exit_code 非 0(对齐 execute 语义)。
#[tokio::test]
async fn streaming_nonzero_exit() {
let mut callbacks = 0u32;
let res = execute_streaming(req("exit 1"), |_, _| { callbacks += 1; })
.await
.expect("非零退出应仍返回 Ok");
assert_ne!(res.exit_code, Some(0), "exit 1 的 exit_code 应非 0");
// exit 1 无输出,回调可为 0 次(无行)——不强制断言次数,只确认无 panic
let _ = callbacks;
}
+93 -30
View File
@@ -7,13 +7,13 @@
//! 评估来源由 [`EvaluatedBy`] 三态标记:`Llm`(LLM 深度评估)/ `Heuristic`(主动选启发式, //! 评估来源由 [`EvaluatedBy`] 三态标记:`Llm`(LLM 深度评估)/ `Heuristic`(主动选启发式,
//! 无 provider)/ `HeuristicFallback`(LLM 调用失败降级)。前端可据此显示评估深度标签。 //! 无 provider)/ `HeuristicFallback`(LLM 调用失败降级)。前端可据此显示评估深度标签。
//! //!
//! LLM prompt 构造与 JSON 解析在 F-260614-03 接入:[`AdversarialEngine::evaluate_with_llm`] //! LLM prompt 构造与 JSON 解析在后续接入:[`AdversarialEngine::evaluate_with_llm`]
//! 构造三角色辩论 prompt(正方/反方/分析师),调一次 `complete()` 要求返回对齐结构的 JSON, //! 构造三角色辩论 prompt(正方/反方/分析师),调一次 `complete()` 要求返回对齐结构的 JSON,
//! 解析失败/字段缺失/枚举非法 → `bail` 触发降级([`AdversarialEngine::evaluate`] 已兜底)。 //! 解析失败/字段缺失/枚举非法 → `bail` 触发降级([`AdversarialEngine::evaluate`] 已兜底)。
//! //!
//! 重构(strategy·自底向上):类型定义 / 常量 / prompt 构造 / JSON 解析等无副作用逻辑抽至 //! 重构(strategy·自底向上):类型定义 / 常量 / prompt 构造 / JSON 解析等无副作用逻辑抽至
//! [`adversarial_helpers`],本文件只保留 [`AdversarialEngine`](有状态引擎 + evaluate 主入口, //! [`adversarial_helpers`],本文件只保留 [`AdversarialEngine`](有状态引擎 + evaluate 主入口,
//! 含 ARC-260618-01-e evaluate_with_llm 一致性待决策逻辑,原样保留)。外部已用路径( //! 含 ARC-260618-01-e 自洽性校验,已实施——解析后按 final_score 修正矛盾字段)。外部已用路径(
//! `df_ideas::adversarial::{AdversarialEval, Recommendation, …}`)经 `pub use` 不变。 //! `df_ideas::adversarial::{AdversarialEval, Recommendation, …}`)经 `pub use` 不变。
use std::sync::Arc; use std::sync::Arc;
@@ -33,8 +33,8 @@ pub use crate::adversarial_helpers::{
AdversarialEval, AnalystAnalysis, Argument, AssessmentLevel, EvaluatedBy, Recommendation, AdversarialEval, AnalystAnalysis, Argument, AssessmentLevel, EvaluatedBy, Recommendation,
}; };
use crate::adversarial_helpers::{ use crate::adversarial_helpers::{
SYSTEM_PROMPT, action_hint, assessment_desc, build_adversarial_prompt, parse_llm_eval, SYSTEM_PROMPT, action_hint, assessment_desc, assessment_for_score, build_adversarial_prompt,
priority_label, enforce_score_consistency, parse_llm_eval, priority_label, recommendation_for_level,
}; };
/// 对抗评估引擎 /// 对抗评估引擎
@@ -42,7 +42,7 @@ pub struct AdversarialEngine {
/// 可选 LLM provider。Some → 优先 LLM 评估(失败降级启发式);None → 纯启发式。 /// 可选 LLM provider。Some → 优先 LLM 评估(失败降级启发式);None → 纯启发式。
/// 构造注入(与 IdeaPromoter::new(policy) 同一模式),批量评估复用同一 provider。 /// 构造注入(与 IdeaPromoter::new(policy) 同一模式),批量评估复用同一 provider。
provider: Option<Arc<dyn LlmProvider>>, provider: Option<Arc<dyn LlmProvider>>,
/// F-01 阶段5: 候选模型池。非空时 evaluate_with_llm 经 select_model_id 路由选模型; /// 候选模型池。非空时 evaluate_with_llm 经 select_model_id 路由选模型;
/// 空(None provider 或未注入池)→ model 留空由 provider impl 回填自身 default_model /// 空(None provider 或未注入池)→ model 留空由 provider impl 回填自身 default_model
/// (与接入前行为一致,平稳过渡)。 /// (与接入前行为一致,平稳过渡)。
model_pool: Vec<ModelConfig>, model_pool: Vec<ModelConfig>,
@@ -54,7 +54,7 @@ impl AdversarialEngine {
Self { provider: Some(provider), model_pool: Vec::new() } Self { provider: Some(provider), model_pool: Vec::new() }
} }
/// F-01 阶段5: 注入 provider + 候选模型池构造。池非空时 evaluate_with_llm 走路由。 /// 注入 provider + 候选模型池构造。池非空时 evaluate_with_llm 走路由。
pub fn with_pool(provider: Arc<dyn LlmProvider>, model_pool: Vec<ModelConfig>) -> Self { pub fn with_pool(provider: Arc<dyn LlmProvider>, model_pool: Vec<ModelConfig>) -> Self {
Self { provider: Some(provider), model_pool } Self { provider: Some(provider), model_pool }
} }
@@ -97,17 +97,19 @@ impl AdversarialEngine {
/// temperature 取 0.4:低于 0.3 偏机械重复启发式信号,高于 0.5 易发散到无关风险, /// temperature 取 0.4:低于 0.3 偏机械重复启发式信号,高于 0.5 易发散到无关风险,
/// 0.4 在「稳定可复现」与「论点多样性」间取得平衡。 /// 0.4 在「稳定可复现」与「论点多样性」间取得平衡。
/// ///
/// ARC-260618-01-e: evaluate_with_llm 返回值一致性未校验(final_score 与 /// ARC-260618-01-e(已实施): evaluate_with_llm 解析后经 [`enforce_score_consistency`]
/// analyst.final_assessment 自洽性等),待产品决策,当前逻辑原样保留不调整。 /// 做自洽性校验——final_score 与 recommendation / analyst.final_assessment 不一致时,
/// 按 final_score 统一重新判定两字段(LLM 论点等自由文本保留,不改 LLM 调用/输出结构)。
async fn evaluate_with_llm(&self, idea: &Idea, provider: &Arc<dyn LlmProvider>) -> Result<AdversarialEval> { async fn evaluate_with_llm(&self, idea: &Idea, provider: &Arc<dyn LlmProvider>) -> Result<AdversarialEval> {
let prompt = build_adversarial_prompt(idea); let prompt = build_adversarial_prompt(idea);
// F-01 阶段5: 智能路由 — 对抗评估 TaskRequirements(Standard,无工具)。 // 智能路由 — 对抗评估 TaskRequirements(Standard,无工具)。
// 池非空 → select_model_id 选最优 model_id;池空/无匹配 → 留空由 provider impl // 池非空 → select_model_id 选最优 model_id;池空/无匹配 → 留空由 provider impl
// 回填自身 default_model(与接入前行为一致,平稳过渡)。 // 回填自身 default_model(与接入前行为一致,平稳过渡)。
let eval_req = df_ai::router::TaskRequirements { let eval_req = df_ai::router::TaskRequirements {
modalities: vec![df_ai_core::model::Modality::Text], modalities: vec![df_ai_core::model::Modality::Text],
needs_tool_use: false, needs_tool_use: false,
estimated_context: 0, estimated_context: 0,
tier: None,
}; };
let model = df_ai::router::select_model_id(&eval_req, &self.model_pool).unwrap_or_default(); let model = df_ai::router::select_model_id(&eval_req, &self.model_pool).unwrap_or_default();
let request = df_ai_core::provider::CompletionRequest { let request = df_ai_core::provider::CompletionRequest {
@@ -133,7 +135,9 @@ impl AdversarialEngine {
// 数值 clamp 由 parse_llm_eval 单点收口(final_score∈[0,10]、confidence∈[0,1], // 数值 clamp 由 parse_llm_eval 单点收口(final_score∈[0,10]、confidence∈[0,1],
// 均在 parse 内对所有 Ok 路径完成),此处不再重复 clamp(CR-40-1 去冗余)。 // 均在 parse 内对所有 Ok 路径完成),此处不再重复 clamp(CR-40-1 去冗余)。
parse_llm_eval(&resp.text, &idea.id) // ARC-260618-01-e: 解析后做自洽性校验(final_score 与 recommendation /
// analyst.final_assessment 矛盾时按 final_score 修正,见 enforce_score_consistency)。
Ok(enforce_score_consistency(parse_llm_eval(&resp.text, &idea.id)?))
} }
/// 启发式评估(基于评分与内容信号,稳定有区分度) /// 启发式评估(基于评分与内容信号,稳定有区分度)
@@ -144,7 +148,7 @@ impl AdversarialEngine {
let positive = self.generate_positive_argument(idea, &scores)?; let positive = self.generate_positive_argument(idea, &scores)?;
let negative = self.generate_negative_argument(idea, &scores)?; let negative = self.generate_negative_argument(idea, &scores)?;
let analyst = self.analyst_analysis(idea, &scores)?; let analyst = self.analyst_analysis(idea, &scores)?;
let recommendation = self.recommendation_for(&analyst.final_assessment); let recommendation = recommendation_for_level(&analyst.final_assessment);
Ok(AdversarialEval { Ok(AdversarialEval {
idea_id: idea.id.clone(), idea_id: idea.id.clone(),
@@ -239,13 +243,8 @@ impl AdversarialEngine {
/// AI 分析师综合分析 — 评估等级由综合评分决定,优势/劣势按维度动态生成 /// AI 分析师综合分析 — 评估等级由综合评分决定,优势/劣势按维度动态生成
fn analyst_analysis(&self, idea: &Idea, scores: &IdeaScores) -> Result<AnalystAnalysis> { fn analyst_analysis(&self, idea: &Idea, scores: &IdeaScores) -> Result<AnalystAnalysis> {
let final_assessment = match scores.overall { // 分档与 LLM 自洽性校验共用 assessment_for_score,单源防两处阈值漂移
x if x >= 7.5 => AssessmentLevel::StrongGo, let final_assessment = assessment_for_score(scores.overall);
x if x >= 6.0 => AssessmentLevel::Recommended,
x if x >= 4.5 => AssessmentLevel::Conditional,
x if x >= 3.0 => AssessmentLevel::Revised,
_ => AssessmentLevel::Defer,
};
let mut strengths = Vec::new(); let mut strengths = Vec::new();
if scores.impact >= 6.0 { if scores.impact >= 6.0 {
@@ -304,22 +303,12 @@ impl AdversarialEngine {
final_assessment, final_assessment,
}) })
} }
/// 评估等级 → 最终建议
fn recommendation_for(&self, level: &AssessmentLevel) -> Recommendation {
match level {
AssessmentLevel::StrongGo => Recommendation::ImmediateAction,
AssessmentLevel::Recommended => Recommendation::Soon,
AssessmentLevel::Conditional => Recommendation::WithResources,
AssessmentLevel::Revised => Recommendation::ResearchMore,
AssessmentLevel::Defer => Recommendation::Monitor,
}
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::adversarial_helpers::recommendation_for_score;
use crate::capture::Idea; use crate::capture::Idea;
use crate::scoring::ScoringEngine; use crate::scoring::ScoringEngine;
use df_types::types::{IdeaStatus, Priority}; use df_types::types::{IdeaStatus, Priority};
@@ -421,7 +410,7 @@ mod tests {
} }
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────
// LLM 路径测试(F-260614-03) // LLM 路径测试
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────
/// mock LlmProvider:按构造时给定的响应文本回放,仅供 adversarial 单测。 /// mock LlmProvider:按构造时给定的响应文本回放,仅供 adversarial 单测。
@@ -593,4 +582,78 @@ mod tests {
assert!((eval.final_score - 7.0).abs() < 1e-9); assert!((eval.final_score - 7.0).abs() < 1e-9);
assert_eq!(eval.recommendation, Recommendation::Soon); assert_eq!(eval.recommendation, Recommendation::Soon);
} }
/// 自洽性校验(ARC-260618-01-e 已实施): LLM 高分配 Monitor/Defer → 按 final_score 修正
/// 为 ImmediateAction/StrongGo,仅字段修正不整体降级启发式。
#[tokio::test]
async fn a13_llm_inconsistent_high_score_corrected() {
let idea = make_idea("矛盾测试", "高分却给 Monitor", Priority::High, vec![]);
let llm_text = r#"{
"positive": {"thesis":"p","evidence":[],"reasoning":[],"confidence":0.8},
"negative": {"thesis":"n","evidence":[],"reasoning":[],"confidence":0.3},
"analyst": {"summary":"s","strengths":[],"weaknesses":[],"risks":[],"opportunities":[],"final_assessment":"Defer"},
"final_score": 8.5,
"recommendation": "Monitor"
}"#;
let provider = Arc::new(MockProvider { text: llm_text.to_string() });
let engine = AdversarialEngine::new(provider);
let eval = engine.evaluate(&idea).await.unwrap();
println!("\n[a13] 高分配 Monitor/Defer → 修正");
println!(" final_score={:.2} (高分)", eval.final_score);
println!(" recommendation={:?} (期望 ImmediateAction)", eval.recommendation);
println!(" final_assessment={:?} (期望 StrongGo)", eval.analyst.final_assessment);
assert_eq!(eval.evaluated_by, EvaluatedBy::Llm, "仅字段矛盾不整体降级");
assert!((eval.final_score - 8.5).abs() < 1e-9, "final_score 不被修正");
assert_eq!(eval.recommendation, Recommendation::ImmediateAction);
assert_eq!(eval.analyst.final_assessment, AssessmentLevel::StrongGo);
}
/// 自洽性校验: LLM 低分配 ImmediateAction/StrongGo → 按 final_score 修正为 Monitor/Defer。
#[tokio::test]
async fn a14_llm_inconsistent_low_score_corrected() {
let idea = make_idea("低分矛盾", "低分却给 ImmediateAction", Priority::Medium, vec![]);
let llm_text = r#"{
"positive": {"thesis":"p","evidence":[],"reasoning":[],"confidence":0.9},
"negative": {"thesis":"n","evidence":[],"reasoning":[],"confidence":0.1},
"analyst": {"summary":"s","strengths":[],"weaknesses":[],"risks":[],"opportunities":[],"final_assessment":"StrongGo"},
"final_score": 2.0,
"recommendation": "ImmediateAction"
}"#;
let provider = Arc::new(MockProvider { text: llm_text.to_string() });
let engine = AdversarialEngine::new(provider);
let eval = engine.evaluate(&idea).await.unwrap();
println!("\n[a14] 低分配 ImmediateAction/StrongGo → 修正");
println!(" final_score={:.2} (低分)", eval.final_score);
println!(" recommendation={:?} (期望 Monitor)", eval.recommendation);
println!(" final_assessment={:?} (期望 Defer)", eval.analyst.final_assessment);
assert_eq!(eval.evaluated_by, EvaluatedBy::Llm);
assert!((eval.final_score - 2.0).abs() < 1e-9, "final_score 不被修正");
assert_eq!(eval.recommendation, Recommendation::Monitor);
assert_eq!(eval.analyst.final_assessment, AssessmentLevel::Defer);
}
/// 自洽性校验分档单测: 与启发式 analyst_analysis 同一分档(阈值边界逐一覆盖)。
#[test]
fn consistency_banding_matches_heuristic() {
assert_eq!(assessment_for_score(9.0), AssessmentLevel::StrongGo);
assert_eq!(assessment_for_score(7.5), AssessmentLevel::StrongGo); // 上界含
assert_eq!(assessment_for_score(7.0), AssessmentLevel::Recommended);
assert_eq!(assessment_for_score(6.0), AssessmentLevel::Recommended);
assert_eq!(assessment_for_score(5.0), AssessmentLevel::Conditional);
assert_eq!(assessment_for_score(4.5), AssessmentLevel::Conditional);
assert_eq!(assessment_for_score(4.0), AssessmentLevel::Revised);
assert_eq!(assessment_for_score(3.0), AssessmentLevel::Revised);
assert_eq!(assessment_for_score(2.9), AssessmentLevel::Defer);
assert_eq!(assessment_for_score(0.0), AssessmentLevel::Defer);
assert_eq!(recommendation_for_score(9.0), Recommendation::ImmediateAction);
assert_eq!(recommendation_for_score(6.5), Recommendation::Soon);
assert_eq!(recommendation_for_score(4.8), Recommendation::WithResources);
assert_eq!(recommendation_for_score(3.5), Recommendation::ResearchMore);
assert_eq!(recommendation_for_score(1.0), Recommendation::Monitor);
}
} }
+69 -5
View File
@@ -1,8 +1,9 @@
//! 对抗评估 — 纯函数 / 类型 / 常量(自 adversarial.rs 抽离,纯搬迁,零行为变更)。 //! 对抗评估 — 纯函数 / 类型 / 常量(自 adversarial.rs 抽离,纯搬迁,零行为变更)。
//! //!
//! 本文件承载 [`AdversarialEval`] 及其字段类型、LLM prompt 构造与 JSON 解析等无副作用 //! 本文件承载 [`AdversarialEval`] 及其字段类型、LLM prompt 构造、JSON 解析与自洽性校验
//! 逻辑;有状态的引擎 [`AdversarialEngine`](含 evaluate / evaluate_with_llm 主入口与 //! 等无副作用逻辑;有状态的引擎 [`AdversarialEngine`](含 evaluate / evaluate_with_llm
//! ARC-260618-01-e 待决策逻辑)仍留在 `adversarial.rs`,二者经 [`use`] 互相引用。 //! 主入口,经 [`enforce_score_consistency`] 落地 ARC-260618-01-e 自洽性校验)仍留在
//! `adversarial.rs`,二者经 [`use`] 互相引用。
//! //!
//! 抽离边界:类型定义(非 impl)+ 常量 + 顶层 free function。impl 主体不动,外部 //! 抽离边界:类型定义(非 impl)+ 常量 + 顶层 free function。impl 主体不动,外部
//! 已用路径(`df_ideas::adversarial::{AdversarialEngine, AdversarialEval, Recommendation}` 等) //! 已用路径(`df_ideas::adversarial::{AdversarialEngine, AdversarialEval, Recommendation}` 等)
@@ -84,7 +85,7 @@ pub enum Recommendation {
} }
// ============================================================ // ============================================================
// LLM 对抗评估 — prompt 构造 / JSON 解析(F-260614-03) // LLM 对抗评估 — prompt 构造 / JSON 解析
// ============================================================ // ============================================================
/// LLM 角色 / 输出契约的系统级约束。 /// LLM 角色 / 输出契约的系统级约束。
@@ -295,7 +296,7 @@ pub(crate) fn extract_json(text: &str) -> String {
// (原实现在此处 return body.trim() 会把语言标记/尾随文字一起喂 serde, // (原实现在此处 return body.trim() 会把语言标记/尾随文字一起喂 serde,
// 必然失败降级启发式,即便正则本可救回。) // 必然失败降级启发式,即便正则本可救回。)
} }
// 兜底:围栏不在开头或混杂前后文字 → 正则提取首个 JSON 对象(CR-40-2)。 // 兜底:围栏不在开头或混杂前后文字 → 正则提取首个 JSON 对象。
// (?s) 让 . 匹配换行,贪婪 {*} 取首 { 到末 },覆盖嵌套对象。 // (?s) 让 . 匹配换行,贪婪 {*} 取首 { 到末 },覆盖嵌套对象。
// 提取失败(无 { })则返回 trimmed 走原文 serde 报错降级,语义不变。 // 提取失败(无 { })则返回 trimmed 走原文 serde 报错降级,语义不变。
static JSON_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new(); static JSON_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
@@ -336,6 +337,69 @@ pub(crate) fn parse_recommendation(s: &str) -> Result<Recommendation> {
} }
} }
// ============================================================
// 自洽性校验(ARC-260618-01-e 已实施)— 综合评分分档 / 修正矛盾字段
// ============================================================
/// 评估等级 → 最终建议(与启发式 recommendation 映射同一套,收敛于此单点实现)。
pub(crate) fn recommendation_for_level(level: &AssessmentLevel) -> Recommendation {
match level {
AssessmentLevel::StrongGo => Recommendation::ImmediateAction,
AssessmentLevel::Recommended => Recommendation::Soon,
AssessmentLevel::Conditional => Recommendation::WithResources,
AssessmentLevel::Revised => Recommendation::ResearchMore,
AssessmentLevel::Defer => Recommendation::Monitor,
}
}
/// 综合评分 → 评估等级(分档与启发式 analyst_analysis 一致;自洽性校验复用,单源防两处漂移)。
pub(crate) fn assessment_for_score(score: f64) -> AssessmentLevel {
if score >= 7.5 {
AssessmentLevel::StrongGo
} else if score >= 6.0 {
AssessmentLevel::Recommended
} else if score >= 4.5 {
AssessmentLevel::Conditional
} else if score >= 3.0 {
AssessmentLevel::Revised
} else {
AssessmentLevel::Defer
}
}
/// 综合评分 → 最终建议(先分档再映射)。
pub(crate) fn recommendation_for_score(score: f64) -> Recommendation {
recommendation_for_level(&assessment_for_score(score))
}
/// 自洽性校验: LLM 解析结果可能 final_score 与 recommendation / analyst.final_assessment
/// 互相矛盾(如高分配 Monitor、低分配 ImmediateAction),parse 只校验枚举合法性不校验语义。
/// 以 final_score 为准重新判定这两个结构化字段消除矛盾;LLM 生成的论点/证据/summary 等
/// 自由文本原样保留。不改 LLM 调用/输出结构,只在解析后修正字段值(ARC-260618-01-e)。
pub(crate) fn enforce_score_consistency(mut eval: AdversarialEval) -> AdversarialEval {
let expected_rec = recommendation_for_score(eval.final_score);
let expected_level = assessment_for_score(eval.final_score);
if eval.recommendation != expected_rec {
tracing::warn!(
score = eval.final_score,
from = ?eval.recommendation,
to = ?expected_rec,
"LLM 评估自洽性修正: recommendation 与 final_score 矛盾,按 final_score 重新判定"
);
eval.recommendation = expected_rec;
}
if eval.analyst.final_assessment != expected_level {
tracing::warn!(
score = eval.final_score,
from = ?eval.analyst.final_assessment,
to = ?expected_level,
"LLM 评估自洽性修正: final_assessment 与 final_score 矛盾,按 final_score 重新判定"
);
eval.analyst.final_assessment = expected_level;
}
eval
}
pub(crate) fn priority_label(p: &Priority) -> &'static str { pub(crate) fn priority_label(p: &Priority) -> &'static str {
match p { match p {
Priority::Critical => "紧急", Priority::Critical => "紧急",
-1
View File
@@ -1,6 +1,5 @@
//! 想法晋升 — PromotionResult(promote_idea IPC 返回类型)。 //! 想法晋升 — PromotionResult(promote_idea IPC 返回类型)。
//! 实际晋升路径:commands/idea.rs::promote_idea → df-project::ProjectManager::create_from_idea。 //! 实际晋升路径:commands/idea.rs::promote_idea → df-project::ProjectManager::create_from_idea。
//! 历史空壳 IdeaPromoter/PromotionPolicy 已删(R-PD-14)。
use serde::Serialize; use serde::Serialize;
-2
View File
@@ -125,8 +125,6 @@ impl ScoringEngine {
} }
} }
/// 统计 text 中命中任一关键词的数量(小写匹配,兼顾中英文)
/// 局限:纯子串匹配,不识别"不复用""无用户增长"等否定前缀,接 LLM 后由语义层修正
/// 单字否定前缀:关键词紧邻这些字时该命中不计(「不复用」不计入「复用」正向命中)。 /// 单字否定前缀:关键词紧邻这些字时该命中不计(「不复用」不计入「复用」正向命中)。
/// ///
/// 注意「别」单字歧义大(别具/别的/特别 均非否定),故不进单字清单—— /// 注意「别」单字歧义大(别具/别的/特别 均非否定),故不进单字清单——
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "df-im-gateway"
version = "0.1.0"
edition = "2021"
description = "IM 网关:飞书/企微/钉钉/Web 四端远程接入 DevFlow AI Chat(WS Commander 连 u-relay)"
[dependencies]
anyhow = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
rusqlite = { workspace = true }
tokio = { workspace = true }
async-trait = { workspace = true }
tracing = { workspace = true }
df-tunnel = { path = "../df-tunnel" }
# HTTP 静态托管 + WS 服务端(P1:Web Chat 页面由网关直接托管,/ 与 /ws 同端口)
axum = { version = "0.7", features = ["ws"] }
# WS 客户端(commander 连 u-relay;与 df-tunnel 同款本地版本声明,workspace 无这几项)
tokio-tungstenite = { version = "0.23", features = ["native-tls"] }
futures-util = "0.3"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# 飞书 SDK(长连接 + 归一化消息;channel 自动连带 ws+messaging)
larksuite-oapi-sdk-rs = { version = "0.3.12", features = ["channel"] }
[dev-dependencies]
tempfile = "3"
@@ -0,0 +1,22 @@
[Unit]
Description=DevFlow IM Gateway (飞书/企微/钉钉/Web 四端远程接入)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/df-im-gateway
ExecStart=/opt/df-im-gateway/df-im-gateway
Restart=on-failure
RestartSec=3
# ── 运行时 env(与本地 config.rs 对齐;token 与 u-relay 同源)──
Environment=GATEWAY_RELAY_URL=ws://127.0.0.1:9180/ws/commander
Environment=GATEWAY_RELAY_TOKEN=devflow-relay-default-token
Environment=GATEWAY_DEFAULT_CHANNEL=chan-work
Environment=GATEWAY_DATA_DIR=/opt/df-im-gateway/data
Environment=RUST_LOG=info,df_im_gateway=debug
StandardOutput=append:/opt/df-im-gateway/gateway.log
StandardError=append:/opt/df-im-gateway/gateway.err.log
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,23 @@
# df-im-gateway nginx 反代片段(与 u-relay 同机,复用 *.1216.top 通配符证书)
# 放入 /etc/nginx/conf.d/ 后 nginx -t && systemctl reload nginx
# Web Chat 页面 + WS 同端口 9191(网关 axum 直接托管)
server {
listen 443 ssl;
server_name im.1216.top;
ssl_certificate /etc/nginx/sslkey/_.1216.top.pem;
ssl_certificate_key /etc/nginx/sslkey/_.1216.top.key;
location / {
proxy_pass http://127.0.0.1:9191;
proxy_http_version 1.1;
# WS 升级头(与 u-relay 反代同款)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# 流式响应不缓冲(SSE/流式文本)
proxy_buffering off;
proxy_read_timeout 300s;
}
}
@@ -0,0 +1,86 @@
# df-im-gateway 运维手册
> 四端 IM 网关(飞书/企微/钉钉/Web)远程接入 DevFlow AI Chat。
> 部署拓扑:与 u-relay 同机(39.99.243.191),WS Commander 经 u-relay 连桌面端 df-tunnel。
## 一、拓扑与环境
```
飞书/企微/钉钉/Web ──► df-im-gateway(9191, 同机)
│ ws://127.0.0.1:9180/ws/commander (Commander 身份)
▼
u-relay(9180, nginx 前置 wss://u-relay.1216.top)
│ /ws/agent
▼
桌面端 DevFlow(df-tunnel Agent)
```
| 项 | 值 |
|---|---|
| 服务器 | 39.99.243.191:22 (root 免密, Ubuntu 22.04) |
| 部署路径 | /opt/df-im-gateway/(二进制 + data/ + 日志) |
| 监听端口 | 9191(本机明文,nginx 前置 im.1216.top → wss) |
| systemd | df-im-gateway.service(Restart=on-failure, 开机自启) |
| token | devflow-relay-default-token(与 u-relay/桌面端三端同源) |
| 数据库 | /opt/df-im-gateway/data/{sessions.db, audit.db}(SQLite WAL) |
## 二、日常运维
```bash
# 状态 / 日志 / 重启
systemctl status df-im-gateway
journalctl -u df-im-gateway -n 50 --no-pager
systemctl restart df-im-gateway
# 端口验证
ss -tlnp | grep 9191
# 审计查询(最近 20 条)
sqlite3 /opt/df-im-gateway/data/audit.db \
"SELECT datetime(ts/1000,'unixepoch','+8 hours'), direction, action, chat_id, outcome FROM tunnel_audit ORDER BY id DESC LIMIT 20;"
# 会话映射表
sqlite3 /opt/df-im-gateway/data/sessions.db "SELECT * FROM session_map;"
```
⚠️ systemd 托管,勿 kill Main PID、勿 nohup 起野进程;重启一律 `systemctl restart`。
## 三、发布流程
```powershell
# 本机(Windows)一键发布:单测 → musl 交叉编译 → scp → 原子切换 → 9191 验证 → 失败自动回滚
powershell -File E:\wk-lab\devflow\crates\df-im-gateway\scripts\publish-im-gateway.ps1
# 仅构建不部署: 加 -SkipDeploy;仅部署不构建: 加 -SkipBuild
```
首次部署需手动铺底:
```bash
scp crates/df-im-gateway/deploy/df-im-gateway.service root@39.99.243.191:/etc/systemd/system/
ssh root@39.99.243.191 'systemctl daemon-reload && systemctl enable df-im-gateway'
# nginx(可选,Web 端公网访问时): scp deploy/nginx-im.1216.top.conf → /etc/nginx/conf.d/ && nginx -t && systemctl reload nginx
```
## 四、回滚
```bash
cd /opt/df-im-gateway
systemctl stop df-im-gateway
LATEST=$(ls -t df-im-gateway.bak.* | head -1)
cp "$LATEST" df-im-gateway && chmod +x df-im-gateway
systemctl start df-im-gateway
```
## 五、配置变更
| env | 说明 | 改法 |
|---|---|---|
| GATEWAY_RELAY_TOKEN | 与 u-relay 三端同源,**改必须同步桌面端** | 改 unit Environment= → daemon-reload → restart |
| GATEWAY_DEFAULT_CHANNEL | 默认路由 channel | 同上 |
| GATEWAY_WHITELIST | 用户白名单(空=任何人可用,生产必设!) | 同上 |
| FEISHU_APP_ID/SECRET | 飞书长连接 | 同上 |
## 六、已知项
- token 仍为 MVP 单共享值(per-user 是 S2 计划)
- 日志 gateway.log 追加无轮转,可配 logrotate 或定期 truncate
- 四端 E2E 联调依赖各平台凭证到位(飞书/钉钉/企微)
@@ -0,0 +1,70 @@
# df-im-gateway 一键发布脚本(复用 publish-u-relay 模式)
# 用法: powershell -File scripts\publish-im-gateway.ps1 [-SkipBuild] [-SkipDeploy]
# 流程: cargo test → musl 交叉编译 → scp 上传(.new 中转) → systemd 原子切换 → 9191 验证
param(
[switch]$SkipBuild,
[switch]$SkipDeploy
)
$ErrorActionPreference = "Stop"
$Repo = "E:/wk-lab/devflow"
$Target = "x86_64-unknown-linux-musl"
$Server = "root@39.99.243.191"
$RemoteDir = "/opt/df-im-gateway"
$ServiceName = "df-im-gateway"
$LocalBin = "$Repo/target-musl/$Target/release/df-im-gateway"
function Step($msg) { Write-Host "`n=== $msg ===" -ForegroundColor Cyan }
if (-not $SkipBuild) {
Step "Step 0: 单测基线(df-im-gateway)"
cargo test -p df-im-gateway --manifest-path "$Repo/Cargo.toml"
if ($LASTEXITCODE -ne 0) { throw "单测失败,中止发布" }
Step "Step 1: musl 交叉编译(rust-lld self-contained)"
$env:CARGO_TARGET_DIR = "$Repo/target-musl"
cargo build --release -p df-im-gateway --manifest-path "$Repo/Cargo.toml" --target $Target
if ($LASTEXITCODE -ne 0) { throw "编译失败,中止发布" }
Remove-Item Env:\CARGO_TARGET_DIR -ErrorAction SilentlyContinue
if (-not (Test-Path $LocalBin)) { throw "产物不存在: $LocalBin" }
$size = [math]::Round((Get-Item $LocalBin).Length / 1MB, 1)
Write-Host "产物: $LocalBin (${size}MB)"
}
if ($SkipDeploy) { Write-Host "`nSkipDeploy: 仅构建完成"; exit 0 }
Step "Step 2: 服务器预检 + 备份"
ssh $Server "mkdir -p $RemoteDir && test -f $RemoteDir/df-im-gateway && cp $RemoteDir/df-im-gateway $RemoteDir/df-im-gateway.bak.`$(date +%Y%m%d%H%M%S) || echo '首次部署,无备份'"
Step "Step 3: 上传(.new 中转防 Text file busy)"
scp $LocalBin "${Server}:$RemoteDir/df-im-gateway.new"
if ($LASTEXITCODE -ne 0) { throw "scp 失败" }
Step "Step 4: 原子切换 + systemd 重启"
ssh $Server "bash -s" @'
set -e
cd /opt/df-im-gateway
systemctl stop df-im-gateway 2>/dev/null || true
mv -f df-im-gateway.new df-im-gateway
chmod +x df-im-gateway
systemctl start df-im-gateway
sleep 1
systemctl is-active df-im-gateway
'@
if ($LASTEXITCODE -ne 0) { throw "切换/启动失败,检查 journalctl -u df-im-gateway"; }
Step "Step 5: 健康验证"
ssh $Server "ss -tlnp | grep 9191 && journalctl -u df-im-gateway -n 5 --no-pager | sed 's/\x1b\[[0-9;]*m//g'"
if ($LASTEXITCODE -ne 0) {
Write-Host "❌ 9191 未监听,执行回滚..." -ForegroundColor Red
ssh $Server "bash -s" @'
set -e
cd /opt/df-im-gateway
systemctl stop df-im-gateway
LATEST=$(ls -t df-im-gateway.bak.* 2>/dev/null | head -1)
if [ -n "$LATEST" ]; then cp "$LATEST" df-im-gateway; chmod +x df-im-gateway; systemctl start df-im-gateway; echo "回滚完成: $LATEST"; else echo "无备份可回滚(首次部署失败,保持停止)"; fi
'@
throw "健康检查失败,已回滚"
}
Write-Host "`n✅ df-im-gateway 发布完成 (9191)" -ForegroundColor Green
+284
View File
@@ -0,0 +1,284 @@
//! 平台适配器(P0 预留项 ④:trait 分层,统一 InboundEvent/OutboundAction)
//!
//! 网关核心只认识这两个类型,四端差异(飞书卡片/企微 Markdown/钉钉卡片/Web JSON)收敛在各 adapter。
//! P0 只实现 Web,飞书/企微/钉钉留 trait 位(P1/P2/P4 各自落地,不动核心)。
use async_trait::async_trait;
use tokio::sync::mpsc;
/// 平台入站事件(IM 平台 → 网关,已归一化)
#[derive(Debug, Clone)]
pub enum InboundEvent {
/// 用户发消息
Message {
platform: String,
chat_id: String,
user_id: String,
text: String,
},
/// 会话切换(/new 或菜单)
NewSession { platform: String, chat_id: String },
/// 审批按钮回调
Approval {
platform: String,
chat_id: String,
approval_id: String,
approved: bool,
},
}
/// 平台出站动作(网关 → IM 平台,已归一化)
#[derive(Debug, Clone)]
pub enum OutboundAction {
/// 发/更新 AI 流式文本(全量内容,适配器自选卡片或文本)
StreamText { chat_id: String, text: String, done: bool },
/// 发送审批卡片(同意/拒绝按钮)
ApprovalCard { chat_id: String, approval_id: String, title: String, detail: String },
/// 发系统提示
Notice { chat_id: String, text: String },
}
/// 平台适配器
#[async_trait]
pub trait PlatformAdapter: Send + Sync {
/// 平台名(feishu/wecom/dingtalk/web,与 session_map.platform 对应)。
/// 核心循环经 platform_index 注册表间接使用。
#[allow(dead_code)]
fn name(&self) -> &str;
/// 启动适配器(长连接/回调服务器/WS),入站事件从 events 通道流出。阻塞运行。
async fn run(&self, events: mpsc::Sender<InboundEvent>) -> anyhow::Result<()>;
/// 出站动作分发(网关核心调用)
async fn dispatch(&self, action: OutboundAction) -> anyhow::Result<()>;
/// 清理平台的流式消息状态(网关在新建会话映射时调用,兜底上轮异常中止的残留)。
/// 默认空实现:无流式覆写语义的平台(Web 直接推帧)无需关心。
async fn reset_stream(&self, _chat_id: &str) {}
}
/// Web Chat 适配器(P1:axum 同端口托管静态页 + WS,浏览器直连)
///
/// - `GET /` → 内嵌单文件聊天页(include_str!,零运行时文件依赖)
/// - `GET /ws` → WS 升级,协议见 handle_web_ws(与 P0 纯 WS 版完全一致)
pub mod web {
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::State;
use axum::response::{Html, Response};
use axum::routing::get;
use axum::Router;
use futures_util::{SinkExt, StreamExt};
use tokio::sync::{mpsc, Mutex};
use super::{InboundEvent, OutboundAction, PlatformAdapter};
/// 内嵌聊天页(单文件,无外部依赖,构建期打进二进制)
const INDEX_HTML: &str = include_str!("../web/index.html");
/// 浏览器连接注册表(chat_id → ws 发送通道)
type WsRegistry = Arc<Mutex<HashMap<String, mpsc::Sender<WsMessage>>>>;
/// axum 路由共享状态(注册表 + 核心入站通道)
#[derive(Clone)]
struct WsState {
registry: WsRegistry,
events: mpsc::Sender<InboundEvent>,
}
/// Web 适配器:HTTP+WS 服务监听 0.0.0.0:9191
#[derive(Clone)]
pub struct WebAdapter {
registry: WsRegistry,
}
impl WebAdapter {
pub fn new() -> Self {
Self { registry: Arc::new(Mutex::new(HashMap::new())) }
}
}
impl Default for WebAdapter {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl PlatformAdapter for WebAdapter {
fn name(&self) -> &str {
"web"
}
async fn run(&self, events: mpsc::Sender<InboundEvent>) -> anyhow::Result<()> {
let state = WsState { registry: self.registry.clone(), events };
let app = Router::new()
.route("/", get(|| async { Html(INDEX_HTML) }))
.route("/ws", get(ws_upgrade))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:9191").await?;
tracing::info!("[web] HTTP+WS 服务监听 http://0.0.0.0:9191 (页面 / , WS /ws)");
axum::serve(listener, app).await?;
Ok(())
}
async fn dispatch(&self, action: OutboundAction) -> anyhow::Result<()> {
match action {
OutboundAction::StreamText { chat_id, text, done } => {
let frame = serde_json::json!({
"type": if done { "stream_done" } else { "stream_delta" },
"text": text,
});
self.send_ws(&chat_id, &frame.to_string()).await
}
OutboundAction::ApprovalCard { chat_id, approval_id, title, detail } => {
let frame = serde_json::json!({
"type": "approval",
"approval_id": approval_id,
"title": title,
"detail": detail,
});
self.send_ws(&chat_id, &frame.to_string()).await
}
OutboundAction::Notice { chat_id, text } => {
let frame = serde_json::json!({ "type": "notice", "text": text });
self.send_ws(&chat_id, &frame.to_string()).await
}
}
}
}
impl WebAdapter {
async fn send_ws(&self, chat_id: &str, text: &str) -> anyhow::Result<()> {
let registry = self.registry.lock().await;
if let Some(tx) = registry.get(chat_id) {
tx.send(WsMessage::Text(text.to_string())).await?;
}
Ok(())
}
}
/// WS 升级入口
async fn ws_upgrade(State(state): State<WsState>, ws: WebSocketUpgrade) -> Response {
ws.on_upgrade(move |socket| async move {
if let Err(e) = handle_web_ws(socket, state).await {
tracing::warn!("[web] WS 会话异常结束: {e:#}");
}
})
}
/// 单个浏览器 WS 会话。
///
/// 协议(浏览器 → 网关):
/// ```json
/// 首帧绑定: {"type":"hello","chat_id":"...","user_id":"..."}
/// 消息: {"type":"message","text":"..."}
/// 重置会话: {"type":"new_session"}
/// 审批: {"type":"approval","approval_id":"...","approved":true|false}
/// ```
/// 网关 → 浏览器:stream_delta / stream_done / approval / notice(见 dispatch)。
async fn handle_web_ws(socket: WebSocket, state: WsState) -> anyhow::Result<()> {
let (mut sink, mut upstream) = socket.split();
// 首帧 hello 绑定 chat_id
let first = match upstream.next().await {
Some(Ok(m)) => m,
_ => {
tracing::warn!("[web] 浏览器连接即断(无首帧)");
return Ok(());
}
};
let hello: serde_json::Value = match &first {
WsMessage::Text(t) => serde_json::from_str(t).unwrap_or_default(),
_ => {
tracing::warn!("[web] 首帧非文本,拒绝绑定");
return Ok(());
}
};
let chat_id = hello
.get("chat_id")
.and_then(|v| v.as_str())
.unwrap_or("web-anon")
.to_string();
let user_id = hello.get("user_id").and_then(|v| v.as_str()).unwrap_or("anon").to_string();
// 注册发送通道:出站帧经 ws_tx → forward 任务写 socket
let (ws_tx, mut ws_rx) = mpsc::channel::<WsMessage>(64);
state.registry.lock().await.insert(chat_id.clone(), ws_tx);
tracing::info!("[web] 浏览器接入 chat_id={chat_id} user={user_id}");
let forward = tokio::spawn(async move {
while let Some(frame) = ws_rx.recv().await {
if sink.send(frame).await.is_err() {
break;
}
}
});
let result: anyhow::Result<()> = async {
while let Some(msg) = upstream.next().await {
let msg = msg?;
match msg {
WsMessage::Text(t) => {
let v: serde_json::Value = serde_json::from_str(&t).unwrap_or_default();
match v.get("type").and_then(|x| x.as_str()).unwrap_or("") {
"message" => {
let text =
v.get("text").and_then(|x| x.as_str()).unwrap_or("").to_string();
state
.events
.send(InboundEvent::Message {
platform: "web".into(),
chat_id: chat_id.clone(),
user_id: user_id.clone(),
text,
})
.await
.map_err(|_| anyhow::anyhow!("核心入站通道关闭"))?;
}
"new_session" => {
state
.events
.send(InboundEvent::NewSession {
platform: "web".into(),
chat_id: chat_id.clone(),
})
.await
.map_err(|_| anyhow::anyhow!("核心入站通道关闭"))?;
}
"approval" => {
let approval_id =
v.get("approval_id").and_then(|x| x.as_str()).unwrap_or("").to_string();
let approved =
v.get("approved").and_then(|x| x.as_bool()).unwrap_or(false);
state
.events
.send(InboundEvent::Approval {
platform: "web".into(),
chat_id: chat_id.clone(),
approval_id,
approved,
})
.await
.map_err(|_| anyhow::anyhow!("核心入站通道关闭"))?;
}
_ => {}
}
}
WsMessage::Close(_) => break,
_ => {}
}
}
Ok(())
}
.await;
state.registry.lock().await.remove(&chat_id);
forward.abort();
tracing::info!("[web] 浏览器断开 chat_id={chat_id}");
result
}
}
+236
View File
@@ -0,0 +1,236 @@
//! 审计日志(P5):IM 消息 ↔ TunnelCommand 全量记录(SQLite + WAL,与 SessionStore 同机库文件)
//!
//! 设计口径:
//! - **append-only**:只写不改,无 UPDATE/DELETE——审计的公信力来自不可篡改。
//! - **双向打点**:inbound(IM 用户 → 网关)与 outbound(网关/桌面端 → IM)各记一行,
//! 同一 request_id 关联请求与响应(排障时按 id 串链)。
//! - **失败不阻断**:审计写入失败仅 tracing::warn,绝不影响主链路(审计是增益非闸门)。
//! - 表:tunnel_audit(id 自增 / ts 毫秒 / direction / platform / chat_id / user_id /
//! conv_id / action / request_id / payload 截断 4KB / outcome ok|error|denied)。
//! payload 截断防长消息撑爆库;敏感凭证不经过此层(config 层已隔离)。
//!
//! 查询接口(运维用):recent 按时间倒序限量;by_request 串同请求双向链。
use std::path::Path;
use std::sync::Arc;
use rusqlite::Connection;
const MAX_PAYLOAD: usize = 4096;
/// 审计方向
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
/// IM 用户 → 网关(入站消息/审批点击/新会话)
Inbound,
/// 网关/桌面端 → IM(流式输出/审批卡片/通知)
Outbound,
/// 网关 → 桌面端(TunnelCommand 下发)
Command,
}
impl Direction {
fn as_str(self) -> &'static str {
match self {
Direction::Inbound => "inbound",
Direction::Outbound => "outbound",
Direction::Command => "command",
}
}
}
#[derive(Clone)]
pub struct AuditLog {
conn: Arc<std::sync::Mutex<Connection>>,
}
/// 打点参数(字段可选,None 写空串,保持列 NOT NULL 简化查询)
pub struct AuditEntry {
pub direction: Direction,
pub platform: String,
pub chat_id: String,
pub user_id: String,
pub conv_id: String,
/// 动作名:message/new_session/approval_click/approval_card/stream/completed/notice/send_cmd/approve_cmd
pub action: String,
/// 关联 ID:入站↔出站串链用(消息场景可用 chat+ts 派生,审批场景用 approval_id)
pub request_id: String,
pub payload: String,
/// ok / error / denied(白名单拒/无 channel)
pub outcome: String,
}
impl AuditLog {
/// 打开(或创建)审计库。默认与 sessions.db 同目录独立文件(隔离写放大)。
pub fn open(path: &Path) -> anyhow::Result<Self> {
let conn = Connection::open(path)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS tunnel_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
direction TEXT NOT NULL,
platform TEXT NOT NULL DEFAULT '',
chat_id TEXT NOT NULL DEFAULT '',
user_id TEXT NOT NULL DEFAULT '',
conv_id TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL,
request_id TEXT NOT NULL DEFAULT '',
payload TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL DEFAULT 'ok'
);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON tunnel_audit(ts);
CREATE INDEX IF NOT EXISTS idx_audit_request ON tunnel_audit(request_id);",
)?;
Ok(Self { conn: Arc::new(std::sync::Mutex::new(conn)) })
}
/// 内存库(测试)
#[allow(dead_code)]
pub fn open_in_memory() -> anyhow::Result<Self> {
let conn = Connection::open_in_memory()?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS tunnel_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL,
direction TEXT NOT NULL, platform TEXT NOT NULL DEFAULT '',
chat_id TEXT NOT NULL DEFAULT '', user_id TEXT NOT NULL DEFAULT '',
conv_id TEXT NOT NULL DEFAULT '', action TEXT NOT NULL,
request_id TEXT NOT NULL DEFAULT '', payload TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL DEFAULT 'ok');",
)?;
Ok(Self { conn: Arc::new(std::sync::Mutex::new(conn)) })
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
/// 追加一条审计(失败仅告警不冒泡)
pub fn record(&self, e: AuditEntry) {
let payload = if e.payload.len() > MAX_PAYLOAD {
format!("{}…[截断]", &e.payload[..MAX_PAYLOAD])
} else {
e.payload
};
let result = (|| -> anyhow::Result<()> {
let conn = self.conn.lock().map_err(|err| anyhow::anyhow!("audit 锁中毒: {err}"))?;
conn.execute(
"INSERT INTO tunnel_audit (ts, direction, platform, chat_id, user_id, conv_id, action, request_id, payload, outcome) \
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",
rusqlite::params![
Self::now_ms(), e.direction.as_str(), e.platform, e.chat_id,
e.user_id, e.conv_id, e.action, e.request_id, payload, e.outcome,
],
)?;
Ok(())
})();
if let Err(err) = result {
tracing::warn!("[audit] 写入失败(不影响主链路): {err}");
}
}
/// 最近 N 条(时间倒序,运维排障)
#[allow(dead_code)]
pub fn recent(&self, limit: usize) -> anyhow::Result<Vec<String>> {
let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("audit 锁中毒: {e}"))?;
let mut stmt = conn.prepare(
"SELECT ts, direction, platform, chat_id, user_id, conv_id, action, request_id, outcome \
FROM tunnel_audit ORDER BY id DESC LIMIT ?1",
)?;
let rows = stmt.query_map([limit as i64], |row| {
Ok(format!(
"{} [{}] {} plat={} chat={} user={} conv={} req={} outcome={}",
row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(6)?,
row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?,
row.get::<_, String>(5)?, row.get::<_, String>(7)?, row.get::<_, String>(8)?,
))
})?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
/// 按 request_id 串双向链(入站→命令→出站全链)
#[allow(dead_code)]
pub fn by_request(&self, request_id: &str) -> anyhow::Result<Vec<String>> {
let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("audit 锁中毒: {e}"))?;
let mut stmt = conn.prepare(
"SELECT ts, direction, action, outcome FROM tunnel_audit WHERE request_id = ?1 ORDER BY id ASC",
)?;
let rows = stmt.query_map([request_id], |row| {
Ok(format!(
"{} [{}] {} outcome={}",
row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?,
))
})?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(direction: Direction, action: &str, req: &str, outcome: &str) -> AuditEntry {
AuditEntry {
direction,
platform: "feishu".into(),
chat_id: "oc_123".into(),
user_id: "u_1".into(),
conv_id: "feishu:oc_123".into(),
action: action.into(),
request_id: req.into(),
payload: "hello".into(),
outcome: outcome.into(),
}
}
#[test]
fn record_and_recent_roundtrip() {
let log = AuditLog::open_in_memory().unwrap();
log.record(entry(Direction::Inbound, "message", "req-1", "ok"));
log.record(entry(Direction::Command, "send_cmd", "req-1", "ok"));
log.record(entry(Direction::Outbound, "stream", "req-1", "ok"));
log.record(entry(Direction::Inbound, "message", "req-2", "denied"));
let rows = log.recent(10).unwrap();
assert_eq!(rows.len(), 4);
// 倒序:最新在前
assert!(rows[0].contains("req-2"));
assert!(rows[0].contains("denied"));
let chain = log.by_request("req-1").unwrap();
assert_eq!(chain.len(), 3);
}
#[test]
fn payload_truncated() {
let log = AuditLog::open_in_memory().unwrap();
let mut e = entry(Direction::Inbound, "message", "req-x", "ok");
e.payload = "x".repeat(10_000);
log.record(e);
let conn_count = |log: &AuditLog| -> i64 {
let conn = log.conn.lock().unwrap();
conn.query_row("SELECT COUNT(*) FROM tunnel_audit", [], |r| r.get(0)).unwrap()
};
assert_eq!(conn_count(&log), 1);
let conn = log.conn.lock().unwrap();
let stored: String = conn
.query_row("SELECT payload FROM tunnel_audit", [], |r| r.get(0))
.unwrap();
assert!(stored.len() < 5_000);
assert!(stored.ends_with("…[截断]"));
}
#[test]
fn record_never_panics_on_poisoned_lock() {
let log = AuditLog::open_in_memory().unwrap();
// 毒锁:持锁 panic 使 Mutex poisoned,record 应 warn 而非 panic
let guard = log.conn.lock().unwrap();
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
std::panic::panic_any("poison");
}))
.err();
drop(guard);
log.record(entry(Direction::Inbound, "message", "req-p", "ok")); // 不 panic 即通过
}
}
+189
View File
@@ -0,0 +1,189 @@
//! WS Commander 客户端(连 u-relay /ws/commander)
//!
//! 与桌面端 df-tunnel(Agent 角色)对称:网关作为 Commander 发 TunnelCommand、收 TunnelEvent。
//! 协议类型直接复用 df_tunnel::events(编译期一致)。
//!
//! 连接生命周期:hello {kind:"commander", channel_id, token} → (relay 校验) → 双向消息。
//! 断线重连:指数退避(1/2/4/8/16/30s 封顶,与桌面端 df-tunnel 同款策略)。
//!
//! 注:relay 对同 channel 多 Commander 的投递行为(P0 必验项 ①)待实测——
//! 过渡期约定同 channel 单活跃控制端。
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;
use tracing::{info, warn};
use df_tunnel::events::{Hello, HelloKind, RelayControlError};
use df_tunnel::{TunnelCommand, TunnelEvent, TunnelMessage};
/// 指数退避序列(秒,封顶 30)
fn backoff_secs(attempt: u32) -> u64 {
[1, 2, 4, 8, 16, 30]
.get(attempt.min(5) as usize)
.copied()
.unwrap_or(30)
}
/// 启动 Commander 连接循环(阻塞直到 shutdown)。重连在内部循环,指令/事件双向透传。
///
/// * `relay_url` — ws://host:9180/ws/commander
/// * `channel_id` — 配对的桌面端 channel
/// * `token` — U_RELAY_TOKEN 同源
/// * `ev_tx` — 事件出口(桌面端 → 网关核心)
/// * `cmd_rx` — 指令入口(网关核心 → 桌面端)
/// * `shutdown` — 关闭信号
pub async fn run_commander(
relay_url: String,
channel_id: String,
token: String,
ev_tx: mpsc::Sender<TunnelEvent>,
mut cmd_rx: mpsc::Receiver<TunnelCommand>,
shutdown: tokio::sync::watch::Receiver<bool>,
) -> anyhow::Result<()> {
let mut attempt: u32 = 0;
loop {
if *shutdown.borrow() {
info!("[commander] 收到关闭信号,退出");
return Ok(());
}
match connect_once(&relay_url, &channel_id, &token, &ev_tx, &mut cmd_rx, shutdown.clone()).await {
Ok(()) => {
info!("[commander] 连接正常关闭");
return Ok(());
}
Err(e) => {
let wait = backoff_secs(attempt);
attempt += 1;
warn!("[commander] 连接断开({e}),{}s 后重连(第 {attempt} 次)", wait);
let mut shutdown_w = shutdown.clone();
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(wait)) => {}
_ = shutdown_w.changed() => return Ok(()),
}
}
}
}
}
/// 单次连接会话(hello 握手 + 消息循环),断开/出错返回 Err 触发外层重连
async fn connect_once(
relay_url: &str,
channel_id: &str,
token: &str,
ev_tx: &mpsc::Sender<TunnelEvent>,
cmd_rx: &mut mpsc::Receiver<TunnelCommand>,
mut shutdown: tokio::sync::watch::Receiver<bool>,
) -> anyhow::Result<()> {
let (ws, _resp) = tokio_tungstenite::connect_async(relay_url).await?;
let (mut sink, mut stream) = ws.split();
// hello 握手(kind=commander;字段与 u-relay::relay::Hello 严格一致)
let hello = Hello {
kind: HelloKind::Commander,
channel_id: channel_id.to_string(),
token: token.to_string(),
proto: 1,
};
sink.send(Message::Text(serde_json::to_string(&hello)?)).await?;
// 等待 hello_ack(首条文本帧;control error 帧则报错)
let ack = tokio::time::timeout(Duration::from_secs(10), stream.next()).await
.map_err(|_| anyhow::anyhow!("hello_ack 10s 超时"))?
.ok_or_else(|| anyhow::anyhow!("连接在握手期关闭"))?;
match ack? {
Message::Text(t) => {
// relay 控制帧:{"kind":"control","error":"..."} → 握手被拒
if let Ok(ctrl_err) = serde_json::from_str::<RelayControlError>(&t) {
if ctrl_err.kind == "control" {
anyhow::bail!("握手被拒: {}", ctrl_err.error);
}
}
info!("[commander] hello_ack: channel={channel_id} 握手成功");
}
other => anyhow::bail!("hello_ack 非文本帧: {other:?}"),
}
// 心跳:30s ping(relay 90s 读死线)
let mut ping = tokio::time::interval(Duration::from_secs(30));
ping.tick().await; // 首个立即返回,跳过
// 消息循环
loop {
tokio::select! {
_ = shutdown.changed() => {
let _ = sink.close().await;
return Ok(());
}
_ = ping.tick() => {
sink.send(Message::Ping(vec![])).await?;
}
cmd = cmd_rx.recv() => {
let Some(cmd) = cmd else {
// 核心侧通道关闭:正常收尾
let _ = sink.close().await;
return Ok(());
};
let frame = TunnelMessage::Command(cmd);
sink.send(Message::Text(serde_json::to_string(&frame)?)).await?;
}
msg = stream.next() => {
match msg {
Some(Ok(Message::Text(t))) => {
match serde_json::from_str::<TunnelMessage>(&t) {
Ok(TunnelMessage::Event(e)) => {
if ev_tx.send(e).await.is_err() {
return Err(anyhow::anyhow!("核心事件通道关闭"));
}
}
Ok(TunnelMessage::Command(_)) => {
warn!("[commander] commander 收到 Command 帧(协议错向,忽略)");
}
Err(_) => warn!("[commander] 无法解析帧(非 TunnelMessage,忽略): {} 字节", t.len()),
}
}
Some(Ok(Message::Ping(p))) => { let _ = sink.send(Message::Pong(p)).await; }
Some(Ok(_)) => {}
Some(Err(e)) => return Err(e.into()),
None => return Err(anyhow::anyhow!("stream 结束")),
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backoff_sequence() {
assert_eq!(backoff_secs(0), 1);
assert_eq!(backoff_secs(1), 2);
assert_eq!(backoff_secs(2), 4);
assert_eq!(backoff_secs(3), 8);
assert_eq!(backoff_secs(4), 16);
assert_eq!(backoff_secs(5), 30);
assert_eq!(backoff_secs(99), 30, "封顶 30s");
}
#[test]
fn tunnel_message_roundtrip() {
let cmd = TunnelMessage::Command(TunnelCommand::Send {
conv_id: "c1".into(),
content: "你好".into(),
});
let json = serde_json::to_string(&cmd).unwrap();
assert!(json.contains("\"command\""));
let back: TunnelMessage = serde_json::from_str(&json).unwrap();
match back {
TunnelMessage::Command(TunnelCommand::Send { conv_id, content }) => {
assert_eq!(conv_id, "c1");
assert_eq!(content, "你好");
}
_ => panic!("roundtrip 失败"),
}
}
}
+143
View File
@@ -0,0 +1,143 @@
//! 网关配置(env 加载,凭证不进 git)
//!
//! 配置来源:环境变量 > 默认值(gateway.toml 文件加载为后续增强,当前未实现)。
//! u-relay token 与桌面端 df-tunnel 同源(env U_RELAY_TOKEN)。
use serde::Deserialize;
/// 网关全局配置
#[derive(Debug, Clone, Deserialize)]
pub struct GatewayConfig {
/// u-relay WS 地址(生产 u-relay.1216.top,本地 http://127.0.0.1:9180)
pub relay_url: String,
/// relay 配对 token(env U_RELAY_TOKEN 同源)
pub relay_token: String,
/// channel 路由表:IM 侧默认走 default_channel;按前缀路由的多桌面端配置(预留项 ⑥)
#[serde(default)]
pub channels: Vec<ChannelRoute>,
/// 飞书应用凭证(未配置则该平台禁用)
#[serde(default)]
pub feishu: Option<FeishuConfig>,
/// TextDelta 节流窗口毫秒(默认 800,IM 频控友好)
#[serde(default = "default_throttle_ms")]
pub throttle_ms: u64,
/// 用户白名单(空=放行所有,生产部署务必显式配置)
///
/// 注:历史注释「空=拒绝所有」与 main.rs 实现(空=放行)矛盾,走查 2026-10-04 定为
/// 空=放行——否则首次启动零配置时飞书/Web 全部拒绝,连 banner 警告都没有,反而
/// 更难发现配置缺失。生产风险由 main.rs 启动 banner 常驻警告 + 白名单缺失提示兜底。
#[serde(default)]
pub whitelist: Vec<String>,
}
/// channel 路由项:IM 指令前缀 → 桌面端 channel
#[derive(Debug, Clone, Deserialize)]
pub struct ChannelRoute {
/// 指令前缀(如 "/dev2 xxx" 路由到 chan-home);空串=默认 channel
pub prefix: String,
/// u-relay channel_id(桌面端 df-tunnel 配置的同一 channel)
pub channel_id: String,
}
/// 飞书企业自建应用凭证
#[derive(Debug, Clone, Deserialize)]
pub struct FeishuConfig {
pub app_id: String,
pub app_secret: String,
/// 长连接模式事件订阅(免公网回调;当前恒 true,保留显式开关供回调模式扩展)
#[allow(dead_code)]
pub long_connection: bool,
}
fn default_throttle_ms() -> u64 {
800
}
impl GatewayConfig {
/// 从 env + 可选 toml 文件构建。缺失关键项(relay_url/token)返回 Err。
pub fn load() -> anyhow::Result<Self> {
let relay_url = std::env::var("GATEWAY_RELAY_URL")
.unwrap_or_else(|_| "ws://127.0.0.1:9180/ws/commander".to_string());
let relay_token = std::env::var("GATEWAY_RELAY_TOKEN")
.or_else(|_| std::env::var("U_RELAY_TOKEN"))
.map_err(|_| anyhow::anyhow!("缺少 GATEWAY_RELAY_TOKEN / U_RELAY_TOKEN 环境变量"))?;
let feishu = match (
std::env::var("FEISHU_APP_ID"),
std::env::var("FEISHU_APP_SECRET"),
) {
(Ok(app_id), Ok(app_secret)) => Some(FeishuConfig {
app_id,
app_secret,
long_connection: true,
}),
_ => None,
};
let whitelist = std::env::var("GATEWAY_WHITELIST")
.map(|v| v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect())
.unwrap_or_default();
let default_channel = std::env::var("GATEWAY_DEFAULT_CHANNEL").unwrap_or_default();
let channels = if default_channel.is_empty() {
Vec::new()
} else {
vec![ChannelRoute { prefix: String::new(), channel_id: default_channel }]
};
Ok(Self {
relay_url,
relay_token,
channels,
feishu,
throttle_ms: default_throttle_ms(),
whitelist,
})
}
/// 查指令应路由到的 channel(前缀匹配,空前缀=兜底默认)
pub fn route(&self, command_text: &str) -> Option<&str> {
// 精确前缀优先,空前缀兜底
self.channels
.iter()
.find(|c| !c.prefix.is_empty() && command_text.starts_with(&c.prefix))
.map(|c| c.channel_id.as_str())
.or_else(|| self.channels.iter().find(|c| c.prefix.is_empty()).map(|c| c.channel_id.as_str()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn route_prefix_priority() {
let cfg = GatewayConfig {
relay_url: String::new(),
relay_token: String::new(),
channels: vec![
ChannelRoute { prefix: "/dev2".into(), channel_id: "chan-home".into() },
ChannelRoute { prefix: String::new(), channel_id: "chan-work".into() },
],
feishu: None,
throttle_ms: 800,
whitelist: vec![],
};
assert_eq!(cfg.route("/dev2 修个 bug"), Some("chan-home"));
assert_eq!(cfg.route("普通指令"), Some("chan-work"));
assert_eq!(cfg.route("/dev2"), Some("chan-home"));
}
#[test]
fn route_empty_when_no_channels() {
let cfg = GatewayConfig {
relay_url: String::new(),
relay_token: String::new(),
channels: vec![],
feishu: None,
throttle_ms: 800,
whitelist: vec![],
};
assert_eq!(cfg.route("任何"), None);
}
}
+410
View File
@@ -0,0 +1,410 @@
//! 飞书适配器(P2):长连接事件 + 归一化消息 + 卡片审批
//!
//! 依赖 larksuite-oapi-sdk-rs features=["channel"](自动连带 ws+messaging)。
//! 长连接:飞书←→网关出站直连,不经 u-relay(u-relay 仅穿 NAT 段到桌面端)。
//!
//! 验证记录(2026-10-04,SDK 0.3.12 源码核对):
//! - 卡片回调:ChannelBuilder::on_card_action(NormalizedCardAction)
//! 内部走 dispatcher.on_card_action_trigger(channel/handler.rs:112),
//! 即新版 card.action.trigger 事件通道,长连接可收(遗留验证项 ✅)。
//! - 发卡片:SendInput.card(String) → INTERACTIVE 类型(messaging.rs:752)。
//! - 流式更新:channel_messaging().send() 拿 message_id 后 edit_text() 覆写全文。
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use larksuite_oapi_sdk_rs::card::v1::{
Action, ActionComponent, Button, ButtonType, Card, CardDocument, Div, Element, Header,
Markdown,
};
use larksuite_oapi_sdk_rs::channel::{Channel, ChannelPolicy};
use larksuite_oapi_sdk_rs::{EventDispatcher, LarkClient, LarkError, RequestOption};
use tokio::sync::mpsc::Sender;
use crate::adapters::{InboundEvent, OutboundAction, PlatformAdapter};
use crate::config::FeishuConfig;
/// 飞书适配器:channel 归一化消息 + 卡片审批,单飞书应用单实例
#[derive(Clone)]
pub struct FeishuAdapter {
/// 常驻 client(&'static,构造期一次性 Box::leak)。D1(u-claw 教训:热重建不关旧
/// 连接→僵尸资源):leak 只在 new()/offline() 各发生一次;run() 重连重入只重建
/// Channel 不再重复 leak,防频繁断线场景 LarkClient 内存阶梯上涨。出站 dispatch
/// 与 Channel 构建共用同一份引用。
client: &'static LarkClient,
/// 流式会话注册表:chat_id → 已发出的流式消息 message_id(用于 edit_text 覆写)
streams: Arc<tokio::sync::Mutex<HashMap<String, String>>>,
}
impl FeishuAdapter {
pub fn new(cfg: &FeishuConfig) -> anyhow::Result<Self> {
let client: &'static LarkClient = Box::leak(Box::new(
LarkClient::builder(&cfg.app_id, &cfg.app_secret).build()?,
));
Ok(Self {
client,
streams: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
})
}
/// 构造失败时的离线哨兵:client 用占位凭证(不启动 run,dispatch 记日志即弃)
pub fn offline() -> Self {
let client: &'static LarkClient = Box::leak(Box::new(
LarkClient::builder("offline", "offline")
.build()
.expect("占位凭证构造 LarkClient 不发网络请求,必成功"),
));
Self {
client,
streams: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
}
}
}
#[async_trait]
impl PlatformAdapter for FeishuAdapter {
fn name(&self) -> &str {
"feishu"
}
/// 兜底清理流式残留(走查 #3):桌面端异常中止时无 Completed 事件,
/// streams entry 永驻导致下轮首帧错覆写旧消息。核心循环在新建会话映射时调用。
async fn reset_stream(&self, chat_id: &str) {
self.streams.lock().await.remove(chat_id);
}
async fn run(&self, events: Sender<InboundEvent>) -> anyhow::Result<()> {
// 长连接模式:空 verification_token/encrypt_key(免公网回调)
let dispatcher = EventDispatcher::new("", "");
let client = self.client;
// 入站事件通道复制两份,分别给消息/卡片回调闭包
let events_for_msg = events.clone();
let events_for_card = events;
let channel: Channel<'static> = {
// client 已是 &'static(构造期一次性 leak);run() 重连重入只重建 Channel,
// 不再重复 Box::leak(D1:防每次重入泄漏一份 LarkClient)
Channel::builder(client, dispatcher)
.policy(ChannelPolicy::default().require_mention(false))
.on_message(move |msg| {
let events = events_for_msg.clone();
async move {
handle_inbound_message(events, msg).await;
Ok(())
}
})
.on_card_action(move |action| {
let events = events_for_card.clone();
async move {
handle_card_action(events, action).await;
Ok(())
}
})
.build()
};
tracing::info!("[feishu] 长连接启动(免公网回调)");
channel.start().await.map_err(|e: LarkError| anyhow::anyhow!("飞书长连接退出: {e}"))?;
Ok(())
}
async fn dispatch(&self, action: OutboundAction) -> anyhow::Result<()> {
let opt = &RequestOption::default();
match action {
OutboundAction::StreamText { chat_id, text, done } => {
// 两段式:锁内只取/写 message_id(纯内存操作),网络 IO 在锁外执行。
// 修复走查 #2:同 chat 仍串行(顺序语义),不同 chat 不再被同一把锁挡住。
let existing_id: Option<String> = {
let streams = self.streams.lock().await;
streams.get(&chat_id).cloned()
};
if let Some(message_id) = existing_id {
let res = self.client.channel_messaging().edit_text(&message_id, &text, opt).await;
if let Err(e) = res {
tracing::warn!("[feishu] 流式更新失败(消息可能已被撤回/超时): {e},回退新发");
self.streams.lock().await.remove(&chat_id);
}
} else {
// 首帧:发新文本消息,记录 message_id 供后续覆写
let input = larksuite_oapi_sdk_rs::channel::SendInput {
receive_id: Some(chat_id.clone()),
text: Some(text),
..Default::default()
};
let send_result = self.client.channel_messaging().send(&input, opt).await;
match send_result {
Ok(sr) if !sr.message_id.is_empty() => {
self.streams.lock().await.insert(chat_id.clone(), sr.message_id);
}
Ok(_) => {
tracing::warn!("[feishu] 发送流式首帧未返回 message_id");
}
Err(e) => {
tracing::error!("[feishu] 发送流式首帧失败: {e}");
}
}
}
if done {
self.streams.lock().await.remove(&chat_id);
}
Ok(())
}
OutboundAction::ApprovalCard { chat_id, approval_id, title, detail } => {
let card = build_approval_card(&approval_id, &title, &detail);
let document = CardDocument::new(card)
.map_err(|e| anyhow::anyhow!("审批卡片校验失败: {e}"))?;
// encoded_content 是 SDK pub(crate);直接序列化 card(校验已过)
let content = serde_json::to_string(document.card())
.map_err(|e| anyhow::anyhow!("卡片序列化失败: {e}"))?;
let input = larksuite_oapi_sdk_rs::channel::SendInput {
receive_id: Some(chat_id),
card: Some(content),
..Default::default()
};
self.client
.channel_messaging()
.send(&input, opt)
.await
.map_err(|e| anyhow::anyhow!("审批卡片发送失败: {e}"))?;
Ok(())
}
OutboundAction::Notice { chat_id, text } => {
let input = larksuite_oapi_sdk_rs::channel::SendInput {
receive_id: Some(chat_id),
text: Some(text),
..Default::default()
};
self.client
.channel_messaging()
.send(&input, opt)
.await
.map_err(|e| anyhow::anyhow!("通知发送失败: {e}"))?;
Ok(())
}
}
}
}
/// 入站消息:群聊需 @机器人(mentioned_bot)或单聊直发
async fn handle_inbound_message(events: Sender<InboundEvent>, msg: larksuite_oapi_sdk_rs::channel::NormalizedMessage) {
// 只处理文本(text 优先,回退 content)
if msg.message_type != "text" {
tracing::debug!("[feishu] 忽略非文本消息 type={}", msg.message_type);
return;
}
// 群聊必须 @机器人;单聊(p2p)直发
if msg.chat_type == "group" && !msg.mentioned_bot {
tracing::debug!("[feishu] 群聊未 @机器人,忽略");
return;
}
let text = msg.text.clone().unwrap_or_default().trim().to_string();
if text.is_empty() {
return;
}
// 指令:/new 重置会话
if text == "/new" {
let _ = events
.send(InboundEvent::NewSession {
platform: "feishu".into(),
chat_id: msg.chat_id.clone(),
})
.await;
return;
}
let user_id = msg
.sender
.user_id
.as_ref()
.and_then(|u| u.open_id.clone())
.unwrap_or_else(|| "unknown".into());
let _ = events
.send(InboundEvent::Message {
platform: "feishu".into(),
chat_id: msg.chat_id,
user_id,
text,
})
.await;
}
/// 卡片按钮回调:action_value 携带 approval_id + approved
async fn handle_card_action(events: Sender<InboundEvent>, action: larksuite_oapi_sdk_rs::channel::NormalizedCardAction) {
// 按钮约定的 value 形态:{"approval_id": "...", "approved": true|false}
let approval_id = action
.action_value
.get("approval_id")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let approved = action
.action_value
.get("approved")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if approval_id.is_empty() {
tracing::warn!("[feishu] 卡片回调缺 approval_id,忽略");
return;
}
let chat_id = action.open_chat_id.clone().unwrap_or_default();
let _ = events
.send(InboundEvent::Approval {
platform: "feishu".into(),
chat_id,
approval_id,
approved,
})
.await;
}
/// 构造审批卡片(Card JSON 1.0):标题 + 详情 + 同意/拒绝按钮
fn build_approval_card(approval_id: &str, title: &str, detail: &str) -> Card {
let detail_md = format!("{}\n\n**审批 ID**: `{approval_id}`", md_to_lark_friendly(detail));
let to_json_value = |v: serde_json::Value| -> larksuite_oapi_sdk_rs::JsonValue {
serde_json::from_value(serde_json::to_value(v).expect("serde_json::Value 可逆"))
.expect("按钮 value 结构兼容")
};
let actions = Action::new()
.action(ActionComponent::Button(
Button::new(larksuite_oapi_sdk_rs::card::v1::Text::plain("✅ 同意"))
.button_type(ButtonType::Primary)
.value(to_json_value(serde_json::json!({"approval_id": approval_id, "approved": true}))),
))
.action(ActionComponent::Button(
Button::new(larksuite_oapi_sdk_rs::card::v1::Text::plain("❌ 拒绝"))
.button_type(ButtonType::Danger)
.value(to_json_value(serde_json::json!({"approval_id": approval_id, "approved": false}))),
));
Card::new()
.header(Header::new(title))
.element(Element::Div(Div::new(
larksuite_oapi_sdk_rs::card::v1::Text::lark_md(detail_md),
)))
.element(Element::Markdown(Markdown::new("---")))
.element(Element::Action(actions))
}
/// 出站 Markdown 净化(飞书卡片 lark_md 语法适配)。
///
/// AI 产出常带标准 Markdown 结构,lark_md 不支持标题与表格语法,原样注入按字面量渲染。
/// 规则(与 src-tauri feishu_channel.rs 侧 md_to_lark_friendly 同规则互引,双端出站口径一致):
/// 标题行(行首一个以上 #)转粗体行;表格行(| 开头)转列表行,表头分隔行整行跳过;
/// 代码块围栏内外均保留原样(转义或改写破坏代码展示);其余行原样透传(lark_md 原生
/// 支持粗体/链接/行内代码,不额外转义)。
fn md_to_lark_friendly(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut in_code = false;
for line in s.lines() {
if line.trim_start().starts_with("```") {
in_code = !in_code;
out.push_str(line);
out.push('\n');
continue;
}
if in_code {
out.push_str(line);
out.push('\n');
continue;
}
let trimmed = line.trim_start();
if trimmed.starts_with('#') {
let title = trimmed.trim_start_matches('#').trim();
if !title.is_empty() {
out.push_str(&format!("**{title}**\n"));
}
continue;
}
if trimmed.starts_with('|') {
let cells: Vec<&str> = trimmed.trim_matches('|').split('|').map(str::trim).collect();
// 表头分隔行(|---|:---|)无内容语义,整行跳过
if cells.iter().all(|c| c.is_empty() || c.chars().all(|ch| ch == '-' || ch == ':')) {
continue;
}
let shown: Vec<&str> = cells.into_iter().filter(|c| !c.is_empty()).collect();
out.push_str(&format!("- {}\n", shown.join(" · ")));
continue;
}
out.push_str(line);
out.push('\n');
}
// lines() 逐行补换行,源文本不以换行结尾时尾部多出一个,与源对齐
if !s.ends_with('\n') {
out.pop();
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::FeishuConfig;
/// 构造冒烟:new/offline 均不发网络请求,验证适配器可实例化
#[test]
fn feishu_adapter_constructs() {
let cfg = FeishuConfig {
app_id: "cli_smoke".into(),
app_secret: "secret".into(),
long_connection: true,
};
let adapter = FeishuAdapter::new(&cfg).expect("适配器构造应成功");
assert_eq!(adapter.name(), "feishu");
let offline = FeishuAdapter::offline();
assert_eq!(offline.name(), "feishu");
}
/// 审批卡片构造 + 协议校验(CardDocument::new 是发送前的强校验门槛)
#[test]
fn approval_card_passes_protocol_validation() {
let card = build_approval_card("appr-123", "工具审批请求", "run_command: cargo test");
CardDocument::new(card).expect("审批卡片应通过 Card JSON 1.0 校验");
}
/// 卡片构造包含同意/拒绝按钮及 approval_id 载荷
#[test]
fn approval_card_has_buttons_and_payload() {
let card = build_approval_card("appr-456", "标题", "详情");
let json = serde_json::to_value(&card).unwrap();
let elements = json["elements"].as_array().expect("elements 数组");
// 最后一个元素是 Action 行,内含 2 个按钮
let action = elements.last().unwrap();
let buttons = action["actions"].as_array().expect("actions 数组");
assert_eq!(buttons.len(), 2);
let values: Vec<String> = buttons
.iter()
.map(|b| b["value"]["approval_id"].as_str().unwrap_or_default().to_string())
.collect();
assert!(values.iter().all(|v| v == "appr-456"));
let approved_flags: Vec<bool> = buttons
.iter()
.map(|b| b["value"]["approved"].as_bool().unwrap_or(false))
.collect();
assert!(approved_flags.contains(&true) && approved_flags.contains(&false));
}
/// 出站净化:标题转粗体行,表格行转列表行,表头分隔行跳过
#[test]
fn lark_friendly_converts_headings_and_tables() {
let md = "# 标题\n| a | b |\n|---|---|\n| 1 | 2 |";
assert_eq!(md_to_lark_friendly(md), "**标题**\n- a · b\n- 1 · 2");
}
/// 出站净化:代码围栏内外均保留原样,围栏外的标题照常改写
#[test]
fn lark_friendly_preserves_code_fence() {
let md = "前文\n```\n# 不是标题\n| 不 | 是表格 |\n```\n## 后文";
assert_eq!(
md_to_lark_friendly(md),
"前文\n```\n# 不是标题\n| 不 | 是表格 |\n```\n**后文**"
);
}
/// 出站净化:普通行原样透传(lark_md 原生支持粗体/链接,不额外转义)
#[test]
fn lark_friendly_passes_plain_lines() {
let md = "**粗** 与 [链接](http://e)\n普通行";
assert_eq!(md_to_lark_friendly(md), md);
}
}
+330
View File
@@ -0,0 +1,330 @@
//! df-im-gateway 主入口:装配 config + store + throttle + commander + adapters
//!
//! 骨架版(P0):Web 适配器真实可跑,事件流全链路打通。
//! 用法:
//! ```text
//! df-im-gateway.exe # 默认配置(env)
//! GATEWAY_RELAY_URL=... GATEWAY_RELAY_TOKEN=... \
//! GATEWAY_DEFAULT_CHANNEL=chan-work df-im-gateway.exe
//! ```
mod adapters;
mod audit;
mod commander;
mod config;
mod feishu;
mod store;
mod throttle;
use std::path::PathBuf;
use tokio::sync::mpsc;
use adapters::{InboundEvent, OutboundAction, PlatformAdapter};
use config::GatewayConfig;
use store::SessionStore;
use throttle::DeltaThrottle;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let cfg = GatewayConfig::load()?;
if cfg.channels.is_empty() {
anyhow::bail!("未配置任何 channel 路由(GATEWAY_DEFAULT_CHANNEL),无法启动");
}
let data_dir = std::env::var("GATEWAY_DATA_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("data"));
std::fs::create_dir_all(&data_dir)?;
let store = SessionStore::open(&data_dir.join("sessions.db"))?;
// 审计日志(P5):IM 消息 ↔ TunnelCommand 全量记录,写失败不阻断主链路
let audit = audit::AuditLog::open(&data_dir.join("audit.db"))?;
info_banner(&cfg);
// ── 核心事件总线:适配器 → 核心 ─────────────────────────────
let (inbound_tx, mut inbound_rx) = mpsc::channel::<InboundEvent>(256);
// Web 适配器(四端首个,P0)
let web_adapter = adapters::web::WebAdapter::new();
{
let web_run = web_adapter.clone();
let ev_tx = inbound_tx.clone();
tokio::spawn(async move {
if let Err(e) = web_run.run(ev_tx).await {
tracing::error!("[web] 适配器退出: {e}");
}
});
}
// 飞书适配器(P2):FEISHU_APP_ID/SECRET 配置时启用长连接
let feishu_adapter = cfg.feishu.as_ref().map(|f| match feishu::FeishuAdapter::new(f) {
Ok(adapter) => {
let feishu_run = adapter.clone();
let ev_tx = inbound_tx.clone();
tokio::spawn(async move {
if let Err(e) = feishu_run.run(ev_tx).await {
tracing::error!("[feishu] 适配器退出: {e}");
}
});
tracing::info!("[feishu] 适配器已启动(长连接)");
adapter
}
Err(e) => {
tracing::error!("[feishu] 适配器构造失败(跳过): {e}");
feishu::FeishuAdapter::offline()
}
});
// ── Commander 连接(u-relay)────────────────────────────────
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let default_channel = cfg
.channels
.iter()
.find(|c| c.prefix.is_empty())
.map(|c| c.channel_id.clone())
.unwrap_or_else(|| cfg.channels[0].channel_id.clone());
// 事件流:commander 任务 → 核心循环
let (ev_tx, mut ev_rx) = mpsc::channel::<df_tunnel::events::TunnelEvent>(256);
// 指令流:核心循环 → commander 任务(透传到 WS)
let (cmd_tx, cmd_rx) = mpsc::channel::<df_tunnel::TunnelCommand>(256);
{
let relay_url = cfg.relay_url.clone();
let token = cfg.relay_token.clone();
let channel = default_channel.clone();
tokio::spawn(async move {
if let Err(e) = commander::run_commander(relay_url, channel, token, ev_tx, cmd_rx, shutdown_rx).await {
tracing::error!("[commander] 连接循环退出: {e}");
}
});
}
// ── 核心分发循环(单 select,持有全部状态,无跨线程共享)─────
let mut throttle = DeltaThrottle::new(cfg.throttle_ms);
// 审批幂等表:approval_id → 已处理标记(P0 必验项 ②:防 pending→approved 二次点击重复转发)
let mut handled_approvals: std::collections::HashSet<String> = std::collections::HashSet::new();
// 平台注册表(走查 #4):显式 platform → 下标映射,替代硬编码 "feishu"=>1 与
// adapters vec 构建顺序的隐式耦合。新增平台(企微/钉钉)只在此处注册一次。
let mut adapters: Vec<Box<dyn adapters::PlatformAdapter>> = vec![Box::new(web_adapter.clone())];
let mut platform_index: std::collections::HashMap<String, usize> =
std::collections::HashMap::from([("web".to_string(), 0usize)]);
if let Some(fs) = feishu_adapter {
platform_index.insert("feishu".to_string(), adapters.len());
adapters.push(Box::new(fs));
}
let platform_of = |platform_or_conv: &str| -> usize {
// conv_id 形如 "platform:chat_id",前缀即平台;未知平台回退 web(0)
let platform = platform_or_conv.split(':').next().unwrap_or("web");
*platform_index.get(platform).unwrap_or(&0)
};
// 停止信号
let shutdown_ctrl = tokio::signal::ctrl_c();
tokio::pin!(shutdown_ctrl);
let cfg_loop = cfg.clone();
loop {
tokio::select! {
_ = &mut shutdown_ctrl => {
tracing::info!("收到 Ctrl+C,优雅退出");
let _ = shutdown_tx.send(true);
break;
}
// ── TunnelEvent(桌面端 → 网关)──
ev = ev_rx.recv() => {
let Some(ev) = ev else { break };
match ev {
df_tunnel::TunnelEvent::TextDelta { conv_id, delta } => {
// 出站按 conv_id 前缀(platform:chat_id)路由回原平台
let flush = throttle.feed(&conv_id, &delta);
if let Some(text) = flush {
let idx = platform_of(&conv_id);
let _ = adapters[idx]
.dispatch(OutboundAction::StreamText { chat_id: conv_id, text, done: false })
.await;
}
}
df_tunnel::TunnelEvent::Completed { conv_id, .. } => {
let tail = throttle.finish(&conv_id);
let full = tail.unwrap_or_default();
let idx = platform_of(&conv_id);
audit.record(audit::AuditEntry {
direction: audit::Direction::Outbound,
platform: String::new(), chat_id: conv_id.clone(),
user_id: String::new(), conv_id: conv_id.clone(),
action: "completed".into(), request_id: String::new(),
payload: full.clone(), outcome: "ok".into(),
});
let _ = adapters[idx]
.dispatch(OutboundAction::StreamText { chat_id: conv_id, text: full, done: true })
.await;
}
df_tunnel::TunnelEvent::Approval { conv_id, approval_id, description } => {
audit.record(audit::AuditEntry {
direction: audit::Direction::Outbound,
platform: String::new(), chat_id: conv_id.clone(),
user_id: String::new(), conv_id: conv_id.clone(),
action: "approval_card".into(), request_id: approval_id.clone(),
payload: description.clone(), outcome: "ok".into(),
});
let idx = platform_of(&conv_id);
let _ = adapters[idx]
.dispatch(OutboundAction::ApprovalCard {
chat_id: conv_id,
approval_id,
title: "工具审批请求".into(),
detail: description,
})
.await;
}
df_tunnel::TunnelEvent::ToolCall { .. } => {
// P0 骨架不转发 ToolCall 到 IM(卡片噪音),仅日志
tracing::debug!("[core] ToolCall 事件(骨架忽略)");
}
}
}
// ── InboundEvent(IM → 网关)──
inbound = inbound_rx.recv() => {
let Some(ev) = inbound else { break };
match ev {
InboundEvent::Message { platform, chat_id, user_id, text } => {
// 回复适配器:按来源平台回路由
let reply_idx = platform_of(&platform);
if !cfg_loop.whitelist.is_empty() && !cfg_loop.whitelist.contains(&user_id) {
audit.record(audit::AuditEntry {
direction: audit::Direction::Inbound,
platform: platform.clone(), chat_id: chat_id.clone(),
user_id: user_id.clone(), conv_id: String::new(),
action: "message".into(), request_id: String::new(),
payload: text.clone(), outcome: "denied".into(),
});
let _ = adapters[reply_idx]
.dispatch(OutboundAction::Notice { chat_id, text: "⛔ 你不在白名单内".into() })
.await;
continue;
}
let Some(channel_id) = cfg_loop.route(&text) else {
audit.record(audit::AuditEntry {
direction: audit::Direction::Inbound,
platform: platform.clone(), chat_id: chat_id.clone(),
user_id: user_id.clone(), conv_id: String::new(),
action: "message".into(), request_id: String::new(),
payload: text.clone(), outcome: "error".into(),
});
let _ = adapters[reply_idx]
.dispatch(OutboundAction::Notice { chat_id, text: "⚠️ 无可用 channel".into() })
.await;
continue;
};
// 会话映射:无则新建(conv_id = platform:chat_id,Web 端 chat_id 同源)
let conv_id = match store.get_conv(&platform, &chat_id) {
Some(m) => m.conv_id,
None => {
let conv = format!("{platform}:{chat_id}");
let _ = store.set_conv(&platform, &chat_id, &conv, channel_id);
// 走查 #3:新建会话时清理流式残留(上轮异常中止无
// Completed 事件,streams entry 永驻会错覆写旧消息)
adapters[reply_idx].reset_stream(&chat_id).await;
audit.record(audit::AuditEntry {
direction: audit::Direction::Inbound,
platform: platform.clone(), chat_id: chat_id.clone(),
user_id: user_id.clone(), conv_id: conv.clone(),
action: "new_session".into(), request_id: String::new(),
payload: String::new(), outcome: "ok".into(),
});
conv
}
};
// 入站消息审计:request_id 用 conv+毫秒派生,出站 Completed 事件无法
// 回带,此处以 (direction=inbound,message) 为链头,command/outbound
// 侧用同一 conv_id 关联(recent 按时间倒序即还原会话时间线)。
audit.record(audit::AuditEntry {
direction: audit::Direction::Inbound,
platform: platform.clone(), chat_id: chat_id.clone(),
user_id: user_id.clone(), conv_id: conv_id.clone(),
action: "message".into(), request_id: String::new(),
payload: text.clone(), outcome: "ok".into(),
});
audit.record(audit::AuditEntry {
direction: audit::Direction::Command,
platform: platform.clone(), chat_id: chat_id.clone(),
user_id: user_id.clone(), conv_id: conv_id.clone(),
action: "send_cmd".into(), request_id: String::new(),
payload: text.clone(), outcome: "ok".into(),
});
let _ = cmd_tx
.send(df_tunnel::TunnelCommand::Send { conv_id, content: text })
.await;
}
InboundEvent::NewSession { platform, chat_id } => {
audit.record(audit::AuditEntry {
direction: audit::Direction::Inbound,
platform: platform.clone(), chat_id: chat_id.clone(),
user_id: String::new(), conv_id: String::new(),
action: "new_session_reset".into(), request_id: String::new(),
payload: String::new(), outcome: "ok".into(),
});
let _ = store.remove(&platform, &chat_id);
let reply_idx = platform_of(&platform);
let _ = adapters[reply_idx]
.dispatch(OutboundAction::Notice { chat_id, text: "🔄 已重置会话".into() })
.await;
}
InboundEvent::Approval { platform, chat_id, approval_id, approved } => {
// 幂等:同 approval_id 只处理首次(P0 必验项 ②)
if handled_approvals.contains(&approval_id) {
tracing::debug!("[core] 审批 {approval_id} 已处理,忽略重复点击");
continue;
}
handled_approvals.insert(approval_id.clone());
let conv_id = store
.get_conv(&platform, &chat_id)
.map(|m| m.conv_id)
.unwrap_or_else(|| format!("{platform}:{chat_id}"));
audit.record(audit::AuditEntry {
direction: audit::Direction::Inbound,
platform: platform.clone(), chat_id: chat_id.clone(),
user_id: String::new(), conv_id: conv_id.clone(),
action: "approval_click".into(), request_id: approval_id.clone(),
payload: if approved { "approved".into() } else { "rejected".into() },
outcome: "ok".into(),
}); audit.record(audit::AuditEntry {
direction: audit::Direction::Command,
platform, chat_id, user_id: String::new(), conv_id: conv_id.clone(),
action: "approve_cmd".into(), request_id: approval_id.clone(), payload: if approved { "approved".into() } else { "rejected".into() },
outcome: "ok".into(),
});
let _ = cmd_tx
.send(df_tunnel::TunnelCommand::Approve { conv_id, approval_id, accepted: approved })
.await;
}
}
}
}
}
Ok(())
}
fn info_banner(cfg: &GatewayConfig) {
println!("╔══════════════════════════════════════════╗");
println!("║ df-im-gateway v0.1.0 (P0 骨架) ║");
println!("╚══════════════════════════════════════════╝");
println!(" relay : {}", cfg.relay_url);
println!(" channels : {} 条路由", cfg.channels.len());
println!(" throttle : {}ms", cfg.throttle_ms);
println!(" whitelist : {} 用户", cfg.whitelist.len());
if cfg.whitelist.is_empty() {
println!(" ⚠️ 白名单为空:任何人可用本网关(开发默认)。生产部署请设置 GATEWAY_WHITELIST!");
}
println!(" feishu : {}", if cfg.feishu.is_some() { "已配置" } else { "未配置(跳过)" });
println!(" web ws : 0.0.0.0:9191");
}
+151
View File
@@ -0,0 +1,151 @@
//! 会话映射存储(IM chat_id ↔ DevFlow conv_id,SQLite + WAL)
//!
//! 建表即带 owner_user_id/platform 列(P0 预留项 ⑤,当前恒默认值,
//! S2 多人场景免迁表)。
//!
//! 表结构:
//! ```sql
//! CREATE TABLE IF NOT EXISTS session_map (
//! platform TEXT NOT NULL, -- feishu/wecom/dingtalk/web
//! chat_id TEXT NOT NULL, -- IM 侧会话(单聊 user_id / 群聊 chat_id)
//! conv_id TEXT NOT NULL, -- DevFlow 会话 ID
//! owner_user_id TEXT NOT NULL DEFAULT '',-- 预留 S2 多人归属(当前恒 '')
//! channel_id TEXT NOT NULL DEFAULT '',-- 路由到的桌面端 channel(预留项 ⑥)
//! created_at INTEGER NOT NULL,
//! updated_at INTEGER NOT NULL,
//! PRIMARY KEY (platform, chat_id)
//! );
//! ```
use std::path::Path;
use std::sync::Arc;
use rusqlite::Connection;
/// 单条会话映射(字段预留 S2 多人/多桌面端,当前核心只读 conv_id)
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct SessionMap {
pub platform: String,
pub chat_id: String,
pub conv_id: String,
pub owner_user_id: String,
pub channel_id: String,
}
#[derive(Clone)]
pub struct SessionStore {
conn: Arc<std::sync::Mutex<Connection>>,
}
impl SessionStore {
/// 打开(或创建)库文件并确保表存在。WAL 模式(与桌面端一致)。
pub fn open(path: &Path) -> anyhow::Result<Self> {
let conn = Connection::open(path)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS session_map (
platform TEXT NOT NULL,
chat_id TEXT NOT NULL,
conv_id TEXT NOT NULL,
owner_user_id TEXT NOT NULL DEFAULT '',
channel_id TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (platform, chat_id)
);",
)?;
Ok(Self { conn: Arc::new(std::sync::Mutex::new(conn)) })
}
/// 内存库(测试/演示)
#[allow(dead_code)]
pub fn open_in_memory() -> anyhow::Result<Self> {
let conn = Connection::open_in_memory()?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS session_map (
platform TEXT NOT NULL, chat_id TEXT NOT NULL, conv_id TEXT NOT NULL,
owner_user_id TEXT NOT NULL DEFAULT '', channel_id TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
PRIMARY KEY (platform, chat_id)
);",
)?;
Ok(Self { conn: Arc::new(std::sync::Mutex::new(conn)) })
}
/// 查 IM 会话映射的 conv_id(未映射返回 None)
pub fn get_conv(&self, platform: &str, chat_id: &str) -> Option<SessionMap> {
let conn = self.conn.lock().ok()?;
conn.query_row(
"SELECT platform, chat_id, conv_id, owner_user_id, channel_id FROM session_map \
WHERE platform = ?1 AND chat_id = ?2",
[platform, chat_id],
|row| {
Ok(SessionMap {
platform: row.get(0)?,
chat_id: row.get(1)?,
conv_id: row.get(2)?,
owner_user_id: row.get(3)?,
channel_id: row.get(4)?,
})
},
)
.ok()
}
/// 写/更新映射(upsert)
pub fn set_conv(&self, platform: &str, chat_id: &str, conv_id: &str, channel_id: &str) -> anyhow::Result<()> {
let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("store 锁中毒: {e}"))?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
conn.execute(
"INSERT INTO session_map (platform, chat_id, conv_id, owner_user_id, channel_id, created_at, updated_at) \
VALUES (?1, ?2, ?3, '', ?4, ?5, ?5) \
ON CONFLICT(platform, chat_id) DO UPDATE SET conv_id = ?3, channel_id = ?4, updated_at = ?5",
rusqlite::params![platform, chat_id, conv_id, channel_id, now],
)?;
Ok(())
}
/// 删除映射(桌面端会话已删,悬空清理)
pub fn remove(&self, platform: &str, chat_id: &str) -> anyhow::Result<()> {
let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("store 锁中毒: {e}"))?;
conn.execute("DELETE FROM session_map WHERE platform = ?1 AND chat_id = ?2", [platform, chat_id])?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn upsert_and_get() {
let store = SessionStore::open_in_memory().unwrap();
store.set_conv("feishu", "oc_123", "conv_abc", "chan-work").unwrap();
let m = store.get_conv("feishu", "oc_123").unwrap();
assert_eq!(m.conv_id, "conv_abc");
assert_eq!(m.channel_id, "chan-work");
assert_eq!(m.owner_user_id, "");
// upsert 覆盖
store.set_conv("feishu", "oc_123", "conv_new", "chan-home").unwrap();
let m = store.get_conv("feishu", "oc_123").unwrap();
assert_eq!(m.conv_id, "conv_new");
// 删除后悬空
store.remove("feishu", "oc_123").unwrap();
assert!(store.get_conv("feishu", "oc_123").is_none());
}
#[test]
fn platform_isolated() {
let store = SessionStore::open_in_memory().unwrap();
store.set_conv("feishu", "c1", "conv1", "ch").unwrap();
store.set_conv("wecom", "c1", "conv2", "ch").unwrap();
assert_eq!(store.get_conv("feishu", "c1").unwrap().conv_id, "conv1");
assert_eq!(store.get_conv("wecom", "c1").unwrap().conv_id, "conv2");
}
}
+103
View File
@@ -0,0 +1,103 @@
//! TextDelta 节流聚合器(IM 频控防护)
//!
//! IM 平台消息 API 频控(飞书 ~50 QPS 应用级、企微 200 次/分钟),逐 delta 刷卡片必被限流。
//! 策略:per-conv 聚合缓冲 + 时间窗(默认 800ms)批量 flush;Completed 时强制冲刷尾包。
//!
//! 设计要点:
//! - 每个 key 一个缓冲,互不干扰
//! - 纯函数式:feed() 返回 Option<全量文本>(窗口到期才返回),不持有回调/不 await
//! - flush 语义:全量文本(IM 卡片更新是「全量内容」语义,非增量)
//! - finish() 冲刷尾包并清缓冲;abort() 丢弃缓冲(错误/停止,不输出残文)
use std::collections::HashMap;
use std::time::{Duration, Instant};
/// 单 key 缓冲
struct ConvBuffer {
/// 累积全量文本
full_text: String,
/// 上次 flush 时刻
last_flush: Instant,
}
/// 节流聚合器
pub struct DeltaThrottle {
window: Duration,
buffers: HashMap<String, ConvBuffer>,
}
impl DeltaThrottle {
pub fn new(window_ms: u64) -> Self {
Self { window: Duration::from_millis(window_ms), buffers: HashMap::new() }
}
/// 喂入 delta。窗口到期则返回 Some(全量文本)(调用方负责外发),否则 None。
pub fn feed(&mut self, key: &str, delta: &str) -> Option<String> {
let now = Instant::now();
let buf = self.buffers.entry(key.to_string()).or_insert_with(|| ConvBuffer {
full_text: String::new(),
last_flush: now,
});
buf.full_text.push_str(delta);
if buf.last_flush.elapsed() >= self.window {
buf.last_flush = now;
Some(buf.full_text.clone())
} else {
None
}
}
/// 会话完成:强制冲刷尾包 + 清缓冲。返回 Some(全量文本) 或(空会话)None。
pub fn finish(&mut self, key: &str) -> Option<String> {
self.buffers.remove(key).map(|buf| buf.full_text)
}
/// 丢弃缓冲(错误/停止,不输出残文)
///
/// 注:当前 TunnelEvent 无 Error/Stopped 变体,核心循环暂无调用点;
/// df-tunnel Phase2 扩展后接入。
#[allow(dead_code)]
pub fn abort(&mut self, key: &str) {
self.buffers.remove(key);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn throttle_window_batch() {
let mut t = DeltaThrottle::new(60_000); // 60s 窗口:模拟窗口永不到期
assert_eq!(t.feed("k", "你"), None);
assert_eq!(t.feed("k", "好"), None);
assert_eq!(t.feed("k", "!"), None);
assert_eq!(t.finish("k"), Some("你好!".to_string()), "finish 冲刷全量");
}
#[test]
fn window_elapsed_flushes() {
let mut t = DeltaThrottle::new(0); // 0ms 窗口:每次 feed 都到期
assert_eq!(t.feed("k", "a"), Some("a".to_string()));
assert_eq!(t.feed("k", "b"), Some("ab".to_string()));
// 缓冲仍在 finish 前的最后 flush 版本;finish 拿走剩余
assert_eq!(t.finish("k"), Some("ab".to_string()));
}
#[test]
fn abort_drops() {
let mut t = DeltaThrottle::new(60_000);
t.feed("k", "残文");
t.abort("k");
assert_eq!(t.finish("k"), None, "abort 后缓冲不存在");
}
#[test]
fn keys_isolated() {
let mut t = DeltaThrottle::new(60_000);
t.feed("a", "1");
t.feed("b", "2");
assert_eq!(t.finish("a"), Some("1".to_string()));
assert_eq!(t.finish("b"), Some("2".to_string()));
}
}
+398
View File
@@ -0,0 +1,398 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>DevFlow AI Chat</title>
<style>
:root {
--bg: #0f1115; --panel: #171a21; --panel2: #1d212b; --border: #2a2f3a;
--text: #e6e9ef; --muted: #8a93a6; --accent: #4f8cff; --accent2: #3a6fd8;
--ok: #3fb96f; --warn: #e5a03c; --err: #e05252; --radius: 10px;
}
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { height: 100%; }
body {
background: var(--bg); color: var(--text);
font: 14px/1.6 -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
display: flex; overflow: hidden;
}
/* ── 侧栏:会话列表 ── */
#sidebar {
width: 220px; min-width: 220px; background: var(--panel);
border-right: 1px solid var(--border); display: flex; flex-direction: column;
}
#sidebar header { padding: 14px 14px 8px; font-weight: 600; font-size: 13px; color: var(--muted); letter-spacing: .05em; }
#conv-list { flex: 1; overflow-y: auto; padding: 4px 8px; }
.conv-item {
padding: 8px 10px; border-radius: 8px; cursor: pointer; margin-bottom: 2px;
display: flex; align-items: center; gap: 8px; color: var(--muted);
}
.conv-item:hover { background: var(--panel2); }
.conv-item.active { background: var(--panel2); color: var(--text); }
.conv-item .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex: none; }
.conv-item .name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
.conv-item .del { opacity: 0; color: var(--muted); font-size: 12px; padding: 0 2px; }
.conv-item:hover .del { opacity: 1; }
.conv-item .del:hover { color: var(--err); }
#sidebar footer { padding: 10px; border-top: 1px solid var(--border); display: flex; gap: 8px; }
/* ── 主区 ── */
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
#topbar {
padding: 10px 16px; border-bottom: 1px solid var(--border); background: var(--panel);
display: flex; align-items: center; gap: 10px; font-size: 13px;
}
#conn-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--err); flex: none; }
#conn-dot.on { background: var(--ok); }
#conn-label { color: var(--muted); }
#topbar .spacer { flex: 1; }
#topbar button {
background: var(--panel2); color: var(--text); border: 1px solid var(--border);
border-radius: 6px; padding: 4px 10px; cursor: pointer; font-size: 12px;
}
#topbar button:hover { border-color: var(--accent); }
#msgs { flex: 1; overflow-y: auto; padding: 18px 16px; scroll-behavior: smooth; }
.msg { margin-bottom: 14px; display: flex; }
.msg.user { justify-content: flex-end; }
.msg .bubble {
max-width: 78%; padding: 9px 13px; border-radius: var(--radius);
background: var(--panel2); white-space: pre-wrap; word-break: break-word;
}
.msg.user .bubble { background: var(--accent2); }
.msg.ai .bubble { background: var(--panel2); }
.msg .bubble.sys { background: transparent; color: var(--muted); font-size: 12px; max-width: 100%; }
.approval-card {
border: 1px solid var(--warn); border-radius: var(--radius); padding: 10px 13px;
max-width: 78%; background: rgba(229,160,60,.08);
}
.approval-card .t { color: var(--warn); font-weight: 600; font-size: 13px; margin-bottom: 4px; }
.approval-card .d { color: var(--muted); font-size: 12px; margin-bottom: 9px; white-space: pre-wrap; word-break: break-word; }
.approval-card .row { display: flex; gap: 8px; }
.approval-card button {
flex: 1; padding: 6px 0; border-radius: 6px; border: none; cursor: pointer; font-size: 13px;
}
.approval-card .yes { background: var(--ok); color: #fff; }
.approval-card .no { background: var(--panel2); color: var(--muted); border: 1px solid var(--border); }
.approval-card.answered { opacity: .55; }
.approval-card.answered .row { display: none; }
.approval-card .verdict { font-size: 12px; color: var(--muted); }
/* ── 输入区 ── */
#inputbar { display: flex; gap: 8px; padding: 12px 14px; border-top: 1px solid var(--border); background: var(--panel); }
#inp {
flex: 1; background: var(--panel2); border: 1px solid var(--border); color: var(--text);
border-radius: 8px; padding: 9px 12px; font: inherit; resize: none; outline: none; height: 44px;
}
#inp:focus { border-color: var(--accent); }
#send, #stop {
border: none; border-radius: 8px; padding: 0 18px; cursor: pointer; font: inherit; font-size: 13px;
}
#send { background: var(--accent); color: #fff; }
#send:disabled { opacity: .45; cursor: not-allowed; }
#stop { background: var(--panel2); color: var(--err); border: 1px solid var(--border); display: none; }
@media (max-width: 640px) {
#sidebar { position: fixed; z-index: 10; height: 100%; transform: translateX(-100%); transition: transform .2s; }
#sidebar.open { transform: translateX(0); box-shadow: 0 0 30px rgba(0,0,0,.5); }
#menu-btn { display: inline-block !important; }
.msg .bubble, .approval-card { max-width: 92%; }
}
#menu-btn { display: none; background: none; border: none; color: var(--text); font-size: 18px; cursor: pointer; }
/* 简单 markdown 粗体/代码 */
.bubble code { background: rgba(79,140,255,.12); border-radius: 4px; padding: 1px 5px; font-size: 12.5px; }
</style>
</head>
<body>
<nav id="sidebar">
<header>会话</header>
<div id="conv-list"></div>
<footer><button id="new-conv" style="flex:1;background:var(--panel2);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px;cursor:pointer">+ 新会话</button></footer>
</nav>
<div id="main">
<div id="topbar">
<button id="menu-btn">☰</button>
<span id="conn-dot"></span><span id="conn-label">连接中…</span>
<span class="spacer"></span>
<button id="clear-btn">🗑 清空当前会话记录</button>
</div>
<div id="msgs"></div>
<div id="inputbar">
<textarea id="inp" placeholder="发消息给 DevFlow AI…(Enter 发送 / Shift+Enter 换行)"></textarea>
<button id="send">发送</button>
<button id="stop">停止</button>
</div>
</div>
<script>
'use strict';
/* ===== 常量与状态 ===== */
var LS_KEY = 'devflow.webchat.v1'; // localStorage: {convs:[{id,name}], activeId}
var state = {
convs: [], // [{id, name, msgs:[{role:'user'|'ai'|'sys', text, ts}], pending:{approval_id, title, detail, el} }]
activeId: null, // 当前会话 chat_id
ws: null, wsReady: false, reconnectTimer: null, closedByUs: false,
generating: false, // 当前会话生成中(流式未完)
};
var $ = function (s) { return document.querySelector(s); };
var msgsEl = $('#msgs'), inpEl = $('#inp'), sendBtn = $('#send'), stopBtn = $('#stop');
/* ===== 本地持久化 ===== */
function loadStore() {
try { state.convs = JSON.parse(localStorage.getItem(LS_KEY) || '[]'); } catch (e) { state.convs = []; }
}
function saveStore() {
try { localStorage.setItem(LS_KEY, JSON.stringify(state.convs)); } catch (e) { /* 配额满忽略 */ }
}
function activeConv() {
return state.convs.find(function (c) { return c.id === state.activeId; }) || null;
}
/* ===== 会话管理 ===== */
function newConvId() { return 'web-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); }
function newConv(activate) {
var id = newConvId();
var c = { id: id, name: '新会话', msgs: [], pending: null };
state.convs.unshift(c);
if (activate) switchConv(id);
saveStore(); renderConvList();
return c;
}
function switchConv(id) {
if (state.activeId === id) { closeSidebar(); return; }
state.activeId = id;
saveStore(); renderConvList(); renderMsgs(); closeSidebar();
// WS 已就绪则重绑到新 chat_id(断开重连,首帧 hello 携带新 id)
if (state.wsReady) reconnect();
}
function deleteConv(id) {
var idx = state.convs.findIndex(function (c) { return c.id === id; });
if (idx < 0) return;
state.convs.splice(idx, 1);
if (state.activeId === id) {
state.activeId = state.convs.length ? state.convs[0].id : null;
if (!state.activeId) newConv(false);
renderMsgs();
if (state.wsReady) reconnect();
}
saveStore(); renderConvList();
}
function newSessionOnServer() {
var c = activeConv();
if (c && state.wsReady) wsSend({ type: 'new_session' });
}
function ensureConv() { return activeConv() || newConv(true); }
/* ===== 渲染 ===== */
function esc(s) {
return String(s).replace(/[&<>"']/g, function (m) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m];
});
}
function fmtText(s) {
// 轻量格式化:转义后 `code` 行内代码
return esc(s).replace(/`([^`\n]+)`/g, '<code>$1</code>');
}
function renderConvList() {
var el = $('#conv-list'); el.innerHTML = '';
state.convs.forEach(function (c) {
var d = document.createElement('div');
d.className = 'conv-item' + (c.id === state.activeId ? ' active' : '');
d.innerHTML = '<span class="dot"></span><span class="name">' + esc(c.name) + '</span><span class="del" title="删除">✕</span>';
d.onclick = function (e) {
if (e.target.classList.contains('del')) {
if (confirm('删除会话「' + c.name + '」?(仅删除本页记录,桌面端对话保留)')) deleteConv(c.id);
return;
}
switchConv(c.id);
};
el.appendChild(d);
});
}
function msgNode(m) {
if (m.role === 'sys') {
var d = document.createElement('div'); d.className = 'msg';
var b = document.createElement('div'); b.className = 'bubble sys'; b.innerHTML = fmtText(m.text);
d.appendChild(b); return d;
}
if (m.role === 'approval') {
var wrap = document.createElement('div'); wrap.className = 'msg';
var card = document.createElement('div'); card.className = 'approval-card';
card.innerHTML = '<div class="t">🛡 ' + esc(m.title || '工具审批请求') + '</div>'
+ '<div class="d">' + fmtText(m.detail || '') + '</div>'
+ '<div class="row"><button class="yes">同意</button><button class="no">拒绝</button></div>';
var done = function (approved) {
card.classList.add('answered');
var v = document.createElement('div'); v.className = 'verdict';
v.textContent = (approved ? '✓ 已同意' : '✕ 已拒绝') + '(未知新帧到达前以此为准)';
card.appendChild(v);
wsSend({ type: 'approval', approval_id: m.approval_id, approved: approved });
};
card.querySelector('.yes').onclick = function () { done(true); };
card.querySelector('.no').onclick = function () { done(false); };
if (m.answered) { card.classList.add('answered'); var v = document.createElement('div'); v.className = 'verdict'; v.textContent = m.answered === 'yes' ? '✓ 已同意' : '✕ 已拒绝'; card.appendChild(v); }
wrap.appendChild(card); return wrap;
}
var d = document.createElement('div'); d.className = 'msg ' + (m.role === 'user' ? 'user' : 'ai');
var b = document.createElement('div'); b.className = 'bubble'; b.innerHTML = fmtText(m.text || '');
d.appendChild(b); return d;
}
function renderMsgs() {
msgsEl.innerHTML = '';
var c = activeConv(); if (!c) return;
c.msgs.forEach(function (m) { msgsEl.appendChild(msgNode(m)); });
scrollBottom();
}
function scrollBottom() { msgsEl.scrollTop = msgsEl.scrollHeight; }
/* ===== WS 连接 ===== */
function wsSend(obj) {
if (state.ws && state.ws.readyState === 1) { state.ws.send(JSON.stringify(obj)); return true; }
return false;
}
function connect() {
clearTimeout(state.reconnectTimer);
var c = ensureConv();
state.closedByUs = false;
var proto = location.protocol === 'https:' ? 'wss' : 'ws';
var ws;
try { ws = new WebSocket(proto + '://' + location.host + '/ws'); } catch (e) { scheduleReconnect(); return; }
state.ws = ws;
ws.onopen = function () {
ws.send(JSON.stringify({ type: 'hello', chat_id: state.activeId, user_id: 'web-user' }));
setConn(true);
};
ws.onmessage = function (e) {
var v; try { v = JSON.parse(e.data); } catch (err) { return; }
handleFrame(v);
};
ws.onclose = function () { setConn(false); if (!state.closedByUs) scheduleReconnect(); };
ws.onerror = function () { try { ws.close(); } catch (err2) {} };
}
function reconnect() {
state.closedByUs = true;
try { state.ws && state.ws.close(); } catch (e) {}
setConn(false);
setTimeout(connect, 60);
}
function scheduleReconnect() {
setConn(false);
clearTimeout(state.reconnectTimer);
state.reconnectTimer = setTimeout(connect, 2500);
}
function setConn(on) {
state.wsReady = on;
$('#conn-dot').className = on ? 'on' : '';
$('#conn-label').textContent = on ? '已连接' : '重连中…(桌面端或网关未在线)';
}
/* ===== 帧处理(网关 → 浏览器) ===== */
function handleFrame(v) {
var c = activeConv(); if (!c) return;
switch (v.type) {
case 'stream_delta': {
var m = lastAiStreaming(c);
if (!m) { m = { role: 'ai', text: '' }; c.msgs.push(m); setGenerating(true); }
m.text = v.text || ''; // 网关推的是全量聚合文本,直接覆盖
appendLive(m);
break;
}
case 'stream_done': {
var m2 = lastAiStreaming(c);
if (m2) { m2.text = v.text || m2.text || ''; m2.done = true; }
else { c.msgs.push({ role: 'ai', text: v.text || '', done: true }); }
setGenerating(false);
renderMsgs();
break;
}
case 'approval': {
var ap = { role: 'approval', approval_id: v.approval_id, title: v.title, detail: v.detail };
c.msgs.push(ap); setGenerating(true); // 等审批期间视为生成中(停止按钮可用)
renderMsgs();
break;
}
case 'notice': {
c.msgs.push({ role: 'sys', text: v.text || '' });
renderMsgs();
break;
}
}
saveStore();
}
function lastAiStreaming(c) {
for (var i = c.msgs.length - 1; i >= 0; i--) {
var m = c.msgs[i];
if (m.role === 'ai' && !m.done) return m;
if (m.role === 'ai') return null; // 最新 ai 消息已完成 → 开新条
}
return null;
}
var liveEl = null;
function appendLive(m) {
if (!liveEl || !liveEl._m) {
// 首个 delta:占位气泡(增量期间只更新最后一条,不全量重渲染,防抖动)
msgsEl.appendChild(msgNode(m));
liveEl = msgsEl.lastChild;
liveEl._m = m;
}
liveEl.lastChild.innerHTML = fmtText(m.text);
scrollBottom();
}
/* ===== 生成态(发送/停止按钮) ===== */
function setGenerating(on) {
state.generating = on;
sendBtn.style.display = on ? 'none' : '';
stopBtn.style.display = on ? '' : 'none';
sendBtn.disabled = on;
}
/* ===== 发送/停止 ===== */
function doSend() {
var text = inpEl.value.trim(); if (!text) return;
var c = ensureConv();
if (c.msgs.length === 0) { c.name = text.slice(0, 18); renderConvList(); } // 首条消息作会话名
c.msgs.push({ role: 'user', text: text });
saveStore(); renderMsgs();
inpEl.value = ''; inpEl.style.height = 'auto';
if (!wsSend({ type: 'message', text: text })) {
c.msgs.push({ role: 'sys', text: '⚠ 未连接,消息未发出(连接恢复后请重发)' }); renderMsgs();
}
}
sendBtn.onclick = doSend;
stopBtn.onclick = function () {
var c = activeConv();
// 本地止血:标记完成 + 提示(协议无 Stop 指令,桌面端继续跑完本轮,网关会继续推帧,本页忽略)
if (c) {
var m = lastAiStreaming(c);
if (m) m.done = true;
c.msgs.push({ role: 'sys', text: '■ 已停止显示(桌面端本轮可能仍在执行,结果以桌面端为准)' });
saveStore(); renderMsgs();
}
setGenerating(false);
};
/* ===== 输入框 ===== */
inpEl.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); doSend(); }
});
inpEl.addEventListener('input', function () {
inpEl.style.height = 'auto';
inpEl.style.height = Math.min(inpEl.scrollHeight, 120) + 'px';
});
/* ===== 顶栏按钮 ===== */
$('#new-conv').onclick = function () { newConv(true); };
$('#clear-btn').onclick = function () {
var c = activeConv(); if (!c) return;
if (!confirm('清空当前会话的页面记录?(不删桌面端对话)')) return;
c.msgs = []; saveStore(); renderMsgs();
};
$('#menu-btn').onclick = function () { $('#sidebar').classList.toggle('open'); };
function closeSidebar() { $('#sidebar').classList.remove('open'); }
/* ===== 启动 ===== */
loadStore();
if (!state.convs.length) newConv(false);
if (!state.activeId) state.activeId = state.convs[0].id;
renderConvList(); renderMsgs();
connect();
</script>
</body>
</html>
+7
View File
@@ -11,6 +11,9 @@ df-types = { path = "../df-types" }
# 含 is_valid_state+can_transition+同态拒绝三层校验),避免 MCP 直调底层 # 含 is_valid_state+can_transition+同态拒绝三层校验),避免 MCP 直调底层
# advance_status_atomic 绕过状态机(防止外部客户端非法跳态 todo→done)。 # advance_status_atomic 绕过状态机(防止外部客户端非法跳态 todo→done)。
df-nodes = { path = "../df-nodes" } df-nodes = { path = "../df-nodes" }
# df-project: bind_directory 复用技术栈探测(detect_stack)与路径规范化(normalize_path),
# 对齐 GUI tool_registry.rs::bind_dir_to_project,避免在本 crate 重复实现镜像。
df-project = { path = "../df-project" }
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
tokio.workspace = true tokio.workspace = true
@@ -18,6 +21,10 @@ anyhow.workspace = true
tracing.workspace = true tracing.workspace = true
uuid.workspace = true uuid.workspace = true
futures = "0.3" futures = "0.3"
# HTTP (streamable HTTP) transport 层:axum Router(workspace 已锁 0.7.9,零新依赖树)
axum = { version = "0.7" }
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true, features = ["full", "test-util"] } tokio = { workspace = true, features = ["full", "test-util"] }
# 单测用 tower::ServiceExt::oneshot 直接打 Router
tower = { version = "0.5", features = ["util"] }
+184
View File
@@ -0,0 +1,184 @@
// df-mcp E2E 实测脚本:spawn 真实二进制,按行喂 JSON-RPC,断言修复项 + 全链路。
// 用法: node df-mcp-e2e.mjs <exe路径> [--db <临时库>]
import { spawn } from 'node:child_process';
import { createInterface } from 'node:readline';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const exe = process.argv[2];
if (!exe) { console.error('用法: node df-mcp-e2e.mjs <exe路径>'); process.exit(1); }
const dbArg = process.argv[4] ?? join(mkdtempSync(join(tmpdir(), 'dfmcp-')), 'test.db');
let pass = 0, fail = 0;
const failures = [];
function assert(cond, name, extra) {
if (cond) { pass++; console.log(` ✅ ${name}`); }
else { fail++; failures.push(name); console.log(` ❌ ${name}${extra ? ` — ${extra}` : ''}`); }
}
function session(dbPath, readOnly = false) {
const args = ['mcp-server', '--db', dbPath];
if (readOnly) args.push('--read-only');
const child = spawn(exe, args, { stdio: ['pipe', 'pipe', 'pipe'] });
const rl = createInterface({ input: child.stdout });
const pending = [];
const waiters = [];
let buf = '';
rl.on('line', (line) => {
const resolved = waiters.shift();
if (resolved) resolved(line);
});
child.stderr.on('data', (d) => process.stderr.write(` [server] ${d}`));
let idSeq = 0;
return {
async call(req) {
const payload = { jsonrpc: '2.0', id: ++idSeq, ...req };
const p = new Promise((res) => waiters.push(res));
child.stdin.write(JSON.stringify(payload) + '\n');
const line = await p;
return JSON.parse(line);
},
async raw(line) {
const p = new Promise((res) => waiters.push(res));
child.stdin.write(line + '\n');
const out = await p;
return JSON.parse(out);
},
close() { child.stdin.end(); },
};
}
// 从 CallToolResult 取文本
function txt(res) {
try { return res.result.content[0].text; } catch { return JSON.stringify(res); }
}
function json(res) { return JSON.parse(txt(res)); }
async function main() {
console.log(`\n═══ df-mcp E2E 实测 exe=${exe} db=${dbArg} ═══`);
// ── 实例 A:读写模式 ──────────────────────────────
const s = session(dbArg, false);
console.log('\n【1】握手与工具清单');
const init = await s.call({ method: 'initialize', params: {} });
assert(init.result.serverInfo.name === 'devflow-mcp', 'initialize 返回 serverInfo.name', init.result?.serverInfo?.name);
assert(init.result.capabilities.tools.listChanged === false, 'capabilities.tools 声明');
const tl = await s.call({ method: 'tools/list', params: {} });
const names = tl.result.tools.map(t => t.name);
const low = ['list_projects','get_project','list_tasks','list_ideas','evaluate_idea','list_trash'];
const med = ['create_project','update_project','bind_directory','create_task','update_task','advance_task','create_idea','update_idea','score_idea','restore_project'];
const high = ['delete_project','delete_task','delete_idea','run_workflow'];
assert(low.every(n => names.includes(n)), 'Low 工具全部可见');
assert(med.every(n => names.includes(n)), 'Medium 工具全部可见');
assert(!high.some(n => names.includes(n)), 'High 工具默认不可见');
assert(names.length === low.length + med.length, `工具数 = ${low.length + med.length}`, `实际 ${names.length}`);
console.log('\n【2】修复项:parse error 带 id:null(修复#1)');
const perr = await s.raw('{"jsonrpc":"2.0","id":999,"method":"bogus", BAD JSON');
assert(perr.error && perr.error.code === -32700, '非法 JSON → PARSE_ERROR(-32700)');
assert(perr.id === null, 'parse error 响应 id 必须为 null', `实际 ${JSON.stringify(perr.id)}`);
console.log('\n【3】修复项:tools/call 缺 name 明确报错(修复#7)');
const noname = await s.call({ method: 'tools/call', params: { arguments: {} } });
assert(noname.error && noname.error.code === -32601, '缺 name → METHOD_NOT_FOUND');
assert(noname.error.message.includes('name'), '错误消息含 name 提示', noname.error.message);
assert(!noname.error.message.includes('未知工具: '), '不是空名「未知工具: 」', noname.error.message);
console.log('\n【4】未知工具');
const unk = await s.call({ method: 'tools/call', params: { name: 'nope', arguments: {} } });
assert(txt(unk).includes('未知工具'), '未知工具报错', txt(unk));
console.log('\n【5】修复项:update_project 非法状态不再静默降级(修复#3)');
const cp = await s.call({ method: 'tools/call', params: { name: 'create_project', arguments: { name: 'E2E项目', description: '实测' } } });
assert(!cp.result.isError, 'create_project 成功', txt(cp));
const proj = json(cp);
const pid = proj.id;
const badSt = await s.call({ method: 'tools/call', params: { name: 'update_project', arguments: { id: pid, status: 'bogus_state' } } });
assert(badSt.result.isError === true, 'update_project 非法 status 报错');
assert(txt(badSt).includes('非法状态值'), '错误信息含"非法状态值"', txt(badSt));
console.log('\n【6】update_project 部分更新保留原值');
const upd = await s.call({ method: 'tools/call', params: { name: 'update_project', arguments: { id: pid, name: 'E2E项目改' } } });
assert(!upd.result.isError, '部分更新成功');
const updJ = json(upd);
assert(updJ.project.name === 'E2E项目改' && updJ.project.status === 'planning', 'name 更新 + status 保留 planning', `status=${updJ.project.status}`);
console.log('\n【7】create_task 带 parent_id(外部新增能力)');
const cproj = await s.call({ method: 'tools/call', params: { name: 'list_projects', arguments: {} } });
const listJ = json(cproj);
const parentT = await s.call({ method: 'tools/call', params: { name: 'create_task', arguments: { project_id: pid, title: '父任务', description: '' } } });
const parentId = json(parentT).id;
const childT = await s.call({ method: 'tools/call', params: { name: 'create_task', arguments: { project_id: pid, title: '子任务', description: '', parent_id: parentId } } });
assert(!childT.result.isError, '创建子任务成功', txt(childT));
const grandchild = await s.call({ method: 'tools/call', params: { name: 'create_task', arguments: { project_id: pid, title: '孙任务', description: '', parent_id: json(childT).id } } });
assert(grandchild.result.isError === true && txt(grandchild).includes('父任务不能是子任务'), '1 级嵌套限制:孙任务被拒', txt(grandchild));
const badP = await s.call({ method: 'tools/call', params: { name: 'create_task', arguments: { project_id: pid, title: 'x', description: '', parent_id: 'ghost' } } });
assert(badP.result.isError === true && txt(badP).includes('父任务不存在'), '父任务不存在被拒', txt(badP));
console.log('\n【8】advance_task 状态机');
const adv = await s.call({ method: 'tools/call', params: { name: 'advance_task', arguments: { id: parentId, to: 'in_progress' } } });
assert(!adv.result.isError && json(adv).task.status === 'in_progress', 'todo→in_progress 成功');
const badAdv = await s.call({ method: 'tools/call', params: { name: 'advance_task', arguments: { id: parentId, to: 'done' } } });
assert(badAdv.result.isError === true, 'todo→done 非法跳态被拒', txt(badAdv));
console.log('\n【9】idea 闭环:evaluate(Low只读) → score(Medium写库)');
const ci = await s.call({ method: 'tools/call', params: { name: 'create_idea', arguments: { title: '核心功能', description: '需要立即重构关键模块以解除阻塞' } } });
const iid = json(ci).id;
const ev = await s.call({ method: 'tools/call', params: { name: 'evaluate_idea', arguments: { id: iid } } });
assert(!ev.result.isError && json(ev).scores.overall >= 0, 'evaluate_idea 返回分数');
const evJ = json(ev);
assert(evJ.idea.scores == null, 'evaluate_idea 不写库(scores 仍 null)');
const sc = await s.call({ method: 'tools/call', params: { name: 'score_idea', arguments: { id: iid } } });
const scJ = json(sc);
assert(!sc.result.isError && scJ.idea.scores != null, 'score_idea 写库成功');
console.log('\n【10】bind_directory:冲突/穿越/不存在路径');
const bd1 = await s.call({ method: 'tools/call', params: { name: 'bind_directory', arguments: { id: pid, path: 'C:/Temp/nonexist_dir' } } });
assert(!bd1.result.isError, '绑定不存在目录成功(规范化为绝对路径)');
const bdConflict = await s.call({ method: 'tools/call', params: { name: 'create_project', arguments: { name: 'E2E项目2', description: '' } } });
const pid2 = json(bdConflict).id;
const bd2 = await s.call({ method: 'tools/call', params: { name: 'bind_directory', arguments: { id: pid2, path: 'c:/temp/nonexist_dir' } } });
assert(bd2.result.isError === true && txt(bd2).includes('路径已被项目'), '路径冲突检测(大小写不敏感)', txt(bd2));
const bdTraverse = await s.call({ method: 'tools/call', params: { name: 'bind_directory', arguments: { id: pid2, path: 'C:/temp/../Windows' } } });
assert(bdTraverse.result.isError === true && txt(bdTraverse).includes('..'), '.. 段拒绝', txt(bdTraverse));
console.log('\n【11】回收站 list_trash / restore_project');
const lt = await s.call({ method: 'tools/call', params: { name: 'list_trash', arguments: {} } });
assert(!lt.result.isError, 'list_trash 成功');
const rest = await s.call({ method: 'tools/call', params: { name: 'restore_project', arguments: { id: 'ghost' } } });
assert(rest.result.isError === true, '恢复不存在的项目报错', txt(rest));
console.log('\n【12】跨实体校验');
const cross = await s.call({ method: 'tools/call', params: { name: 'update_task', arguments: { id: iid, title: 'x' } } });
assert(cross.result.isError === true && txt(cross).includes('属于 idea'), 'update_task 传 idea id → 跨实体报错', txt(cross));
console.log('\n【13】High 工具即便绕过 tools/list 也被拒');
const del = await s.call({ method: 'tools/call', params: { name: 'delete_project', arguments: { id: pid } } });
assert(del.result.isError === true && txt(del).includes('DevFlow'), 'delete_project 默认拒绝', txt(del));
s.close();
// ── 实例 B:read-only 模式 ─────────────────────────
console.log('\n════ read-only 模式 ════');
const ro = session(dbArg, true);
const roList = await ro.call({ method: 'tools/list', params: {} });
const roNames = roList.result.tools.map(t => t.name);
assert(roNames.length === low.length, `read-only 仅 Low(${low.length} 个)`, `实际 ${roNames.length}`);
assert(roNames.every(n => low.includes(n)), 'read-only 只含 Low 工具');
const roCall = await ro.call({ method: 'tools/call', params: { name: 'create_project', arguments: { name: 'x', description: '' } } });
assert(roCall.result.isError === true && txt(roCall).includes('只读模式'), 'read-only 拒绝 Medium', txt(roCall));
const roEval = await ro.call({ method: 'tools/call', params: { name: 'evaluate_idea', arguments: { id: iid } } });
assert(!roEval.result.isError, 'read-only 放行 evaluate_idea(Low)');
ro.close();
try { await new Promise(r => setTimeout(r, 300)); rmSync(join(dbArg, '..'), { recursive: true, force: true }); } catch {}
console.log(`\n═══ 结果: ${pass} 通过 / ${fail} 失败 ═══`);
if (failures.length) { console.log('失败项:'); failures.forEach(f => console.log(` - ${f}`)); process.exit(1); }
console.log('✅ 全部通过');
}
main().catch(e => { console.error('脚本异常:', e); process.exit(1); });
+2 -1
View File
@@ -14,6 +14,7 @@
pub mod protocol; pub mod protocol;
pub mod server; pub mod server;
pub mod server_http;
pub mod tools; pub mod tools;
pub use server::run_server; pub use server::{run_server, ServerConfig, MCP_DATA_CHANGED_KEY};
+3
View File
@@ -118,6 +118,9 @@ impl McpMethod {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("") .unwrap_or("")
.to_owned(); .to_owned();
if name.is_empty() {
return McpMethod::Unknown("tools/call missing required 'name' parameter".to_owned());
}
let arguments = req.params.get("arguments").cloned().unwrap_or(Value::Null); let arguments = req.params.get("arguments").cloned().unwrap_or(Value::Null);
McpMethod::ToolsCall { name, arguments } McpMethod::ToolsCall { name, arguments }
} }
+454 -26
View File
@@ -8,10 +8,13 @@
//! //!
//! 高风险(High)工具:tools/list 不暴露(从清单剔除),tools/call 即便绕过也由 handler 兜底拒绝。 //! 高风险(High)工具:tools/list 不暴露(从清单剔除),tools/call 即便绕过也由 handler 兜底拒绝。
//! read-only:tools/list 仅留 Low,tools/call Medium/High 一律拒绝。 //! read-only:tools/list 仅留 Low,tools/call Medium/High 一律拒绝。
//! 生命周期:空闲超时(默认 60s 无请求)自动退出,防客户端强杀后进程残留;写操作(risk != Low)可选回调(stdio 预留接线点)。
use std::path::Path; use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
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};
@@ -23,35 +26,76 @@ use crate::protocol::{
use crate::tools::{self, Ctx, RiskLevel}; use crate::tools::{self, Ctx, RiskLevel};
/// 协议版本(MCP 2025-06-18) /// 协议版本(MCP 2025-06-18)
const PROTOCOL_VERSION: &str = "2025-06-18"; pub(crate) const PROTOCOL_VERSION: &str = "2025-06-18";
const SERVER_NAME: &str = "devflow-mcp"; pub(crate) const SERVER_NAME: &str = "devflow-mcp";
const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION"); pub(crate) const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
/// 启动 MCP server。 /// app_settings KV key:stdio MCP 写库后落的变更标记,供 GUI 侧轮询兜底消费。
///
/// 独立进程形态的 stdio MCP 无 AppHandle(`on_write_call=None`),写库后 GUI 无从经
/// `df-data-changed` 事件感知,造成双端数据断层(P0-2)。此标记是跨进程的落库信令:
/// 每次成功写工具覆盖写入,值为 `{"at": <毫秒时间戳>, "tool": "<工具名>"}`。
/// GUI 侧轮询读此 key,`at` 变化即知库被外部改动;再按 `tool` 复用 df-data-changed
/// 的 entity 映射刷新对应列表(entity 映射留在 GUI 侧,避免本 crate 引入 df-ai 依赖)。
pub const MCP_DATA_CHANGED_KEY: &str = "mcp.data_changed";
/// MCP server 运行配置。
///
/// 聚合 stdio 形态的运行参数,避免 `run_server` 参数膨胀。
/// [`Default`] 即常用生产配置(可写 + 空闲超时 60s + 无写回调)。
pub struct ServerConfig {
/// 只读模式:true 则仅暴露 list/get 工具,Medium/High 写操作一律拒绝。
pub read_only: bool,
/// 空闲超时:连续 N 时长无 stdin 输入则自动退出进程(防客户端强杀后孤儿残留)。
/// `None` = 永不因空闲退出(常驻)。默认 60s。
pub idle_timeout: Option<Duration>,
/// 写操作回调:成功执行(未被 read-only/High 拒绝)的写工具(risk != Low)触发。
/// 桌面内嵌形态经 server_http → on_tool_call → emit df-data-changed 刷新 GUI;
/// stdio 独立进程暂无 AppHandle,传 None 预留接线点,后续可注入。传 None 时由
/// 落库变更标记([`MCP_DATA_CHANGED_KEY`])兜底,供 GUI 侧轮询消费。
pub on_write_call: Option<Arc<dyn Fn(&str) + Send + Sync>>,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
read_only: false,
idle_timeout: Some(Duration::from_secs(60)),
on_write_call: None,
}
}
}
/// 启动 MCP server(stdin/stdout 接实际进程句柄)。
/// ///
/// - `db_path`:SQLite 数据库文件路径(应用同库,开 WAL 模式) /// - `db_path`:SQLite 数据库文件路径(应用同库,开 WAL 模式)
/// - `read_only`:true 则仅暴露 list/get 工具 /// - `config`:运行配置(只读/空闲超时/写操作回调),见 [`ServerConfig`]
/// ///
/// 复用 [`Database::open`](df_storage::Database::open)(已含 `PRAGMA journal_mode=WAL`), /// 复用 [`Database::open`](df_storage::Database::open)(已含 `PRAGMA journal_mode=WAL`),
/// 故 SQLite WAL 状态:随 df-storage 一起已启用,无需额外处理。 /// 故 SQLite WAL 状态:随 df-storage 一起已启用,无需额外处理。
pub async fn run_server(db_path: &Path, read_only: bool) -> anyhow::Result<()> { pub async fn run_server(db_path: &Path, config: ServerConfig) -> anyhow::Result<()> {
let db = Arc::new(Database::open(db_path).await?); let db = Arc::new(Database::open(db_path).await?);
let ctx = Ctx::new(db); let ctx = Ctx::new(db);
let stdin = tokio::io::stdin(); let stdin = tokio::io::stdin();
let stdout = tokio::io::stdout(); let stdout = tokio::io::stdout();
main_loop(stdin, stdout, &ctx, read_only).await main_loop(stdin, stdout, &ctx, &config).await
} }
/// 可单测的主循环(参数化 stdin/stdout)。 /// 可单测的主循环(参数化 stdin/stdout)。
/// ///
/// 协议正确性:每行一个 JSON-RPC 消息,Response 单行写回(末尾 \n)。 /// 协议正确性:每行一个 JSON-RPC 消息,Response 单行写回(末尾 \n)。
/// Notification(id=None)不回响应。 /// Notification(id=None)不回响应。
///
/// 生命周期:
/// - 空闲超时:仅在**等待下一个请求**(`read_line`)时计时;正在处理的请求不受影响,
/// 超时即 break 优雅退出(防客户端强杀后进程残留)。
/// - 写操作回调:成功执行的写工具(risk != Low)在响应写回后触发,供外部(GUI)感知数据变更。
pub async fn main_loop<R, W>( pub async fn main_loop<R, W>(
stdin: R, stdin: R,
stdout: W, stdout: W,
ctx: &Ctx, ctx: &Ctx,
read_only: bool, config: &ServerConfig,
) -> anyhow::Result<()> ) -> anyhow::Result<()>
where where
R: tokio::io::AsyncRead + Unpin, R: tokio::io::AsyncRead + Unpin,
@@ -63,7 +107,17 @@ where
loop { loop {
line.clear(); line.clear();
let n = reader.read_line(&mut line).await?; // 空闲超时:只包裹「等待下一请求」,不包裹 dispatch/写响应,处理期间绝不误杀。
let n = match config.idle_timeout {
Some(dur) => match tokio::time::timeout(dur, reader.read_line(&mut line)).await {
Ok(n) => n,
Err(_elapsed) => {
tracing::info!(target: "df_mcp", idle_secs = dur.as_secs(), "空闲超时无新请求,自动退出");
break;
}
}?,
None => reader.read_line(&mut line).await?,
};
if n == 0 { if n == 0 {
// EOF(stdin 关闭),优雅退出 // EOF(stdin 关闭),优雅退出
break; break;
@@ -79,7 +133,7 @@ where
Err(e) => { Err(e) => {
// 解析失败:无 id 时无法回响应,只能 log;有 id(尽力猜)回 PARSE_ERROR // 解析失败:无 id 时无法回响应,只能 log;有 id(尽力猜)回 PARSE_ERROR
tracing::warn!(target: "df_mcp", line = %trimmed, err = %e, "解析 JSON-RPC 行失败"); tracing::warn!(target: "df_mcp", line = %trimmed, err = %e, "解析 JSON-RPC 行失败");
let resp = Response::err(None, crate::protocol::PARSE_ERROR, "Parse error", None); let resp = Response::err(Some(Value::Null), crate::protocol::PARSE_ERROR, "Parse error", None);
write_response(&mut writer, &resp).await?; write_response(&mut writer, &resp).await?;
continue; continue;
} }
@@ -101,17 +155,53 @@ where
continue; continue;
} }
let resp = dispatch(ctx, read_only, req.id.clone(), method).await; // 写回调需在 dispatch 后判定,先预取工具名(仅 tools/call 需要,避免 clone 整包请求)
let tool_name = match &method {
McpMethod::ToolsCall { name, .. } => Some(name.clone()),
_ => None,
};
let resp = dispatch(ctx, config.read_only, req.id.clone(), method).await;
write_response(&mut writer, &resp).await?; write_response(&mut writer, &resp).await?;
if let Some(name) = tool_name {
// MC-5仅业务成功才触发写回调——防业务失败(如 update_project 非法 status、
// create_project 空名等 result.isError=true)仍假触发 df-data-changed。
if is_success_tool_call(&resp) && is_executed_write(&name, config.read_only) {
// 多进程兜底:stdio 形态无 AppHandle,先落库内变更标记,供 GUI 侧轮询消费
// (即便未注入回调也不丢变更感知);再触发进程内回调(HTTP 内嵌形态走事件)。
record_data_change(ctx, &name).await;
if let Some(cb) = &config.on_write_call {
cb(&name);
}
}
}
} }
Ok(()) Ok(())
} }
/// 判定 tools/call 响应是否为「业务成功」(写回调触发条件)。
///
/// - JSON-RPC 层 `error` 非空(协议错/未识别方法)→ 失败
/// - `result` 内 `isError=true`(MCP 业务错,handler 返 `CallToolResult::error`)→ 失败
/// - 其余(正常 result / initialize/ping 等非 tools/call)→ 成功
///
/// stdio(main_loop)与 HTTP(server_http on_tool_call)共用此判定,
/// 保证两 transport 对「业务失败不触发写回调」语义一致。
pub(crate) fn is_success_tool_call(resp: &Response) -> bool {
if resp.error.is_some() {
return false;
}
match &resp.result {
Some(v) => !matches!(v.get("isError"), Some(Value::Bool(true))),
None => false,
}
}
/// 方法分发 → 构造 Response。 /// 方法分发 → 构造 Response。
/// ///
/// `id`:JSON-RPC 请求 id(回响应时原样回填;通知由 main_loop 已过滤)。 /// `id`:JSON-RPC 请求 id(回响应时原样回填;通知由 main_loop 已过滤)。
async fn dispatch(ctx: &Ctx, read_only: bool, id: Option<Value>, method: McpMethod) -> Response { /// `pub(crate)`:stdio(main_loop)与 HTTP(server_http)transport 共用。
pub(crate) async fn dispatch(ctx: &Ctx, read_only: bool, id: Option<Value>, method: McpMethod) -> Response {
match method { match method {
McpMethod::Initialize { .. } => { McpMethod::Initialize { .. } => {
let result = InitializeResult { let result = InitializeResult {
@@ -135,9 +225,10 @@ async fn dispatch(ctx: &Ctx, read_only: bool, id: Option<Value>, method: McpMeth
} }
McpMethod::ToolsList => { McpMethod::ToolsList => {
let tools: Vec<_> = tools::all_tools() let tools: Vec<_> = tools::all_tools()
.into_iter() .iter()
.filter(|t| visible(read_only, t.risk)) .filter(|t| visible(read_only, t.risk))
.map(|t| serde_json::to_value(&t.tool).unwrap_or(Value::Null)) .map(|t| serde_json::to_value(&t.tool).unwrap_or(Value::Null))
.filter(|v| !v.is_null())
.collect(); .collect();
Response::ok(id, json!({ "tools": tools })) Response::ok(id, json!({ "tools": tools }))
} }
@@ -151,22 +242,30 @@ async fn dispatch(ctx: &Ctx, read_only: bool, id: Option<Value>, method: McpMeth
.unwrap_or(Value::Null), .unwrap_or(Value::Null),
); );
}; };
// read-only 模式:Medium/High 全拒 // 执行前防御:read-only 拒 Medium/High;High 兜底拒(handler 内也会拒,双保险)。
if read_only && spec.risk != RiskLevel::Low { // 判定收口到 should_execute,与 main_loop 写回调共用同一事实源,避免两份逻辑漂移。
if !should_execute(read_only, spec.risk) {
let msg = if read_only && spec.risk != RiskLevel::Low {
format!("只读模式拒绝执行 {name}(风险等级 {:?})", spec.risk)
} else {
format!("High 风险操作 {name} 默认拒绝,请在 DevFlow 应用内执行。")
};
let r = CallToolResult::error(msg);
return Response::ok(id, serde_json::to_value(r).unwrap_or(Value::Null));
}
// Low / Medium:执行前先校验 schema 必填参数(缺必填直接拒,
// 防 handler 内 arg_str_or 静默兜底把缺参当空串/默认值写入)
let missing: Vec<String> = required_names(&spec.tool.input_schema)
.into_iter()
.filter(|name| arguments.get(name).map_or(true, |v| v.is_null()))
.collect();
if !missing.is_empty() {
let r = CallToolResult::error(format!( let r = CallToolResult::error(format!(
"只读模式拒绝执行 {name}(风险等级 {:?})", "缺少必填参数: {}",
spec.risk missing.join(", ")
)); ));
return Response::ok(id, serde_json::to_value(r).unwrap_or(Value::Null)); return Response::ok(id, serde_json::to_value(r).unwrap_or(Value::Null));
} }
// 非 read-only:High 兜底拒绝(handler 内也会拒,双保险)
if spec.risk == RiskLevel::High {
let r = CallToolResult::error(format!(
"High 风险操作 {name} 默认拒绝,请在 DevFlow 应用内执行。"
));
return Response::ok(id, serde_json::to_value(r).unwrap_or(Value::Null));
}
// Low / Medium:执行
let result = (spec.handler)(ctx, arguments).await; let result = (spec.handler)(ctx, arguments).await;
Response::ok(id, serde_json::to_value(result).unwrap_or(Value::Null)) Response::ok(id, serde_json::to_value(result).unwrap_or(Value::Null))
} }
@@ -179,8 +278,66 @@ async fn dispatch(ctx: &Ctx, read_only: bool, id: Option<Value>, method: McpMeth
} }
} }
/// 工具是否会被执行(dispatch 与 main_loop 写回调共用的判定)。
///
/// read-only 下仅 Low 可执行;非 read-only 下 High 仍兜底拒绝。两条件都过 → 可执行。
fn should_execute(read_only: bool, risk: RiskLevel) -> bool {
!(read_only && risk != RiskLevel::Low) && risk != RiskLevel::High
}
/// 从工具 inputSchema 提取必填参数名列表。
///
/// 兼容 MCP schema required 的两种形态:array 显式列出必填属性;bool true 表示全部
/// properties 必填;false/缺失表示无必填。供 tools/call 执行前校验参数完整性,
/// 缺必填直接拒绝,杜绝 handler 内 arg_str_or 静默兜底把缺参当空串/默认值写入。
fn required_names(schema: &Value) -> Vec<String> {
match schema.get("required") {
Some(Value::Array(items)) => items
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_owned()))
.collect(),
Some(Value::Bool(true)) => {
let mut names: Vec<String> = schema
.get("properties")
.and_then(|p| p.as_object())
.map(|props| props.keys().cloned().collect())
.unwrap_or_default();
names.sort();
names
}
_ => Vec::new(),
}
}
/// 判定工具是否属于「会执行的成功写操作」:risk != Low 且未被 read-only/High 拒绝。
///
/// 变更标记与进程内写回调共用此判定,保证两通道对「哪些写操作需通知 GUI」语义一致;
/// 与 dispatch 的执行判定(`should_execute`)同源,避免逻辑漂移。
fn is_executed_write(name: &str, read_only: bool) -> bool {
match tools::find(name) {
Some(spec) => spec.risk != RiskLevel::Low && should_execute(read_only, spec.risk),
None => false,
}
}
/// 写成功后向 [`MCP_DATA_CHANGED_KEY`] 覆盖写入一条变更标记(多进程兜底信令)。
///
/// 独立进程形态完成写库后,GUI 无法经事件感知;此标记是落库的持久信令,GUI 轮询读
/// 该 key、`at` 变化即知库被外部改动。写失败仅告警不阻断(工具调用已成功,下次成功
/// 写会再次覆盖,标记只作「有变更」的粗粒度提示,非精确事件队列)。
async fn record_data_change(ctx: &Ctx, tool_name: &str) {
let at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let value = json!({ "at": at, "tool": tool_name }).to_string();
if let Err(e) = SettingsRepo::new(&ctx.db).set(MCP_DATA_CHANGED_KEY, &value).await {
tracing::warn!(target: "df_mcp", tool = tool_name, err = %e, "写变更标记失败(不改工具调用结果)");
}
}
/// 工具可见性:read-only 仅 Low,否则 Low + Medium(High 永不可见) /// 工具可见性:read-only 仅 Low,否则 Low + Medium(High 永不可见)
fn visible(read_only: bool, risk: RiskLevel) -> bool { pub(crate) fn visible(read_only: bool, risk: RiskLevel) -> bool {
if read_only { if read_only {
risk == RiskLevel::Low risk == RiskLevel::Low
} else { } else {
@@ -211,6 +368,8 @@ async fn write_response<W: tokio::io::AsyncWrite + Unpin>(
mod tests { mod tests {
use super::*; use super::*;
use crate::tools::RiskLevel; use crate::tools::RiskLevel;
use std::sync::Mutex;
use tokio::io::AsyncWriteExt;
/// 构造内存 DB + Ctx /// 构造内存 DB + Ctx
async fn test_ctx() -> Ctx { async fn test_ctx() -> Ctx {
@@ -287,6 +446,22 @@ mod tests {
assert_eq!(v["error"]["code"], crate::protocol::METHOD_NOT_FOUND); assert_eq!(v["error"]["code"], crate::protocol::METHOD_NOT_FOUND);
} }
#[tokio::test]
async fn tools_call_missing_name_returns_clear_error() {
// tools/call 缺 name 参数:不应回「未知工具: 」(空名),应回明确 METHOD_NOT_FOUND
let input =
r#"{"jsonrpc":"2.0","id":41,"method":"tools/call","params":{"arguments":{}}}"#;
let out = run_io_lines(&[input], false).await;
let v: Value = serde_json::from_str(&out[0]).unwrap();
assert_eq!(v["error"]["code"], crate::protocol::METHOD_NOT_FOUND);
let msg = v["error"]["message"].as_str().unwrap();
assert!(
msg.contains("name"),
"空 name 应给出明确提示,实际: {msg}"
);
assert!(!msg.contains("未知工具: "), "不应是空名「未知工具: 」: {msg}");
}
#[tokio::test] #[tokio::test]
async fn tools_call_high_risk_is_rejected() { async fn tools_call_high_risk_is_rejected() {
let input = let input =
@@ -331,6 +506,54 @@ mod tests {
assert_eq!(list_v["projects"][0]["name"], "McpProj"); assert_eq!(list_v["projects"][0]["name"], "McpProj");
} }
// ── schema required 校验(dispatch 层缺必填直接拒)────────────────
#[tokio::test]
async fn tools_call_missing_required_arg_returns_error() {
// create_project schema required 含 name:缺 name 应被 dispatch 层拒绝
// (而非 handler 内 arg_str_or 静默兜底把 description 当空串)
let input =
r#"{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"create_project","arguments":{"description":"缺 name"}}}"#;
let out = run_io_lines(&[input], false).await;
let v: Value = serde_json::from_str(&out[0]).unwrap();
assert_eq!(v["result"]["isError"], true);
let text = v["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("缺少必填参数: name"), "实际: {text}");
}
#[test]
fn required_names_from_array() {
let schema = json!({
"type": "object",
"properties": { "id": {}, "name": {} },
"required": ["id", "name"]
});
assert_eq!(required_names(&schema), vec!["id".to_string(), "name".to_string()]);
}
#[test]
fn required_names_from_bool_true_means_all_properties() {
let schema = json!({
"type": "object",
"properties": { "id": {}, "name": {}, "desc": {} },
"required": true
});
// bool true = 全部 properties 必填,返回全部属性名(排序保证确定性)
assert_eq!(
required_names(&schema),
vec!["desc".to_string(), "id".to_string(), "name".to_string()]
);
}
#[test]
fn required_names_false_or_missing_is_empty() {
assert_eq!(required_names(&json!({ "type": "object" })), Vec::<String>::new());
assert_eq!(
required_names(&json!({ "type": "object", "required": false })),
Vec::<String>::new()
);
}
#[tokio::test] #[tokio::test]
async fn visible_predicate() { async fn visible_predicate() {
assert!(visible(false, RiskLevel::Low)); assert!(visible(false, RiskLevel::Low));
@@ -340,4 +563,209 @@ mod tests {
assert!(!visible(true, RiskLevel::Medium)); assert!(!visible(true, RiskLevel::Medium));
assert!(!visible(true, RiskLevel::High)); assert!(!visible(true, RiskLevel::High));
} }
// ── should_execute 判定(dispatch 与写回调共用)────────────────────
#[test]
fn should_execute_predicate() {
assert!(should_execute(false, RiskLevel::Low));
assert!(should_execute(false, RiskLevel::Medium));
assert!(!should_execute(false, RiskLevel::High));
assert!(should_execute(true, RiskLevel::Low));
assert!(!should_execute(true, RiskLevel::Medium));
assert!(!should_execute(true, RiskLevel::High));
}
// ── 空闲超时 / 写操作回调(main_loop 集成)────────────────────────
/// 构造测试配置:只读开关 + 默认空闲超时 + 默认无回调(字段可覆盖)
async fn test_config(read_only: bool) -> ServerConfig {
ServerConfig {
read_only,
idle_timeout: Some(Duration::from_secs(60)),
on_write_call: None,
}
}
#[tokio::test]
async fn main_loop_exits_on_idle_timeout() {
let ctx = test_ctx().await;
// 用 duplex 造一个「开着但永不写数据」的 stdin:read_line 会一直挂起,
// 空闲超时(100ms)触发后应正常 break 退出,而非阻塞或 panic。
let (_tx, rx) = tokio::io::duplex(1024);
let config = ServerConfig {
idle_timeout: Some(Duration::from_millis(100)),
..test_config(false).await
};
let outer = tokio::time::timeout(
Duration::from_secs(2),
main_loop(rx, tokio::io::sink(), &ctx, &config),
)
.await;
let inner = outer.expect("main_loop 应在空闲超时后返回,而非一直阻塞");
assert!(inner.is_ok(), "空闲超时退出应为 Ok,实际: {inner:?}");
}
#[tokio::test]
async fn main_loop_fires_write_callback_for_write_tool() {
let ctx = test_ctx().await;
let calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let calls_cb = calls.clone();
let config = ServerConfig {
on_write_call: Some(Arc::new(move |name| {
calls_cb.lock().unwrap().push(name.to_string());
})),
..test_config(false).await
};
let (mut tx, rx) = tokio::io::duplex(1024);
let line = r#"{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"McpProj","description":"via mcp"}}}"#;
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
drop(tx); // 关 stdin → 处理后 EOF,正常退出
main_loop(rx, tokio::io::sink(), &ctx, &config)
.await
.unwrap();
assert!(
calls.lock().unwrap().contains(&"create_project".to_string()),
"写工具 create_project 应触发写回调,实际: {:?}",
*calls.lock().unwrap()
);
}
#[tokio::test]
async fn main_loop_no_write_callback_for_read_tool() {
let ctx = test_ctx().await;
let calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let calls_cb = calls.clone();
let config = ServerConfig {
on_write_call: Some(Arc::new(move |name| {
calls_cb.lock().unwrap().push(name.to_string());
})),
..test_config(false).await
};
let (mut tx, rx) = tokio::io::duplex(1024);
let line = r#"{"jsonrpc":"2.0","id":8,"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, &config)
.await
.unwrap();
assert!(
calls.lock().unwrap().is_empty(),
"只读工具 list_projects 不应触发写回调,实际: {:?}",
*calls.lock().unwrap()
);
}
#[tokio::test]
async fn main_loop_no_write_callback_when_read_only_denies_write() {
let ctx = test_ctx().await;
let calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let calls_cb = calls.clone();
let config = ServerConfig {
read_only: true,
on_write_call: Some(Arc::new(move |name| {
calls_cb.lock().unwrap().push(name.to_string());
})),
..test_config(true).await
};
let (mut tx, rx) = tokio::io::duplex(1024);
let line = r#"{"jsonrpc":"2.0","id":9,"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, &config)
.await
.unwrap();
assert!(
calls.lock().unwrap().is_empty(),
"read-only 下 Medium 写被拒,不应触发写回调,实际: {:?}",
*calls.lock().unwrap()
);
}
#[tokio::test]
async fn main_loop_no_write_callback_on_business_failure() {
// MC-5业务失败(handler 返 isError=true)不得触发写回调(防假 df-data-changed)。
// create_project 空名(name 为纯空白)→ MC-6 拒空 → isError=true。
let ctx = test_ctx().await;
let calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let calls_cb = calls.clone();
let config = ServerConfig {
on_write_call: Some(Arc::new(move |name| {
calls_cb.lock().unwrap().push(name.to_string());
})),
..test_config(false).await
};
let (mut tx, rx) = tokio::io::duplex(1024);
let line = r#"{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"create_project","arguments":{"name":" ","description":"d"}}}"#;
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
drop(tx);
main_loop(rx, tokio::io::sink(), &ctx, &config)
.await
.unwrap();
assert!(
calls.lock().unwrap().is_empty(),
"业务失败(isError=true)不应触发写回调,实际: {:?}",
*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:?}");
}
} }
+341
View File
@@ -0,0 +1,341 @@
//! HTTP (streamable HTTP, 2025-06-18) transport 层。
//!
//! 桌面进程内嵌 server 的传输层:POST /mcp 单响应 JSON;GET 405;notification 202 无 body。
//! 与 stdio transport 共享同一 dispatch/handler/Ctx,纯 df-mcp 内部实现,零 tauri 依赖。
//!
//! 协议合规(streamable HTTP 2025-06-18):
//! - POST /mcp → `application/json` 单响应(dispatch 层错误随 JSON-RPC 错误体回,HTTP 仍 200)
//! - GET /mcp → 405 + `Allow: POST`
//! - notification(id 缺省)→ 202 Accepted 无 body
//! - batch 数组 body → 400 + INVALID_REQUEST(本 server 不支持 batch)
//! - 非法 JSON body → 400 + PARSE_ERROR
//! - 无状态 server:忽略 Mcp-Session-Id 头(合规)
//!
//! 桌面端注入方式:构造 [`McpHttpState`] 传入共享 db + on_tool_call 回调,
//! [`build_router`] 得 Router,再 `axum::serve(listener, router)` 常驻监听。
use std::sync::Arc;
use axum::{
body::Bytes,
extract::State,
http::{header, HeaderValue, StatusCode},
response::{IntoResponse, Response},
routing::post,
Router,
};
use df_storage::db::Database;
use serde_json::{json, Value};
use crate::protocol::{McpMethod, Request, INVALID_REQUEST, PARSE_ERROR};
use crate::server::dispatch;
use crate::tools::Ctx;
/// 默认端口(桌面内嵌固定端口;可用 env `DEVFLOW_MCP_PORT` 覆盖)。
pub const DEFAULT_MCP_PORT: u16 = 18765;
/// HTTP MCP 共享状态(axum State)。
pub struct McpHttpState {
pub ctx: Ctx,
pub read_only: bool,
/// 成功 tools/call 回调(工具名)。桌面端注入 → emit df-data-changed;None=不回调。
pub on_tool_call: Option<Arc<dyn Fn(&str) + Send + Sync>>,
}
impl McpHttpState {
pub fn new(
db: Arc<Database>,
read_only: bool,
on_tool_call: Option<Arc<dyn Fn(&str) + Send + Sync>>,
) -> Self {
Self {
ctx: Ctx::new(db),
read_only,
on_tool_call,
}
}
}
/// 构造 axum Router(/mcp 单路由:POST 处理请求,GET 回 405)。
pub fn build_router(state: McpHttpState) -> Router {
Router::new()
.route("/mcp", post(post_mcp).get(get_mcp))
.with_state(Arc::new(state))
}
/// 在已绑定 listener 上启动 HTTP server(永不返回直至 shutdown)。
///
/// 桌面端用法:bind 127.0.0.1:18765 → spawn(serve_on(listener, state)),
/// 进程内常驻,多 Claude 会话 HTTP 直连同一进程。
pub async fn serve_on(listener: tokio::net::TcpListener, state: McpHttpState) -> anyhow::Result<()> {
axum::serve(listener, build_router(state))
.await
.map_err(|e| anyhow::anyhow!("axum::serve 失败: {e}"))
}
/// POST /mcp:解析 body → dispatch → JSON 单响应 / 202 notification。
async fn post_mcp(State(state): State<Arc<McpHttpState>>, body: Bytes) -> Response {
match handle_body(&state, &body).await {
Outcome::Json(status, value) => (status, axum::Json(value)).into_response(),
Outcome::Accepted => StatusCode::ACCEPTED.into_response(),
}
}
/// GET /mcp → 405 + `Allow: POST`(streamable HTTP 规范)。
async fn get_mcp() -> Response {
let mut resp = StatusCode::METHOD_NOT_ALLOWED.into_response();
resp.headers_mut()
.insert(header::ALLOW, HeaderValue::from_static("POST"));
resp
}
/// 分发结果:单响应 JSON / notification 202。
enum Outcome {
/// 单响应(HTTP 状态码 + JSON-RPC body)
Json(StatusCode, Value),
/// notification:202 Accepted,无 body
Accepted,
}
/// 解析请求体并分发到共享 dispatch。
async fn handle_body(state: &McpHttpState, body: &[u8]) -> Outcome {
// ① UTF-8 校验
let text = match std::str::from_utf8(body) {
Ok(t) => t,
Err(_) => {
return Outcome::Json(
StatusCode::BAD_REQUEST,
rpc_error(None, PARSE_ERROR, "Parse error: 请求体不是合法 UTF-8"),
);
}
};
// ② 整体 JSON 解析:数组 = batch,本 server 不支持
let raw: Value = match serde_json::from_str(text) {
Ok(v) => v,
Err(_) => {
return Outcome::Json(
StatusCode::BAD_REQUEST,
rpc_error(None, PARSE_ERROR, "Parse error: 非法 JSON"),
);
}
};
if raw.is_array() {
return Outcome::Json(
StatusCode::BAD_REQUEST,
rpc_error(None, INVALID_REQUEST, "Invalid request: batch 请求不支持"),
);
}
// ③ 解析为 JSON-RPC Request(结构非法 → INVALID_REQUEST)
let req: Request = match serde_json::from_value(raw) {
Ok(r) => r,
Err(_) => {
return Outcome::Json(
StatusCode::BAD_REQUEST,
rpc_error(None, INVALID_REQUEST, "Invalid request"),
);
}
};
let method = McpMethod::from_request(&req);
let is_notification = req.id.is_none();
// ④ notification(id 缺省)→ 202 Accepted 无 body
if is_notification {
match method {
McpMethod::Initialized => {
tracing::debug!(target: "df_mcp", "HTTP: 客户端 initialized 通知已收");
}
_ => {
tracing::debug!(target: "df_mcp", m = ?method, "HTTP: 忽略未识别通知");
}
}
return Outcome::Accepted;
}
// ⑤ 工具名预取(tools/call 成功回调用;method 随后 move 进 dispatch)
let tool_name = match &method {
McpMethod::ToolsCall { name, .. } => Some(name.clone()),
_ => None,
};
let resp = dispatch(&state.ctx, state.read_only, req.id.clone(), method).await;
// ⑥ 成功 tools/call(JSON-RPC 无 error 且 result.isError≠true)→ on_tool_call 回调
// (桌面端据此刷新 GUI)。MC-5/业务失败(handler 返 isError=true)不触发,
// 防假 df-data-changed;与 stdio fire_write_hook 共用 is_success_tool_call,两 transport 统一。
if crate::server::is_success_tool_call(&resp) {
if let (Some(name), Some(cb)) = (tool_name, &state.on_tool_call) {
cb(&name);
}
}
let value = serde_json::to_value(&resp)
.unwrap_or_else(|_| rpc_error(req.id, crate::protocol::INTERNAL_ERROR, "响应序列化失败"));
Outcome::Json(StatusCode::OK, value)
}
/// 构造 JSON-RPC 2.0 错误响应体。
fn rpc_error(id: Option<Value>, code: i32, message: &str) -> Value {
json!({
"jsonrpc": "2.0",
"id": id,
"error": { "code": code, "message": message }
})
}
// ============================================================
// 单测:tower oneshot 打 Router + open_in_memory DB
// 覆盖:initialize / tools/list / notification 202 / GET 405 / 非法 JSON / batch / on_tool_call
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
use axum::{
body::Body,
http::{header, Request, StatusCode},
};
use std::sync::Mutex;
use tower::ServiceExt;
/// 构造内存 DB + McpHttpState(on_tool_call 可注入)
async fn test_state(
read_only: bool,
on_tool_call: Option<Arc<dyn Fn(&str) + Send + Sync>>,
) -> McpHttpState {
let db = Arc::new(Database::open_in_memory().await.unwrap());
McpHttpState::new(db, read_only, on_tool_call)
}
/// oneshot 打 Router:method + body → (status, json body, headers)
async fn send(
router: Router,
method: &str,
body: &str,
) -> (StatusCode, Value, axum::http::HeaderMap) {
let builder = Request::builder().uri("/mcp").method(method);
let builder = if method == "GET" {
builder
} else {
builder.header(header::CONTENT_TYPE, "application/json")
};
let req = builder.body(Body::from(body.to_string())).unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
let status = resp.status();
let headers = resp.headers().clone();
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let value: Value = if bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice(&bytes).unwrap_or(Value::Null)
};
(status, value, headers)
}
#[tokio::test]
async fn post_initialize_returns_server_info_and_capabilities() {
let state = test_state(false, None).await;
let router = build_router(state);
let (status, v, _) = send(
router,
"POST",
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(v["jsonrpc"], "2.0");
assert_eq!(v["id"], 1);
assert_eq!(v["result"]["protocolVersion"], crate::server::PROTOCOL_VERSION);
assert_eq!(v["result"]["serverInfo"]["name"], crate::server::SERVER_NAME);
assert!(v["result"]["capabilities"]["tools"].is_object());
}
#[tokio::test]
async fn post_tools_list_excludes_high_risk_by_default() {
let state = test_state(false, None).await;
let router = build_router(state);
let (status, v, _) = send(
router,
"POST",
r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#,
)
.await;
assert_eq!(status, StatusCode::OK);
let tools = v["result"]["tools"].as_array().unwrap();
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
// 默认(非 read-only):Low + Medium 可见,High 不可见
assert!(names.contains(&"list_projects"));
assert!(names.contains(&"create_project")); // Medium
assert!(!names.contains(&"delete_project")); // High
assert!(!names.contains(&"run_workflow")); // High
}
#[tokio::test]
async fn post_notification_returns_202_no_body() {
let state = test_state(false, None).await;
let router = build_router(state);
let (status, v, _) = send(
router,
"POST",
r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}"#,
)
.await;
assert_eq!(status, StatusCode::ACCEPTED);
assert_eq!(v, Value::Null);
}
#[tokio::test]
async fn get_mcp_returns_405_allow_post() {
let state = test_state(false, None).await;
let router = build_router(state);
let (status, _, headers) = send(router, "GET", "").await;
assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(headers.get(header::ALLOW).unwrap(), "POST");
}
#[tokio::test]
async fn post_invalid_json_returns_400_parse_error() {
let state = test_state(false, None).await;
let router = build_router(state);
let (status, v, _) = send(router, "POST", "not json").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(v["error"]["code"], PARSE_ERROR);
}
#[tokio::test]
async fn post_batch_returns_400_invalid_request() {
let state = test_state(false, None).await;
let router = build_router(state);
let body = r#"[{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}]"#;
let (status, v, _) = send(router, "POST", body).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(v["error"]["code"], INVALID_REQUEST);
}
#[tokio::test]
async fn tools_call_success_triggers_on_tool_call() {
let calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let calls_cb = calls.clone();
let on_tool_call: Option<Arc<dyn Fn(&str) + Send + Sync>> = Some(Arc::new(move |name| {
calls_cb.lock().unwrap().push(name.to_string());
}));
let state = test_state(false, on_tool_call).await;
let router = build_router(state);
let body = r#"{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"HttpProj","description":"via http"}}}"#;
let (status, v, _) = send(router, "POST", body).await;
assert_eq!(status, StatusCode::OK);
// 成功业务响应:isError 不置位
assert!(
v["result"]["isError"].is_null() || v["result"]["isError"] == Value::Bool(false)
);
assert!(
calls.lock().unwrap().contains(&"create_project".to_string()),
"成功 tools/call 应触发 on_tool_call,实际: {:?}",
*calls.lock().unwrap()
);
}
}
+1388 -117
View File
File diff suppressed because it is too large Load Diff
+207
View File
@@ -0,0 +1,207 @@
//! update_idea/update_project/update_task 部分更新回归测试
//!
//! 回归 P0 bug:LLM 客户端做部分更新(只传 description 不传 title)时,
//! 旧实现用 `arg_str_or(args, "title", "")` 取值,缺省 → 空串覆盖 existing
//! → title 被静默清空 → 数据丢失。
//!
//! 根本修:title/description(name)缺省回退 existing,而非空默认覆盖。
//! 本测试覆盖三个 handler 的「只传一个字段,另一字段保留 existing」语义。
use df_mcp::tools::{find, Ctx};
use df_storage::db::Database;
use serde_json::{json, Value};
/// 从 CallToolResult 取首个 text 块解析为 JSON。
fn result_json(res: &df_mcp::protocol::CallToolResult) -> Value {
assert!(
res.is_error != Some(true),
"工具调用失败(is_error=true): {:?}",
res.content
);
match res.content.first() {
Some(df_mcp::protocol::ContentBlock::Text { text }) => {
serde_json::from_str(text).expect("响应非合法 JSON")
}
other => panic!("预期 Text 块,实际: {other:?}"),
}
}
/// 取嵌套对象 record(title/name/description 等业务字段在其下)。
fn record_of(v: &Value, key: &str) -> Value {
v.get(key)
.cloned()
.unwrap_or_else(|| panic!("响应缺 `{key}` 字段: {v}"))
}
async fn setup() -> Ctx {
let db = Database::open_in_memory().await.expect("open_in_memory");
Ctx::new(std::sync::Arc::new(db))
}
async fn call(ctx: &Ctx, name: &str, args: Value) -> Value {
let spec = find(name).expect("工具已注册");
let res = (spec.handler)(ctx, args).await;
result_json(&res)
}
// ============================================================
// update_idea:只传 description,title 必须保留 existing
// ============================================================
#[tokio::test]
async fn update_idea_keeps_title_when_only_description_sent() {
let ctx = setup().await;
// 先建一条想法:title="原始标题"
let created = call(
&ctx,
"create_idea",
json!({ "title": "原始标题", "description": "原始描述" }),
)
.await;
let id = created["id"].as_str().expect("id").to_owned();
// LLM 只传 description(不传 title)—— 旧实现会把 title 清空为 ""
let updated = call(
&ctx,
"update_idea",
json!({ "id": id, "description": "新描述" }),
)
.await;
let idea = record_of(&updated, "idea");
assert_eq!(idea["title"].as_str(), Some("原始标题"), "title 应保留 existing,不被空默认清空");
assert_eq!(idea["description"].as_str(), Some("新描述"), "description 应更新为新值");
}
#[tokio::test]
async fn update_idea_keeps_description_when_only_title_sent() {
let ctx = setup().await;
let created = call(
&ctx,
"create_idea",
json!({ "title": "原标题", "description": "原描述" }),
)
.await;
let id = created["id"].as_str().expect("id").to_owned();
let updated = call(&ctx, "update_idea", json!({ "id": id, "title": "新标题" })).await;
let idea = record_of(&updated, "idea");
assert_eq!(idea["title"].as_str(), Some("新标题"));
assert_eq!(idea["description"].as_str(), Some("原描述"), "description 应保留 existing");
}
// ============================================================
// update_project:只传 description,name 必须保留 existing
// ============================================================
#[tokio::test]
async fn update_project_keeps_name_when_only_description_sent() {
let ctx = setup().await;
let created = call(
&ctx,
"create_project",
json!({ "name": "原始项目", "description": "原始描述" }),
)
.await;
let id = created["id"].as_str().expect("id").to_owned();
let updated = call(
&ctx,
"update_project",
json!({ "id": id, "description": "新描述" }),
)
.await;
let project = record_of(&updated, "project");
assert_eq!(project["name"].as_str(), Some("原始项目"), "name 应保留 existing");
assert_eq!(project["description"].as_str(), Some("新描述"));
// path/stack/idea_id 未传也应保留(existing 创建时为 None,这里间接保证不被改)
assert_eq!(project["path"].as_str(), None);
}
#[tokio::test]
async fn update_project_keeps_status_when_not_sent() {
// 状态字段缺省同样应保留 existing(旧实现默认 "planning" 会重置状态)
let ctx = setup().await;
let created = call(
&ctx,
"create_project",
json!({ "name": "P", "description": "D", "status": "in_progress" }),
)
.await;
let id = created["id"].as_str().expect("id").to_owned();
let updated = call(
&ctx,
"update_project",
json!({ "id": id, "description": "改描述" }),
)
.await;
let project = record_of(&updated, "project");
assert_eq!(
project["status"].as_str(),
Some("in_progress"),
"status 应保留 existing,不被默认 planning 重置"
);
}
// ============================================================
// update_task:只传 description,title/project_id 必须保留 existing
// ============================================================
#[tokio::test]
async fn update_task_keeps_title_and_project_when_only_description_sent() {
let ctx = setup().await;
let proj = call(
&ctx,
"create_project",
json!({ "name": "所属项目", "description": "d" }),
)
.await;
let project_id = proj["id"].as_str().expect("project id").to_owned();
let created = call(
&ctx,
"create_task",
json!({ "project_id": project_id, "title": "原始任务标题", "description": "原始描述" }),
)
.await;
let id = created["id"].as_str().expect("id").to_owned();
// 只传 description:旧实现 title 是必填会报错,description 缺省会清空(行为不一)
// 根本修后三者都应保留 existing(或更新为新值)
let updated = call(
&ctx,
"update_task",
json!({ "id": id, "description": "新描述" }),
)
.await;
let task = record_of(&updated, "task");
assert_eq!(task["title"].as_str(), Some("原始任务标题"), "title 应保留 existing");
assert_eq!(task["description"].as_str(), Some("新描述"));
assert_eq!(task["project_id"].as_str(), Some(project_id.as_str()), "project_id 应保留 existing");
// status 走状态机,update_task 不改也应保留
assert_eq!(task["status"].as_str(), Some("todo"));
}
#[tokio::test]
async fn update_task_keeps_description_when_only_title_sent() {
let ctx = setup().await;
let proj = call(
&ctx,
"create_project",
json!({ "name": "P2", "description": "d" }),
)
.await;
let project_id = proj["id"].as_str().expect("project id").to_owned();
let created = call(
&ctx,
"create_task",
json!({ "project_id": project_id, "title": "原标题", "description": "原描述" }),
)
.await;
let id = created["id"].as_str().expect("id").to_owned();
let updated = call(&ctx, "update_task", json!({ "id": id, "title": "新标题" })).await;
let task = record_of(&updated, "task");
assert_eq!(task["title"].as_str(), Some("新标题"));
assert_eq!(task["description"].as_str(), Some("原描述"), "description 应保留 existing");
}
+1
View File
@@ -15,3 +15,4 @@ tokio = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }

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