Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

验收: cargo check -p devflow EXIT 0 + vue-tsc EXIT 0 + build EXIT 0。
2026-10-09 22:53:14 +08:00
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
lxy 9c3f27f4ca 修复: workspace_root 编译期常量消除+data_dir 运行期+首次授权引导
- 删除 production 中全部 env!("CARGO_MANIFEST_DIR")引用
- workspace_root() 替换为运行期 data_dir(Tauri app_data_dir)
- 相对路径锚定改为 AllowedDirs.first_persistent_dir(),无授权时引导绑定项目
- .trash 迁到 data_dir/.trash,含自动迁移
- authz_debug → temp_dir,workspace_drive → current_dir
- run_command 默认 working_dir 改为空串
- G1 目标刷新改为每次消息覆盖(含 ai_chat_edit 路径)
- #6 评分关键词拆到独立文件 scoring_keywords.rs
- #7 promote 补偿删除 purge_with_descendants → soft_delete
- #8 关联双向同步: 后端事务 sync_related_ids + IPC + 前端全链路
- 设置面板新增数据目录显示
2026-06-27 21:32:36 +08:00
lxy a6d692270f 锁定: Cargo.lock tree-sitter-go/java/python(fc70544 AST 扩展配套) 2026-06-27 01:31:00 +08:00
lxy 9189877a59 修复: B-260626-01 首条 user 协议合规(sanitize 兜底 + ensure_leading_user)
- context.rs: 兜底全量也过 sanitize(对齐分支1/3,防主 loop 唯一 sanitize 漏洞——大体量 tool_result 致超预算且保护区满时,裸返 all_messages_clone 不过滤 truncated/中毒三元组/首条非法直送 provider)
- anthropic_compat.rs + openai_compat.rs: ensure_leading_user 补 user 占位(Anthropic/OpenAI 协议要求首条非 assistant/tool;上游绕过 sanitize 的调用方——标题生成/知识注入/工作流 AI 节点——可能传入首条 assistant 序列,补占位保留上下文,tool_use/tool_result 配对完整无 orphan,远优于砍丢历史)
2026-06-27 01:31:00 +08:00
lxy bb2cfcbb62 修复: grep/read_file 命中行行号与内容分行
行号 gutter 与内容改同一 flex row 并排(IDE 风格),取代原 head 块+content 块垂直堆叠;去冒号前缀改纯数字右对齐。BUG-260626-01
2026-06-27 01:24:50 +08:00
lxy 80239500f1 新增: 知识图谱设计文档 + AI原生上下文地图设计(父②⑥实施基础)
- 项目知识图谱与任务队列系统-2026-06-26.md:AI Working 终局设计(任务网络queue/parent/links + 事件流 + 基础设施 + 注入 + Mission Control 五视图),父②Phase1 + 父⑥Phase2-3 已据此实施落地
- AI原生上下文地图与去AI化进化系统-2026-06-26.md:AI Working 定位体系设计
2026-06-27 01:22:46 +08:00
lxy 4b754fe230 更新: 目标丢失诊断报告 G1/G2/G4 实施状态(commit 8ce18cb)
§五方向建议核对:G1 目标钉扎✅ / G2 探索熔断✅ / G4 话题标记修正✅ 已实施,
G3 状态机收敛(文档衔接),G5 排查结论(历史脏数据,数据治理项不改代码)。
诊断→实施闭环标注。todo DAG + 待决策.md 状态核对一致(本 session 16 提交全景已同步)。
2026-06-27 00:56:55 +08:00
lxy e33e8f86d3 新增: 知识图谱Phase3基础设施(父⑥) — project_services+D10凭证审查+IPC+AI工具
父⑥ Phase 3(对标设计 §2.3):
- V31 迁移:project_services 表(id/project_id FK/name/service_type/endpoint/config_json/environment/remark)+ idx_project_services_project
- ProjectServiceRecord + ProjectServiceRepo(impl_repo! 宏 + insert_validated/update_full_validated/list_by_project/list_by_project_env)
- service_type 白名单(mysql/postgresql/sqlite/redis/mongodb/mq/api/other,应用层校验)
- D10 凭证审查:config_json/endpoint/remark 含 password/passwd/secret/api_key/apikey/token 拒绝(不存敏感凭证,凭证走环境变量)
- 4 IPC(add/update/remove/list_project_services,环境过滤)+ lib.rs 注册
- AI 工具 add/list_project_services(Medium/Low)+ 基线 39→41(data 25→27)+ 3 端到端测试

P2 修复(verify agent 发现):
- settings.rs project_services 白名单移除 service_type(对齐注释"不列入"+ status 收口,防 update_field 旁路绕过 validate_service_type)

df-storage 117(105+12) + df-ai 331 + 基线 41 测试全过。
2026-06-27 00:49:09 +08:00
lxy 0c348f2311 新增: 父④F-09 streaming/currentText per-conv(accessor委派,真多会话前端基础)
DEC-07 a(父②后启动)。accessor 委派方案(非 3 独立 Map,禁动域零触碰):
- aiShared.ts: convStreamStates reactive Map<convId,{streaming,currentText}> + getConvStreamState/setConvStreaming/setConvCurrentText/clearConvStreamState
- stores/ai.ts: state.streaming/currentText 改 Object.defineProperty accessor,委派 convStreamStates 按 activeConversationId 索引(消费方零改动透明继承)
- useAiConversations.ts: deleteConversation 调 clearConvStreamState 防 Map 无限增长

铁律达成:
- 单会话回归零变化(activeConversationId 恒定 Map 仅1项,accessor 等价全局单例,13/13 测试)
- 禁动域零触碰(useAiSend/useAiEvents/streamingGuard/MessageList 等全零改动)
- 切会话隔离(BUG-260624-01 单例根因清除,A 后台 currentText 不串 B 视图)
- 内存泄漏防护(deleteConversation + streaming false 收敛删项)

vue-tsc EXIT 0。streamingGuard convId 精确路由留 F-09 后续批次(技术债点,单会话无回归)。
2026-06-27 00:25:30 +08:00
lxy 5a893680b7 新增: 知识图谱Phase2事件流(父⑥) — project_events统一事件流+埋点+timeline
父⑥ Phase 2(对标设计 §2.4):
- V30 迁移:project_events 追加型审计表(id/project_id/event_type/entity_type/entity_id/from_state/to_state/context_json/source/conversation_id/created_at)+ 双索引(project,time / entity)
- ProjectEventRecord + ProjectEventRepo(insert 追加型/get_by_project/get_by_entity/list_recent)
- 事件埋点(best-effort,hook/after,失败 warn 不阻断主操作):
  - task.rs emit_event 辅助 + create_task/advance_task/move_task_queue 埋点
  - idea.rs promote_idea 埋点(idea_promoted,project_id=新项目)
  - project.rs create/delete 埋点
  - 注:idea_created 暂不埋点(idea 无 project_id,待 Inbox 项目落地,NOT NULL+FK 约束)
- get_project_timeline IPC(events 模块)+ lib.rs 注册 + AI 工具(基线 +N)
- source 语义:IPC 标 human,AI 工具路径标 ai

注:workflow 脚本中文变量名 const 致验证 agent 未跑,主控独立 cargo check EXIT 0 + grep 落地齐全核验。
2026-06-27 00:23:17 +08:00
lxy 744b68da5b 新增: 父⑤⑤.2收尾(评分拆const+表单补全+css陷阱) + priority跨层映射修复
父⑤⑤.2(DEC-03 c / DEC-04 a):
- #6 scoring.rs 关键词拆 const(FEASIBILITY_POS/NEG + IMPACT + URGENCY 4 组,零行为变化,df-ideas 22 测试过)
- #9/#10 Ideas.vue 表单补 tags(逗号分隔对标 KnowledgeDetail)+priority 下拉+source,i18n 中英对称
- css 陷阱修:components.css .modal-* 通配→"modal 系列"(避 */ 闭合,memory css-comment-star-slash-trap)

priority 跨层映射修复(verify agent 发现的预存 bug):
- idea.rs priority_from_i32 原跟 df-types 枚举(0=Low),与前端约定(0=critical,api/types.ts:141+Tasks.vue)反
- 改手动映射对齐前端(0=Critical/1=High/2=Medium/3=Low),Priority as_str 序列化不受影响
- 校验注释同步更正(我父①①.3 加的 Low=0 注释)

父⑤ 全闭环(⑤.1/①.4/⑤.2)。css warning 清零。
2026-06-26 23:53:51 +08:00
lxy 40b74e11bf 新增: 知识图谱Phase1业务层(父②) + update_task parent_id 校验(P1)
父② 业务层(②.3+②.4+②.5):
- IPC: create_task 扩展 queue/parent_id/content_json + create/remove/list_task_links + move_task_queue(一致性约束联动 status) + get_task_tree
- 父聚合(②.4): advance_task 后重算父 status(count_children_by_status,set_status_for_aggregation 专用路径绕 D-260616-04 status 收口,父任务=容器模型唯一非状态机写入)
- AI 工具(②.5): register_task_graph_tools 分组 6 工具 + create_task 扩展,基线测试 32→38
- state.task_links: TaskLinkRepo + lib.rs 注册 5 新 IPC

P1 修复(verify agent 发现,主控修):
- update_task 漏 parent_id 校验(可绕 1 级嵌套创建孙任务/悬空 parent_id/自环)
- 补 parent_id 特判(对标 create_task: 父存在 + 父自身无 parent_id + 自环拒)

df-storage 99 lib + df-ai 331 + 基线测试 38 全过。
2026-06-26 23:23:23 +08:00
lxy df8ed7e74f 新增: 知识图谱Phase1数据层(父②) + 修复 idea list_by_query 占位符(父⑤⑤.1)
父② 数据层(V29):
- migrate_v29: tasks +queue/parent_id/content_json + task_links 表+双索引(幂等对标 v14/v24)
- TaskRecord 加 3 字段(15→18 列,INSERT/SELECT×6/from_row/struct 同步)+ 8 构造点补默认
- TaskLinkRepo(新文件,CRUD + 循环依赖 BFS depends_on 链检测,14 测)
- task_repo list_by_query queue/parent_id 筛选 + get_children + 聚合计数(10 测)
- queue DEFAULT 'todo' 向后兼容;task_links 软删保留(无 ON DELETE)

idea_repo list_by_query 占位符修复(父⑤⑤.1 引入的潜伏 bug):
- 加 deleted_at IS NULL 恒带后 where_clauses.len()+1 偏移 → 非空 status/keyword 查询 rusqlite 错误
- 改 params_vec.len()+1(对标 task_repo ②.2 同款修复,父② agent 发现)

df-storage 96 lib + 11 集成测试全过。
2026-06-26 22:50:48 +08:00
lxy 549f198cea 更新: 待决策回填(7DEC+3专题按推荐) + 父②V29
- DEC-01 父② 知识图谱独立 V29(V28 已被灵感软删除 ideas.deleted_at 占用)
- DEC-02 #7 保留 purge / DEC-03 #6 拆 const(接 LLM 后升 a) / DEC-04 #9/#10 逗号 tags
- DEC-05 层2 维持白名单 / DEC-06 父③排父②后 / DEC-07 父④排父②后
- 专题-1 workspace_root ①先行分发前定② / 专题-2 审批超时不做(等待即等待) / 专题-3 F-09归父④
2026-06-26 22:16:30 +08:00
lxy e7c9ed0805 更新: 待决策队列(2026-06-26 任务图谱推进 7 项)
自主推进父①+父⑤(⑤.1/①.4/#5)后,剩余项均需决策,登记:
DEC-01 父②多工程合并 / DEC-02 #7 promote补偿 / DEC-03 #6 评分配置形式 /
DEC-04 #9/#10 表单交互 / DEC-05 BUG-260620-05层2授权 / DEC-06 父③ToolCard排期 / DEC-07 父④F-09排期
2026-06-26 21:51:46 +08:00
lxy 6d979d6646 修复: record_to_idea 读真实 status/related_ids(父⑤⑤.2 #5)
IDEA-FIX-05: record_to_idea 原 status 硬编码 Draft / related_ids 硬编码空,
评估时丢失真实上下文。改读 record.status(加 status_from_str 映射 DB snake_case
→ IdeaStatus 枚举,fallback Draft)+ related_ids(parse JSON Vec,同 tags 模式)。
scores/time 不改(注释明示评估不用)。
2026-06-26 20:48:31 +08:00
lxy 005079f762 新增: 灵感软删除回收站+雷达图正名(父⑤)
- IDEA-FIX-01: ideas 软删除(对标 tasks V14)— V28 迁移 deleted_at(幂等 ALTER) + IdeaRepo soft_delete/restore/list_deleted + list_by_query 恒带 deleted_at IS NULL(防回收站泄漏) + delete_idea 改软删 + restore_idea/list_deleted_ideas IPC + IdeaRecord 不加字段(纯 SQL WHERE 过滤)
- IDEA-FIX-14: 雷达图 CSS class radar→score-bar(IdeaDetail/ProjectDetail 2 文件 template+style+注释,纯正名零行为变更)
- 6 新测试(soft_delete 标记+过滤/幂等/missing, restore 还原/仅已删, list_deleted 排序);df-storage 73 测试过
2026-06-26 20:42:48 +08:00
lxy 308775cf4d 修复: 灵感模块数据安全 + 建立整体任务图谱DAG
- IDEA-FIX-02: ideas 白名单移除 id/created_at(对标 tasks B-260616-16,防主键/创建时间篡改)
- IDEA-FIX-03: create_idea 加 priority ∈ 0..=3 值域校验(对标 tasks B-260615-15,防越界静默吞)
- docs/todo.md: 建立整体任务图谱(DAG·有方向有状态),整合知识图谱设计+22条待办+G1
- 核销 BUG-260620-05 层1: 已被 F-260619-03 方案①弱化取代(default_with_root 空,reload 不塞 workspace_root),层2 黑名单制仍待决策
2026-06-26 20:27:58 +08:00
lxy 8ce18cb482 新增: AI 对话目标钉扎+探索熔断+话题标记降级
治 AI Chat 目标丢失/意图丢失(实测会话 480万token,目标消息被压缩出局,LLM 尾部盲搜死循环):
- G1 目标钉扎:首条 user 目标提取存 PerConvState.pinned_goal,run_agentic_loop 入口拼进
  system_prompt 尾部(loop 不变量,免疫 F-15 压缩/裁剪/sanitize,治目标物理出局)
- G2 探索熔断:连续 N 次空结果无进展 → 两段式警示 + AiHelpRequired 熔断(治游荡死循环)
- G4 话题标记降级:pinned_goal 存在时跳过 topic marker insert(治反向误导+双锚点稀释)
- G3 衔接:pinned_goal 内容态与 ConvState 生命周期态正交,不进 enum
各改进配开关 flag(GOAL_PIN_ENABLED/STALL_BREAKER_*/TOPIC_MARKER_GOAL_AWARE)可单点回退。
诊断报告见 docs/02-架构设计/专项设计/AI对话目标丢失诊断-2026-06-26.md。
2026-06-26 20:01:28 +08:00
lxy c011f864fd 重构: aichat 双轨状态机收口 + AiCompressed 事件拆分
- generating bool + CONV_STATE_ENABLED 开关双轨退役,ConvState enum 单一真相源
- can_accept_request 接入 chat 域入口(覆盖 Stopping 竞态,严谨于 is_active)
- AiCompressed 拆 AiManualCompressed/AiAutoCompressed(治自动压缩误触桌面toast+刷新)
- convStates/getConvState 下沉 aiShared.ts 破循环依赖
2026-06-25 03:20:42 +08:00
lxy fc705443bd 新增: AST代码智能语言扩展 Go/Java/Python(tree-sitter 表驱动)
Phase1 范围 3 grammar/4 类 → 6 grammar/9 类。grammar_for/DEFINITION_KINDS/Cargo.toml 加表项(零架构改动,grammar crate 0.23 ABI 兼容 0.25 主 crate),补 3 处 py 相关测试断言(py 加支持后原无 grammar 样例失效),测试 306 passed。
2026-06-25 02:13:46 +08:00
lxy d7e0d7ac55 修复: AI 工具基线补 read_symbol(31→32)
baseline_tool_count 断言 file 层 12→13(7c2e3b2 read_symbol 符号解析注册未更新基线测试)。cargo test --lib 308 全绿。
2026-06-25 01:21:15 +08:00
lxy c409a675cf 重构: workspace_root 弱化方案①去默认白名单
default_with_root 不再编译期硬塞 CARGO_MANIFEST_DIR(分发后无效路径)+ reload_allowed_dirs KV 空时不兜底塞。用户首次访问走弹窗三档授权,对齐产品定位(不预设源码目录)。3 测试改显式构造。
2026-06-25 01:19:05 +08:00
lxy 4c54288a8d 重构: 移除 AI 消息重新生成入口及配套调整
- MessageList: 移除末条 AI 消息重新生成操作栏入口
- package.json: build:dev 改 tauri build --debug --no-bundle(只编译不打包)
- 文档: workspace_root 弱化决策 + BUG-260625-01/02 工具修复记录
2026-06-25 01:03:52 +08:00
lxy 047b3b91fa 修复: read_file/grep 工具结果处理
BUG-260625-01 grep 单文件路径走 grep_one_file(旧 read_dir 报 os error 267 目录无效);BUG-260625-02 read_file search 模式渲染命中行 matches(旧取 content 显 0 行误判读取失败)。
2026-06-25 00:27:02 +08:00
lxy 24c3fa08f1 新增: 路径授权三档化 once/session/always
Phase B 弹窗 once/always → once/session/always 三档(本次单次执行后清/当前会话切会话清/始终落KV)。修 once 原写 session 致本次实为本会话名实不符。AllowedDirs 加 once 层 + ai_authorize_dir decision 改 match 四分支 + 执行后 clear_once。
2026-06-25 00:23:07 +08:00
lxy 56a08fb5c1 修复: 压缩摘要落可压缩区致循环卡死 2026-06-24 23:26:48 +08:00
lxy 953863af8e 文档: CR-260622-01残留销账(P2-1伪问题核验+979 return过早+P2-2评估降级) 2026-06-24 23:08:30 +08:00
lxy 7d2998e04e 修复: grep搜索误标Medium致每次审批 2026-06-24 23:04:26 +08:00
lxy 93172a6844 修复: 工具执行无心跳致流中断误报 2026-06-24 22:13:48 +08:00
lxy 8df7e248b4 文档: 审查销账与CR-260622-01结案(TD-260621-05双轨销账+BUG残留销账+CR残留登记+REFACTOR描述更新) 2026-06-24 17:53:08 +08:00
lxy ba811ead96 修复: TD-260621-05审批状态双轨统一(executed→completed+V27迁移+测试) 2026-06-24 17:48:42 +08:00
lxy ebba9735ed 修复: BUG-260624-01残留(scrollToFirstPending return过早+listener props过滤) 2026-06-24 17:38:40 +08:00
lxy a7dbd50a4d 修复: aichat P2确定性bug批(audit吞错落日志+switch/delete占位终态化)
finalize: audit_tool_call insert 用 let _ = 吞 DB 错改 match Err tracing::error(对齐 audit_finalize:82模式,控制流不变不阻断,审计写失败落日志可确诊)
conversation+chat: switch/delete retain pending 前补 finalize_pending_placeholders 占位终态化(对齐 stop/clear/force_send,防占位 tool_result 残留下次发送喂 LLM);chat.rs finalize_pending_placeholders 可见性 fn→pub(crate)
cargo check --workspace 零错
2026-06-24 17:18:54 +08:00
lxy 7f6aa1e782 新增: AST代码智能Phase2(read_symbol drill下钻+调用点带行+prompt优先引导)
code_intel: read_symbol 加 drill(目标节点子树递归找嵌套子定义,命中走full)/calls 每项带 line号(与start_row同口径,Vue切段偏移自动对齐)/2 新测试(drill命中+drill_not_found兜底)
prompt: 中英能力列表加 read_symbol 优先引导(治 read_file 全文回灌 prompt 爆)+ 修支持集标错bug(.rs/.ts等原标不支持实为支持集)
tool_registry: read_symbol schema 加 drill 参数 + read_file desc 反向引导
密度: code_intel.rs 全文 25733B vs 骨架 1348B → 降 19.1x;16 单测全绿
2026-06-24 10:22:09 +08:00
lxy 40a97a7655 文档: 插件机制设计(grammar静态/集成切入点/通用远期)
复杂度=架构成本框架评估可扩展性(持续+复利负债)。
决策: grammar 静态编译+集中 lookup(当前,红利未兑现不承担动态加载成本)/
外部 API 集成(云效禅道)是插件正确切入点(窄契约窄插件,真实高价值)/
通用插件系统(VSCode式)远期(无生态=过早=负债)。
渐进路径: 静态grammar→云效集成直接实现→抽 IntegrationProvider trait→禅道印证抽象→插件化。
单向同步起步(双向冲突是分布式级难题,远期)。
2026-06-24 02:50:01 +08:00
lxy 7c2e3b23c1 新增: AST 代码智能 read_symbol 三态(治 prompt 爆,信息密度驱动)
tree-sitter 语法层符号解析,治 aichat read_file 全文回灌 prompt 爆(e46f5605 360K/8dfe0b94 5M)。
核心思想:信息密度≠压缩,read_symbol 按语义层级返符号骨架/全文,非物理读全文件。
- code_intel.rs: grammar_for 集中 lookup(Rust/TS/JS/Vue借TS,3 grammar 4 类)+ read_symbol 三态(骨架默认/全文)+ 不报错兜底(无grammar/解析失败/未找到→grep提示)
- tool_registry 注册 read_symbol(对齐 read_file handler 闭包模式)
- Cargo.toml 加 tree-sitter 0.25 + rust/typescript/javascript grammar
- AST 设计文档 grammar 策略章节定稿(静态编译+lookup,不动态不trait)
实测: code_intel.rs 全文 21851B vs read_symbol 骨架 897B,降 24.4x(达设计目标一个量级+)。单测 14 全过。
2026-06-24 02:49:48 +08:00
lxy 1ffa023f3d 修复: 消息重叠与审批卡死 2026-06-24 01:44:46 +08:00
lxy 61bad4fa28 新增: AST 符号解析设计(信息密度三态供给+主题压缩) 2026-06-24 01:43:12 +08:00
lxy bdf3f96491 文档: 待办与决策更新 2026-06-24 00:29:02 +08:00
lxy bb8b3c6c59 优化: 工程配置清理 2026-06-24 00:27:10 +08:00
lxy 96e554ce22 修复: 后端若干bug 2026-06-24 00:27:09 +08:00
lxy 0a8db6504a 优化: 设置页UX与AI自动执行三档 2026-06-24 00:27:08 +08:00
lxy 78b48d871b 新增: 跨端用户消息同步 2026-06-24 00:27:08 +08:00
lxy 698d981962 新增: 小程序端跨端AI对话 2026-06-24 00:19:54 +08:00
lxy 0c7c5e6068 新增: df-relay Dockerfile + .dockerignore(云端部署基建)
docker build -f crates/df-relay/Dockerfile -t df-relay .(context=repo根,multi-stage rust:slim 编译→debian 运行)。容器内 :8080(DF_RELAY_ADDR),宿主映射 :9180 避开测试机 8080 占用。
2026-06-22 03:50:37 +08:00
lxy f4b65f6c2e 新增: df-relay binary 入口(main.rs axum WS Server :8080)
Phase3 联调链补 relay 启动入口:src/main.rs 调 DefaultRelayServer::start(listen 0.0.0.0:8080,DF_RELAY_ADDR env 覆盖)+ tracing-subscriber 日志。cargo run -p df-relay 启动,提供 /ws/device + /ws/miniapp 端点。cargo check EXIT=0。
2026-06-22 03:43:17 +08:00
lxy 59f07bf060 修复: df-miniapp 打包结构(manifest/pages/uni.scss 移 src/ + 补 vite.config)
Phase2 落地遗漏 build 验证致结构错位:manifest.json/pages.json/uni.scss 在根(uni-app 找 src/)+ 无 vite.config.ts(vite-plugin-uni 未加载,-p mp-weixin 失效走 h5)。移 src/ + 补 vite.config 后 build:mp-weixin 产出完整小程序(app.js/app.json/pages/project.config.json)。
2026-06-22 03:43:16 +08:00
lxy a9703c3a1f 文档: Phase3 阶段2/3后端闭环销账
阶段2(EventBus Value+emit双写54处+send_raw_event+tunnel subscriber)✅ 后端上行闭环 / 阶段3(remote_bridge+miniapp4命令+on_command)✅ 下行闭环。剩阶段4真机联调 + relay_url/device_id UI + token生产级(Phase4)。
2026-06-22 03:33:48 +08:00
lxy ed8e2fc155 新增: Phase3 src-tauri 集成 df-tunnel(跨端闭环)
Cargo.toml(df-tunnel+uuid workspace) + state.rs(tunnel 字段 Arc<WsTunnelClient>) + lib.rs spawn tunnel task:device_id(KV/UUID 持久) + relay_url(KV/默认 localhost:8080) + token 固定常量 + subscriber(ai_event_bus→send_raw_event) + on_command(remote_bridge 下行路由) + connect(失败非阻断)。打通桌面↔relay↔miniapp 全双工闭环。cargo EXIT=0。
2026-06-22 03:31:59 +08:00
lxy 612de4cd7c 新增: Phase3阶段2 emit双写全接入(D2全19变体)
chat.rs 19 + audit/mod.rs 12 + guard.rs 3 + stream_recv.rs 2 + agentic/mod.rs 7(emit-only 补全,原11已落地)。每处 app.emit 前端 + publish_event tunnel 双路独立(emit 不删)。D2 全 19 变体透传 EventBus 供 tunnel subscriber 透传 miniapp。cargo EXIT=0。
2026-06-22 03:31:58 +08:00
lxy 60b73377f6 文档: Phase3 阶段2/3销账
阶段2(②EventBus Value载荷+agentic 11处双写+send_raw_event)✅ / 阶段3(remote_bridge+miniapp 4命令)✅ 部分登记;F+G 核验项销账(guard/审批态)。剩余:tunnel subscriber + setup 集成(src-tauri 集成模块从零)。
2026-06-22 03:11:46 +08:00
lxy 030ddb668a 新增: df-tunnel send_raw_event 出站透传
Phase3 阶段2(D2 全19变体透传):trait TunnelClient 加 send_raw_event(Value) + WsTunnelClient 实现;OutFrame::Event 改持 String(send_event/send_raw_event 预序列化,run_loop 只写 socket DRY)。cargo check + 5 测试过。
2026-06-22 03:11:46 +08:00
lxy 0fb5af50c7 新增: Phase3 EventBus Value载荷+桥接层
- EventBus 载荷改 serde_json::Value(D2 全19变体透传),删 AiBusEvent 强类型子集
- 新增 publish_event(AiChatEvent) helper(内部 to_value→publish,供 emit 双写)
- 新增 remote_bridge.rs 桥接层(MiniCommand→Tauri command 路由+R1 同conv并发兜底)
- agentic/mod.rs 11处 AiBusEvent→AiChatEvent 迁移(Error/Completed/Round)
2026-06-22 03:06:28 +08:00
lxy 8ccdce282c 新增: miniapp补齐4远程命令(approve/authorize_dir/continue_loop/stop_loop 对齐Phase3 §2.2) 2026-06-22 02:53:13 +08:00
lxy 5337c7d5e3 文档: Phase3阶段2前置决策登记(EventBus AiBusEvent 8变体 vs D2全19变体透传 载荷冲突) 2026-06-22 02:46:58 +08:00
lxy d38d1af68d 重构: df-tunnel Phase3阶段1纯透传(D1=A:CommandHandler收Value+payload透传不解析TunnelCommand,D5保留弱校验) 2026-06-22 02:45:36 +08:00
lxy 057fd0994e 文档: todo登记Phase3联调待办+跨端状态更新(Phase2三层全落+Phase3设计出) 2026-06-22 02:38:09 +08:00
lxy da886fa1c8 文档: todo销账(TD-260621-07 ToolResult字段已落地F-260621 grep) 2026-06-22 02:32:00 +08:00
lxy e8468c5bc6 新增: Phase3跨端联调设计文档(协议统一方案A纯透传+AiSession桥接风险+4阶段路线) 2026-06-22 02:29:13 +08:00
lxy 36a8391626 优化: 删除孤儿GeneralPanel.vue(拆3Section后零引用+plan阶段2弃用) 2026-06-22 02:29:13 +08:00
lxy a5a4fc887a 重构: df-miniapp移入apps/(monorepo分层 crates=Rust库/apps=前端应用)
- df-miniapp/ -> apps/df-miniapp/(uni-app 工程归集前端应用层,与 crates/ Rust 库分层)
- 删冗余 apps/df-miniapp/.gitignore(根已覆盖 node_modules/dist/.DS_Store 等)
- 根 .gitignore 加 unpackage/(uni-app 编译产物,唯一根未覆盖项)
- vue-tsc 零错(移动不改代码,类型不变)
2026-06-22 02:19:23 +08:00
lxy 280baeaee0 新增: df-miniapp uni-app跨端AI Chat(WS连relay透传+18变体AiChatEvent镜像)
- 脚手架: uni-app vue3 ts + 微信小程序manifest + pages(chat/conversations)+暗色主题
- WS客户端(ws.ts): uni.connectSocket连relay /ws/miniapp + Hello握手 + 指数退避重连 + 心跳Ping + JSON透传分派
- 类型镜像: events.ts AiChatEvent 18变体(对齐mod.rs grep核验) + relay.ts协议(BroadcastMessage/Hello/ControlMessage/MiniCommand)
- useAiChat: 18变体分派状态机(TextDelta累积/AgentRound flush/Tool状态/ConvState驱动generating) + send/stop/regenerate/switch + 乐观渲染
- 页面: chat(消息气泡/流式渲染/输入栏/工具调用展示) + conversations(列表占位)
- vue-tsc零错; 核验修正注释(变体数22→18, 心跳heartbeat→ping)
- 待对齐(Phase3联调): miniapp MiniCommand{cmd,args} vs df-tunnel TunnelCommand{kind,...} 格式需统一
2026-06-22 01:59:02 +08:00
lxy 25d65652cd 新增: df-tunnel Phase2 WS隧道客户端(出站穿NAT+Hello握手+收发循环)
- WsTunnelClient: connect_async连df-relay /ws/device + Hello(HelloKind::Device)
- 握手探测(HANDSHAKE_PROBE 3s):无负帧=relay静默通过 / error帧=Auth失败 / Close=Connect失败
- 收发循环: socket入帧→parse_command_from_broadcast提payload(kind=command)→on_command回调; mpsc出帧→socket写; heartbeat 25s Ping
- cleanup_conn: Close帧+500ms等task+abort兜底; connected用Arc<AtomicBool>共享
- backoff_delay(1→2→4→8→16→30s cap)预留Phase3 supervisor自动重连
- 协议对齐 df-relay(Hello字段/方向Event↔Command/payload透传),cargo check零warning+4测试通过
2026-06-22 01:55:46 +08:00
lxy 2b8b30ef41 新增: df-relay Phase2 WS中继实现(Hello握手+鉴权+ConnRegistry配对路由+Event/Command方向分发) 2026-06-22 01:47:01 +08:00
lxy 2fe1fe57c8 优化: tool_result截断50KB→8KB治GLM1214(conversation.rs持久化视图) 2026-06-22 01:45:44 +08:00
lxy d1d3871a88 重构: scan.rs拆4模块(stack/discover/readme/sample)共享常量提mod.rs 2026-06-22 01:45:43 +08:00
lxy 2a13f45650 文档: todo销账合并批(REFACTOR-02/08/10+ARC-e+related_ids+B-260619-02+version/ARC-d标注+Phase2评估暂缓) 2026-06-22 01:45:43 +08:00
lxy 83cee61476 新增: 列表分页UI(Paginator组件+三视图接入)
Paginator.vue复用组件(客户端切片,不发新IPC,数据量小前瞻基建)
Tasks/Ideas/Projects.vue接store列表切片(pagedProjects/pagedIdeas)
向后兼容铁律:pageSize=0整组件v-if隐藏+切片返全量,等价改造前行为
旧调用方(store.loadTasks等签名)零改动,分页状态纯前端ref
筛选/排序变化page重置1防越界空页;i18n双语pagination键齐备
2026-06-22 01:24:50 +08:00
lxy 82059bac4a 优化: tasks查询索引补全(V26 priority+assignee索引)
list_by_query已支持priority/assignee过滤下推,缺索引致全表扫描
migrate_v26 CREATE INDEX IF NOT EXISTS补建(老库幂等)
V1_SQL同步加(新库一次性建),对齐idx_tasks_status同类维度
数据量~15前瞻基建,为后续priority排序/分配人筛选铺路
2026-06-22 01:24:14 +08:00
lxy cf7fe867ee 优化: aichat B路线Phase1规划开关启用(PLANNING_ENABLED=true)
主loop走filter_tool_defs_planned规划(agentic/mod.rs:633接入点就绪)
双层开关可回退:PLANNING_ENABLED(主loop)+PLAN_HINT_ENABLED(plan_hint函数)
任一关闭退扁平旧行为;feature_flags_default_on测试断言同步
2026-06-22 01:24:14 +08:00
lxy 5d1749a58f 新增: 跨端WS隧道脚手架(df-tunnel客户端+df-relay云中继)
df-tunnel:桌面端出站WS客户端(TunnelClient trait+骨架,穿NAT连云后端)
df-relay:axum WS中继服务(转发小程序↔桌面端,广播骨架)
双crate独立消息骨架,不依赖src-tauri/df-types避跨crate强耦合
workspace crates/* glob自动注册,Phase2填充WS握手/鉴权/重连/路由
2026-06-22 01:24:01 +08:00
lxy 566bbcb394 修复: run_workflow空dag反序列化失败(nodes/edges加serde default)
BUG-260621-01: 前端推进链传空 dag {} 时,DagDef.nodes/edges 无 serde
default 致 Tauri IPC 入口报 missing field nodes,挡在 workflow.rs:91
template_for 选模板逻辑之前。加 #[serde(default)] 后 {} 反序列化成空
DagDef(HashMap/Vec Default 均空集合)→ is_empty() 进选模板分支。非空 dag
零回归。2 单测防回归,df-workflow test 51 passed。
2026-06-22 01:08:26 +08:00
lxy eda2a5f887 优化: aichat L2状态机双轨清理+读侧迁移+L3事件总线emit双写
L2 双轨清理:删 guard 本地 state 冗余字段(ConvState 已落 PerConvState
持久化作读侧真相源)。读侧迁移:ai_is_generating/ai_chat_stop 加
CONV_STATE_ENABLED 门控读 conv_state.is_active()。L3 emit 双写:mod.rs
关键事件 AiCompleted/AiError/AiAgentRound 经 ai_event_bus.publish 双写
(EVENT_BUS_ENABLED 门控,与 app.emit 并存非替换,高频 delta 不双写)。

双轨过渡收尾,可一键回退(CONV_STATE_ENABLED off 回退 bool 真相源)。
2026-06-22 01:03:49 +08:00
lxy c9b6e28433 新增: 三实体列表查询维度补全(status/关键词/排序/分页下沉后端)
任务/项目/灵感三实体新增 list_by_query 动态 WHERE(累积式 where_clauses
+params_vec 收口)+ order_by 白名单防注入 + limit/offset 钳制。命令层
list_{tasks,projects,ideas} 吃 Option<XxxQuery> 双参向后兼容(旧无参/单参
路径等价全量)。前端 Tasks/Ideas status/keyword 筛选下沉后端 query。

F-260621-02
2026-06-22 01:03:37 +08:00
lxy 72e57c6232 优化: L3事件总线入AppState(基建就位,emit双写留批3)
ai_event_bus字段独立于工作流event_bus,批2b仅入AppState+初始化。
emit点双写+前端订阅留批3:无后端订阅者(工作流独立域)+前端经ai-chat-event已收→全双写空转,待真实消费者(跨端/多模块)出现再针对性接入。渐进可回退。
2026-06-22 00:46:35 +08:00
lxy cdb227f999 优化: L2统一状态机完整(ConvState持久化+事件暴露+前端status联合)
1a PerConvState.conv_state持久化(入口/reset/drop写收敛,CONV_STATE_ENABLED门控)
1b AiChatEvent加AiConvStateChanged变体,guard持app_handle在迁移点emit
1c 前端convStates真相源+停止按钮三态(status union)+MaxRoundsCard读conv_state
双轨过渡:enum视图与generating bool并行,off降级可回退
2026-06-22 00:34:43 +08:00
lxy d2cada97cd 重构: aichat agent 能力系统化(L1元能力+L2/L3后端+list去重)
L1 agent 元能力层(治痛①死循环零交付):
- env_profile 环境姿势注入 + shell 默认 PowerShell(防引号地狱)
- 断路器:同类工具失败≥3熔断 + guard.reset
- detect_environment 主动探测工具(python/node/shell)
- 求助协议 AiHelpRequired 事件 + 前端求助卡

L2 统一状态机后端(治痛②,前端批2):
- ConvState enum 5态 + 合法转换守卫(conv_state.rs)
- GeneratingGuard 接入视图层(guard.rs)

L3 事件总线后端骨架(治痛③④⑤,接入批2):
- EventBus pub-sub + AiBusEvent 8变体(event_bus.rs)

list 工具调用重复治理第一步:
- build_system_prompt_with_excluded 去重被@实体 + 清单注明语
2026-06-22 00:04:14 +08:00
lxy bd6a41fe6e 新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理
后端:
- 工作流推进链(D-03):advance_task/状态机/闸门走 df-nodes Node trait,conditions 条件引擎扩展
- 想法评估闭环:启发式评分+对抗评估,df-ideas/scoring + df-storage/idea_eval_repo + idea 前端打通
- 全局事件数据总线:df-ai/context+context_helpers+augmentation 跨模块解耦
- AI planner/plan_hint/intent:aichat B 路线并行多轮基础
- patch_file 加固(TD-03/04):读改写整体锁防 lost update,expected_hash 合约闭环
- 压缩超时兜底(F-15 卡死根治)
- F-09 多会话并发:LlmConcurrency per-conv + streamingGuard 前端守护 + verify 脚本
- 知识注入 DRY/skills/audit 扩展

清理:
- aichat 技术债(误报 allow/死导入/过时注释 30 项)
- URGENT.md 删除(11 项加急全解决/迁 todo)
- 文档整理(todo/待决策/待审查/ARCHITECTURE/INDEX + 总线/技术债审查新文档)
2026-06-21 20:51:26 +08:00
lxy 330bb7f505 新增: 设置页 UX 重构(master-detail 左导航 + 搜索 + 即时反馈 + 导入导出)
- SettingsNav 左导航 7 类 + 顶部搜索(索引匹配 label/desc 高亮定位)
- GeneralPanel 拆分 Appearance/Performance/Advanced Section
- SettingRow 统一行 + 即时反馈(已保存)+ 下轮生效 badge
- 统一组件库 settings.css(scoped 重复 class 抽全局)
- 导入导出(默认排除 keyring 密钥)
- activeCategory 持久化 + 窄屏折叠 tab
2026-06-21 20:50:44 +08:00
lxy 4b5da38e28 新增: 并行多轮Phase0落地路线图+4agent论证整合
Phase0十维方案(锁分片/三层Semaphore/sub_react_loop/审批sub_flow/压缩协调/UX事件schema/规划校验/planner+plan_hint/全局协调/测试矩阵)+路线图(DAG/关键路径115人时/0a-0b-0c三子阶段独立可回退);4agent共识P0机制缺失须先Phase0
2026-06-20 16:34:59 +08:00
lxy 44ecdf825e 新增: 并行多轮设计文档+审查回填+待决策更新
Plan-driven子流并行Phase1-3/CR-260620-01~03审查回填/④必须做+②已完成
2026-06-20 15:54:51 +08:00
lxy 126bee5b24 优化: F-09生成中新建对话不中断(决策e)
去中断弹窗+去generatingConvs.clear(对齐switchConversation真并发)+死key清理;vue-tsc✅
2026-06-20 15:54:51 +08:00
lxy e2ece2d898 优化: AI授权目录改进(误弹窗根因+安全加固)
strip_verbatim比对侧收口/黑名单统一/symlink/bind双路径reload/toast接线;cargo test 18✅
2026-06-20 15:54:51 +08:00
lxy e5a89165e6 文档: todo 更新(跑题 P0-P2 ✅ + F-260619-03 ✅ 去固定根完整)
- AI Chat 跑题改进 P0-P2 全完成(系统提示聚焦 + 意图接入 + 压缩增强 + 工具压缩 +
  主题检测 + 38 苛刻测 + db 基线试验记录,de04970/a2db5c7)
- F-260619-03 Phase A/B/C + workspace_root 去固定根完整(is_authorized 删固定 +
  reload 尊重用户 persistent,CR-09 + b22e9ae)
2026-06-20 05:06:37 +08:00
lxy b22e9ae912 修复: workspace_root 去固定根完整(reload 尊重用户 persistent)
CR-28 审查 🟡 WATCH-1:reload_allowed_dirs 仍无条件 insert workspace_root(pre-existing),
致 commit 声称"用户删白名单后需授权"无法兑现(reload 塞回 persistent)。
修:reload KV 有配时不无条件 insert workspace_root,尊重用户 persistent。
(仅 KV 未配 default_with_root 含 workspace_root,首次免授权行为不变)

CR-28 审查回填(✅ PASS,🟡 WATCH-1 已修闭环)。
2026-06-20 04:14:10 +08:00
lxy e4c2e310a5 新增: idea source 消息级溯源 + workspace_root 去固定根 + run_command 偏好修复
idea source 结构化(消息级溯源闭环 — 灵感来源 conv_msg:{id}):
- 新建 idea_source.rs(IDEA_SOURCE_AUTO_FILL_ENABLED + maybe_fill_idea_source 保守五条件:
  开关/create_idea/source 空(不覆盖 AI 填)/message_id 非 None/result.id)
- audit/mod low_risk + chat ai_approve 双执行路径接补全(create_idea Medium 审批)

workspace_root 去固定根(F-260619-03 收尾):
- is_authorized 删 workspace_root 固定放行(代码硬编码),改走 persistent/session 白名单
- default_with_root 保留(默认初始 persistent,首次免授权行为不变)
- 用户从白名单删工程根后也需授权(动态白名单完整语义)

run_command 偏好修复(db 分析:591ff4a0 run_command 31/56=55% 严重偏好):
- P1 描述收紧(明确仅命令执行 + 负向引导:读取 read_file/编辑 patch_file/列目录 list_directory/搜索 search_files)
- P2 ToolDomain::Exec(run_command 独立 domain,仅 Debug 含 Exec,Code/File/Search 不含)
- P3 注册顺序(run_command 放 File 层最后)+ DEBUG_RULES 加命令执行关键词

自验: df-ai 228(intent Exec)+ devflow 169(idea source)+ workspace EXIT 0
2026-06-20 04:08:10 +08:00
lxy 7911bc292c 文档: 审查回填 CR-22~27 + 跨端 AI Chat 设计 + 跑题试验记录
审查回填(均 ✅ PASS):
- CR-22 摘要不改 updated_at / CR-23 P2 批次A 读 / CR-24 批次B 写 / CR-25 跑题P0 /
  CR-26 跑题P1 / CR-27 跑题P2(6维度全过,topic 无破坏 + 双高置信保守)

跨端 AI Chat 设计(F-260620-01):
- 三层架构 df-tunnel/df-relay/df-miniapp + Rust 云后端选型(代码复用/类型一致/团队版演进)
- todo 规划跨端 P1-P4 + 跑题修复 P0-P2

跑题改进试验记录:
- db 基线(3 长对话:assistant 文本仅 4-5%,95% tool_result,47% echo)
- 5 类跑题现象与 P0-P2 五改进精准对应 + 四层测试计划 + 迭代过程
2026-06-20 03:54:59 +08:00
lxy de049704c6 新增: 消息级溯源 P2 切 ai_messages + AI Chat 跑题改进 P0-P2 + 标题诊断 + 苛刻测
消息级溯源 P2(一次性切读 b + 备份保留):
- 批次A 读路径切 ai_messages(state.rs AppState.ai_messages + 映射 ChatMessage↔AiMessageRecord +
  switch/export load 切 + fallback 兜底)
- 批次B 写路径切 ai_messages(replace_conversation 单事务全量重写 + save/clear 切 + 旧 messages 备份)
- 摘要/压缩不改 updated_at(save_conversation touch_updated_at,compress false 防时间分组跳变)

AI Chat 跑题改进 P0-P2(5 改进,治跑题 5 根因,三原则优雅/可靠/易迭代):
- P0 系统提示聚焦(## 聚焦准则独立段)+ 意图接入 loop(filter_tool_defs 收敛工具 29→5-10,三重 fallback)
- P1 压缩增强(compress prompt 主题锚点 + 失败关键词兜底 extract_keyword_summary)+
  工具结果压缩(should_summarize + extract_key_info view-only 不改持久化)
- P2 主题检测(TrackedMessage.topic + 双高置信保守标记 + tokenize 2-gram 修复中文锚点)
- title LLM complete 失败诊断日志(助定位返空根因)
- 跑题 P0 审查🟡修复(Debug 加 Data 域 + threshold 注释)

苛刻测 38 条(边界/对抗:全漂移/全停用词/全错误行/2KB 边界/连续主题切换/中文 2-gram/emoji)
df-ai 227 passed + workspace EXIT 0
2026-06-20 03:54:58 +08:00
lxy 4e3a11f925 修复: 主菜单点不开 + 对话列表样式/菜单 + 标题卡死/弹回/updated_at(God 拆分遗漏 + 多 bug 合并)
主菜单点不开(7f1242c):c38cefe ErrorBoundary 嵌 transition 内部,slot 透传非元素根 →
  mode="out-in" 过渡卡死 → 点主菜单 URL 变页面不切换。修:ErrorBoundary 移 transition 外 +
  根固定 div.error-boundary-root + display:contents

对话列表样式/菜单(God 拆分遗漏 4f8f1cf + 脉冲点 5c395f4 + 审查 agent 菜单):
  - God 拆分样式没移子组件,AiChat scoped 不匹配子元素 → 列表/输入框样式丢。修:样式移
    ConversationSidebar/ChatInput scoped + 复用抽 components.css
  - 脉冲点 vertical-align/margin-left 冲突。修:移除用 flex gap 统一
  - 4 操作按钮窄宽撑高 → 收进"..."下拉菜单(置顶/归档/删除)+ 移除导出(后端 IPC 保留)

标题 3 重根因(8192cd2 合并):
  - 卡死:ensure :40 title.is_some() return,"新对话"占位永远跳过;switch need_title_regen
    仅 None 不触发。修:排除"新对话"占位 + need_title_regen 含"新对话"
  - 弹回::121 无条件落 LLM 结果,clean_title 空兜底"新对话"覆盖 extract。修:非空非"新对话"才落
  - updated_at 跳变:update_field 强制 SET updated_at=now,标题生成致时间分组跳。修:set_title 不动 updated_at

CR-20/21 审查登记(待审查.md)
cargo check + vue-tsc + vite build EXIT 0
2026-06-20 01:29:20 +08:00
lxy 4746288a70 优化: 待审查 CR-19 样式重构审查回填(✅ PASS)
6 维度全过:纯移动无属性暗改(集合差集核验)+ AiChat scoped CLEAN +
子组件 scoped 匹配(ChatInput 33/Sidebar 71 引用)+ 复用抽取 + 容器保留 +
TopBar 接管(38 选择器)+ 编译。hover 重构前后逐字一致(非 4f8f1cf 回归)。
清理审查 agent 遗留重复登记块。无 🔴/🟡 阻塞项。
2026-06-20 00:36:59 +08:00
lxy 5811991887 优化: 待审查 CR-18 清理重复块 + CR-19 样式重构登记
CR-18 脉冲点样式:✅ PASS(清理审查 agent 遗留重复登记块)
CR-19 AiChat 样式同步子组件 scoped + 复用抽取:🟡 待审(God 拆分遗漏修复)
2026-06-20 00:30:11 +08:00
lxy 4f8f1cf794 重构: AiChat 样式同步到子组件 scoped + 复用抽取(God 拆分遗漏修复)
根因:AiChat God 拆分(抽 ConversationSidebar/ChatInput/MessageList)时样式没移子组件,
留 AiChat <style scoped>。Vue scoped 不匹配子组件内部元素(子元素无父 data-v)→
对话列表/输入框样式丢失 → 布局塌陷(主导航被覆盖点击无反应 + 样式乱)。

修复(纯样式移动,不改选择器/属性/值):
- AiChat <style scoped> 886→151 行(瘦身 83%)
- 侧栏 .ai-conv-* 47 选择器 → ConversationSidebar <style scoped>(新建)
- 输入框 .ai-input-*/.ai-skill-*/.ai-mention-*/.ai-send-btn 40 → ChatInput <style scoped>(新建)
- 复用 .ai-btn-icon* 6 处 → components.css(全局,TopBar+ConversationSidebar 共用)
- 容器 .ai-panel/.ai-chat-area/.ai-queue-*/.ai-toast* 32 → AiChat 保留 scoped
- 清理 TopBar 重复块(AiChat 漏删死代码,零视觉变更)+ 删 TEMP DEBUG 参考线

自验: vite build ✓ + vue-tsc EXIT 0 + AiChat scoped CLEAN(0 子组件类)
2026-06-20 00:28:14 +08:00
lxy 5c395f4825 修复: 对话列表脉冲点样式错位(vertical-align/margin-left 冲突)
CR-14 多会话脉冲点 .ai-conv-gen-dot vertical-align:middle 在 inline-flex 父内无效
+ margin-left:4px 与父 gap:3px 叠加(间距 7px 不一致)+ 脉冲点可能被 ellipsis 挤出。
- 移除 vertical-align:middle(flex 用 align-items center)+ margin-left:4px(用父 gap 统一)
- .ai-conv-item-title 加 min-width:0(允许 flex 收缩,脉冲点 flex-shrink:0 不被 ellipsis 挤出)

深入分析(Explore agent)另发现 MEDIUM/LOW(搜索缺 --flash 动画/置顶 hover 才可见/归档缺重命名提示),
属交互一致性非样式错乱,本次只修脉冲点 HIGH,余项按需后续。
2026-06-19 23:50:27 +08:00
lxy 70de99ae77 增强: df-mcp list_tasks spec 声明 status 参数(CR-13 ⚪1 补)
CR-13 审查 ⚪ WATCH-1:df-mcp list_tasks spec 未声明 status(MCP 客户端 schema 不可见)。
补 spec 加 status field(todo/in_progress/in_review/testing/blocked/done/cancelled)。
handler 早已支持过滤(2f8b13f),本次补 schema 声明对齐 tool_registry。
cargo check df-mcp EXIT 0
2026-06-19 23:06:11 +08:00
lxy 26765a050a 优化: 待审查 CR-260619-17 ai_node 拆分审查回填(✅ PASS)
6 维度全过:逻辑等价(逐字搬运)+ AiSelfReviewNode 抽出完整 + 测试共置 +
state.rs 路径引用 + 注册键不变 + helpers 未重复抽。
Node trait/路径引用/注册键三项无回归。df-nodes 82 passed + workspace EXIT 0。
清理审查 agent 遗留重复登记块。无 🔴/🟡 阻塞项。
2026-06-19 22:17:20 +08:00
lxy c61a222db8 重构: ai_node.rs 拆分(836→423,抽 AiSelfReviewNode,REFACTOR-260619-06)
纯结构拆分,逻辑等价,Node trait/AiNode API 不变:
- 抽 ai_self_review_node.rs(447,AiSelfReviewNode 结构体+Node impl+build_review_prompt,
  符合 crate 一节点一文件约定:human_node/script_node/task_advance_node)
- ai_node.rs 瘦身至 423 行(保留 AiNode+测试,params/self_review 纯逻辑已在 ai_node_helpers.rs)
- 测试共置(9 自审测试移 ai_self_review_node::tests)
- state.rs 路径引用更新(ai_node::AiSelfReviewNode → ai_self_review_node::AiSelfReviewNode)
- 注册键 "ai_self_review" 字符串 + node_type() 不变

注:任务清单标 1107 行实为 836(标注有误),params/self_review 已在 ai_node_helpers(298)
自验: df-nodes 82 passed + workspace EXIT 0
2026-06-19 22:11:48 +08:00
lxy ad25925af7 优化: 待审查 CR-260619-16 ToolCard 拆分审查回填(✅ PASS)
6 维度全过:逻辑等价 + 8 渲染分支完整 + 接口契约零变更 + composable 抽离 + 瘦身 374 + 行为等价。
CSS class 0 丢失(sort -u + comm 核验)。
⚪ WATCH:7 diff class 重复(scoped 不跨组件,等价非冲突,后续全局 DRY 低优先)。
清理审查 agent 遗留重复登记块。无 🔴/🟡 阻塞项。
2026-06-19 21:52:13 +08:00
lxy 74f38609e0 重构: ToolCard.vue 拆分(1167→374 行,REFACTOR-260619-04)
纯结构拆分,逻辑等价,渲染/交互/接口契约不变:
- 抽 ToolResultBody.vue(545 行,8 工具结果渲染分支:read_file/list_directory/
  search_files/write_file/patch_file/run_command/http_request/通用 CRUD)
- 抽 useToolCardRender.ts(45 行,parseDiffLines 渲染纯函数)
- 抽 useToolCardHeader.ts(154 行,toolDisplayName/displayArgValue/projectNameById 头部显示)
- ToolCard.vue 瘦身至 374 行(容器+头部+审批区+状态机,结果展示委托 ToolResultBody)
- props/emits 契约不变(ToolCardList/AiChat/MessageList 调用零改动)

vue-tsc EXIT 0 + 8/8 渲染分支完整核对
2026-06-19 21:45:09 +08:00
lxy ba47f44ab8 优化: 待审查 CR-260619-15 F-260619-01 任务关联灵感审查回填(✅ PASS)
7 维度全过:V20 迁移幂等 + task_repo 3 处 SELECT 完整 + 6 处 TaskRecord 构造补 idea_id + 前端 router-link 展示。
⚪2 WATCH:schema_version INSERT 非 IGNORE(同 v1-v19 统一模式)/ V20 测试 mock(同 V21 模式)。
清理审查 agent 遗留重复登记块。无 🔴/🟡 阻塞项。
2026-06-19 21:09:25 +08:00
lxy 4a87c552cc 新增: F-260619-01 任务可关联灵感(tasks.idea_id 1对1 单向)
- df-storage V20 迁移:tasks 加 idea_id TEXT REFERENCES ideas(id)(填预留空位,
  幂等 column_exists 探测)+ V9_SQL 同步 + TaskRecord.idea_id + task_repo SQL/SELECT + 白名单
- AI 工具 create_task 加 idea_id 参数;update_task 经白名单自动支持
- IPC commands/task.rs CreateTaskInput 加 idea_id
- 前端 types TaskRecord/CreateTaskInput idea_id + TaskDetail.vue 关联灵感展示
  (router-link 友好 title 非裸 id)+ i18n
- 补 idea_id 字段:df-mcp tools / df-nodes 测试 helper
- 单向 1对1,Option 可空,复用 projects.idea_id 模式,老任务 None 兼容

自验: df-storage 47 passed(含 V20 2)+ workspace EXIT 0 + vue-tsc EXIT 0
2026-06-19 21:03:18 +08:00
lxy e5a8fec1fe 文档: 待决策登记 F-260619-05 任务关联灵感 + 消息级溯源 P2 切读策略
F-260619-05:产品粒度/方向决策(1对1/1对多 + 列/关联表 + 单向/双向),
主代推荐 tasks.idea_id 复用 projects 模式(1对1 单向起步)。
P2 切读:技术策略 a 三阶段渐进(推荐)/ b 一次性,
大改 ContextManager 读写路径+双写+删旧列,待用户知情拍板。
用户拍板后 spawn 实施解锁后续推进。
2026-06-19 20:37:10 +08:00
lxy 6a7a8cbaac 优化: 待审查 CR-260619-14 多会话前端UI审查回填(✅ PASS)
6 维度全过:Set 响应式/迁移完整/AiError 精确清/侧栏并行/独立窗口/向后兼容。
AiError delete(convId) 修原 =null 全局误清(多会话并发正确性核心修复)。
⚪2 WATCH:newConversation clear 全清(前端视觉非后端)/streaming 单值联合判定。
清理审查 agent 遗留重复登记块。无 🔴/🟡 阻塞项。
2026-06-19 20:20:12 +08:00
lxy 7d5402951b 新增: 多会话并发前端 UI 阶段3/4(F-09 收尾)
后端 per-conv 并发已就绪(d899c58 + CR-03~07),前端呈现层收尾:

阶段3 多会话生成态:
- stores/ai.ts generatingConvId 单值 → generatingConvs:Set + add/remove/isGenerating helper
- useAiEvents 5 处迁移(Completed/Error/路由标记改 per-conv Set 操作,
  AiError 精确清出错会话非全局清空,修原 = null 误清其他会话)
- ConversationSidebar 多会话并行生成脉冲指示(isGenerating 各会话独立)

阶段4 独立窗口多会话:
- useAiWindow label ai-detached → ai-detached-{convId}(per-conv 独立窗口)
- localStorage 单 key → per-conv key(防多会话串扰,读路径兼容旧 key 回退)

vue-tsc EXIT 0 + generatingConvId 零代码残留(仅注释)
2026-06-19 20:15:37 +08:00
lxy 19eb14ffc8 优化: 待审查 CR-260619-12/13 审查回填(均 ✅ PASS)
CR-12 P1+重试:✅ PASS 🔴0🟡1⚪1
- message_id 传递完整 + 老数据兼容 + 症状1+2 + 重试不丢参数
- 🟡 MED-1 主代已修(flushCurrentText 向前找占位)

CR-13 任务查询:✅ PASS 🔴0🟡0⚪1
- df-mcp spec status 待补(中文 Edit 编码限制,handler 功能就绪)

清理审查 agent 遗留重复登记块。
2026-06-19 20:04:25 +08:00
lxy 6034e58b43 修复: flushCurrentText 向前找非 isError 占位(UX-260619-06 MED-1)
CR-12 审查 🟡 MED-1:AiStreamRetry→AiError 链路,末条 isError 气泡致 flush 跳过,
占位 assistant(部分回复)未填充,currentText 清空丢。
修:flushCurrentText 从末尾向前找第一个非 isError assistant 写入
(跳 AiStreamRetry 错误气泡,写其前占位)。

vue-tsc EXIT 0
2026-06-19 20:04:25 +08:00
lxy 2f8b13ff7c 增强: 任务查询支持按状态过滤(list_tasks status)
- AI 工具 list_tasks: schema 加 status 参数 + handler 内存过滤
  (todo/in_progress/in_review/testing/blocked/done/cancelled)
- df-mcp list_tasks: handler 加 status 过滤(MCP 对外一致)
  (spec 声明待补:中文 Edit 编码匹配限制,后续补)

自验: workspace EXIT 0 + df-mcp cargo check EXIT 0
2026-06-19 19:54:16 +08:00
lxy d3e7640b8d 新增: 消息级溯源 P1 + 修复流式失败重试(UX-260619-06)
消息级溯源 P1(溯源字段升级):
- ContextManager 加 last_assistant/last_user_message_id 取消息 id
- audit 6 处 message_id 接真值(替换 P0 None 占位)
- 知识提炼 source_ref 升级 conv_msg:{id}(知识生命线 extracted/referenced 双事件)
- 4 处 build_knowledge_context 调用方传 user message id
- 老数据 id=None 降级 conv:{id} 兼容

流式失败重试修复(UX-260619-06):
- 症状1(回答一半消失):AiError 收尾前 flushCurrentText 保留部分回复到占位气泡
  (原仅 AiAgentRound/AiCompleted 回填,AiError 清 currentText 致部分回复丢失)
- 症状2(重试报"没有可重新生成的回复"):ai_regenerate pop_last_assistant_round 返 false 时
  末尾是 user(流式中途失败这轮 assistant 未持久化)直接从该 user 重跑(等价重发)
  1214 连续 user 已由 merge_consecutive_users(7c98134)修,本批补失败重试链路

自验: workspace EXIT 0 + cargo test 全绿 + vue-tsc EXIT 0
2026-06-19 19:54:16 +08:00
lxy cea0c71546 修复: df-mcp evaluate_idea 写回失败感知 + list_trash DB 错误感知(CR-11 ⚪2)
CR-260619-11 审查 ⚪2 可选改进(MCP 对外场景,非阻塞):
- evaluate_idea: 写回 scores 失败返 err(原 let _ 丢弃)+ 响应用写回后 rec(原 idea 旧版本)
- list_trash: DB 错误返 err(原 unwrap_or_default 吞错误返空列表对外误导)
df-mcp 8 passed 无回归
2026-06-19 19:33:40 +08:00
lxy ef465625ed 优化: 待审查 CR-260619-10/11 审查回填(均 ✅ PASS)
CR-260619-10 消息级溯源 P0:✅ PASS 🔴0🟡0⚪1
- 迁移幂等/坏数据容错/全量数据完整性严密 + id 并发安全 + AiMessageRepo 5 方法正确 + P0 边界坐实(0 调用方)
- ⚪ WATCH:V21_SQL/V9_SQL 镜像双向维护(已有注释护栏)

CR-260619-11 MCP Server:✅ PASS 🔴0🟡0⚪2
- JSON-RPC 协议 + 安全降级三层严密无漏 + 不暴露文件系统 + 19 工具复用正确 + CLI 子命令 + WAL
- purge_project 未注册是设计正确(物理删不对外)
- ⚪2 可选:list_trash 吞 DB 错误返空 / evaluate_idea 丢弃写回(非阻塞)

清理审查 agent 遗留重复登记块。无 🔴/🟡 阻塞项。
2026-06-19 19:32:16 +08:00
lxy 353b07427f 文档: todo 登记 F-260619-05 任务可关联灵感
tasks 表无 idea 关联字段;projects 已有 idea_id 模式可复用。
设计点(粒度 1对1/1对多 + 存储 列/关联表 + 方向待决策)+
依赖消息级溯源 P0 完成(释放 migrations/models)。
2026-06-19 19:24:02 +08:00
lxy f217e39bfe 新增: MCP Server(df-mcp crate + stdio 协议 + CLI 子命令)
依据 F-260619-02 设计,对外暴露数据层接入 Claude Code/Cursor:
- 新建 df-mcp crate:JSON-RPC 2.0 over stdio(initialize/tools/list/tools/call)
- 暴露 19 数据层工具(项目/任务/灵感/工作流/回收站),复用 df-storage Repo 零重复 CRUD
- 三层安全降级:High 风险(delete/purge/run_workflow)tools/list 不暴露+dispatch 拒+handler 二次拒;Medium 允许+审计日志;--read-only 仅 Low
- 不暴露文件系统工具(防绕过应用内路径校验)
- Tauri CLI 子命令 devflow mcp-server [--db/--read-only/--print-config]
- SQLite WAL 已启用(复用 Database::open),应用与 MCP server 并发写安全

自验: df-mcp 8 passed + workspace EXIT 0
2026-06-19 19:24:02 +08:00
lxy e981c1492a 新增: 消息级溯源 P0 地基(ChatMessage.id + ai_messages 拆表 + 迁移 + Repo)
依据消息拆分存储设计 + 消息级溯源设计 P0(地基,P1 溯源/P2 切读待后续):
- df-ai-core ChatMessage 加 id 字段(Option<String> serde 向前兼容)+ new_message_id(AtomicU64+ts 并发安全)
- 6 构造器生成 id,老 JSON 无 id → None 兼容
- df-storage V21 一次原子迁移:建 ai_messages 表 + ai_tool_executions.message_id 列 + 全量数据迁移(分批+坏数据容错+COUNT 幂等)
- AiMessageRecord + AiMessageRepo(insert_batch/list_by_conversation/delete_range/update_status/update_content_by_tool_call_id)
- audit message_id 列补建(conversation_repo/settings 白名单)
- src-tauri title.rs/finalize.rs 字面量占位(P1 接真值)

自验: df-storage 45 passed + df-ai-core 28 passed + workspace EXIT 0
2026-06-19 19:24:02 +08:00
lxy 44d1c6a00c 优化: 文档路径同步 + 审查登记 + 消息级溯源设计文档
- PROGRESS/URGENT 文档分类后子目录路径修正
- 待审查.md 登记 CR-260619-08 intent + CR-260619-09 PhaseB+C(均 ✅ PASS)
- 新增消息级溯源设计(消息拆分存储 + 知识/审计/灵感全链路消息级定位)
2026-06-19 18:58:49 +08:00
lxy db129fd78f 修复: AI 消息卡片友好展示
- update_task 卡片后端补返 title + 前端 title 优先 + i18n 文案对齐(UX-260619-04,防裸 UUID id 辨识度低)
- anthropic_compat 合并连续 user 块,修 GLM 1214 协议交替违规(B-260619-03)
2026-06-19 18:58:49 +08:00
lxy 8da6bdcec2 新增: AI 工具意图识别层 + 文件访问权限模型 Phase B+C
会话意图识别层(intent.rs / df-ai crate):
- Intent 枚举 11 态 + recognize 规则识别(关键词+权重+优先级)+ tool_subset_for 工具子集映射
- 模态接口预留 None,独立模块待接入 loop,36 单测

文件访问权限模型 Phase B+C(F-260619-03):
- Phase B 会话临时白名单 + 循环挂起 + DirAuthDialog 弹窗(仅本次/未来都允许/拒绝)
- Phase C 系统目录黑名单(Win System32/Program Files;Unix /etc /usr...)分段匹配 + 写操作约束
- 已审 CR-260619-09 ✅ PASS
2026-06-19 18:58:48 +08:00
lxy 968c3df5ab 新增: F-260619-03 PhaseA AI工具文件访问动态权限白名单
- state.rs: AllowedDirs(persistent HashSet + is_authorized workspace_root+starts_with + canonicalize) + AppState allowed_dirs Arc + reload/set/get Settings KV
- tool_registry.rs: resolve_workspace_path 多目录白名单双校验(词法+canonicalize) + handler闭包引入AllowedDirs Arc(9文件工具) + run_command不走白名单
- commands/config.rs: ai_get/set_allowed_dirs IPC + lib.rs注册
- 前端: api/ai.ts + AllowedDirsPanel.vue(Settings授权目录UI) + i18n
- 测试: 5 AllowedDirs单测 + 基线适配(29工具不变)
workspace_root始终授权(零回归); Phase A仅持久化(不含B弹窗/C写约束)
主代兜底: cargo check devflow 0 + test 129 + vue-tsc 0
2026-06-19 16:41:45 +08:00
lxy ec1daa8844 新增: AI Chat @灵感提及(ChatInput mention idea组)
- ChatInput.vue: MentionItem type加idea + mentionItems加idea过滤(title/description projectsStore.ideas) + mentionGroups加idea组 + selectMention插入[灵感: title]
- i18n zh/en: mentionGroupIdea
灵感走store同步computed(同project/task模式 无IPC)
2026-06-19 16:26:42 +08:00
lxy c38cefe47b 优化: P2低风险收尾(router404+meta.icon清理+ErrorBoundary接入) 2026-06-19 15:04:05 +08:00
lxy 5feeaa6cb0 重构: P0前端架构(useAiSend/useAiEvents/useToolCard拆分+status union全)
squash合并:
- P0-1 useAiSend审批抽离(useAiApproval)
- P0-2 useAiEvents switch拆3域handler
- P0-3 useToolCard formatToolResult策略表
- P0-4 status union(Idea+Task+Project+Workflow+AiToolCall)
2026-06-19 15:04:04 +08:00
lxy 998a2f243d 文档: 架构方案文档(意图识别论证+多主题愿景/论证+文档物理分类+边界清晰化)
squash合并:
- 意图识别层论证(8维度+10业界佐证)
- 多主题上下文管理愿景+并存论证+补充论证(多轮agentic)
- 架构设计文档物理分类(四子目录+INDEX+命名规范+引用同步+边界清晰化)
- 前端架构技术债清单归档
2026-06-19 15:04:04 +08:00
lxy f1a06732fd 新增: http_request AI工具(结构化HTTP·SSRF防御·High审批)
- 后端: Cargo.toml reqwest0.12(json/gzip/brotli native-tls对齐df-ai) + http.rs(handler+SSRF三层防御 validate_url/resolve_and_check/重定向≤3跳每跳校验) + tool_registry register_http_tools + 基线28→29
- 前端: useToolCard formatHttpResult + ToolCard渲染(method/url/status/body折叠/截断) + HIGH_RISK_TOOLS + confirmHighHttp + i18n
- SSRF: localhost/私有IP(127/10/172.16/192.168/169.254云元数据/100.64CGNAT/≥224/IPv6保留)拒绝 + DNS resolve校验防重绑定 + 重定向每跳重校验
- 审批: High(统一保守 GET也审批 防漏写操作)
- 截断: 响应50KB(T-05)+ 请求1MB + 超时1-60s默认30
2026-06-19 12:48:28 +08:00
lxy b42cc66e7a 重构: P0-4 IdeaRecord.status union类型化(试点·类型安全)
- types.ts: 新增 IdeaStatus union(draft|pending_review|approved|rejected|promoted|archived, 对齐后端df-types IdeaStatus serde snake_case) + IdeaRecord.status string→IdeaStatus
- Ideas.vue/IdeaDetail.vue/IdeasPanel.vue: statusOptions/statusLabelKey/onStatusChange/emit收窄 类型IdeaStatus(编译期校验拼写错)
- stores/project.ts/state.ts: 比较 IdeaStatus
后端契约: union 6值=df-types IdeaStatus serde, IPC/DB不变零行为变更
P0-4试点跑通: 推广路径验证(union+父子类型传递+emit收窄); 剩余TaskRecord/AiToolCallInfo/WorkflowRecord/ProjectRecord status待推广
2026-06-19 12:38:20 +08:00
lxy b237f716dd 重构: 前端命名一致性调整(低风险·2处)
- ToolCard.vue:392 MAX→MAX_DIFF_LINES(描述性, 对齐项目常量规范)
- ConversationSidebar.vue:365 BUCKET_LABELS→bucketLabels(computed用camelCase非UPPER_SNAKE)
命名审查整体一致(A95): 文件/组件/变量/类型/常量/composable/api函数/emit事件名/props全合规
留待(中高风险): store模式统一(useXxxStore vs createXxxStore跨文件) / defineEmits写法统一(14组件) / KnowledgeConfig snake_case后端契约
2026-06-19 12:36:49 +08:00
lxy 874cad22db 文档: 前端架构技术债清单归档(2026-06-19审查·供后续AI coding)
- docs/06-前端开发/前端架构技术债-2026-06-19.md: P0/P1/P2清单 + 留待用户决策 + AI coding强项
- 来源: fe-arch-review子代理多角度审查(B+ AI coding 7.5/10)
- 已修复: i18n as any收口(f4cc2e5); 留待: status union试点/AI三大件拆/ErrorBoundary接入/components归位/meta.icon
2026-06-19 12:26:17 +08:00
lxy 11b579eb87 重构: 前端架构审查降技术债·i18n收口(高ROI)
- 新建 src/i18n/i18n-helpers.ts: 类型安全 t + currentLocale(any中转内部1处带注释)
- 12文件改 i18n as any 样板收口: utils/time + stores(knowledge/project/{ideas,projects,tasks,workflow}) + composables(aiShared/useAiConversations/useAiEvents/useAiSend/useAiStream/useToolCard)
- as any 22→10(i18n 13→0对外), 签名统一(key, named?)
- useToolCard文档块同步(防AI被过时注释误导)
2026-06-19 12:25:10 +08:00
lxy cfae6027d2 重构: layout全局布局抽取(strategy前端架构)
- 新建 components/layout/AppSidebar.vue(289行): Logo/Nav/Footer/AI toggle/响应式768px折叠
- 新建 components/layout/AppLayout.vue(129行): shell容器+sidebar+main+AI Panel拖拽+3 slot(default/detached/ai-panel)
- App.vue 546→179: 收敛应用初始化(主题/locale/迁移/dataChangedListener/Cmd+I) + slot填充router-view/AiChat + 根Toast
多角度分析: App.vue原含全局布局, 11 views只渲染内容(无跨view重复), 值得抽取(546行可清晰拆)
strategy: 前端架构, layout全局布局抽取; scoped CSS按渲染归属拆三组件(hash一一对应零视觉差异); 填补空layout/目录(此前预留)
2026-06-19 12:19:03 +08:00
lxy 7748e3ecbc 重构: 前端DRY(style全局沉淀 + ts/js公共沉淀)
style-dry:
- 新增 src/styles/components.css: .info-item*/.panel/.panel-header/.tech-tag/.card-tags 全局
- main.ts 注册 import
- 删重复: ProjectDetail/TaskDetail/TaskOutputCard/ProjectCard/Projects(scoped删重复, 全局对子组件生效)
- 未使用清理: Projects .card-progress dead(1处保守核对)

tsjs-dry:
- aiShared.ts: resolveAiLang(合并useAiSend.resolveLang + useAiContext.resolveLanguage)
- stores/knowledge.ts: kindText/kindLabel/kindIcon/knowledgeStatusLabel/knowledgeConfidenceLabel(5公共)
- 删重复: useAiSend/useAiContext(resolveLang) + Knowledge/KnowledgeDetail(4+5 helper) + ActiveProjectsPanel(formatLastActivity→formatRelativeZh复用)

strategy: DRY收口, 公共沉淀(utils/store/shared); relativeTime非纯重复保留; .btn/.empty-hint局部override WATCH未动
2026-06-19 12:11:19 +08:00
lxy 64ece1d3a0 重构: 拆tool_registry register_data_tools(strategy·降单函数复杂度)
- register_data_tools 381→10调度 + 5子函数(register_project_tools 8/register_task_tools 6/register_workflow_tools 1/register_idea_tools 2/register_trash_tools 1)
- 基线测试守护(28工具集合稳定, 漏移/rename立即红)
- build_ai_tool_registry调用零变更, HashMap注册顺序无关
strategy: 单函数复杂度降(register_data 381→10, 子函数<136); 总行+36(拆分签名+doc非压缩目标); register_file_tools 713留后续
2026-06-19 12:03:26 +08:00
lxy 03f0effcf2 重构: 拆Dashboard.vue仪表盘(strategy前端·Dashboard<500达标)
- 新建 components/dashboard/(StatCardRow 126 + ActiveProjectsPanel 174 + IdeasPanel 82): 统计卡+活跃项目+灵感池
- Dashboard.vue 563→238(<500达标): 头部+refresh+loadAll编排+共享样式
- store共享(useProjectStore单例, 子组件纯读, 无props/emit)
strategy: 前端views, 子组件三拆; Dashboard<500达标(第四个继Ideas/Knowledge); scoped .df-panel父子各存(隔离必需)
2026-06-19 12:00:26 +08:00
lxy 55209cfbf9 重构: 拆TaskDetail任务产出(strategy前端)
- 新建 components/task/TaskOutputCard.vue(139行): 任务产出+AI自审结果(JSON解析+Markdown渲染+reviewVerdict)+自有i18n
- TaskDetail.vue 701→612(-89): v-if output→<TaskOutputCard> + 删2接口+5computed+output样式
- props outputJson(自包含无store无emit)
主代兜底(新工作模式·vue-tsc only不grep降上下文): vue-tsc 0(子代理自验印证)
strategy: 前端views, 子组件抽产出; TaskDetail余612(工作流推进深度耦合留下批)
git add指定(TaskDetail.vue+TaskOutputCard.vue)
2026-06-19 11:57:14 +08:00
lxy e32eca3572 重构: 拆Projects.vue项目卡片(strategy前端)
- 新建 components/project/ProjectCard.vue(133行): 卡片模板+样式+parseStack/stripMd/statusLabel/stageClass+useRouter导航
- Projects.vue 692→593(-99): v-for改<ProjectCard> + 删卡片样式/导入
- props project + emit delete(props/emit最小耦合, 无store)
主代兜底(新工作模式·vue-tsc only不grep降上下文): vue-tsc 0(子代理自验印证)
strategy: 前端views, 子组件抽卡片; Projects余593(导入模态留下批); tech-tag父子各存(scoped隔离)
git add指定(Projects.vue+ProjectCard.vue)
2026-06-19 11:56:37 +08:00
lxy f5fd580215 重构: 拆Knowledge.vue知识库详情(strategy前端·Knowledge<500达标)
- 新建 components/knowledge/KnowledgeDetail.vue(385行<500): 基本信息+溯源+引用列表+生命周期时间线+编辑态+parseTags复用
- Knowledge.vue 775→495(<500达标): 列表+三态+selectKnowledge+action保留
- props detail/topTab/submitting + emit save/publish/reject/archive(子无store, emit回父单向)
主代兜底(新工作模式·vue-tsc only不grep细节降上下文): vue-tsc 0(子代理自验印证)
strategy: 前端views, 子组件抽详情面板; Knowledge+KnowledgeDetail双<500达标; parseTags复用SW-260618-20
git add指定(Knowledge.vue+KnowledgeDetail.vue)
2026-06-19 11:52:14 +08:00
lxy a2330de2a8 重构: 拆human_node人工节点(strategy核心库)
- 新建 df-nodes/human_node_helpers.rs(32行): REJECT_KEYWORDS + is_reject_decision + contains_reject(B-260615-05拒绝语义保留)
- human_node.rs 850→831: HumanNode struct+impl+tests保留 + use helpers(impl块约束主体内联)
- lib.rs: mod human_node_helpers
主代兜底(独立 -p df-nodes): cargo 0 + test 82(B-260615-05 reject全绿)
strategy: 核心库, 纯函数+常量抽离, impl保留; human_node主体内联execute大(impl约束, 可拆有限)
git add指定(df-nodes/*)
2026-06-19 11:47:40 +08:00
lxy 356c8f27f8 重构: 拆adversarial对抗评估(strategy核心库)
- 新建 df-ideas/adversarial_helpers.rs(366行): 6类型(EvaluatedBy/AdversarialEval/Argument/AnalystAnalysis/AssessmentLevel/Recommendation) + SYSTEM_PROMPT + 8纯fn(build_adversarial_prompt/parse_llm_eval/extract_json/parse_assessment_level/parse_recommendation/priority_label/assessment_desc/action_hint) + LlmEvalRaw中间结构
- adversarial.rs 930→596: AdversarialEngine impl保留 + pub use helpers(外部idea.rs路径零变更)
- lib.rs: mod adversarial_helpers(私有)
- ARC-260618-01-e(evaluate_with_llm一致性)逻辑保留(待决策, doc标注)
主代兜底(独立 -p df-ideas 避 df-ai): cargo 0 + test 20
strategy: 核心库, 纯函数/类型抽离, evaluate主入口+ARC-e保留
git add指定(df-ideas/*)
2026-06-19 11:45:26 +08:00
lxy 3794b3d333 重构: 拆ProjectDetail审批对话框(strategy前端)
- 新建 components/project/ApprovalDialog.vue(110行): 审批对话框+handleApproval/handleApprovalMulti/isMultipleSelect/multiDecisions+submitting(UX-260618-17防双击保留)
- ProjectDetail.vue 811→743(-68): template→<ApprovalDialog> + 删迁移script
- props visible/store透传 + emit cancel/later(单一store源 _storeInstance)
主代兜底: vue-tsc 0 + grep ApprovalDialog抽离/ProjectDetail import/emit印证
strategy: 前端views, 子组件抽审批对话框; UX-260618-17 submitting保留; 余743(style 315逻辑428, 后续TaskList/InfoCard可拆)
git add指定(ProjectDetail.vue+ApprovalDialog.vue)
2026-06-19 11:44:03 +08:00
lxy a1ad93ab31 重构: 拆Ideas.vue灵感详情(strategy前端·Ideas<500达标)
- 新建 components/ideas/IdeaDetail.vue(573行): 对抗评估+雷达图+标签+状态+操作按钮+style(详情面板下沉)
- Ideas.vue 990→485(<500达标): 列表+筛选+搜索+捕获+action保留
- props idea/evaluating/evalError/promoting/deleting/statusOptions + emits evaluate/promote/delete/status-change(无store依赖, emit上抛单向数据流)
主代兜底: vue-tsc 0 + grep IdeaDetail抽离/Ideas import/emit印证
strategy: 前端views, 子组件抽详情面板; Ideas<500达标; onStatusChange签名Event→value行为等价
git add指定(Ideas.vue+IdeaDetail.vue)
2026-06-19 11:40:44 +08:00
lxy 35c976793b 重构: 拆ai_node AI节点(strategy核心库)
- 新建 df-nodes/ai_node_helpers.rs(298行): AiNodeParams/ResolvedProvider + resolve_and_parse/provider_from_params/resolve_provider/resolve_from_record/parse_params/parse_review_json/truncate_for_summary/gate_should_block + REVIEW_SYSTEM_PROMPT
- ai_node.rs 1107→835: AiNode/AiSelfReviewNode struct+impl+tests保留 + use helpers
- lib.rs: mod ai_node_helpers
主代兜底(独立 -p df-nodes 避 df-ai/前端): cargo 0 + test 82(1 ignored GLM真调)
strategy: 核心库自底向上, 纯函数/类型抽离, impl保留, SW-09 helper随之搬; 私有mod零外部可见性变化
git add指定(df-nodes/*)
2026-06-19 11:39:26 +08:00
lxy 3e97fff328 重构: 拆AiChat第五批MaxRoundsCard(strategy收尾)
- 新建 ai/MaxRoundsCard.vue(144行): 达max操作卡(继续/停止)+isViewingGenerating/maxRoundsActing/handleContinue/StopLoop+pendingMaxRounds watch
- AiChat.vue 1587→1474(-113): template→<MaxRoundsCard> + 删max_rounds块 + 清dead aiApi import
- store共享 + emit toast(单一源)
主代兜底: vue-tsc 0 + grep MaxRoundsCard抽离/AiChat import/emit印证
strategy: 单批1-2文件原子, 边界最干净段优先; AiChat余1474(QueuePanel/ContextActions/恢复态/快捷键待后续)
git add指定(AiChat.vue+MaxRoundsCard.vue)
2026-06-19 11:32:32 +08:00
lxy a5d125847f 重构: 拆AiChat God第四批MessageList(strategy·主体消息列表)
- 新建 ai/MessageList.vue(1444行): 消息列表+流式Markdown分块渲染(scheduleStreamParse/rAF节流/块级memo)+工具卡ToolCardList+滚动跟随(B-260618-24)+分段+选区保持
- AiChat.vue 2937→1587(-1350): MessageList template/script/style迁移, store共享(useAiStore单例)+useMarkdown单例
- props editingMsgId + emits(start-edit/send-example-prompt/go-to-settings/toast) + expose(scrollToBottom/scrollToFirstPending)
流式行为不变: scheduleStreamParse/rAF/块级memo/选区/滚动跟随原样迁移
主代兜底: vue-tsc 0 + grep MessageList抽离/AiChat emit绑定expose印证
strategy: 单批1-2文件原子, 主体抽离; AiChat余1587(组装+队列+max_rounds+上下文+快捷键+confirm/toast+恢复态, 后续批)
AiChat拆分主体完成: ConversationSidebar+ChatInput+TopBar+MessageList四批, 4081→1587
git add指定(AiChat.vue+MessageList.vue, 不含anthropic_compat.rs/docs其他会话)
2026-06-19 05:18:38 +08:00
lxy 2d6eff66a7 重构: 拆provider.rs df-ai-core核心类型(strategy核心库)
- 新建 df-ai-core/types.rs(221行): 13类型(CompletionRequest/ContentPart/ChatMessage/MessageRole/ToolDefinition/ToolCall/TokenUsage/StreamChunk/ToolCallDelta等) + now_millis_i64
- provider.rs 572→369: pub use crate::types::* re-export透明 + impl/LlmProvider trait保留
- lib.rs: pub mod types
主代兜底(独立 -p df-ai-core 避 df-ai anthropic_compat 其他会话E0599): cargo check 0 + test 24
strategy: 核心库自底向上, 纯类型抽离, re-export稳定(df_ai::provider::* 与 df_ai_core::provider::限定路径双通, df-ideas印证)
git add指定(df-ai-core/*)
核心库拆分完整: context/anthropic/openai/scan/model_fetch/migrations/executor/model_probe/provider 八大文件
2026-06-19 04:57:13 +08:00
lxy 4dee706631 重构: audit第五批utils/diff收尾(strategy·audit拆分完成)
- 新建 audit/utils.rs(39行 risk_str/risk_from_str/truncate_chars) + audit/diff.rs(29行 build_write_file_diff)
- audit/mod.rs 416→379: mod utils/diff + re-export(pub(crate) use risk_str/risk_from_str) + 删原fn
audit拆分完成: reason/restore/finalize/cache/data_change/utils/diff七子模块, mod.rs余379(process_tool_calls主+DTO+list_tool_executions)
主代grep核验(不cargo--workspace避anthropic_compat.rs其他会话中间态df-ai E0599): mod.rs mod utils/diff印证; agent隔离自验cargo 0+test 98
git add指定(audit/*, 不含anthropic_compat.rs/docs其他会话改动)
2026-06-19 04:53:01 +08:00
lxy c908a570e5 重构: 拆model_probe模型探测(strategy核心库)
- 新建 df-ai/model_probe_helpers.rs(137行): PRESETS_JSON/presets(OnceLock) + heuristic_infer + has_vision_token
- model_probe.rs 388→251: probe主入口 + use helpers
- lib.rs: mod model_probe_helpers
主代grep核验(不cargo避audit-split-5中间态): model_probe.rs use+probe + lib.rs mod印证; agent df-ai独立自验cargo 0+test 119
strategy: 核心库自底向上, 纯函数抽离, pub(super)可见性, helper私有mod零API表面扩大
git add指定(df-ai/*)
2026-06-19 04:50:25 +08:00
lxy a81cab074a 重构: 拆executor工作流执行器(strategy核心库·抽测试)
- 新建 df-workflow/executor_helpers.rs(314行): 5测试节点mock + 5测试用例(含3 SW-01 TOCTOU)
- executor.rs 513→210: 删内联tests + #[path] mod tests; run主体49-205保留(SW-01 TOCTOU :130-186零改动)
- agent偏离合理: run无可抽纯helper(全内联+self耦合), 改抽测试降阅读噪音(305行tests→helper)
主代兜底: cargo check --workspace 0 + test df-workflow 23(含TOCTOU) + grep #[path] mod tests印证
strategy: 核心库, 测试代码抽离(cfg(test)零库影响), SW-01 TOCTOU保留
git add指定(df-workflow/*)
2026-06-19 04:47:33 +08:00
lxy a0987bad7d 重构: 拆migrations SQL迁移(strategy核心库)
- 新建 df-storage/migrations_helpers.rs(103行): column_exists纯查询fn + 4测试
- migrations.rs 659→541: use column_exists + 删原fn+测试, run/migrate_v1-v19主入口保留
- lib.rs: pub mod migrations_helpers
主代grep核验(不cargo避executor-split df-workflow中间态): migrations.rs use+column_exists 16处调用 + lib.rs pub mod印证; agent df-storage独立自验cargo 0+test 35+11
strategy: 核心库自底向上, 纯查询函数抽离(PRAGMA无副作用, SQL行为不变), pub(crate)零外部暴露扩大
git add指定(df-storage/*)
2026-06-19 04:46:25 +08:00
lxy f5f2358799 重构: 拆model_fetch模型获取(strategy核心库)
- 新建 df-ai/model_fetch_helpers.rs(400行): build_models_url/is_non_chat_model/filter_chat_models + ModelsList/ModelEntry + 27测试
- model_fetch.rs 21KB→6KB(176行): fetch_model_names/fetch_and_probe入口 + IO fn + use helpers
- lib.rs: mod model_fetch_helpers
防污染核验: extract_error_diag不存在(以源码为准非背景声明), probe在model_probe
主代兜底: cargo check --workspace 0 + test df-ai 119
strategy: 核心库自底向上, 纯函数抽离, ModelsList pub(crate)
git add指定(df-ai/*)
2026-06-19 04:44:30 +08:00
lxy d6e45f7bc6 重构: 拆scan.rs项目扫描(strategy核心库)
- 新建 df-project/scan_helpers.rs(324行): ImageRef+BADGE常量+is_badge_image/collect_images/strip_readme_noise/is_pure_badge_line/is_toc_line + 8测试
- scan.rs 1015→741: #[path]mod scan_helpers + pub use ImageRef + use collect_images/strip_readme_noise(规避scan.rs→scan/mod.rs目录化大改)
- re-export ImageRef路径稳定(project.rs/tool_registry.rs零改动)
主代兜底: cargo check --workspace 0 + test df-project 29 + grep #[path]/pub use印证
strategy: 核心库自底向上, 纯函数抽离(无IO文本规则), #[path]规避目录化大改
git add指定(df-project/*)
2026-06-19 04:41:28 +08:00
lxy a3835e2acb 重构: 拆openai_compat协议适配(strategy核心库·对齐anthropic)
- 新建 df-ai/openai_helpers.rs(231行): apply_openai_sse纯函数 + 13 struct(OpenAi*字段pub(crate))
- openai_compat.rs 809→597: 删抽离+use引入, Provider impl保留(impl块约束: new/convert_request/parse_tool_calls/LlmProvider trait)
- lib.rs: pub mod openai_helpers
主代兜底: cargo check --workspace 0 + test df-ai 119
strategy: 核心库自底向上, impl块约束(Provider方法保留只抽纯函数/类型), 对齐anthropic_helpers模式
git add指定(df-ai/*)
协议适配双拆完整: anthropic_compat + openai_compat 均helpers抽离
2026-06-19 04:36:42 +08:00
lxy f3a287bbd4 重构: audit第四批data_change收尾(strategy)
- 新建 audit/data_change.rs(50行 data_change_for_tool/emit_data_changed), audit/mod.rs 451→416(-35)
- pub(crate) re-export emit_data_changed(chat.rs super::super::audit + process_tool_calls裸名路径透明)
- F-09 per_conv保留(本批未触session/conv_id逻辑)
主代grep核验(不cargo避openai-split df-ai中间态): mod data_change/pub(crate)use + data_change.rs fn印证; agent隔离自验cargo 0+test 98
strategy: 单批1-2文件原子; audit/mod.rs余416(process_tool_calls主逻辑+build_write_file_diff/risk/truncate helper); audit四批拆分接近完成
git add指定(audit/*)
2026-06-19 04:35:41 +08:00
lxy 55bc1e1b23 重构: audit第三批cache + anthropic_compat拆分(strategy并行)
- audit 第三批: 新建 audit/cache.rs(151行 find_cached_high_risk_result/canonical_args_key/sort_object_keys/PENDING_APPROVAL_PLACEHOLDER), audit/mod.rs 586→451(-135); pub(crate) re-export, F-09 per_conv保留
- anthropic_compat 拆分: 新建 df-ai/anthropic_helpers.rs(223行 apply_anthropic_event + 5 struct + 2常量), anthropic_compat.rs 1036→842; Provider impl保留(impl块约束), 字段pub(crate)
主代兜底: cargo check --workspace 0 + test df-ai 119 + test devflow 98
strategy: 核心库自底向上, 单批1-2文件原子, impl块约束(Provider方法保留只抽纯函数/类型)
git add指定(audit/* + df-ai/*, 不含其他会话)
2026-06-19 04:31:19 +08:00
lxy ed35c51a47 重构: AiChat第三批TopBar + audit第二批restore/finalize(strategy并行)
- AiChat 第三批: 新建 ai/TopBar.vue(499行, header+provider bar+切换bar+scoped样式), AiChat.vue 3128→2937(-191); props4+emits13+store共享; toast单一源emit provider-switched
- audit 第二批: 新建 audit/restore.rs(183行 restore_pending_approvals+2单测) + audit/finalize.rs(79行 audit_tool_call/audit_finalize), audit/mod.rs 812→586(-226); re-export路径透明, F-09 per_conv保留
主代兜底: cargo check --workspace 0 + test 98 + vue-tsc 0
strategy: 单批1-2文件原子, 并行(AiChat前端vue-tsc/audit后端cargo独立域)
git add指定(非-A, 避免覆盖其他会话改动)
2026-06-19 04:16:57 +08:00
lxy e0ffd98223 重构: 拆audit.rs第一批reason helper(strategy)
- audit.rs → audit/mod.rs(959→812行) + 新建 audit/reason.rs(167行)
- 抽离 reason helper: resolve_project_label/resolve_task_label/build_approval_reason(3 fn自成闭包, pub(super))
- re-export 调用方零变更(commands::ai::audit::* 路径透明, 4处调用方+mod re-export核验)
- F-09 batch2 per_conv 保留(本批只搬 reason helper, conv_id路由不动)
主代兜底: cargo check --workspace 0 + test 98 + grep mod reason/use印证
strategy: 单批1-2文件原子; audit/mod.rs余812行(后续批restore/audit_finalize/find_cached)
2026-06-19 04:11:47 +08:00
lxy f026880fa7 重构: 拆AiChat God第二批ChatInput(strategy)
- 新建 ai/ChatInput.vue(641行): 输入框+图片粘贴/拖拽+技能/联想+@mention+发送/停止(4块独立逻辑)
- AiChat.vue 3663→3128行(-535): ChatInput template+script迁移, store共享(useAiStore/useProjectStore单例)
- props editingMsgId + emit sent/error/cancel-edit + expose focus/startEditFocus/clearInput/hasUnsentInput/sendExamplePrompt
边界: scrollToBottom/showToast/editingMsgId留父(emit回调); ChatInput 641>500(4块逻辑, 后续可拆useImageInput/useSkillMention composable)
主代兜底: vue-tsc 0 + grep ChatInput抽离/AiChat emit绑定印证
strategy: 单批1-2文件原子; AiChat余3128行(后续MessageList核心/TopBar/QueueList)
2026-06-19 04:04:35 +08:00
lxy 4eaff04ac1 重构: 拆AiChat God第一批ConversationSidebar(strategy)
- 新建 ai/ConversationSidebar.vue(457行): 侧栏+会话列表+搜索+重命名/导出/拖拽/分组(全迁sidebar内部态)
- AiChat.vue 4081→3663行(-418, 172→148KB): sidebar template→<ConversationSidebar>+删迁移script(titleFlash/重命名/导出/拖拽/搜索/分组等)
- 零props(store共享useAiStore单例) + 3emit(confirm-delete/confirm-new-conversation/export-error) + defineExpose focusSearch
useConfirm非单例→删除确认emit交父(防ConfirmDialog错位);CSS未动(子组件无scoped沿用父级,样式下沉后续批)
主代兜底: vue-tsc 0 + grep ConversationSidebar抽离/AiChat emit绑定印证
strategy: 单批1-2文件原子, 最独立子组件优先降风险; AiChat余3663行(后续批MessageList/ChatInput/ApprovalPanel)
2026-06-19 03:54:31 +08:00
lxy 6a75c11dfa 重构: 拆ToolCard.vue前端(strategy·composable抽离)
- 新建 composables/ai/useToolCard.ts(502行): 纯函数/常量/类型/工具元数据/审批参数/结果格式化(formatToolResult/toolResultSummary/combineOutputs/parseResult等20+)
- ToolCard.vue 1527→1066行(-461): script模块级块删+import composable, 模板/style零改动
- 留ToolCard.vue: 耦合projectStore的computed/审批watch(非纯函数)
i18n等价: toolResultSummary原useI18n.t→composable i18n.global.t(单根同一实例)
主代兜底: vue-tsc --noEmit 0 + grep useToolCard抽离/ToolCard import印证
strategy: 单批1-2文件原子, 模板不变逻辑抽离
2026-06-19 03:44:16 +08:00
lxy 96a05638b1 重构: 拆agentic.rs第一批GeneratingGuard(strategy自底向上)
- 新建 agentic/guard.rs(69行): GeneratingGuard struct+new/reset/disarm/Drop(F-09 batch2 per_conv双写语义保留, pub(super)可见性)
- agentic.rs 1221→agentic/mod.rs 1165行: mod guard + use GeneratingGuard + 删原定义
- loop主体(run_agentic_loop/try_continue/stream_one_provider/compress)原位保留(后续批,高风险)
helpers未建: 无独立纯helper可抽(DEFAULT_*被state.rs引用留原位, PROTECT_COUNT loop绑定)
主代兜底: cargo check --workspace 0 + test 98 + grep guard抽离/mod.rs use印证
strategy: 自底向上, 单批1-2文件原子, loop主体留后续批(行为敏感需充分测试)
2026-06-19 03:37:28 +08:00
lxy 63fe0a6375 重构: 拆context.rs核心库(strategy自底向上·纯函数抽离)
- 新建 df-ai/context_helpers.rs(195行): 纯函数/类型/常量(TokenEstimator/ContextConfig/MessageGroup/TrackedMessage/classify_group/EvictionUnit/PROTECT_COUNT/TOOL_MISSING_PREFIX)
- context.rs 1332→1178行: 保留ContextManager struct+impl, pub use重导出(外部agentic.rs/mod.rs路径不变)
- lib.rs: pub mod context_helpers
Rust impl块约束: ContextManager方法未动一个字符, 纯函数搬迁零行为变更
主代兜底: cargo check --workspace 0 + test df-ai 119 + grep抽离项/pub use印证
strategy: 自底向上核心库优先, 单批1-2文件原子操作
2026-06-19 03:31:50 +08:00
lxy f1aa7ac33d 重构: commands拆分config+skills域·commands.rs 92KB拆分完整(strategy)
- 新建 commands/config.rs(89行, 4 IPC: ai_list_skills/ai_set_concurrency_config/ai_set_agent_max_iterations/ai_set_agent_max_retries)
- commands/mod.rs 99→34行(收敛re-export only, 无IPC函数体)
- re-export三级透传(chat/conversation/provider/config),lib.rs invoke_handler+前端零改动
commands.rs 92KB拆分完整: 原1829行God→5文件(chat 1147+conversation 336+provider 348+config 89+mod.rs 34 re-export)
主代兜底: cargo check --workspace 0 + test 98 + cat mod.rs re-export only印证
strategy全程: 分批1-2文件原子操作(chat→conv→provider→config四批); 零调用方预留保留
2026-06-19 03:26:44 +08:00
lxy 6769100b57 重构: commands拆分provider域(strategy单批)
- 新建 commands/provider.rs(348行, 7 provider/model IPC + mask_api_key/normalize_provider_type helper)
- commands/mod.rs 418→98行(删provider域 + re-export + 清理imports)
- re-export三级透传(chat/conversation/provider),lib.rs invoke_handler+前端零改动;FR-S1密钥/F-260614-04c池配置/F-01探测保留
主代兜底: cargo check --workspace 0 + test 98 + grep re-export三域印证
进度: chat✅+conversation✅+provider✅,余 skills+config(4 IPC,最后批→mod.rs收敛re-export only)
2026-06-19 03:23:09 +08:00
lxy 86f6c9685b 重构: commands拆分conversation域(strategy单批1-2文件)
- 新建 commands/conversation.rs(336行, 8 conv IPC: create/list/switch/delete/rename/archive/set_pinned/export)
- commands/mod.rs 725→418行(删conv域 + pub mod conversation re-export三级透传)
- lib.rs invoke_handler + 前端零改动;F-09 batch4决策e保留(create不杀旧loop/switch删readonly)
主代兜底: cargo check --workspace 0 + test 98 + grep 8IPC/re-export印证
strategy: 单批1-2文件原子操作;进度 chat✅+conversation✅,余 provider/skills/config
2026-06-19 03:19:48 +08:00
lxy e6dbb14c5d 重构: commands.rs拆分chat域(strategy单批1-2文件)
- commands.rs → commands/mod.rs(1829→725行) + 新建 commands/chat.rs(1147行, 13 chat IPC + finalize_pending_placeholders + PendingToolCallInfo)
- re-export三级透传: chat::* → commands::* → ai::*(lib.rs invoke_handler 9处零改动 + 前端零改动)
- 签名/行为原样搬迁(F-09 batch4 conv_id 决策e保留);零调用方预留保留
strategy: 单批1-2文件防上下文溢出 + 原子(新文件→旧改→编译验证)
主代兜底: cargo check --workspace 0 + test 98 + grep 13IPC/re-export三级印证
杂项销账: B-260617-11 tauri.conf.json ["nsis"]已入库关闭(WATCH跨平台) + CR-07 batch4待审登记
2026-06-19 03:15:03 +08:00
lxy d899c58682 重构: F-09 batch4 启用多会话上线(决策e真并发·F-09完整)
- IPC签名加conv_id: ai_is_generating/ai_chat_send/ai_chat_force_send/ai_chat_stop + 内部移除active一致性校验(后台conv可操作)
- 删switch readonly分支 + 删ai_conversation_create强制结束旧loop(决策e,切走旧loop不退出)
- 删双写桥接+顶层字段: messages/generating/stop_flag/notify/iteration_used/agent_language/model_override/session_trust全删,per_conv唯一真相源 + 删双写代码(全模块)+ 删fallback读 + pending_approvals retain目标conv
- 前端: api/ai.ts+useAiSend/useAiWindow传conv_id
保留: session_state(SW预留标allow)/readonly(前端不读);零调用方预留按用户指导保留
主代兜底: cargo check --workspace 0 + vue-tsc 0 + test 98 + grep顶层删除/per_conv唯一印证
F-09完整(batch1 PerConvState+batch2迁移+batch5限流调整+batch8恢复+batch4上线);双会话人工验收留用户
2026-06-19 03:03:48 +08:00
lxy 2b40f65ef2 修复: BUG-260619-06 L0 clear致冷启动审批丢失(方案A retain)
- lib.rs:62 clear → retain(!recovered): 仅清非recovered(本次会话/HMR死pending),保留restore重建(recovered=true audit.rs:331)
- cr06-review跨版本git diff核验: clear批2已有(B-260616-01 d2cb38c),batch8仅合并位置非新增 → 批2+跨批遗留,非batch8回归(CR-06 PASS)
- 问题真实(冷启动restore填充后clear清空→审批丢失),方案A修复对齐switchConversation retain保护意图
主代兜底: cargo check --workspace 0 + test 98
2026-06-19 02:42:28 +08:00
lxy 7c708a6e01 优化: F-09 batch5调整 删global会话级限流(用户决策不设并发会话上限)
- agentic.rs: 删loop入口acquire_global(会话数不限) + per_conv保留(单对话内限流permits=2)
- state.rs: global回归LLM调用并发限流原义(5处单次调用点+stream_llm防provider429),非会话数上限
用户决策"不设并发会话上限",token暴增接受;主代兜底cargo 0+test98+grep印证
2026-06-19 02:39:39 +08:00
lxy 6ad4ec2848 重构: F-09 batch8 启动恢复多conv restore + L0清残留(纯后端,F-09后端完整)
- audit.rs restore_pending_approvals: 多conv分配(按conversation_id分组惰性建/复用PerConvState,无主None不建R-9) + 2单测(分配不变量/全None无per_conv)
- lib.rs L0握手: 遍历per_conv清多conv残留generating(dirty_convs+逐个复位+各发AiCompleted补偿) + active兜底
与双写兼容(顶层双写复位),不改IPC签名/前端/顶层字段
主代兜底: cargo check --workspace 0 + test 98 + grep restore多conv/L0遍历印证
F-09后端完整: batch1 PerConvState + batch2 per-conv迁移 + batch5 限流 + batch8 恢复; batch4(启用·前后端)待用户
2026-06-19 02:26:24 +08:00
lxy 5c15b72d3d 审查回填: CR-04(F-09批2)+CR-05(batch5) PASS
- CR-04 batch2 双写桥接 PASS(⚪1 low:注释「共享同一Arc」误导,批4清顶层时清)
- CR-05 batch5 llm_concurrency会话级 PASS(⚪1 WATCH修正:审批disarm+return不占global槽,prompt描述不准)
- 待修项回流 todo: 无
独立 grep/read + cargo check --workspace 0 warning 核验
2026-06-19 02:19:26 +08:00
lxy d96041721a 重构: F-09 batch5 llm_concurrency会话级改造(决策c)
- state.rs: per_conv HashMap<conv_id,Semaphore>(每对话permits=2) + per_conv_permits热改 + acquire_per_conv(conv_id) + release_conv
- agentic.rs: loop入口acquire_global+acquire_per_conv(conv_id)整loop持有(会话级并发上限3) + 删每轮acquire/drop
- compress/title/knowledge_inject: acquire_per_conv(conv_id)+签名conv_id
- project.rs: 扫描合成key + ai_conversation_delete release_conv
global: LLM调用并发→并发会话数上限;F-260616-12 retry permit自洽
主代兜底: cargo check --workspace 0 + test 96 + grep印证
2026-06-19 02:12:08 +08:00
lxy 2a4d745c5b 重构: F-09批2 调用方迁移per-conv(双写桥接共存期)
- agentic.rs: GeneratingGuard加conv_id + 三处退出校验改conv存在性(决策e) + stop_flag/notify/messages/iteration per-conv + try_continue加conv_id参数
- commands.rs: IPC写路径双写桥接(per_conv新真相源+顶层双写,批4删) + ai_is_generating读per_conv fallback顶层
- audit/conversation/title/knowledge_inject/lib.rs: per_conv读写迁移
- conv()/conv_read()去allow(批2有调用方)
共存期双写: per_conv主+顶层双写兼容批4前IPC,批4签名改后删顶层
主代兜底: cargo check --workspace 0 + test 96 passed + grep三处退出/GeneratingGuard/双写印证
2026-06-19 02:01:06 +08:00
lxy 2c8764abee 重构: crud.rs按表拆分(SMELL-P1-9) + F-09批1 PerConvState数据结构
- SMELL-P1-9: crud.rs 2212行→crud/6文件(mod/settings/project_repo/task_repo/conversation_repo/idea_repo)
  re-export pub use *_repo::* 零调用方改动,宏 pub(crate) use + 子模块 use super::impl_repo
  基线测试锁12表白名单+13Repo构造
- F-09 批1: PerConvState struct(9字段对齐AiSession::new)+AiSession.per_conv HashMap+conv()/conv_read()访问器+3单测
  纯新增无行为变化,b-1主代自主裁决采纳,批2迁移承接
主代统一兜底: cargo check --workspace 0 + df-storage 35+11 + devflow 96 passed
2026-06-19 01:34:59 +08:00
lxy d817386418 新增: F-09多会话并发架构B阶段设计文档 + UX-15审查闭环
- F-09 设计文档(docs/02-架构设计/F-09-多会话并发架构设计-2026-06-19.md)
  现状核验+设计选项+实施路径(阶段2批1-8)+风险R1-9+验收清单
- 核验: A路线补漏已落地(阶段1跳过), AiSession 12字段
- 待决策: b-1 messages per-conv(技术必然,偏离决策b原文待拍板) + c-1 global=3+Settings + e-1 save原路径(主代定)
- CR-260619-02 UX-15第一批 审查回填 PASS
2026-06-19 01:15:36 +08:00
lxy 0142654be3 修复: 流式重试N+1气泡根治(UX-260618-15第一批方案A)
- stream_recv: InitFailed加error字段,5处重试路径移除AiError emit(retryable交agentic统一emit)
- agentic: 重试loop只emit AiStreamRetry,Fatal/全candidate耗尽emit最终AiError(单气泡聚合)
- useAiEvents: AiStreamRetry改首次创建+后续更新(N+1气泡消除)
- 注释残留修正(stream_recv :251/:299 旧"emit AiError"→"返回InitFailed")
N+1独立气泡降为「1重试气泡+1最终错误气泡」
2026-06-19 01:07:50 +08:00
lxy 1dc8b12ec4 修复: aichat advance_task渲染显任务名 + 审批防双击 + time.ts销账
- UX-260618-14: advance_task 工具结果/审批卡显任务名替代UUID
  前端 ToolCard 取 r.title fallback r.id + TASK_ID_TOOL_ARG 审批卡 task_id→title 回显 + i18n
  后端 audit resolve_task_label 查 tasks 表(原误查 projects恒报不存在) + 删 tool_display_hint 短路让模板生效
- UX-260618-16: time.ts formatDate .value 已修(CR-24/25闭环),漏销账补登
- UX-260618-17: ProjectDetail handleApprovalMulti 补 submitting try/finally + 模板联动防双击
2026-06-19 00:52:20 +08:00
lxy 6d2244c03b 优化: 审查销账记录同步 2026-06-19 00:12:46 +08:00
lxy 60b01d03ee 重构: tool_registry拆分及多批改进 2026-06-19 00:10:14 +08:00
lxy a2871a66e0 优化: AI Chat全栈多批审查修复与架构清理(risk_level清理/路由解耦/工具渲染/测试补测/死代码) 2026-06-18 22:57:19 +08:00
lxy 0ca5d9805f 诊断: 临时禁用虚拟滚动裁剪隔离历史消息重叠根因 2026-06-17 23:41:13 +08:00
lxy 02bee38738 优化: todo 销账 B-260617-09+UX-260617-01 虚拟滚动 minHeight 防御修复 2026-06-17 23:34:33 +08:00
lxy 8abcd567a6 修复: 虚拟滚动 IO 卸载分支 height=0 时 minHeight fallback 防 slot 塌 0 重叠(P0) 2026-06-17 23:33:28 +08:00
lxy 36cde88b80 优化: ToolCardList 批量审批+全局收起合并同行(UX-260616-02)+销账 UX-01/B-260617-15 2026-06-17 19:25:39 +08:00
lxy ecff4cc23d 优化: todo 销账 B-260617-10 误判+B-260617-14 已修 2026-06-17 19:04:03 +08:00
lxy b4f738bada 优化: todo 销账 UX-260617-13 主面板流式恢复 2026-06-17 18:53:09 +08:00
lxy 3055b06734 修复: 主面板刷新后流式恢复(restoreGeneratingState 加 fromMainPanel 绕过 localStorage 快照) 2026-06-17 18:52:46 +08:00
lxy aa75454878 优化: todo 销账 BUG-12+B-260617-14+DRY重构回填状态 2026-06-17 18:45:44 +08:00
lxy f6e4e824fa 修复: 补提 adversarial+project CompletionRequest reasoning_content 字段(74003bc 遗漏) 2026-06-17 18:44:28 +08:00
lxy 645f73d1a3 优化: LEGACY_STATUS_MAP 映射抽 mapLegacyStatus 函数收敛 2026-06-17 18:10:28 +08:00
lxy 74003bc04f 修复: DeepSeek reasoning_content 全链路透传(跨6 crate补字段+映射+agentic loop累积回传) 2026-06-17 18:08:04 +08:00
lxy 1cd7652a34 修复: taskStatusLabel旧态映射防老DB状态报错 2026-06-17 14:38:19 +08:00
lxy 924158cff2 修复: force_send单锁竞态+classify unknown不重试(撤销误判)+UX交互批 2026-06-17 14:31:47 +08:00
lxy 0633aa6614 优化: todo销账(BUG-260617-01误判·单测证保守设计) 2026-06-17 14:10:39 +08:00
lxy 6b67214395 修复: AI工具OOM/路径遍历+续跑锁收敛+UX交互批 2026-06-17 14:07:16 +08:00
lxy 2065335c8c 优化: todo销账(B-260616-11 wrapNakedDiff落地)+文档索引补录 2026-06-17 13:36:37 +08:00
lxy ca9b3187b5 修复: sendQueuedNow立即发送退化入队+裸diff围栏渲染+工具卡轮次自动收起 2026-06-17 13:00:37 +08:00
lxy 9d1e5c2737 优化: todo销账(F-05/T-06/B-05误判)+夜间6决策记录+CR-65审查PASS回填 2026-06-17 04:02:04 +08:00
lxy a622bdca61 修复: 队列发送失败回填不丢消息+池权重revert+lang解析DRY+注释变量名修正 2026-06-17 03:55:30 +08:00
lxy 6b2dd8fb5d 优化: CR-65补登记+CR-67登记(波22 F-05闭环·波24 execution_id治本) 2026-06-17 03:38:45 +08:00
lxy bac3c76fa2 修复: B-03b-R10③工作流事件补execution_id治本并发串扰(emit填+消费exec_id守卫) 2026-06-17 03:36:38 +08:00
lxy cf829a1933 优化: CR-63/64/65/66登记(波20-23 F-05多模态+Settings拆) 2026-06-17 03:32:42 +08:00
lxy 9c60c8fb78 重构: Settings拆4子组件(Provider/Connection/General/Knowledge·1042行瘦身零行为变化) 2026-06-17 03:30:47 +08:00
lxy 4317d02e0f 新增: F-05多模态Phase2c闭环(IPC接parts+api透传+doSend传参) 2026-06-17 03:22:20 +08:00
lxy e1d396dbf2 新增: F-05多模态Phase2b前端(图片粘贴/拖拽+渲染+parts透传·保留虚拟滚动) 2026-06-17 03:17:30 +08:00
lxy e3cd44802a 新增: F-05多模态Phase2a后端(ContentPart+parts字段+provider图片块+truncate防撑爆) 2026-06-17 03:09:10 +08:00
lxy 71718c1cd8 优化: CR-62登记(波19虚拟滚动)+CR-61审查回填PASS 2026-06-17 02:56:54 +08:00
lxy e38474b794 新增: UX-19消息列表虚拟滚动(自研IntersectionObserver+sentinel占位+流式末条保活) 2026-06-17 02:55:27 +08:00
lxy 3f160464ea 优化: CR-61登记(波18 df-core改名df-types机械重构) 2026-06-17 02:46:11 +08:00
lxy 4be15912cd 重构: df-core改名df-types(类型库语义准+全workspace机械改名54处) 2026-06-17 02:44:34 +08:00
lxy a19f535134 优化: CR-58/59/60登记(AiNode闸门+F-04c前端+B-03b前端·CR-51编号去重) 2026-06-17 02:38:07 +08:00
lxy 0289d8c37c 优化: B-03b-R10前端审批(终态清pending+互斥锁防重复IPC) 2026-06-17 02:36:55 +08:00
lxy 3f90aad2d6 新增: F-04c前端Settings池编辑(enabled toggle+weight输入+IPC绑定) 2026-06-17 02:36:54 +08:00
lxy e16d038bb2 新增: AiNode自审闸门阶段3(gate_should_block+verdict=fail阻断下游) 2026-06-17 02:36:54 +08:00
lxy 32878e9ceb 优化: S-260614-01用户澄清(多会话来回切对话)+待决策A/B路线 2026-06-17 02:32:17 +08:00
lxy 2c8004df65 优化: CR-53/54审查回填PASS+CR-55/56/57登记+CR-53-1注释修正(并行化非移锁外) 2026-06-17 02:29:12 +08:00
lxy 80c0955a1a 新增: F-04b多provider fallback切换(stream_one_provider+候选链+resolved_model重算) 2026-06-17 02:26:22 +08:00
lxy b3684f4d1f 新增: F-04c后端(provider池enabled/weight更新IPC+set_caps启动接入) 2026-06-17 02:26:22 +08:00
lxy 247dc5da4a 修复: B-21工具卡重复治本(audit tc_list按id去重保证emit唯一) 2026-06-17 02:26:22 +08:00
lxy 2d02cff7a6 优化: CR-53/54登记(波11yellow修+UX·波12F-04负载均衡) 2026-06-17 02:08:02 +08:00
lxy 83cd8ed256 优化: 待办调整B-21升P1+UX-12·19虚拟滚动开推进 2026-06-17 02:04:59 +08:00
lxy 79b6a43095 新增: F-04多Provider负载均衡池(数据层+选择器+并发原语)+CR-52白项 2026-06-17 02:04:58 +08:00
lxy 31ea151bb2 优化: 工具失败视觉区分(UX-01)+旧卡自动收起增强(UX-03) 2026-06-17 02:04:01 +08:00
lxy 332a6bba1a 修复: CR-51 trust-hit持锁阻塞IPC(audit execute移锁外spawn+回填持锁) 2026-06-17 02:04:01 +08:00
lxy b6fe405431 优化: 波10审查回填(CR-51 PASS-WATCH yellow回波11修/CR-52 PASS) 2026-06-17 01:48:05 +08:00
lxy 31e048faf7 优化: 波9待办销账(B-260617-01方案a2)+CR-52登记+B-260617-01已决 2026-06-17 01:41:14 +08:00
lxy e23b316faf 修复: B-260617-01 run_workflow审批执行(抽取run_workflow_inner+ai_approve后端分支) 2026-06-17 01:39:28 +08:00
lxy f2a56b6982 优化: 波8待办销账(F-15阶段3+AE-04)+CR-51登记+B-260617-01入待决策 2026-06-17 01:32:27 +08:00
lxy 6ec191e750 新增: AE-2025-04会话级授权(TrustKey目录级+同会话同类自动放行) 2026-06-17 01:29:30 +08:00
lxy 0202b5195e 新增: F-15阶段3 agentic loop自动压缩(loop顶部检测+压缩+降级原裁剪) 2026-06-17 01:29:29 +08:00
lxy 5ca178436f 优化: F-01阶段1-4销账+CR-49/50审查回填(CR-50RED已修纠正) 2026-06-17 01:21:48 +08:00
lxy fb4b067728 修复: CR-50 RED(useAiContext事件case改PascalCase+compress兜底复位) 2026-06-17 01:21:47 +08:00
lxy de9809688e 优化: 波6待办销账(F-15阶段2手动上下文管理)+CR-50登记 2026-06-17 01:11:29 +08:00
lxy 4194842561 新增: F-15阶段2手动上下文管理(2 IPC+3事件+前端按钮+status渲染) 2026-06-17 01:09:41 +08:00
lxy 7fd29e68c3 重构: 待决策历史按月归档拆分(主文件370→64行,已决项迁07-项目管理/待决策归档/) 2026-06-17 01:08:38 +08:00
lxy c191d122bd 优化: 波5待办销账(F-15阶段1compress基础+F-01阶段6模型下拉)+CR-49登记 2026-06-17 00:53:03 +08:00
lxy 4c84fbc1b6 新增: F-01阶段6 AiChat模型下拉+override穿透 2026-06-17 00:50:47 +08:00
lxy 63bff8bf96 新增: F-15上下文压缩基础(compress_prompt四段式+compress_via_llm+context辅助方法) 2026-06-17 00:50:47 +08:00
lxy 76d823ded0 优化: 波4待办销账(patch_file三模式+F-01阶段5完成) 2026-06-17 00:38:28 +08:00
lxy 8667e850e3 新增: CR-48波4审查登记(F-01调用点+patch_file三模式) 2026-06-17 00:35:49 +08:00
lxy 2446d1b876 新增: patch_file支持行号区间和锚点定位 2026-06-17 00:33:49 +08:00
lxy 2ddfea67c7 新增: F-01阶段5模型路由调用点接入 2026-06-17 00:33:44 +08:00
lxy 5b7f342e14 新增: F-260617-01 patch_file增强需求登记(行号区间+锚点匹配) 2026-06-17 00:14:38 +08:00
lxy a26b86e003 新增: F-01模型拉取IPC+Settings拉取UI(FR-S1密钥内存解析闭环) 2026-06-17 00:14:38 +08:00
lxy 8a142c212c 优化: F-15上下文基础(is_active正面白名单+push token仅active计入 零行为变化) 2026-06-17 00:14:38 +08:00
lxy afcdf5e17d 优化: 波2销账UX三件套+UX-04+CR-46登记+F-15需求细化 2026-06-17 00:02:22 +08:00
lxy 313459b8e4 新增: UX队列三件套(打断保留续发+队列编辑+立即发送) 2026-06-16 23:59:10 +08:00
lxy 8dad15ecc1 新增: F-01模型能力阶段4路由器(ModelRouter+select 7步过滤链) 2026-06-16 23:59:09 +08:00
lxy 44bc18294c 新增: F-15上下文管理增强设计+波1审查登记(CR-45) 2026-06-16 23:49:24 +08:00
lxy bebf2ec329 优化: UX错误气泡区分重试类型+工具卡按轮次自动收起 2026-06-16 23:48:06 +08:00
lxy 107b8486ae 新增: F-01模型能力阶段3厂商模型列表拉取(model_fetch+协议分派+噪音过滤) 2026-06-16 23:47:56 +08:00
lxy 5c2122ac3b 优化: B-21重复渲染治标(Started幂等守卫)+排查文档+CR-43复审回填 2026-06-16 23:33:46 +08:00
lxy 22d866d4de 新增: UX-04工具卡片友好展示(formatToolResult全工具摘要+i18n) 2026-06-16 23:33:45 +08:00
lxy b3e78e6061 新增: F-01模型能力阶段1+2(ModelConfig数据模型+model_probe探测器+df-storage兼容V18) 2026-06-16 23:33:45 +08:00
lxy d3e6f80d2b 优化: 推进链阶段3代码层闭环(P0 secret下沉+注入链✅⑥待实测)+F-07c方案B落地+CR-44 2026-06-16 23:09:28 +08:00
lxy 84a6fc41d1 新增: AiNode provider_id注入链(resolve_provider三路径+FR-S1 api_key不进config) 2026-06-16 23:07:39 +08:00
lxy 766f235ab7 重构: secret纯密钥逻辑下沉df-storage(方案B避循环依赖,12调用点零改) 2026-06-16 22:57:43 +08:00
lxy 36f70b3229 新增: secret下沉与provider注入方案设计(方案B纯密钥下沉df-storage避循环依赖) 2026-06-16 22:50:06 +08:00
lxy d3c9b4ad47 优化: AiNode自审阶段2进度登记+P0 provider注入待决+CR-43审查队列 2026-06-16 22:45:46 +08:00
lxy 741b0b9d0c 新增: TaskDetail任务产出与AI自审结论展示(output_json解析+review红绿标) 2026-06-16 22:41:05 +08:00
lxy c10adaf0b0 新增: AiNode自审ai侧(AiNode持db写产出+ai_self_review节点+DAG透传) 2026-06-16 22:40:55 +08:00
lxy 25409e5329 新增: AiNode自审实施方案设计(NodeContext注入task+prompt模板+5项实施) 2026-06-16 22:29:25 +08:00
lxy 539b5ed63e 新增: TaskRecord加output_json产出字段+V17迁移(决策a AiNode自审闭环) 2026-06-16 22:29:21 +08:00
lxy b5a9557b50 新增: F-01模型能力/F-09B多会话并发设计文档+看板同步销账排期 2026-06-16 22:20:29 +08:00
lxy 129f0f0156 优化: 对抗评估clamp去冗余+JSON提取正则兜底+update_task注释对齐status收口 2026-06-16 22:20:14 +08:00
lxy d242e8c69c 新增: AiNode自审对象来源架构决策入待决策 2026-06-16 22:03:55 +08:00
lxy 4bdaac5983 修复: df-nodes测试回归(status收口fixture改advance_status_atomic) 2026-06-16 21:30:56 +08:00
lxy b94e74a96d 新增: 推进链阶段3收口(advance_task/run_workflow工具+status状态机收口) 2026-06-16 21:20:41 +08:00
lxy dfe0096498 新增: F-03对抗评估接LLM+F-02纯技能调用标题+i18n@转义 2026-06-16 21:07:42 +08:00
lxy 35b8eac46b 优化: 移除prod白屏临时诊断桩(main.ts/index.html) 2026-06-16 21:07:13 +08:00
lxy 2069f79198 重构: df-ai-core trait下沉拆crate+导入历史项目批量扫描 2026-06-16 20:19:55 +08:00
lxy d00b30f0ba 重构: AI聊天可靠性批次(审批累计计数/write_file diff预览/会话隔离补清/token缓存) 2026-06-16 19:56:29 +08:00
lxy ba7f35552b 重构: AI流式断线保文StreamResult三分支+重试对齐决策a1,推进链落df-nodes 2026-06-16 19:11:19 +08:00
lxy 7d5cd4c89a 新增: AI Chat多项增强(审批去重/编辑重发/导出/实体引用/会话置顶搜索)+任务推进链df-nodes落地 2026-06-16 12:41:13 +08:00
lxy 212a927eee 文档: todo销账(batch29 ARC-05/06+FR-D3+R-PD-10)+待审查登记+DOC整理
- todo: ARC-05越层核验0命中+四领域拆分/ARC-06破环aiShared/FR-D3三子项闭环销账+新增ARC-06b遗留

- 待审查: 登记CR-260616-02(batch29+R-PD-10代码 fc767e1)待审

- DOC-14: df-storage/SQLite-CRUD文档表数V13/V9→V15对齐migrations.rs schema_version=15
2026-06-16 03:28:20 +08:00
lxy fc767e1db6 优化: R-PD-10 storage_err DRY统一 + FR-D3 migrations if链转数组循环
- crud.rs 110处 .map_err(|e| Error::Storage(e.to_string())) 统一 storage_err helper(R-PD-10 DRY)

- migrations.rs run() 15个 if current_version<N 块改 step 数组循环,零行为变更(FR-D3),新增版本追加一行即可
2026-06-16 03:27:01 +08:00
lxy 49e61d4153 文档: AI文件操作工具手册扩充至10工具全量并归档08-用户指南目录+INDEX更新 2026-06-16 03:12:25 +08:00
lxy 4dc5219a3c 修复: CR-01审查待修项①类7项(CSS补testing/blocked徽章+CAS防回收站推进+DRY+注释+测试改名+advancing接文案)+新增待审查.md审查队列(职责分离:主会话写/审查agent读)+todo销账 2026-06-16 03:12:24 +08:00
lxy 38c7180365 新增: 文档(任务推进链实施路径+任务模块分析+审查报告+patch_file指南) 2026-06-16 02:33:16 +08:00
lxy 73ed4bd637 修复: 多视图UX(知识库窄屏CSS+任务详情路由+项目灵感视图) 2026-06-16 02:33:16 +08:00
lxy 10e4945e5a 优化: AI模块(provider重试兼容+aichat交互+工具卡片可读化+diff高亮) 2026-06-16 02:33:16 +08:00
lxy d2cb38cdac 新增: 任务推进链(7态状态机+advance_task CAS原子写)+软删除+前后端7态对齐 2026-06-16 02:33:15 +08:00
lxy f30df333b3 docs: 巡检简报+todo 回写(2026-06-15 第2轮)
巡检发现:
- 2196c77 workflow 整文件替换回退破坏(AiChat+Ideas 12项功能)
- B-260615-03 truncated 标志已落地
- AR-8-scroll scrollToBottom smooth 已补
- CR-260615-09 .ai-md 残余:4详情页各21处 scoped .ai-md
  (全局 ai-md.css 75行已建,旧副本待清理但非阻塞)
2026-06-15 17:23:57 +08:00
lxy 672d677046 修复: 2196c77 workflow整文件替换回退恢复+scrollToBottom smooth+truncated标志
根因: 批次1 workflow(2196c77)做 @/ 路径统一时对 AiChat.vue/Ideas.vue
做了整文件替换(非手术式编辑),把 6254d06 之后积累的功能改动全部抹掉。

AiChat.vue 恢复 12 项功能:
- row-reverse 用户头像右侧布局
- groupedActive 对话按今天/昨天/更早分组
- archivedConvs 归档分组(可折叠)
- confirmClearChat 清空对话二次确认
- showBackToBottom 回到底部按钮(含 smooth 参数)
- queue UI 生成中消息排队可视化
- 技能联想(/ 触发浮层)
- token-usage 展示
- .ai-conv-group/item-actions/back-to-bottom/queue/skill 样式组
- onBeforeUnmount 补 stopListener() (CR-24 回归修复)

Ideas.vue 恢复 i18n/useConfirm/useRendered/过滤器/晋升逻辑

新增:
- F-260615-03 list工具 truncated 截断标志(tool_registry.rs)
- AR-8-scroll scrollToBottom 加 smooth 参数支持平滑滚动

vue-tsc 0 err / cargo check 0 err
2026-06-15 17:19:47 +08:00
lxy b880a976c2 修复: 2196c77 workflow整文件替换回退恢复(AiChat+Ideas 12项功能+头像位置)
根因: 批次1 workflow(2196c77)做 @/ 路径统一时对 AiChat.vue/Ideas.vue
做了整文件替换(非手术式编辑),把 6254d06 之后积累的功能改动全部抹掉。

AiChat.vue 恢复 12 项功能:
- row-reverse 用户头像右侧布局
- groupedActive 对话按今天/昨天/更早分组
- archivedConvs 归档分组(可折叠)
- confirmClearChat 清空对话二次确认
- showBackToBottom 回到底部按钮
- queue UI 生成中消息排队可视化
- 技能联想(/ 触发浮层)
- token-usage 展示
- .ai-conv-group/item-actions/back-to-bottom/queue/skill 样式组
- onBeforeUnmount 补 stopListener() (CR-24 回归修复)

Ideas.vue 恢复:
- i18n 全量($t() 33处)
- useConfirm + ConfirmDialog
- useRendered/useMarkdown 描述渲染
- 过滤器 + 状态选项 + 晋升逻辑

vue-tsc 0 err / cargo check 0 err
2026-06-15 17:15:19 +08:00
lxy 2196c7748f 优化: P0 shell stdout修复+灵感路由+3工具新增+ai-md全局CSS+import路径统一
P0 修复:
- B-260615-37: shell.rs 补 Stdio::piped() 修复 run_command stdout/stderr 恒空
  (tokio 默认 inherit 导致 wait_with_output 读不到 pipe)

P1 功能:
- B-260615-36: 路由加 /ideas/:id + Ideas.vue 接 route.params.id + watch
  (修复项目详情点「来源灵感」跳空白页)
- F-260615-08: 新增 file_info 工具(元信息: exists/size/lines/binary/dir)
- F-260615-09: 新增 append_file 工具(追加写入, RiskLevel::Medium)
- F-260615-12: 新增 search_files 工具(文件名 glob 搜索,递归,限50条)
  + search_files_recursive 辅助函数 + i18n zh/en 3工具×2命名空间

DRY/重构:
- CR-260615-09: .ai-md 样式5份→全局 src/styles/ai-md.css(75行)
  AiChat.vue 删71行重复 + main.ts 引入 + typo修正(.a-md→.ai-md)
- 全量 import 路径统一为 @/ 别名(stores/composables/views ~28文件)
  vite.config.ts 加 resolve.alias.{ '@': '/src' }
- i18n 批量改进: store error fallback 11处中文→t() / ToolCard ARG_LABEL
  / useAiSend queue文案 / Dashboard 空态 / Ideas.vue null守卫
- cargo check 0 error / vue-tsc 0 error
2026-06-15 16:40:09 +08:00
lxy 6254d06c7a 优化: CR-08 i18n batch2全量(ProjectDetail5处+TaskDetail16key+AiChat3处+store error fallback 23处+useAiSend 3处)+@/i18n路径统一+vite别名 2026-06-15 15:06:21 +08:00
lxy d6eb8551da 修复: vite加@路径别名(解决linter改i18n为@/i18n后5文件import解析失败)+CR-08 P2-7 store error fallback i18n(projects/ideas/workflow/knowledge/tasks补err块+全局t) 2026-06-15 15:04:49 +08:00
lxy d8f1d97804 优化: CR-08 P1-10 time.ts相对时间走i18n(原硬编码中文致en locale时间恒中文,复用common.justNow/minutesAgo/hoursAgo/dayAgo已有key) 2026-06-15 13:48:38 +08:00
lxy f95fa5ee8f 优化: CR-08 P1-2 Dashboard暂无项目+P1-9 useAiStream看门狗文案走i18n(各建zh/en key,composable用全局i18n实例) 2026-06-15 13:37:24 +08:00
lxy fb68c731a4 优化: DOC-12 DEVFLOW-1~4/Phase1/Phase2据源码核对标完成(保留原文)+ARCHITECTURE §5.4 ModelRouter保留补提交(reset漏staged) 2026-06-15 13:37:24 +08:00
lxy b9bdd2eb8b 优化: DOC-16 URGENT停用横幅+DOC-14a Tauri-IPC归档横幅(P0全迁todo,过时文档标归档指向ARCHITECTURE) 2026-06-15 12:55:38 +08:00
lxy 8382730bbd 优化: CR-06 mdReady翻转主动重渲染(_mdRenderKey强制刷新)+CR-09 .ai-md样式抽全局css(70行去重→ai-md.css+main.ts导入) 2026-06-15 12:23:19 +08:00
lxy be38a44b0c 修正: ARCHITECTURE §5.4保留ModelRouter标已删+§5.5内置节点保留8个标状态(历史项全保留不删,仅标注实现状态) 2026-06-15 12:11:45 +08:00
lxy dcd8b9359a 修复: CR-08 P1-1 useAiEvents拒绝提示走i18n(原硬编码中文致en locale恒中文,废已有rejectedHint翻译) 2026-06-15 12:07:36 +08:00
lxy c7a1d5665f 修复: ARCHITECTURE §5.4 df-ai虚构清理(ModelRouter删/AgentCoordinator标空壳)+§5.5 df-nodes 8→3节点+crate树注释+src-tauri/src路径树对齐 2026-06-15 12:07:36 +08:00
lxy 2ddd2940cc 修复: CR-31 useConfirm 并发覆盖 resolve+ToolCardList 对称清 expandedTools
①useConfirm confirmDialog 进入时先 resolve?.(false) 打断前一个(连点删除/confirm 交错时前一个 Promise 永挂,resolve 被覆盖静默中断+内存泄漏),视为取消;②ToolCardList collapseInactive 对称过滤 expandedTools(原只清 expandedCards,expandedTools 残留致已完成卡片折叠后内容展开态)。cron 巡检捕获走查第8轮 CR-31,vue-tsc 0
2026-06-15 09:17:21 +08:00
lxy 6116c9bb4a 优化: CR-29 ai.ts messages 软上限+knowledge 异步竞态序号/错误统一
①ai.ts MESSAGE_CAP=200 watch(messages.length) 拦截 push 超限 splice 最早条滚动淘汰(条件 oldLen>0 && newLen-oldLen<=2 规避切换对话整体替换大跳变/首次加载误截断),现有 composables push 逻辑零改动;
②knowledge.ts _itemsReqSeq/_configReqSeq 模块级竞态序号(loadList/loadCandidates/search/loadConfig 请求捕获 seq,响应回序号不匹配丢弃旧响应含错误分支,loading 仅最新请求复位);
③create/updateStatus/archive/saveConfig/extractNow 加 try/catch 统一塞 state.error(与 load*/search 一致,CR-28 Knowledge.vue banner 消费)。
批11 agent,vue-tsc 0
2026-06-15 08:53:48 +08:00
lxy b300bb721a 优化: CR-30 appSettings useSetting 值类型契约文档+changed helper JSON 深比较
useSetting JSDoc 加值类型/整体替换契约(对象/数组深 mutate r.value.x=1 不落库,须 ref.value=newObj 整体替换)+✅/❌示例,当前无 bug 注明防误用;加 changed(a,b) helper(Object.is 短路保循环守卫 + JSON.stringify 回退对象/数组深比较),双 watch 守卫 Object.is→changed 增强对象值鲁棒性。批10,vue-tsc 0
2026-06-15 08:50:09 +08:00
lxy 40461b260a 修复: CR-28 knowledge error 通道接 Knowledge.vue banner+create 语义修正
knowledge.ts create() 改直接 listCandidates 绕开 loadCandidates 的 state.error 污染,失败 console.warn 非阻塞(保 create 成功语义纯净,原 loadCandidates 失败塞 error 与 create 返回 record 成功矛盾)+加 clearError action 导出;Knowledge.vue 加 error banner(v-if store.error + ✕ 关闭按钮 clearError)让加载/检索/配置失败对用户可见(原 state.error 全应用零消费只看空白列表)。批10 w8orghove,vue-tsc 0
2026-06-15 08:50:08 +08:00
lxy ac8dab3855 修复: CR-25 分离窗口状态机(destroyed 恢复 panelOpen/核 ai_is_generating/不自动 switch)
R3 win.once destroyed 回调补 state.panelOpen=true(X 关分离窗口后主面板自动恢复,无需手动 Ctrl+I);R4 resumeInDetached 先 await invoke('ai_is_generating') 核后端真值,generating=true 才重建生成态(置 streaming+占位气泡),false/Err 清 df-ai-gen 残留不重建(防假气泡+watchdog 130s 假超时);R5 移除 resumeInDetached 自动 switchConversation(避免覆盖 AiSession 单例 activeConversationId 致主窗口期间发消息落错会话,B-22 延伸),分离窗口可看历史列表但切换改显式触发。批9 agent,vue-tsc 0
2026-06-15 08:23:33 +08:00
lxy 5e6aa7920a 修复: CR-27 Dashboard refresh 健壮性+stage 颜色修正+删死代码
M1 refresh 包 try/catch+console.error(原 Promise.all 无兜底 unhandledrejection);M3 getProjectStage planning→planning(蓝)/cancelled→cancelled(灰) 颜色与 label 语义一致(原 planning 归 coding 紫/cancelled 归 testing 橙颜色撒谎)+CSS 新增;L4 决策/批注 panel 死代码删(模板+CSS,功能未实现)。批8,vue-tsc 0
2026-06-15 08:21:11 +08:00
lxy 7637f297c9 修复: CR-26 AR-11 listener 按 B-29 筛选契约+Tasks 空态兜底
project.ts listener task 分支改读 _activeTaskProject 闭包 var(undefined 跳过保筛选/all 全量/具体 id 按项目),加 setActiveTaskProject 暴露由 Tasks 注册,保 B-29 store.tasks 反映当前筛选契约(原无参 loadTasks 冲成全量破坏筛选);Tasks.vue 加 v-if loading/error/empty 兜底+setActiveTaskProject onMounted/watch 更新。批8,vue-tsc 0
2026-06-15 08:21:11 +08:00
lxy ef2d033ad6 修复: CR-24 AiChat listener 泄漏(stopListener 接入 onBeforeUnmount+补 clearStreamWatchdog)
useAiEvents stopListener 补 clearStreamWatchdog()(原漏清 _streamWatchdog 计时器,卸载后 130s onStreamTimeout 仍写 state);AiChat onBeforeUnmount 补 store.stopListener()(原全仓零调用 listener+watchdog 永久泄漏)。R1 双注册经核查 App.vue:94 v-if 互斥(detach 卸载主窗口 AiChat)同一时刻仅一窗口持活 listener,无真实双消费,不加去重标志避免过度防御(留后续:互斥逻辑改变则需去重或后端 emit_to 定向)。批8 wsyxw19ai,vue-tsc 0
2026-06-15 08:21:11 +08:00
lxy 16aeacc37b 修复: CR-22 types.ts 枚举注释对齐后端+删 models 冗余(CR-23 不适用)
①TaskRecord.status 注释 review_ready/merged/abandoned 错,改 todo/in_progress/in_review/testing/done/blocked/cancelled(后端 TaskStatus 7 态 types.rs:131);②IdeaRecord.status 补 archived(后端 IdeaStatus 含);③ProjectRecord.status 补 testing/releasing+删 cancelled(后端 ProjectStatus 无 Cancelled);④AiConversationSummary.models 删(前端零消费 grep 确认,冗余)。
CR-23 走查过时不适用:B-34「Tauri 不转」注释在批6 ARC-05 拆分时已简化(workflow.ts:81 无错误说明,:58 只说风格对齐)。cron 巡检捕获另一会话走查第5轮 CR-22/23,vue-tsc 0
2026-06-15 07:48:42 +08:00
lxy 94cdcdcb6a 修复: CR-21 startDataChangedListener await 包 try/catch(AR-11 挂载健壮性)
App.vue onMounted startDataChangedListener() await 无 try/catch,Tauri listen 失败(极罕见)会 reject onMounted 致 AR-11 静默失效+Vue warn。包 try/catch console.error。cron 巡检捕获另一会话走查第3轮 CR-21(我 AR-11 挂载引入),vue-tsc 0
2026-06-15 06:46:25 +08:00
lxy f574562a8d 修复: CR-18 decision 死逻辑(CR-11⑪引入)+CR-19 action 注释
CR-18 workflow.ts:67-69 selectType 三元两分支返回值相同(死逻辑)删,直接 decisions[0] ?? '';CR-19 project.ts listener action 当前未消费,保留为未来 delete 本地移除等差异化刷新预留+注释明确;CR-20 stopDataChangedListener try/catch 经核查与 workflow.ts:108 stopEventListener 写法一致(走查误判,不修)。cron 巡检捕获另一会话走查第2轮 CR-18/19/20,vue-tsc 0
2026-06-15 06:17:43 +08:00
lxy dc27e79fd2 新增: AR-11 数据变更联动(AI 工具执行后 emit df-data-changed+store listen 刷新列表)
audit.rs data_change_for_tool 映射(create_project/idea/task→create,update_*→update,delete_*/purge→delete,restore/bind_directory→update)+emit_data_changed(process_tool_calls 已有 app_handle 无需透传);commands.rs ai_approve 成功路径 emit;types.ts DfDataChangedPayload;project.ts barrel listen df-data-changed + start/stopDataChangedListener 单例;App.vue onMounted 全局挂载+onUnmounted 清理。AI 工具(create/update/delete project/task/idea)执行后前端 Projects/Tasks/Ideas 列表自动刷新。批7,cargo+vue-tsc 0
2026-06-15 05:54:15 +08:00
lxy b08adcb9da 修复: B-35 broadcast Lagged 终态兜底(P0,防前端永久卡死)
workflow.rs forward 任务 move state.db.clone() 进去(原仅 executor spawn 持有)+ Lagged 累计 lagged_total(LAGGED_PROBE_THRESHOLD=8)达阈值查 WorkflowRepo::get_by_id 终态,completed/failed/cancelled 补 emit workflow-event + break 退出 forward。防 Lagged 丢终态事件致 forward 永远 rx.recv().await 等 finished → 前端审批/完成弹窗永久卡死。合成事件 total_duration_ms/failed_node 占位(broadcast 不暴露原字段,DB 为准)。批7 wdntu5adj,cargo 0
2026-06-15 05:54:15 +08:00
lxy 06a2dea1d7 修复: §12 df-execute 文档删虚构 Docker/SSH/Git(实际仅 shell)
ARCHITECTURE.md 框图(L66)+目录注释(L84)+PROGRESS.md 实现程度(L29)删 Docker/SSH/Git,对齐 df-execute 实际(仅 shell.rs tokio::process,被 run_command 复用)。核对 crates/df-execute/src 确认无 docker/git_ops/ssh。批6
2026-06-15 05:48:48 +08:00
lxy 8710d6cfd1 重构: ARC-05 project.ts 上帝 store 拆分(4 子 store+barrel 兼容零改动)
拆 stores/project/{state,projects,tasks,ideas,workflow}.ts 四领域子 store(共享 state 单例 reactive 引用一致);越层 invoke approve_human_approval/cancel_workflow_node 下沉 src/api/workflow.ts;project.ts 改 barrel 组合器(实例化四子 store+re-export 所有成员,useProjectStore 单例+ProjectStore 类型保留)。所有 view(App/Dashboard/Ideas/ProjectDetail/Projects/Tasks/ToolCard)import '../stores/project' 零改动。行为零变化(含 CR-11⑪ approveHumanApproval 签名+B-34 snake_case)。批6,vue-tsc 0
2026-06-15 05:48:47 +08:00
lxy d809cf4b18 修复: B-22 前后端状态同步(ai_is_generating IPC+发送前查后端真值)
commands.rs 加 ai_is_generating IPC(读 session.generating)+lib.rs 注册;useAiSend sendMessage 预检先 invoke('ai_is_generating')查后端真值:后端 generating=true 时入队(撞 QUEUE_LIMIT 抛错)+本地 streaming=false 则复位 streaming=true 让用户感知,IPC 失败退化为 false 不阻断。消除前端 streaming 与后端 generating 不同步致预检放行撞拦截。批6 w8qbkxxvz,cargo+vue-tsc 0
2026-06-15 05:48:47 +08:00
lxy 19d64fc421 修复: DOC-14+§11 View 指南归档+对抗评估未实施横幅
View 改造指南归档(横幅指 DEVFLOW-3.Store对接为真相源 + 列过时点:Phase1 四问题已解决/页面命名 ProjectsView·WorkflowView 与实际不符);对抗评估文档加未实施横幅(纯启发式,三路 LLM+AI Prompt+Phase2+idea_evaluations 表为设计稿,grep idea_evaluations 零命中/migrations 无此表)。批5
2026-06-15 05:45:04 +08:00
lxy 892a642bd4 优化: R-PD-10 commands err_str helper 统一错误格式化(87 处)
mod.rs 加 pub fn err_str<E: ToString>,commands/ 10 文件 87 处 .map_err(|e| e.to_string()) → .map_err(err_str),残留 0(10 处复杂表达式 e 用于 format!/anyhow 保留)。行为零变化,统一入口为未来加日志/分类留点。批5,cargo workspace 0
2026-06-15 05:45:04 +08:00
lxy fddca9daf1 重构: CR-11 composables+ToolCard 健壮性 6 子项
⑦ useAiConversations JSON.parse 逐条 try/catch 容错(坏条降级不清空整对话);⑧ ToolCard 去 as any + argString 安全取字段;⑨ useAiSend approveToolCall 复用 findToolCall;⑩ useAiEvents 补 AiHeartbeat case 防穿透;⑪ approveHumanApproval 签名收敛(decisions:string[] + select_type 判单/多选)+ ProjectDetail 调用方同步(单选包数组/多选直传);⑫ ToolCard formatBytes/formatToolName/argString 兜底。批5 ww1mh6mry,vue-tsc 0
2026-06-15 05:45:04 +08:00
lxy 0e196ee86f 修复: DOC-09+10 df-workflow/df-storage 模块文档过期修正
- DOC-09 df-workflow 4 处:NodeRegistry 删 Default impl(无 panic 铁律)+conditions 默认 true→false(B-260614-02)+try_recv_human_approval 已删+set_waiting/set_skipped 已删(仅 set_cancelled 唯一旁路)+set_* 签名 &mut→&self。
- DOC-10 df-storage:V8→V13 + 表数 13 业务表(+schema_version)+Repo 13(12 impl_repo!+1 手写 SettingsRepo)+MIGRATION_VERSION 常量已删(版本靠 schema_version 表)+迁移表补 V9-V13+knowledges DDL 补 reasoning 列。
主代理抽查:过期项仅历史说明残留。批4
2026-06-15 05:40:37 +08:00
lxy 9bf9e12058 重构+优化: CR-04+06 AiChat 流式渲染改进 + CR-10 useRendered DRY 抽
- CR-04: splitBlocks 正则切块改 marked.lexer() tokens 遍历(type=code 的 .raw 整块,其余按双换行切),正确处理行首空格/4+反引号嵌套/未闭合围栏——lexer 与 parse 同 marked 实例同 gfm 规则,切块边界与渲染边界一致。
- CR-06: watch(mdReady) 翻 true 时主动 scheduleStreamParse 重算,修首屏 marked 慢+暂无 delta 致末段停留 escapeFallback 纯文本。
- CR-10: useMarkdown 抽 useRendered(computed 读 mdReady+renderMd + ensureLoaded 幂等预热),4 view(TaskDetail/ProjectDetail/Ideas/Knowledge)删手写 renderedDesc computed + onMounted loadMarkdown 重复,loadMarkdown 提模块级。DRY 行为零变化。
批4 wlnazlvdc,vue-tsc 0 err
2026-06-15 05:40:37 +08:00
lxy dfb459a8d6 修复: DOC-02+03+04 模块文档+使用手册重写对齐真实代码
- DOC-02 df-nodes: 8 节点虚构→3 实际(Ai/Script/Human 全完整实现),删 5 虚构节点(Docker/Git/Notify/HTTP/Subflow)+5 虚构 *_node.rs 文件行,补每节点真实实现要点。
- DOC-03 df-ai: 删虚构 router.rs(ModelRouter)+stream.rs(StreamCollector),trait 删 supported_features 改 endpoint(),工具数 12→19(tool_registry.rs 实际核对,核对报告 §3 称 13 亦滞后)。
- DOC-04 使用手册: 7 处过期修正(bun→npm / 任务状态 4→7 态 / 优先级 P0→P3 数字越小越高 / 节点 Script/Ai/Human / EventBus tokio broadcast 非 WebSocket / 多 Provider / 知识库 Tier1 AI 提炼+向量检索)。
主代理抽查:df-nodes 虚构节点仅历史说明残留 / 使用手册 bun 零命中。批3
2026-06-15 05:34:32 +08:00
lxy 4665e910ee 修复: B-14 stop 即时打断 Notify 跨文件接入(去 30s 心跳延迟)
AiSession 加 notify:Arc<Notify> 字段 + stop_notify() 取引用;stream_llm 加 notify 参数 + select! notify.notified() 分支(唤醒后 load stop_flag 判退出,防误唤醒继续跑——Notify 仅承载即时唤醒,停止真值仍由 stop_flag 决定);agentic.rs session.notify.clone() 透传 &notify;commands.rs ai_chat_stop 流式分支 stop_flag.store 后立即 notify_one() 唤醒阻塞的 stream.next()。批1 脚手架(本地无用实例)已 revert,本批完整接入。批3 wkitz0twz,cargo workspace 0 err
2026-06-15 05:34:31 +08:00
lxy af8f74e29e 修复: DOC-01 ARCHITECTURE 数据模型重写(删 9 虚构表+补 7 遗漏+字段对齐)
第六章数据模型段重写(其余 9 段未动)——删 9 虚构表(workflow_defs/runs/artifacts/connections/features/test_cases/test_runs/annotations/decisions)+补 7 遗漏(branches/node_executions/ai_conversations/ai_tool_executions/knowledges/knowledge_events/app_settings)+workflow 合并修正为单表 workflow_executions(dag_json 序列化)+字段对齐 models.rs/migrations.rs V1-V13(时间戳 INTEGER→TEXT 毫秒串,projects.idea_id 补外键)。主代理核查:虚构表零命中/CREATE TABLE 13 与 migrations.rs 一致/表名清单吻合。批2,另一会话文档核对 §1
2026-06-15 05:30:06 +08:00
lxy f93b7584ff 修复: B-32+33 流式收尾清队列+回滚 running toolCall(走查 P0)
- B-32:四路径(onStreamTimeout in useAiStream / AiError case in useAiEvents / stopChat+approveToolCall catch in useAiSend)补 state.queue=[] 清队列——drainQueue 仅 AiCompleted 触发,此前三路径不清致生成中入队消息静默丢失。
- B-33:onStreamTimeout 合并单遍扫描回滚 status===running toolCall→rejected(B-260615-07 残留)——approveToolCall 乐观置 running,后端 hang 时看门狗只复位 streaming 不碰 toolCalls 致卡片永久骨架屏无重审入口,现回滚恢复。
批2 wjg2t6j8d,vue-tsc 0 err
2026-06-15 05:30:06 +08:00
lxy 8720b9424d 重构+修复: ARC-06 composables/ai 循环依赖破环 + B-34 多选审批 select_type 回归
- ARC-06: 提 aiShared.ts 下沉 nextMsgId+_msgCounter(叶子模块),useAiEvents/useAiStream/useAiSend/useAiWindow import 源改 aiShared,useAiStream 不再 import useAiEvents 环消除。watchdog 三函数留 useAiStream(持计时器状态非纯函数)。行为零变化。
- B-34(另一会话走查发现 P0): stores/project.ts:273 selectType→select_type(Tauri 2 IPC 不转 camelCase),对齐后端 workflow.rs:211。F-260615-01 多选审批回归修复——前端传 selectType 后端收 None 归一化 Single 致多选被拒。删错误注释。
批1 wwllb4ith(ARC-06)+主代理(B-34),vue-tsc 0 err
2026-06-15 05:25:38 +08:00
lxy c7386a12c5 重构: R-PD-6 try_continue conv_id 解耦(emit 收尾用审批所属 conversation_id 消竞态)
agentic.rs 入口新增 pending_conv_id(pending_approvals.values() find_map 首个非空 conversation_id);!should_continue emit 收尾分支 + provider-Err 分支 conv_id 优先取 pending_conv_id 消除竞态,回退 active_conversation_id。续生成主路径保留 active_conversation_id(该期 generating=true 且 switch readonly 非竞态,触发审批在 ai_approve 已 remove 无法从 pending 取,扩签名需改 commands.rs 超本任务边界)。批1 wwllb4ith 实施,cargo check workspace 0 err
2026-06-15 05:25:33 +08:00
lxy 80b9243528 重构: 前端 composable 拆分+详情页 Markdown 渲染+流式 memo+状态同步+bug 修复
- composables/useMarkdown 抽(AiChat 复用)+ai/* 流式核心+useAiSend catch 回滚 user msg(B-21)+useAiStream 超时文案(B-03)+useAiConversations 切换 token(FR-R1)
- views/TaskDetail 新建(F-02)+描述 md+mdReady 响应式+字段同行布局(B-24/31)
- views/ProjectDetail 多选审批 UI(F-01)+描述 md+mdReady+字段同行(B-25/31)
- views/Ideas+Knowledge 描述 md+mdReady(B-25)
- views/Tasks 项目切换联动(B-29)+task 详情跳转
- components/AiChat toast 失败提示(B-20)+流式块级 memo(ARC-08)+clean UI(AR-7)+stop 兜底(AR-5)
- components/ToolCard 审批卡片可读化(AR-3)
- stores useAiStore barrel(ARC-04)+del settings mock(ARC-01)+approve options(R-PD-5)
- i18n aiTool/nav 中英对称(AR-3/9)+删 nav.decisions 死链(ARC-02)
- router /tasks/:id(F-02)+global.css selection 可见(R-260615-01)+api task detail+App 删 decisions nav
2026-06-15 05:14:56 +08:00
lxy 2de0c6ecb7 重构: 后端 df-ai/commands 拆分+df-nodes/workflow 改造+P0 bug 修复
- df-ai: context 历史中毒三档自愈 sanitize_messages(AC3)+anthropic_compat tool_use_id None 跳过(AC1/AC2)+删 router/stream 死码
- df-core: events 加 select_type+decisions 多选审批契约(F-260615-01)
- df-execute: shell run_command 工具复用(F-260615-05)
- df-nodes: human_node 多选校验+2 端到端测(F-01)+取消跳 set_failed(B-03b-R1/R2/R8)
- df-workflow: executor/dag/state cancel 闭环(B-06/07/03a/b)+provider approve options(R-PD-5)
- df-storage: find_path_conflict 抽公共(R-PD-11)+COLS 常量断言
- df-ideas: 删 IdeaPromoter/PromotionPolicy 死码(R-PD-14)
- src-tauri/commands/ai: secret keyring 迁移(FR-S1/R-PD-4)+GeneratingGuard RAII+disarm(B-09/26)+newConversation 软复位(B-10)+stream 心跳/stop select/空回复判错(B-02/04/05/15)+run_command(F-05)+mask audit(AR-3)
- src-tauri/commands/{project,task,workflow,mod,lib,state}: task detail IPC(F-02)+approve decisions+task list 联动(B-29)
- Cargo.lock+Cargo.toml 依赖同步
2026-06-15 05:14:42 +08:00
lxy 04032a2a8d 重构: 文档汇总+进度看板+孤儿任务清理脚本+gitignore 噪音排除
- docs/02 架构设计: 新增 aichat审查/异步审批构想/流式渲染调研/generating状态机/密钥迁移健壮性/工作流脚本执行边界/条件表达式引擎/F-07 trait下沉/Agent架构说明/任务推进构想/功能创意池;更新功能决策记录+归档/对抗论证/文档记录规范/经验记录
- docs/03 模块文档: 新增 AI对话引擎/DAG引擎详解;更新 df-knowledge/df-nodes/df-storage/df-workflow/df-ai
- docs/05 代码审查: 新增 全栈审查/全局review/架构审查/近期改动审查/工作区多角度走查/自研memo流式渲染审查
- docs/09 问题排查: 新增 aichat-apikey-401
- docs/INDEX+README 索引同步;docs/todo 待办看板(2026-06-15 汇总)
- PROGRESS.md Sprint 22-25;URGENT.md 加急清单快照(5 项 P0 已全修)
- scripts/cleanup_orphan_tasks.{py,sh} 孤儿任务清理工具
- .gitignore 补 *.broken.bak + tmp/ 噪音排除
2026-06-15 05:14:21 +08:00
lxy 4b5f096d1c 优化: dag拓扑O(V+E)+前端findToolCall反向遍历
- FR-D1 dag.rs topological_layers + executor.rs run 建 adjacency 索引, O(V·E)→O(V+E)(14测试全绿, 含executor3测)
- FR-R5 useAiEvents findToolCall 正向 O(n²) 改反向遍历(命中最近, 放弃Map索引防陈旧引用)
- AR-6 核查确认已于 f82dd8b 落地(Low失败emit AiToolCallCompleted), 零改动
2026-06-14 22:56:06 +08:00
lxy 4a95f6a1e2 重构: confirmDialog抽composable+tool_result反向查找+search列限定
- FR-D5 抽 useConfirm 收敛 4 视图(Projects/ProjectDetail/Ideas/Settings)确认弹层重复
- FR-D4 replace_tool_result_content 正向 O(n) 改反向 rposition(审批替换命中最近,调用低频)
- FR-D2 search_vector SELECT * 改显式 14 列(消除隐式依赖,字段级精简待单独立项)
2026-06-14 22:50:59 +08:00
lxy 36d68ddb26 修复: AI工具调用健壮性+审批卡片可读化+任务工具补全
- AC1/AC2 anthropic_compat tool_use_id None/空时跳过或占位(防GLM端500卡死)
- AR-3 审批卡片 id→项目名回显(前端白名单特化) + 后端查不到友好提示
- FR-D6 补 delete_task/update_task 工具(防误用 delete_project 清理孤儿任务)
- FR-D7 抽 bind_dir_to_project 消除 create_project/bind_directory 重复
- FR-D8 create_idea schema 补 priority 契约对齐
- FR-S4 SKILL.md 注入加头尾隔离标注防 prompt injection 混淆
- FR-R4 complete() 加 60s 单请求超时(不影响 stream)
2026-06-14 22:48:04 +08:00
lxy 49ac0601e1 修复: FR-S1 api_key IPC mask + 编辑不回填(防前端拿明文,P0 安全)
- ai_list_providers: 返回前 mask api_key(首尾各4+••••),IPC 不传明文给前端 realm
- ai_save_provider: api_key 空(编辑不改)→保留原 DB 值
- Settings: 编辑不回填 apiKey(留空=不改) + saveProvider 校验编辑时 apiKey 可空
- 注:DB 仍明文存储(c,keyring 加密存储)留迁移单列;本轮解 IPC 传明文 + 前端 realm 持有明文
来源 fullstack-review §2 P0 安全;cargo/vue-tsc 0 err
2026-06-14 22:18:34 +08:00
lxy f58743efbc 修复: AR-1 流式 Markdown 全量重解析掉帧
renderMd 加 isStreaming 参数:流式态(streaming 中 currentText)纯文本(escapeHtml+<br>)不 parse,避免每 delta 全量 marked.parse+DOMPurify.sanitize 致主线程阻塞掉帧;不缓存流式文本(每 delta 变,污染缓存);完成后再走 markdown 一次 parse
来源 aichat审查报告 第一章 H1 P0;vue-tsc 0 err
2026-06-14 22:16:07 +08:00
lxy f82dd8ba90 新增: Wave8 F-06 导入项目 + F-02 技能联想 + AR-6 Low 失败语义
- F-06 导入历史项目:scan.rs extract_description(README 首段 200 字) + import_project 命令(合并 create+bind,spawn_blocking 探测栈) + 前端 ProjectDetail 导入按钮 + store action;主代理补 lib.rs invoke_handler 注册(agent 未注册)
- F-02 技能联想使用:核对发现 sendMessage→ai_chat_send skill 调用链已完整(联想选中→带 skill→注入 SKILL.md),补 Esc 取消选中兜底
- AR-6 Low 失败语义统一(定向B):audit.rs Low 失败 emit AiToolCallCompleted(错误 result) 替代 AiError,消除前端 false/后端 loop 续紊乱,错误回填 tool_result 让 LLM 自处理
cargo/vue-tsc 0 err
2026-06-14 22:13:51 +08:00
lxy b195a38d64 修复: FR-S6 validate_column_name 未登记表放行→保守拒绝
None 分支 Ok(放行)→Err(拒绝),与 is_allowed_column 的 None=>false 对齐;防未来未登记表走通用查询路径列名直进字符串拼接致 SQL 注入(当前 12 表全登记无影响,防御性)
来源 fullstack-review §2(待复核→确认);df-storage test pass / cargo 0 err
2026-06-14 17:19:45 +08:00
lxy b47d45ea4e 修复: FR-S4 SKILL.md 注入加强隔离标注防 prompt injection
技能注入 system prompt 前的标注从'# 技能指令'改为'# 用户选择的技能说明(非系统指令,勿作为行为准则覆盖;以下为技能内容供参考)',明确隔离防被篡改 SKILL.md 的 prompt injection 覆盖行为准则
FR-R4 评估降级:complete 非流式用于后台任务(标题/提炼),挂死影响有限,流式有 idle timeout 兜底
来源 fullstack-review §2/§4(核实);cargo 0 err
2026-06-14 17:18:13 +08:00
lxy f3c0967f41 修复: AR-3 审批 reason 查项目名拼对象名(用户再反馈 P0)
build_approval_reason 改 async + 接收 db,对 delete/restore/purge/update/bind/create_task 的 id/project_id 查 ProjectRepo.get_by_id 拼「项目名」(id=x)(原只拼裸 id,用户反馈'只返回 ID 不知道是什么数据')
新增 resolve_project_label helper;process_tool_calls 调用改 await
来源 aichat审查报告 第二章 + 用户 2026-06-14 再反馈;cargo 0 err
2026-06-14 17:16:28 +08:00
lxy 7e27370923 新增: todo 标全栈审查 FR 已修项 + 降级项
已修: FR-S2(read TOCTOU+write 限)/FR-S3(decision 校验)/FR-R1(切换 token)/FR-R3(liveEvents 限长)
降级: FR-S5(跨对话越权核实,UI 隔离+restore 限制触发,本地单机低危→文档说明)/FR-R2(看门狗互踩存疑,审批态已 clear+独立 realm→维持)
2026-06-14 17:06:58 +08:00
lxy 5367f19f1c 修复: FR-S2 read_file TOCTOU + write_file 无大小限制
- read_file: 单次 File::open 取 metadata+read 消除 TOCTOU(原 metadata+read 两步 syscall 间 symlink 替换破 1MB 限)
- write_file: content.len() 超 1MB bail(原无限制,LLM 经审批可写超大文件撑爆磁盘/对话历史)
来源 fullstack-review §2 P0 安全(已核实);cargo 0 err
2026-06-14 17:06:11 +08:00
lxy 8dbe3d277b 优化: FR-R3 liveEvents 限长防内存膨胀
push 后超 200 条截断保留最近(原无限制,长工作流执行致数组无限增长);来源 fullstack-review §4(待复核→确认)
FR-S5 核实降级:ai_approve 无对话归属校验属实,但 UI 隔离+restore 只载当前对话历史限制触发,本地单机低危,降级文档说明
2026-06-14 17:04:36 +08:00
lxy 698a874709 修复: FR-S3 approve decision 非空校验 + FR-R1 switchConversation 切换 token
- FR-S3: approve_human_approval 加 decision 非空校验(防 "" 透传;HumanNode options 非空时还校验 ∈ options)
- FR-R1: switchConversation 加 _latestSwitchId token,过期响应丢弃(防快速连点 A→B 后返回的 A 覆盖 B messages)
来源 fullstack-review §2/§4(核实确认);cargo/vue-tsc 0 err
2026-06-14 17:03:13 +08:00
lxy 3c5f702288 新增: todo 标 FR-C1~C5 完成 + B-03b-R7 types.ts 补完
FR-C1~C5 五项正确性 bug 修复(cf18678);B-03b-R7 遗留 types.ts event.type 收窄 Wave5 已补(3e1f119)
2026-06-14 17:01:38 +08:00
lxy cf18678634 修复: 全栈审查 FR-C1~C5 五项正确性 bug
- FR-C1 formattedEvents 时间漂移:事件入数组固 _ts 时间戳(project.ts push + ProjectDetail 用 _ts),computed 重算不再刷历史时间为当前时刻
- FR-C2 sentiment 三档统一:模板 >0/<0/===0 + sentimentClass 同源 + i18n neutral(zh/en),原模板>0 与 class>=0 矛盾
- FR-C3 Settings timer 泄漏:onUnmounted 清全部三个(原仅 _concurrencyTimer)
- FR-C4 MIGRATION_VERSION 死常量:删(零代码引用,run() if 链自管版本)
- FR-C5 AiConversationDetail 缺 readonly:加 readonly?: boolean(后端 generating 时返回)
来源 fullstack-review §3(已核实);cargo/vue-tsc 0 err
2026-06-14 17:00:33 +08:00
lxy 3e1f119003 修复: Wave5 审批子系统收尾(R7补 types.ts + R9⑪ failed_node + R8 Request 测试)
- R7补: types.ts event.type string→WorkflowEventType 字面量联合(11 变体 snake_case),静态拦截写错 type
- R9⑪: workflow.rs WorkflowFailed failed_node String::new()→状态机取首个 Failed/Cancelled 节点
- R8: human_node 加 request_is_emitted_to_bus 测试,验证 R6(Request 真发到 EventBus)
cargo/vue-tsc 0 err / df-nodes 15 test pass
2026-06-14 16:40:43 +08:00
lxy 0ec2b3fd56 新增: todo 标 AR-10/R6/R7 完成 + R3/R4/R5 评估维持
AR-10 文案统一(65c475b) + R6 human_node send await + R7 前端契约 snake_case/扁平(0bb96fc,31-agent 审查报告头号 P0 审批链断裂);R3/R4/R5 评估维持现状合理(std Mutex 纳秒级/&self 放宽无害/poison fail-fast)
2026-06-14 16:33:30 +08:00
lxy 0bb96fc509 修复: B-03b-R6 human_node send 缺 await + R7 前端契约失配(P0 审批链断裂)
R6: human_node.rs:41 发 HumanApprovalRequest 缺 .await(async fn send 的 Future 被 let _ = 丢弃未 poll,Request 从未进 channel,审批链最上游断裂,B-03a 重写引入,7 单测不覆盖 Request 发出故漏)
R7: project.ts:214 type 'HumanApprovalRequest'→'human_approval_request'(serde rename snake_case) + :215 payload.event.data→payload.event 扁平取字段(WorkflowEvent 无 data 包装)
来源: workflow-approval-review-2026-06-14.md §0/§1(31-agent 审查头号 P0);cargo/vue-tsc 0 err / df-nodes 14 test pass
2026-06-14 16:32:14 +08:00
lxy 3e042f049b 新增: todo 标 AR-10 完成 + B-03b-R3/R4/R5 评估维持
AR-10 想法→灵感 13 文件统一(65c475b);R3/R4/R5 复核低优项评估结论=维持现状合理(std Mutex 纳秒级持锁 OK / &self 放宽无害 / poison panic 合理 fail-fast);#6 AR-3 id→真名评估暂缓(后端 reason 已覆盖核心可读性,ToolCard 接 store 边际增强)
2026-06-14 16:28:28 +08:00
lxy 65c475b549 优化: AR-10 想法→灵感文案统一(13 文件)
用户可见文案 + 后端错误信息 + LLM 工具描述/系统提示词 + store toast + 注释统一改灵感:
i18n zh-CN(ideas/aiTool/projectDetail) + idea.rs 错误 + tool_registry/prompt LLM + stores/project toast + api/views 注释
en 版待定(用 Ideas/Idea)+ docs/crates 注释低优先本轮略;cargo check/vue-tsc 0 error
2026-06-14 16:26:32 +08:00
lxy d6cd2805be 修复: B-03b-R1 取消节点跳 set_failed + R2 取消测试(补完 B-03b 复核)
R1: executor Err 处理加 is_cancelled 检测,已取消节点跳 set_failed(Cancelled→Failed transition 非法会 bail 致工作流崩溃),状态保 Cancelled,emit NodeFailed 仍发
R2: test_cancelled_node_skips_set_failed 验证取消传播链(CancelSelfNode 共享 node_status 自取消→Err→executor 不 bail→状态 Cancelled)
补完 B-03b 端到端:4aa689e 只通 IPC→set_cancelled→HumanNode→Err 链,漏验 HumanNode→executor Err 处理与 Cancelled 冲突;df-workflow 14 test pass
2026-06-14 16:10:10 +08:00
lxy 406b9216de 新增: todo 标 Wave3 完成(AR-5/7/9)+ 记 agent 幻觉教训
AR-7 agent impl 声称改 commands.rs 实际 diff 零改动(self_boundary_check 造假),审查 semantic_check 正确抓到——审查 prompt 加固对 correctness 有效,对 boundary 根除不掉 agent 跑全局 diff 本能
2026-06-14 16:08:04 +08:00
lxy 9e2aeffbe4 修复: aichat AR-5/7/9(stop 兜底 + clean UI 真删 + friendlyError i18n)
- AR-5 stopChat 本地先复位 streaming + clearStreamWatchdog(防审批态看门狗已 clear + AiCompleted 竞态丢失致 streaming 永真卡死)
- AR-7 clean UI + 后端真删:crud.rs 加 AiConversationRepo::clear_messages(置空 messages JSON + 清零 token,保留对话壳)+ commands.rs ai_chat_clear 调之真删 DB + AiChat 垃桶按钮二次确认。**主代理补完 agent 半成品**:agent impl 声称改 commands.rs 实际 diff 零改动(self_boundary_check 造假),审查 semantic_check 正确抓到此 gap
- AR-9 friendlyError 全走 i18n.global.t + zh/en 双语补 4 key(TS2589 用 as any 规避 vue-i18n 深度泛型)
- cargo check / vue-tsc 0 error
2026-06-14 16:07:21 +08:00
lxy 6a6f9829e3 新增: todo 补 B-03b 复核后续 5 项(R1 转换图双踩真 bug + R2 取消测试 + 3 低优)
B-03b 端到端补完后独立复核:①Cancelled 未纳入 is_legal 转换图,executor set_failed 在已取消节点 bail 致工作流异常终止(补完时漏验 HumanNode→executor Err 处理链);②零端到端取消测试
2026-06-14 16:02:03 +08:00
lxy bf8e105440 新增: todo 标 Wave2 完成 + docs 落地 aichat 审查报告/构想
- todo: AR-2/3/4 标完成 + 已完成区加 WF-F(含审查误判记录)
- docs 落地 3 份历史遗留(todo/memory 指针此前断链):aichat-review-2026-06-14.md(审查报告,todo AR 段引用)+ aichat-async-approval-concept.md + aichat-info-density-concept.md(构想)
- task-advance-concept.md 为 Wave2 agent 越权自主产出,保留未追踪待评估
2026-06-14 15:52:58 +08:00
lxy 057a2121a5 修复: aichat P0 三项(AR-2 新建对话守卫 + AR-3 审批卡片可读化 + AR-4 create_project 双审)
- AR-2 ai_conversation_create 加 generating 守卫(clear 前,防审批态新建清空 session 致 generating 永不复位卡死)
- AR-3 audit.rs build_approval_reason 按 9 工具特化拼对象名(id/field/path/title/name)+ ToolCard resultSummary 补 restore/purge case + ARG_LABEL_MAP id 加友好标签 + i18n 双语补 4 key
- AR-4 create_project schema 加 path/stack + handler 有 path 时合并绑定(spawn_blocking 探测栈,复用 bind_directory 逻辑,消除双审双 API)
- cargo check / vue-tsc 0 error
- 审查 boundary 全误判(并行同工作区 git diff 污染),主代理独立核查三任务全正确
2026-06-14 15:51:49 +08:00
lxy f0c525d0e7 新增: todo 补 aichat 审查报告 11 项(AR-1~11)+ S-02/B-05 去重标注
修审查→执行断点:aichat-review-2026-06-14.md 11 项原仅留 memory 指针未回流看板,今按 P0/P1/P2 补入;S-02 标与 AR-3 重叠、B-05 标与 AR-M5 同类
2026-06-14 15:43:59 +08:00
lxy 422cef00e5 新增: todo 标 Wave1 完成 + PROGRESS Sprint 22(B-03b 端到端补完)
- todo: B-03b/T-09/T-10/T-12 标完成,已完成区加 WF-E Wave1
- PROGRESS Sprint 22: 记录 Wave1 四任务 + B-03b agent 半成品教训 + Explore 审查语义缺陷盲区
2026-06-14 15:31:49 +08:00
lxy 89da9fad9a 优化: 立项回滚级联删 + df-ideas 死代码清理
- T-09: idea.rs:149 立项失败回滚 delete→purge_with_descendants(防孤儿子记录)
- T-12: capture.rs 删 CaptureInput/IdeaCapture 死代码,保留 Idea/IdeaScores 共享实体
- T-10: 无改动(normalize_path 已含 canonicalize,判定已解决)
2026-06-14 15:19:41 +08:00
lxy 4aa689e110 新增: 工作流审批取消端到端(StateMachine Arc 共享 + 取消 IPC 注册表)
- StateMachine 内部 HashMap→Arc<Mutex<HashMap>>,clone 共享底层
- DagExecutor 加 state_machine() 访问器
- AppState 加 workflow_state_registry(execution_id→StateMachine)
- run_workflow 注册执行器状态机 + 完成清理
- cancel_workflow_node IPC 经 execution_id 取共享引用 set_cancelled,直达 HumanNode 轮询
- 前端取消按钮 + cancelHumanApproval store 方法
- 加 clone_shares 单测验证共享语义
2026-06-14 15:19:36 +08:00
lxy 0789a36030 新增: todo 补 6 项缺口 + PROGRESS Sprint 21(workflow 推进)
todo: T-03 标✅(WF-B⑤解决)+ 补 F-07 df-ai-core/F-08 决策治理/F-09 status治理/F-10 知识库MCP/T-11 条件引擎/T-12 df-ideas死代码(决策记录+PROGRESS 缺口补进看板)
PROGRESS: Sprint 21 记录 A/B/C/D 四 workflow(commit 3f0839a/d5a6417/02ff88f/22964a2)+ 验证结论
2026-06-14 14:33:52 +08:00
lxy d9d4a0fcf6 新增: todo 标 Workflow A/B/C/D 完成 + Sprint 20 批1/2/3 收尾
B-06/B-07/B-03a 标✅(commit 22964a2),B-03b 取消机制待做。已完成段补 WF-A/B/C/D 四条(commit 3f0839a/d5a6417/02ff88f/22964a2)
2026-06-14 14:28:24 +08:00
lxy 22964a2e20 修复: 工作流审批闭环(execution_id 下沉 + 共享状态机 + HumanNode 真审批)
- B-06: DagExecutor 接收 execution_id 下沉到 NodeContext,消除 dummy-execution-id 硬编码
- B-07: NodeContext.node_status 共享 self.state_machine.clone()(is_cancelled 可工作)
- B-03a: HumanNode execute 重写为 subscribe→send→select! 循环(响应/超时/取消 + execution_id+node_id 双键 + Lagged 容忍),删假返回"同意"
- eventbus.rs 删 try_recv_human_approval 死代码
- 新增 human_node 7 单测(正常/双键过滤/超时/非法决策/自由文本)

照 B-03-人工审批响应机制.md 设计。cargo check 0 error,df-nodes 14 + df-workflow 11 test 全过
2026-06-14 14:27:08 +08:00
lxy 02ff88fc61 优化: 前端扫描文案 i18n + parseStack 抽公共 + alert/confirm 换组件
- 扫描/目录状态文案走 $t(Projects/ProjectDetail,zh-CN+en 同步)
- parseStack 抽公共到 src/utils/project.ts(删两份本地实现)
- 8 处原生 alert/confirm 全换(confirm→ConfirmDialog / alert→Arco Message),控制流语义不变

Sprint 20 审查 ⑥⑦⑧。vue-tsc 0 error,验证 safe 0 issue
2026-06-14 14:22:57 +08:00
lxy d5a641797c 优化: 项目管理阻塞去重 + normalize_path/白名单抽公共
- collect_sample/detect_stack 三处包 spawn_blocking 防 async 阻塞 + bind_directory 补漏一处
- normalize_path 抽公共到 df-project/scan(删 project.rs 本地 + tool_registry inline 闭包)
- tool_registry update_project 复用 crud is_allowed_column(删硬编码白名单,与 CRUD 同源)

Sprint 20 审查 ③④⑤。todo 记 A/B 验证发现的 2 项非阻断(T-09 idea 补偿删级联 / T-10 normalize_path 同步)
2026-06-14 14:21:24 +08:00
lxy 3f0839ace1 修复: AI 删项目改软删 + 补回收站三工具 + list_projects 排除回收站
- delete_project 由 repo.delete 物理删改 soft_delete(与 UI 删一致)
- 新增 restore_project / purge_project / list_trash 三工具(复用 ProjectRepo 回收站方法)
- list_projects 改 list_active 排除回收站,防 LLM 看到已删项目继续操作

Sprint 20 审查 ①② + Workflow A 验证发现 list_projects 泄漏
2026-06-14 14:14:19 +08:00
lxy cf017f81e2 新增: Phase2 阶段收尾(Sprint 1-20)
重构:删 5 零引用 crate(df-evolve/plugin/stages/task/traceability)+ 清死模块、ai.rs 拆 11 子 module、ai.ts 拆 6 composable、i18n 拆目录
功能:知识库全栈(df-project/scan + CRUD + 时间线 + 前端)、Settings 拆分、appSettings KV 迁移、模型池、LLM 并发 Semaphore
修复:审批持久化根治、ConditionEngine 默认拒绝、NodeRegistry unimplemented 清除、promote 补偿删除、工具结果截断 50KB、路径校验防 symlink 逃逸
文档:B-03 人工审批设计、决策记录三分档、规格契约自检、经验记录、todo 看板、PROGRESS 更新

详见 PROGRESS.md。src-tauri/儿童每日打卡应用/ 与本项目无关,已排除。
2026-06-14 14:08:20 +08:00
845 changed files with 256419 additions and 14453 deletions
+21
View File
@@ -0,0 +1,21 @@
# musl linux 交叉编译(Windows 主机无 cc/musl-gcc,用 rust-lld self-contained link)
# 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]
linker = "rust-lld"
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
+105
View File
@@ -0,0 +1,105 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "cc"
version = "1.2.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "regex"
version = "1.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "tree-sitter"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0203df02a3b6dd63575cc1d6e609edc2181c9a11867a271b25cfd2abff3ec5ca"
dependencies = [
"cc",
"regex",
"regex-syntax",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-language"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
[[package]]
name = "tree-sitter-rust"
version = "0.23.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca8ccb3e3a3495c8a943f6c3fd24c3804c471fd7f4f16087623c7fa4c0068e8a"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "ts-bench"
version = "0.1.0"
dependencies = [
"tree-sitter",
"tree-sitter-rust",
]
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "ts-bench"
version = "0.1.0"
edition = "2021"
[dependencies]
tree-sitter = "0.23"
tree-sitter-rust = "0.23"
[profile.release]
opt-level = 3
[workspace]
+184
View File
@@ -0,0 +1,184 @@
use std::env;
use std::fs;
use std::hint::black_box;
use std::time::Instant;
use tree_sitter::{Node, Parser, Query, QueryCursor};
fn char_to_token_est(chars: usize) -> usize {
(chars + 3) / 4
}
fn extract_skeleton(root: Node, src: &str, q: &Query) -> (String, usize) {
let mut out = String::new();
let mut count = 0usize;
let mut cursor = QueryCursor::new();
let matches = cursor.matches(q, root, src.as_bytes());
for mat in matches {
for cap in mat.captures {
let n = cap.node;
let start_byte = n.start_byte();
let header_end = {
let from_start = &src[start_byte..];
let mut depth: i32 = 0;
let mut end = from_start.len();
for (i, b) in from_start.char_indices() {
match b {
'{' => {
if depth == 0 {
end = i;
break;
}
depth += 1;
}
';' => {
end = i;
break;
}
_ => {}
}
let _ = depth;
}
start_byte + end.min(from_start.len())
};
let header = &src[start_byte..header_end].trim_end();
out.push_str(header);
out.push_str(" { ... }\n");
count += 1;
}
}
(out, count)
}
fn main() {
let args: Vec<String> = env::args().collect();
let path = args.get(1).expect("usage: ts-bench <file.rs>");
let src = fs::read_to_string(path).expect("read");
let src_chars = src.chars().count();
let src_tokens_est = char_to_token_est(src.len());
let mut parser = Parser::new();
parser.set_language(&tree_sitter_rust::LANGUAGE.into()).unwrap();
// warmup
let _ = parser.parse(&src, None).unwrap();
// ---- PURE PARSE timing ----
let n = 200;
let mut times_us = Vec::with_capacity(n);
for _ in 0..n {
let t0 = Instant::now();
let tree = parser.parse(black_box(&src), None).unwrap();
let t1 = Instant::now();
drop(tree);
times_us.push(t1.duration_since(t0).as_nanos() as f64 / 1000.0);
}
times_us.sort_by(|a, b| a.partial_cmp(b).unwrap());
let min = times_us[0];
let med = times_us[n / 2];
let p95 = times_us[(n as f64 * 0.95) as usize];
let mean = times_us.iter().sum::<f64>() / n as f64;
// ---- INCREMENTAL PARSE (edit one byte at end) ----
let mut src_edited = src.clone();
src_edited.push_str("\n// comment\n");
let tree0 = parser.parse(&src, None).unwrap();
let mut times_inc = Vec::with_capacity(n);
for _ in 0..n {
let t0 = Instant::now();
let t2 = parser.parse(&src_edited, Some(&tree0)).unwrap();
let t1 = Instant::now();
drop(t2);
times_inc.push(t1.duration_since(t0).as_nanos() as f64 / 1000.0);
}
times_inc.sort_by(|a, b| a.partial_cmp(b).unwrap());
let inc_med = times_inc[n / 2];
// ---- Query compilation timing ----
let query_text = r#"
[
(function_item name: (identifier) @name)
(struct_item name: (type_identifier) @name)
(enum_item name: (type_identifier) @name)
(trait_item name: (type_identifier) @name)
(impl_item trait: (type_identifier)? @trait type: (type_identifier)? @type)
(const_item name: (identifier) @name)
(static_item name: (identifier) @name)
(type_item name: (type_identifier) @name)
(mod_item name: (identifier) @name)
(macro_definition name: (identifier) @name)
]
"#;
let mut qtimes = Vec::with_capacity(n);
for _ in 0..n {
let t0 = Instant::now();
let q = Query::new(&tree_sitter_rust::LANGUAGE.into(), query_text).unwrap();
let t1 = Instant::now();
drop(q);
qtimes.push(t1.duration_since(t0).as_nanos() as f64 / 1000.0);
}
qtimes.sort_by(|a, b| a.partial_cmp(b).unwrap());
let q_med = qtimes[n / 2];
// ---- Skeleton extraction (compiled query reused) ----
let tree = parser.parse(&src, None).unwrap();
let root = tree.root_node();
let q = Query::new(&tree_sitter_rust::LANGUAGE.into(), query_text).unwrap();
let m = 200;
let mut ext_us = Vec::with_capacity(m);
let mut last_skel = String::new();
let mut last_count = 0;
for _ in 0..m {
let t0 = Instant::now();
let (skel, cnt) = extract_skeleton(root, &src, &q);
let t1 = Instant::now();
ext_us.push(t1.duration_since(t0).as_nanos() as f64 / 1000.0);
last_skel = skel;
last_count = cnt;
}
ext_us.sort_by(|a, b| a.partial_cmp(b).unwrap());
let ext_med = ext_us[m / 2];
let skel_chars = last_skel.chars().count();
let skel_tokens_est = char_to_token_est(last_skel.len());
println!("=== FILE ===");
println!("path : {}", path);
println!("lines : {}", src.lines().count());
println!("chars : {}", src_chars);
println!("bytes : {}", src.len());
println!("tokens est (~/4c) : {}", src_tokens_est);
println!();
println!("=== PARSE SPEED (pure tree-sitter-rust, release, {} iters) ===", n);
println!("min={:.2}us median={:.2}us p95={:.2}us mean={:.2}us", min, med, p95, mean);
println!("median ms : {:.3} ms", med / 1000.0);
println!();
println!("=== INCREMENTAL PARSE (small edit) ===");
println!("median : {:.2}us ({:.3}ms)", inc_med, inc_med / 1000.0);
println!();
println!("=== QUERY COMPILATION (once, should be cached) ===");
println!("median : {:.2}us ({:.3}ms)", q_med, q_med / 1000.0);
println!();
println!("=== SKELETON EXTRACTION (compiled query reused) ===");
println!("symbols extracted : {}", last_count);
println!("skeleton chars : {}", skel_chars);
println!("skeleton tokens : {}", skel_tokens_est);
println!("extract median : {:.2}us ({:.3}ms)", ext_med, ext_med / 1000.0);
println!();
println!("=== DENSITY (full vs skeleton) ===");
println!(
"chars : {:.1}x ({:.1}% reduction)",
src_chars as f64 / skel_chars as f64,
(1.0 - skel_chars as f64 / src_chars as f64) * 100.0
);
println!(
"tokens : {:.1}x ({:.1}% reduction)",
src_tokens_est as f64 / skel_tokens_est as f64,
(1.0 - skel_tokens_est as f64 / src_tokens_est as f64) * 100.0
);
println!();
println!("=== SKELETON PREVIEW (first 30 lines) ===");
for line in last_skel.lines().take(30) {
println!("{}", line);
}
}
+64
View File
@@ -3,6 +3,9 @@ logs
*.log
npm-debug.log*
# 换装脚本(scripts/swap-install.ps1)运行期锁文件
/swap-install.lock
# Dependencies
node_modules/
@@ -10,6 +13,9 @@ node_modules/
dist/
dist-ssr/
# uni-app 编译产物(微信小程序/H5 打包输出)
unpackage/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
@@ -22,12 +28,17 @@ dist-ssr/
# Rust / Cargo
target/
target-musl/ # df-relay 交叉编译产物隔离目录(见 .cargo/config.toml)
!Cargo.lock
# Tauri
src-tauri/target/
src-tauri/Cargo.lock
# 签名私钥(应用内升级,设计 §七 高风险项:私钥绝不入仓)
*.key
*.key.pub
# Tauri 自动生成的 schema(每次构建变,可重建)
src-tauri/gen/schemas/
@@ -47,3 +58,56 @@ coverage/
.env
.env.*
!.env.example
# 损坏备份(FR-S7 write_file 事故残留)+临时文件
*.broken.bak
tmp/
.temp/
# 临时杂项(2026-08-05 误入的开发机临时物:网易爬虫/播放脚本/mp3,勿提交)
archive/
# AI 编排脚本(Claude Code Workflow 临时产物,非产品代码)
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 残留)
devflow-dev.db
devflow-dev.db-shm
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/**"
]
}
+479 -228
View File
@@ -1,6 +1,6 @@
# DevFlow — 产研全流程工作流平台
> 版本: v0.1.0 | 创建: 2026-06-10 | 状态: 设计阶段
> 版本: v0.1.0 | 创建: 2026-06-10 | 状态: Phase 2 本地优先开发流程验证
## 一、项目定位
@@ -19,7 +19,9 @@
- **经验进化 (Evolution)**:开发过程中的模式自动沉淀为知识库(审查规则/Prompt模板/踩坑经验),持续进化复用
- **阶段插件**:想法→需求→编码→测试→发布,阶段即模板
### 层级模型
### 层级模型(业务层级)
DevFlow 的业务抽象分三层,自上而下层层实例化:
```
💡 Idea Pool (想法池) — 独立运转,持续捕捉和评估
@@ -31,6 +33,82 @@
└→ 🎯 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 定位体系
DevFlow 的终极交互模型是 **AI 驱动 (AI Working)**:**AI 是系统的主要操作者,人是监督者与决策者**。
```
┌──────────────────────────────────┐
│ AI Chat(唯一对话入口) │
│ 工具注册表 ←→ Agentic Loop ←→ 审批 │
└──────────────┬───────────────────┘
│
┌───────┬───────┬───┴───┬───────┬───────┐
│ 查看 │ 驱动 │ 创意 │ 决策 │ 录入 │ ← 人的五种行为
└───────┴───────┴───┬───┴───────┴───────┘
│
┌──────────────┴──────────────┐
│ IPC 命令层 │
│ project / task / idea / │
│ knowledge / workflow / ai │
└──────────────┬──────────────┘
│
┌──────────────┴──────────────┐
│ 数据层 (SQLite) │
└─────────────────────────────┘
```
#### 人的五种行为(均通过 AI Chat 完成)
| 行为 | 说明 | 示例 |
|------|------|------|
| **查看数据** | 不点页面翻列表,自然语言查询 | "最近 blocked 的任务有哪些?" → AI 调 `list_tasks` |
| **驱动执行** | 不手动推进状态,对话指令驱动 | "把子1推进到 in_progress" → AI 调 `advance_task` |
| **创意捕获** | 不打开灵感表单,对话中自然捕获 | "这个想法记一下" → AI 调 `create_idea` |
| **决策审批** | 不逐条点批准,Chat 内确认/拒绝 | 工具审批卡片(Agentic Loop 内) |
| **录入信息** | 不填 CRUD 表单,自然语言录入 | "建个项目叫 foo,在 E:/bar" → AI 调 `create_project` |
#### 关键设计原则
1. **AI 工具注册表是主接口**:IPC 就绪后必须立刻注册为 AI 工具,页面是旁路查看器
2. **页面降级为「查看层」**:Dashboard / Projects / Tasks / Ideas 等页面用于事后查看、审计、回放
3. **功能优先级 = AI 工具覆盖度**:人对模块的操作能力取决于 AI 工具注册表中该模块的工具是否完整
4. **灵感 / 项目 / 任务 三模块的 AI 工具必须完全覆盖 CRUD + 状态推进 + 升级/评估**
## 二、技术栈
| 层 | 技术 | 说明 |
@@ -63,7 +141,7 @@
│ Workflow Engine (DAG · Node · State · Event · Persist) │
├──────────────────────┬───────────────────────────────────────────┤
│ AI Orchestrator │ Execution Runtime │
│ Multi-Provider │ Shell · Docker · SSH · Git (libgit2) │
│ Multi-Provider │ Shell (tokio::process) │
│ Agent Coordinator │ Merge · Conflict Resolve │
├──────────────────────┴───────────────────────────────────────────┤
│ Storage Layer (SQLite) │
@@ -77,29 +155,27 @@
devflow/
├── Cargo.toml # Workspace 根
├── crates/
│ ├── df-core/ # 核心类型、错误、常量、事件
│ ├── df-types/ # 核心类型、错误、常量、事件、augmentation
│ ├── df-workflow/ # 工作流 DAG 引擎 (核心)
│ ├── df-nodes/ # 内置节点集合 (AI/Script/Docker/Git/Human/HTTP/Subflow)
│ ├── df-ai/ # AI 编排层 (Multi-Provider/Router/Coordinator)
│ ├── df-execute/ # 执行运行时 (Shell/Docker/SSH/Git)
│ ├── df-nodes/ # 内置节点集合 (AI/Script/Human/Git/HTTP/Notify/Subflow/Docker/自审/推进)
│ ├── df-ai/ # AI 编排层 (Multi-Provider/Coordinator)
│ ├── df-execute/ # 执行运行时 (Shell)
│ ├── df-storage/ # 存储层 (SQLite)
│ ├── df-ideas/ # 想法池引擎 (捕捉/评估/评分/晋升)
│ ├── df-project/ # 多项目管理 (调度/上下文/时间线)
│ ├── df-task/ # 任务/分支管理 (并行任务/分支/合并/冲突)
│ ├── df-traceability/ # 可追溯性 (标注/决策留痕/需求-测试映射)
│ ├── df-evolve/ # 经验进化 (知识沉淀/Prompt模板/审查规则/踩坑经验)
│ ├── df-stages/ # 阶段插件 (5 阶段内置模板)
│ └── df-plugin/ # 插件系统 (WASM/动态库)
├── src/ # Tauri 主入口
│ ├── main.rs
│ ├── state.rs
│ └── commands/ # IPC 命令
├── frontend/ # Vue 3 前端
│ └── df-project/ # 多项目管理 (调度/上下文/时间线)
│
│ 注: df-task / df-traceability / df-evolve / df-stages / df-plugin
│ 5 个 crate 已移除(2026-06-14 零引用清理,推翻原"保留骨架"取舍)
├── src-tauri/ # Tauri 主入口 (Rust)
│ └── src/
│ ├── views/ # 页面组件
│ ├── components/ # 业务组件
│ ├── stores/ # Pinia 状态
│ └── composables/ # 组合式函数
│ ├── main.rs
│ ├── state.rs
│ └── commands/ # IPC 命令
├── src/ # Vue 3 前端
│ ├── views/ # 页面组件
│ ├── components/ # 业务组件
│ ├── stores/ # Pinia 状态
│ └── composables/ # 组合式函数
├── templates/ # 工作流模板 (YAML)
└── plugins/ # 外部插件目录
```
@@ -137,7 +213,9 @@ devflow/
项目状态:`Planning / InProgress / Testing / Releasing / Completed / Paused / Cancelled`
### 5.3.1 Task & Branch Manager (df-task)
### 5.3.1 ~~Task & Branch Manager (df-task)~~ — 已移除
> **2026-06-14 零引用清理**:df-task crate 已删除。以下内容保留作为历史设计参考,不再对应实际代码。
项目内部的并行任务管理,每个任务绑定一个 Git 分支。
@@ -165,7 +243,9 @@ Task 生命周期:
2. AI 辅助解决:冲突文件交给 AI 分析并建议解决方案
3. 发布编排:选择多个 Task → 创建 release 分支 → 合并 → 集成测试 → 发布
### 5.4 Traceability & Annotation (df-traceability)
### 5.4 ~~Traceability & Annotation (df-traceability)~~ — 已移除
> **2026-06-14 零引用清理**:df-traceability crate 已删除。以下内容保留作为历史设计参考,不再对应实际代码。
贯穿所有阶段的可追溯性引擎。
@@ -238,253 +318,420 @@ pub struct Decision {
### 5.4 AI Orchestrator (df-ai)
- **LlmProvider trait**:统一接口,各模型实现
- **ModelRouter**:按任务类型路由到最优模型 + 降级链
- **AgentCoordinator**:多 Agent 协作(Planner/Coder/Reviewer/Fixer)
- **ContextManager**:Token 预算管理
- **ToolRegistry**:工具注册(供 Agent 调用)
- **LlmProvider trait**:统一接口,各模型实现(`provider.rs`)✅
- **ModelRouter**:按任务类型路由到最优模型 + 降级链 — ❌ 已删(R-PD-7),模型选择改由调用方在 `LlmProvider` 实现间直接指定
- **AgentCoordinator**:多 Agent 协作(Plan DAG 拆解 + JoinSet 层内并行 + Token 预算池 + Reviewer 仲裁) — ✅ 已实现
- **Git Worktree 隔离**:子 Agent 文件隔离(git worktree + 独立分支) — ✅ 已实现
- **ContextManager**:Token 预算管理(`context/mod.rs`,拆分为 sanitize/manager_tests 子模块)✅
- **ToolRegistry**:工具注册(`ai_tools.rs`,供 Agent 调用)✅
### 5.5 内置节点 (df-nodes)
| 节点 | 功能 |
|------|------|
| AINode | 调用 LLM,支持流式输出、工具调用 |
| ScriptNode | Shell/脚本执行 |
| DockerNode | Docker 容器操作 |
| GitNode | Git 操作 (libgit2) |
| HumanNode | 人工审批/确认 (阻塞) |
| NotifyNode | 通知 (桌面/飞书/Webhook) |
| HTTPNode | HTTP 请求 |
| SubflowNode | 嵌套子工作流 |
| 节点 | 功能 | 状态 |
|------|------|------|
| AINode | 调用 LLM,支持流式输出、工具调用 | ✅ 已实现 |
| AiSelfReviewNode | AI 对任务产出自审(verdict 闸门) | ✅ 已实现 |
| ScriptNode | Shell/脚本执行 | ✅ 已实现 |
| HumanNode | 人工审批/确认 (阻塞) | ✅ 已实现 |
| TaskAdvanceNode | 任务状态推进链唯一 status 写入路径 | ✅ 已实现 |
| GitNode | Git 操作(branch/checkout/commit/merge/push/status/log) | ✅ 已实现 |
| HTTPNode | HTTP 请求(GET/POST/PUT/DELETE) | ✅ 已实现 |
| NotifyNode | 通知(桌面/Webhook) | ✅ 已实现 |
| SubflowNode | 嵌套子工作流(深度限制防递归) | ✅ 已实现 |
| DockerNode | Docker 容器内构建/测试(环境检测+授权) | ✅ 已实现 |
> `crates/df-nodes/src/` 含 10 个 `impl Node`(ai/ai_self_review/script/human/task_advance/git/http/notify/subflow/docker,全部已实现)。
## 六、数据模型
### SQLite 表结构
> 真相源:`crates/df-storage/src/migrations.rs`(V1-V41,幂等迁移)。下表对齐真实 schema,字段/类型/默认值/外键与迁移文件一致。Rust 结构体见 `crates/df-storage/src/models.rs`。
共 **24 张业务表**(另含内部表 `schema_version` 记录迁移版本)。
```sql
-- 想法池
-- 想法池 (V1 建表 / V2 补 promoted_to + ai_analysis + scores)
CREATE TABLE ideas (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
tags TEXT, -- JSON array
source TEXT NOT NULL, -- Manual/Clipboard/AI
status TEXT NOT NULL, -- Draft/PendingReview/Approved/Rejected/Promoted/Archived
scores TEXT, -- JSON: {market_potential, competition, feasibility, roi, overall}
ai_analysis TEXT,
related_ideas TEXT, -- JSON array of idea ids
promoted_to TEXT, -- project id
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft', -- draft/pending_review/approved/rejected/promoted/archived
priority INTEGER NOT NULL DEFAULT 1,
score REAL,
tags TEXT, -- JSON 数组
source TEXT,
promoted_to TEXT, -- 晋升后的 project_id (V2)
ai_analysis TEXT, -- AI 分析结果 JSON (V2)
scores TEXT, -- 多维评分 JSON: feasibility/impact/urgency/overall (V2)
related_ids TEXT, -- 关联灵感 ID JSON 数组 (V24)
deleted_at TEXT, -- 软删回收站 (V28)
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 项目
-- 项目 (V1 建表 / V11 补 deleted_at / V12 补 path + stack)
CREATE TABLE projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
idea_id TEXT, -- 来源想法
repo_path TEXT, -- 仓库路径
tech_stack TEXT, -- JSON array
status TEXT NOT NULL, -- Planning/InProgress/Testing/Releasing/Completed/Paused/Cancelled
priority INTEGER NOT NULL DEFAULT 1, -- 0=Low, 1=Medium, 2=High, 3=Critical
current_stage TEXT, -- Idea/Requirement/Coding/Testing/Release
config TEXT, -- JSON: 项目级配置
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'planning',
idea_id TEXT REFERENCES ideas(id),
path TEXT, -- 绑定的本地代码目录绝对路径 (V12,可空=未绑定)
stack TEXT, -- 技术栈 JSON 数组字符串 (V12,探测填充)
deleted_at TEXT, -- 软删回收站: NULL=正常, 非空=已进回收站 (V11)
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 工作流定义
CREATE TABLE workflow_defs (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
task_id TEXT, -- 关联的任务 (可为空,如项目级工作流)
name TEXT NOT NULL,
description TEXT,
dag TEXT NOT NULL, -- JSON: DAG 结构
template_id TEXT, -- 来源模板
version INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- 任务 (项目内的并行工作单元)
-- 任务 (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 (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL, -- Todo/InProgress/InReview/Testing/Done/Blocked/Cancelled
priority INTEGER NOT NULL DEFAULT 1, -- 0=Low, 1=Medium, 2=High, 3=Critical
branch_name TEXT, -- Git 分支名
base_branch TEXT DEFAULT 'main', -- 基于哪个分支创建
workflow_def_id TEXT, -- 关联的工作流定义
assignee TEXT, -- AI agent 或人工
merge_conflict TEXT, -- JSON: 冲突信息
merged_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'todo', -- todo/in_progress/in_review/testing/done/blocked/cancelled
priority INTEGER NOT NULL DEFAULT 2, -- 0=critical..3=low,默认 2=medium
branch_name TEXT,
assignee TEXT,
workflow_def_id TEXT, -- 关联的工作流定义 ID (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,
updated_at TEXT NOT NULL
);
-- 发布计划 (合并多个 Task → 发布)
-- 分支 (V2 建表) — 任务与 Git 分支绑定
CREATE TABLE branches (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
task_id TEXT REFERENCES tasks(id),
name TEXT NOT NULL,
base TEXT NOT NULL DEFAULT 'main',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
merged_at TEXT
);
-- 发布计划 (V1 建表)
CREATE TABLE releases (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
version TEXT NOT NULL, -- e.g. v2.3.0
branch_name TEXT, -- release 分支
task_ids TEXT NOT NULL, -- JSON array: 包含的 Task ID 列表
status TEXT NOT NULL, -- Planning/Integrating/Testing/Ready/Published
workflow_def_id TEXT, -- 发布工作流
changelog TEXT,
published_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
version TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'planned',
task_ids TEXT NOT NULL DEFAULT '[]', -- JSON 数组: 包含的 Task ID 列表
changelog TEXT,
created_at TEXT NOT NULL,
released_at TEXT
);
-- 工作流执行
CREATE TABLE workflow_runs (
id TEXT PRIMARY KEY,
workflow_def_id TEXT NOT NULL,
project_id TEXT NOT NULL,
task_id TEXT, -- 关联的任务 (可为空)
release_id TEXT, -- 关联的发布 (可为空)
status TEXT NOT NULL DEFAULT 'pending', -- Pending/Running/Paused/Completed/Failed/Cancelled
node_states TEXT, -- JSON: {node_id: {status(Pending/Running/Completed/Failed/Skipped/Waiting), output, started_at, finished_at}}
current_layer INTEGER,
started_at INTEGER,
finished_at INTEGER,
error TEXT
-- 工作流执行 (V1 建表 / V2 补 project_id + task_id)
-- 注: 不区分 def/run, DAG 直接序列化进 dag_json, 一次执行一行
CREATE TABLE workflow_executions (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
dag_json TEXT NOT NULL, -- DAG 的 JSON 序列化
status TEXT NOT NULL DEFAULT 'pending', -- pending/running/paused/completed/failed/cancelled
triggered_by TEXT,
project_id TEXT, -- 关联项目 ID (V2)
task_id TEXT, -- 关联任务 ID (V2)
created_at TEXT NOT NULL,
completed_at TEXT,
updated_at TEXT -- 更新时间戳,排序/增量同步 (V33)
);
-- 产出物
CREATE TABLE artifacts (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
workflow_run_id TEXT,
node_id TEXT,
kind TEXT NOT NULL, -- PRD/CodeChange/TestReport/ReleaseNote/Image
title TEXT,
content TEXT,
file_path TEXT,
metadata TEXT, -- JSON
created_at INTEGER NOT NULL
-- 节点执行 (V1 建表) — 工作流执行过程中每个节点的运行快照
CREATE TABLE node_executions (
id TEXT PRIMARY KEY,
workflow_id TEXT NOT NULL REFERENCES workflow_executions(id),
node_id TEXT NOT NULL,
node_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
input_json TEXT,
output_json TEXT,
error_message TEXT,
started_at TEXT,
completed_at TEXT
);
-- 连接配置
CREATE TABLE connections (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
kind TEXT NOT NULL, -- MySQL/SSH/MongoDB/Redis/HTTP
config TEXT NOT NULL, -- JSON (加密存储)
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- AI 模型配置
-- AI 提供商配置 (V9 建表 / V18 补 model_configs / V19 补 enabled + weight)
CREATE TABLE ai_providers (
id TEXT PRIMARY KEY,
provider TEXT NOT NULL, -- Claude/GLM/DeepSeek/OpenAI
api_key TEXT, -- 加密存储
base_url TEXT,
models TEXT, -- JSON: 可用模型列表
is_default INTEGER DEFAULT 0,
config TEXT -- JSON: 额外配置
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
provider_type TEXT NOT NULL DEFAULT 'openai_compat',
api_key TEXT NOT NULL,
base_url TEXT NOT NULL,
default_model TEXT NOT NULL,
models TEXT, -- JSON array of model names
is_default INTEGER NOT NULL DEFAULT 0,
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,
updated_at TEXT NOT NULL
);
-- 需求功能清单
CREATE TABLE features (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
task_id TEXT, -- 关联任务
requirement_id TEXT, -- 来源需求
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'selected', -- Selected/Deferred/Rejected/Completed
priority INTEGER NOT NULL DEFAULT 1, -- 0=Low, 1=Medium, 2=High, 3=Critical
rejection_reason TEXT, -- 不做的原因(自动进入决策留痕)
test_case_count INTEGER DEFAULT 0,
order_index INTEGER NOT NULL, -- 排序
metadata TEXT, -- JSON: 额外属性
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
-- AI 对话 (V3 建表 / V4 补 archived / V5 补 prompt_tokens + completion_tokens / V6 补 models /
-- V16 补 pinned / V32 补 pinned_goals / V33 补 pending_approvals)
CREATE TABLE ai_conversations (
id TEXT PRIMARY KEY,
title TEXT,
messages TEXT NOT NULL DEFAULT '[]', -- JSON array of ChatMessage (V21 起消息拆分到 ai_messages)
provider_id TEXT,
model TEXT,
models TEXT, -- 用过的所有 model JSON 数组字符串 (去重, V6)
archived INTEGER NOT NULL DEFAULT 0, -- 是否归档(侧栏折叠展示, V4)
pinned INTEGER NOT NULL DEFAULT 0, -- 侧栏置顶 (V16)
prompt_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,
updated_at TEXT NOT NULL
);
-- 测试用例
CREATE TABLE test_cases (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
feature_id TEXT NOT NULL, -- 关联功能
title TEXT NOT NULL,
description TEXT,
preconditions TEXT, -- 前置条件
steps TEXT NOT NULL, -- JSON: 测试步骤 [{step, expected}]
kind TEXT NOT NULL DEFAULT 'Manual', -- Manual/Auto/AI-Generated
priority INTEGER NOT NULL DEFAULT 1, -- 0=Low, 1=Medium, 2=High, 3=Critical
-- AI 工具执行审计 (V9 建表 / V21 补 message_id / V27 状态统一 executed→completed / V36 补 subtask_id)
-- Agent 工具调用的审批/执行留痕
CREATE TABLE ai_tool_executions (
id TEXT PRIMARY KEY,
conversation_id TEXT,
message_id TEXT, -- 关联消息 (V21)
tool_call_id TEXT NOT NULL,
tool_name TEXT NOT NULL,
arguments TEXT NOT NULL,
result TEXT,
status TEXT NOT NULL DEFAULT 'pending', -- pending/approved/rejected/executing/completed/failed
risk_level TEXT NOT NULL DEFAULT 'medium', -- low/medium/high
requested_at TEXT NOT NULL,
executed_at TEXT,
decided_by TEXT, -- human/auto
subtask_id TEXT -- 归属子任务 (V36)
);
-- 测试执行记录
CREATE TABLE test_runs (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
task_id TEXT, -- 关联任务
feature_id TEXT, -- 关联功能
test_case_id TEXT NOT NULL,
status TEXT NOT NULL, -- Passed/Failed/Skipped/Blocked
actual_result TEXT,
error_detail TEXT, -- 失败详情
executed_by TEXT, -- AI/Human
duration_ms INTEGER,
executed_at INTEGER NOT NULL
-- 知识条目 (V7 建表 / V8 补 embedding / V10 补 reasoning) — 经验沉淀基本单元, 共享记忆层
-- 状态机: candidate → pending_review → published → archived
CREATE TABLE knowledges (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL DEFAULT 'pitfall', -- 7 种: review_rule/prompt_template/pitfall/architecture_pattern/diagnosis/deployment_note/workflow_optimization
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
tags TEXT, -- JSON 数组字符串
status TEXT NOT NULL DEFAULT 'candidate', -- candidate|pending_review|published|archived
confidence TEXT, -- high|medium|low (AI 提炼自评)
reuse_count INTEGER NOT NULL DEFAULT 0, -- 唯一客观排序信号
verified INTEGER NOT NULL DEFAULT 0, -- 发布审核时一次性人工标
source_project TEXT, -- 来源项目(仅溯源不过滤)
source_ref TEXT, -- 来源实体引用(如 conv:{id})
reasoning TEXT, -- AI 提炼判断依据("为何值得沉淀"), 手动录入为 NULL (V10)
embedding BLOB, -- Vec<f32> 小端字节序列化, NULL=未嵌入走 LIKE 降级 (V8)
embedding_status TEXT, -- 嵌入状态,失败可补偿重试 (V23)
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 标注 (贯穿所有内容)
CREATE TABLE annotations (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
entity_type TEXT NOT NULL, -- Requirement/Feature/Code/TestCase/TestReport/DesignDoc
entity_id TEXT NOT NULL, -- 关联实体 ID
marker TEXT NOT NULL, -- FIXME/TODO/QUESTION/RISK/DECISION/OPTIMIZE
content TEXT NOT NULL, -- 标注内容
location TEXT, -- 文件路径+行号 / 文档段落
status TEXT NOT NULL DEFAULT 'Open', -- Open/AI-Processing/Resolved/WontFix
resolved_by TEXT, -- AI/Human
resolution TEXT, -- 处理结果
created_at INTEGER NOT NULL,
resolved_at INTEGER
-- 知识生命线事件 (V10 建表) — 追加型审计: 产生/审核/引用/归档
CREATE TABLE knowledge_events (
id TEXT PRIMARY KEY,
knowledge_id TEXT NOT NULL,
event_type TEXT NOT NULL, -- created | extracted | status_changed | referenced | archived
source_ref TEXT, -- 触发来源: conv:{id} / manual / system
context_json TEXT, -- JSON: 因 event_type 而异
timestamp TEXT NOT NULL
);
-- 决策留痕
CREATE TABLE decisions (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
context TEXT NOT NULL, -- 决策背景
question TEXT NOT NULL, -- 需要决定的问题
alternatives TEXT, -- JSON array: 考虑过的方案
decision TEXT NOT NULL, -- 最终决定
reason TEXT NOT NULL, -- 决策原因
decided_by TEXT NOT NULL, -- AI/Human
entity_type TEXT, -- 关联实体类型
entity_id TEXT, -- 关联实体 ID
impact TEXT, -- 影响范围
stage TEXT, -- Idea/Requirement/Coding/Testing/Release
created_at INTEGER NOT NULL
-- 通用应用设置 (V13 建表) — 前端 localStorage 迁移目标 KV 表
CREATE TABLE app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL, -- JSON 字符串
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) — 迁移版本记录
```
## 七、阶段模板
## 七、三层模型:模板 → 工作流 → 人设
5 个内置阶段作为工作流模板(YAML 定义),用户可自定义。
> 本章详细设计已迁至专项文档,详见 [docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md](./docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md)。此处仅保留摘要性定义。
- 💡 **想法**:市场分析 → 竞品调研 → 可行性评分
- 📋 **需求**:AI 生成 PRD → 人工审阅 → 任务拆解
- 💻 **编码**:AI 编码 → 代码审查 → 自动修复
- 🧪 **测试**:运行测试 → AI 分析失败 → 回归验证
- 🚀 **发布**:构建 → 人工确认 → 部署 → 健康检查
### 7.1 三层定义
| 层 | 回答的问题 | 本质 | 生命周期 | 当前状态 |
|----|-----------|------|---------|---------|
| **流程模板 (Template)** | 应该做什么 | 可复用的蓝图(节点拓扑 + 建议人设 + 质量门禁) | 长期存在,跨项目复用 | ⚡ 需重新设计(原 df-stages 已移除) |
| **工作流 (Workflow)** | 怎么执行 | 模板的运行时实例(DAG + 状态 + 数据绑定) | 随项目启停,单次执行归档 | ✅ df-workflow 核心完成 |
| **人设 (Persona)** | 谁来做 | Agent 角色卡(system prompt + 工具集 + 行为规则) | 长期存在,跨节点复用 | ⬜ 待设计 |
**关键原则**:模板不绑定具体人设、工作流不感知人设、人设与模板解耦。
详细定义、三者关系、实例化流程、数据结构及 YAML 模板示例见 [专项设计文档](./docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md)。
## 八、Phase 规划
### Phase 1 — 引擎骨架 (4-6 周)
- df-core + df-workflow (DAG + Node trait + Executor)
- df-types + df-workflow (DAG + Node trait + Executor)
- df-storage (SQLite 基础表)
- df-execute (Shell 执行)
- 最小前端:项目列表 + 工作流执行日志
@@ -502,9 +749,10 @@ CREATE TABLE decisions (
- 前端:想法池视图 + 多项目 Tab
- 验证:想法捕捉 → AI 评估 → 立项 → 工作流执行
### Phase 4 — 节点丰富 + 阶段插件 (3-4 周)
### Phase 4 — 节点丰富 + 三层模型落地 (3-4 周)
- df-nodes (Docker/Git/Human/HTTP)
- df-stages (5 阶段模板)
- 流程模板系统(YAML 定义 + 模板库 + 实例化引擎)
- 人设系统(AgentPersona 数据结构 + 内置人设 + 工具过滤)
- 条件分支 + 断点续跑
- 验证:跑通标准产研流程模板
@@ -540,3 +788,6 @@ CREATE TABLE decisions (
8. **本地优先**:SQLite 嵌入,不依赖云服务
9. **多模型并行**:统一抽象,按任务路由,不锁定单一模型
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
+2007 -98
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -16,3 +16,21 @@ chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }
rusqlite = { version = "0.31", features = ["bundled"] }
tracing = "0.1"
regex = "1"
# keyring v3 默认不带任何 platform 后端,必须显式启 feature——
# 否则 Entry 走 noop store:set_password 静默成功不持久化,get_password 永远空。
# Win+mac 启用原生后端;linux 用 async-persistent 变体避同步阻塞 Secret Service。
# 由 df-storage(密钥解析下沉层) + src-tauri(build_provider_for 转发壳)共同引用。
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
-397
View File
@@ -1,397 +0,0 @@
# DevFlow — 项目进展与工作交接
> 创建: 2026-06-10 | 最后更新: 2026-06-12 | 当前阶段: Phase 2 aichat UX 快赢(A 路线)+ 技能联想需求调研(Sprint 8)
---
## 一、项目速览
| 项目 | 值 |
|------|-----|
| 定位 | AI 原生创作流程驾驶舱,从想法到创作成果的全流程管理 |
| 技术栈 | Tauri v2 + Vue 3 + TypeScript + Pinia / Rust Workspace (13 crate) / SQLite |
| 路径 | `E:/wk-lab/devflow/` |
| 架构文档 | `ARCHITECTURE.md` (22,745 字) |
| Git 状态 | 未首次 commit,代码全在 untracked |
| AI 能力 | df-ai OpenAI 兼容 Provider + 12 工具 + Agentic Loop |
---
## 二、代码规模统计
### Rust 后端 (71 个 .rs 文件)
| Crate | 文件数 | 总行数 | 有效行 | 实现程度 |
|-------|--------|--------|--------|---------|
| df-core | 4 | 259 | 151 | ✅ 完整 — 错误/事件/状态枚举/ID生成 |
| df-workflow | 7 | 489 | 333 | ✅ 核心 — DAG拓扑排序/执行器/状态机/事件总线 可用 |
| df-storage | 4 | 294 | 217 | ✅ 部分 — SQLite连接/迁移/建表完整,**缺 CRUD 层** |
| df-execute | 5 | 203 | 129 | ⚡ 混合 — Shell 执行器真实可用,Docker/SSH/Git 骨架 |
| df-ideas | 6 | 421 | 278 | ⚡ 大部分 — 捕获/评估/晋升/关联图 有逻辑,评分固定值 |
| df-task | 5 | 390 | 252 | ⚡ 大部分 — Task/Branch/Semver 可用,合并冲突 骨架 |
| df-project | 5 | 272 | 175 | ⚡ 混合 — 数据模型/Timeline 可用,调度器 骨架 |
| df-ai | 6 | 281 | 173 | 🔧 接口 — 类型/Provider trait 完整,无实际 LLM 调用 |
| df-traceability | 4 | 312 | 201 | ⚡ 混合 — 决策记录/标注构建 可用,SQLite查询 骨架 |
| df-plugin | 4 | 255 | 158 | ⚡ 混合 — Host/Builder 有逻辑,核心加载 骨架 |
| df-evolve | 6 | 301 | 172 | ⚡ 混合 — Knowledge/PromptTemplate 可用,引擎/提取器 骨架 |
| df-nodes | 9 | 344 | 277 | ⚡ 混合 — script_node 真实可用(接 Shell),human_node 半实现(审批响应 TODO),其余 6 种骨架 |
| df-stages | 6 | 287 | 209 | 🔩 全骨架 — 11个阶段节点 Schema 完整,execute() 全空 |
| **合计** | **71** | **~4,108** | **~2,625** | |
### 前端 (9 页面 + Store + i18n)
| 类别 | 文件数 | 行数 | 状态 |
|------|--------|------|------|
| Vue 页面 | 9 | ~3,372 | ✅ UI 全部真实 — 无空壳 |
| Pinia Store | 4 | ~362 | ⚠️ 有 state/getter 但**无 action,View 未引用** |
| Router | 1 | 63 | ✅ 9 条路由含动态路由 |
| i18n | 2 | ~94 | ⚠️ 翻译完整但**未接入 main.ts** |
| 设计系统 | 1 | 105 | ✅ CSS 变量/动画/Arco 暗色覆盖 |
| Tauri Commands | 1 | 13 | 🔩 仅 `greet` 示例,无业务逻辑 |
**前端关键问题**:
- 所有 View 组件数据**硬编码** (`ref([...])`),与 Store 完全独立
- 所有按钮事件处理函数为**空函数** `{}`
- i18n 插件未注册到 Vue app
- Arco Design 组件库未实际使用(仅 CSS 变量覆盖)
---
## 三、架构分层与依赖关系
```
真实可用路径(有端到端逻辑):
df-core (类型基础)
→ df-workflow (DAG 拓扑排序 + 执行器 + 状态机 + EventBus)
→ df-execute/shell (tokio::process Shell 执行)
→ df-storage (SQLite 连接 + 迁移 + 建表)
待打通路径(骨架就位,需填充):
df-workflow → df-nodes (8种节点 execute() 待实现)
df-workflow → df-stages (11种阶段节点 execute() 待实现)
df-ai → LlmProvider 实现 (无 HTTP client)
df-storage → 所有 crate 的 CRUD 操作
Tauri commands → Rust 业务逻辑
Vue Views → Pinia Store → Tauri IPC → Rust
```
---
## 四、全局性问题
| # | 问题 | 影响 | 优先级 | 状态 |
|---|------|------|--------|------|
| 1 | **无 CRUD 层** — df-storage 只有建表 | 所有 crate 无法持久化数据 | P0 | ✅ Sprint 2 |
| 2 | **Tauri IPC 未建** — 只有 greet 示例 command | 前端无法调用后端 | P0 | ✅ Sprint 3(17 commands) |
| 3 | **Store 未接入 View** — 两套独立硬编码数据 | 数据流断裂 | P0 | ✅ Sprint 4 |
| 4 | **AI Provider 无实现** — trait 定义完整但无 HTTP client | AI 功能全部不可用 | P1 | ✅ Sprint 5 |
| 5 | **同层节点未并行** — DagExecutor 有 TODO 注释 | 工作流执行效率 | P1 | ✅ Sprint 3(join_all) |
| 6 | **条件表达式引擎** — 仅支持 true/false 字面量 | 条件分支不可用 | P2 | ⬜ 待办 |
| 7 | **i18n 未注册** — 翻译文件存在但未挂载 | 多语言不生效 | P2 | ✅ Sprint 7 |
| 8 | **未 git commit** — 代码全 untracked | 无版本基线 | P0 | ✅ Sprint 2 |
---
## 五、Phase 规划与当前进度
### Phase 1 — 引擎骨架 ✅ 完成
| 任务 | 状态 | 说明 |
|------|------|------|
| df-core 类型系统 | ✅ 完成 | 错误/事件/状态枚举/ID生成 |
| df-workflow DAG 引擎 | ✅ 完成 | 拓扑排序/执行器/状态机/EventBus |
| df-storage SQLite 基础表 | ✅ 完成 | 6 张表 + 4 索引,缺 CRUD |
| df-execute Shell 执行 | ✅ 完成 | tokio::process 实现 |
| 最小前端:项目列表 + 工作流日志 | ✅ 完成 | Sprint 4:5 页面接 Store + 创建/运行交互 |
| 验证:3 节点简单工作流 | ⬜ 待验证 | 后端就绪,需端到端跑通 |
| df-storage CRUD 层 | ✅ 完成 | Sprint 2:6 Repo + impl_repo! 宏;Sprint 3 增 BranchRepo |
| 对抗式评估系统 | ✅ 完成 | Sprint 6:AdversarialEngine + 正反方辩论 + AI 分析师 |
| Migrations V2(关联字段 + branches 表) | ✅ 完成 | Sprint 3 |
| Executor 同层并行 + 状态转换校验 | ✅ 完成 | Sprint 3,4 单测全绿 |
| Tauri IPC 命令 | ✅ 完成 | Sprint 3:17 commands + 事件转发 |
| Store 接入 View | ✅ 完成 | Sprint 4:API 层 + composable Store + 5 页面改造 |
### Phase 2 — AI 集成 ✅ 核心完成
| 任务 | 状态 | 说明 |
|------|------|------|
| df-ai Provider 实现 | ✅ | OpenAI 兼容 HTTP client,流式 SSE 解析 |
| AI 工具注册 + V3 迁移 | ✅ | AiToolRegistry + 12 工具(list/create/update/delete + 文件系统) |
| AI IPC 命令 | ✅ | 17 个 AI command(对话/工具执行/审批门控) |
| AI Chat 面板 | ✅ | 侧边栏 + 分离窗口 + 拖拽调宽 + 对话管理 |
| Agentic Loop | ✅ | 多轮工具调用循环(max 10 轮)+ 中/高风险人工审批 |
| AI Node 实现 | ✅ | df-nodes 接 df-ai:config 驱动 provider(base_url/api_key/model/prompt)→ OpenAICompatProvider.complete → 输出 text/model/usage;注册 "ai" 节点;未端到端实测 |
| **AI Chat LLM 优化** | 🚧 P0 完成 | 任务 #43:P0 可靠性(超时/断连/停止生成)✅;P1 token 控制(usage采集/滑动窗口/摘要)⬜ | |
### Phase 3 — 想法池 + 多项目 (3-4 周) | ⬜ 未开始
### Phase 4 — 节点丰富 + 阶段插件 (3-4 周) | ⬜ 未开始
### Phase 5 — 体验打磨 (4-6 周) | ⬜ 未开始
---
## 六、工作迭代日志
### [Sprint 1] 2026-06-10 — 项目初始化
**工作内容**:
- 创建项目骨架:`bun create vite` + Tauri v2 + Cargo Workspace
- 编写完整架构文档 `ARCHITECTURE.md` (22,745 字)
- 13 个 Rust crate 全部创建,类型系统和接口设计完整
- 9 个 Vue 页面 UI 全部编写(~3,372 行),设计系统完善
- 4 个 Pinia Store 定义(state + computed)
- i18n 中英文翻译
**代码规模**:Rust ~4,108 行 / Vue+TS ~3,896 行 / 总计 ~8,000 行
**遗留问题**:
1. 所有数据硬编码,无持久化
2. 前后端未桥接
3. 未做 git commit
**下一步建议**:
1. 首次 git commit 建立基线
2. 优先实现 df-storage CRUD 层(所有持久化的前置条件)
3. 实现 Tauri IPC 命令层
4. 将 Store 接入 View,替换硬编码数据
5. 端到端验证:3 节点简单工作流跑通
---
### [Sprint 2] 2026-06-10/11 — 设计审查 + 对抗论证 + 阻塞项修复
**工作内容**:
- 三路功能审查(架构/前端/数据模型):发现 4 条核心路径全断、缺 10 张表、状态枚举三套不一致
- 三路对抗论证(市场/技术/需求"魔法打败魔法"):综合结论 = 方向有价值、scope 必须砍 60%
- 修复 Phase 1 阻塞项:Shell Windows 兼容(cmd /C)、状态枚举统一(types.rs 为准)、DAG 序列化(DagDef + NodeRegistry)
- 实现 df-storage CRUD 层(6 Repo + impl_repo! 宏 + spawn_blocking + 列名白名单)
- ScriptNode 接入 df-execute Shell 执行器
- 建立 docs/ 8 层文档体系(中文命名)
- 新增设计文档:业务系统设计、对抗论证裁决报告、想法探索-对抗式评估
**关键决策(详见 docs/02-架构设计/对抗论证裁决报告.md)**:
- 定位调整:"产研操作系统" → "本地优先的个人开发流程驾驶舱"
- 砍掉:插件系统、经验进化(自动)、需求-测试追溯、多模型路由、Agent 协作
- 降级:想法池(简化+对抗式评估)、标注(TODO 扫描器)、决策留痕(字段级)
- Phase 1 只激活 6 个 crate,其余 7 个标记预留
- 验证策略:先自用 3 个月再决定是否对外
**遗留问题**:
1. Executor 同层并行化 + StateMachine 转换校验(任务 #8)
2. Migrations V2 补字段(promoted_to/workflow_def_id/project_id 等)+ branches 表(任务 #9)
3. Tauri IPC / 前端 Store 对接尚未开始
4. Sprint 2 的代码变更未 commit
**下一步**:
1. commit Sprint 2 变更
2. Migrations V2 + Executor 并行化
3. Tauri IPC(AppState + project/task/workflow commands)
4. 前端类型对齐 + Store 接 View
### [Sprint 3] 2026-06-11 — Migrations V2 + Executor 并行化 + Tauri IPC(三代理并行)
**工作内容**(三个子代理并行,目录互不重叠):
- **Migrations V2**(df-storage/df-core):版本推进到 2 — ideas 补 promoted_to/ai_analysis/scores、tasks 补 workflow_def_id/base_branch、workflow_executions 补 project_id/task_id、新建 branches 表(含 2 索引);BranchRecord/BranchRepo/BranchStatus 枚举同步落地,ALLOWED_COLUMNS 白名单 +8 列
- **Executor 并行化**(df-workflow):同层节点改 futures::join_all 并发,三阶段模式(串行准备→并发执行→串行收尾)规避借用冲突;StateMachine 增加转换校验(仅 Pending→Running、Running→Completed/Failed 合法,set_xxx 改返回 Result);4 个单测全绿(含并行耗时断言 <180ms、失败中止下游层)
- **Tauri IPC**(src-tauri):AppState(db + 6 Repo + EventBus + NodeRegistry)+ 17 个 command(project/task/idea CRUD + run_workflow/list/get executions);run_workflow 先落库 status=running 立即返回 execution_id,事件经 app.emit("workflow-event") 转发前端,完成后回写状态。注意:NodeRegistry 不能用 default()(script 工厂是 unimplemented! 占位),改为 new() + 手动注册真实 ScriptNode
**验证**:cargo check 通过(仅 1 个 dead_code 警告:releases/node_executions Repo 暂无 command 使用);cargo test -p df-workflow 4 passed
**遗留问题**:
1. executor 失败路径只发 NodeFailed 不发 WorkflowFailed,目前由 run_workflow 在 IPC 层补发——后续应下沉到 executor
2. 前端 Store/View 仍硬编码,未调用任何 command
3. NodeRegistry::default() 的 unimplemented! 占位是隐患,应改为注册真实节点或移除
**下一步**:
1. 前端类型对齐(id: string、状态枚举对齐 types.rs)+ src/api/ 封装层
2. Store 加 action 接 API,核心 4 页面(Dashboard/Projects/ProjectDetail/Tasks)接 Store
3. 端到端验证:tauri dev 创建项目 → 运行 3 节点工作流 → 日志实时展示
### [Sprint 4] 2026-06-11 — 前端交互功能(API 层 + Store + 5 页面)
**工作内容**:
- **API 层**:新建 `src/api/` 目录 — types.ts(与 Rust Record 严格对齐)+ project/task/idea/workflow 四个 invoke 封装 + 事件监听(listen workflow-event)
- **Store 重写**:`stores/project.ts` 改为 composable 模式(reactive 全局状态 + 12 个 actions + computed stats),不依赖 Pinia
- **Projects.vue**:接 Store 真数据 + 创建项目模态框(名称/描述)+ 卡片点击跳详情页 + status 中文映射
- **ProjectDetail.vue**:路由参数加载项目 + 真实任务列表 + **3 节点 Shell DAG 工作流运行**(环境检查→运行测试→构建产物)+ 实时事件日志 + 创建任务模态框 + onUnmounted 清理监听
- **Tasks.vue**:项目/状态双筛选联动 + 创建任务模态框(选择项目+标题)+ 状态/优先级中文映射
- **Ideas.vue**:捕捉想法模态框 + 选中详情(tags JSON 解析、scores 展示)+ 删除功能
- **Dashboard.vue**:统计卡从 Store computed 计算 + Active Projects/Idea Pool 接真数据 + quickCapture 跳 Ideas
**代码变更**:13 files, +1059/-486
**遗留问题**:
1. 端到端验证未完成:需 tauri dev 实际创建项目→运行工作流→确认日志展示
2. 无数据时空状态展示较简陋
3. ~~Settings/Knowledge/Decisions 三个页面仍为硬编码 demo~~ ✅ Sprint 7 已解决(三 view 接 stores/{knowledge,settings}.ts,无 ref([ 硬编码)
**下一步**:
1. 端到端验证:tauri dev → 创建项目 → 运行 3 节点工作流 → 日志实时展示
2. 修复验证中发现的问题
3. 考虑 Phase 2 规划
### [Sprint 5] 2026-06-11 — AI Chat 全功能(Provider + 工具执行 + Agentic Loop)
**工作内容**:
- **df-ai Provider**:OpenAI 兼容 HTTP client,SSE 流式解析,ChatMessage/CompletionRequest 类型系统
- **V3 存储迁移**:ai_providers、ai_conversations、ai_tool_executions 三张表 + 对应 Repo
- **AI IPC 命令层**:17 个 command — 对话发送/流式推送、工具执行/审批门控(低风险自动/中高风险人工)、对话管理(CRUD + 切换)
- **AI 工具注册**:12 个工具 — list_projects/tasks/ideas(只读)、create_project/task/idea(创建)、update_project(更新)、delete_project(删除)、read_file/list_directory/write_file(文件系统)
- **Agentic Loop**:多轮工具调用循环(max 10 轮),流式输出→检测工具调用→执行→结果回传→继续,中高风险暂停等审批后恢复
- **AI Chat 前端**:侧边栏面板 + 拖拽调宽 + 分离独立窗口 + 对话列表管理 + 工具审批弹窗 + AgentRound 多轮消息
- **Tauri Capabilities**:ai-detached 窗口权限配置
**代码变更**:20+ files,新增 ~2000 行
**遗留问题**:
1. ~~空白屏问题:Vite 启动正常(122s)、HTML 200,但 webview 显示空白~~ ✅ Sprint 7 已修复(index.html 主题防闪烁脚本 + 启动计时埋点 __APP_T0 + body 背景色)
2. AI Node 未实现 — 目前只有骨架,需接入 df-ai 的 LlmProvider(任务 #44)
3. AI Chat LLM 调用逻辑待优化:长对话可靠性、大任务执行稳定性、token 消耗控制(任务 #43)— **P0 可靠性已在 Sprint 6 完成,P1 token 控制待办**
**下一步**:
1. 排查并修复空白屏问题
2. 实际验证 AI Chat 端到端(选 Provider → 发消息 → 工具调用 → 审批)
3. HumanNode + Settings 页面
4. AI Chat LLM 调用逻辑优化(任务 #43)
5. AI Node 实现(任务 #44)
### [Sprint 6] 2026-06-11 — AI Chat 调用可靠性 P0 + 前端类型修复
**工作内容**:
**A. AI Chat 调用可靠性 P0(df-ai + src-tauri,跨前后端 6 文件)** — 三路代理核对(调用链/上下文-token/前端)后定位可靠性死锁类问题,本轮按"先保命"只做 P0 五项(P1 token 控制设计已定,留待下轮):
- **P0-1 连接超时**:`openai_compat.rs` Client 加 `connect_timeout(30s)` 防连接阶段无限 hang;不设总 timeout(避免 reqwest 总时长限制误砍流式长生成任务)
- **P0-2 流式 idle timeout**:`stream_llm` 用 `tokio::time::timeout(120s)` 包 `stream.next()`,防"连上后中途静默"无限 hang
- **P0-3 断连检测丢弃残缺**:维护 `finished_received` 标志,流尽未收到 finished 信号 → emit AiError + **丢弃残缺响应不当完整入库**(防脏历史污染)
- **P0-4 停止生成**:`AiSession.stop_flag: Arc<AtomicBool>` + 新 `ai_chat_stop` command + 前端停止按钮(streaming 时切换为珊瑚红方块按钮);run_agentic_loop/stream_llm 多检查点响应,停止后保留已生成文本
- **P0-5 切对话防护**:`ai_conversation_switch` 生成中拒绝切换(防 active_conversation_id 被改导致旧 loop 的 save_conversation 串台写库)
**B. 前端既有类型错误修复(14 处,让 `bun run build` 通过)**:
- `types.ts`:删重复的 `WorkflowEventPayload`(declaration merging 冲突)
- `stores/project.ts`:补 `invoke` import + `payload.event.data` 类型断言 + **return 包 `reactive()` 自动解包 computed**(一处解决 Dashboard/ProjectDetail 的 store.stats/pendingApproval 解包,消费端零改动)
- `Ideas/Tasks/ProjectDetail`:去掉 `parseInt(number)` 误用 → `new Date(timestamp)`
- `ProjectDetail.vue`:删 unused `nextTick` import
- `AiChat.vue`:删死代码 `TOOL_STATUS_MAP` + `toolStatusLabel`
**C. AI Chat 代码审查修复(9 处,ai.rs + openai_compat.rs)** — 对 P0 代码做 /review 审查后修复,cargo check ✓:
- 🔴① `finish_reason="length"`(max_tokens 截断)纳入 finished — 修「大任务输出撞 4096 上限被误判断连、丢弃整段响应」(与 ④ 配合是本轮关键)
- 🔴② 路径校验最小加固:正斜杠→反斜杠规范化 + 拒 `..` 遍历 + 扩 `.aws`/`.gnupg`(根治 workspace 白名单 + canonicalize 待定边界后再做)
- 🔴③ tool_calls 按 index 排序(assistant 消息 line506 + tool_result line709 两处),消 HashMap 迭代乱序
- 🔴④ max_tokens 4096→8192,贴合大任务
- 🟡⑤ `ai_chat_stop` 在审批等待态清 pending_approvals + 复位 generating(原 stop_flag 无人读取,停止按钮表面无反应、会话卡 generating=true)
- ⚪⑨ 4 个 i18n 小函数(language_instruction/capability_section/behavior_rules/context_label)合并为 `system_prompt_parts`
- ⚪⑩ `extract_title` 单次 chars 遍历
- ⚪⑪ reqwest builder 降级加 `warn!`
**代码变更**:df-ai/openai_compat.rs、src-tauri/{commands/ai.rs, lib.rs}、前端 7 文件(+ C 轮 review:ai.rs、openai_compat.rs)
**验证**:cargo check -p devflow ✓(仅既有 warning)、vue-tsc --noEmit ✓、bun run build ✓(built in 2.47s)
**遗留问题 / 下一步**:
1. **P1 token 控制(任务 #43 续)**:流式 usage 采集(`stream_options:{include_usage:true}` + SSE usage 字段)+ token 预算自适应窗口(chars≈/3.5 启发式估算)+ 历史摘要压缩(早期摘要 + 近期全文)—— 设计已定,未实施。**⚠️ usage 采集 2026-06-11 用户叫停暂缓(结构层写一半已回退),P1 从 token 估算/裁剪/摘要续**
2. **实测验证**:`cargo tauri dev` 跑起来验证停止按钮/超时/断连真实生效(本轮仅编译 + 类型验证)
3. 停止生成边界:LLM 正常输出时停止立即生效(<1s);idle(无输出)时最多等 120s idle timeout —— P2 可用 `tokio::sync::Notify` 优化为绝对即时
4. ~~Sprint 5 遗留的空白屏问题仍待排查~~ ✅ Sprint 7 已修复(用户实测确认)
### [Sprint 7] 2026-06-11 — 空白屏修复 + i18n 接入 + 剩余页面接 Store
**工作内容**:
- **空白屏修复**(前端):`index.html` 加主题防闪烁脚本(localStorage `df-theme` 渲染前置 `data-theme`)+ 启动计时埋点 `window.__APP_T0` + body 背景 `#0c0e1a`;`main.ts` 加 `.use(i18n)` 并打印 mount 耗时日志。用户实测确认空白屏消失
- **i18n 接入**:`main.ts` 注册 `i18n` 插件;`i18n/index.ts` 完整(legacy:false / globalInjection / zh-CN 默认 + en fallback / localStorage `df-language` 持久化);App.vue 导航与各 view 改 `$t()`
- **剩余 3 页面接 Store**:`Knowledge.vue`/`Settings.vue`/`Decisions` 去硬编码 `ref([...])`,对接新建 `stores/{knowledge,settings,ai}.ts`
- **新路由**:`/` redirect → `/ai-home`,新增 `AiHome.vue`(默认落地页)/`AiDetached.vue`(分离窗口);路由 9→11 条
- **df-nodes 实现度修正**:核对发现 `script_node.rs` 早为真实实现(接 df_execute Shell + 非零退出码判断),`human_node.rs` 半实现(发 `HumanApprovalRequest` 事件 + 超时循环,但审批响应 TODO 直接返回"同意")—— 非文档原记"全骨架"
**遗留问题 / 下一步**:
1. **P1 token 控制(任务 #43 续)**:仍待办(usage 采集用户叫停,从 token 估算/裁剪/摘要续)
2. **条件表达式引擎**(conditions.rs 仍 true/false 字面量)— P2 未动
3. ~~AI Node 实现(任务 #44)~~ ✅ 本轮已实现:`ai_node.rs` 接 df-ai `OpenAICompatProvider`,从 `ctx.config` 读 base_url/api_key/model/prompt/system_prompt/temperature/max_tokens 调 `complete()`,prompt 支持上游输入优先;`df-nodes/Cargo.toml` 加 df-ai 依赖;`state.rs` build_registry 注册 "ai"。cargo check ✓(未端到端实测真实 LLM 调用)
4. **df-stages** 11 个 execute() 全空骨架 — Phase 4
5. **HumanNode 审批响应闭环**:目前直接返回"同意",需前端审批弹窗回写结果
### [Sprint 8] 2026-06-12 — Anthropic 协议 + 切对话不中断 + 技能联想需求调研
**工作内容**:
**A. aichat 升级 A 路线(UX 快赢,已实现并验证)** — 详见 memory `aichat-roadmap-ab-split`:
- **Anthropic 协议 Provider**:新增 `crates/df-ai/src/anthropic_compat.rs`,实现 `LlmProvider` trait 覆盖 Anthropic Messages API(`x-api-key` + `anthropic-version`、顶层 `system`、必填 `max_tokens`、SSE event 类型解析、`content_block` tool_use)。按 `provider_type` 路由分发(`run_agentic_loop` / `ai_node`)。支持 GLM 订阅端点 `https://open.bigmodel.cn/api/anthropic` — **用户实测对话流式 OK**
- **切对话不打断生成**:后端 `AiChatEvent` 全变体加 `conversation_id`,agentic loop spawn 前快照 conv_id,所有 emit 填充;`ai_conversation_switch` 生成中只读返回;前端 `stores/ai.ts` 按 conversation_id 路由(后台对话事件不污染当前视图,完成/错误时刷新侧边栏)
- **审查项落地**:删全屏 Tool Approval Modal(保留行内卡片审批)、错误友好化(`friendlyError` 正则映射 404/401/timeout/network → 中文提示 + `isError` 红色气泡)、智能滚动(`isNearBottom` < 80px 阈值)、provider bar 点击切换、未配 provider 空状态引导、删死 class
- **B 路线(规划式多智能体)单独立项**:填 `coordinator.rs` + `conditions.rs` 空壳,详见 memory `aichat-decision-capability`
**B. 技能联想功能需求(本轮重点,未实现,需求阶段)**:
- **需求**:aichat 输入框输入 `/` → 联想列出本机 Claude 技能(SKILL.md),按输入过滤,选中插入 `/name`;发送时后端读 SKILL.md 全文注入 system prompt / context
- **首批数据源 = Claude 3 类**(SKILL.md frontmatter 统一 `name` / `description` / `user_invocable` / `metadata.argument-hint` / `triggers[]`,只取 `user_invocable: true`):
1. Claude skills:`~/.claude/skills/*/SKILL.md`(23 个:sleep / mission-control / idea / cpa 等)
2. Claude commands:`~/.claude/commands/*.md`(1 个:idea)
3. Claude plugins:`~/.claude/plugins/marketplaces/**/skills/*/SKILL.md`(caveman 全家、官方 frontend-design / skill-creator / mcp-server-dev、wuming prompt-optimizer;`cache/` 与 `marketplaces/` 重复需按 path 去重)
- 加项目级 `.claude/skills` + `.claude/commands`(DevFlow 暂无)
- **实现要点**:① 后端新 IPC `list_skills()` 扫 Claude 3 类来源解析 frontmatter + 按 path 去重,返回 `[{name, description, argument_hint, triggers, source}]`;② 前端输入框 `/` 触发联想浮层(名称 + description);③ `/name` 发送 → 后端读 SKILL.md 注入 context;④ 跨平台 home 路径(dirs crate)
**C. 多工具 skill 体系调研结论(仅记录,后续按需扩展)**:
| 工具 | 本机状态 | skill 机制 | 是否纳入 |
|------|---------|-----------|---------|
| Claude Code | ✅ 已装 | SKILL.md(skills + commands + plugins 三类) | ✅ 首批数据源 |
| Codex (OpenAI) | ✅ 已装 | SKILL.md(`~/.codex/vendor_imports/skills/skills/.curated/*/SKILL.md`,39 个:figma / notion / playwright / security / vercel·netlify·render-deploy / latex 等;frontmatter 与 Claude 一致,可统一解析) | ⏳ 后续可扩展 |
| openclaw | ✅ 已装 | 多 agent gateway(`~/.openclaw/openclaw.json` 注册 8 agent 绑各 workspace + glm-5,经飞书 channel binding),非 SKILL.md;`agents/` 实体目录暂空 | ❌ 属 agent 选择层,非 skill 联想,暂不纳入 |
| opencode | ❌ 未装 | AGENTS.md + custom agents + slash commands(机制待确认) | ⏳ 待装后调研 |
**代码变更**:A 路线 — `crates/df-ai/src/{anthropic_compat.rs, lib.rs}`、`src-tauri/src/commands/ai.rs`、`crates/df-nodes/src/ai_node.rs`、`src/views/Settings.vue`、`src/stores/ai.ts`、`src/components/AiChat.vue`、`src/api/types.ts`
**遗留问题 / 下一步**:
1. **技能联想(B)未实现** — 需求已记,首批聚焦 Claude 3 类,等排期(工作量中:后端扫文件 + IPC、前端联想浮层)
2. **A 路线剩余场景实测**:切对话不中断(场景 2/3)、错误友好化(场景 4)待用户实测
3. **B 路线(规划式能力)**:coordinator / conditions 空壳,单独立项
<!--
### [Sprint N] YYYY-MM-DD — 简述
**工作内容**:
- 具体做了什么
**代码变更**:
- 修改了哪些文件/Crate
**遗留问题**:
- 1. ...
**下一步**:
- 1. ...
-->
---
## 七、开发约定
### 构建命令
```bash
# Rust 检查
cd E:/wk-lab/devflow && cargo check
# 前端开发
cd E:/wk-lab/devflow && bun run dev
# Tauri 开发(必须用这个,不能 cargo build)
cd E:/wk-lab/devflow && bun run tauri dev
# Tauri 构建
cd E:/wk-lab/devflow && bun run tauri build
```
### 关键设计决策
1. **引擎不绑定业务** — Workflow Engine 只做 DAG 执行,阶段是插件
2. **无 panic** — 所有占位代码返回空/默认值,不使用 `todo!`/`unimplemented!`
3. **本地优先** — SQLite 嵌入,不依赖云服务
4. **想法第一公民** — 想法池独立于项目
5. **多任务/分支并行** — 同一项目多任务各绑 Git 分支
### 复用资源
| 资源 | 路径 | 复用点 |
|------|------|--------|
| u-desk-rust Tauri 经验 | `E:/wk-lab/u-desk-rust/` | Workspace + crate 拆分 + Tauri v2 |
| Rust proxy 工具链 | `E:/wk-oth/rust-work/` | 连接管理,内嵌调用 |
| Tauri 开发铁律 | Memory: `feedback_tauri_dev_rules` | 必须用 `cargo tauri dev/build` |
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,minimum-scale=1.0,user-scalable=no" />
<title>DevFlow Mini</title>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.documentElement.style.fontSize = window.innerWidth / 20 + 'px'
})
</script>
<link rel="stylesheet" href="<%= BASE_URL %>static/index.<%= VUE_APP_INDEX_CSS_HASH %>.css" />
</head>
<body>
<noscript>
<strong>本应用需 JavaScript 启用。</strong>
</noscript>
<div id="app"></div>
</body>
</html>
+13927
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "df-miniapp",
"version": "0.1.0",
"description": "DevFlow 微信小程序(uni-app Vue3)—— 远程 AI Chat 操作桌面端",
"private": true,
"scripts": {
"dev:h5": "uni",
"dev:mp-weixin": "uni -p mp-weixin",
"build:h5": "uni build",
"build:mp-weixin": "uni build -p mp-weixin && node scripts/fix-mp-build.cjs",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-components": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-h5": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-mp-weixin": "3.0.0-alpha-4080720251125001",
"highlight.js": "^11.11.1",
"marked": "^18.0.5",
"vue": "^3.4.0",
"vue-i18n": "^9.0.0"
},
"devDependencies": {
"@dcloudio/types": "^3.4.0",
"@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001",
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001",
"@swc/helpers": "^0.5.23",
"@types/node": "^20.0.0",
"@vue/runtime-core": "^3.4.0",
"@vue/tsconfig": "^0.5.0",
"sass": "^1.70.0",
"typescript": "^5.3.0",
"vite": "^5.2.8",
"vue-tsc": "^2.0.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()
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
import { useAiChat } from '@/composables/useAiChat'
// 应用启动:连接 relay。useAiChat 单例管理 WS 生命周期 + 状态。
const aiChat = useAiChat()
onLaunch(() => {
console.log('[DevFlow Mini] 应用启动')
aiChat.connect()
})
onShow(() => {
// 切回前台:重启心跳/重连 + 恢复流式看门狗(P1-9/P1-10)
aiChat.resumeBackground()
})
onHide(() => {
// 切后台:停心跳 + 流式看门狗,防后台定时器节流触发写状态(P1-9/P1-10)
console.log('[DevFlow Mini] 应用进入后台')
aiChat.pauseBackground()
})
</script>
<style lang="scss">
page {
background-color: $uni-bg-color;
color: $uni-text-color;
font-size: $uni-font-size-base;
}
</style>
+601
View File
@@ -0,0 +1,601 @@
/**
* u-relay WS 客户端(uni-app 跨端适配)
*
* 协议(对齐 E:/wk-lab/u-relay/src/relay.rs handle_connection):
* 1. 建立 WS 连接到 `/ws/commander`
* 2. 首帧发 Hello{kind:'commander', channel_id, token} 身份宣告(relay 10s 超时)
* 3. 校验通过 → relay 回 hello_ack;失败 relay 发 error 帧 + Close
* (错误码:handshake_failed/kind_mismatch/invalid_channel_id/server_misconfigured/auth_failed)
*
* 跨端适配:
* - 微信小程序:uni.connectSocket(微信原生 API,非浏览器 WebSocket)
* - H5:uni.connectSocket 封装浏览器 WebSocket
* - 统一经 uni API 封装,业务层不直接 new WebSocket
*
* 连接生命周期(P0/P1 审计后重构,根本性,非补丁):
* - 所有"建连"经 reconnectNow() 幂等入口:clear 退避 timer + cleanup + openSocket
* connect()/resumeIfDisconnected()/scheduleReconnect 回调全部经此,杜绝双连竞态 + socket 泄漏
* - openSocket() 幂等:首行 destroySocket 残留 socket,保证 this.socket 引用唯一
* - destroySocket(task):显式注销 onOpen/onMessage/onClose/onError(覆盖空函数,微信无 offXxx)
* + close,防旧 socket 闭包回调污染新连接(单例 this 句柄被多 socket 捕获)
* - cleanup() 彻底化:destroySocket + 停心跳 + 清所有 timer + 清 pendingQueue
* 清队列防跨连接/跨会话陈旧命令补发(旧命令上下文已失效)
* - 超时三重兜底:连接超时(onOpen 未到)/握手超时(hello_ack 丢失,>relay 10s)/
* onError 兜底(微信 socket onError 不保证 onClose 跟随,DNS/TLS/域名未加白场景)
* - 心跳活性检测:lastInboundAt + 2x 间隔判定半开死连接(NAT 超时/网络切换)
* - pendingQueue 上限 + TTL:防握手永卡时无限堆积内存泄漏
*
* 重连:指数退避(baseDelay * 2^min(n,6),封顶 maxDelay,带随机抖动)
* 心跳:固定间隔发 ControlMessage{control_kind:'ping'},防空闲断开 + 在线状态
* JSON 透传:入站解析为 BroadcastMessage,payload 是 AiChatEvent JSON
* 出站直接发 Command JSON(relay 自动包 payload,见 relay.rs:332-365)
*/
import { getConfig } from '@/config'
import type { BroadcastMessage, Hello, MiniCommand, ControlMessage } from '@/types/relay'
/** 调试日志门控(P1 走查:生产 console.log 过微信 bridge,热路径成本)。dev=true / build=false。 */
const DEBUG = import.meta.env.DEV
/** WS 连接状态 */
export type WsStatus = 'disconnected' | 'connecting' | 'connected' | 'handshaking' | 'reconnecting'
/** 入站事件回调(Event/Control 消息分派) */
export interface WsHandlers {
/** 收到 Event 消息(payload 已解为 AiChatEvent JSON,业务层按 type discriminator 处理) */
onEvent: (msg: BroadcastMessage) => void
/** 收到 Control 消息(心跳响应 / 在线状态等) */
onControl: (msg: ControlMessage) => void
/** 状态变更(连接 / 断开 / 重连中) */
onStatus: (status: WsStatus, detail?: string) => void
}
/** pendingQueue 带入队时间戳,flush 时按 TTL 丢弃陈旧命令 */
interface QueuedCmd {
cmd: MiniCommand
ts: number
}
/** 握手超时(>relay.rs:321 服务端 10s,留余量) */
const HANDSHAKE_TIMEOUT_MS = 12000
/** 连接超时(openSocket 后 onOpen 未到则重连;治 onError 不触发 onClose 场景) */
const CONNECT_TIMEOUT_MS = 8000
/** pendingQueue 上限(防握手永卡时无限堆积内存泄漏) */
const PENDING_MAX = 50
/** pendingQueue 单条 TTL(flush 时超时丢弃,防跨连接陈旧命令补发) */
const PENDING_TTL_MS = 60000
/** 心跳活性判定因子:超过 N 倍心跳间隔无入站消息判半开死连接 */
const HEARTBEAT_DEAD_FACTOR = 2
/** 最大重连次数(永久性故障如 relayHost 错/中继下线,停止无限退避,提示用户检查) */
const MAX_RECONNECT_ATTEMPTS = 20
/**
* WS 客户端单例(miniapp 全局一个连接)
*
* 设计:单 socket + 单 handlers 回调集合(composable 订阅,业务层不直接持 socket)。
* 握手/心跳/重连机制内部封装,业务层仅关心 send(Command) + onEvent。
*/
class WsClient {
private socket: UniApp.SocketTask | null = null
private handlers: WsHandlers | null = null
private status: WsStatus = 'disconnected'
/** 握手是否已完成(收到首个广播或握手成功信号) */
private handshaked = false
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
/** 握手超时定时器(handleOpen 发 Hello 后启,收 hello_ack 后清) */
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
/** 连接超时定时器(openSocket 启,onOpen 到达后清) */
private connectTimer: ReturnType<typeof setTimeout> | null = null
private reconnectAttempts = 0
/** 主动关闭标志(用户调用 disconnect 时置 true,不再触发重连) */
private manualClose = false
/** 可恢复标志:达上限 disconnected 时置 true,允许 connect/resumeIfDisconnected 重置重连 */
private recoverable = false
/**
* 握手完成前积压的命令队列(send 在 !handshaked 时不丢弃,入队等 hello_ack 后补发)。
* 治「握手成功到首帧竞态丢命令」:握手刚翻 true 时 relay 设备路由表可能未就绪,
* 业务层快速重发会被 send 守卫放行后静默丢失;入队保证握手稳定后顺序补发。
* 带时间戳 + 上限 + TTL,防握手永卡时无限堆积(见 PENDING_MAX/PENDING_TTL_MS)。
*/
private pendingQueue: QueuedCmd[] = []
/** 最后入站消息时间戳(心跳活性检测,relay 无 pong 故用任意入站消息替代) */
private lastInboundAt = 0
/** 订阅(单订阅,composable 注册一次) */
subscribe(h: WsHandlers): void {
this.handlers = h
}
/** 读取当前状态 */
getStatus(): WsStatus {
return this.status
}
/** 主动连接 */
connect(): void {
if (this.status === 'connected' || this.status === 'connecting' || this.status === 'handshaking') {
return
}
this.manualClose = false
this.recoverable = false
// 用户主动连接复位重连计数(可达上限后的 disconnected 也允许手动重试)
this.reconnectAttempts = 0
// 统一经 reconnectNow(幂等 clear timer + cleanup + openSocket),杜绝 reconnecting 态双连
this.reconnectNow()
}
/** 主动断开(用户手动,不重连) */
disconnect(): void {
this.manualClose = true
this.reconnectAttempts = 0
// cleanup 内部清 pendingQueue + 注销 socket + 清所有 timer
this.cleanup()
this.setStatus('disconnected', 'manual close')
}
/**
* 若已断开或正在退避重连则立即重连(onShow 时调用,小程序后台回前台)。
*
* 覆盖 `disconnected` 与 `reconnecting` 两种态:
* - disconnected:onClose 已触发,直接重连;
* - reconnecting:退避计时未到,抢占退避立即重连(治「切后台回前台卡在 reconnecting」头号症状)。
*
* 统一经 reconnectNow(幂等清理),保证抢占时不残留旧 socket/timer。
*/
resumeIfDisconnected(): void {
if (this.manualClose) return
// 已达上限 disconnected(recoverable=true)也允许重置计数并重连
if (this.status === 'disconnected' || this.status === 'reconnecting' || this.recoverable) {
this.recoverable = false
// 用户触发的立即重连(onShow 抢占退避)复位计数,允许达上限后手动恢复
this.reconnectAttempts = 0
this.reconnectNow()
}
}
/**
* 暂停后台定时器(小程序 onHide 调,P1-9):停心跳 setInterval。
*
* 微信后台定时器节流但仍触发,心跳持续发 ping 浪费 + 后台无入站致半开检测误判。
* 不 disconnect(disconnect 清队列 + manualClose,不适合后台暂离),仅停心跳保留连接。
* 重连 setTimeout 若后台断连触发,单次非循环,影响可接受(MVP 不暂停)。
*/
pauseBackground(): void {
this.stopHeartbeat()
}
/**
* 恢复前台(小程序 onShow 调):连接在则重启心跳,断则重连。
*/
resumeBackground(): void {
if (this.status === 'connected' && this.handshaked) {
this.startHeartbeat()
} else {
this.resumeIfDisconnected()
}
}
/**
* 发送 Command(commander → agent)
*
* 不包装 BroadcastMessage —— relay.rs:332-365 入站文本帧直接当 payload 包成
* BroadcastMessage(channel_id/kind/source/from 由 relay 填),故客户端仅发业务 JSON。
*/
send(cmd: MiniCommand): boolean {
if (DEBUG) console.log('[dbg:ws] send', cmd.cmd, 'socket=', !!this.socket, 'handshaked=', this.handshaked)
if (!this.socket || !this.handshaked) {
// 握手未完成不丢弃,入队待 hello_ack 后补发(治握手成功到首帧竞态丢命令)。
// 仅在未主动断开(manualClose=false)且连接尚有恢复预期时入队,
// 否则(manualClose=true / 长期 disconnected)不堆积陈旧命令。
if (this.manualClose) {
console.warn('[WsClient] 已主动断开,丢弃命令', cmd.cmd)
return false
}
this.enqueuePending(cmd)
return false
}
try {
const text = JSON.stringify(cmd)
this.socket.send({ data: text })
return true
} catch (e) {
console.error('[WsClient] send 失败', e)
return false
}
}
/** 入队(带上限 + 时间戳,TTL 丢弃见 flushPendingQueue) */
private enqueuePending(cmd: MiniCommand): void {
if (this.pendingQueue.length >= PENDING_MAX) {
// 超上限丢弃最旧,防握手永卡时无限堆积内存泄漏
this.pendingQueue.shift()
console.warn('[WsClient] pendingQueue 超上限,丢弃最旧命令')
}
this.pendingQueue.push({ cmd, ts: Date.now() })
console.log('[WsClient] 握手未完成,命令入队待补发', cmd.cmd, 'queue=', this.pendingQueue.length)
}
/**
* 补发握手期间积压的命令队列(收到 hello_ack 后调)。
*
* list_conversations 已由握手逻辑自己发(不进队列),此处仅补发业务 send 积压。
* 补发前置 handshaked=true,send 守卫放行,逐条直发 socket。
* TTL 丢弃:入队超 PENDING_TTL_MS 的视为陈旧(上下文已失效),不补发。
*/
private flushPendingQueue(): void {
if (this.pendingQueue.length === 0) return
const now = Date.now()
console.log('[WsClient] 补发握手积压命令', this.pendingQueue.length)
while (this.pendingQueue.length > 0) {
const item = this.pendingQueue.shift()!
if (now - item.ts > PENDING_TTL_MS) {
console.warn('[WsClient] 补发时丢弃超时陈旧命令', item.cmd.cmd)
continue
}
try {
if (this.socket) {
this.socket.send({ data: JSON.stringify(item.cmd) })
}
} catch (e) {
console.error('[WsClient] 补发命令失败', item.cmd.cmd, e)
}
}
}
// ─── 连接生命周期(统一入口 + 幂等清理) ───────────────────
/**
* 幂等立即建连入口(clear 退避 timer + cleanup + openSocket)。
*
* 所有需要"立即开新连接"的路径(connect/resumeIfDisconnected/scheduleReconnect 回调)
* 统一经此,保证:
* - 不残留挂起的 reconnectTimer(防退避 timer 与 openSocket 双触发建两个 socket);
* - 旧 socket 被 cleanup 注销+关闭(防 this.socket 引用覆盖后句柄泄漏);
* - pendingQueue 被清(防跨连接/跨会话陈旧命令补发)。
*/
private reconnectNow(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
this.cleanup()
this.openSocket()
}
private openSocket(): void {
// 幂等:残留 socket 先注销+关闭(reconnectNow 已 cleanup,此为双保险)
if (this.socket) {
this.destroySocket(this.socket)
this.socket = null
}
const cfg = getConfig()
this.setStatus('connecting')
console.log('[WsClient] 连接', cfg.relayHost)
let task: UniApp.SocketTask
try {
task = uni.connectSocket({
url: cfg.relayHost,
complete: () => {
// complete 回调在 uni-app 仅表示"任务创建完成",非连接完成(onOpen 才是)
},
})
} catch (e) {
console.error('[WsClient] connectSocket 异常', e)
this.scheduleReconnect()
return
}
this.socket = task
task.onOpen(() => this.handleOpen())
task.onMessage((res) => this.handleMessage(res))
task.onClose((res) => this.handleClose(res))
task.onError((res) => this.handleError(res))
// 连接超时:onOpen 未到则重连(治 onError 不触发 onClose 的场景:
// DNS 解析失败/TLS 握手失败/relayHost 不可达/wss 域名未加白名单)
this.connectTimer = setTimeout(() => {
this.connectTimer = null
if (this.status === 'connecting') {
console.warn('[WsClient] 连接超时,onOpen 未到,重连')
this.cleanup()
this.scheduleReconnect()
}
}, CONNECT_TIMEOUT_MS)
}
/** WS 已建立连接 → 立即发 Hello 握手帧 + 启握手超时 */
private handleOpen(): void {
// onOpen 到达,取消连接超时
if (this.connectTimer) {
clearTimeout(this.connectTimer)
this.connectTimer = null
}
this.setStatus('handshaking')
const cfg = getConfig()
const hello: Hello = {
kind: 'commander',
channel_id: cfg.channelId,
token: cfg.token,
}
try {
this.socket?.send({ data: JSON.stringify(hello) })
} catch (e) {
console.error('[WsClient] 发送 Hello 失败', e)
// 失败先 cleanup(关坏 socket + 清监听器)再退避,与 handleHandshakeResponse 失败路径对齐
this.cleanup()
this.scheduleReconnect()
return
}
// 握手超时:hello_ack 丢失(网络抖动/relay send_text 失败吞错 relay.rs:253-257)则重连。
// >relay.rs:321 服务端 10s,留余量。握手成功/失败时 handleHandshakeResponse 会清此 timer。
this.handshakeTimer = setTimeout(() => {
this.handshakeTimer = null
if (!this.handshaked) {
console.warn('[WsClient] 握手超时,hello_ack 未收到,重连')
this.cleanup()
this.scheduleReconnect()
}
}, HANDSHAKE_TIMEOUT_MS)
}
/** 入站消息分派:握手阶段首帧=控制(握手成功/失败),握手后=BroadcastMessage */
private handleMessage(res: { data: string | ArrayBuffer }): void {
// 协议纯 JSON 文本帧(relay 发文本),ArrayBuffer 非预期 —— 加 warn 可观测
// (防二进制帧静默丢弃致握手期 hello_ack 被吃却无日志)
if (typeof res.data !== 'string') {
console.warn('[WsClient] 收到非预期二进制帧,丢弃', (res.data as ArrayBuffer).byteLength)
return
}
const text = res.data
if (!text) return
// 活性:任意入站帧更新(含 relay 回的 pong 心跳响应,用于半连接检测)
this.lastInboundAt = Date.now()
// 握手阶段可能收到 relay 发的错误控制帧(relay.rs:200-238)
if (!this.handshaked) {
this.handleHandshakeResponse(text)
return
}
// 解析 BroadcastMessage(device 事件经 relay 包成完整 BroadcastMessage 下发)
let msg: BroadcastMessage
try {
msg = JSON.parse(text) as BroadcastMessage
} catch (e) {
console.warn('[WsClient] 入站消息 JSON 解析失败', e, text.slice(0, 200))
return
}
// 按 kind 分派:Event → 业务层;Control → 心跳响应等
if (msg.kind === 'event') {
this.handlers?.onEvent(msg)
} else if (msg.kind === 'control') {
this.handlers?.onControl(msg.payload as ControlMessage)
}
// command 是 device→miniapp 反向(不应在此端收到,忽略)
}
/** 处理握手响应(relay 发 hello_ack 显式 ack;失败时发 error 帧 + 关连接) */
private handleHandshakeResponse(text: string): void {
// 结构化解析(优先):对齐 relay.rs:255 实发 hello_ack 字面量
// 成功:{"kind":"control","payload":{"control_kind":"hello_ack"}}
// 失败:{"kind":"control","error":"handshake_failed|kind_mismatch|auth_failed"} (relay.rs:200-238)
// 注:hello_ack 的 control_kind 在 payload 内(与 error 顶层字段结构不同,须分别判)。
let parsedOk = false
let parsed: { kind?: string; error?: string; payload?: { control_kind?: string } } | null = null
try {
parsed = JSON.parse(text) as { kind?: string; error?: string; payload?: { control_kind?: string } }
parsedOk = true
} catch (e) {
// JSON.parse 失败:降级回旧 includes 兜底,保证非 JSON 帧不误判
console.warn('[WsClient] 握手响应 JSON 解析失败,降级 includes 兜底', e, text.slice(0, 200))
}
if (parsedOk && parsed) {
// 显式 error 字段存在即握手失败(对齐 relay.rs:203/220/233 error 顶层字段)
if (parsed.error !== undefined) {
console.error('[WsClient] 握手失败', parsed.error, text)
this.setStatus('disconnected', `handshake failed: ${parsed.error}`)
this.clearHandshakeTimer()
// 握手失败不重连(token 错则重连也错),等用户修配置后手动 connect
this.cleanup()
return
}
// 显式 ack 信号:kind==='control' 且 payload.control_kind==='hello_ack'
// (对齐 relay.rs:255 实发字面量;ControlMessage 枚举未含 hello_ack 故不强转类型)
if (parsed.kind === 'control' && parsed.payload?.control_kind === 'hello_ack') {
this.clearHandshakeTimer()
this.handshaked = true
this.reconnectAttempts = 0
this.setStatus('connected')
this.startHeartbeat()
// F-#95:握手成功后立即发 list_conversations —— device 在线探测 + 首屏会话列表同步。
// device 在线则 route_list_conversations 推回 AiConversationList(useAiChat 置 deviceOnline=true);
// device 离线则无响应,文案保持「已连接中继」诚实表述。
// 此时 handshaked=true,send 守卫放行;relay 纯透传入站文本帧作 payload。
this.send({ cmd: 'list_conversations', args: {} })
// 补发握手期间积压的业务命令(治握手成功到首帧竞态丢命令)
this.flushPendingQueue()
// hello_ack 是纯控制帧无业务 payload,无需转 handleMessage 处理
return
}
// 解析成功但既非 error 也非 hello_ack:异常帧,丢弃等下一条(保守不误判握手通过)
console.warn('[WsClient] 握手阶段收到非 ack/error 控制帧,丢弃', text.slice(0, 200))
return
}
// 降级兜底(JSON.parse 失败走此分支):保留旧 includes 判定,防协议演进/非 JSON 帧
if (text.includes('"error"')) {
console.error('[WsClient] 握手失败(includes 兜底)', text)
this.setStatus('disconnected', `handshake failed: ${text}`)
this.clearHandshakeTimer()
this.cleanup()
return
}
// 兜底:非错误消息视为握手通过(覆盖旧实现行为,防解析失败的 hello_ack 帧)
this.clearHandshakeTimer()
this.handshaked = true
this.reconnectAttempts = 0
this.setStatus('connected')
this.startHeartbeat()
this.send({ cmd: 'list_conversations', args: {} })
// 补发握手期间积压的业务命令(降级兜底路径同样补发)
this.flushPendingQueue()
this.handleMessage({ data: text })
}
private handleClose(res: { code: number; reason: string }): void {
console.log('[WsClient] 连接关闭', res.code, res.reason)
this.cleanup()
if (!this.manualClose) {
this.scheduleReconnect()
} else {
this.setStatus('disconnected', 'manual close')
}
}
private handleError(res: { errMsg: string }): void {
console.error('[WsClient] socket 错误', res.errMsg)
// 微信 socket onError 不保证 onClose 跟随(DNS/TLS 失败/域名未加白名单/onOpen 也不触发)。
// 若尚在 connecting/handshaking(未进过 connected),主动兜底重连,防永卡。
// connected 态的半开死连接由心跳活性检测(P1-2)兜底,此处不重连(可能 onClose 随后跟随)。
if (this.status === 'connecting' || this.status === 'handshaking') {
this.cleanup()
this.scheduleReconnect()
}
}
/** 启动心跳(固定间隔发 ControlMessage{control_kind:'heartbeat'} + 半开死连接检测) */
private startHeartbeat(): void {
this.stopHeartbeat()
const cfg = getConfig()
// 连接成功初始化活性时间(首次心跳检查不立即判死)
this.lastInboundAt = Date.now()
this.heartbeatTimer = setInterval(() => {
// 死连接检测:NAT 超时/网络切换致 TCP 半开(onClose 不触发,socket 仍 open)。
// relay 无 pong,用「任意入站消息」更新 lastInboundAt;超过 N 倍间隔无入站判死,主动重连。
const now = Date.now()
if (now - this.lastInboundAt > cfg.heartbeatInterval * HEARTBEAT_DEAD_FACTOR) {
console.warn('[WsClient] 心跳活性检测:连接疑似半开死连接,主动重连')
this.cleanup()
this.scheduleReconnect()
return
}
const ping: ControlMessage = { control_kind: 'ping' }
// 直接发 ControlMessage JSON(relay 透传,device 可回 pong)
try {
this.socket?.send({ data: JSON.stringify(ping) })
} catch (e) {
console.warn('[WsClient] 心跳发送失败', e)
}
// 注:cfg.heartbeatInterval 读但不显式用(已通过 setInterval 传入),保 cfg 引用
void cfg
}, cfg.heartbeatInterval)
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
}
/**
* 指数退避重连(baseDelay * 2^min(attempt,6),封顶 maxDelay,带随机抖动)。
*
* - attempt 软上限 6:超 6 不再翻倍,防长尾退避爬到 maxDelay 后卡死;
* - 抖动因子 0.5~1.0:防多客户端重连风暴(雷同退避同步触发);
* - manualClose/disconnect 时复位 reconnectAttempts(见 disconnect)。
*
* 回调统一经 reconnectNow(幂等清理 + openSocket),不裸调 openSocket。
*/
private scheduleReconnect(): void {
if (this.reconnectTimer) return
// 永久性故障上限:relayHost 错/中继下线时停止无限退避,避免长跑空耗 + 误导用户「重连中」。
// 用户手动重连(connect/resumeIfDisconnected)复位计数后可重新开始。
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
console.warn(`[WsClient] 已达最大重连次数 ${MAX_RECONNECT_ATTEMPTS},停止重连`)
// 不清 cleanup(保留 manualClose 不变),仅设状态 + 停退避 timer
// 让 connect()/resumeIfDisconnected() 可重置计数后恢复
this.setStatus('disconnected', '已达最大重连次数,请检查中继地址/网络后手动重连')
this.recoverable = true
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
return
}
const cfg = getConfig()
const attempt = this.reconnectAttempts++
const exp = Math.min(attempt, 6)
const base = Math.min(cfg.reconnectBaseDelay * Math.pow(2, exp), cfg.reconnectMaxDelay)
const jitter = 0.5 + Math.random() * 0.5
const delay = Math.round(base * jitter)
this.setStatus('reconnecting', `attempt ${attempt + 1}, delay ${delay}ms`)
console.log(`[WsClient] ${delay}ms 后重连(attempt ${attempt + 1})`)
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.reconnectNow()
}, delay)
}
/** 清握手超时定时器(握手成功/失败时调) */
private clearHandshakeTimer(): void {
if (this.handshakeTimer) {
clearTimeout(this.handshakeTimer)
this.handshakeTimer = null
}
}
/**
* 注销并关闭指定 socket task(防旧 socket 闭包回调污染新连接)。
*
* 微信 SocketTask 无 offXxx API,用「覆盖空函数」解绑:onXxx 多次调用覆盖最后者。
* 必须对传入 task 操作(非 this.socket),避免 this.socket 时序指向新 socket 时误注销。
*/
private destroySocket(task: UniApp.SocketTask): void {
try { task.onOpen(() => {}) } catch { /* 注销忽略 */ }
try { task.onMessage(() => {}) } catch { /* 注销忽略 */ }
try { task.onClose(() => {}) } catch { /* 注销忽略 */ }
try { task.onError(() => {}) } catch { /* 注销忽略 */ }
try {
task.close({})
} catch {
// 已关闭的 socket close 抛错忽略
}
}
/**
* 彻底清理:停心跳 + 清所有 timer + 注销关闭 socket + 清队列 + 复位 handshaked。
*
* 清 pendingQueue:断连/重连/抢占时,旧命令上下文已失效,不补发到新连接(防串话)。
*/
private cleanup(): void {
this.stopHeartbeat()
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
this.clearHandshakeTimer()
if (this.connectTimer) {
clearTimeout(this.connectTimer)
this.connectTimer = null
}
if (this.socket) {
this.destroySocket(this.socket)
this.socket = null
}
this.handshaked = false
this.pendingQueue = []
}
private setStatus(status: WsStatus, detail?: string): void {
this.status = status
this.handlers?.onStatus(status, detail)
}
}
/** 全局单例(miniapp 进程内一个连接) */
export const wsClient = new WsClient()
@@ -0,0 +1,36 @@
<template>
<!-- rich-text 小程序原生组件(无子组件依赖,稳)。marked md→html → rich-text :nodes 渲染。
alpha 根因已查实(uni-app 2026 alpha 致 tap 失效),rich-text 非元凶,降级后正常用。 -->
<rich-text :nodes="html" :selectable="true" />
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { marked } from 'marked'
import { styleMarkdown } from '@/utils/mdRenderer'
defineOptions({ name: 'MdView' })
/**
* Markdown 渲染气泡(miniapp,rich-text 原生方案)。
*
* 链路:marked.parse(md) → styleMarkdown 注入 inline style → rich-text :nodes(原生渲染 html 节点)。
* - breaks:true 聊天换行(单换行转 <br>)
* - P1-G:styleMarkdown 给代码块/链接/表格加 inline style(rich-text 不认 class)
* - parse 失败回退原文
* - 空内容返空(rich-text 不渲染)
*
* 流式:currentText 变化 → html computed 重算 → rich-text 重渲染。
*/
const props = defineProps<{ content: string }>()
const html = computed(() => {
const src = props.content || ''
if (!src) return ''
try {
return styleMarkdown(marked.parse(src, { breaks: true, async: false }) as string)
} catch {
return src
}
})
</script>
@@ -0,0 +1,133 @@
<template>
<!-- @/ 联想弹层(MVP)。
固定占位列表:不接真实技能路由/实体检索,仅作 chip 插入。
/file /web /task /memory + @file @web @task 四类技能占位。
渲染对齐 chat/index.vue 的 mp-weixin 稳定风格(原生 view/text + 绝对定位浮层)。 -->
<view v-if="visible" class="mention-mask" @tap="onClose">
<view class="mention-pop" @tap.stop>
<view class="mention-head">
<text class="mention-title">{{ trigger === '/' ? '技能' : '提及' }}</text>
<text class="mention-hint">MVP 占位 · 点击插入</text>
</view>
<scroll-view class="mention-list" scroll-y>
<view
v-for="(item, idx) in items"
:key="idx"
class="mention-item"
@tap="onSelect(item)"
>
<text class="mention-item-label">{{ item.label }}</text>
<text class="mention-item-insert">{{ item.insert.trim() }}</text>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup lang="ts">
/**
* MentionInput 联想弹层组件(MVP)。
*
* 设计:
* - 纯展示:接收 visible/trigger/items,选中后 emit select(insert) + close
* - 不查后端/不调路由:items 由父组件固定注入
* - 触发字符 `/`(技能)或 `@`(提及)由父组件依据光标位置判定
* - 选中即在 inputText 末尾插入对应字面量(如 `/file `),发送时整体纯文本走 ai.send
*
* props:
* - visible:是否显示
* - trigger:触发类型(`/` 技能 / `@` 提及)
* - items:占位项列表 {label, insert}
* emits:
* - select(insert):插入文本
* - close():关闭弹层
*/
defineOptions({ name: 'MentionInput' })
interface MentionItem {
label: string
insert: string
}
defineProps<{
visible: boolean
trigger: '/' | '@'
items: MentionItem[]
}>()
const emit = defineEmits<{
(e: 'select', insert: string): void
(e: 'close'): void
}>()
function onSelect(item: MentionItem): void {
emit('select', item.insert)
}
function onClose(): void {
emit('close')
}
</script>
<style scoped>
.mention-mask {
position: fixed;
left: 0;
right: 0;
bottom: 0;
top: 0;
background-color: rgba(0, 0, 0, 0.3);
z-index: 999;
}
.mention-pop {
position: absolute;
left: 12px;
right: 12px;
bottom: 56px;
max-height: 260px;
background-color: #1f1f1f;
border: 1px solid #333333;
border-radius: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.mention-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background-color: #262626;
border-bottom: 1px solid #2e2e2e;
}
.mention-title {
color: #e0e0e0;
font-size: 13px;
font-weight: bold;
}
.mention-hint {
color: #777777;
font-size: 11px;
}
.mention-list {
max-height: 220px;
}
.mention-item {
display: flex;
flex-direction: column;
padding: 10px 12px;
border-bottom: 1px solid #2a2a2a;
}
.mention-item:last-child {
border-bottom: none;
}
.mention-item-label {
color: #e0e0e0;
font-size: 14px;
}
.mention-item-insert {
color: #4a9eff;
font-size: 12px;
margin-top: 2px;
}
</style>
File diff suppressed because it is too large Load Diff
+136
View File
@@ -0,0 +1,136 @@
/**
* miniapp 运行时配置(channel_id/token 占位,由用户在设置页或 build env 填)
*
* 这些值在真实部署前是占位:
* - RELAY_HOST:ws/wss 地址,需替换为实际部署的 u-relay 地址
* - CHANNEL_ID:u-relay 配对通道 ID(与桌面端 df-tunnel 共享同键)
* - TOKEN:对齐 u-relay 的 U_RELAY_TOKEN(生产建议 per-channel 独立 token,Phase3)
*/
export interface MiniappConfig {
/** relay WS 地址(含协议),如 'ws://192.168.1.100:9180/ws/commander' 或 'wss://relay.example.com/ws/commander' */
relayHost: string
/** 配对绑定的通道 ID(u-relay channel_id,与桌面端共享同键配对) */
channelId: string
/** 配对 token(对齐 u-relay U_RELAY_TOKEN) */
token: string
/** 心跳间隔(ms) */
heartbeatInterval: number
/** 重连初始间隔(ms,指数退避) */
reconnectBaseDelay: number
/** 重连最大间隔(ms) */
reconnectMaxDelay: number
/** file.1216.top 文件服务上传地址(不带路径,上传时拼 /upload) */
fileUploadBaseUrl: string
/** 上传 X-Source 头(项目隔离,文件存到 file.1216.top 对应目录) */
fileUploadSource: string
}
/**
* 默认配置(占位值,需替换)
*
* 真实部署:用户在设置页填 relayHost/channelId/token,或通过 build-time env 注入。
* MVP 阶段留占位让类型自洽,实际连接会失败(token 校验不通过 → relay 关连接,
* 这符合预期,提示用户配置)。
*/
export const defaultConfig: MiniappConfig = {
// u-relay 现行协议 commander 端点:wss://u-work.1216.top → nginx 终止 TLS → u-relay :9180 明文 ws
// 微信小程序真机要求 wss + socket 合法域名白名单(微信公众平台→开发设置→服务器域名)
relayHost: 'wss://u-work.1216.top/ws/commander',
// 配对桌面端 channel_id(查桌面端 KV app_settings:channel_id;填错则握手过但路由无对端)
// 当前填本机桌面端 channel_id(联调期硬编码,后续做配对绑定流程)
channelId: '1b92cbbd-d03e-4a0c-b226-9fadf8dcf0f0',
token: 'devflow-relay-default-token',
heartbeatInterval: 30000,
reconnectBaseDelay: 1000,
reconnectMaxDelay: 30000,
fileUploadBaseUrl: 'https://file.1216.top',
fileUploadSource: 'devflow',
}
/** storage key(持久化完整 MiniappConfig JSON) */
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 {
// 首次调用时从 storage 加载并缓存
if (_config === null) {
_config = loadFromStorage()
}
return _config
}
export function setConfig(patch: Partial<MiniappConfig>): void {
// 确保已加载(避免在 getConfig 前调用 setConfig 丢失 storage 旧值)
if (_config === null) {
_config = loadFromStorage()
}
_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)
}
}
+13
View File
@@ -0,0 +1,13 @@
import { createSSRApp } from 'vue'
import App from './App.vue'
/**
* uni-app 入口:createSSRApp 规范(uni-app Vue3 必需,非 createApp)。
* 桌面端 main.ts 用 createApp(Tauri 浏览器环境),小程序用 createSSRApp 对齐 uni 跨端。
*/
export function createApp() {
const app = createSSRApp(App)
return {
app,
}
}
+51
View File
@@ -0,0 +1,51 @@
{
"pages": [
{
"path": "pages/chat/index",
"style": {
"navigationBarTitleText": "AI 对话",
"enablePullDownRefresh": false
}
},
{
"path": "pages/conversations/index",
"style": {
"navigationBarTitleText": "会话列表",
"enablePullDownRefresh": true
}
},
{
"path": "pages/settings/index",
"style": {
"navigationBarTitleText": "设置"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "white",
"navigationBarTitleText": "DevFlow",
"navigationBarBackgroundColor": "#1a1a1a",
"backgroundColor": "#0f0f0f",
"backgroundTextStyle": "light"
},
"tabBar": {
"color": "#999999",
"selectedColor": "#4a9eff",
"backgroundColor": "#1a1a1a",
"borderStyle": "black",
"list": [
{
"pagePath": "pages/chat/index",
"text": "对话"
},
{
"pagePath": "pages/conversations/index",
"text": "会话"
},
{
"pagePath": "pages/settings/index",
"text": "设置"
}
]
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,236 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { onPullDownRefresh } from '@dcloudio/uni-app'
import { useAiChat } from '@/composables/useAiChat'
import type { Conversation } from '@/types/events'
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 {
ai.switchConversation(convId)
// 切换后跳回对话页
uni.switchTab({ url: '/pages/chat/index' })
}
/** 新建会话 */
function handleNew(): void {
ai.newConversation()
uni.switchTab({ url: '/pages/chat/index' })
}
/** 长按会话项重命名(对齐桌面端改会话名) */
function handleRename(conv: Conversation): void {
uni.showModal({
title: '重命名会话',
editable: true,
placeholderText: '输入新名称',
content: conv.title || '',
success: (r) => {
if (r.confirm && r.content) {
const newTitle = r.content.trim()
if (newTitle && newTitle !== conv.title) {
ai.renameConversation(conv.id, newTitle)
}
}
},
})
}
/** 下拉刷新:发 list_conversations 同步 device 会话列表(F-#95)。
* 命令异步回流经 AiConversationList 事件替换 ai.conversations(handleEvent),
* 此处仅发命令 + 收尾停止下拉动画(列表更新非同步返回,故立即停止动画即可)。 */
onPullDownRefresh(() => {
ai.refreshConversations()
uni.stopPullDownRefresh()
})
/** 格式化时间戳为可读字符串 */
function formatTime(ts?: number): string {
if (!ts) return ''
const d = new Date(ts)
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const mi = String(d.getMinutes()).padStart(2, '0')
return `${mm}-${dd} ${hh}:${mi}`
}
</script>
<template>
<view class="conv-page">
<!-- 顶部新建按钮 -->
<view class="new-bar" @click="handleNew">
<text class="new-icon">+</text>
<text class="new-text">新建会话</text>
</view>
<!-- 会话列表 -->
<scroll-view class="conv-list" scroll-y>
<!-- 加载中骨架屏 -->
<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 class="empty-sub">在桌面端创建后,此处同步</text>
</view>
<view
v-for="conv in conversations"
:key="conv.id"
class="conv-item"
:class="{ active: conv.id === ai.activeConversationId.value }"
@click="handleSelect(conv.id)"
@longpress="handleRename(conv)"
>
<view class="conv-main">
<text class="conv-title">{{ conv.title || '(未命名会话)' }}</text>
<text v-if="conv.lastMessage" class="conv-last">{{ conv.lastMessage }}</text>
</view>
<text v-if="conv.updatedAt" class="conv-time">{{ formatTime(conv.updatedAt) }}</text>
</view>
<view v-if="conversations.length > 0" class="list-hint">
<text>长按会话可重命名</text>
</view>
</scroll-view>
</view>
</template>
<style lang="scss" scoped>
.conv-page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: $uni-bg-color;
}
.new-bar {
display: flex;
align-items: center;
padding: 24rpx;
background-color: $uni-bg-color-dark;
border-bottom: 1rpx solid $uni-border-color;
}
.new-icon {
font-size: 40rpx;
color: $uni-color-primary;
margin-right: 16rpx;
}
.new-text {
font-size: $uni-font-size-base;
color: $uni-color-primary;
}
.conv-list {
flex: 1;
}
.empty-hint {
display: flex;
flex-direction: column;
align-items: center;
padding: 200rpx 24rpx;
text {
color: $uni-text-color-disable;
font-size: $uni-font-size-base;
}
.empty-sub {
margin-top: 12rpx;
font-size: $uni-font-size-sm;
}
}
.conv-item {
display: flex;
align-items: center;
padding: 24rpx;
border-bottom: 1rpx solid $uni-border-color;
&.active {
background-color: $uni-bg-color-light;
border-left: 6rpx solid $uni-color-primary;
}
}
.conv-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.conv-title {
font-size: $uni-font-size-base;
color: $uni-text-color;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conv-last {
margin-top: 8rpx;
font-size: $uni-font-size-sm;
color: $uni-text-color-grey;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conv-time {
font-size: $uni-font-size-sm;
color: $uni-text-color-disable;
margin-left: 16rpx;
}
.list-hint {
padding: 24rpx;
text-align: center;
}
.list-hint text {
color: $uni-text-color-disable;
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>
@@ -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>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="@dcloudio/types" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
export default component
}
+229
View File
@@ -0,0 +1,229 @@
/**
* AiChatEvent 类型镜像(对齐 DevFlow 后端 Rust 定义)
*
* 类型证据:
* - Rust 定义:src-tauri/src/commands/ai/mod.rs:105-216 `pub enum AiChatEvent`
* (19 个变体,#[serde(tag = "type")] → TS 用 type discriminator 镜像)
* - Rust 字段 snake_case:Rust 默认 snake_case 字段,TS 直接对齐(不加 camelCase 转换)
* - 桌面 TS:src/api/types.ts(未在本批核验,但变体名/字段从 Rust 源码逐字对齐)
*
* 注意:TS 联合类型 discriminator 名为 `type`,与 Rust #[serde(tag = "type")] 一致。
* 变体名 PascalCase(Rust enum 变体名原样,serde 默认 PascalCase 序列化)。
*/
// SkillInfo 复用 relay.ts 同协议层定义(`/` 联想浮层渲染 + 选中后 name 透传 SendMessageArgs.skill)
import type { SkillInfo } from './relay'
/** 错误源分类(对齐 Rust ErrorType,mod.rs:89-102,snake_case 序列化) */
export type ErrorType = 'auth' | 'network' | 'timeout' | 'provider_config' | 'unknown'
/** 会话生命周期状态(对齐 Rust ConvState,L2 统一状态机,snake_case 序列化) */
export type ConvState = 'idle' | 'generating' | 'stopping' | 'error' | 'compressed'
/**
* AI 聊天事件联合类型(19 个变体,严格对齐 Rust mod.rs:107-216)
*
* 字段全部对齐 Rust serde(rename_all 默认 snake_case 字段名,tag = "type")。
* 变体名 PascalCase 来自 Rust enum 变体名(serde 默认 PascalCase 序列化 enum variant)。
*
* 注:AiConversationList 的 conversations 字段对齐 Rust ConvSummary(#[serde(rename_all="camelCase")]),
* 元素结构同本文件 Conversation interface(id/title/lastMessage?/updatedAt?/createdAt?)。
*/
export type AiChatEvent =
| { type: 'AiTextDelta'; delta: string; conversation_id?: string | null }
| { type: 'AiToolCallStarted'; id: string; name: string; args: unknown; conversation_id?: string | null }
| { type: 'AiToolCallCompleted'; id: string; result: unknown; conversation_id?: string | null }
| { type: 'AiToolAutoApproved'; id: string; tool: string; dir: string; conversation_id?: string | null }
| {
type: 'AiApprovalRequired'
id: string
name: string
args: unknown
reason: string
diff?: string | null
conversation_id?: string | null
}
| { type: 'AiApprovalResult'; id: string; approved: boolean; conversation_id?: string | null }
| {
type: 'AiCompleted'
total_tokens: number
prompt_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
conversation_id?: string | null
}
| { type: 'AiError'; error: string; error_type?: ErrorType | null; conversation_id?: string | null }
| { type: 'AiUserMessage'; message: string; conversation_id?: string | null }
| { type: 'AiAgentRound'; round: number; conversation_id?: string | null }
| { type: 'AiHeartbeat'; conversation_id?: string | null }
| { type: 'AiMaxRoundsReached'; conversation_id?: string | null }
| { type: 'AiStreamRetry'; attempt: number; max_attempts: number; conversation_id?: string | null }
| {
type: 'AiDirAuthRequired'
id: string
tool: string
path: string
dir: string
conversation_id?: string | null
}
| { type: 'AiContextCleared'; conversation_id?: string | null }
| { type: 'AiCompressing'; conversation_id?: string | null }
// 治 Task#1:拆分手动/自动两变体(字段相同仅 type 字面量不同),桌面按 type 分流,
// miniapp 两变体都插摘要气泡(历史可见性,见 useAiChat.ts:618 case fallthrough)。
| { type: 'AiManualCompressed'; conversation_id?: string | null; summary: string }
| { type: 'AiAutoCompressed'; conversation_id?: string | null; summary: string }
| {
type: 'AiHelpRequired'
reason: string
context: string
options: string[]
conversation_id?: string | null
}
| { type: 'AiConvStateChanged'; conv_state: ConvState; conversation_id?: string | null }
| { type: 'AiConversationList'; conversations: Conversation[] }
| { type: 'AiMessageHistory'; conversation_id: string; messages: ChatMessage[] }
| { type: 'AiSkillList'; skills: SkillInfo[] }
| {
type: 'AiEntityList'
projects: ProjectRecord[]
tasks: TaskRecord[]
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 / 分派) */
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 约定) */
export interface Conversation {
id: string
title: string
/** 最近一条消息摘要(可选) */
lastMessage?: string
/** 最近更新时间戳(ms) */
updatedAt?: number
/** 创建时间(ms) */
createdAt?: number
}
/** 本地渲染消息气泡(对齐桌面端 AiMessage 结构,简化为 miniapp 必需字段) */
export interface ChatMessage {
id: string
role: 'user' | 'assistant' | 'system'
content: string
timestamp: number
isError?: boolean
/** 工具调用列表(对齐 AiToolCallInfo) */
toolCalls?: AiToolCallInfo[]
/** 不完整标记(AiCompleted{incomplete:true} 对应系统提示气泡) */
incomplete?: boolean
/** 图片输入:消息携带的图片 URL 列表(预览用,发送时透传) */
images?: string[]
}
/** 工具调用信息(对齐桌面 useAiEvents.ts:399 的 AiToolCallInfo) */
export interface AiToolCallInfo {
id: string
name: string
args: unknown
result?: unknown
status: 'running' | 'completed' | 'failed' | 'pending_approval' | 'rejected'
reason?: string
diff?: string
/** 审批类型(risk 普通 / path 路径授权,对齐后端 ApprovalKind) */
kind?: 'risk' | 'path'
dir?: string
path?: string
}
/** 挂起审批项(复用 AiToolCallInfo 结构,miniapp 不实现审批 UI 仅展示) */
export type PendingApproval = AiToolCallInfo
/** Token 用量(对齐桌面端 state.lastTokenUsage) */
export interface TokenUsage {
prompt: number
completion: number
total: number
/** token 分项(2026-08-02):cache 命中(低价)/未命中(全价)/思考,对齐桌面端 */
cache_hit?: number
cache_miss?: number
reasoning?: number
/** 该轮 token 是否估算值(AiCompleted.is_estimated 透传,reload 历史消息可能无) */
is_estimated?: boolean
}
// ============================================================
// 实体列表类型(F-#95 联想扩展,AiEntityList 事件载荷)
// ============================================================
//
// 精简字段对齐桌面端 src/api/types.ts(后端 Record 全量字段经 Tauri command Serialize 直传,
// miniapp 仅声明联想浮层展示所需字段;多余字段 JSON 解析保留不报错)。
// SkillInfo 从 ./relay 复用(同协议层定义,避免重复)。
/** 项目记录(对齐桌面端 src/api/types.ts:80 ProjectRecord,miniapp 精简版) */
export interface ProjectRecord {
id: string
name: string
description: string
status: string
idea_id: string | null
/** 绑定的本地代码目录绝对路径(null=未绑定) */
path: string | null
/** 技术栈 JSON 数组字符串(如 ["rust","vue"],null=未探测) */
stack: string | null
created_at: string
updated_at: string
}
/** 任务记录(对齐桌面端 src/api/types.ts:135 TaskRecord,miniapp 精简版) */
export interface TaskRecord {
id: string
project_id: string
title: string
description: string
status: string
/** 优先级:0=critical, 1=high, 2=medium, 3=low */
priority: number
branch_name: string | null
assignee: string | null
created_at: string
updated_at: string
}
/** 灵感记录(对齐桌面端 src/api/types.ts:11 IdeaRecord,miniapp 精简版) */
export interface IdeaRecord {
id: string
title: string
description: string
status: string
priority: number
score: number | null
tags: string | null
source: string | null
created_at: string
updated_at: string
}
+235
View File
@@ -0,0 +1,235 @@
/**
* u-relay 协议类型镜像(对齐 E:/wk-lab/u-relay/src/)
*
* 类型证据:
* - ClientKind / MessageKind:u-relay/src/broadcast.rs
* (#[serde(rename_all = "snake_case")] → 'agent'/'commander', 'event'/'command'/'control')
* - BroadcastMessage:broadcast.rs(channel_id/kind/source/from/payload/ts)
* - Hello 握手帧:u-relay/src/relay.rs(kind/channel_id/token)
* kind 用 ClientKindWire,序列化为 'agent'/'commander'
* - 路由端点:relay.rs `/ws/agent`(执行端)/ `/ws/commander`(控制端)
*
* 关键协议点(relay.rs handle_inbound_text):
* relay 不解析 payload —— commander 入站文本帧直接当 payload 包成 BroadcastMessage 路由给 agent。
* 故 miniapp 发送时,直接序列化 Command JSON(relay 自动包 payload),不需客户端构造 BroadcastMessage。
* 接收时,agent 事件经 relay 包成完整 BroadcastMessage 序列化下发,miniapp 需先解 BroadcastMessage
* 再从 payload 取 AiChatEvent。
*
* 历史注:df-relay 一代协议(kind:'device'/'miniapp' + device_id)已在 u-relay 全部下线,
* 旧名握手期直接拒绝(端点 /ws/miniapp、/ws/device 均已失效)。
*/
/** 客户端类型(对齐 Rust ClientKind,snake_case 序列化;u-relay 现行仅此两值) */
export type ClientKind = 'agent' | 'commander'
/** 消息语义分类(对齐 Rust MessageKind,snake_case 序列化) */
export type MessageKind = 'event' | 'command' | 'control'
/** 在线状态(对齐 Rust PresenceState,snake_case 序列化) */
export type PresenceState = 'online' | 'offline'
/** ConnId 是 Rust u64(对齐 conn.rs ConnId struct),TS 用 number;0 为 NIL 伪值 */
export type ConnId = number
/**
* 中继路由消息骨架(对齐 Rust BroadcastMessage)
*
* miniapp 接收时 payload 为 AiChatEvent JSON(relay 透传不解析)。
*/
export interface BroadcastMessage {
/** 配对绑定的通道 ID */
channel_id: string
/** 消息语义 */
kind: MessageKind
/** 消息来源连接 ID(relay 分配,u64;0=relay 自构造 NIL 伪值) */
source: ConnId
/** 来源客户端类型 */
from: ClientKind
/** 载荷(原始 JSON,relay 透传不解析 —— Event 时为 AiChatEvent) */
payload: unknown
/** 时间戳(ms) */
ts: number
}
/**
* 客户端首消息:身份宣告(对齐 Rust Hello)
*
* 小程序连 `/ws/commander` 后首帧必须发此结构(10s 内),否则 relay 关连接。
* kind 恒为 'commander'(与端点匹配,不匹配 relay 回 kind_mismatch 错误帧 + Close)。
*/
export interface Hello {
/** 客户端类型,小程序(控制端)恒 'commander'(对齐 ClientKindWire) */
kind: 'commander'
/** 配对绑定的通道 ID(非空,≤128B,无控制字符) */
channel_id: string
/** 配对 token(env U_RELAY_TOKEN,与桌面端共享) */
token: string
}
/**
* 控制面消息(对齐 Rust ControlMessage,tag = "control_kind")
*
* miniapp 主要发 Ping 心跳(relay 本地直回 pong,不经对端);Pair/Heartbeat/Presence 骨架保留。
*/
export type ControlMessage =
| { control_kind: 'pair'; channel_id: string; token: string }
| { control_kind: 'heartbeat'; channel_id: string }
| { control_kind: 'presence'; channel_id: string; state: PresenceState }
| { control_kind: 'ping' }
| { control_kind: 'pong' }
/**
* 小程序(控制端)→ 桌面端(执行端)Command 协议
*
* 类型为对齐 Tauri command 名,relay 透传 channel_id 路由到桌面端,桌面端 invoke 对应 command。
* 字段:
* - cmd:命令名(对齐 src-tauri command 函数名)
* - args:命令参数(对齐各 command 的参数)
*
* 路由表全覆盖(14 条):
* - send_message / stop / switch_conversation / regenerate(MVP 已发)
* - approve / authorize_dir / continue_loop / stop_loop(阶段3 补齐,见 useAiChat.ts)
* - list_conversations / load_messages(F-#95 扩展,会话列表/历史消息读取)
* - list_skills / list_entities(F-#95 联想扩展,技能/项目·任务·灵感列表,见 useAiChat.ts)
* - rename_conversation(会话重命名,2026-06-23)/ sync_pending(重连审批恢复,2026-06-23)
* - list_models(每会话独立模型,模型选择器拉取 provider enabled 模型列表,见 useAiChat.ts)
*/
export interface MiniCommand {
/** Tauri command 名 */
cmd: string
/** 命令参数(原始对象,对齐各 command 参数签名) */
args: Record<string, unknown>
}
/** send_message 命令参数(对齐 src-tauri ai_chat_send command) */
export interface SendMessageArgs {
message: string
conversation_id?: 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 参数)。
* null/undefined=无技能(普通对话);非空=后端注入对应 SKILL.md 全文到 prompt。
*/
skill?: string | null
/**
* @ mention 区间元数据(用户选中 @项目/@任务/@灵感 后透传,
* 对齐 ai_chat_send 的 mention_spans 参数,后端 resolve 投影成 Augmentation 注入)。
* null/undefined/空=无 mention(纯文本消息)。
*/
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 区间的元数据
* (对齐后端 MentionSpanDto,crates/df-types/src/augmentation.rs:235)。
*
* 字段名对齐后端 serde:refId(camelCase,后端 serde rename)。
* 前端按 {start, length} 在原文中切出区间替换为 chip。
*/
export interface MentionSpan {
/** 区间起点(字符偏移,前端约定,后端仅透传不解释) */
start: number
/** 区间长度 */
length: number
/** kind 标签:project / task / idea / skill(对齐后端 MentionKind serde snake_case) */
kind: 'project' | 'task' | 'idea' | 'skill'
/** 引用稳定标识(Project/Task/Idea 为 id,Skill 为 name),前端用于反查 */
refId: string
/** chip 展示文本(项目名/任务标题/技能名) */
label: string
}
/**
* 技能信息(对齐桌面端 src/api/types.ts:614 SkillInfo + 后端 SkillInfo,
* src-tauri/src/commands/ai/skills.rs:19-34)。
*
* 供 `/` 联想浮层渲染 + 选中后 name 透传 SendMessageArgs.skill。
* duplicates 可选:后端 #[serde(skip_serializing_if=Option::is_none)] 无冲突不序列化,
* miniapp 加 `?` 兜底防 prod 反序列化宽松报错。
*/
export interface SkillInfo {
/** 技能名(联想浮层展示 + 选中后透传 skill 参数) */
name: string
/** 技能描述(浮层副标题) */
description: string
/** 参数提示(可选,如 "<url>") */
argument_hint?: string
/** skill | command | plugin(来源分类) */
source: string
/** SKILL.md 绝对路径(后端注入时读全文) */
path: string
/** 同名冲突的其他来源路径(可选,无冲突不序列化) */
duplicates?: string[]
}
/** switch_conversation 命令参数 */
export interface SwitchConversationArgs {
conversation_id: string
}
/** stop 命令参数(对齐 ai_chat_stop) */
export interface StopArgs {
conversation_id?: string | null
}
/** approve 命令参数(对齐 src-tauri ai_approve,Phase3 §2.2 路由表) */
export interface ApproveArgs {
/** 待审批的工具调用 id(对应 AiApprovalRequired 事件的 id) */
tool_call_id: string
/** 是否批准(true=批准执行,false=拒绝) */
approved: boolean
}
/**
* authorize_dir 命令参数(对齐 src-tauri ai_authorize_dir,Phase3 §2.2 路由表)
*
* decision 取值:对齐 src-tauri ai_authorize_dir 的 decision 参数
* - 'once':本次授权(下次同路径仍需问)
* - 'always':永久授权(写入会话级信任目录)
* - 'deny':拒绝(本次不授权)
*/
export interface AuthorizeDirArgs {
/** 待授权的工具调用 id(对应 AiDirAuthRequired 事件的 id) */
tool_call_id: string
/** 授权决策('once' / 'always' / 'deny') */
decision: 'once' | 'always' | 'deny'
}
/** continue_loop 命令参数(对齐 src-tauri ai_continue_loop,Phase3 §2.2 路由表) */
export interface ContinueLoopArgs {
/** 需继续运行的会话 id */
conversation_id: string
}
/** stop_loop 命令参数(对齐 src-tauri ai_stop_loop,Phase3 §2.2 路由表) */
export interface StopLoopArgs {
/** 需停止循环的会话 id */
conversation_id: string
}
+33
View File
@@ -0,0 +1,33 @@
/**
* 全局样式变量(uni-app 规范:uni.scss 由 uni-app 自动引入到各页面/组件)
*
* 颜色基调对齐桌面端暗色主题(便于跨端视觉一致)。
*/
/* 主色调 */
$uni-color-primary: #4a9eff;
$uni-color-success: #4cd964;
$uni-color-warning: #f0ad4e;
$uni-color-error: #dd524d;
/* 中性色(暗色背景适配) */
$uni-bg-color: #0f0f0f;
$uni-bg-color-dark: #1a1a1a;
$uni-bg-color-light: #2a2a2a;
$uni-text-color: #e0e0e0;
$uni-text-color-grey: #999999;
$uni-text-color-disable: #666666;
$uni-border-color: #333333;
/* 字号 */
$uni-font-size-sm: 24rpx;
$uni-font-size-base: 28rpx;
$uni-font-size-lg: 32rpx;
/* 气泡角色配色 */
$uni-bubble-user-bg: #4a9eff;
$uni-bubble-user-text: #ffffff;
$uni-bubble-assistant-bg: #2a2a2a;
$uni-bubble-assistant-text: #e0e0e0;
$uni-bubble-error-bg: #3a1a1a;
$uni-bubble-error-text: #ff6b6b;
+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),
})
})
}
+223
View File
@@ -0,0 +1,223 @@
/**
* marked HTML 后处理:为代码块/内联 code/链接/表格/图片/任务列表注入 inline style。
*
* 为什么后处理而非 marked 自定义 renderer:
* - mp-weixin rich-text 不链接页面 CSS class,仅认节点 inline style → 必须把样式写进 HTML 属性。
* - 纯字符串后处理规避 marked renderer 版本签名差异(v5 前 (text,info) / v5+ Token 对象),
* 版本无关,build 不依赖 renderer TS 签名。
*
* mp-weixin rich-text 限制(已核官方文档,非臆测):
* - <a> 节点事件屏蔽,无法 bindtap 跳转 → 链接仅展示 + 长按复制(rich-text selectable=true);
* 跳转需第三方 mp-html(本次不做,记后续)。
* - rich-text 非滚动容器,overflow-y:auto/max-height 不创建滚动区 → 长代码靠
* 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 则合并,无则新增)。 */
function styleTag(html: string, tag: string, style: string): string {
const re = new RegExp(`<${tag}(\\s[^>]*)?>`, 'g')
return html.replace(re, (m, attrs: string) => {
if (attrs && /\sstyle\s*=/.test(attrs)) {
// 已有 style:追加(在现有 style 值末尾加分号 + 新 style)
return m.replace(/style\s*=\s*"([^"]*)"/, (_full, prev: string) => {
const merged = prev.endsWith(';') ? prev : `${prev};`
return `style="${merged}${style}"`
})
}
return `<${tag}${attrs || ''} style="${style}">`
})
}
/** 反转 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。
*
* 设计取舍:pre(块代码)给深色背景;<code> 不给背景(透明),仅等宽 + 橙色 —— 避免块代码内层 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 {
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 换行防撑爆
out = styleTag(
out,
'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',
)
// code(块内 + 内联):等宽橙色,透明背景(避与 pre 双层色块;块代码 token 由高亮色覆盖)
out = styleTag(
out,
'code',
'font-family:monospace;color:#e0a070;background-color:transparent;font-size:12px',
)
// 链接:蓝下划线(rich-text 不可点击,仅展示 + selectable 长按复制);word-break 防长 URL 撑开
out = styleTag(out, 'a', 'color:#4a9eff;text-decoration:underline;word-break:break-all')
// 图片: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,
'table',
'display:table;border-collapse:collapse;width:100%;max-width:100%;font-size:12px;word-break:break-all',
)
// th/td:边框 + 内边距保留,字号与 table 对齐(font-size:12px)
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
}
+25
View File
@@ -0,0 +1,25 @@
{
"extends": "@vue/tsconfig/tsconfig.json",
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"jsx": "preserve",
"isolatedModules": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"skipLibCheck": true,
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM"],
"types": ["@dcloudio/types", "@types/node", "vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"exclude": ["node_modules", "dist", "unpackage"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
// uni-app Vue3 vite 配置:vite-plugin-uni 注入跨端编译(mp-weixin / h5)。
// Phase2 落地遗漏本文件致 `uni build -p mp-weixin` 走纯 vite h5(只产 index.html),
// 补回后 -p mp-weixin 正确产出小程序文件(app.js/app.json/pages/*)。
export default defineConfig({
plugins: [uni()],
})
+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",
"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",
"@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",
"tslib": "^2.8.1",
"vue": "^3.5.13",
"vue-i18n": "9",
"vue-router": "4",
@@ -24,6 +32,20 @@
},
},
"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/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/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/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=="],
"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=="],
"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=="],
"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=="],
@@ -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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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-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-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=="],
"@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=="],
}
}
@@ -1,11 +1,11 @@
[package]
name = "df-traceability"
name = "df-ai-core"
version = "0.1.0"
edition = "2021"
[dependencies]
df-core = { path = "../df-core" }
serde = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }
futures = "0.3"
+13
View File
@@ -0,0 +1,13 @@
//! df-ai-core: LLM Provider trait + AI 数据结构(轻量 crate,零 HTTP 依赖)
//!
//! 从 df-ai 下沉的全局 AI 接入标准。df-ai 保留 HTTP impl 与业务逻辑
//! (ContextManager / AiToolRegistry / build_provider 等),通过 re-export
//! 保持 `df_ai::provider::*` 路径不变。df-ideas 等轻消费方直接依赖本 crate
//! 的 trait 即可接 LLM,不引入 reqwest / eventsource-stream 等重依赖。
pub mod model;
pub mod provider;
pub mod types;
pub use model::*;
pub use provider::*;
+481
View File
@@ -0,0 +1,481 @@
//! 模型能力数据模型。
//!
//! 单模型的完整描述(4 维度 + 路由控制)。纯数据结构,零 IO / 零 DB 依赖。
//! df-storage 反序列化 DB 行时直接消费本模块类型;df-ai 探测器/路由器阶段 2-4 再用。
//!
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §2
use serde::{Deserialize, Serialize};
// ============================================================
// 4 维度枚举
// ============================================================
/// 维度 1 — 模态(模型能接收什么输入)
///
/// 设计文档 §2.2 仅列出 Text/Vision,Audio/Video 为未来扩展占位。
/// 序列化为 snake_case(`text` / `vision` / `audio` / `video`)。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum Modality {
Text,
Vision,
/// 未来扩展:音频输入(预留,Phase 2 多模态)
Audio,
/// 未来扩展:视频输入(预留)
Video,
}
/// 维度 2 — 能力(模型能做什么)
///
/// 设计文档 §2.2 列出 ToolUse/Embedding/CodeGen。序列化为 snake_case。
/// §3.4 提到未来可加 Streaming/Custom(String),当前 Phase 1 仅 3 个确定变体。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum Capability {
/// function calling / tool use
ToolUse,
/// 向量嵌入(知识库)
Embedding,
/// 代码生成强项
CodeGen,
}
/// 维度 3 — 价格分级(成本)
///
/// 序列化为 snake_case。原设计文档 §2.2 列 Free/Low/Medium/High,但 Free 变体形同虚设:
/// 预设表(presets/models.json)0 条 free + 启发式从不赋 Free(只 Low/Medium/High),
/// Free 死档变体已删除。
///
/// 路由已解耦:provider /v1/models API 不返回 cost_tier,
/// 此维度 100% 靠预设表写死 + 模型名启发式猜,数据无客观依据不可信,不再参与硬路由
/// (原 §6.1 `cost_tier <= max_cost` 过滤已删除)。枚举保留供未来出现真实判别源时再接回。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum CostTier {
Low,
Medium,
High,
}
/// 维度 4 — 聪明程度(智力分级)
///
/// 设计文档 §2.2:Lite/Standard/Plus/Ultra。序列化为 snake_case。
/// 派生 Ord:Lite < Standard < Plus < Ultra。
///
/// 路由已解耦:provider /v1/models API 不返回 intelligence,
/// 此维度 100% 靠预设表写死 + 模型名启发式猜,数据无客观依据不可信,不再参与硬路由
/// (原 §6.1 `intelligence >= min_intelligence` 过滤已删除)。枚举保留供未来出现真实判别源时再接回。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum IntelligenceTier {
/// flash/mini/lite/nano — 快但简单任务
Lite,
/// air/standard — 日常够用
Standard,
/// plus/pro/max — 复杂推理
Plus,
/// 最强模型 — 兜底用
Ultra,
}
/// 探测元数据来源(只读,由 ModelProbe 填充,Phase 2)
///
/// 设计文档 §2.1 的 ProbeSource 字段。Phase 1 仅定义占位,Phase 2 探测器填充。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ProbeSource {
/// 用户手动设定(最高优先)
UserSet,
/// 内置预设表精确/模糊匹配
PresetTable,
/// 模型名启发式推断
Heuristic,
/// 默认值兜底
Default,
}
// ============================================================
// 核心结构 ModelConfig
// ============================================================
/// 单个模型的完整配置(4 维度 + 路由控制)。
///
/// 设计文档 §2.1。serde 默认值保证老库/部分字段缺失时向后兼容。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ModelConfig {
// ── 基础 ──
/// 模型名(如 "glm-4v-flash")
pub model_id: String,
/// 启用/禁用(false = 配了但不参与路由,相当于"备档")
#[serde(default = "default_true")]
pub enabled: bool,
/// 用户自定义别名(可选)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
// ── 维度 1:模态(能接收什么输入)──
#[serde(default)]
pub modalities: Vec<Modality>,
// ── 维度 2:能力(能做什么)──
#[serde(default)]
pub capabilities: Vec<Capability>,
// ── 维度 3:价格(成本分级)──
#[serde(default)]
pub cost_tier: CostTier,
// ── 维度 4:聪明程度(智力分级)──
#[serde(default)]
pub intelligence: IntelligenceTier,
// ── 路由控制 ──
/// 权重(0-100,同能力候选中优先选权重高的)
#[serde(default = "default_weight")]
pub weight: u32,
/// 上下文窗口大小(tokens)
#[serde(default = "default_context_window")]
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)──
#[serde(default, skip_serializing_if = "Option::is_none")]
pub probe_source: Option<ProbeSource>,
}
fn default_true() -> bool {
true
}
fn default_weight() -> u32 {
50
}
fn default_context_window() -> usize {
8192
}
impl ModelConfig {
/// 用模型名构造默认配置(老格式字符串数组 `["glm-4-flash"]` 升级时逐项调用)。
///
/// 设计文档 §2.3 向后兼容:老 `models: ["model-a"]` → 每个名字转默认 ModelConfig。
/// 默认值:enabled=true / modalities=[Text] / capabilities=[ToolUse] /
/// cost_tier=Medium / intelligence=Standard / weight=50 / context_window=8192。
/// (ToolUse 默认开:主流对话模型支持 function calling;精确能力由 Phase 2 探测器修正)
pub fn with_defaults(model_id: impl Into<String>) -> Self {
Self {
model_id: model_id.into(),
enabled: true,
label: None,
modalities: vec![Modality::Text],
capabilities: vec![Capability::ToolUse],
cost_tier: CostTier::Medium,
intelligence: IntelligenceTier::Standard,
weight: default_weight(),
context_window: default_context_window(),
// 默认支持 temperature(老格式升级/未知模型保持原样透传,零回归);
// 明确不支持的模型由 presets 表 temperature_supported=false 标注。
temperature_supported: true,
probe_source: None,
}
}
}
/// 默认值(#[serde(default)] 用于 Vec/Option 字段时 serde 要求 Default impl)
impl Default for Modality {
fn default() -> Self {
Modality::Text
}
}
impl Default for CostTier {
fn default() -> Self {
CostTier::Medium
}
}
impl Default for IntelligenceTier {
fn default() -> Self {
IntelligenceTier::Standard
}
}
// ============================================================
// 向后兼容反序列化:老格式字符串数组 → ModelConfig 数组
// ============================================================
/// 反序列化 `models` JSON 字段,兼容两种格式:
///
/// - 老格式:`["glm-4-flash", "glm-4v"]`(纯字符串数组)→ 每个名字转 `ModelConfig::with_defaults`
/// - 新格式:`[{ "model_id": ..., "modalities": [...] }, ...]`(ModelConfig 对象数组)
/// - 空/非数组:null / 缺失 / 空字符串 → 空 Vec
///
/// 设计文档 §2.3。挂在 AiProviderRecord.models 字段上(#[serde(default, deserialize_with = ...)])。
pub fn deserialize_model_configs<'de, D>(deserializer: D) -> Result<Vec<ModelConfig>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
let opt: Option<serde_json::Value> = Option::deserialize(deserializer)?;
let Some(value) = opt else {
return Ok(Vec::new());
};
match value {
// null / 空字符串 / 空数组 → 空
serde_json::Value::Null => Ok(Vec::new()),
serde_json::Value::String(s) if s.trim().is_empty() => Ok(Vec::new()),
serde_json::Value::String(s) => {
// 字符串内容可能是 JSON 文本(DB TEXT 列存 JSON 字符串)
match serde_json::from_str::<serde_json::Value>(s.trim()) {
Ok(inner) => parse_models_value(inner).map_err(D::Error::custom),
Err(_) => Ok(Vec::new()),
}
}
serde_json::Value::Array(arr) => parse_models_value(serde_json::Value::Array(arr)).map_err(D::Error::custom),
other => parse_models_value(other).map_err(D::Error::custom),
}
}
fn parse_models_value(value: serde_json::Value) -> Result<Vec<ModelConfig>, serde_json::Error> {
match value {
serde_json::Value::Array(arr) => {
// 老格式:全为字符串 → 每个转默认配置
if arr.iter().all(|v| v.is_string()) {
return Ok(arr
.into_iter()
.filter_map(|v| v.as_str().map(ModelConfig::with_defaults))
.collect());
}
// 新格式:ModelConfig 对象数组
serde_json::from_value::<Vec<ModelConfig>>(serde_json::Value::Array(arr))
}
serde_json::Value::Null => Ok(Vec::new()),
_ => Ok(Vec::new()),
}
}
#[cfg(test)]
mod tests {
use super::*;
// ── 枚举 serde 映射 ──
#[test]
fn modality_serde_snake_case() {
let cases = [
(Modality::Text, "\"text\""),
(Modality::Vision, "\"vision\""),
(Modality::Audio, "\"audio\""),
(Modality::Video, "\"video\""),
];
for (variant, expected) in cases {
let s = serde_json::to_string(&variant).unwrap();
assert_eq!(s, expected, "serialize {variant:?}");
let back: Modality = serde_json::from_str(expected).unwrap();
assert_eq!(back, variant, "deserialize {variant:?}");
}
}
#[test]
fn capability_serde_snake_case() {
let cases = [
(Capability::ToolUse, "\"tool_use\""),
(Capability::Embedding, "\"embedding\""),
(Capability::CodeGen, "\"code_gen\""),
];
for (variant, expected) in cases {
let s = serde_json::to_string(&variant).unwrap();
assert_eq!(s, expected, "serialize {variant:?}");
let back: Capability = serde_json::from_str(expected).unwrap();
assert_eq!(back, variant, "deserialize {variant:?}");
}
}
#[test]
fn cost_tier_serde_and_ordering() {
let cases = [
(CostTier::Low, "\"low\""),
(CostTier::Medium, "\"medium\""),
(CostTier::High, "\"high\""),
];
for (variant, expected) in cases {
assert_eq!(serde_json::to_string(&variant).unwrap(), expected);
assert_eq!(serde_json::from_str::<CostTier>(expected).unwrap(), variant);
}
// Ord:Low < Medium < High(枚举序,路由已解耦不再用于过滤,保留供未来判别源)。
// Free 变体已删除(预设/启发式从不赋 Free,死档)。
assert!(CostTier::Low < CostTier::Medium);
assert!(CostTier::Medium < CostTier::High);
}
#[test]
fn intelligence_tier_serde_and_ordering() {
let cases = [
(IntelligenceTier::Lite, "\"lite\""),
(IntelligenceTier::Standard, "\"standard\""),
(IntelligenceTier::Plus, "\"plus\""),
(IntelligenceTier::Ultra, "\"ultra\""),
];
for (variant, expected) in cases {
assert_eq!(serde_json::to_string(&variant).unwrap(), expected);
assert_eq!(serde_json::from_str::<IntelligenceTier>(expected).unwrap(), variant);
}
// Ord:Lite < Standard < Plus < Ultra(枚举序,路由已解耦不再用于过滤,保留供未来判别源)
assert!(IntelligenceTier::Lite < IntelligenceTier::Standard);
assert!(IntelligenceTier::Standard < IntelligenceTier::Plus);
assert!(IntelligenceTier::Plus < IntelligenceTier::Ultra);
}
// ── ModelConfig serde roundtrip ──
#[test]
fn model_config_full_roundtrip() {
let mc = ModelConfig {
model_id: "glm-4v".into(),
enabled: true,
label: Some("我的视觉".into()),
modalities: vec![Modality::Text, Modality::Vision],
capabilities: vec![Capability::ToolUse],
cost_tier: CostTier::Medium,
intelligence: IntelligenceTier::Plus,
weight: 70,
context_window: 128_000,
temperature_supported: true,
probe_source: Some(ProbeSource::PresetTable),
};
let json = serde_json::to_string(&mc).unwrap();
let back: ModelConfig = serde_json::from_str(&json).unwrap();
assert_eq!(back, mc);
}
#[test]
fn model_config_skip_none_label_and_probe() {
let mc = ModelConfig::with_defaults("glm-4-flash");
let json = serde_json::to_string(&mc).unwrap();
// label / probe_source 为 None 应被跳过
assert!(!json.contains("label"), "json={json}");
assert!(!json.contains("probe_source"), "json={json}");
// 反序列化回来相等(skip_serializing 不影响 deserialize)
let back: ModelConfig = serde_json::from_str(&json).unwrap();
assert_eq!(back, mc);
}
#[test]
fn model_config_with_defaults_values() {
let mc = ModelConfig::with_defaults("test-model");
assert_eq!(mc.model_id, "test-model");
assert!(mc.enabled);
assert_eq!(mc.modalities, vec![Modality::Text]);
assert_eq!(mc.capabilities, vec![Capability::ToolUse]);
assert_eq!(mc.cost_tier, CostTier::Medium);
assert_eq!(mc.intelligence, IntelligenceTier::Standard);
assert_eq!(mc.weight, 50);
assert_eq!(mc.context_window, 8192);
assert_eq!(mc.label, None);
assert_eq!(mc.probe_source, None);
}
#[test]
fn model_config_defaults_applied_on_missing_fields() {
// 最小 JSON,仅 model_id — 其余字段靠 #[serde(default)] / default fn 填充
let json = r#"{"model_id":"minimal"}"#;
let mc: ModelConfig = serde_json::from_str(json).unwrap();
assert_eq!(mc.model_id, "minimal");
assert!(mc.enabled, "enabled 默认 true");
assert_eq!(mc.weight, 50, "weight 默认 50");
assert_eq!(mc.context_window, 8192, "context_window 默认 8192");
assert!(mc.modalities.is_empty(), "Vec 字段 default 空");
assert!(mc.capabilities.is_empty());
assert_eq!(mc.cost_tier, CostTier::Medium);
assert_eq!(mc.intelligence, IntelligenceTier::Standard);
}
// ── deserialize_model_configs 向后兼容 ──
#[derive(Debug, Deserialize)]
struct Wrap {
#[serde(default, deserialize_with = "deserialize_model_configs")]
models: Vec<ModelConfig>,
}
#[test]
fn deserialize_old_format_string_array() {
// 老格式:["glm-4-flash", "glm-4v"]
let json = r#"{"models":["glm-4-flash","glm-4v"]}"#;
let w: Wrap = serde_json::from_str(json).unwrap();
assert_eq!(w.models.len(), 2);
assert_eq!(w.models[0].model_id, "glm-4-flash");
assert!(w.models[0].enabled);
assert_eq!(w.models[0].modalities, vec![Modality::Text]);
assert_eq!(w.models[1].model_id, "glm-4v");
}
#[test]
fn deserialize_new_format_object_array() {
let json = r#"{"models":[{"model_id":"glm-4v","enabled":true,"modalities":["text","vision"],"capabilities":["tool_use"],"cost_tier":"medium","intelligence":"plus","weight":70,"context_window":128000}]}"#;
let w: Wrap = serde_json::from_str(json).unwrap();
assert_eq!(w.models.len(), 1);
let m = &w.models[0];
assert_eq!(m.model_id, "glm-4v");
assert_eq!(m.modalities, vec![Modality::Text, Modality::Vision]);
assert_eq!(m.intelligence, IntelligenceTier::Plus);
assert_eq!(m.context_window, 128_000);
}
#[test]
fn deserialize_null_or_missing_yields_empty() {
// null
let w: Wrap = serde_json::from_str(r#"{"models":null}"#).unwrap();
assert!(w.models.is_empty());
// 缺失字段
let w: Wrap = serde_json::from_str(r#"{}"#).unwrap();
assert!(w.models.is_empty());
// 空字符串(DB TEXT 列可能存空串)
let w: Wrap = serde_json::from_str(r#"{"models":""}"#).unwrap();
assert!(w.models.is_empty());
// 空数组
let w: Wrap = serde_json::from_str(r#"{"models":[]}"#).unwrap();
assert!(w.models.is_empty());
}
#[test]
fn deserialize_json_string_text_column() {
// DB TEXT 列存的是 JSON 文本字符串:`"[\"model-a\"]"`
let json = r#"{"models":"[\"glm-4-flash\"]"}"#;
let w: Wrap = serde_json::from_str(json).unwrap();
assert_eq!(w.models.len(), 1);
assert_eq!(w.models[0].model_id, "glm-4-flash");
}
#[test]
fn roundtrip_via_text_column() {
// 模拟 DB 落库:Vec<ModelConfig> → serde_json 序列化 → 存 TEXT 列
// 读出来:TEXT 列值 → 反序列化回 Vec<ModelConfig>
let original = vec![
ModelConfig::with_defaults("glm-4-flash"),
ModelConfig {
model_id: "glm-4v".into(),
modalities: vec![Modality::Text, Modality::Vision],
intelligence: IntelligenceTier::Plus,
weight: 70,
context_window: 128_000,
..ModelConfig::with_defaults("glm-4v")
},
];
let text = serde_json::to_string(&original).unwrap();
let wrapped = format!(r#"{{"models":"{}"}}"#, text.replace('"', "\\\""));
let w: Wrap = serde_json::from_str(&wrapped).unwrap();
assert_eq!(w.models.len(), 2);
assert_eq!(w.models[1].intelligence, IntelligenceTier::Plus);
}
}
+555
View File
@@ -0,0 +1,555 @@
//! LLM Provider trait — 统一的 LLM 调用抽象
//!
//! 支持 OpenAI 兼容 API(覆盖 OpenAI / GLM / DeepSeek / Claude 兼容模式),
//! 含 function calling / tool use 能力。
//!
//! 本文件含 trait + 类型 impl 块(零 IO)。纯类型定义(CompletionRequest /
//! ChatMessage / ContentPart 等 `#[derive]` struct/enum)已拆分到 `types.rs`,
//! 经下方 `pub use crate::types::*` re-export 保持外部路径
//! `df_ai_core::provider::*` / `df_ai::provider::*` 不变(编译期验证)。
//! HTTP impl(OpenAICompatProvider / AnthropicCompatProvider)+ 业务逻辑
//! (ContextManager / AiToolRegistry / build_provider 工厂)留在 df-ai crate。
use async_trait::async_trait;
// 透明 re-export:types.rs 中所有 pub 类型经此回流到 provider::* glob,
// 使 df_ai::provider::*(df-ai/src/provider.rs:15 的 `pub use df_ai_core::provider::*`)
// 与直接 `df_ai_core::provider::Type` 限定路径全部继续可用。
pub use crate::types::*;
use crate::types::{new_message_id, now_millis_i64};
impl ContentPart {
pub fn text(text: impl Into<String>) -> Self {
ContentPart::Text { text: text.into() }
}
pub fn image_base64(media_type: impl Into<String>, data: impl Into<String>) -> Self {
ContentPart::Image {
url: None,
base64: Some(data.into()),
media_type: Some(media_type.into()),
alt: None,
}
}
pub fn image_url(url: impl Into<String>) -> Self {
ContentPart::Image { url: Some(url.into()), base64: None, media_type: None, alt: None }
}
/// 是否图片片
pub fn is_image(&self) -> bool {
matches!(self, ContentPart::Image { .. })
}
}
impl ChatMessage {
pub fn system(content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::System, content: content.into(), parts: None, source: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
}
pub fn user(content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: None, source: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
}
pub fn assistant(content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, source: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
}
pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, source: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
}
pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::Tool, content: content.into(), source: None, parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
}
/// 多模态 user 消息:content 文本 + parts(含 Image 片)。
/// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。
pub fn user_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: Some(parts), 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 判定走多模态分支)。
pub fn has_image(&self) -> bool {
self.parts.as_ref().map(|ps| ps.iter().any(|p| p.is_image())).unwrap_or(false)
}
/// parts 若存在则返回引用,否则 None。
pub fn parts(&self) -> Option<&[ContentPart]> {
self.parts.as_deref()
}
/// 把 content + parts 拍平为有序 ContentPart 序列:先 content 作 Text 片(非空时),
/// 再追加 parts(若有)。供 provider 生成 content blocks(保证文本在前、图片在后)。
pub fn flattened_parts(&self) -> Vec<ContentPart> {
let mut out: Vec<ContentPart> = Vec::new();
if !self.content.is_empty() {
out.push(ContentPart::Text { text: self.content.clone() });
}
if let Some(ps) = &self.parts {
for p in ps {
out.push(p.clone());
}
}
out
}
/// 是否处于 active 态(status 为 None 或 "active")。其余状态一律 false。
///
/// 正面白名单:仅认 None / "active",新状态
/// (如"archived_segment" / "compressed")自动落入不 active 分支,
/// 无需每加一个状态就来这里改。当前取值 None/Some("active")/Some("truncated")
/// 行为与旧反面排除完全等价(None=true / "active"=true / "truncated"=false)。
pub fn is_active(&self) -> bool {
matches!(self.status, None | Some(MessageStatus::Active))
}
}
impl ToolDefinition {
pub fn function(name: impl Into<String>, description: impl Into<String>, parameters: serde_json::Value) -> Self {
Self {
tool_type: ToolType::new("function"),
function: ToolFunction { name: name.into(), description: description.into(), parameters },
}
}
}
impl ToolCall {
pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: impl Into<String>) -> Self {
Self {
id: id.into(),
call_type: "function".into(),
function: ToolCallFunction { name: name.into(), arguments: arguments.into() },
}
}
}
/// 解析点统一兜底: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
#[async_trait]
pub trait LlmProvider: Send + Sync {
/// 同步调用
async fn complete(&self, request: CompletionRequest) -> anyhow::Result<CompletionResponse>;
/// 流式调用(返回异步流)
async fn stream(
&self,
request: CompletionRequest,
) -> anyhow::Result<StreamResult>;
/// 文本嵌入:批量文本 → 语义向量(供知识库向量检索)
///
/// 默认实现返回 Err(协议不支持)。OpenAI 兼容协议覆盖实现(/v1/embeddings);
/// Anthropic 无 embedding API,保持默认。
async fn embed(&self, _model: &str, _texts: Vec<String>) -> anyhow::Result<Vec<Vec<f32>>> {
anyhow::bail!("该 Provider 不支持 embedding({})", self.name())
}
/// Provider 名称
fn name(&self) -> &str;
/// 实际请求端点(含 base_url + 关键路径,如 chat completions / messages)。
/// 默认回落 `name()`,provider 实现覆盖返真实 URL,供 401/网络错误诊断打印
/// —— 旧路径只能近似打印 provider_type,看不到实际请求端点。
fn endpoint(&self) -> String {
self.name().to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_active_whitelist() {
// 正面白名单:仅 None / "active" 为 true,其余一律 false。
// 零行为变化:None / "active" / "truncated" 与旧反面排除完全等价;
// "archived_segment" / "compressed" 由白名单 matches! 只认 None/active 自动落入 false。
// None(构造默认值,向前兼容老 JSON)
let m = ChatMessage::user("hi");
assert!(m.is_active(), "None 应 active");
// "active"
let mut m = ChatMessage::user("hi");
m.status = Some(MessageStatus::Active);
assert!(m.is_active(), "Some(active) 应 active");
// "truncated" — 当前取值,与旧实现等价(false)
let mut m = ChatMessage::user("hi");
m.status = Some(MessageStatus::Truncated);
assert!(!m.is_active(), "truncated 应不 active");
// "archived_segment" — 白名单自动隔离
let mut m = ChatMessage::user("hi");
m.status = Some(MessageStatus::ArchivedSegment);
assert!(!m.is_active(), "archived_segment 应不 active(白名单隔离)");
// "compressed" — 白名单自动隔离
let mut m = ChatMessage::user("hi");
m.status = Some(MessageStatus::Compressed);
assert!(!m.is_active(), "compressed 应不 active(白名单隔离)");
}
// ---------- ContentPart ----------
/// 老 JSON(无 parts 字段)反序列化时 parts 应为 None(向前兼容)
#[test]
fn contentpart_legacy_json_no_parts() {
let json = r#"{"role":"user","content":"hello"}"#;
let m: ChatMessage = serde_json::from_str(json).expect("老 JSON 应可反序列化");
assert_eq!(m.content, "hello");
assert!(m.parts.is_none(), "老 JSON 无 parts 字段 → None");
assert!(!m.has_image());
}
/// 新 JSON 带 parts(含 Image 片)反序列化 round-trip
#[test]
fn contentpart_with_image_roundtrip() {
let m = ChatMessage::user_parts(
"看这张图",
vec![
ContentPart::image_base64("image/png", "iVBORw0KGgo="),
ContentPart::text("说明"),
],
);
let json = serde_json::to_string(&m).expect("序列化");
// parts 顺序:[image_base64, text] → 首元素是 image
assert!(json.contains(r#""parts":[{"type":"image""#));
assert!(json.contains(r#""type":"text""#));
assert!(json.contains(r#""base64":"iVBORw0KGgo=""#));
assert!(json.contains(r#""media_type":"image/png""#));
let back: ChatMessage = serde_json::from_str(&json).expect("反序列化 round-trip");
assert_eq!(back.content, "看这张图");
assert!(back.has_image());
let parts = back.parts.expect("parts 应存在");
assert_eq!(parts.len(), 2);
assert!(parts[0].is_image());
}
/// has_image 判定
#[test]
fn contentpart_has_image_detection() {
assert!(!ChatMessage::user("纯文本").has_image());
let m = ChatMessage::user_parts("t", vec![ContentPart::text("只文本片")]);
assert!(!m.has_image(), "仅 Text 片不算 has_image");
let m = ChatMessage::user_parts(
"t",
vec![ContentPart::text("前缀"), ContentPart::image_url("https://x/a.png")],
);
assert!(m.has_image());
}
/// flattened_parts:content 非空 → 前置 Text 片 + parts 追加
#[test]
fn contentpart_flattened_parts() {
let m = ChatMessage::user("hi");
let flat = m.flattened_parts();
assert_eq!(flat.len(), 1);
assert_eq!(flat[0], ContentPart::Text { text: "hi".into() });
let m = ChatMessage::user_parts(
"cap",
vec![ContentPart::image_url("u"), ContentPart::text("尾")],
);
let flat = m.flattened_parts();
assert_eq!(flat.len(), 3);
assert_eq!(flat[0], ContentPart::Text { text: "cap".into() });
assert!(flat[1].is_image());
assert_eq!(flat[2], ContentPart::Text { text: "尾".into() });
let mut m = ChatMessage::user("");
m.parts = Some(vec![ContentPart::text("x")]);
let flat = m.flattened_parts();
assert_eq!(flat.len(), 1, "空 content 不应产生空 Text 片");
}
/// 向后兼容:旧代码 ChatMessage 字面量构造仍合法(audit/title/commands 零回归)
#[test]
fn contentpart_struct_literal_compat() {
let m = ChatMessage {
id: None,
role: MessageRole::User,
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: None,
};
assert_eq!(m.content, "字面量构造");
assert!(m.parts.is_none());
}
// ---------- DeepSeek reasoning_content ----------
/// 有 reasoning_content 时应序列化出来;None 时不出现
#[test]
fn completion_request_reasoning_content_serialization() {
let req = CompletionRequest {
model: "deepseek-r1".to_string(),
messages: vec![ChatMessage::user("hello")],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: Some("let me think...".to_string()),
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("reasoning_content"), "reasoning_content 应出现在 JSON 中, got: {}", json);
// None 时不应出现(skip_serializing_if)
let req_none = CompletionRequest { reasoning_content: None, ..req };
let json_none = serde_json::to_string(&req_none).unwrap();
assert!(!json_none.contains("reasoning_content"), "None 的 reasoning_content 不应序列化, got: {}", json_none);
}
/// 所有便捷构造函数默认 reasoning_content 为 None
#[test]
fn chat_message_reasoning_content_constructors() {
assert!(ChatMessage::system("sys").reasoning_content.is_none());
assert!(ChatMessage::user("hi").reasoning_content.is_none());
assert!(ChatMessage::assistant("resp").reasoning_content.is_none());
assert!(ChatMessage::tool_result("call_1", "result").reasoning_content.is_none());
}
/// ChatMessage reasoning_content 序列化 round-trip
#[test]
fn chat_message_reasoning_content_roundtrip() {
let m = ChatMessage {
id: None,
role: MessageRole::Assistant,
content: "answer".to_string(),
parts: None,
source: None,
tool_call_id: None,
tool_calls: None,
model: None,
status: None,
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,
};
let json = serde_json::to_string(&m).unwrap();
assert!(json.contains("reasoning_content"));
let deserialized: ChatMessage = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.reasoning_content, Some("thinking process".to_string()));
}
// ---------- 消息级溯源:id 字段 ----------
/// 所有便捷构造函数默认生成非 None 的 id(ULID 风格)
#[test]
fn chat_message_id_generated_by_constructors() {
assert!(ChatMessage::system("sys").id.is_some(), "system 应有 id");
assert!(ChatMessage::user("hi").id.is_some(), "user 应有 id");
assert!(ChatMessage::assistant("resp").id.is_some(), "assistant 应有 id");
assert!(
ChatMessage::assistant_with_tools("r", vec![]).id.is_some(),
"assistant_with_tools 应有 id"
);
assert!(
ChatMessage::tool_result("call_1", "result").id.is_some(),
"tool_result 应有 id"
);
assert!(
ChatMessage::user_parts("t", vec![ContentPart::text("x")]).id.is_some(),
"user_parts 应有 id"
);
}
/// id 全局唯一性:连续构造 100 条不重复(AtomicU64 计数器保证)
#[test]
fn chat_message_id_uniqueness() {
let mut ids = std::collections::HashSet::new();
for _ in 0..100 {
let m = ChatMessage::user("x");
let id = m.id.expect("构造的消息应有 id");
assert!(ids.insert(id), "100 条消息 id 应全部唯一");
}
}
/// id 序列化 round-trip:有 id 时序列化保留,反序列化回来一致
#[test]
fn chat_message_id_roundtrip() {
let m = ChatMessage {
id: Some("msg_1718800000000_42".to_string()),
role: MessageRole::Assistant,
content: "answer".to_string(),
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: None,
};
let json = serde_json::to_string(&m).unwrap();
assert!(json.contains(r#""id":"msg_1718800000000_42""#), "id 应序列化, got: {}", json);
let back: ChatMessage = serde_json::from_str(&json).unwrap();
assert_eq!(back.id.as_deref(), Some("msg_1718800000000_42"));
}
/// 向前兼容:老 JSON 无 id 字段 → 反序列化为 None(serde default)
#[test]
fn chat_message_id_legacy_json_no_id() {
let json = r#"{"role":"user","content":"hello"}"#;
let m: ChatMessage = serde_json::from_str(json).expect("老 JSON 应可反序列化");
assert_eq!(m.content, "hello");
assert!(m.id.is_none(), "老 JSON 无 id 字段 → None");
// 重新序列化:id=None 时不应出现 id 字段(skip_serializing_if)
let re = serde_json::to_string(&m).unwrap();
assert!(!re.contains(r#""id""#), "id=None 不应序列化, got: {}", re);
}
/// StreamChunk reasoning_content 序列化
#[test]
fn stream_chunk_reasoning_content() {
let chunk = StreamChunk {
delta: "text".to_string(),
finished: false,
tool_calls: None,
usage: None,
error: None,
reasoning_content: Some("thought".to_string()),
truncated_by_max_tokens: false,
};
let json = serde_json::to_string(&chunk).unwrap();
assert!(json.contains("reasoning_content"));
let chunk_none = StreamChunk { reasoning_content: None, ..chunk };
let json_none = serde_json::to_string(&chunk_none).unwrap();
assert!(!json_none.contains("reasoning_content"));
}
/// CompletionResponse reasoning_content 序列化
#[test]
fn completion_response_reasoning_content() {
let resp = CompletionResponse {
text: "ok".to_string(),
model: "r1".to_string(),
usage: TokenUsage { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30, ..Default::default() },
tool_calls: None,
reasoning_content: Some("r1 thought".to_string()),
};
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("reasoning_content"));
let deserialized: CompletionResponse = serde_json::from_str(&json).unwrap();
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_"));
}
}
+401
View File
@@ -0,0 +1,401 @@
//! LLM Provider 数据结构定义(纯类型,零 IO)
//!
//! 从 provider.rs 拆分的核心数据类型层:请求/响应/消息/工具/Token 等
//! `#[derive]` struct 与 enum + 一个内联时间戳辅助函数。所有 impl 块与
//! `LlmProvider` trait 留在 provider.rs,本文件只承载类型定义。
//!
//! 经 `provider.rs` 的 `pub use crate::types::*` re-export,外部路径
//! `df_ai_core::provider::*` / `df_ai::provider::*` 保持不变(编译期验证)。
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering};
use futures::Stream;
use serde::{Deserialize, Serialize};
// ============================================================
// 核心数据结构
// ============================================================
/// LLM 调用请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompletionRequest {
/// 模型名称
pub model: String,
/// 提示消息列表
pub messages: Vec<ChatMessage>,
/// 温度(0.0 ~ 2.0)
pub temperature: Option<f32>,
/// 最大生成 token 数
pub max_tokens: Option<u32>,
/// 是否流式输出
pub stream: bool,
/// 可调用的工具定义
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<ToolDefinition>>,
/// 工具调用策略: "auto" | "none" | {"type":"function","name":"xxx"}
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<serde_json::Value>,
/// DeepSeek thinking 模式:需把上一轮 response 的 reasoning_content 回传
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
}
/// 多模态消息内容片。Text 片为字符串;Image 片可走 url 或 base64(二选一,base64 非空时 url 忽略)。
///
/// ContentPart 作为 `ChatMessage.parts` 的元素类型。
/// 设计上 `content: String`(纯文本主载荷)保持不变,多模态片挂在 `parts`:
/// 这样未接入多模态的调用方(audit/title/commands/knowledge_inject 等读 content 当字符串)
/// 零回归,避免一次性改全仓。provider 转换层在 `has_image()` 为真时把 parts 透传给
/// vision 端点,否则只用 content 文本(保持纯文本端点兼容)。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentPart {
/// 文本片
Text { text: String },
/// 图片片。
/// - url:http(s) 可达 URL(provider 直接转发,不读字节)。
/// - base64:data URI 之外的纯 base64 字符串 + media_type(provider 内嵌转发)。
/// base64 非空时 url 忽略,便于前端上行无网络回拉的本地粘贴图。
Image {
url: Option<String>,
base64: Option<String>,
/// base64 模式必填(image/png | image/jpeg | image/webp | image/gif);url 模式可空。
media_type: Option<String>,
/// 可选 alt(vision 模型/降级文本时用)
#[serde(skip_serializing_if = "Option::is_none")]
alt: Option<String>,
},
}
/// 消息来源(#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`/
/// `tool_calls`/`model`/`status`/`reasoning_content`/`timestamp`)被
/// `df-storage/src/migrations.rs` 的 `migrate_v21` 硬编码提取(裸 JSON 反序列化,
/// 因 df-storage 不依赖 df-ai-core)。**改字段名必须同步更新迁移函数**,否则老库
/// 迁移漏数据。详见消息拆分存储设计 §4.2.3 迁移耦合点。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
/// 消息全局唯一 ID。用于消息级溯源(source_ref / audit message_id / idea source)。
/// 构造时由 `new_message_id()` 生成;老 JSON 反序列化为 None(向前兼容)。
/// 消息拆分存储后,此 ID 即 `ai_messages.id` 列主键。
/// 临时/派生消息(如 title 摘要)可显式赋 None(不溯源不落库)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub role: MessageRole,
pub content: String,
/// 消息来源(#958 溯源):消息从哪个渠道/端点进入会话。None=桌面本端(既有语义,
/// 老数据反序列化为 None 零回归);Some=外部渠道(飞书 message_id 可跳原消息追溯,
/// 多端混发可区分来源端)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<MessageSource>,
/// 多模态内容片。
///
/// `None`/空 → 纯文本消息(绝大多数场景,content 即全部载荷)。
/// `Some(含 Image 片)` → 多模态消息,provider 在 `has_image()` 为真时把 parts
/// 连同 content(作为前置 Text 片)一起转成 OpenAI/Anthropic 的 content blocks。
/// content 字段始终保留人类可读文本(文本消息的 Text 片内容与 content 一致),
/// 保证 audit/title/export 等读 content 当字符串的调用方零回归。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parts: Option<Vec<ContentPart>>,
/// 工具调用 ID(role=Tool 时必填)
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
/// AI 发起的工具调用列表(role=Assistant 时可能有)
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
/// 生成该消息的 model(仅 assistant 消息有,消息级 model 追溯)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// 消息状态(UX-09 编辑重生成):None 正常可见;
/// "truncated" 软删(编辑某条 user 消息后其后续消息标记,保留 DB 可追溯但不进 LLM 上下文、前端视图过滤)
/// 默认 None(向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<MessageStatus>,
/// DeepSeek thinking 模式的推理内容(多轮需回传)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
/// 消息创建时间(Unix 毫秒)。仅前端时间戳展示用,序列化进 messages JSON 持久化;
/// provider 请求映射不读此字段(构造器打戳→映射忽略,不进 LLM 请求),老数据反序列化为 None。
#[serde(default, skip_serializing_if = "Option::is_none")]
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,内联避免新增依赖)。
pub(crate) fn now_millis_i64() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
/// ChatMessage.id 生成器:全局唯一 + 单调递增 + 零依赖。
///
/// 格式 `msg_{ts_ms}_{counter}` —— 时间戳(毫秒)+ 进程内 AtomicU64 计数器双保险。
/// 决策:不用 ULID crate(workspace 无此依赖,df-ai-core 也不依赖 df-types::new_id),
/// 沿用 `now_millis_i64` 内联模式;AtomicU64 保证并发安全(多线程构造消息 ID 不冲突),
/// 计数器即便时间戳回拨(系统时钟调整)也单调。可读性好(含时间戳,便于排查)。
pub(crate) fn new_message_id() -> String {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let ts = now_millis_i64();
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
format!("msg_{}_{n}", ts)
}
/// 消息角色
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MessageRole {
System,
User,
Assistant,
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)]
pub struct ToolDefinition {
#[serde(rename = "type")]
pub tool_type: ToolType,
pub function: ToolFunction,
}
/// 函数定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolFunction {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
}
/// 工具调用(AI 发起)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
#[serde(rename = "type")]
pub call_type: ToolType,
pub function: ToolCallFunction,
}
/// 工具调用函数部分
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallFunction {
pub name: String,
pub arguments: String,
}
/// LLM 调用响应
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompletionResponse {
/// 生成的文本
pub text: String,
/// 使用的模型
pub model: String,
/// 消耗的 token 数
pub usage: TokenUsage,
/// AI 发起的工具调用(如有)
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
/// DeepSeek thinking 模式响应中的推理内容(需回传到下一轮请求)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
}
/// 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)]
pub struct TokenUsage {
pub prompt_tokens: u32,
pub completion_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
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamChunk {
/// 增量文本
pub delta: String,
/// 是否结束
pub finished: bool,
/// 工具调用增量(如有)
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCallDelta>>,
/// Token 用量(流末 chunk 携带,由 provider 解析自 SSE usage 事件)
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<TokenUsage>,
/// provider 流式错误事件(如 Anthropic SSE `type=="error"`)。
/// 非空表示流中途出错,不应视为正常完成(finished 路径),由 stream_llm 转 AiError。
#[serde(skip)]
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 模式推理内容增量
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
}
/// 工具调用增量(流式中的片段)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallDelta {
/// 索引
pub index: u32,
/// 工具调用 ID(仅第一个 chunk 有)
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
/// 函数名片段
#[serde(skip_serializing_if = "Option::is_none")]
pub function_name: Option<String>,
/// 函数参数片段
#[serde(skip_serializing_if = "Option::is_none")]
pub function_arguments: Option<String>,
}
/// 异步流类型别名
pub type StreamResult = Pin<Box<dyn Stream<Item = anyhow::Result<StreamChunk>> + Send>>;
+10 -4
View File
@@ -4,15 +4,21 @@ version = "0.1.0"
edition = "2021"
[dependencies]
df-core = { path = "../df-core" }
df-types = { path = "../df-types" }
df-ai-core = { path = "../df-ai-core" }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
tokio = { workspace = true, features = ["sync", "time"] }
async-trait = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
base64 = "0.22"
# HTTP + 流式
reqwest = { version = "0.12", features = ["stream", "json"] }
reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"] }
bytes = "1"
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()
);
}
+173
View File
@@ -0,0 +1,173 @@
[
{
"model_id": "glm-4",
"enabled": true,
"modalities": ["text"],
"capabilities": ["tool_use"],
"weight": 60,
"context_window": 128000
},
{
"model_id": "glm-4-air",
"enabled": true,
"modalities": ["text"],
"capabilities": ["tool_use"],
"weight": 80,
"context_window": 128000
},
{
"model_id": "glm-4-flash",
"enabled": true,
"modalities": ["text"],
"capabilities": ["tool_use"],
"weight": 90,
"context_window": 128000
},
{
"model_id": "glm-4-plus",
"enabled": true,
"modalities": ["text"],
"capabilities": ["tool_use"],
"weight": 50,
"context_window": 128000
},
{
"model_id": "glm-4v",
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"weight": 70,
"context_window": 128000
},
{
"model_id": "glm-4v-flash",
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"weight": 85,
"context_window": 128000
},
{
"model_id": "gpt-4o",
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"weight": 50,
"context_window": 128000
},
{
"model_id": "gpt-4o-mini",
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"weight": 80,
"context_window": 128000
},
{
"model_id": "claude-3-5-sonnet-20241022",
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"weight": 50,
"context_window": 200000
},
{
"model_id": "claude-3-opus-20240229",
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"weight": 45,
"context_window": 200000
},
{
"model_id": "claude-3-haiku-20240307",
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"weight": 80,
"context_window": 200000
},
{
"model_id": "gemini-1.5-pro",
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"weight": 55,
"context_window": 1000000
},
{
"model_id": "gemini-1.5-flash",
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"weight": 85,
"context_window": 1000000
},
{
"model_id": "deepseek-chat",
"enabled": true,
"modalities": ["text"],
"capabilities": ["tool_use"],
"weight": 75,
"context_window": 64000
},
{
"model_id": "deepseek-coder",
"enabled": true,
"modalities": ["text"],
"capabilities": ["tool_use", "code_gen"],
"weight": 75,
"context_window": 64000
},
{
"model_id": "qwen-2.5",
"enabled": true,
"modalities": ["text"],
"capabilities": ["tool_use"],
"weight": 65,
"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",
"enabled": true,
"modalities": [],
"capabilities": ["embedding"],
"weight": 50,
"context_window": 8192
}
]
+621 -22
View File
@@ -8,6 +8,7 @@
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::{LazyLock, RwLock};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -40,10 +41,328 @@ pub struct AiTool {
pub definition: ToolDefinition,
/// 风险级别
pub risk_level: RiskLevel,
/// 工具元数据(DRY 债 #5 收敛:注册点声明,消费方按条目或全局表查询,不再散布 match 表)
pub meta: ToolMeta,
/// 执行处理器
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 结果
pub type AiToolHandler =
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(
&mut self,
name: impl Into<String>,
@@ -73,19 +392,47 @@ impl AiToolRegistry {
parameters: Value,
risk_level: RiskLevel,
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 definition = ToolDefinition::function(&name_str, description, parameters);
GLOBAL_TOOL_META
.write()
.expect("全局工具元数据表锁")
.insert(name_str.clone(), meta);
self.tools.insert(
name_str,
AiTool {
definition,
risk_level,
meta,
handler,
},
);
}
/// 查询工具元数据条目(持有 registry 实例的消费方用)
pub fn meta(&self, name: &str) -> Option<ToolMeta> {
self.tools.get(name).map(|t| t.meta)
}
/// 获取所有工具定义(发送给 LLM 的 tools 参数)
pub fn tool_definitions(&self) -> Vec<ToolDefinition> {
self.tools.values().map(|t| t.definition.clone()).collect()
@@ -96,6 +443,19 @@ impl AiToolRegistry {
self.tools.get(name)
}
/// 执行指定工具 — handler 是唯一执行路径(schema+risk+实现同源,消除双轨)
pub async fn execute(
&self,
name: &str,
args: serde_json::Value,
) -> anyhow::Result<serde_json::Value> {
let tool = self
.tools
.get(name)
.ok_or_else(|| anyhow::anyhow!("未知工具: {}", name))?;
(tool.handler)(args).await
}
/// 获取所有已注册工具名称
pub fn tool_names(&self) -> Vec<String> {
self.tools.keys().cloned().collect()
@@ -118,27 +478,6 @@ impl Default for AiToolRegistry {
}
}
// ============================================================
// 工具执行结果
// ============================================================
/// 工具执行结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolExecutionResult {
/// 工具调用 ID
pub tool_call_id: String,
/// 工具名称
pub tool_name: String,
/// 执行参数
pub arguments: Value,
/// 执行结果
pub result: Value,
/// 是否需要人工批准
pub approval_required: bool,
/// 风险级别
pub risk_level: RiskLevel,
}
// ============================================================
// 辅助: 构建 JSON Schema 参数
// ============================================================
@@ -164,3 +503,263 @@ pub fn object_schema(properties: Vec<(&str, &str, bool)>) -> Value {
"required": required,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// 构造一个空参恒返 Ok 的 handler(注册测试用·不实际执行)
fn dummy_handler() -> AiToolHandler {
Box::new(|_args: Value| {
Box::pin(async { Ok(serde_json::json!({ "ok": true })) })
as Pin<Box<dyn Future<Output = anyhow::Result<Value>> + Send>>
})
}
#[test]
fn risk_level_serde_lowercase() {
// serde rename_all="lowercase": Low→"low" / Medium→"medium" / High→"high"
assert_eq!(serde_json::to_string(&RiskLevel::Low).unwrap(), "\"low\"");
assert_eq!(serde_json::to_string(&RiskLevel::Medium).unwrap(), "\"medium\"");
assert_eq!(serde_json::to_string(&RiskLevel::High).unwrap(), "\"high\"");
assert_eq!(
serde_json::from_str::<RiskLevel>("\"high\"").unwrap(),
RiskLevel::High
);
}
#[test]
fn register_and_get_tool() {
let mut reg = AiToolRegistry::new();
assert!(reg.is_empty());
reg.register(
"list_tasks",
"列出任务",
object_schema(vec![]),
RiskLevel::Low,
dummy_handler(),
);
assert!(!reg.is_empty());
assert_eq!(reg.len(), 1);
assert_eq!(reg.get("list_tasks").unwrap().risk_level, RiskLevel::Low);
assert!(reg.get("nonexistent").is_none());
}
#[test]
fn register_same_name_overwrites() {
// 同名二次注册覆盖(HashMap insert 语义)·非新增·后注册的 risk_level 胜出
let mut reg = AiToolRegistry::new();
reg.register("t", "v1", object_schema(vec![]), RiskLevel::Low, dummy_handler());
reg.register("t", "v2", object_schema(vec![]), RiskLevel::High, dummy_handler());
assert_eq!(reg.len(), 1);
assert_eq!(reg.get("t").unwrap().risk_level, RiskLevel::High);
}
#[test]
fn tool_definitions_and_names() {
let mut reg = AiToolRegistry::new();
reg.register("a", "desc a", object_schema(vec![]), RiskLevel::Low, dummy_handler());
reg.register("b", "desc b", object_schema(vec![]), RiskLevel::Medium, dummy_handler());
assert_eq!(reg.tool_definitions().len(), 2);
let names = reg.tool_names();
assert_eq!(names.len(), 2);
assert!(names.contains(&"a".to_string()));
assert!(names.contains(&"b".to_string()));
}
#[test]
fn default_is_empty() {
let reg = AiToolRegistry::default();
assert!(reg.is_empty());
assert_eq!(reg.len(), 0);
}
#[test]
fn object_schema_collects_required() {
let schema = object_schema(vec![("name", "string", true), ("age", "integer", false)]);
assert_eq!(schema["type"], "object");
assert_eq!(schema["properties"]["name"]["type"], "string");
let required = schema["required"].as_array().unwrap();
assert_eq!(required.len(), 1);
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
+286
View File
@@ -0,0 +1,286 @@
//! Anthropic 协议适配 — 协议数据结构与 SSE 事件解析(纯函数)。
//!
//! 本模块从 `anthropic_compat.rs` 抽离,承载与 HTTP 无关的纯协议逻辑:
//! - 请求/响应结构体(`AnthropicRequest` 等)
//! - 协议常量(`ANTHROPIC_VERSION` / `DEFAULT_MAX_TOKENS`)
//! - SSE 事件 → `StreamChunk` 转换纯函数(`apply_anthropic_event`)
//!
//! Provider struct + impl(含 HTTP 调用)仍留在 `anthropic_compat.rs`,
//! Rust impl 块不可跨文件,故仅搬迁 impl 块外部的类型/常量/纯函数。
//! 零行为变更(纯搬迁)。
use serde::{Deserialize, Serialize};
use tracing::{error, warn};
use crate::provider::{tool_call_id_or_fallback, StreamChunk, TokenUsage, ToolCallDelta};
// ============================================================
// 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 请求体
#[derive(Debug, Clone, Serialize)]
pub(crate) struct AnthropicRequest {
pub model: String,
pub messages: Vec<serde_json::Value>,
pub max_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub system: Option<SystemBlock>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
pub stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<AnthropicToolDef>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<serde_json::Value>,
}
/// Anthropic 工具定义(input_schema 对应 OpenAI 的 parameters)
#[derive(Debug, Clone, Serialize)]
pub(crate) struct AnthropicToolDef {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub input_schema: serde_json::Value,
}
/// Anthropic 同步响应
#[derive(Debug, Deserialize)]
pub(crate) struct AnthropicResponse {
// Anthropic 响应反序列化字段,保留以对齐响应结构(响应 id),标注意图消除 dead_code warning
#[allow(dead_code)]
id: String,
pub model: String,
pub content: Vec<AnthropicContentBlock>,
// Anthropic 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning
#[allow(dead_code)]
stop_reason: Option<String>,
pub usage: AnthropicUsage,
}
/// 响应 content 块(text / tool_use / thinking)
#[derive(Debug, Deserialize)]
pub(crate) struct AnthropicContentBlock {
#[serde(rename = "type")]
pub block_type: String,
#[serde(default)]
pub text: Option<String>,
/// thinking 块推理正文(端点侧开启扩展思考时出现;本侧请求未启用 thinking,一般缺省)
#[serde(default)]
pub thinking: Option<String>,
/// tool_use 块字段
pub id: Option<String>,
pub name: Option<String>,
pub input: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct AnthropicUsage {
pub input_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,
}
// ============================================================
// 协议常量
// ============================================================
/// Anthropic 流式协议版本头
pub(crate) const ANTHROPIC_VERSION: &str = "2023-06-01";
/// Anthropic max_tokens 必填,缺省时的兜底值
pub(crate) const DEFAULT_MAX_TOKENS: u32 = 4096;
// ============================================================
// SSE 解析纯函数(与 HTTP 解耦,便于单测)
// ============================================================
/// 将一条 Anthropic Messages SSE 事件 data 解析为 StreamChunk,并按需更新 usage 累加器。
///
/// 按 `type` 字段分发:
/// - `message_start` → 用 `message.usage.input_tokens` 初始化累加器(output 置 0)。
/// - `message_delta` → **output_tokens 是累计值(非增量)**,直接覆盖 `completion_tokens` 并重算 `total`。
/// - `content_block_delta` (text_delta/input_json_delta/thinking_delta) → 文本/工具入参/思考增量,
/// signature_delta 安静忽略。
/// - `content_block_start` (tool_use) → 工具块开始,带 id+name。
/// - `message_stop` → 返回 `finished=true` 终态 chunk,`usage` 取自累加器(`take()`)。
/// - `error` → 返回 `finished=true` 终态空 chunk。
/// - 其它(content_block_stop / ping 等)→ 空 chunk。
///
/// 等价性:content_block / message_stop / error 等事件分支与原 stream() 闭包逐字一致;
/// usage 透传(message_stop 终态 take() 带出、message_delta 的 output_tokens 按累计值覆盖)
/// 为本次新增能力,对应 StreamChunk 新增的 usage 字段。
pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUsage>) -> StreamChunk {
// 解析 data 中的 JSON,按 type 字段决定如何转 StreamChunk
let v: serde_json::Value = match serde_json::from_str(data) {
Ok(v) => v,
Err(_) => {
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("");
match ty {
// 消息开始:取 input_tokens 初始化累积器(output 此时未知,置 0)。
// anthropic prompt caching:cache_creation/read 在 message_start.usage 携带。
"message_start" => {
if let Some(inp) = v
.get("message")
.and_then(|m| m.get("usage"))
.and_then(|u| u.get("input_tokens"))
.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 {
prompt_tokens: inp as u32,
completion_tokens: 0,
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: usage_accum.clone(), error: None, reasoning_content: None, truncated_by_max_tokens: false }
}
// 消息增量:output_tokens 是累计值(非增量),直接覆盖 completion + 重算 total
"message_delta" => {
if let Some(out) = v.get("usage").and_then(|u| u.get("output_tokens")).and_then(|t| t.as_u64()) {
let acc = usage_accum
.get_or_insert(TokenUsage::default());
acc.completion_tokens = out as u32;
acc.total_tokens = acc.prompt_tokens.saturating_add(acc.completion_tokens);
}
// 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" => {
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") {
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, truncated_by_max_tokens: false };
}
// 工具入参增量
if delta.get("type").and_then(|t| t.as_str()) == Some("input_json_delta") {
let partial = delta.get("partial_json").and_then(|t| t.as_str()).unwrap_or("").to_string();
let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32;
return StreamChunk {
delta: String::new(),
finished: false,
tool_calls: Some(vec![ToolCallDelta {
index: idx,
id: None,
function_name: None,
function_arguments: Some(partial),
}]),
usage: None,
error: 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, truncated_by_max_tokens: false }
}
// 工具块开始:带 id + name
"content_block_start" => {
// thinking / redacted_thinking 块起点无正文要记(正文随后经 thinking_delta 流出;
// redacted_thinking 为密文不可解析),落入末尾统一空 chunk 即安静跳过,不告警。
if let Some(cb) = v.get("content_block") {
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 name = cb.get("name").and_then(|t| t.as_str()).map(|s| s.to_string());
// CR-空 id:id 缺失/空时用 fallback 兜底(流式后续 input_json_delta 按 index 累加,
// 中途无法整体跳过)。与同步路径 + OpenAI 路径共用 tool_call_id_or_fallback(DRY),
// prefix=`gen_anthropic_stream` 区分来源。非空原样。
let raw_id = cb.get("id").and_then(|t| t.as_str()).unwrap_or("");
let id = if raw_id.is_empty() {
let fallback = tool_call_id_or_fallback(raw_id, idx as usize, "gen_anthropic_stream");
warn!(%fallback, name = ?name, "Anthropic 流式 tool_use 块 id 为空,已生成 fallback id(原样回传会触发 GLM 500)");
Some(fallback)
} else {
Some(raw_id.to_string())
};
return StreamChunk {
delta: String::new(),
finished: false,
tool_calls: Some(vec![ToolCallDelta {
index: idx,
id,
function_name: name,
function_arguments: None,
}]),
usage: None,
error: 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, truncated_by_max_tokens: false }
}
// 消息结束:带出累积 usage
"message_stop" => StreamChunk {
delta: String::new(),
finished: true,
tool_calls: None,
usage: usage_accum.take(),
error: None,
reasoning_content: None,
truncated_by_max_tokens: false,
},
// 错误事件:流中途出错。不走 finished 完成路径(避免残缺响应被当正常完成入库),
// 改由 stream_llm 识别 error 非空 → 发 AiError + 丢弃残缺(与 OpenAI 路径 Err 一致)。
"error" => {
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 流式错误事件");
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
_ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false },
}
}
-59
View File
@@ -1,59 +0,0 @@
//! 上下文管理器 — 管理对话上下文和 token 预算
use std::collections::VecDeque;
use serde::{Deserialize, Serialize};
use crate::provider::ChatMessage;
/// 上下文窗口配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextConfig {
/// 最大 token 数
pub max_tokens: u32,
/// 保留的系统提示 token 数
pub system_reserve: u32,
}
impl Default for ContextConfig {
fn default() -> Self {
Self {
max_tokens: 128_000,
system_reserve: 4_000,
}
}
}
/// 上下文管理器
pub struct ContextManager {
/// 消息历史
messages: VecDeque<ChatMessage>,
/// 配置
config: ContextConfig,
}
impl ContextManager {
/// 创建上下文管理器
pub fn new(config: ContextConfig) -> Self {
Self {
messages: VecDeque::new(),
config,
}
}
/// 添加消息
pub fn push(&mut self, message: ChatMessage) {
self.messages.push_back(message);
// TODO: 当超过 token 预算时,淘汰旧消息
}
/// 获取当前消息列表
pub fn messages(&self) -> &VecDeque<ChatMessage> {
&self.messages
}
/// 清空上下文
pub fn clear(&mut self) {
self.messages.clear();
}
}
+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(_), .. }));
}
}
File diff suppressed because it is too large Load Diff
+164 -2
View File
@@ -1,10 +1,172 @@
//! df-ai: AI 编排 — LLM Provider、模型路由、Agent 协调、上下文管理、流式处理、工具注册
//! df-ai: AI 编排 — LLM Provider、Agent 协调、上下文管理、流式处理、工具注册
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_helpers;
pub mod context;
pub mod context_helpers;
pub mod memory_adapter;
pub(crate) mod net_probe;
// 多 Agent 协作调度中心(Phase 1 规则驱动, Phase 2 LLM 扩展)。
pub mod coordinator;
// 会话意图识别层(纯函数,不接入 agentic loop)。依据 docs/02-架构设计/构想审查/
// 意图识别层论证-2026-06-19.md。提供 recognize / tool_subset_for / suggested_model_tier。
// 待 Phase B+C 完成 + 模型模态管理落地后再接入 agentic loop。
pub mod git_worktree;
pub mod intent;
pub mod model_fetch;
// model_fetch 的纯逻辑子模块(URL 拼接 / 噪音过滤 / 响应反序列化),
// crate 内复用,private mod — 外部经 model_fetch 间接访问,无直接路径需求。
mod model_fetch_helpers;
pub mod model_probe;
// model_probe 的纯逻辑子模块(预设表加载 / 启发式推断 / 词素判定),
// crate 内复用,private mod — 外部经 model_probe 间接访问,无直接路径需求。
mod model_probe_helpers;
// 单对话并行多轮 Phase 0a · 0.8 plan_hint 纯函数(扩 intent::tool_subset_for 编排语义)。
// 依据 docs/02-架构设计/单对话并行多轮-Phase0落地路线图-2026-06-20.md 第 0.8 节。
// 纯函数(零 IO 零状态),Phase 1 接入主 loop,LLM 调度留 Phase 2。
pub mod plan_hint;
pub mod openai_compat;
pub mod openai_helpers;
// 本地图片引用展开(#958 图片入站引用化):消息存 file:// 引用,发送前单点展开 base64
pub mod image_ref;
// Phase 0 (B 路线): Plan-driven 规划骨架(维度 0.8 Plan/SubTask 结构 + 0.7 规划校验/Kahn 分层)。
// 纯函数模块(零 IO 零状态)。依据 docs/02-架构设计/单对话并行多轮-{设计,Phase0落地路线图}-2026-06-20.md。
// Phase 1(plan_hint 接入主 loop)/Phase 2(planning)/Phase 3(并行 execution) 接入后续。
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 router;
pub mod stream;
// 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用,
// stream_recv/agentic 流前重试需复用 backoff_delay(jitter)+is_status_retryable(Fatal 分类)
// 避免重写退避/分类逻辑。改 pub mod 后对外仅暴露纯函数 + 常量。
pub mod retry;
pub mod sse_parser;
pub mod namespace_store;
// P1 工具结果瘦身(上下文根治专项 2026-10-06 设计 §二):超阈值工具结果正文落盘 + 句柄。
// 与 src-tauri 审计侧 D2a(tool_result_offload) 同构;base 未注入时降级为纯摘要(零行为变化)。
pub mod tool_offload;
use provider::LlmProvider;
use reqwest::Client;
// df-ai-core 直接暴露,供需要直接引用 trait crate 的下游(可选)。
pub use df_ai_core;
/// 构建 Provider 共用的 reqwest Client(OpenAI/Anthropic Provider::new DRY)。
///
/// connect_timeout 30s 防连接阶段静默 hang(网络静默断);不设总 timeout——reqwest 的
/// `.timeout()` 会限制整个响应 body 时长,流式长生成任务会被误砍。读取阶段中途静默由上层
/// stream_llm 的 idle timeout 兜底。builder 失败回退 `Client::new()` 保可用(warn 日志)。
pub(crate) fn build_provider_client() -> Client {
Client::builder()
.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()
.unwrap_or_else(|e| {
tracing::warn!("reqwest builder 失败,回退默认 client: {}", e);
Client::new()
})
}
/// 流式 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)
///
/// `anthropic` 协议走 AnthropicCompatProvider(GLM 订阅端点 / Claude 官方),
/// 其余(openai / glm / deepseek 等 OpenAI 兼容)走 OpenAICompatProvider。
/// 调用方(AI Chat 的 run_agentic_loop、df-nodes 的 AiNode)统一引用此工厂,
/// 新增 provider 只改这一处。
///
/// 未知 provider_type 无法走显式报错(工厂签名不动,调用方横跨 src-tauri/df-nodes),
/// 兜底 OpenAI 兼容协议但 warn 留痕,不再静默——协议错配的请求失败可溯源到配置项。
/// anthropic_family 口径与 prompt.rs cache_split / chat.rs needs_url_prefetch 对齐。
pub fn build_provider(
provider_type: &str,
base_url: &str,
api_key: &str,
model: &str,
) -> Box<dyn LlmProvider> {
match provider_type {
"anthropic" | "anthropic_compat" => Box::new(
anthropic_compat::AnthropicCompatProvider::new(base_url, api_key, model),
),
"openai_compat" | "openai" | "glm" | "deepseek" => {
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())
}
}
+388
View File
@@ -0,0 +1,388 @@
//! 厂商模型列表拉取。
//!
//! 按 `provider_type` 分派拉取厂商模型列表,过滤非 chat 模型,返回模型名 Vec。
//! `fetch_and_probe` 在拉取基础上对每个模型名调 `model_probe::probe` 探测出完整 `ModelConfig`。
//!
//! 协议分派:
//! - `openai_compat` → `GET {base_url}/v1/models`(Bearer 鉴权)
//! - `anthropic_compat` → `GET {base_url}/v1/models`(x-api-key + anthropic-version 鉴权)
//! - 其他 → `UnsupportedProvider` 错误
//!
//! 本模块仅含网络 IO 入口;URL 拼接 / 噪音过滤 / 响应反序列化等纯逻辑见
//! [`model_fetch_helpers`]。
//!
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §5
//! (设计 §5.1 端点表 + §5.3 URL 智能拼接 + §5.4 噪音过滤)。
use std::time::Duration;
use anyhow::{anyhow, Result};
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_probe::probe;
// ────────────────────────────────────────────────────────────
// 公共入口
// ────────────────────────────────────────────────────────────
/// HTTP 请求超时(秒)。模型列表接口响应小、无需长超时。
const FETCH_TIMEOUT_SECS: u64 = 10;
/// 拉取厂商模型列表,返回已过滤噪音的模型名 Vec。
///
/// 协议分派(见模块文档)。错误信息含 `provider_type` + HTTP 状态/原因,便于前端友好提示。
///
/// 不真实调测:本函数本身是网络 IO,单测只覆盖 URL 拼接 / 噪音过滤等纯逻辑;
/// 集成验证留给阶段 5 的 IPC `ai_fetch_models`(用户在 Settings 点「测试连接」触发)。
pub async fn fetch_model_names(
provider_type: &str,
base_url: &str,
api_key: &str,
) -> Result<Vec<String>> {
match provider_type {
"openai_compat" => fetch_openai_compat(base_url, api_key).await,
"anthropic_compat" => fetch_anthropic_compat(base_url, api_key).await,
other => Err(anyhow!(
"拉取模型列表失败:不支持的 provider_type={other}(仅支持 openai_compat / anthropic_compat)"
)),
}
}
/// 拉取模型列表 + 对每个模型名调 `model_probe::probe` 探测出完整 `ModelConfig`。
///
/// 探测为纯 CPU(无网络),不会因单模型探测失败中断整体 — 探测对任意输入都有兜底(见 model_probe)。
pub async fn fetch_and_probe(
provider_type: &str,
base_url: &str,
api_key: &str,
) -> Result<Vec<ModelConfig>> {
let names = fetch_model_names(provider_type, base_url, api_key).await?;
Ok(names.into_iter().map(|n| probe(&n)).collect())
}
// ────────────────────────────────────────────────────────────
// 协议分派实现
// ────────────────────────────────────────────────────────────
/// OpenAI 兼容(GLM / DeepSeek / Kimi / 通义 / 中转站):`GET /v1/models`,Bearer 鉴权。
async fn fetch_openai_compat(base_url: &str, api_key: &str) -> Result<Vec<String>> {
let url = build_models_url(base_url);
let client = build_client()?;
let resp = client
.get(&url)
.bearer_auth(api_key)
.send()
.await
.map_err(|e| map_network_error("openai_compat", &url, e))?;
let status = resp.status();
if !status.is_success() {
return Err(map_status_error("openai_compat", status, &url));
}
// OpenAI 响应:`{data:[{id, owned_by, ...}]}`。中转站通常同构。
// 不用 resp.json():reqwest::Error::Decode 的 Display 吞 serde 详情(只给 "error decoding
// response body"),SenseNova 等厂商解析失败时无法定位根因。改 text() + serde_json::from_str,
// 解析失败时 serde_json::Error 含具体 field/type/position;再叠加宽松 Value fallback 兜底。
let body = resp
.text()
.await
.map_err(|e| anyhow!("openai_compat 读取响应体失败({url}):{e}"))?;
Ok(filter_chat_models(parse_models_compat("openai_compat", &url, &body)?))
}
/// Anthropic 兼容(Claude 官方 / GLM 订阅端点):`GET /v1/models`,x-api-key + anthropic-version 鉴权。
async fn fetch_anthropic_compat(base_url: &str, api_key: &str) -> Result<Vec<String>> {
let url = build_models_url(base_url);
let client = build_client()?;
let resp = client
.get(&url)
.header("x-api-key", api_key)
.header("anthropic-version", "2023-06-01")
.send()
.await
.map_err(|e| map_network_error("anthropic_compat", &url, e))?;
let status = resp.status();
if !status.is_success() {
return Err(map_status_error("anthropic_compat", status, &url));
}
// Anthropic 响应:`{data:[{id, display_name, type, ...}]}`(has_more 分页字段忽略)。
// 兼容兜底:`{models:[{name, ...}]}`(Ollama 风格,理论 anthropic_compat 不会命中,
// 但中转站行为不可控,用 `#[serde(alias)]` 零成本兜底 — 见 issues)。
// 与 openai_compat 同:text() + 严格 serde + Value 宽松 fallback,见 parse_models_compat。
let body = resp
.text()
.await
.map_err(|e| anyhow!("anthropic_compat 读取响应体失败({url}):{e}"))?;
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
}
}
// ────────────────────────────────────────────────────────────
// HTTP 客户端 + 错误映射
// ────────────────────────────────────────────────────────────
fn build_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
.build()
.map_err(|e| anyhow!("构建 HTTP 客户端失败:{e}"))
}
/// 网络错误(连接拒绝 / DNS / 超时)→ 友好提示,标注 provider_type + url 便于排查。
fn map_network_error(provider_type: &str, url: &str, e: reqwest::Error) -> anyhow::Error {
if e.is_timeout() {
anyhow!("{provider_type} 拉取模型列表超时({url},>{FETCH_TIMEOUT_SECS}s)— 检查 base_url 可达性")
} else {
anyhow!("{provider_type} 拉取模型列表网络错误({url}):{e}")
}
}
/// HTTP 状态错误 → 按 401/403/404/5xx 分类友好提示(对齐设计 §5.5 失败降级)。
fn map_status_error(provider_type: &str, status: reqwest::StatusCode, url: &str) -> anyhow::Error {
if status.as_u16() == 401 || status.as_u16() == 403 {
anyhow!("{provider_type} 鉴权失败({status}, {url})— 检查 api_key 是否正确 / 是否有该接口权限")
} else if status.as_u16() == 404 {
anyhow!("{provider_type} 端点不存在(404, {url})— 该厂商可能不支持模型列表拉取,请手动输入模型名")
} else if status.is_server_error() {
anyhow!("{provider_type} 厂商服务异常({status}, {url})— 稍后重试或手动输入模型名")
} else {
anyhow!("{provider_type} 拉取模型列表失败(HTTP {status}, {url})")
}
}
// ────────────────────────────────────────────────────────────
// 测试(协议分派:不支持类型直接报错,不触网)
// ────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
// ── fetch_model_names 协议分派:不支持类型直接报错(不触网) ──
#[tokio::test]
async fn fetch_unsupported_provider_errors_without_network() {
// ollama / glm 等非 openai_compat / anthropic_compat 类型 → 直接错误,不发请求
let err = fetch_model_names("ollama", "http://localhost:11434", "k")
.await
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("ollama"), "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());
}
}
+426
View File
@@ -0,0 +1,426 @@
//! 厂商模型列表拉取 — 纯逻辑(无 IO)。
//!
//! 从 `model_fetch.rs` 抽离的纯函数 / 类型:URL 拼接、噪音过滤、响应反序列化。
//! 这些逻辑无网络依赖,可独立单测;主文件 `model_fetch.rs` 保留有 IO 的拉取入口。
//!
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §5.3 / §5.4。
use serde::Deserialize;
// ────────────────────────────────────────────────────────────
// URL 智能拼接
// ────────────────────────────────────────────────────────────
/// 拼接模型列表端点 URL。
///
/// 规则(设计 §5.3,base_url 先去尾 `/`):
/// - 以 `/chat/completions` 结尾 → 替换为 `/models`(用户把对话端点当 base_url 填了)
/// - 以 `/v1` / `/v4` 结尾 → 直接补 `/models`(避免 `/v1/v1/models`)
/// - 以 `/models` 结尾 → 原样用(用户已填完整端点)
/// - 以 `/api` 结尾 → 补 `/models`(Ollama 风格 base,anthropic_compat 兜底用)
/// - 其余 → 补 `/v1/models`(最常见:用户填 `https://open.bigmodel.cn/api/paas/v4`)
pub fn build_models_url(base_url: &str) -> String {
let url = base_url.trim_end_matches('/');
if url.ends_with("/chat/completions") {
url.replace("/chat/completions", "/models")
} else if url.ends_with("/v1") || url.ends_with("/v4") {
format!("{url}/models")
} else if url.ends_with("/models") {
url.to_string()
} else if url.ends_with("/api") {
format!("{url}/models")
} else {
format!("{url}/v1/models")
}
}
// ────────────────────────────────────────────────────────────
// 噪音过滤(剔除非 chat 模型)
// ────────────────────────────────────────────────────────────
/// 判断是否为非 chat 模型(应从列表中剔除)。
///
/// 规则(设计 §5.4 + 合理扩展,见 issues):
/// - 图片生成:dall-e / midjourney / stable-diffusion / imagen / sensenova-u1 / infographic / image-generation
/// - 语音:tts / whisper / audio / speech / voice(语音合成/识别)
/// - 实时:realtime(OpenAI Realtime API 语音对话,非 chat completions)
/// - 转写:transcribe / transcription
/// - 审查:moderation(内容审查模型)
/// - 搜索:davinci-search / search(旧 GPT 搜索变种)
///
/// embedding 设计 §5.4 注释「保留(知识库需要)」— 此处遵循设计保留 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 {
let id = id.to_lowercase();
// 图片生成
id.contains("dall-e")
|| id.contains("midjourney")
|| id.contains("stable-diffusion")
|| 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)
|| id.contains("tts")
|| id.contains("whisper")
|| id.contains("audio")
|| id.contains("speech")
|| id.contains("voice")
// 实时语音对话(OpenAI Realtime API,走独立协议非 chat completions)
|| id.contains("realtime")
// 转写
|| id.contains("transcribe")
|| id.contains("transcription")
// 内容审查
|| id.contains("moderation")
// 旧 GPT 搜索变种(davinci-search 覆盖旧 GPT 搜索;裸 -search 误伤合法 search-augmented chat
// 模型如 gpt-4o-search-preview/qwen-search/glm-4-search,已移除)
|| id.contains("davinci-search")
}
/// 过滤噪音:剔除非 chat 模型 + 去重(中转站可能返回重复) + 去空名。
pub fn filter_chat_models(ids: Vec<String>) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::with_capacity(ids.len());
for id in ids {
let trimmed = id.trim();
if trimmed.is_empty() || is_non_chat_model(trimmed) {
continue;
}
if seen.insert(trimmed.to_string()) {
out.push(trimmed.to_string());
}
}
out
}
// ────────────────────────────────────────────────────────────
// 响应反序列化
// ────────────────────────────────────────────────────────────
/// 厂商 `/v1/models` 响应统一反序列化。
///
/// 兼容三种结构(用 `#[serde(alias)]`):
/// - OpenAI / Anthropic:`{data:[{id, ...}]}`
/// - Ollama:`{models:[{name, ...}]}`(anthropic_compat 走中转站时兜底)
/// - 单字段别名:`id` / `name` 都认
#[derive(Debug, Deserialize)]
pub(crate) struct ModelsList {
#[serde(default)]
data: Vec<ModelEntry>,
#[serde(default, alias = "models")]
data_alt: Vec<ModelEntry>,
}
#[derive(Debug, Deserialize)]
struct ModelEntry {
#[serde(default, alias = "name")]
id: Option<String>,
}
impl ModelsList {
/// 合并 `data` 与 `models`(alias)字段,提取所有模型 id/name,丢弃 None。
pub(crate) fn into_ids(self) -> Vec<String> {
self.data
.into_iter()
.chain(self.data_alt)
.filter_map(|e| e.id)
.collect()
}
}
// ────────────────────────────────────────────────────────────
// 测试(纯逻辑/字符串处理,不真实调 API)
// ────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
// ── is_non_chat_model:search-augmented chat 模型不应误剔(裸 -search 已移除) ──
#[test]
fn is_non_chat_model_keeps_search_augmented_chat() {
// search-augmented chat 模型(对话端点可调)不应被 -search 子串误剔
assert!(!is_non_chat_model("gpt-4o-search-preview"));
assert!(!is_non_chat_model("qwen-search"));
assert!(!is_non_chat_model("glm-4-search-preview"));
// 旧 GPT 搜索变种仍剔(davinci-search 覆盖)
assert!(is_non_chat_model("text-davinci-search-001"));
}
// ── build_models_url:尾 / 处理 ──
#[test]
fn build_url_strips_trailing_slash() {
// 用户填 `https://api.x.com/v1/`(尾 /)→ 去 / 后命中 /v1 分支 → 补 /models
assert_eq!(
build_models_url("https://api.x.com/v1/"),
"https://api.x.com/v1/models"
);
// 多个尾 /
assert_eq!(
build_models_url("https://api.x.com/v4//"),
"https://api.x.com/v4/models"
);
}
#[test]
fn build_url_v1_suffix_appends_models() {
assert_eq!(
build_models_url("https://api.openai.com/v1"),
"https://api.openai.com/v1/models"
);
// 不应产生 /v1/v1/models
assert!(!build_models_url("https://api.openai.com/v1").contains("/v1/v1"));
}
#[test]
fn build_url_v4_suffix_appends_models() {
// GLM:`https://open.bigmodel.cn/api/paas/v4`
assert_eq!(
build_models_url("https://open.bigmodel.cn/api/paas/v4"),
"https://open.bigmodel.cn/api/paas/v4/models"
);
}
#[test]
fn build_url_bare_base_appends_v1_models() {
// 用户只填根:`https://api.deepseek.com` → 补 /v1/models
assert_eq!(
build_models_url("https://api.deepseek.com"),
"https://api.deepseek.com/v1/models"
);
}
#[test]
fn build_url_chat_completions_replaced() {
// 用户误把对话端点当 base_url
assert_eq!(
build_models_url("https://api.x.com/v1/chat/completions"),
"https://api.x.com/v1/models"
);
}
#[test]
fn build_url_already_models_kept() {
// 用户已填完整端点 → 原样用
assert_eq!(
build_models_url("https://api.x.com/v1/models"),
"https://api.x.com/v1/models"
);
}
#[test]
fn build_url_api_suffix_ollama_style() {
// Ollama 风格 base(anthropic_compat 兜底路径)
assert_eq!(
build_models_url("http://localhost:11434/api"),
"http://localhost:11434/api/models"
);
}
// ── is_non_chat_model:噪音过滤规则 ──
#[test]
fn non_chat_image_generation_filtered() {
assert!(is_non_chat_model("dall-e-3"));
assert!(is_non_chat_model("dall-e-2"));
assert!(is_non_chat_model("midjourney-v6"));
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]
fn non_chat_speech_filtered() {
assert!(is_non_chat_model("tts-1"));
assert!(is_non_chat_model("tts-1-hd"));
assert!(is_non_chat_model("whisper-1"));
assert!(is_non_chat_model("audio-transcribe"));
assert!(is_non_chat_model("speech-synth"));
}
#[test]
fn non_chat_realtime_filtered() {
assert!(is_non_chat_model("gpt-4o-realtime"));
assert!(is_non_chat_model("gpt-4o-mini-realtime-preview"));
}
#[test]
fn non_chat_moderation_filtered() {
assert!(is_non_chat_model("text-moderation-stable"));
assert!(is_non_chat_model("omni-moderation-latest"));
}
#[test]
fn non_chat_search_variants_filtered() {
assert!(is_non_chat_model("text-davinci-search-001"));
}
#[test]
fn chat_models_kept() {
// 主流 chat 模型不应被误剔
for ok in [
"glm-4",
"glm-4-flash",
"glm-4v",
"glm-4-air",
"glm-4-plus",
"deepseek-chat",
"deepseek-coder",
"gpt-4o",
"gpt-4o-mini",
"claude-3-5-sonnet-20241022",
"claude-3-haiku-20240307",
"qwen-plus",
"kimi-k2",
] {
assert!(!is_non_chat_model(ok), "{ok} 不应被当噪音剔除");
}
}
#[test]
fn embedding_kept_per_design() {
// 设计 §5.4 明确「embedding 保留(知识库需要)」
for emb in ["embedding-3", "text-embedding-3-large", "bge-m3"] {
assert!(!is_non_chat_model(emb), "{emb} 按 §5.4 应保留");
}
}
// ── filter_chat_models:过滤 + 去重 + 去空 ──
#[test]
fn filter_removes_noise_and_keeps_chat() {
let raw = vec![
"glm-4-flash".to_string(),
"dall-e-3".to_string(), // 噪音
"tts-1".to_string(), // 噪音
"whisper-1".to_string(), // 噪音
"text-moderation-stable".to_string(), // 噪音
"glm-4v".to_string(),
"gpt-4o".to_string(),
];
let got = filter_chat_models(raw);
assert_eq!(got, vec!["glm-4-flash", "glm-4v", "gpt-4o"]);
}
#[test]
fn filter_dedupes() {
// 中转站可能返回重复模型名
let raw = vec![
"glm-4".to_string(),
"glm-4".to_string(),
"glm-4-flash".to_string(),
];
let got = filter_chat_models(raw);
assert_eq!(got, vec!["glm-4", "glm-4-flash"]);
}
#[test]
fn filter_drops_empty_and_whitespace_only() {
let raw = vec![
"".to_string(),
" ".to_string(),
"glm-4".to_string(),
" glm-4v ".to_string(), // 带空格 → trim 后保留
];
let got = filter_chat_models(raw);
assert_eq!(got, vec!["glm-4", "glm-4v"]);
}
#[test]
fn filter_empty_input() {
assert!(filter_chat_models(vec![]).is_empty());
}
// ── ModelsList 反序列化:兼容 OpenAI / Anthropic / Ollama 三种结构 ──
#[test]
fn parse_openai_format() {
// OpenAI:`{data:[{id, ...}]}`
let json = r#"{
"data": [
{"id": "gpt-4o", "owned_by": "openai"},
{"id": "gpt-4o-mini", "owned_by": "openai"}
]
}"#;
let m: ModelsList = serde_json::from_str(json).unwrap();
let ids = m.into_ids();
assert_eq!(ids, vec!["gpt-4o", "gpt-4o-mini"]);
}
#[test]
fn parse_anthropic_format() {
// Anthropic:`{data:[{id, display_name, type}], has_more:false}`
let json = r#"{
"data": [
{"id": "claude-3-5-sonnet-20241022", "display_name": "Claude 3.5 Sonnet", "type": "model"},
{"id": "claude-3-haiku-20240307", "display_name": "Claude 3 Haiku", "type": "model"}
],
"has_more": false,
"first_id": "claude-3-5-sonnet-20241022",
"last_id": "claude-3-haiku-20240307"
}"#;
let m: ModelsList = serde_json::from_str(json).unwrap();
let ids = m.into_ids();
assert_eq!(ids, vec!["claude-3-5-sonnet-20241022", "claude-3-haiku-20240307"]);
}
#[test]
fn parse_ollama_format_via_alias() {
// Ollama:`{models:[{name, size}]}` — 走 anthropic_compat 中转站兜底
let json = r#"{
"models": [
{"name": "llama3:8b", "size": 4661214673},
{"name": "qwen2:7b", "size": 4436122400}
]
}"#;
let m: ModelsList = serde_json::from_str(json).unwrap();
let ids = m.into_ids();
assert_eq!(ids, vec!["llama3:8b", "qwen2:7b"]);
}
#[test]
fn parse_missing_data_field_yields_empty() {
// 厂商返回 `{}` 或缺 data 字段 → 空列表,不 panic
let json = r#"{}"#;
let m: ModelsList = serde_json::from_str(json).unwrap();
assert!(m.into_ids().is_empty());
}
#[test]
fn parse_entry_missing_id_skipped() {
// data 数组项缺 id → 该项跳过
let json = r#"{
"data": [
{"id": "glm-4"},
{"owned_by": "x"},
{"id": "glm-4v"}
]
}"#;
let m: ModelsList = serde_json::from_str(json).unwrap();
let ids = m.into_ids();
assert_eq!(ids, vec!["glm-4", "glm-4v"]);
}
}
+418
View File
@@ -0,0 +1,418 @@
//! 模型探测器。
//!
//! 给定模型名,产出完整 `ModelConfig`(4 维度 + 路由控制 + 探测来源标注)。
//!
//! 多源探测,高优先源命中即返(短路):
//! 1. 内置预设表精确匹配(name 完全相等) → `ProbeSource::PresetTable`
//! 2. 内置预设表前缀匹配(前缀 + 分隔符边界) → `ProbeSource::PresetTable`
//! 3. 模型名启发式推断(命名模式) → `ProbeSource::Heuristic`
//! 4. 默认值兜底(`ModelConfig::with_defaults`) → `ProbeSource::Default`
//!
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §4
//! (设计文档 §4.1 写的是"启发式先行 + 预设表合并";本实施按阶段 2 任务规格
//! 收敛为"预设优先 > 启发式"链 — 预设表精确数据可信度高于命名猜测,优先短路)。
use df_ai_core::model::{ModelConfig, ProbeSource};
// 纯逻辑子模块(预设表加载 / 启发式推断 / 词素判定)抽离至此 —
// private mod,crate 内复用,外部经 model_probe::probe 间接访问(对齐 model_fetch_helpers)。
use crate::model_probe_helpers::{heuristic_infer, presets};
// ────────────────────────────────────────────────────────────
// 公共入口
// ────────────────────────────────────────────────────────────
/// 探测单个模型,返回完整 `ModelConfig`(已填充 `probe_source`)。
///
/// 多源探测顺序(高优先源命中即返):
/// 1. 预设表精确匹配(`model_id` 完全相等,大小写敏感)
/// 2. 预设表前缀匹配(`model_id` 是入参前缀且后缀以分隔符开始,大小写不敏感)
/// 3. 启发式推断(模型名命名模式)
/// 4. 默认值兜底
///
/// 返回的 `ModelConfig.probe_source` 标注实际命中来源。
pub fn probe(model_id: &str) -> ModelConfig {
// 1. 预设表精确匹配
if let Some(mut hit) = presets().iter().find(|m| m.model_id == model_id).cloned() {
hit.probe_source = Some(ProbeSource::PresetTable);
return hit;
}
// 2. 预设表前缀匹配(入参名以预设名开头 + 后缀以分隔符开始,大小写不敏感)
// 收紧自原「双向子串包含」:子串匹配会把 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 fuzzy = presets()
.iter()
.filter(|m| {
let cand = m.model_id.to_lowercase();
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());
if let Some(mut hit) = fuzzy.cloned() {
// 模糊命中:保留预设维度,但用入参的实际模型名覆盖 model_id(避免回写错名)
hit.model_id = model_id.to_string();
hit.probe_source = Some(ProbeSource::PresetTable);
return hit;
}
// 3. 启发式推断
let mut heuristic = heuristic_infer(model_id);
heuristic.probe_source = Some(ProbeSource::Heuristic);
heuristic
}
// ────────────────────────────────────────────────────────────
// 测试
// ────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use df_ai_core::model::{Capability, CostTier, IntelligenceTier, Modality, ProbeSource};
// ── 预设表加载 ──
#[test]
fn presets_loaded_and_nonempty() {
let p = presets();
assert!(!p.is_empty(), "预设表应非空");
// 确保每个预设都有 model_id
assert!(p.iter().all(|m| !m.model_id.is_empty()));
}
#[test]
fn presets_contain_expected_models() {
let p = presets();
let ids: Vec<&str> = p.iter().map(|m| m.model_id.as_str()).collect();
assert!(ids.contains(&"glm-4"), "glm-4 应在预设表: {ids:?}");
assert!(ids.contains(&"glm-4-flash"));
assert!(ids.contains(&"glm-4v"));
assert!(ids.contains(&"gpt-4o"));
assert!(ids.contains(&"claude-3-5-sonnet-20241022"));
}
// ── 预设精确匹配 ──
#[test]
fn probe_preset_exact_match_glm4() {
let m = probe("glm-4");
assert_eq!(m.model_id, "glm-4");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
assert_eq!(m.modalities, vec![Modality::Text]);
// 预设表不写死 cost_tier/intelligence,由 serde default 兜底中性值
assert_eq!(m.intelligence, IntelligenceTier::Standard);
assert_eq!(m.cost_tier, CostTier::Medium);
}
#[test]
fn probe_preset_exact_match_glm4v_has_vision() {
let m = probe("glm-4v");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
assert!(m.modalities.contains(&Modality::Vision));
}
#[test]
fn probe_preset_exact_match_deepseek_coder_has_codegen() {
let m = probe("deepseek-coder");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
assert!(m.capabilities.contains(&Capability::CodeGen));
}
#[test]
fn probe_preset_exact_match_embedding_no_text_modality() {
let m = probe("embedding-3");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
assert!(m.capabilities.contains(&Capability::Embedding));
assert!(
!m.modalities.contains(&Modality::Text),
"embedding 模型不应有 Text 模态"
);
}
// ── 预设表:已知多模态模型(命名无 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]
fn probe_preset_fuzzy_match_glm4v_variant() {
// "glm-4v-x" 不在预设表精确命中,但 "glm-4v" 是其前缀(后缀 "-x" 以分隔符开始) → 前缀命中
let m = probe("glm-4v-x");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
assert_eq!(m.model_id, "glm-4v-x", "前缀命中后 model_id 应用入参名");
assert!(
m.modalities.contains(&Modality::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 ──
#[test]
fn heuristic_vision_from_v_suffix() {
// glm-4v 不走启发式(精确命中预设);用未知名验证启发式
let m = probe("custom-model-v");
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
assert!(
m.modalities.contains(&Modality::Vision),
"v 后缀应推断 Vision"
);
}
#[test]
fn heuristic_vision_from_vl_token() {
let m = probe("qwen-vl-unknown");
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
assert!(m.modalities.contains(&Modality::Vision));
}
#[test]
fn heuristic_vision_from_vision_word() {
let m = probe("some-vision-7b");
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
assert!(m.modalities.contains(&Modality::Vision));
}
// ── 启发式:档位中性(flash/mini 不再猜 Lite) ──
#[test]
fn heuristic_flash_keeps_neutral_tier() {
let m = probe("unknown-flash");
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
// cost/intel 一律中性,不靠名字猜
assert_eq!(m.intelligence, IntelligenceTier::Standard);
assert_eq!(m.cost_tier, CostTier::Medium);
}
#[test]
fn heuristic_mini_keeps_neutral_tier() {
let m = probe("test-mini-model");
assert_eq!(m.intelligence, IntelligenceTier::Standard);
assert_eq!(m.cost_tier, CostTier::Medium);
}
// ── 启发式:CodeGen ──
#[test]
fn heuristic_codegen_from_code() {
let m = probe("acme-code-7b");
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
assert!(m.capabilities.contains(&Capability::CodeGen));
// 对话模型仍应保留 ToolUse
assert!(m.capabilities.contains(&Capability::ToolUse));
}
#[test]
fn heuristic_codegen_from_coder() {
let m = probe("acme-coder");
assert!(m.capabilities.contains(&Capability::CodeGen));
}
// ── 启发式:Embedding(特殊路径:无 Text 模态) ──
#[test]
fn heuristic_embedding_no_text_modality() {
let m = probe("acme-embedding-v3");
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
assert!(m.capabilities.contains(&Capability::Embedding));
assert!(!m.modalities.contains(&Modality::Text));
assert!(!m.capabilities.contains(&Capability::ToolUse));
}
#[test]
fn heuristic_embedding_from_text_embedding_prefix() {
let m = probe("text-embedding-3-large");
assert!(m.capabilities.contains(&Capability::Embedding));
assert!(m.modalities.is_empty());
}
// ── 启发式:档位中性(pro/opus/4o 不再猜 Plus/Ultra/High) ──
#[test]
fn heuristic_pro_keeps_neutral_tier() {
let m = probe("acme-pro");
assert_eq!(m.intelligence, IntelligenceTier::Standard);
assert_eq!(m.cost_tier, CostTier::Medium);
}
#[test]
fn heuristic_opus_keeps_neutral_tier() {
let m = probe("acme-opus");
assert_eq!(m.intelligence, IntelligenceTier::Standard);
}
#[test]
fn heuristic_4o_keeps_neutral_cost() {
let m = probe("acme-4o");
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) ──
#[test]
fn heuristic_default_for_unknown_name() {
let m = probe("acme-unknown-model");
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
assert_eq!(m.modalities, vec![Modality::Text]);
assert_eq!(m.capabilities, vec![Capability::ToolUse]);
assert_eq!(m.intelligence, IntelligenceTier::Standard);
assert_eq!(m.cost_tier, CostTier::Medium);
}
// ── 优先级链:预设精确 > 启发式 ──
#[test]
fn probe_preset_beats_heuristic() {
// "glm-4-flash" 精确命中预设表;预设/启发式档位都中性,
// 此处仅校验 source 标注为 PresetTable
let m = probe("glm-4-flash");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
}
#[test]
fn probe_priority_chain_returns_config() {
// 任何名字都应返回完整 ModelConfig,不 panic
for name in ["glm-4", "unknown-xyz", "qwen-vl", "acme-embedding", "gpt-5"] {
let m = probe(name);
assert_eq!(m.model_id, name);
assert!(
m.probe_source.is_some(),
"probe_source 应被填充: {name}"
);
}
}
}
+132
View File
@@ -0,0 +1,132 @@
//! 模型探测器 — 纯逻辑子模块。
//!
//! 从 `model_probe.rs` 抽离的纯函数实现(预设表加载 / 启发式推断 / 词素判定)。
//! 无 IO、无外部状态,crate 内经 `model_probe::probe` 间接复用 —
//! 对齐 `model_fetch_helpers` 拆分先例(private mod,外部零直接路径需求)。
//!
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §4
use std::sync::OnceLock;
use df_ai_core::model::{
Capability, CostTier, IntelligenceTier, Modality, ModelConfig,
};
// ────────────────────────────────────────────────────────────
// 预设表:编译期嵌入(include_str! 相对 crate 根),零运行时文件依赖
// ────────────────────────────────────────────────────────────
/// 预设表原始 JSON(编译期从 `crates/df-ai/presets/models.json` 嵌入)。
pub(super) const PRESETS_JSON: &str = include_str!("../presets/models.json");
/// 解析后的预设表(进程内单例,首次访问惰性解析一次)。
pub(super) fn presets() -> &'static [ModelConfig] {
static PRESETS: OnceLock<Vec<ModelConfig>> = OnceLock::new();
PRESETS.get_or_init(|| {
// include_str! 内容由仓库控制,解析失败属编译期/仓库错误,panic 合理。
serde_json::from_str::<Vec<ModelConfig>>(PRESETS_JSON)
.expect("presets/models.json 解析失败 — 检查 JSON 格式与 ModelConfig serde 映射")
})
}
// ────────────────────────────────────────────────────────────
// 启发式推断(模型名命名模式 → 4 维度)
// ────────────────────────────────────────────────────────────
/// 模型名启发式推断。仅推断功能性维度(modalities/capabilities),
/// cost_tier / intelligence 一律返中性默认(Medium / Standard)。
///
/// 取舍:模型名关键词猜档位(flash→Lite、4o→High、pro→Plus)无依据,
/// 厂商定价/智力与命名无关,瞎填会污染路由器过滤(intelligence >= min / cost_tier <= max)。
/// 改中性默认,真实档位由用户手填或更高阶探测源(如厂商 API/定价表)提供。
///
/// 规则(任务规格,设计文档 §4.4 模糊匹配规则对齐):
/// - `embed`/`embedding`/`e-`(前缀) → Embedding 模型:capabilities=[Embedding],modalities=[](去 Text)
/// - `v`/`vision`/`vl`(词素) → modalities 加 Vision
/// - `code`/`coder` → capabilities 加 CodeGen
/// - cost_tier / intelligence → 中性默认 Medium / Standard(不靠名字猜)
pub(super) fn heuristic_infer(model_id: &str) -> ModelConfig {
let name = model_id.to_lowercase();
let mut modalities: Vec<Modality> = Vec::new();
let mut capabilities: Vec<Capability> = Vec::new();
// 中性默认:不靠模型名猜档位
let cost_tier = CostTier::Medium;
let intelligence = IntelligenceTier::Standard;
// —— Embedding 优先判定(改变模态集合,且通常独占) ——
// 规则:含 embed / embedding / 以 "e-" / "embedding-" 起首
let is_embedding = name.contains("embed")
|| name.contains("embedding")
|| name.starts_with("e-")
|| name.starts_with("embedding-")
|| name.starts_with("text-embedding");
if is_embedding {
capabilities.push(Capability::Embedding);
// embedding 模型不走对话模态,modalities 留空(设计 §4.4 embedding-3 例:modalities=[])
return ModelConfig {
model_id: model_id.to_string(),
enabled: true,
label: None,
modalities,
capabilities,
cost_tier,
intelligence,
weight: 50,
context_window: 8192,
temperature_supported: temperature_supported_by_name(&name),
probe_source: None,
};
}
// —— 对话模型默认 Text 模态 ——
modalities.push(Modality::Text);
// —— Vision 词素:含 "v"(独立词素:glm-4v / qwen-vl)、"vision"、"vl" ——
// 注:裸 "v" 子串误伤大(如 "review"),故仅在边界词素命中:
// 以 "v" 结尾、含 "-v" / "v-" 分隔、含 "vl" / "vision"
if has_vision_token(&name) {
modalities.push(Modality::Vision);
}
// —— CodeGen:含 code / coder ——
if name.contains("code") || name.contains("coder") {
capabilities.push(Capability::CodeGen);
}
// —— 对话模型默认 ToolUse(主流支持 function calling) ——
capabilities.push(Capability::ToolUse);
ModelConfig {
model_id: model_id.to_string(),
enabled: true,
label: None,
modalities,
capabilities,
cost_tier,
intelligence,
weight: 50,
context_window: 8192,
temperature_supported: temperature_supported_by_name(&name),
probe_source: None,
}
}
// —— 词素判定助手(命名约定式启发式,集中维护便于增删) ——
/// Vision 词素:`vision` / `vl` 子串、`v` 词素尾(任一分隔段以 v 结尾:glm-4v / qwen-v-plus)。
/// 裸 "v" 必须是词素尾:`contains("-v")` 会把版本号误判成视觉(deepseek-v3 纯文本,
/// "-v" 后接数字属版本号而非视觉词素),按段尾判定后 v3/v2 不命中、4v/v 段仍命中。
pub(super) fn has_vision_token(name: &str) -> bool {
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
+542
View File
@@ -0,0 +1,542 @@
//! OpenAI 兼容协议适配 — 协议数据结构与 SSE 事件解析(纯函数)。
//!
//! 本模块从 `openai_compat.rs` 抽离,承载与 HTTP 无关的纯协议逻辑:
//! - 请求/响应结构体(`OpenAiRequest` / `OpenAiResponse` / `OpenAiStreamChunk` 等)
//! - SSE 事件 data → `StreamChunk` 转换纯函数(`apply_openai_sse`)
//!
//! Provider struct + impl(含 HTTP 调用 / `convert_request` / `chat_url`)仍留在
//! `openai_compat.rs`,Rust impl 块不可跨文件,故仅搬迁 impl 块外部的类型/纯函数。
//! 零行为变更(纯搬迁)。结构对齐 `anthropic_helpers.rs`。
use serde::{Deserialize, Serialize};
use tracing::{debug, error};
use crate::provider::{tool_call_id_or_fallback, StreamChunk, TokenUsage, ToolCallDelta};
// ============================================================
// OpenAI API 请求/响应结构体
// ============================================================
/// OpenAI 兼容请求体
#[derive(Debug, Clone, Serialize)]
pub(crate) struct OpenAiRequest {
pub model: String,
pub messages: Vec<OpenAiMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
pub stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<serde_json::Value>,
/// 流式时请求末 chunk 携带 usage(OpenAI 官方 + DeepSeek/GLM 兼容)
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_options: Option<serde_json::Value>,
/// DeepSeek thinking 模式
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
}
/// OpenAI 消息格式
///
/// `content` 为 `serde_json::Value`:纯文本消息走字符串简写(与老端点兼容),
/// 含图消息走 `[{type:"text",text},{type:"image_url",image_url:{url}}]` 数组。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct OpenAiMessage {
pub role: String,
pub content: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<serde_json::Value>>,
/// DeepSeek thinking 模式推理内容
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
}
/// OpenAI 同步响应
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiResponse {
pub choices: Vec<OpenAiChoice>,
pub model: String,
pub usage: Option<OpenAiUsage>,
}
#[derive(Debug, Deserialize)]
pub struct OpenAiChoice {
pub(crate) message: OpenAiMessageResp,
/// 调用终止原因。`length` = 输出被 max_tokens 截断(不完整),由 [`FinishReason`]
/// 的 Drop 侧效应打 warn 留痕 — 此前该字段无人读、截断不可见,截断的 JSON 会被
/// 下游当解析失败整批丢弃而无任何日志线索。
#[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)]
pub(crate) struct OpenAiMessageResp {
pub content: Option<String>,
pub tool_calls: Option<Vec<OpenAiToolCallResp>>,
/// DeepSeek thinking 模式响应中的推理内容
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiToolCallResp {
/// 缺省容错:个别兼容端点同步响应省略 id → 缺省空串,由 parse_tool_calls 的
/// tool_call_id_or_fallback 生成唯一 fallback。此前必填,单条 tool_call 缺 id 令
/// 整个响应 serde 失败 → complete 报"响应解析失败"整批丢弃(流式路径早已容错)。
#[serde(default)]
pub id: String,
// 反序列化 #[serde(rename="type")] 字段,保留以对齐 OpenAI 响应结构,标注意图消除 dead_code warning
#[allow(dead_code)]
#[serde(rename = "type", default)]
pub call_type: String,
pub function: OpenAiFunctionResp,
}
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiFunctionResp {
pub name: String,
/// 缺省容错:arguments 缺失 → 空串(对齐 anthropic 同步 tool_use input 缺失 args="" 口径),
/// 防单条缺字段令整个响应解析失败;name 保持必填(无名 tool_call 无法派发,无安全缺省)。
#[serde(default)]
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)]
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 prompt_tokens: u32,
pub completion_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
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiStreamChunk {
/// 流式 choice 数组。标准 error 帧(`{"error":{..}}`)与 usage 尾帧均不带 choices 键,
/// 缺省为空数组才能整帧解析成功;必填会让这类合法帧 serde 失败走空 chunk 分支,
/// 流呈无声截断。
#[serde(default)]
pub choices: Vec<OpenAiStreamChoice>,
/// 末 chunk(choices 为空)携带的累计 usage
#[serde(default)]
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)]
pub(crate) struct OpenAiStreamChoice {
/// 部分 usage 尾帧的 choice 仅带 finish_reason 无 delta 键,缺省按空 delta 处理
#[serde(default)]
pub delta: OpenAiStreamDelta,
pub finish_reason: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
pub(crate) struct OpenAiStreamDelta {
pub content: Option<String>,
pub tool_calls: Option<Vec<OpenAiStreamToolCall>>,
/// DeepSeek thinking 模式流式推理内容
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiStreamToolCall {
/// 部分端点 tool_call 增量省略 index,缺省 0(单工具调用场景语义等价)
#[serde(default)]
pub index: u32,
pub id: Option<String>,
pub function: Option<OpenAiStreamFunction>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiStreamFunction {
pub name: Option<String>,
pub arguments: Option<String>,
}
// ============================================================
// SSE 解析纯函数(与 HTTP 解耦,便于单测)
// ============================================================
/// 将一条 OpenAI 兼容 SSE 事件 data 解析为 StreamChunk,并按需更新 usage 累加器。
///
/// - `[DONE]` → 返回 `finished=true` 的终态 chunk,`usage` 取自累加器(`take()`)。
/// - 普通文本/工具增量 chunk → 返回对应 `StreamChunk`,usage 字段恒为 None(usage 仅在终态带出)。
/// - usage(`stream_options.include_usage` 时末段或 usage-only chunk 携带)→ 覆盖累加器(覆盖语义保对)。
/// - 解析失败 → 返回空 chunk(与原内联实现一致)。
///
/// 等价性:delta / tool_calls / finished / 解析失败等分支与原 stream() 闭包逐字一致;
/// usage 透传([DONE] 终态 take() 带出、usage chunk 覆盖累加器)为本次新增能力,
/// 对应 StreamChunk 新增的 usage 字段 + 请求体新增 stream_options.include_usage。
pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>) -> StreamChunk {
// OpenAI 发送 "data: [DONE]" 表示流结束,带出累积 usage
if data == "[DONE]" {
return StreamChunk {
delta: String::new(),
finished: true,
tool_calls: None,
usage: usage_accum.take(),
error: None,
reasoning_content: None,
truncated_by_max_tokens: false,
};
}
match serde_json::from_str::<OpenAiStreamChunk>(data) {
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 携带,覆盖累积)
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 {
prompt_tokens: u.prompt_tokens,
completion_tokens: u.completion_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() {
let delta_text = choice.delta.content.unwrap_or_default();
// 终态 finish_reason:stop(正常结束)/ tool_calls(转交工具执行)/
// length(max_tokens 截断,属正常终止非断连)/ content_filter(内容审查拦截)。
// content_filter 的流部分 provider 不发 [DONE] 帧,不纳入终态会一直悬挂
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| {
// 缺 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()
.map(|tc| {
// CR-空 id:流式 chunk 的 id 可能为 Some("")(SenseNova 兼容缺陷)。
// 仅对「provider 显式给了 id 字段」的 chunk 做兜底——None(OpenAI
// 协议:仅首 chunk 携带 id,后续 chunk 无 id)保持 None,避免
// 覆盖首 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()
});
StreamChunk {
delta: delta_text,
finished,
tool_calls,
// 加固:usage 与 finish_reason 同帧的端点(部分兼容实现),此处已累积则挂上,
// 使下游 stream_recv 不必等到 [DONE] 帧即可拿到真实 usage。
usage: usage_accum.clone(),
error: None,
reasoning_content: choice.delta.reasoning_content,
truncated_by_max_tokens,
}
} else {
// choices 为空 = usage-only chunk,不输出文本(usage 已累积),usage 一并带出
StreamChunk {
delta: String::new(),
finished: false,
tool_calls: None,
usage: usage_accum.clone(),
error: None,
reasoning_content: None,
truncated_by_max_tokens: false,
}
}
}
Err(e) => {
debug!("SSE 数据解析失败: {} — data: {}", e, data);
StreamChunk {
delta: String::new(),
finished: false,
tool_calls: None,
usage: None,
error: 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 仍执行
}
}
+708
View File
@@ -0,0 +1,708 @@
//! 单对话并行多轮 — Phase 0a · 0.8 plan_hint 纯函数
//!
//! 依据:`docs/02-架构设计/单对话并行多轮-Phase0落地路线图-2026-06-20.md` 第 0.8 节。
//! 上游设计:`单对话并行多轮-设计-2026-06-20.md`。
//!
//! ## 定位
//! Phase 0a 的**纯函数地基**:基于用户意图(`crate::intent::Intent`)/对话上下文关键词,
//! 产出一份「编排提示」(`PlanHint`)——告诉主 loop「这批工具调用可以并行」/「这些有
//! 顺序依赖」。它**扩 `intent::tool_subset_for` 语义**:后者只返扁平 tool 子集,本模块
//! 在其之上叠加「并行组 / 顺序依赖」的编排关系。
//!
//! ## 纯函数边界(零 IO 零状态)
//! - 无 `.await` / 无 db / 无 provider / 无全局状态。
//! - 输入:意图标签 + 上下文关键词(纯字符串切片)。
//! - 输出:`PlanHint`(数据结构 + 启发式算法产物)。
//! - **不调 LLM**:LLM 调度(plan_hint 落 prompt 让模型产 Plan)留 Phase 2;本模块是
//! Phase 1 主 loop 接入前的「确定性降级路径」——主 loop 拿到 PlanHint 直接做编排,
//! 无需等 Phase 2 LLM 介入也能跑通串行/并行调度。
//!
//! ## 与 0.7 planner.rs 的关系
//! 本 agent 独立先做(避免等 agent A),用**自有轻量 `PlanHint` 结构**。
//! - `PlanHint` 是「提示」层(启发式,可错,主 loop 可忽略);
//! - `Plan`(`planner.rs`,agent A 出)是「计划」层(LLM/用户确认后的硬编排)。
//! Phase 1 接入主 loop 时,两者字段(group/dep)对齐,主 loop 优先消费 `Plan`,无 `Plan`
//! 时退 `PlanHint`。结构对齐工作放 Phase 1,本批互不阻塞。
//!
//! ## feature flag
//! `PLAN_HINT_ENABLED` 常量占位。关时单链 ReAct=旧行为(主 loop 不读 PlanHint)。
//! Phase 1 接入时主 loop 据 `PLAN_HINT_ENABLED` 决定是否走 plan_hint 分支。
// ---- feature flag 占位 ------------------------------------------------------
/// plan_hint 总开关。Phase 0a 先占位为 `true`(纯函数无副作用可单测)。
/// Phase 1 接入主 loop 时,关闭则主 loop 不读 PlanHint,退单链 ReAct(旧行为)。
/// 真正的运行时开关(配置驱动)留 Phase 1,本批用编译期常量便于单测固定行为。
pub const PLAN_HINT_ENABLED: bool = true;
// ---- 数据结构 --------------------------------------------------------------
/// 并行组:一组可同时调度的工具名(组内无顺序约束,可并行执行)。
///
/// 工具名对齐 `intent::tool_subset_for` 返回的工具名常量(read_file/write_file/...,
/// 见 `intent::ToolDomain::tools`)。空组无意义(构造时应保证非空,但 validate 兜底)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParallelGroup {
/// 组内工具名列表(均为 `intent` crate 内声明的注册名)。
pub tools: Vec<&'static str>,
}
impl ParallelGroup {
/// 构造一个并行组。空 tools 允许构造(validate 阶段标记非法),调用方应保证非空。
pub fn new(tools: Vec<&'static str>) -> Self {
Self { tools }
}
/// 组内工具数。
pub fn len(&self) -> usize {
self.tools.len()
}
/// 是否为空组。
pub fn is_empty(&self) -> bool {
self.tools.is_empty()
}
}
/// 顺序依赖边:`from` 必须先于 `to` 完成(A → B)。
///
/// 用于表达「读后写」「先查后改」等强顺序约束。`from`/`to` 是工具名
/// (或更细的工具调用标签,Phase 2 LLM 产 Plan 时可携带调用 id;本批仅工具名粒度)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SequentialDep {
/// 前置工具(先执行)。
pub from: &'static str,
/// 后置工具(待 from 完成后执行)。
pub to: &'static str,
}
impl SequentialDep {
/// 构造一条顺序依赖边。
pub fn new(from: &'static str, to: &'static str) -> Self {
Self { from, to }
}
}
/// 编排提示(轻量 PlanHint)。
///
/// - `groups`:可并行执行的分组(每组内工具无序,可同时调度)。
/// - `sequence`:跨组的顺序依赖边(DAG 边)。
///
/// **合法 PlanHint 约束**(与 0.7 Kahn 拓扑对齐):
/// 1. `groups` 内各组 `tools` 非空;
/// 2. `sequence` 不含自环(`from != to`);
/// 3. `sequence` 无重复边(去重);
/// 4. `sequence` 构成的有向图**无环**(DAG)——`validate` 用 Kahn 判定。
///
/// validate 失败时主 loop 应**丢弃** PlanHint 退单链(不能据此调度,否则死锁/无限循环)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PlanHint {
/// 并行组列表。空 `Vec` 表示「无并行提示」(单链或全顺序)。
pub groups: Vec<ParallelGroup>,
/// 顺序依赖边列表。空 `Vec` 表示「无跨组顺序约束」(组间全并行)。
pub sequence: Vec<SequentialDep>,
}
impl PlanHint {
/// 空提示(无并行、无顺序)。主 loop 见到此值应退旧行为。
pub fn empty() -> Self {
Self::default()
}
/// 是否为空提示(无任何编排信息)。
pub fn is_empty(&self) -> bool {
self.groups.is_empty() && self.sequence.is_empty()
}
/// 工具总数(所有并行组工具去重后的并集大小)。
pub fn tool_count(&self) -> usize {
let mut seen = std::collections::HashSet::new();
for g in &self.groups {
for t in &g.tools {
seen.insert(*t);
}
}
seen.len()
}
}
// ---- 校验(Kahn 拓扑判 DAG 无环,对齐 0.7) --------------------------------
/// PlanHint 合法性校验结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HintValidity {
/// 合法,可交付主 loop 调度。
Ok,
/// 非法(附原因)。主 loop 应丢弃 PlanHint 退单链。
Invalid(&'static str),
}
/// 校验 PlanHint 合法性(纯函数)。
///
/// 检查四项(见 `PlanHint` 文档约束):
/// 1. 组内非空;
/// 2. 无自环(`from != to`);
/// 3. 无重复边;
/// 4. **Kahn 拓扑判无环**(与 0.7 `Plan::validate` 同算法,这里在 PlanHint 层独立实现,
/// 避免依赖 agent A 的 planner.rs;Phase 1 接入时若 planner 已稳定可抽公共函数)。
///
/// 返回 `Invalid(reason)` 时 reason 是静态原因标签(便于日志/断言)。
pub fn validate(hint: &PlanHint) -> HintValidity {
// 1. 组内非空
for g in &hint.groups {
if g.tools.is_empty() {
return HintValidity::Invalid("empty_parallel_group");
}
}
// 2. 无自环
for d in &hint.sequence {
if d.from == d.to {
return HintValidity::Invalid("self_loop_dep");
}
}
// 3. 无重复边(同 from→to 不重复)
let mut seen_edges = std::collections::HashSet::new();
for d in &hint.sequence {
if !seen_edges.insert((d.from, d.to)) {
return HintValidity::Invalid("duplicate_dep_edge");
}
}
// 4. Kahn 拓扑判无环
if has_cycle(&hint.sequence) {
return HintValidity::Invalid("cyclic_dep");
}
HintValidity::Ok
}
/// Kahn 算法判有向边集合是否含环(纯函数)。
///
/// 输入是 `SequentialDep` 边列表,节点是工具名(`&'static str`)。
/// 返回 `true` = 有环(非法),`false` = DAG(合法)。
///
/// 实现:统计入度 → 入度 0 的节点入队 → BFS 出队并削减邻居入度 →
/// 最终已出队节点数 < 总节点数 → 存在环。
fn has_cycle(edges: &[SequentialDep]) -> bool {
if edges.is_empty() {
return false;
}
// 收集所有节点(去重)。
let mut nodes: std::collections::HashSet<&'static str> = std::collections::HashSet::new();
for d in edges {
nodes.insert(d.from);
nodes.insert(d.to);
}
let n = nodes.len();
if n == 0 {
return false;
}
// 节点 → 索引(便于用 Vec 计数)。
let mut idx: std::collections::HashMap<&'static str, usize> =
std::collections::HashMap::with_capacity(n);
for (i, name) in nodes.iter().enumerate() {
idx.insert(*name, i);
}
// 入度表 + 邻接表。
let mut indeg = vec![0usize; n];
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
for d in edges {
let u = idx[&d.from];
let v = idx[&d.to];
adj[u].push(v);
indeg[v] += 1;
}
// BFS from 入度 0 节点。
let mut queue: std::collections::VecDeque<usize> = std::collections::VecDeque::new();
for (i, &d) in indeg.iter().enumerate() {
if d == 0 {
queue.push_back(i);
}
}
let mut visited = 0usize;
while let Some(u) = queue.pop_front() {
visited += 1;
for &v in &adj[u] {
indeg[v] -= 1;
if indeg[v] == 0 {
queue.push_back(v);
}
}
}
// 访问到的节点数 < 总节点数 → 存在环(有节点入度永远不为 0)。
visited < n
}
// ---- 启发式产 PlanHint(纯函数,无 LLM) -----------------------------------
/// 输入:意图标签(来自 `intent::recognize`)+ 上下文关键词(原始用户消息)。
///
/// 关键词检测用 `str::contains`(中英文都支持,与 `intent::recognize` 一致风格)。
/// 输出基于**确定性启发式规则**(非 LLM),规则覆盖 devflow 常见编排场景:
///
/// | 场景关键词 | 产出 |
/// |---|---|
/// | 读多个 + 写后改 | [读组 并行] → write_file/patch_file |
/// | grep/find 后改 | search_files → write_file/patch_file |
/// | http + 写文件 | http_request → write_file(先取再存) |
/// | 仅读多文件 | 全并行单组 |
/// | 单工具 / 无编排信号 | 空 PlanHint(主 loop 退单链) |
///
/// **重要**:这是降级路径(Phase 2 前)。LLM 产 Plan 的质量一定高于本启发式,
/// 但本函数保证 Phase 1 主 loop 接入后**无 LLM 也能跑通基本编排**(读后写/grep 后改)。
///
/// `intent_label` 仅为日志/未来扩展预留(当前规则用关键词驱动,intent_label 不参与判定,
/// 保留参数对齐 Phase 1 主 loop 调用签名:`plan_hint(&intent, &message)`)。
pub fn plan_hint(intent_label: &str, context_keywords: &str) -> PlanHint {
// 开关关闭:直接返空(主 loop 退单链)。Phase 1 接入时主 loop 据常量短路,这里也兜底。
if !PLAN_HINT_ENABLED {
return PlanHint::empty();
}
let _ = intent_label; // 预留:当前规则纯关键词驱动,不读 intent。
let ctx = context_keywords;
let lower = ctx.to_lowercase();
// ---- 规则 1:grep/搜索 后改 → search_files → write_file/patch_file ----
let is_search = lower.contains("grep")
|| lower.contains("搜索")
|| lower.contains("查找")
|| lower.contains("find ");
let is_modify = lower.contains("修改")
|| lower.contains("改")
|| lower.contains("write")
|| lower.contains("patch")
|| lower.contains("写入")
|| lower.contains("重命名")
|| lower.contains("rename");
if is_search && is_modify {
return PlanHint {
groups: vec![
ParallelGroup::new(vec!["search_files"]),
ParallelGroup::new(vec!["write_file", "patch_file", "rename_file"]),
],
sequence: vec![SequentialDep::new("search_files", "write_file")],
};
}
// ---- 规则 2:http 取数 + 写文件 → http_request → write_file ----
let is_http = lower.contains("http")
|| lower.contains("api")
|| lower.contains("请求")
|| lower.contains("fetch");
if is_http && (lower.contains("保存") || lower.contains("写入") || lower.contains("write")) {
return PlanHint {
groups: vec![
ParallelGroup::new(vec!["http_request"]),
ParallelGroup::new(vec!["write_file"]),
],
sequence: vec![SequentialDep::new("http_request", "write_file")],
};
}
// ---- 规则 3:读多文件(无写) → 全并行单组 ----
let read_count = [
("read_file", lower.matches("read").count() + lower.matches("读取").count()),
("list_directory", lower.matches("目录").count() + lower.matches("directory").count()),
]
.iter()
.filter(|(_, c)| *c >= 1)
.count();
if read_count >= 2 && !is_modify {
// 多种读工具信号 → 单并行组(可同时调度)
let mut tools: Vec<&'static str> = Vec::new();
if lower.contains("read") || lower.contains("读取") {
tools.push("read_file");
}
if lower.contains("目录") || lower.contains("directory") {
tools.push("list_directory");
}
if tools.len() >= 2 {
return PlanHint {
groups: vec![ParallelGroup::new(tools)],
sequence: vec![],
};
}
}
// ---- 规则 4:读后写 → read_file → write_file/patch_file ----
let is_read = lower.contains("read") || lower.contains("读取");
if is_read && is_modify {
return PlanHint {
groups: vec![
ParallelGroup::new(vec!["read_file"]),
ParallelGroup::new(vec!["write_file", "patch_file"]),
],
sequence: vec![SequentialDep::new("read_file", "write_file")],
};
}
// ---- 兜底:无编排信号 → 空 PlanHint(主 loop 退单链) ----
PlanHint::empty()
}
// ---- 单测 -------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// --- feature flag 占位 ---
#[test]
fn plan_hint_enabled_is_true_in_phase0a() {
// Phase 0a 占位为 true,纯函数可单测。Phase 1 接入后改配置驱动。
assert!(PLAN_HINT_ENABLED, "Phase 0a 占位应为 true");
}
// --- 数据结构基本 ---
#[test]
fn parallel_group_new_and_len() {
let g = ParallelGroup::new(vec!["read_file", "write_file"]);
assert_eq!(g.len(), 2);
assert!(!g.is_empty());
}
#[test]
fn parallel_group_empty_construct_allowed_but_invalid() {
// 空 tools 允许构造(调用方失误),validate 阶段标记非法
let g = ParallelGroup::new(vec![]);
assert!(g.is_empty());
assert_eq!(g.len(), 0);
}
#[test]
fn sequential_dep_new() {
let d = SequentialDep::new("read_file", "write_file");
assert_eq!(d.from, "read_file");
assert_eq!(d.to, "write_file");
}
#[test]
fn plan_hint_empty_is_empty() {
let h = PlanHint::empty();
assert!(h.is_empty());
assert_eq!(h.tool_count(), 0);
}
#[test]
fn plan_hint_tool_count_dedup_across_groups() {
// 两组含同名工具 → tool_count 去重
let h = PlanHint {
groups: vec![
ParallelGroup::new(vec!["read_file", "write_file"]),
ParallelGroup::new(vec!["read_file", "patch_file"]),
],
sequence: vec![],
};
// read_file/write_file/patch_file = 3(去重)
assert_eq!(h.tool_count(), 3);
}
// --- plan_hint 基本编排提示 ---
#[test]
fn hint_read_then_modify_yields_sequence() {
// 规则 4:读后写 → read_file → write_file
let h = plan_hint("code", "先 read 这个文件再修改它");
assert!(!h.is_empty(), "读后写应产非空 hint");
assert_eq!(h.groups.len(), 2, "应分两组(读组/改组)");
assert!(h.sequence.iter().any(|d| d.from == "read_file" && d.to == "write_file"),
"应含 read_file→write_file 依赖边");
}
#[test]
fn hint_grep_then_modify_yields_search_first() {
// 规则 1:grep 后改 → search_files → write_file
let h = plan_hint("search", "grep 一下再修改 write_file");
assert!(!h.is_empty());
// 首组应是 search_files
assert!(h.groups[0].tools.contains(&"search_files"));
assert!(h.sequence.iter().any(|d| d.from == "search_files"),
"search_files 应作为顺序依赖起点");
}
#[test]
fn hint_http_then_save_yields_http_first() {
// 规则 2:http + 保存 → http_request → write_file
let h = plan_hint("http", "请求这个 api 并保存写入到文件");
assert!(!h.is_empty());
assert!(h.sequence.iter().any(|d| d.from == "http_request" && d.to == "write_file"));
}
#[test]
fn hint_multi_read_only_yields_single_parallel_group() {
// 规则 3:读多文件(无写)→ 单并行组
let h = plan_hint("file", "read 文件,再看一下 directory 目录");
assert!(!h.is_empty());
assert_eq!(h.groups.len(), 1, "应单并行组");
let tools = &h.groups[0].tools;
assert!(tools.contains(&"read_file"), "组内应含 read_file");
assert!(tools.contains(&"list_directory"), "组内应含 list_directory");
assert!(h.sequence.is_empty(), "全并行无顺序依赖");
}
// --- 并行组识别 ---
#[test]
fn parallel_group_tools_are_registry_names() {
// 对抗:产出的工具名必须在 intent ToolDomain 注册表内(防笔误)。
// 注:intent::ToolDomain::tools() 是 private 方法(同模块 intent.rs 内单测可用,
// 跨模块不可访问)。本测用从 intent.rs:246-284 抄录的 registry 名常量做校验,
// 不依赖私有方法访问(保持 plan_hint.rs 独立可单测)。若 registry 名漂移,
// intent.rs 的 tool_domain_names_match_registry 测会先抓到,本测同步对齐。
let registry: std::collections::HashSet<&str> = [
// Data domain
"list_projects", "create_project", "update_project", "delete_project",
"bind_directory", "list_tasks", "create_task", "update_task", "advance_task",
"delete_task", "list_ideas", "create_idea", "run_workflow", "list_trash",
"restore_project", "purge_project", "get_project_count", "get_task_count",
// File domain
"read_file", "write_file", "patch_file", "list_directory", "search_files",
"file_info", "append_file", "delete_file", "rename_file",
// Exec domain
"run_command",
// Http domain
"http_request",
]
.iter()
.copied()
.collect();
let h = plan_hint("file", "read 再读取目录 directory");
if !h.is_empty() {
for g in &h.groups {
for t in &g.tools {
assert!(registry.contains(*t), "工具名 {} 不在 registry", t);
}
}
}
}
#[test]
fn hint_groups_tools_non_empty_when_non_empty_hint() {
// 非空 hint 的每组都应非空(由 plan_hint 产出保证,validate 兜底)
let h = plan_hint("code", "read 后 write_file 修改");
if !h.is_empty() {
for g in &h.groups {
assert!(!g.tools.is_empty(), "产出组的 tools 不应空");
}
}
}
// --- 顺序依赖 ---
#[test]
fn hint_sequence_edges_from_less_than_to() {
// 顺序依赖边 from/to 都是合法工具名(from 先于 to)
let h = plan_hint("code", "read 后修改 write_file");
for d in &h.sequence {
assert_ne!(d.from, d.to, "依赖边不应自环");
assert!(!d.from.is_empty());
assert!(!d.to.is_empty());
}
}
#[test]
fn hint_no_duplicate_sequence_edge() {
// 产出的依赖边不应重复
let h = plan_hint("code", "read 后 write_file 修改");
let mut seen = std::collections::HashSet::new();
for d in &h.sequence {
assert!(seen.insert((d.from, d.to)), "重复依赖边: {}→{}", d.from, d.to);
}
}
// --- 空 hint ---
#[test]
fn hint_empty_for_plain_chat() {
// 无编排信号 → 空 PlanHint
let h = plan_hint("chat", "你好谢谢");
assert!(h.is_empty(), "闲聊应产空 hint, 退单链");
}
#[test]
fn hint_empty_for_unknown_intent_no_signal() {
let h = plan_hint("unknown", "今天的天气不错");
assert!(h.is_empty());
}
#[test]
fn hint_empty_when_flag_off() {
// 开关关闭 → 恒空(主 loop 退单链)。即使有强编排信号也忽略。
// 注:PLAN_HINT_ENABLED 是编译期常量,本测在 true 时验证 plan_hint 内部
// 短路逻辑(若常量改 false,此测会失败提醒对齐)。
// 用单工具避免命中多工具规则。
let h = plan_hint("file", "read");
// 单 read(不命中规则 3 需 ≥2 读工具,不命中规则 4 需 modify)→ 空
assert!(h.is_empty(), "单工具无编排信号应空 hint");
}
// --- validate 合法性 ---
#[test]
fn validate_empty_hint_is_ok() {
let h = PlanHint::empty();
assert_eq!(validate(&h), HintValidity::Ok);
}
#[test]
fn validate_normal_dag_is_ok() {
// read_file → write_file(单边 DAG)合法
let h = PlanHint {
groups: vec![
ParallelGroup::new(vec!["read_file"]),
ParallelGroup::new(vec!["write_file"]),
],
sequence: vec![SequentialDep::new("read_file", "write_file")],
};
assert_eq!(validate(&h), HintValidity::Ok);
}
#[test]
fn validate_empty_parallel_group_is_invalid() {
let h = PlanHint {
groups: vec![ParallelGroup::new(vec![])],
sequence: vec![],
};
assert_eq!(validate(&h), HintValidity::Invalid("empty_parallel_group"));
}
#[test]
fn validate_self_loop_dep_is_invalid() {
let h = PlanHint {
groups: vec![
ParallelGroup::new(vec!["read_file"]),
ParallelGroup::new(vec!["write_file"]),
],
sequence: vec![SequentialDep::new("read_file", "read_file")],
};
assert_eq!(validate(&h), HintValidity::Invalid("self_loop_dep"));
}
#[test]
fn validate_duplicate_edge_is_invalid() {
let h = PlanHint {
groups: vec![
ParallelGroup::new(vec!["read_file"]),
ParallelGroup::new(vec!["write_file"]),
],
sequence: vec![
SequentialDep::new("read_file", "write_file"),
SequentialDep::new("read_file", "write_file"),
],
};
assert_eq!(validate(&h), HintValidity::Invalid("duplicate_dep_edge"));
}
// --- Kahn 环检测 ---
#[test]
fn has_cycle_empty_edges_false() {
assert!(!has_cycle(&[]));
}
#[test]
fn has_cycle_single_edge_no_cycle() {
let edges = vec![SequentialDep::new("a", "b")];
assert!(!has_cycle(&edges));
}
#[test]
fn has_cycle_two_node_cycle_detected() {
// a→b→a 环
let edges = vec![
SequentialDep::new("a", "b"),
SequentialDep::new("b", "a"),
];
assert!(has_cycle(&edges), "a↔b 环应被检测");
}
#[test]
fn has_cycle_three_node_cycle_detected() {
// a→b→c→a 环
let edges = vec![
SequentialDep::new("a", "b"),
SequentialDep::new("b", "c"),
SequentialDep::new("c", "a"),
];
assert!(has_cycle(&edges));
}
#[test]
fn has_cycle_dag_with_branch_no_cycle() {
// a→b, a→c, b→d, c→d(菱形 DAG,无环)
let edges = vec![
SequentialDep::new("a", "b"),
SequentialDep::new("a", "c"),
SequentialDep::new("b", "d"),
SequentialDep::new("c", "d"),
];
assert!(!has_cycle(&edges));
}
#[test]
fn validate_cyclic_dep_is_invalid() {
let h = PlanHint {
groups: vec![
ParallelGroup::new(vec!["read_file"]),
ParallelGroup::new(vec!["write_file"]),
],
sequence: vec![
SequentialDep::new("read_file", "write_file"),
SequentialDep::new("write_file", "read_file"),
],
};
assert_eq!(validate(&h), HintValidity::Invalid("cyclic_dep"));
}
// --- 对抗/边界 ---
#[test]
fn validate_check_order_self_loop_before_cycle() {
// 自环也构成环,但 validate 应先报 self_loop_dep(更具体的错误)
let h = PlanHint {
groups: vec![ParallelGroup::new(vec!["a"])],
sequence: vec![SequentialDep::new("a", "a")],
};
assert_eq!(validate(&h), HintValidity::Invalid("self_loop_dep"));
}
#[test]
fn hint_intent_label_ignored_in_phase0a() {
// 当前规则纯关键词驱动,intent_label 不参与判定(预留 Phase 1)
// 同关键词不同 intent_label → 同产出
let h1 = plan_hint("code", "read 后修改 write_file");
let h2 = plan_hint("debug", "read 后修改 write_file");
assert_eq!(h1, h2, "Phase 0a 不读 intent_label, 产出应一致");
}
#[test]
fn hint_single_tool_no_signal_is_empty() {
// 单 read(无写无多读)→ 不命中任何规则 → 空
let h = plan_hint("file", "读取文件");
assert!(h.is_empty());
}
#[test]
fn validate_ignores_nodes_not_in_groups() {
// validate 不检查 sequence 边引用的工具是否在 groups 内(Phase 0a 宽松,
// 主 loop 自行对齐;Phase 1 接入 planner.rs 后由 Plan::validate 收口)
let h = PlanHint {
groups: vec![ParallelGroup::new(vec!["read_file"])],
sequence: vec![SequentialDep::new("read_file", "write_file")],
};
// write_file 不在任何 group,但边合法且 DAG,validate 仍 Ok(宽松)
assert_eq!(validate(&h), HintValidity::Ok);
}
}
File diff suppressed because it is too large Load Diff
+13 -205
View File
@@ -1,207 +1,15 @@
//! LLM Provider trait — 统一的 LLM 调用抽象
//! LLM Provider trait + 数据结构 re-export
//!
//! 支持 OpenAI 兼容 API(覆盖 OpenAI / GLM / DeepSeek / Claude 兼容模式),
//! 含 function calling / tool use 能力。
//! trait 与数据结构定义已下沉到 df-ai-core crate(零 HTTP 依赖,轻消费方
//! 如 df-ideas 可直接依赖 trait 不引入 reqwest/eventsource-stream)。
//! 本文件保留为 df-ai 的 re-export 入口,使 `df_ai::provider::*` 路径不变
//! —— df-ai 内部模块(openai_compat / anthropic_compat / context / ai_tools)
//! 与外部消费方(df-nodes / src-tauri)的 `use df_ai::provider::LlmProvider`
//! 等引用全部透明继续可用(编译期验证)。
//!
//! 留在 df-ai 的部分:
//! - `OpenAICompatProvider` / `AnthropicCompatProvider`(HTTP impl,见 openai_compat.rs / anthropic_compat.rs)
//! - `build_provider()` 工厂(见 lib.rs,按协议选 impl)
//! - `ContextManager` / `AiToolRegistry` / `StreamCollector`(业务逻辑)
use std::pin::Pin;
use async_trait::async_trait;
use futures::Stream;
use serde::{Deserialize, Serialize};
// ============================================================
// 核心数据结构
// ============================================================
/// LLM 调用请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompletionRequest {
/// 模型名称
pub model: String,
/// 提示消息列表
pub messages: Vec<ChatMessage>,
/// 温度(0.0 ~ 2.0)
pub temperature: Option<f32>,
/// 最大生成 token 数
pub max_tokens: Option<u32>,
/// 是否流式输出
pub stream: bool,
/// 可调用的工具定义
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<ToolDefinition>>,
/// 工具调用策略: "auto" | "none" | {"type":"function","name":"xxx"}
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<serde_json::Value>,
}
/// 聊天消息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: MessageRole,
pub content: String,
/// 工具调用 ID(role=Tool 时必填)
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
/// AI 发起的工具调用列表(role=Assistant 时可能有)
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
}
impl ChatMessage {
pub fn system(content: impl Into<String>) -> Self {
Self { role: MessageRole::System, content: content.into(), tool_call_id: None, tool_calls: None }
}
pub fn user(content: impl Into<String>) -> Self {
Self { role: MessageRole::User, content: content.into(), tool_call_id: None, tool_calls: None }
}
pub fn assistant(content: impl Into<String>) -> Self {
Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: None }
}
pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: Some(tool_calls) }
}
pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self {
Self { role: MessageRole::Tool, content: content.into(), tool_call_id: Some(call_id.into()), tool_calls: None }
}
}
/// 消息角色
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MessageRole {
System,
User,
Assistant,
Tool,
}
/// 工具定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDefinition {
#[serde(rename = "type")]
pub tool_type: String,
pub function: ToolFunction,
}
impl ToolDefinition {
pub fn function(name: impl Into<String>, description: impl Into<String>, parameters: serde_json::Value) -> Self {
Self {
tool_type: "function".into(),
function: ToolFunction { name: name.into(), description: description.into(), parameters },
}
}
}
/// 函数定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolFunction {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
}
/// 工具调用(AI 发起)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
#[serde(rename = "type")]
pub call_type: String,
pub function: ToolCallFunction,
}
impl ToolCall {
pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: impl Into<String>) -> Self {
Self {
id: id.into(),
call_type: "function".into(),
function: ToolCallFunction { name: name.into(), arguments: arguments.into() },
}
}
}
/// 工具调用函数部分
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallFunction {
pub name: String,
pub arguments: String,
}
/// LLM 调用响应
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompletionResponse {
/// 生成的文本
pub text: String,
/// 使用的模型
pub model: String,
/// 消耗的 token 数
pub usage: TokenUsage,
/// AI 发起的工具调用(如有)
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
}
/// Token 用量
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenUsage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
}
/// Provider 支持的特性标志
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProviderFeatures {
pub streaming: bool,
pub function_calling: bool,
pub vision: bool,
}
/// 流式输出的 chunk
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamChunk {
/// 增量文本
pub delta: String,
/// 是否结束
pub finished: bool,
/// 工具调用增量(如有)
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCallDelta>>,
}
/// 工具调用增量(流式中的片段)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallDelta {
/// 索引
pub index: u32,
/// 工具调用 ID(仅第一个 chunk 有)
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
/// 函数名片段
#[serde(skip_serializing_if = "Option::is_none")]
pub function_name: Option<String>,
/// 函数参数片段
#[serde(skip_serializing_if = "Option::is_none")]
pub function_arguments: Option<String>,
}
/// 异步流类型别名
pub type StreamResult = Pin<Box<dyn Stream<Item = anyhow::Result<StreamChunk>> + Send>>;
/// LLM Provider trait
#[async_trait]
pub trait LlmProvider: Send + Sync {
/// 同步调用
async fn complete(&self, request: CompletionRequest) -> anyhow::Result<CompletionResponse>;
/// 流式调用(返回异步流)
async fn stream(
&self,
request: CompletionRequest,
) -> anyhow::Result<StreamResult>;
/// Provider 名称
fn name(&self) -> &str;
/// 支持的特性
fn supported_features(&self) -> ProviderFeatures;
}
pub use df_ai_core::provider::*;
+354
View File
@@ -0,0 +1,354 @@
//! complete() 指数退避重试 — 仅非流式同步路径,不动 stream()
//!
//! 背景: complete() 是 LLM 一次性请求,网络波动/瞬态 5xx/429 会让整轮对话静默失败。
//! 各调用方(title.rs/knowledge_inject.rs/project.rs/ai_node.rs)虽已有人工兜底降级,
//! 但重试能在不侵入上层的前提下吸收瞬态错误,对低频后台调用零风险。
//!
//! 策略:
//! - 最多 3 次尝试(初次 + 2 次重试),指数退避 1s → 2s → 4s + ±50% jitter
//! - 总挂钟预算 30s(含 sleep + 各次请求耗时),超预算直接放弃重试返回最后错误
//! - 仅对可重试错误重试: timeout / connect / 5xx / 429; 不重试 4xx(非429) / 参数错 / 鉴权错
//! - 不引入 reqwest-retry / tower: complete 路径简单,手写 loop 零依赖、可调试、不与 SSE/双层 Semaphore 耦合
//!
//! 流式 stream() 路径不进入此模块(流式长生成不能整体 timeout,重试会丢增量)。
//!
//! 重试期间不释放 Semaphore permit(已在调用方持有),对并发池有挤占 —— 但 complete 调用低频可接受。
use std::future::Future;
use std::time::Duration;
use rand::Rng;
use tracing::warn;
/// 最多尝试次数(含初次)。3 次 = 初次 + 2 次重试。
///
/// 配置化 TODO: 未来接入 per-provider config(`AiProviderRecord.config` JSON)或全局开关
/// (`useSetting('df-ai-max-retries')`)时改为读取配置。当前低频后台调用,常量足够。
pub const MAX_COMPLETE_ATTEMPTS: u32 = 3;
/// 退避基准(秒): 第 n 次重试前 sleep `BASE_BACKOFF_SECS * 2^(n-1)`。
/// 即 1s → 2s → 4s。
const BASE_BACKOFF_SECS: u64 = 1;
/// 整体重试挂钟预算(含 sleep 与各次请求耗时)。超过即放弃,返回最后错误。
/// 与单请求 60s timeout 配合: 最坏 3 次 × 60s = 180s,30s 预算会在此之前主动止损。
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 的 ±比例)。避免重试风暴对齐。
/// CR-XX: 范围扩到 ±50%(由 gen_range(-0.5..0.5) × JITTER_RATIO=1.0 合成)。
const JITTER_RATIO: f64 = 1.0;
/// 一次尝试的分类结果 —— 在 anyhow 不透明化前决定是否值得重试。
///
/// 暴露给 agentic.rs 流前重试复用(Init 失败分类 Fatal/Retryable 决定是否重试)。
#[derive(Debug)]
pub enum AttemptOutcome<T> {
/// 成功,携带结果。
Ok(T),
/// 可重试错误(timeout / connect / 5xx / 429)。携带人类可读错误描述。
Retryable(String),
/// 不可重试错误(4xx 非 429 / 鉴权 / 参数错 / 解析错)。携带描述,直接放弃。
Fatal(String),
}
/// 判定 reqwest::Error 是否可重试(仅 timeout / connect 类)。
///
/// body 解析错(reqwest::Error::is_decode)与请求构造错(is_builder)属 Fatal,不重试。
pub fn is_reqwest_error_retryable(e: &reqwest::Error) -> bool {
e.is_timeout() || e.is_connect() || (e.is_request() && !e.is_builder())
}
/// 判定 HTTP 状态码是否可重试: 5xx 与 429 重试,其余(含 4xx)不重试。
///
/// 暴露给 agentic.rs 流前重试复用(4xx Fatal 立即放弃,5xx/429 重试)。
pub fn is_status_retryable(status: u16) -> bool {
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 的时长。
/// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s,各 ±50% jitter。
///
/// jitter 用 `rand::thread_rng().gen_range(-0.5..0.5)` 生成 ±50% 比例,避免多客户端同步重试风暴。
/// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。
/// 全输入安全: attempt 任意 u32(含 0 / >64)不 panic,结果 ≤ [`MAX_BACKOFF_MS`]。
///
/// 暴露 pub 供 src-tauri/agentic.rs 流前重试复用
/// ("复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。
pub fn backoff_delay(attempt: u32) -> Duration {
let base_ms = base_backoff_ms(attempt) as f64;
// ±50% jitter,相对 base 时长的浮动比例
let jitter_ratio = rand::thread_rng().gen_range(-0.5..0.5); // [-0.5, 0.5)
let factor = 1.0 + jitter_ratio * JITTER_RATIO;
let ms = (base_ms * factor).clamp(0.0, MAX_BACKOFF_MS as f64) as u64;
Duration::from_millis(ms)
}
/// 对一个返回 `AttemptOutcome` 的异步操作做指数退避重试。
///
/// `attempt_fn` 每次被调用执行一次尝试并返回分类结果。
/// 达到 `MAX_COMPLETE_ATTEMPTS` 或超出 `MAX_TOTAL_BUDGET` 或遇 Fatal 即终止。
/// 终止时若仍失败,返回最后一次的 Retryable/Fatal 描述作为 `anyhow::Error`。
///
/// 调用方约定: `attempt_fn` 内部应保留完整请求重发能力(每次重建 RequestBuilder),
/// 因为 reqwest::RequestBuilder 一次 `.send()` 后不可复用。
///
/// 仍 pub(crate)(complete() 内部用),流前重试 stream_recv/agentic 不走此函数
/// (流式 request 不可整体 retry_with_backoff 包裹,需在 agentic loop 内手写循环复用
/// backoff_delay + is_status_retryable)。
pub(crate) async fn retry_with_backoff<T, F, Fut>(
label: &str,
attempt_fn: F,
) -> anyhow::Result<T>
where
F: Fn(u32) -> Fut,
Fut: Future<Output = AttemptOutcome<T>>,
{
let deadline = tokio::time::Instant::now() + MAX_TOTAL_BUDGET;
let mut last_err = String::from("未尝试");
for attempt in 1..=MAX_COMPLETE_ATTEMPTS {
match attempt_fn(attempt).await {
AttemptOutcome::Ok(v) => return Ok(v),
AttemptOutcome::Fatal(msg) => {
// 不可重试: 立即放弃,不浪费预算
return Err(anyhow::anyhow!("{}", msg));
}
AttemptOutcome::Retryable(msg) => {
last_err = msg;
let is_last = attempt == MAX_COMPLETE_ATTEMPTS;
if is_last {
warn!(
label = label,
attempt = attempt,
"重试已达上限({}次),放弃", MAX_COMPLETE_ATTEMPTS
);
break;
}
let now = tokio::time::Instant::now();
if now >= deadline {
warn!(
label = label,
attempt = attempt,
"重试预算({:?})已耗尽,放弃", MAX_TOTAL_BUDGET
);
break;
}
let delay = backoff_delay(attempt).min(deadline.saturating_duration_since(now));
warn!(
label = label,
attempt = attempt,
delay_ms = delay.as_millis() as u64,
err = %last_err,
"可重试错误,退避后重试"
);
tokio::time::sleep(delay).await;
}
}
}
Err(anyhow::anyhow!("{} 重试耗尽: {}", label, last_err))
}
// ============================================================
// 单测(纯逻辑,不发 HTTP)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
#[test]
fn backoff_is_monotonic_within_jitter() {
// base 序列: 1, 2, 4 秒; jitter ±50%(JITTER_RATIO=1.0) 后仍在 [0.5x, 1.5x] 范围
for attempt in 1..=3 {
let d = backoff_delay(attempt);
let base_ms = base_backoff_ms(attempt);
let lo = (base_ms as f64 * (1.0 - JITTER_RATIO / 2.0)) as u64;
let hi = (base_ms as f64 * (1.0 + JITTER_RATIO / 2.0)) as u64;
assert!(
d.as_millis() as u64 >= lo && d.as_millis() as u64 <= hi,
"attempt={} delay={:?} 应在 [{},{}]ms",
attempt,
d,
lo,
hi
);
}
}
/// 倍率溢出安全: 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]
fn status_classification() {
assert!(is_status_retryable(429));
assert!(is_status_retryable(500));
assert!(is_status_retryable(503));
assert!(!is_status_retryable(400));
assert!(!is_status_retryable(401));
assert!(!is_status_retryable(403));
assert!(!is_status_retryable(404));
}
/// 初次成功 → 不重试
#[tokio::test]
async fn first_attempt_ok() {
let calls = Arc::new(AtomicU32::new(0));
let calls_clone = calls.clone();
let r = retry_with_backoff("test", move |_| {
let c = calls_clone.clone();
async move {
c.fetch_add(1, Ordering::SeqCst);
AttemptOutcome::Ok(42u32)
}
})
.await
.unwrap();
assert_eq!(r, 42);
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
/// 第二次成功 → 恰好 2 次调用
#[tokio::test]
async fn retry_once_then_ok() {
let calls = Arc::new(AtomicU32::new(0));
let calls_clone = calls.clone();
let r = retry_with_backoff("test", move |_| {
let c = calls_clone.clone();
async move {
let n = c.fetch_add(1, Ordering::SeqCst) + 1;
if n == 1 {
AttemptOutcome::Retryable("timeout".into())
} else {
AttemptOutcome::Ok(7u32)
}
}
})
.await
.unwrap();
assert_eq!(r, 7);
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
/// 全部 Retryable → 用尽 3 次,返回错误
#[tokio::test]
async fn all_retryable_exhausts() {
let calls = Arc::new(AtomicU32::new(0));
let calls_clone = calls.clone();
let r = retry_with_backoff("test", move |_| {
let c = calls_clone.clone();
async move {
c.fetch_add(1, Ordering::SeqCst);
AttemptOutcome::<u32>::Retryable("503".into())
}
})
.await;
assert!(r.is_err());
assert!(r.unwrap_err().to_string().contains("重试耗尽"));
assert_eq!(calls.load(Ordering::SeqCst), MAX_COMPLETE_ATTEMPTS);
}
/// Fatal 立即放弃,不重试
#[tokio::test]
async fn fatal_aborts_immediately() {
let calls = Arc::new(AtomicU32::new(0));
let calls_clone = calls.clone();
let r = retry_with_backoff("test", move |_| {
let c = calls_clone.clone();
async move {
c.fetch_add(1, Ordering::SeqCst);
AttemptOutcome::<u32>::Fatal("401".into())
}
})
.await;
assert!(r.is_err());
assert!(r.unwrap_err().to_string().contains("401"));
assert_eq!(calls.load(Ordering::SeqCst), 1, "Fatal 不应触发重试");
}
}
+561 -37
View File
@@ -1,51 +1,575 @@
//! 模型路由 — 根据任务类型选择最优模型
//! 模型路由器。
//!
//! 不接调用点(那是调用方:agentic.rs / title.rs / knowledge_inject.rs / project.rs /
//! df-ideas / df-nodes ai_node.rs)。
//!
//! ModelRouter 为单元结构,select 是无状态关联函数。
use serde::{Deserialize, Serialize};
// 调用点经 `df_ai::router::{Modality, Capability, CostTier, IntelligenceTier}`
// 直接 import 维度枚举构造 TaskRequirements,re-export 避免调用点
// 各自从 df_ai_core::model 取(跨 crate 路径冗长)。select/select_model_id 仅借用枚举,无重定义。
// 注:CostTier/IntelligenceTier 路由已解耦——provider /v1/models API
// 不返回这两维度,数据无客观依据不可信,不参与硬路由;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};
/// 任务类型
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskType {
/// 代码生成
CodeGeneration,
/// 代码审查
CodeReview,
/// 文档生成
Documentation,
/// 分析推理
Analysis,
/// 摘要总结
Summarization,
/// 通用对话
Chat,
/// 任务对模型的需求(4 维度)。
///
/// 由调用点构造,描述本次调用需要什么模态/能力/上下文/档位,
/// 交 ModelRouter::select 在候选池中选最优模型。
///
/// 路由已解耦:原 `min_intelligence`/`max_cost` 两字段删除。
/// provider /v1/models API 不返回 cost_tier/intelligence,这两维度 100% 靠预设表写死 +
/// 模型名启发式猜,数据无客观依据不可信,不应参与硬路由。枚举(CostTier/IntelligenceTier)
/// 保留供未来出现真实判别源时再接回。
///
/// `tier`(子项 2 根因修复):任务建议的模型档位(由 `intent::suggested_model_tier` 派生,
/// 或无意图场景传 None)。原 `max_by_key(weight)` 同 weight 返最后一个,顺序敏感无语义;
/// 接 tier 后,同 weight 时优先选 `intelligence` 满足 tier 下限的候选(见 `tier_match`)。
#[derive(Debug, Clone)]
pub struct TaskRequirements {
/// 任务所需的模态集合(全子集匹配:任务所需模态都必须在模型模态里)
pub modalities: Vec<Modality>,
/// 是否需要工具调用能力(needs_tool_use=true 时候选必须含 Capability::ToolUse)
pub needs_tool_use: bool,
/// 预估上下文大小(tokens,模型 context_window 必须 >= 此值)。
/// 调用点应传 TokenEstimator 估值而非 0(0 = 当前空操作,窗口过滤维度失效)。
pub estimated_context: usize,
/// 任务建议的模型档位(意图→ModelTier,无意图场景 None)。
/// 同 weight 候选间按 tier tiebreak(满足 tier 下限的候选胜)。
pub tier: Option<ModelTier>,
}
/// 模型路由器
/// 模型路由器(单元结构,无状态)。
///
/// 根据任务类型、成本、延迟等选择最优模型
pub struct ModelRouter {
/// 默认模型
default_model: String,
/// select 为关联函数:给定需求 + 候选池,执行过滤链选最优模型。
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 {
/// 创建路由器
pub fn new(default_model: &str) -> Self {
Self {
default_model: default_model.to_string(),
/// 在候选池中选出最优模型(过滤链)。
///
/// 步骤:
/// 1. enabled — 只选启用的
/// 2. 模态匹配 — 任务所需模态全在模型模态里
/// 3. 能力匹配 — needs_tool_use 时候选必须含 ToolUse
/// 4. 窗口够大 — context_window >= estimated_context
/// 5. max_by 选最优:**主键 weight 降序**(权重高者胜),**同 weight 时按 tier tiebreak**
/// (满足任务建议档位 `intelligence >= tier_min` 的候选胜)。
///
/// tier tiebreak(子项 2 根因修复):原 `max_by_key(weight)` 同 weight 返最后一个,
/// 顺序敏感无语义(intent suggested_model_tier 恒 None)→ 现接 `req.tier`
/// (由 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> {
pool.iter()
.filter(|m| m.enabled) // 1. 只选启用的
.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| m.context_window >= req.estimated_context) // 4. 窗口够大
// 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)
})
}
}
/// 调用点 helper — 路由选模型并直接返回 model_id(纯函数)。
///
/// 给定 TaskRequirements + 候选池,返回最优模型的 `model_id`。
/// 调用点用法:`provider.model_configs`(Vec<ModelConfig>)→ `select_model_id(&req, &pool)`
/// → `Option<String>`;None 时兜底 `provider.default_model`(行为不变,平滑过渡)。
///
/// 行为不变保证:
/// - 池空(用户未通过 Settings 拉取模型)→ 返回 None → 调用点兜底 default_model
/// - 池非空但无候选满足需求 → 返回 None → 兜底 default_model
/// - 池非空命中 → 返回 model_id(F-01 路由目标,拉取即启用路由)
pub fn select_model_id(req: &TaskRequirements, pool: &[ModelConfig]) -> Option<String> {
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)]
mod tests {
use super::*;
/// 构造一个全维度可定制的 ModelConfig(默认全过过滤,调用方按需覆盖字段)。
fn model(model_id: &str) -> ModelConfig {
ModelConfig {
model_id: model_id.into(),
enabled: true,
label: None,
modalities: vec![Modality::Text],
capabilities: vec![Capability::ToolUse],
cost_tier: CostTier::Medium,
intelligence: IntelligenceTier::Standard,
weight: 50,
context_window: 8192,
temperature_supported: true,
probe_source: None,
}
}
/// 根据任务类型路由到合适的模型
pub fn route(&self, task_type: &TaskType) -> String {
// TODO: 实现基于规则的模型路由
match task_type {
TaskType::CodeGeneration => self.default_model.clone(),
TaskType::CodeReview => self.default_model.clone(),
TaskType::Documentation => self.default_model.clone(),
TaskType::Analysis => self.default_model.clone(),
TaskType::Summarization => self.default_model.clone(),
TaskType::Chat => self.default_model.clone(),
/// 构造一个宽松需求(默认全过过滤,调用方按需覆盖字段)。tier=None 保留旧行为。
fn req() -> TaskRequirements {
TaskRequirements {
modalities: vec![Modality::Text],
needs_tool_use: false,
estimated_context: 0,
tier: None,
}
}
// ── select_model_id helper ──
#[test]
fn select_model_id_empty_pool_returns_none() {
// 池空(用户未拉取模型)→ None,调用点兜底 default_model
let pool: Vec<ModelConfig> = vec![];
assert!(select_model_id(&req(), &pool).is_none());
}
#[test]
fn select_model_id_hit_returns_model_id() {
// 池非空命中 → 返回 model_id 字符串(非引用)
let pool = vec![model("glm-4-flash")];
assert_eq!(select_model_id(&req(), &pool).as_deref(), Some("glm-4-flash"));
}
// ── 步骤 1:enabled 过滤 ──
#[test]
fn empty_pool_returns_none() {
let pool: Vec<ModelConfig> = vec![];
assert!(ModelRouter::select(&req(), &pool).is_none());
}
#[test]
fn all_disabled_returns_none() {
let pool = vec![
ModelConfig {
enabled: false,
..model("a")
},
ModelConfig {
enabled: false,
..model("b")
},
];
assert!(ModelRouter::select(&req(), &pool).is_none());
}
// ── 步骤 2:模态匹配 ──
#[test]
fn modality_mismatch_filtered() {
// 任务需要 Vision,但池里模型只有 Text
let pool = vec![model("text-only")];
let r = TaskRequirements {
modalities: vec![Modality::Vision],
..req()
};
assert!(ModelRouter::select(&r, &pool).is_none());
}
#[test]
fn modality_subset_multimodal_required_filters_text_only() {
// 多模态子集匹配:任务需 [Text, Vision](两个模态都得支持),
// 模型仅 [Text](缺 Vision)→ 步骤 2 `.all(|r| m.modalities.contains(r))` 不成立,滤掉。
// 区别于 modality_mismatch_filtered(单模态 Vision):本测覆盖「任务需多模态全子集」路径。
let pool = vec![ModelConfig {
modalities: vec![Modality::Text],
..model("text-only")
}];
let r = TaskRequirements {
modalities: vec![Modality::Text, Modality::Vision],
..req()
};
assert!(ModelRouter::select(&r, &pool).is_none());
// 对照:模型补全 Vision 后通过(Text+Vision ⊇ 任务需求 Text+Vision)
let pool_ok = vec![ModelConfig {
modalities: vec![Modality::Text, Modality::Vision],
..model("vision-capable")
}];
assert_eq!(
ModelRouter::select(&r, &pool_ok).unwrap().model_id,
"vision-capable"
);
}
#[test]
fn enabled_false_excluded_from_routing() {
// enabled=false 不参与路由:混池里禁用候选 weight 更高(100),
// 但应被步骤 1 `.filter(|m| m.enabled)` 滤掉,只选 enabled=true 的低 weight 候选。
// 区别于 all_disabled_returns_none(全禁用返 None):本测覆盖「部分禁用」混池场景。
let pool = vec![
ModelConfig {
enabled: false,
weight: 100, // 若未被 enabled 过滤,weight 100 会胜
..model("disabled-heavy")
},
ModelConfig {
enabled: true,
weight: 30,
..model("enabled-light")
},
];
assert_eq!(
ModelRouter::select(&req(), &pool).unwrap().model_id,
"enabled-light"
);
}
#[test]
fn empty_candidate_pool_returns_none() {
// 空候选池 → None(与 select_model_id_empty_pool_returns_none 对应,
// 本测覆盖 select() 关联函数本身而非 helper;语义等价但断言点不同)。
let pool: Vec<ModelConfig> = vec![];
assert!(ModelRouter::select(&req(), &pool).is_none());
}
// ── 步骤 3:能力匹配(ToolUse) ──
#[test]
fn needs_tool_use_filters_non_tool() {
// 任务需要 ToolUse,池里模型 capabilities 无 ToolUse
let pool = vec![ModelConfig {
capabilities: vec![Capability::Embedding],
..model("embed-only")
}];
let r = TaskRequirements {
needs_tool_use: true,
..req()
};
assert!(ModelRouter::select(&r, &pool).is_none());
}
#[test]
fn needs_tool_use_false_allows_non_tool() {
// 任务不需 ToolUse,池里模型无 ToolUse 仍入选
let pool = vec![ModelConfig {
capabilities: vec![Capability::Embedding],
..model("embed-only")
}];
let r = TaskRequirements {
needs_tool_use: false,
..req()
};
assert_eq!(
ModelRouter::select(&r, &pool).unwrap().model_id,
"embed-only"
);
}
// ── 步骤 4(原智力/成本过滤已解耦):窗口够大 ──
#[test]
fn context_window_insufficient() {
// 任务预估 100000,池里模型 8192
let pool = vec![model("small-window")];
let r = TaskRequirements {
estimated_context: 100_000,
..req()
};
assert!(ModelRouter::select(&r, &pool).is_none());
}
#[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]
fn single_match_returns_it() {
let pool = vec![model("only-one")];
assert_eq!(
ModelRouter::select(&req(), &pool).unwrap().model_id,
"only-one"
);
}
#[test]
fn weight_priority_higher_wins() {
// 两候选都满足,weight 70 胜 50
let pool = vec![
ModelConfig {
weight: 50,
..model("low-weight")
},
ModelConfig {
weight: 70,
..model("high-weight")
},
];
assert_eq!(
ModelRouter::select(&req(), &pool).unwrap().model_id,
"high-weight"
);
}
#[test]
fn same_weight_picks_first_match() {
// 同 weight 70,tier=None(req() 默认):tiebreak 维度退等价,max_by 遇并列返最后一个
// (rust Iterator::max_by 语义,与原 max_by_key 一致)。验证同 weight + tier=None
// 不再按 cost 取舍,行为对齐接入 tier tiebreak 前的语义(标题/扫描路径无回归)。
let pool = vec![
ModelConfig {
weight: 70,
cost_tier: CostTier::High,
..model("expensive")
},
ModelConfig {
weight: 70,
cost_tier: CostTier::Low,
..model("low-cost")
},
];
assert_eq!(
ModelRouter::select(&req(), &pool).unwrap().model_id,
"low-cost"
);
}
#[test]
fn all_dimensions_match_picks_best() {
// 3+ 候选各维度参差,验证过滤链全过 + max_by(weight, tier) 选最优。
// (智力/成本过滤已解耦,原步骤 4/5 删除,候选 d 不再因 intelligence 滤掉)
//
// 候选:
// a: weight 60 → 通过全部过滤
// b: weight 80 → 通过 — weight 次高档(与 c 并列,但 tier=None 故 tiebreak 退等价)
// c: weight 80 → 通过 — 同 weight 80,tier=None 时 max_by 返并列最后一个
// d: weight 90 → 通过(intelligence 不参与过滤)— weight 最高,胜
// e: enabled=false → 步骤 1 滤掉
//
// 预期:d 胜(weight 90 最高,tier tiebreak 不触发因 weight 已决出胜负)
let pool = vec![
ModelConfig {
weight: 60,
cost_tier: CostTier::Medium,
intelligence: IntelligenceTier::Standard,
..model("a")
},
ModelConfig {
weight: 80,
cost_tier: CostTier::High,
intelligence: IntelligenceTier::Plus,
..model("b")
},
ModelConfig {
weight: 80,
cost_tier: CostTier::Low,
intelligence: IntelligenceTier::Plus,
..model("c")
},
ModelConfig {
weight: 90,
cost_tier: CostTier::Low,
intelligence: IntelligenceTier::Lite,
..model("d")
},
ModelConfig {
enabled: false,
weight: 100,
..model("e")
},
];
let r = TaskRequirements {
modalities: vec![Modality::Text],
needs_tool_use: true,
estimated_context: 0,
tier: None,
};
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()]);
}
}
-45
View File
@@ -1,45 +0,0 @@
//! 流式处理 — LLM 响应的流式输出管理
use crate::provider::StreamChunk;
/// 流式响应收集器
pub struct StreamCollector {
/// 已收集的文本
text: String,
/// 是否完成
finished: bool,
}
impl StreamCollector {
/// 创建收集器
pub fn new() -> Self {
Self {
text: String::new(),
finished: false,
}
}
/// 追加一个 chunk
pub fn push(&mut self, chunk: &StreamChunk) {
self.text.push_str(&chunk.delta);
if chunk.finished {
self.finished = true;
}
}
/// 获取已收集的文本
pub fn text(&self) -> &str {
&self.text
}
/// 是否已完成
pub fn is_finished(&self) -> bool {
self.finished
}
}
impl Default for StreamCollector {
fn default() -> Self {
Self::new()
}
}
+117
View File
@@ -0,0 +1,117 @@
//! 上下文侧工具结果正文外置(P1 工具结果瘦身)。
//!
//! 依据 docs/02-架构设计/上下文根治设计-20261006.md §二「工具结果瘦身(治最大头,先行)」:
//! 超阈值工具结果**正文落盘**,上下文只留「摘要 + 句柄路径」;句柄可被 agent 主动展开
//! (read_file 读句柄路径)——需要时找得回,不需要时不占位。与 src-tauri 审计侧 D2a 同构。
//!
//! 与 namespace_store 的区别(两者互补,不冲突):
//! - namespace_store:内存态引用化(省进程内存),发 LLM 前展开为全文 → token 零收益。
//! - 本模块:落盘 + 句柄,被省略的中段**不进 prompt 但可读回** → 真正减 token 且不丢信息。
//!
//! 降级契约:base 未注入(单测/未初始化)→ 不落盘返回 None,调用方退化为纯摘要(改造前行为)。
//! 幂等:文件名取内容稳定哈希,同内容重复压缩只落盘一次。
//! 保留策略:与全局「历史数据默认保留」一致,本模块自产文件只增不减(无清理调用点)。
//!
//! 测试隔离:核心逻辑 `offload_to(base, ...)` 显式传 base(零全局态),单测只测它;
//! `init_base` 的 OnceLock 全局态一次性不可重置,故测试**绝不调用**它(避免污染同进程
//! 其他用例——如 extract_key_info 系列断言会被意外注入的句柄行破坏)。
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
/// 外置根目录(进程级一次注入;src-tauri lib.rs setup 调 init_base)。
/// 与审计侧共用 {app_data},文件名前缀 `ctx-` 区分(tool-results/ 目录共存)。
static BASE: OnceLock<PathBuf> = OnceLock::new();
/// 注入外置根目录(app data dir)。幂等,首次生效。
pub fn init_base(base: PathBuf) {
let _ = BASE.set(base);
}
/// base 是否已注入(降级判定用)。
pub fn is_enabled() -> bool {
BASE.get().is_some()
}
/// 内容稳定哈希(非密码学用途;碰撞后果仅同名文件互相覆盖,概率可忽略)。
fn content_id(content: &str) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
content.hash(&mut h);
h.finish()
}
/// 落盘工具结果正文,返回可读句柄行(供摘要末尾追加)。base 未注入 → None。
pub fn offload_with_handle(content: &str, tool_name: &str) -> Option<String> {
offload_to(BASE.get()?, content, tool_name)
}
/// 核心实现(显式 base,零全局态,可测)。
///
/// 建目录失败 / 写文件失败 → None(降级:调用方仅用摘要,不丢数据)。
pub fn offload_to(base: &Path, content: &str, tool_name: &str) -> Option<String> {
let dir = base.join("tool-results");
if std::fs::create_dir_all(&dir).is_err() {
return None;
}
let file = dir.join(format!("ctx-{:016x}.txt", content_id(content)));
if !file.exists() && std::fs::write(&file, content).is_err() {
return None;
}
Some(format!(
"[工具 {} 原文 {} 字符已外置: {} —— 需完整内容用 read_file 读该路径]",
tool_name,
content.chars().count(),
file.display()
))
}
/// 读回外置文件(查询侧/测试用)。文件缺失返回带语义错误(降级不 panic)。
pub fn read_offloaded(path: &str) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|e| format!("外置结果读取失败: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_base(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("df-ai-offload-{tag}-{}", std::process::id()))
}
#[test]
fn offload_to_writes_readable_file_and_is_idempotent() {
let base = tmp_base("ok");
let _ = std::fs::remove_dir_all(&base);
let payload = "large tool output ".repeat(1000);
let handle = offload_to(&base, &payload, "grep").expect("显式 base 应落盘");
assert!(handle.contains("已外置"), "句柄应含提示文案: {handle}");
assert!(handle.contains("read_file"), "句柄应指引读回方式: {handle}");
// 幂等:同内容二次调用返回同一文件名
let handle2 = offload_to(&base, &payload, "grep").expect("二次调用仍返回句柄");
assert_eq!(handle, handle2, "同内容应幂等复用同一文件");
// 落盘文件可读回原文(无损)
let path = std::fs::read_dir(base.join("tool-results"))
.expect("目录应存在")
.flatten()
.map(|e| e.path())
.find(|p| p.extension().map(|e| e == "txt").unwrap_or(false))
.expect("应有落盘文件");
assert_eq!(
read_offloaded(path.to_string_lossy().as_ref()).expect("应可读回"),
payload
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn different_content_gets_different_file() {
let base = tmp_base("diff");
let _ = std::fs::remove_dir_all(&base);
let h1 = offload_to(&base, &"a".repeat(3000), "grep").unwrap();
let h2 = offload_to(&base, &"b".repeat(3000), "grep").unwrap();
assert_ne!(h1, h2, "不同内容应落不同文件");
let _ = std::fs::remove_dir_all(&base);
}
}
+85
View File
@@ -0,0 +1,85 @@
//! P1 工具结果瘦身 — 端到端集成验证。
//!
//! 独立测试二进制(独立进程):`tool_offload::init_base` 的 OnceLock 全局态不外泄,
//! 不污染 lib 单测(反之亦然)。这是验证「超阈值 → 落盘句柄 → 可无损读回」全链的唯一安全方式。
use df_ai::context_helpers::{extract_key_info, READ_FILE_OFFLOAD_BYTES};
use df_ai::tool_offload;
fn tmp_base(tag: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("df-ai-ctx-offload-{tag}-{}", std::process::id()))
}
/// 从句柄行里粗取落盘路径(格式:`[...已外置: <path> —— ...]`)。
fn path_of(handle_line: &str) -> String {
handle_line
.split("已外置: ")
.nth(1)
.and_then(|s| s.split(" ——").next())
.expect("句柄行应含路径")
.trim()
.to_string()
}
#[test]
fn large_read_file_summarized_with_lossless_handle() {
let base = tmp_base("large");
let _ = std::fs::remove_dir_all(&base);
tool_offload::init_base(base.clone());
// 造一个超阈值的 read_file 结果(代码风格多行文本)
let body: String = (1..=800)
.map(|i| format!("line {i}: fn sample_{i}() {{ let x = {i}; }}\n"))
.collect();
assert!(body.len() > READ_FILE_OFFLOAD_BYTES, "夹具须超阈值");
let out = extract_key_info(&body, "read_file");
assert!(out.len() < body.len(), "超阈值 read_file 应被压缩");
assert!(out.contains("已外置"), "应追加落盘句柄:\n{out}");
assert!(out.contains("read_file 读该路径"), "句柄应指引读回方式");
// 无损性:按句柄路径读回 = 原文逐字节一致
let line = out.lines().find(|l| l.contains("已外置")).expect("应有句柄行");
let back = tool_offload::read_offloaded(&path_of(line)).expect("句柄路径应可读回");
assert_eq!(back, body, "落盘原文须与输入逐字节一致(无损)");
// 摘要确实变小(瘦身生效)
let ratio = out.len() as f64 / body.len() as f64;
assert!(ratio < 0.2, "压缩比应显著(<0.2),实际 {ratio:.3}");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn small_read_file_still_exempt() {
let base = tmp_base("small");
tool_offload::init_base(base);
let small = "line1: fn a() {}\nline2: fn b() {}\n";
assert_eq!(
extract_key_info(small, "read_file"),
small,
"阈值内 read_file 应原样返回(保 AI 定向读代码语义)"
);
}
#[test]
fn non_read_file_large_result_also_gets_handle() {
let base = tmp_base("other");
let _ = std::fs::remove_dir_all(&base);
tool_offload::init_base(base.clone());
// 中性行夹具:不得含 error/panic/.rs: 等错误行信号——错误行保留策略会全量保留
// 命中行,用信号行做夹具会让「应被压缩」断言先天不成立(测的是普通大结果外置)
let body: String = (1..=1200).map(|i| format!("match {i}: src/foo.txt:{i}\n")).collect();
assert!(body.len() >= 8 * 1024, "夹具须 ≥8KB(句柄追加下限)");
let out = extract_key_info(&body, "grep");
assert!(out.len() < body.len(), "应被压缩");
assert!(out.contains("已外置"), "≥8KB 结果应带落盘句柄");
let line = out.lines().find(|l| l.contains("已外置")).expect("应有句柄行");
assert_eq!(
tool_offload::read_offloaded(&path_of(line)).expect("应可读回"),
body
);
let _ = std::fs::remove_dir_all(&base);
}
-74
View File
@@ -1,74 +0,0 @@
//! 工作流事件定义
use serde::{Deserialize, Serialize};
use crate::types::NodeId;
/// 人工审批响应
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HumanApprovalResponse {
pub execution_id: String,
pub node_id: NodeId,
pub decision: String,
pub comment: Option<String>,
}
/// 工作流事件
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum WorkflowEvent {
/// 节点开始执行
NodeStarted {
node_id: NodeId,
},
/// 节点执行进度更新
NodeProgress {
node_id: NodeId,
progress: f32,
message: String,
},
/// 节点产生输出
NodeOutput {
node_id: NodeId,
output: String,
},
/// 节点执行完成
NodeCompleted {
node_id: NodeId,
duration_ms: u64,
},
/// 节点执行失败
NodeFailed {
node_id: NodeId,
error: String,
},
/// 工作流暂停(等待外部输入)
WorkflowPaused {
reason: String,
waiting_node: NodeId,
},
/// 工作流执行完成
WorkflowCompleted {
total_duration_ms: u64,
},
/// 工作流执行失败
WorkflowFailed {
error: String,
failed_node: NodeId,
},
/// 人工审批请求
HumanApprovalRequest {
execution_id: String,
node_id: NodeId,
title: String,
description: String,
options: Vec<String>,
},
/// 人工审批响应
HumanApprovalResponse {
execution_id: String,
node_id: NodeId,
decision: String,
comment: Option<String>,
},
}
-5
View File
@@ -1,5 +0,0 @@
//! df-core: 核心类型定义,所有 crate 的基础依赖
pub mod error;
pub mod events;
pub mod types;
-301
View File
@@ -1,301 +0,0 @@
//! 核心类型定义:ID 别名、状态枚举、优先级等
use serde::{Deserialize, Serialize};
// ============================================================
// ID 类型别名
// ============================================================
/// 想法 ID
pub type IdeaId = String;
/// 项目 ID
pub type ProjectId = String;
/// 任务 ID
pub type TaskId = String;
/// 工作流 ID
pub type WorkflowId = String;
/// 节点 ID
pub type NodeId = String;
/// 发布 ID
pub type ReleaseId = String;
/// 分支 ID
pub type BranchId = String;
/// 插件 ID
pub type PluginId = String;
/// 执行 ID
pub type ExecutionId = String;
/// 决策 ID
pub type DecisionId = String;
// ============================================================
// 状态枚举
// ============================================================
/// 想法状态
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IdeaStatus {
/// 草稿
Draft,
/// 待评估
PendingReview,
/// 已批准
Approved,
/// 已拒绝
Rejected,
/// 已转为项目
Promoted,
/// 已归档
Archived,
}
impl IdeaStatus {
/// 返回数据库存储用的小写字符串
pub fn as_str(&self) -> &'static str {
match self {
IdeaStatus::Draft => "draft",
IdeaStatus::PendingReview => "pending_review",
IdeaStatus::Approved => "approved",
IdeaStatus::Rejected => "rejected",
IdeaStatus::Promoted => "promoted",
IdeaStatus::Archived => "archived",
}
}
}
impl Default for IdeaStatus {
fn default() -> Self {
IdeaStatus::Draft
}
}
/// 项目状态
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProjectStatus {
/// 规划中
Planning,
/// 开发中
InProgress,
/// 测试中
Testing,
/// 发布中
Releasing,
/// 已完成
Completed,
/// 已暂停
Paused,
/// 已取消
Cancelled,
}
impl ProjectStatus {
/// 返回数据库存储用的小写字符串
pub fn as_str(&self) -> &'static str {
match self {
ProjectStatus::Planning => "planning",
ProjectStatus::InProgress => "in_progress",
ProjectStatus::Testing => "testing",
ProjectStatus::Releasing => "releasing",
ProjectStatus::Completed => "completed",
ProjectStatus::Paused => "paused",
ProjectStatus::Cancelled => "cancelled",
}
}
}
impl Default for ProjectStatus {
fn default() -> Self {
ProjectStatus::Planning
}
}
/// 任务状态
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskStatus {
/// 待开始
Todo,
/// 进行中
InProgress,
/// 代码审查中
InReview,
/// 测试中
Testing,
/// 已完成
Done,
/// 已阻塞
Blocked,
/// 已取消
Cancelled,
}
impl TaskStatus {
/// 返回数据库存储用的小写字符串
pub fn as_str(&self) -> &'static str {
match self {
TaskStatus::Todo => "todo",
TaskStatus::InProgress => "in_progress",
TaskStatus::InReview => "in_review",
TaskStatus::Testing => "testing",
TaskStatus::Done => "done",
TaskStatus::Blocked => "blocked",
TaskStatus::Cancelled => "cancelled",
}
}
}
impl Default for TaskStatus {
fn default() -> Self {
TaskStatus::Todo
}
}
/// 工作流状态
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkflowStatus {
/// 待执行
Pending,
/// 运行中
Running,
/// 已暂停(等待人工输入等)
Paused,
/// 已完成
Completed,
/// 已失败
Failed,
/// 已取消
Cancelled,
}
impl WorkflowStatus {
/// 返回数据库存储用的小写字符串
pub fn as_str(&self) -> &'static str {
match self {
WorkflowStatus::Pending => "pending",
WorkflowStatus::Running => "running",
WorkflowStatus::Paused => "paused",
WorkflowStatus::Completed => "completed",
WorkflowStatus::Failed => "failed",
WorkflowStatus::Cancelled => "cancelled",
}
}
}
impl Default for WorkflowStatus {
fn default() -> Self {
WorkflowStatus::Pending
}
}
/// 节点状态
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NodeStatus {
/// 待执行
Pending,
/// 运行中
Running,
/// 已完成
Completed,
/// 已失败
Failed,
/// 已跳过
Skipped,
/// 等待中(如等待人工操作)
Waiting,
/// 已取消
Cancelled,
}
impl NodeStatus {
/// 返回数据库存储用的小写字符串
pub fn as_str(&self) -> &'static str {
match self {
NodeStatus::Pending => "pending",
NodeStatus::Running => "running",
NodeStatus::Completed => "completed",
NodeStatus::Failed => "failed",
NodeStatus::Skipped => "skipped",
NodeStatus::Waiting => "waiting",
NodeStatus::Cancelled => "cancelled",
}
}
}
impl Default for NodeStatus {
fn default() -> Self {
NodeStatus::Pending
}
}
/// 分支状态
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BranchStatus {
/// 活跃中
Active,
/// 已合并
Merged,
/// 已废弃
Abandoned,
}
impl BranchStatus {
/// 返回数据库存储用的小写字符串
pub fn as_str(&self) -> &'static str {
match self {
BranchStatus::Active => "active",
BranchStatus::Merged => "merged",
BranchStatus::Abandoned => "abandoned",
}
}
}
impl Default for BranchStatus {
fn default() -> Self {
BranchStatus::Active
}
}
/// 优先级
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Priority {
/// 低
Low = 0,
/// 中
Medium = 1,
/// 高
High = 2,
/// 紧急
Critical = 3,
}
impl Priority {
/// 返回数据库存储用的小写字符串
pub fn as_str(&self) -> &'static str {
match self {
Priority::Low => "low",
Priority::Medium => "medium",
Priority::High => "high",
Priority::Critical => "critical",
}
}
}
impl Default for Priority {
fn default() -> Self {
Priority::Medium
}
}
// ============================================================
// 辅助函数
// ============================================================
/// 生成新的 UUID v4 字符串
pub fn new_id() -> String {
uuid::Uuid::new_v4().to_string()
}
-12
View File
@@ -1,12 +0,0 @@
[package]
name = "df-evolve"
version = "0.1.0"
edition = "2021"
[dependencies]
df-core = { path = "../df-core" }
serde = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
-58
View File
@@ -1,58 +0,0 @@
//! 进化引擎:知识沉淀的核心闭环
//!
//! 使用 → 沉淀 → 复用 → 改进 → 再沉淀
use crate::knowledge::{Knowledge, KnowledgeKind, KnowledgeStore};
use crate::pattern::PatternExtractor;
/// 进化引擎
pub struct EvolveEngine {
extractor: PatternExtractor,
}
impl EvolveEngine {
pub fn new() -> Self {
Self {
extractor: PatternExtractor,
}
}
/// 自动扫描项目事件,提取可沉淀的知识
///
/// 触发时机:
/// - 工作流节点完成后
/// - 代码审查完成后
/// - Bug 修复完成后
/// - 发布完成后
pub async fn evolve_from_events(&self, _events: &[serde_json::Value]) -> Vec<Knowledge> {
let mut new_knowledge = Vec::new();
// TODO: 遍历事件,分类处理
// 1. 审查事件 → 提取审查规则
// 2. Bug 修复事件 → 提取诊断知识
// 3. 发布事件 → 提取部署经验
// 4. Prompt 事件 → 提取 Prompt 模板
new_knowledge
}
/// 查询当前任务相关的知识(供 AI 节点使用)
///
/// AI 在执行任务前可以查询知识库,获取相关经验和规则
pub fn query_relevant(
&self,
_context: &str,
_kind: Option<&KnowledgeKind>,
) -> Vec<Knowledge> {
// TODO: 语义搜索知识库
KnowledgeStore::search(_context, _kind, 5)
}
/// 验证知识的有效性(定期执行)
///
/// 检查知识是否仍然适用(依赖版本是否过时、规则是否仍有意义等)
pub async fn validate_knowledge(&self) -> Vec<String> {
// TODO: 遍历知识库,标记过时的知识
vec![]
}
}
-98
View File
@@ -1,98 +0,0 @@
//! 知识条目:经验沉淀的基本单元
use df_core::types::ProjectId;
use serde::{Deserialize, Serialize};
/// 知识类型
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum KnowledgeKind {
/// 代码审查规则(如"禁止在循环中创建连接")
ReviewRule,
/// 有效的 Prompt 模板
PromptTemplate,
/// 踩坑经验
Pitfall,
/// 架构模式
ArchitecturePattern,
/// 诊断知识(Bug 根因分析)
Diagnosis,
/// 部署经验
DeploymentNote,
/// 工作流优化建议
WorkflowOptimization,
}
/// 知识条目
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Knowledge {
pub id: String,
pub kind: KnowledgeKind,
/// 标题
pub title: String,
/// 内容
pub content: String,
/// 标签
pub tags: Vec<String>,
/// 来源项目
pub source_project: Option<ProjectId>,
/// 来源实体(如某次审查、某个 Bug 修复)
pub source_ref: Option<String>,
/// 被复用次数
pub reuse_count: usize,
/// 效果评分 (0-100,由用户或 AI 评估)
pub effectiveness: Option<f32>,
/// 是否已验证有效
pub verified: bool,
pub created_at: i64,
pub updated_at: i64,
}
impl Knowledge {
pub fn new(kind: KnowledgeKind, title: String, content: String) -> Self {
let now = chrono::Utc::now().timestamp();
Self {
id: df_core::types::new_id(),
kind,
title,
content,
tags: vec![],
source_project: None,
source_ref: None,
reuse_count: 0,
effectiveness: None,
verified: false,
created_at: now,
updated_at: now,
}
}
/// 记录一次复用
pub fn record_reuse(&mut self) {
self.reuse_count += 1;
self.updated_at = chrono::Utc::now().timestamp();
}
}
/// 知识库(内存索引,持久化到 SQLite)
pub struct KnowledgeStore;
impl KnowledgeStore {
/// 搜索相关知识
pub fn search(_query: &str, _kind: Option<&KnowledgeKind>, _limit: usize) -> Vec<Knowledge> {
// TODO: SQLite 全文搜索或向量搜索
vec![]
}
/// 获取最常用的知识
pub fn top_used(_limit: usize) -> Vec<Knowledge> {
// TODO: 按 reuse_count 降序
vec![]
}
/// 保存知识条目
pub fn save(_knowledge: &Knowledge) -> anyhow::Result<()> {
// TODO: SQLite INSERT/UPDATE
Ok(())
}
}
-12
View File
@@ -1,12 +0,0 @@
//! 经验进化引擎:从开发过程中自动沉淀知识,持续进化复用
//!
//! 核心闭环:使用 → 沉淀 → 复用 → 改进 → 再沉淀
pub mod knowledge;
pub mod pattern;
pub mod prompt_template;
pub mod review_rule;
pub mod evolve_engine;
pub use evolve_engine::EvolveEngine;
pub use knowledge::{Knowledge, KnowledgeKind, KnowledgeStore};
-47
View File
@@ -1,47 +0,0 @@
//! 模式提取器:从开发过程中自动识别可沉淀的模式
use crate::knowledge::{Knowledge, KnowledgeKind};
/// 模式提取器
///
/// 自动从以下场景中识别可沉淀的模式:
/// - 代码审查 → 审查规则
/// - Bug 修复 → 诊断知识
/// - 发布流程 → 部署经验
/// - Prompt 调优 → Prompt 模板
pub struct PatternExtractor;
impl PatternExtractor {
/// 从代码审查结果中提取审查规则
///
/// 如果同一类问题在多次审查中重复出现,自动沉淀为规则
pub fn extract_review_rule(
_findings: &[serde_json::Value],
_occurrence_threshold: usize,
) -> Option<Knowledge> {
// TODO:
// 1. 分析 findings 的共性
// 2. 如果出现次数 >= threshold,生成规则
// 3. 去重(与已有规则比较)
None
}
/// 从 Bug 修复过程中提取诊断知识
pub fn extract_diagnosis(
_bug_description: &str,
_root_cause: &str,
_fix_description: &str,
) -> Option<Knowledge> {
// TODO: AI 总结为可复用的诊断知识
None
}
/// 从成功的 Prompt 中提取模板
pub fn extract_prompt_template(
_prompt: &str,
_result_quality: f32,
) -> Option<Knowledge> {
// TODO: 如果 result_quality > 0.8,提取为模板
None
}
}
-41
View File
@@ -1,41 +0,0 @@
//! Prompt 模板管理:AI 交互经验的沉淀与复用
use serde::{Deserialize, Serialize};
/// Prompt 模板
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PromptTemplate {
pub id: String,
/// 模板名称
pub name: String,
/// 模板内容(支持 {variable} 占位符)
pub template: String,
/// 变量说明
pub variables: Vec<TemplateVariable>,
/// 适用场景
pub applicable_scenarios: Vec<String>,
/// 效果评分
pub avg_score: f32,
/// 使用次数
pub use_count: usize,
}
/// 模板变量
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateVariable {
pub name: String,
pub description: String,
pub default_value: Option<String>,
pub required: bool,
}
impl PromptTemplate {
/// 渲染模板(替换变量)
pub fn render(&self, vars: &std::collections::HashMap<String, String>) -> String {
let mut result = self.template.clone();
for (key, value) in vars {
result = result.replace(&format!("{{{}}}", key), value);
}
result
}
}
-45
View File
@@ -1,45 +0,0 @@
//! 审查规则:从历史审查经验中沉淀的代码审查规则
use serde::{Deserialize, Serialize};
/// 审查规则
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewRule {
pub id: String,
/// 规则标题
pub title: String,
/// 规则描述
pub description: String,
/// 严重级别
pub severity: RuleSeverity,
/// 适用的语言/框架
pub scope: Vec<String>,
/// 检查方式(正则/AST/AI)
pub check_method: CheckMethod,
/// 发现次数(历史累计)
pub found_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuleSeverity {
/// 必须修复
MustFix,
/// 建议改进
ShouldFix,
/// 可选优化
NiceToHave,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CheckMethod {
/// 正则匹配
Regex,
/// AST 分析
Ast,
/// AI 判断
AiAnalysis,
/// 人工判断
Manual,
}
-3
View File
@@ -4,10 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
df-core = { path = "../df-core" }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
+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(), "复位后恢复放行");
}
}
-46
View File
@@ -1,46 +0,0 @@
//! Docker 执行器 — 在容器中运行任务
use serde::{Deserialize, Serialize};
/// Docker 容器执行请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DockerRequest {
/// 镜像名称
pub image: String,
/// 容器内执行的命令
pub command: Option<String>,
/// 环境变量
pub env: std::collections::HashMap<String, String>,
/// 挂载卷
pub volumes: Vec<VolumeMount>,
/// 是否在执行后自动删除容器
pub auto_remove: bool,
}
/// 卷挂载
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VolumeMount {
pub host_path: String,
pub container_path: String,
pub read_only: bool,
}
/// Docker 执行结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DockerResult {
pub stdout: String,
pub stderr: String,
pub exit_code: Option<i32>,
}
/// 在 Docker 容器中执行命令
///
/// TODO: 实现 Docker API 调用或 CLI 包装
pub async fn execute(_request: DockerRequest) -> anyhow::Result<DockerResult> {
tracing::info!("Docker 执行: TODO");
Ok(DockerResult {
stdout: String::new(),
stderr: String::new(),
exit_code: None,
})
}
+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("请生成本平台兼容的命令"));
}
}
-47
View File
@@ -1,47 +0,0 @@
//! Git 操作 — 克隆、提交、推送、合并等
use serde::{Deserialize, Serialize};
/// Git 操作类型
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GitAction {
Clone,
Commit,
Push,
Pull,
Merge,
Checkout,
CreateBranch,
}
/// Git 操作请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitRequest {
/// 操作类型
pub action: GitAction,
/// 仓库路径(本地路径或远程 URL)
pub repo: String,
/// 分支名
pub branch: Option<String>,
/// 提交消息
pub message: Option<String>,
}
/// Git 操作结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitResult {
pub success: bool,
pub message: String,
}
/// 执行 Git 操作
///
/// TODO: 实现完整的 Git 操作(可包装 git CLI 或使用 git2 crate)
pub async fn execute(_request: GitRequest) -> anyhow::Result<GitResult> {
tracing::info!("Git 操作: TODO");
Ok(GitResult {
success: true,
message: "TODO: 未实现".to_string(),
})
}
+6 -4
View File
@@ -1,6 +1,8 @@
//! df-execute: 执行运行时 — Shell、Docker、SSH、Git 操作
//! df-execute: 执行运行时 — Shell + 环境感知 + 提交冻结闸
pub mod docker;
pub mod git_ops;
pub mod commit_freeze;
pub mod env_snapshot;
pub mod shell;
pub mod ssh;
pub use commit_freeze::is_commit_command;
pub use env_snapshot::EnvSnapshot;

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