Compare commits

...
280 Commits
Author SHA1 Message Date
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
438 changed files with 96248 additions and 13115 deletions
+13
View File
@@ -47,3 +47,16 @@ coverage/
.env
.env.*
!.env.example
# 损坏备份(FR-S7 write_file 事故残留)+临时文件
*.broken.bak
tmp/
.temp/
# AI 编排脚本(Claude Code Workflow 临时产物,非产品代码)
workflows/
# MCP/SQLite 测试库(mcp_test2 --db 残留)
devflow-dev.db
devflow-dev.db-shm
devflow-dev.db-wal
+242 -216
View File
@@ -31,6 +31,48 @@
└→ 🎯 Release (发布) — 合并多个 Task → 集成测试 → 发布
```
### 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 +105,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) │
@@ -79,27 +121,25 @@ devflow/
├── crates/
│ ├── df-core/ # 核心类型、错误、常量、事件
│ ├── 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)
│ ├── 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 +177,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 +207,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,237 +282,219 @@ 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 协作(Planner/Coder/Reviewer/Fixer)— ⚠️ 骨架空壳(`coordinator.rs`,仅单元结构体 + 日志,未实现实际协调)
- **ContextManager**:Token 预算管理(`context.rs`,完整实现)✅
- **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,支持流式输出、工具调用 | ✅ 已实现 |
| ScriptNode | Shell/脚本执行 | ✅ 已实现 |
| HumanNode | 人工审批/确认 (阻塞) | ✅ 已实现 |
| DockerNode | Docker 容器操作 | ❌ 未实现 |
| GitNode | Git 操作 (libgit2) | ❌ 未实现 |
| NotifyNode | 通知 (桌面/飞书/Webhook) | ❌ 未实现 |
| HTTPNode | HTTP 请求 | ❌ 未实现 |
| SubflowNode | 嵌套子工作流 | ❌ 未实现 |
> `crates/df-nodes/src/` 实际仅 ai_node / script_node / human_node 3 文件(Docker/Git/Notify/HTTP/Subflow 为设计预留,尚未实现)。详见 [df-nodes 模块文档](docs/03-模块文档/df-nodes-节点集合-2026-06-12.md)。
## 六、数据模型
### SQLite 表结构
> 真相源:`crates/df-storage/src/migrations.rs`(V1-V13,幂等迁移)。下表对齐真实 schema,字段/类型/默认值/外键与迁移文件一致。Rust 结构体见 `crates/df-storage/src/models.rs`。
共 **13 张业务表**(另含内部表 `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)
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)
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 1,
branch_name TEXT,
assignee TEXT,
workflow_def_id TEXT, -- 关联的工作流定义 ID (V2)
base_branch TEXT, -- 基础分支 (V2)
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
);
-- 产出物
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 建表) — 多 Provider 统一抽象
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
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)
CREATE TABLE ai_conversations (
id TEXT PRIMARY KEY,
title TEXT,
messages TEXT NOT NULL DEFAULT '[]', -- JSON array of ChatMessage
provider_id TEXT,
model TEXT,
models TEXT, -- 用过的所有 model JSON 数组字符串 (去重, V6)
archived INTEGER NOT NULL DEFAULT 0, -- 是否归档(侧栏折叠展示, V4)
prompt_tokens INTEGER, -- 输入 token 累计 (流式 usage 落库, V5)
completion_tokens INTEGER, -- 输出 token 累计 (流式 usage 落库, V5)
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 建表) — Agent 工具调用的审批/执行留痕
CREATE TABLE ai_tool_executions (
id TEXT PRIMARY KEY,
conversation_id TEXT,
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
);
-- 测试执行记录
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)
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
);
-- 内部表: schema_version (version INTEGER PRIMARY KEY) — 迁移版本记录
```
## 七、阶段模板
@@ -504,7 +530,7 @@ CREATE TABLE decisions (
### Phase 4 — 节点丰富 + 阶段插件 (3-4 周)
- df-nodes (Docker/Git/Human/HTTP)
- df-stages (5 阶段模板)
- ~~df-stages (5 阶段模板)~~ — 已移除(2026-06-14 零引用清理)
- 条件分支 + 断点续跑
- 验证:跑通标准产研流程模板
Generated
+567 -83
View File
@@ -83,6 +83,18 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-compression"
version = "0.4.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"
dependencies = [
"compression-codecs",
"compression-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "async-executor"
version = "1.14.0"
@@ -97,6 +109,17 @@ dependencies = [
"slab",
]
[[package]]
name = "async-fs"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
dependencies = [
"async-lock",
"blocking",
"futures-lite",
]
[[package]]
name = "async-io"
version = "2.6.0"
@@ -465,6 +488,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.45"
@@ -489,6 +518,24 @@ dependencies = [
"memchr",
]
[[package]]
name = "compression-codecs"
version = "0.4.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
dependencies = [
"brotli",
"compression-core",
"flate2",
"memchr",
]
[[package]]
name = "compression-core"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@@ -721,18 +768,30 @@ name = "devflow"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"base64 0.22.1",
"chrono",
"df-ai",
"df-core",
"df-execute",
"df-ideas",
"df-mcp",
"df-nodes",
"df-project",
"df-storage",
"df-types",
"df-workflow",
"futures",
"keyring",
"percent-encoding",
"regex",
"reqwest 0.12.28",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-window-state",
"tokio",
"tracing",
]
@@ -743,7 +802,8 @@ version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"df-core",
"df-ai-core",
"df-types",
"eventsource-stream",
"futures",
"reqwest 0.12.28",
@@ -754,26 +814,14 @@ dependencies = [
]
[[package]]
name = "df-core"
version = "0.1.0"
dependencies = [
"chrono",
"serde",
"serde_json",
"thiserror 2.0.18",
"uuid",
]
[[package]]
name = "df-evolve"
name = "df-ai-core"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"df-core",
"async-trait",
"futures",
"serde",
"serde_json",
"tracing",
]
[[package]]
@@ -781,12 +829,9 @@ name = "df-execute"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"df-core",
"serde",
"serde_json",
"tokio",
"tracing",
]
[[package]]
@@ -794,14 +839,34 @@ name = "df-ideas"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"chrono",
"df-core",
"df-ai",
"df-ai-core",
"df-types",
"regex",
"serde",
"serde_json",
"tokio",
"tracing",
]
[[package]]
name = "df-mcp"
version = "0.1.0"
dependencies = [
"anyhow",
"df-nodes",
"df-storage",
"df-types",
"futures",
"serde",
"serde_json",
"tokio",
"tracing",
"uuid",
]
[[package]]
name = "df-nodes"
version = "0.1.0"
@@ -809,22 +874,9 @@ dependencies = [
"anyhow",
"async-trait",
"df-ai",
"df-core",
"df-execute",
"df-workflow",
"serde",
"serde_json",
"tokio",
"tracing",
]
[[package]]
name = "df-plugin"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"df-core",
"df-storage",
"df-types",
"df-workflow",
"serde",
"serde_json",
@@ -838,21 +890,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"df-core",
"serde",
"serde_json",
"tokio",
"tracing",
]
[[package]]
name = "df-stages"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"df-core",
"df-workflow",
"df-types",
"serde",
"serde_json",
"tokio",
@@ -864,7 +902,9 @@ name = "df-storage"
version = "0.1.0"
dependencies = [
"anyhow",
"df-core",
"df-ai-core",
"df-types",
"keyring",
"rusqlite",
"serde",
"serde_json",
@@ -873,27 +913,14 @@ dependencies = [
]
[[package]]
name = "df-task"
name = "df-types"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"df-core",
"serde",
"serde_json",
"tokio",
"tracing",
]
[[package]]
name = "df-traceability"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"df-core",
"serde",
"serde_json",
"thiserror 2.0.18",
"uuid",
]
[[package]]
@@ -902,11 +929,10 @@ version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"df-core",
"df-types",
"futures",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
]
@@ -2216,6 +2242,23 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "keyring"
version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
"byteorder",
"linux-keyutils",
"log",
"secret-service",
"security-framework 2.11.1",
"security-framework 3.7.0",
"windows-sys 0.60.2",
"zbus 4.4.0",
"zeroize",
]
[[package]]
name = "leb128fmt"
version = "0.1.0"
@@ -2291,6 +2334,16 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "linux-keyutils"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590"
dependencies = [
"bitflags 2.13.0",
"libc",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -2410,7 +2463,7 @@ dependencies = [
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework 3.7.0",
"security-framework-sys",
"tempfile",
]
@@ -2445,6 +2498,19 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.13.0",
"cfg-if",
"cfg_aliases",
"libc",
"memoffset",
]
[[package]]
name = "nom"
version = "7.1.3"
@@ -2455,12 +2521,76 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
dependencies = [
"autocfg",
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2615,6 +2745,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.0",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -2974,6 +3105,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
@@ -3082,6 +3222,36 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -3234,6 +3404,30 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -3410,6 +3604,34 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "secret-service"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4"
dependencies = [
"futures-util",
"generic-array",
"num",
"once_cell",
"rand",
"serde",
"zbus 4.4.0",
]
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.13.0",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -3632,6 +3854,17 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -3747,6 +3980,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "string_cache"
version = "0.9.0"
@@ -4055,6 +4294,48 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.4"
@@ -4074,7 +4355,22 @@ dependencies = [
"thiserror 2.0.18",
"url",
"windows",
"zbus",
"zbus 5.16.0",
]
[[package]]
name = "tauri-plugin-window-state"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704"
dependencies = [
"bitflags 2.13.0",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]]
@@ -4498,12 +4794,17 @@ version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"async-compression",
"bitflags 2.13.0",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"pin-project-lite",
"tokio",
"tokio-util",
"tower",
"tower-layer",
"tower-service",
@@ -5239,6 +5540,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -5272,13 +5582,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows-threading"
version = "0.1.0"
@@ -5309,6 +5636,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -5321,6 +5654,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -5333,12 +5672,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -5351,6 +5702,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -5363,6 +5720,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -5375,6 +5738,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@@ -5387,6 +5756,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.5.40"
@@ -5586,6 +5961,16 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "xdg-home"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]]
name = "yoke"
version = "0.8.3"
@@ -5609,6 +5994,44 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zbus"
version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725"
dependencies = [
"async-broadcast",
"async-executor",
"async-fs",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-sink",
"futures-util",
"hex",
"nix",
"ordered-stream",
"rand",
"serde",
"serde_repr",
"sha1",
"static_assertions",
"tracing",
"uds_windows",
"windows-sys 0.52.0",
"xdg-home",
"zbus_macros 4.4.0",
"zbus_names 3.0.0",
"zvariant 4.2.0",
]
[[package]]
name = "zbus"
version = "5.16.0"
@@ -5639,9 +6062,22 @@ dependencies = [
"uuid",
"windows-sys 0.61.2",
"winnow 1.0.3",
"zbus_macros",
"zbus_names",
"zvariant",
"zbus_macros 5.16.0",
"zbus_names 4.3.2",
"zvariant 5.12.0",
]
[[package]]
name = "zbus_macros"
version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
"zvariant_utils 2.1.0",
]
[[package]]
@@ -5654,9 +6090,20 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"zbus_names",
"zvariant",
"zvariant_utils",
"zbus_names 4.3.2",
"zvariant 5.12.0",
"zvariant_utils 3.4.0",
]
[[package]]
name = "zbus_names"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
dependencies = [
"serde",
"static_assertions",
"zvariant 4.2.0",
]
[[package]]
@@ -5667,7 +6114,7 @@ checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d"
dependencies = [
"serde",
"winnow 1.0.3",
"zvariant",
"zvariant 5.12.0",
]
[[package]]
@@ -5756,6 +6203,19 @@ version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zvariant"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe"
dependencies = [
"endi",
"enumflags2",
"serde",
"static_assertions",
"zvariant_derive 4.2.0",
]
[[package]]
name = "zvariant"
version = "5.12.0"
@@ -5766,8 +6226,21 @@ dependencies = [
"enumflags2",
"serde",
"winnow 1.0.3",
"zvariant_derive",
"zvariant_utils",
"zvariant_derive 5.12.0",
"zvariant_utils 3.4.0",
]
[[package]]
name = "zvariant_derive"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
"zvariant_utils 2.1.0",
]
[[package]]
@@ -5780,7 +6253,18 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"zvariant_utils",
"zvariant_utils 3.4.0",
]
[[package]]
name = "zvariant_utils"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
+6
View File
@@ -16,3 +16,9 @@ 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"] }
+708 -39
View File
@@ -1,6 +1,6 @@
# DevFlow — 项目进展与工作交接
> 创建: 2026-06-10 | 最后更新: 2026-06-12 | 当前阶段: Phase 2 aichat UX 快赢(A 路线)+ 技能联想需求调研(Sprint 8)
> 创建: 2026-06-10 | 最后更新: 2026-06-18 | 当前阶段: PROGRESS Sprint 日志截至 Sprint 24(2026-06-15 · P0 generating 状态机加固 + 独立项批量);2026-06-16 起多会话进展(B-03 路由解耦 / 工具结果渲染 / 吞错降级 / 模型能力维度治理等)见 [docs/todo.md](docs/todo.md) + [docs/待审查.md](docs/待审查.md)
---
@@ -9,51 +9,49 @@
| 项目 | 值 |
|------|-----|
| 定位 | AI 原生创作流程驾驶舱,从想法到创作成果的全流程管理 |
| 技术栈 | Tauri v2 + Vue 3 + TypeScript + Pinia / Rust Workspace (13 crate) / SQLite |
| 技术栈 | Tauri v2 + Vue 3 + TypeScript + Pinia / Rust Workspace (8 crate) / SQLite |
| 路径 | `E:/wk-lab/devflow/` |
| 架构文档 | `ARCHITECTURE.md` (22,745 字) |
| Git 状态 | 未首次 commit,代码全在 untracked |
| AI 能力 | df-ai OpenAI 兼容 Provider + 12 工具 + Agentic Loop |
| Git 状态 | 已纳入版本控制(完整 commit 历史,当前分支 feat/batch-260615-workflow-unlock) |
| AI 能力 | df-ai OpenAI/Anthropic 双协议 Provider + 12 工具 + Agentic Loop |
---
## 二、代码规模统计
### Rust 后端 (71 个 .rs 文件)
### Rust 后端 (78 个 .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-core | 4 | 429 | ~260 | ✅ 完整 — 错误/事件/状态枚举/ID生成 |
| df-workflow | 9 | 908 | ~620 | ✅ 核心 — DAG拓扑排序/执行器/状态机/事件总线 可用 |
| df-storage | 5 | ~2,100 | ~1,400 | ✅ 完整 — 连接/迁移/建表(V1-V8) + impl_repo! 宏 CRUD + KnowledgeRepo(search/list_by_status/set_embedding/search_vector 等)+ 余弦相似度工具函数 |
| df-execute | 5 | 203 | 129 | ✅ Shell 执行器真实可用(tokio::process,被 run_command 复用) |
| df-ideas | 7 | 761 | ~500 | ✅ 评分/对抗启发式已真实化(Sprint 9)— 捕获/评估/晋升/关联图 有逻辑,晋升/关联图仍骨架 |
| 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** | |
| df-ai | 9 | 1,965 | ~1,300 | ✅ 完整 — OpenAI/Anthropic 双协议 Provider + 流式 SSE + 上下文窗口(ContextManager 分组滑窗) + token usage 解析 + 12 工具 |
| df-nodes | 9 | ~500 | ~350 | ⚡ 混合 — script_node(接 Shell)/ ai_node(接 df-ai,Sprint 7)真实可用,human_node 半实现(审批响应 TODO),其余骨架 |
| **合计** | **53** | **~7,400** | **~4,800** | 已删除 5 个零引用 crate(2026-06-14,原 78/9100/5900) |
### 前端 (9 页面 + Store + i18n)
### 前端 (9 页面 + 2 组件 + Store + i18n)
| 类别 | 文件数 | 行数 | 状态 |
|------|--------|------|------|
| Vue 页面 | 9 | ~3,372 | ✅ UI 全部真实 — 无空壳 |
| Pinia Store | 4 | ~362 | ⚠️ 有 state/getter 但**无 action,View 未引用** |
| Router | 1 | 63 | ✅ 9 条路由含动态路由 |
| i18n | 2 | ~94 | ⚠️ 翻译完整但**未接入 main.ts** |
| Vue 页面 | 9 | ~3,372 | ✅ UI 全部真实 — 无空壳(含 AiHome/AiDetached)|
| Vue 组件 | 2 | ~1,400 | ✅ AiChat(主面板)+ ConfirmDialog |
| Pinia Store | 4+ | ~800 | ✅ composable 模式,View 已引用(Sprint 4 接入)|
| Router | 1 | ~90 | ✅ 10 条路由含动态路由(Sprint 7 增 AiHome/AiDetached)|
| i18n | 2 | ~94 | ✅ 已注册 main.ts(Sprint 7)+ localStorage 持久化 |
| 设计系统 | 1 | 105 | ✅ CSS 变量/动画/Arco 暗色覆盖 |
| Tauri Commands | 1 | 13 | 🔩 仅 `greet` 示例,无业务逻辑 |
| **前端合计** | | **~8,812** | |
**前端关键问题**:
- 所有 View 组件数据**硬编码** (`ref([...])`),与 Store 完全独立
- 所有按钮事件处理函数为**空函数** `{}`
- i18n 插件未注册到 Vue app
- Arco Design 组件库未实际使用(仅 CSS 变量覆盖)
> Tauri Commands 已达 **67 个**(src-tauri/src/lib.rs invoke_handler 注册数:project 14 / task 5 / idea 6 / workflow 5 / ai 19 / knowledge 14 / settings 4),见后端 src-tauri/src/commands/。
**前端关键问题**(Sprint 4/7 已全部解决):
- ~~所有 View 组件数据硬编码~~ ✅ Sprint 4 接 Store 真数据
- ~~所有按钮事件处理函数为空函数~~ ✅ Sprint 4 接 Store action
- ~~i18n 插件未注册~~ ✅ Sprint 7 注册 + $t() 全 view 改造
- ~~Arco Design 组件库未实际使用~~ ✅ 已用(表单/弹窗/消息等),CSS 变量覆盖暗色
---
@@ -68,11 +66,11 @@
待打通路径(骨架就位,需填充):
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
~~df-workflow → df-stages (11种阶段节点 execute() 待实现)~~ — df-stages 已移除(2026-06-14 零引用清理)
~~df-ai → LlmProvider 实现~~ ✅ 已打通(OpenAI/Anthropic 双协议真实 HTTP,Sprint 5/8)
~~df-storage → 所有 crate 的 CRUD 操作~~ ✅ 已打通(impl_repo! 宏 CRUD + 多 Repo,Sprint 2)
~~Tauri commands → Rust 业务逻辑~~ ✅ 已打通(Sprint 3 时点 47 commands 全接业务;现 67)
~~Vue Views → Pinia Store → Tauri IPC → Rust~~ ✅ 已打通(Sprint 4)
```
---
@@ -89,6 +87,8 @@
| 6 | **条件表达式引擎** — 仅支持 true/false 字面量 | 条件分支不可用 | P2 | ⬜ 待办 |
| 7 | **i18n 未注册** — 翻译文件存在但未挂载 | 多语言不生效 | P2 | ✅ Sprint 7 |
| 8 | **未 git commit** — 代码全 untracked | 无版本基线 | P0 | ✅ Sprint 2 |
| 9 | **AI 删项目绕过软删** — tool_registry:263 `repo.delete` 物理删,IPC 走 `soft_delete` 进回收站;AI 层缺 restore/purge/list_trash 工具(仅 project 表有回收站设计,故最该修) | AI 对话删项目 = 数据丢失不可恢复,与 UI 删语义割裂 | P1 | ✅ Sprint 21 已修(tool_registry 补 restore_project/purge_project/list_trash,delete_project 改 soft_delete) |
| 10 | **api_key 明文存储** — DB 落盘仍明文(migrations.rs:446 api_key TEXT NOT NULL) | 本地库被读/备份外流则泄露 | P0 | 🟡 部分修(FR-S1:IPC mask+编辑不回填✅ commit 49ac060;DB 明文落盘❌ 待迁移 keyring 单列) |
---
@@ -121,9 +121,9 @@
| 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采集/滑动窗口/摘要)⬜ | |
| **AI Chat LLM 优化** | ✅ ABCD 完成 | 任务 #43:P0 可靠性 ✅;P1 上下文窗口(ContextManager 分组滑窗)✅ + 并行化(工具 join_all / save 后台)✅;**Sprint 14 流式 token 用量记录(usage 解析 + 累加落库 + 展示 + Settings 开关)✅**;Part C 并发 Semaphore(双层 + 运行时可调)+ Part D Settings UI ✅(Sprint 16);Review 两轮 8 修复 ✅ | |
### Phase 3 — 想法池 + 多项目 (3-4 周) | ⬜ 未开始
### Phase 3 — 想法池 + 多项目 (3-4 周) | 🚧 进行中 — Sprint 9 评估闭环已通(启发式评分 + 对抗评估接 IPC),晋升前端层闭环,多项目/关联图待续;Sprint 12 全面审查修 5 项前端 bug(置信度条/状态选择/删除确认等),死代码组缓另会话重审
### Phase 4 — 节点丰富 + 阶段插件 (3-4 周) | ⬜ 未开始
### Phase 5 — 体验打磨 (4-6 周) | ⬜ 未开始
@@ -136,7 +136,7 @@
**工作内容**:
- 创建项目骨架:`bun create vite` + Tauri v2 + Cargo Workspace
- 编写完整架构文档 `ARCHITECTURE.md` (22,745 字)
- 13 个 Rust crate 全部创建,类型系统和接口设计完整
- 13 个 Rust crate 全部创建,类型系统和接口设计完整(2026-06-14 评审后删 5 僵尸 crate,现 8 crate)
- 9 个 Vue 页面 UI 全部编写(~3,372 行),设计系统完善
- 4 个 Pinia Store 定义(state + computed)
- i18n 中英文翻译
@@ -318,7 +318,7 @@
- **审查项落地**:删全屏 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. 技能联想功能需求(本轮重点,未实现,需求阶段)**:
**B. 技能联想功能(首批 Claude 3 类,已实现待实测)**:
- **需求**: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 等)
@@ -338,10 +338,679 @@
**代码变更**: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、前端联想浮层)
1. **技能联想(B)已实现待实测** — `ai_list_skills` IPC(扫 skills/commands/plugins 三类 + frontmatter 解析 + name 去重)+ `ai_chat_send` 加 `skill` 参数读 SKILL.md 注入 system prompt + 前端 `/` 浮层(↑↓ 导航 / Enter·Tab 选中 / chip 显示 / Esc 关)。cargo check ✓(7 warning 既有)/ bun build ✓(4.68s)。实测:重启 dev → AiChat 输入 `/` → 选技能 → 发送
2. **A 路线剩余场景实测**:切对话不中断(场景 2/3)、错误友好化(场景 4)待用户实测
3. **B 路线(规划式能力)**:coordinator / conditions 空壳,单独立项
### [Sprint 9] 2026-06-12 — 灵感模块评估闭环(启发式评分 + 对抗评估接 IPC)
**工作内容**:
- **df-ideas 修复与真实化**:① 修 adversarial.rs E0308 类型错(`AdversarialEval.negative: Argument` vs `generate_negative_argument` 返 `CounterArgument` 直接赋值,df-ideas 实际编译不过——前文档"cargo check 通过"为误记)→ 删 CounterArgument、正反方归一 Argument;② scoring.rs 三维固定 5.0 → 内容启发式(priority / 描述充实度 / tags / 中英关键词,clamp 0-10);③ adversarial.rs 正反方论点/evidence 基于真实 idea 内容生成,confidence 由评分驱动,final_score=综合评分,AssessmentLevel 由 overall 映射
- **devflow 接 df-ideas**:`src-tauri/Cargo.toml` +df-ideas +chrono 依赖(df-ideas 此前为孤儿 crate,devflow 未依赖、零 IPC 接入)
- **evaluate_idea IPC**(commands/idea.rs):取 IdeaRecord → record_to_idea 转 df-ideas::Idea → ScoringEngine + AdversarialEngine.evaluate → 组装前端扁平结构(对齐 Ideas.vue AdversarialEval interface)→ 写回 scores(中文维度 0-100)/score(0-100)/ai_analysis/status=pending_review → 返回更新记录;lib.rs 注册
- **前端接真实评估**:api/idea.ts +evaluate;stores/project.ts +evaluateIdea action;Ideas.vue 删 mockEval 调真实 + evaluating loading 态
- **审核修 2 bug**:① recommendation 大小写(后端原 "With Resources" 与前端 assessmentLabel 全小写 map key 不匹配致中文标签不显示)→ 全小写空格;② assessmentClass 生成的 class 与 CSS 类名不一致致 badge 无色 → map 映射到 .immediate/.soon/.conditional/.revised/.defer/.cancel
**代码变更**:df-ideas/{scoring,adversarial}.rs、src-tauri/{Cargo.toml,commands/idea.rs,lib.rs}、前端 {api/idea.ts,stores/project.ts,views/Ideas.vue}(8 文件)
**验证**:cargo check -p df-ideas ✓、cargo check -p devflow ✓、bun run build ✓(vue-tsc + vite 3.3s)
**遗留问题 / 下一步**:
1. 对抗评估仍启发式,未接 df-ai LlmProvider(设计:LLM 生成正反方论点 + 启发式 fallback)
2. promotion.rs do_promote 仍 TODO(晋升走前端 createProject 闭环,不经 df-ideas/promotion)
3. evaluate_idea 用 list_all().find() + 5 次 update_field(IdeaRepo 无 get/多字段更新,量小可接受)
4. IdeaGraph 关联图零接入(自动关联发现/聚类待续)
5. scores JSON 含「综合」维度也被雷达图渲染一行(小瑕疵)
### [Sprint 10] 2026-06-12 — AiChat 工具卡片自动折叠(卡片级折叠 + 新内容追加自动收起)
**工作内容**:
- **需求**:对话中工具调用卡片(List Projects / List Tasks / Create Idea / List Ideas / 读取文件等)完成后仍整卡铺开,多步调用时界面臃肿。目标——展示更友好紧凑:所有卡片可点击 header 展开/收起,新内容追加时旧卡片自动收起为单行 header(类似 ChatGPT/Cursor 旧 tool call 折叠)
- **卡片级折叠**(区别于 read_file 已有的内容级折叠):`.ai-tool-card` 内 body 区(骨架屏/审批/拒绝/file/dir/write/通用结果)包进 `.ai-tool-body`,`v-show` 控制显隐;header 加 `@click` + ▸ 箭头指示器;新增 `expandedCards` Set + `toggleCardExpand` + `cardCollapseClass`,与既有 `expandedTools`(read_file 预览级)职责分离不互相干扰
- **自动收起**:deep watch `store.state.messages` + 轻量 JSON snapshot diff 检测新内容(新消息 / toolCall 状态变化 / 文本增长)→ 清除旧 completed/rejected 的展开态,保留 running/pending 活跃卡片;`isFirst` guard 防首次加载/切对话误触发
- **running/pending_approval 强制展开**(需看骨架屏与审批按钮);`v-show` 保留 DOM 避免重挂载
**代码变更**:`src/components/AiChat.vue`(template header/body 包裹 + script 状态与 watcher + style 折叠态),单文件 7 处改动
**验证**:vue-tsc --noEmit ✓(运行时待 `bun run tauri dev` 实测多步工具调用折叠效果)
**遗留问题 / 下一步**:
1. ~~审查项 ①~~ ✅ 已落地(2026-06-12):`isRunningOrPending(status)` → `shouldKeepOpen(tc)`,`running` / `pending_approval` / `rejected` / `write_file` 强制展开(短结果不折叠、原因/路径不丢失),仅 `read_file` / `list_directory` / 通用 JSON 大体量结果折叠。vue-tsc --noEmit ✓。决策详见 [功能决策记录 - 短结果保持展开](docs/02-架构设计/功能决策记录.md)
2. 实测验证:tauri dev 发多步工具调用消息,观察"执行中展开→完成→新内容追加自动收起→点击 header 重展开"
### [Sprint 11] 2026-06-13 — AI Chat LLM 优化(上下文窗口管理 + 并行化)
**任务 #43 续**(Part A + B 落地,Part C/D 待续)
**工作内容**:
**Part A: 上下文窗口管理(df-ai/context.rs + ai.rs 接线)** — 长对话无限增长致 `context_length_exceeded` 死锁
- **A1 ContextManager**(`crates/df-ai/src/context.rs` 新增 ~350 行):TokenEstimator 字符粗估(chars×0.35 + per_message 4 + per_tool_call 30,零依赖,保守 ±15%)+ ContextConfig(max_tokens 128k / output_reserve 8192 / safety 0.85)+ 分组滑动窗口淘汰——淘汰单元保工具调用三元组原子性(ToolCallHead + ToolResultTail* + 紧随文本 Assistant 同进同出),保护区最后 6 条永不裁;裁剪仅影响发送视图(build_for_request 返裁剪 Vec),不影响持久化(all_messages_clone 返全量)。5 单测全绿(短对话无裁剪 / 超预算裁剪保保护区 / 三元组原子性双分支 / replace 重估 token / restore 重建缓存)
- **A2 ai.rs 接线**(~15 处):AiSession.messages 类型 `Vec<ChatMessage>` → `ContextManager`;核心 run_agentic_loop 用 `build_for_request(sys_tokens)` 替代全量 `.clone()`;save/title 用 `all_messages_clone()`;switch 用 `restore_from_messages()`;`replace_tool_result` 收敛为 ContextManager 方法(DRY,删自由函数)
**Part B: 并行化改造(ai.rs)** — 串行瓶颈 + Completed 事件阻塞
- **B1 process_tool_calls 并行化**:Low 风险工具 `futures::future::join_all` 并行 execute + 即时 emit Completed,结果串行回填 session.messages(N 个独立工具 ≈ 最慢一个耗时,原 N 倍串行);Med/High 仍串行审批门控。tool_result push 顺序变化无语义影响(LLM 按 tool_call_id 关联,不看绝对位置)
- **B2 正常完成后台化**:save+extract+title 打包同一 `tauri::async_runtime::spawn`(非裸 spawn save——保 maybe_spawn_extraction 读已落库消息的顺序依赖);Completed 事件即时下发
- **B3 标题后台化**:`ensure_conversation_title` 签名收 `provider_config` 克隆进 task 自建 provider(`&dyn` 非 'static 无法 move),`spawn_ensure_title` 后台(含 2 处 stop 路径)
**关键取舍**:①打包 spawn 而非裸 spawn save(保 extract 顺序依赖);②并发 upsert 低概率丢少量 token 可接受(非功能错误);③停止路径保持同步 save(避免 stop 后即发新消息的并发 upsert 竞态丢 token)
**代码变更**:`crates/df-ai/src/context.rs`(新增)、`src-tauri/src/commands/ai.rs`(核心改造)、`docs/02-架构设计/功能决策记录.md`(8 条新决策:Part A 2 + Part B 3 + 既有的)
**验证**:cargo check -p df-ai ✓(5 单测绿)、cargo check -p devflow ✓(6 既有 warning,无 error,无单测回归)
**遗留问题 / 下一步**:
1. **Part C/D 未做**(todo #48-52 已建)—— Part C 双层 Semaphore(global 默认 3 / per_conv 默认 2)+ 3 LLM 调用点(stream_llm / 标题 / 提炼)加 permit + `ai_set_concurrency_config` command;Part D Settings.vue 两 setting-row + localStorage 持久化 + onMounted 同步
2. **实测验证**(P1):tauri dev 验证长对话裁剪行为 + 多工具并行耗时 + Completed 事件即时性
### [Sprint 12] 2026-06-13 — 灵感模块全面审查 + A 类问题修复
**工作内容**:
- **df-ideas 灵感模块全面审查**(前端 Ideas.vue / api / store + IPC idea.rs + crate 7 文件 + df-project/manager + df-storage/crud):按「界面展示 / 交互 / 功能可用性 / 合理性」+「功能架构 / 代码逻辑 / 代码架构」六维逐条核对,共 12 项发现(🔴5 🟡5 ⚪2)。复核确认 12 项全部真实存在、无误判无虚报。
- **修复 A 类 5 项**(明确知道怎么做、零争议,全在 Ideas.vue):
- ① 置信度进度条不显示填充(`::after` 无 width + 锚点跑到 `.debate-column`)→ 拆 `.confidence-bar`(灰轨)+ `.confidence-fill`(填充层,正绿反红),删失效 `::after`
- ② 状态 select 改选不生效(v-model 绑只读 computed 致写失败、回弹并写回旧 status)→ 弃 v-model,改 `:value` + `@change` 读 `event.target.value`
- ③ 删 `filterIdeas` 空函数及其 `@input` 绑定(filteredIdeas 本是 computed 自动响应)
- ④ 删除想法加 `confirm()` 二次确认
- ⑥ 对抗评估标题加「启发式」tag(诚实标注当前为启发式非 LLM,避免「对抗辩论」措辞误导)
- **决策**:死代码/双套抽象组(⑦⑧⑨⑩)缓到另一会话,届时重新审核问题真实性;B 类(⑤⑥根治⑪⑫)方案未定,待定方向后再推。
**代码变更**:`src/views/Ideas.vue`(单文件 9 处:模板 5 + script 3 + CSS 2 段)
**验证**:`vue-tsc --noEmit` ✓(零错误零输出)
**遗留问题 / 下一步**:
1. **B 类(有必要但方案未定)**:⑤ 立项引导(三种改法改变产品流程语义:放宽显示条件 / 保持 approved 门槛加提示 / 评估达标自动 approved)/ ⑥ 对抗评估接 df-ai LlmProvider 根治(大工程,单独立项;当前 UI「启发式」标记为临时绕过)/ ⑪ 状态常量集中(跨 Rust+TS 双侧,IdeaStatus 枚举已有但 IdeaRecord 用 String 落库)/ ⑫ 实体↔Record 映射(评估为合理分层,建议不改)
2. **缓组(另会话重审真实性后再动)**:⑦ df-ideas 死代码空壳(capture/graph/evaluator/promotion;IdeaGraph 关联图是已知下一步增强需求,删/留需定)/ ⑧ 两套 Recommendation 枚举 + 两套评估入口并存(adversarial 6 变体实用 vs evaluator 5 变体仅死代码引用)/ ⑨ PromotionPolicy 三档死枚举 / ⑩ Idea 实体构造分散(record_to_idea 与 IdeaCapture::capture 字段重叠)
3. **实测验证**:tauri dev 看置信度条彩色填充 / 状态切换生效 / 删除弹确认 / 评估区「启发式」标
### [Sprint 13] 2026-06-13 — 文档记录治理体系(路由规范 + decision-record skill + 降频自检 hook)
**背景**:功能决策/需求散落于对话、PROGRESS、模块文档,缺统一真相源与触发机制。建立「单一真相源 + 自动自检」治理体系。
**工作内容**:
- **文档记录规范**(`docs/02-架构设计/文档记录规范.md` 新建):核心原则(SSOT / 不复制只引用 / 决策与流水分离 / 先主后辅)+ 文档职责矩阵(每类信息唯一主文档)+ 内容路由表 + 更新顺序 + 唯一性记录与检测(记前 grep 查重 / 单向引用 / 定期扫描)+ 与 skill 关系 + 治理体系实现决策(hook 设计取舍)
- **功能决策记录扩展**(`docs/02-架构设计/功能决策记录.md`):约定节加需求条目说明;末尾新增「需求与待办」总览(📋 待做需求表 + 需求澄清)——散落各决策条目 📐/🚧 状态的待办汇集为单一清单
- **decision-record skill 增强**(`~/.claude/skills/decision-record/SKILL.md`):除「为什么这么定」决策,增记需求细节(待做/规格/澄清);维护规则(记前查重 / 演进 `→` 标注 / 定期压缩防爆炸);原则补「更新优先于新增」
- **降频自检 Stop hook**(`~/.claude/hooks/dr-check.sh` + `settings.json` Stop 配置):累计 ≥10 轮 或 (>1 轮 且 距上次 ≥10 分钟) 才注入自检提示触发 skill;纯脚本计数无 API(替代每轮自检,省 ~90% token);`stop_hook_active` guard 防循环;状态按项目隔离(`~/.claude/.dr-state/`,不污染 git)。设计取舍详见 [文档记录规范 - 治理体系实现决策](docs/02-架构设计/文档记录规范.md)
- **修 `docs/INDEX.md` 误登记**:PROGRESS 实际在根级,移除 `07-项目管理/` 树下的重复登记行
**代码变更**:非产品代码——`docs/02-架构设计/{文档记录规范.md,功能决策记录.md,INDEX.md}`、`~/.claude/{skills/decision-record/SKILL.md,hooks/dr-check.sh,settings.json}`
**验证**:hook 逻辑实测(首战静默初始化 / 2-9 轮静默计数 / 第 10 轮或 ≥10 分钟触发 / 防循环 guard);无 jq 依赖纯脚本
**遗留问题 / 下一步**:
1. hook 实际长期触发频率待观察(10 轮 / 10 分钟阈值是否合理,按需调)
2. 决策爆炸后执行首次压缩(> 300 行 / 老 Sprint > 3 / 单域 > 10 条触发归档或拆域)
### [Sprint 14] 2026-06-13 — 流式 token 用量记录 + Provider 默认态持久化
**工作内容**:
**A. 流式 Token 用量获取 + 记录 + 展示 + 设置开关**(跨 df-ai/df-storage/src-tauri/前端,11 文件)— `stream_llm` 全程丢弃 token 计数、`AiCompleted.total_tokens` 恒 0 的旧疾根治(Sprint 6 P1 token 控制中「usage 采集用户叫停」项重启落地):
- **Provider 层 usage 解析**:`StreamChunk` 加 `usage: Option<TokenUsage>`;OpenAI 开 `stream_options:{include_usage:true}` 末 chunk 取累计 usage;Anthropic `message_start`/`message_delta` 累积(output_tokens 当累计值覆盖,非增量)
- **累加落库**:`ai_conversations` 加 `prompt_tokens`/`completion_tokens` 列(V5 迁移);`save_conversation` upsert 走累加模式(读旧值叠加,非覆盖——保审批暂停→恢复跨 loop 实例总用量正确);`run_agentic_loop` 局部累加器每轮叠加
- **前端展示 + 开关**:store 双状态 `lastTokenUsage`(单次)+ `convTokenTotal`(对话累计,切对话从 DB 加载);AI 气泡底部 token 条(默认关闭,`df-show-token-usage` 开关放 Settings)
- **model 记录**:消息级 model + 对话级 models 数组(C+B),取 `default_model` 配置值补填不覆盖
**B. Provider 默认态持久化(is_default 重启丢失 bug 修复)** — 「设为默认」重启后丢失、两条记录都显示「设为默认」按钮的 bug 根治:
- 根因:`is_default` 字段恒写 `false` + `ai_set_provider` 只改内存 session 不落库 → 重启 session 清零默认全丢
- 修复:`ai_set_provider` 互斥落库(目标 true/其余 false,仅写变化记录);`ai_save_provider` 新建首个自动默认;`ai_list_providers` 直返 DB 值;`active_provider_id` 降为运行时缓存
**代码变更**:A — df-ai/{provider,openai_compat,anthropic_compat}.rs、df-storage/{models,crud,migrations}.rs、src-tauri/commands/ai.rs、前端 {types,stores/ai,components/AiChat,views/Settings}(11 文件);B — src-tauri/commands/ai.rs(3 函数)
**验证**:cargo check --workspace ✓(0 error,6 既有 warning)、vue-tsc --noEmit ✓。**未 tauri dev 运行时实测**
**遗留问题 / 下一步**:
1. tauri dev 实测:token 落库 / 切换对话总量加载 / 重启保默认 / 首个自动默认
2. `ai_save_provider` get_by_id 双调用(review 🟡①,冗余往返,待合并)
3. Settings 页面 UI 重构(Part D,用户说稍后)
4. model UI 展示(暂缓)
### [Sprint 15] 2026-06-13 — 知识库 Tier 1 全栈打通 + Phase 5.5 向量检索
**工作内容**:
**Tier 1 知识库双向闭环(AI Chat ↔ 知识库,全栈打通)** — candidate→published 状态机 + AI 提炼 + 手动录入 + 知识注入:
**A. 存储层(df-storage,V7/V8 迁移)**:
- V7 建表 `knowledges`(id/kind/title/content/tags/status/confidence/reuse_count/verified/source_project/source_ref/created_at/updated_at),3 索引
- V8 幂等补列 `embedding BLOB`(PRAGMA table_info 探测,同 v4/v5/v6 模式)
- `KnowledgeRecord`(models.rs),含 `embedding: Option<Vec<u8>>`
- `KnowledgeRepo`(crud.rs):`search()`(LIKE 双分支,按 kind 可选过滤)/ `list_by_status()`(CASE WHEN confidence 语义排序)/ `increment_reuse_count()`(SQL 原子 +1)/ `top_used()`;向量扩展:`set_embedding()` / `search_vector()`(纯 Rust 余弦批量比较,skip 维度不匹配);工具函数:`f32s_to_blob` / `blob_to_f32s` / `cosine_similarity`
**B. 领域层(df-evolve)**:
- `KnowledgeStatus` 枚举(Candidate/PendingReview/Published/Archived,snake_case,`from_str`/`as_str`)
- `Confidence` 枚举(High/Medium/Low,同上)
- `Knowledge` struct:移除 `effectiveness` 字段,时间字段统一 String 毫秒(对齐全部既有 model)
- `pattern.rs`:清理死参数 `result_quality: f32`
**C. IPC 层(src-tauri/commands/knowledge.rs 新建,11 个 Command)**:
- `knowledge_{list,get,search,create,update_status,record_reuse,list_candidates,archive,get_config,save_config,extract_now}`
- 状态转换合法矩阵硬编码:candidate→{pending_review|published|archived}; pending_review→{published|archived}; published→{archived}
- `knowledge_archive`(软删除,匹配 `ai_conversation_archive` 语义)
- 配置读写:`knowledge_{get,save}_config`(读写 `AppState.knowledge_config: Arc<Mutex<KnowledgeConfig>>`)
- 手动提炼:`knowledge_extract_now`
**D. AI 集成(ai.rs,知识注入 + 提炼)**:
- `build_knowledge_context()`:`auto_inject` 开关 → `hybrid_search()` top-3 → `increment_reuse_count` fire-and-forget → 注入 system prompt 头部
- `extract_knowledge_from_conversation()`:后台 spawn,min_messages≥4 守卫 → 取最后 6 条消息 → LLM JSON 提炼 → 逐条写 candidate;提炼失败仅 warn 不阻断
- `maybe_spawn_extraction()`:agentic loop 两处正常退出统一调用(`OnComplete` 默认模式)
**Phase 5.5 — 向量检索(Settings 开关,默认关闭)**:
- `LlmProvider::embed()` trait 方法:默认返回 Err,openai_compat 实现 POST `/v1/embeddings`(按 index 排序)
- `KnowledgeConfig` 加三字段:`vector_enabled`(默认 false)/ `embedding_provider_id`(排除 anthropic)/ `embedding_model`
- `generate_embedding()`:截断 8000 字 → build_provider → embed()
- `spawn_embedding_for_knowledge()`:知识发布时 fire-and-forget 生成嵌入(不在 candidate 阶段浪费调用)
- `hybrid_search()`:三层降级链——开关关→LIKE; embed 失败→LIKE; 正常→双信号排序(同时命中>仅 LIKE>仅向量 cos≥0.3);不引入 sqlite-vec(规避 Windows MSVC C 扩展编译风险)
- `Settings.vue`:语义检索开关 + v-if 联动 embedding provider 下拉 + 模型名输入
**E. 前端(TypeScript + Vue)**:
- `types.ts`:`KnowledgeRecord` / `CreateKnowledgeInput` / `KnowledgeConfig`(含 vector 三字段)
- `api/knowledge.ts`(新建,11 个 invoke 封装)
- `stores/knowledge.ts`(完全重写,getter 模式,消除全部 15 条 mock + localStorage)
- `Knowledge.vue`(完全重写:知识库/审核收件箱双 Tab + 状态徽章 + confidence 指示器 + 新增对话框 + 候选 badge)
- `App.vue`:侧栏候选 badge(`candidateCount` ref,store loadCandidates 后更新)
**F. 配置设计**(AppState 内存 + IPC,非 Tauri config 插件):
- `KnowledgeConfig`(state.rs):auto_extract / trigger_mode / min_messages / idle_timeout_ms / auto_inject / vector_enabled / embedding_provider_id / embedding_model
**代码变更**:df-storage(models.rs/crud.rs/migrations.rs)/ df-evolve(knowledge.rs/pattern.rs)/ src-tauri(state.rs + commands/knowledge.rs 新建 + commands/ai.rs + lib.rs)/ 前端(api/types.ts + api/knowledge.ts 新建 + api/index.ts + stores/knowledge.ts + views/Knowledge.vue + views/Settings.vue + App.vue)。约 1,400 行 Rust + 700 行 TS。
**验证**:cargo check 0 error ✓,vue-tsc 0 error ✓。**未 tauri dev 运行时实测**
**遗留问题 / 下一步**:
1. tauri dev 实测 10 项功能验证清单(手动录入 / 审核流 / 归档 / 检索注入 / reuse_count / AI 提炼 OnComplete / AI 提炼 Manual / 配置修改 / 注入开关 / 矛盾场景)
2. 向量检索待实测(需配 embedding provider + 开启开关 + 发布 ≥1 条知识后验证混合排序)
3. Tier 2 待做:ai_node prompt 注入 / 工作流 NodeFailed→pitfall / 决策记录→architecture_pattern(前置 traceability 持久化)
4. 知识库 API 对外暴露(MCP Shell 封装,外部工具 Claude Code/CodeX/Cursor 使用)
### [Sprint 16] 2026-06-13 — AI Chat LLM 优化收尾(Part C/D + Review 修复) + AI Node 单测
**工作内容**:
- **Part C LLM 并发控制**:`state.rs` 新增 `LlmConcurrency`(双层 `Arc<Mutex<Arc<Semaphore>>>` — 全局默认 3 / 单对话默认 2,运行时替换内层 Arc 重建);`ai.rs` 3 个叶子 LLM 调用点(stream_llm / generate_title / extract_knowledge)加 permit,acquire 顺序固定 global→per_conv 防死锁,stream 后显式 `drop` 不占工具执行槽;新增 `ai_set_concurrency_config` command(运行时调并发上限);`lib.rs` 注册
- **Part D Settings UI**:`Settings.vue` 通用设置区加「全局最大并发 / 单对话并发」两 setting-row + 前端 clamp(1-10 / 1-5)+ debounce 300ms + localStorage 持久化 + onMounted 启动同步 + onUnmounted 清 timer
- **Review 两轮 8 修复**:首轮 6(① per_conv 单例语义注释 / ② save-extract 并发注释改正 / ③ permit 显式 drop / ④ Settings clamp / ⑤ 合并 to_chat_messages 进 all_messages_clone / ⑥ system prompt 超 budget warn)+ 复审 2(后端 set_concurrency 补 clamp 边界对齐前端 / onUnmounted 清 debounce timer)
- **AI Node 单测**:`ai_node.rs` 重构抽 `parse_params` 纯函数(execute 参数解析剥离,单测不 mock LLM 零网络)+ 7 单测(缺参×3 / prompt 上游优先回退 / config 取值 / 可选默认值 / 显式参数解析)
- **任务收尾**:#43/#44 → completed(代码 + 单测全绿,GUI 实测转跟踪);新增 #54(GUI 动态实测跟踪任务)
**代码变更**:
- `crates/df-ai/src/context.rs`(Review ⑤⑥ — 合并 to_chat_messages / system prompt 超 budget warn)
- `src-tauri/src/state.rs`(LlmConcurrency 双层 Semaphore + per_conv 单例语义注释)
- `src-tauri/src/commands/ai.rs`(permit×3 + drop / process_tool_calls join_all 并行 / save 后台 spawn / ai_set_concurrency_config command + clamp)
- `src-tauri/src/lib.rs`(注册 ai_set_concurrency_config)
- `src/views/Settings.vue`(并发 UI + clamp + debounce + onUnmounted)
- `crates/df-nodes/src/ai_node.rs`(parse_params 重构 + 7 单测)
- `docs/02-架构设计/功能决策记录.md`(新增「AI Node」域记 parse_params 决策 + per_conv 退化条目)
**验证**:cargo check 0 error ✓ / df-ai context 5 单测 ✓ / df-nodes ai_node 7 单测 ✓ / vue-tsc 0 error ✓。**未 tauri dev 运行时实测**(转 #54 跟踪)
**遗留问题 / 下一步**:
1. **#54 GUI 动态实测**(用户跑 tauri dev,Claude 核对日志):长对话裁剪(context_trimmed 日志 + DB 全量)/ 多工具并行(Low join_all 时序)/ save 异步不阻塞 Completed / 双层 Semaphore 限流(多对话撞 RPM)/ Settings 并发即时生效 + 刷新保持 / DAG ai 节点真调 LLM
2. ~~AI Node 可补 GLM 集成测试~~ ✅ 已补并通过:`glm_live_complete`(`#[ignore]` + env var)真调 GLM glm-4-flash 返回"通过",usage 解析正确(provider 调用层验证;GUI DAG 触发仍属 #54)
### [Sprint 17] 2026-06-13 — 模块文档↔代码一致性审查(4 文档纠错补全)
**工作内容**:
- **审查范围**:docs/03 模块文档(df-ai / df-storage / df-workflow)+ docs/01 技术文档(SQLite-CRUD 模式)共 4 份 vs 对应 crate 代码,三代理并行审查
- **🔴 硬伤 28 全修**:df-ai(LlmProvider trait 签名 / 删 OpenAI 虚构 idle-timeout + finished_received / ContextManager 方法名 replace_tool_result_content + build_for_request 返回元组 / Token 公式 / Anthropic usage take() / 工具归属)/ df-storage(V1 表名 / Repo 表名 releases·workflow_executions / search published 过滤 / increment_reuse_count updated_at / cosine epsilon / blob 截断)/ SQLite-CRUD 整篇过时(表 6→11 / CRUD 待→已 / 时间 INTEGER→TEXT / 写返回 ()→String·bool / 连接池→单连接 / schema.rs→migrations.rs)/ df-workflow(Node trait 按值 + 默认 false + node_type / 文件名 conditions·eventbus / 虚构事件区分已发未发)
- **⚪ 虚构 7 全清**:df-workflow(阻塞节点「等待人工操作」标未实现 / 持久化快照删改仅内存态 / 断点续跑补无暂停恢复机制)/ df-ai(conditions.rs 不存在,清状态表 + 决策缺口表虚构引用 / AiToolRegistry「12 工具」夸大改基础设施)
- **🟡 核心 8 + 碎琐 10 补全**:df-ai(apply_openai_sse / apply_anthropic_event 纯函数 / ContextManager 6 pub 方法 / Anthropic 事件全集 / 文件结构 router·stream / URL 三分支 / finish=length / max_tokens 4096 / PROTECT_COUNT / budget 公式)/ df-workflow(NodeRegistry / DagDef / StateMachine / Dag API / NodeContext·NodeOutput 字段 / EventBus API / 条件引擎默认放行 warn)/ df-storage(list_non_archived / set_archived 例外对照)
**代码变更**:无(纯文档纠错,代码未动)
**验证**:每项修正均经代理 Read 代码复核签名/SQL/行为后改;git diff 4 文档落地(df-ai 443 / df-storage 134 / SQLite-CRUD 56 / df-workflow 122 行变动)
**遗留问题 / 下一步**:
1. 4 文档已与代码实质一致;后续若改 df-ai/df-storage/df-workflow 公开接口,需同步对应模块文档(文档治理:模块文档随代码变)
2. df-evolve(knowledge.rs / pattern.rs)本轮未审 — docs/03 无对应模块文档,若补可另起审查
### [Sprint 18] 2026-06-13 — 知识生命线(详情页 + 溯源 + 引用 + 生命周期)
**工作内容**:
- **根因**:知识库 candidate 卡片点不开详情、不能编辑、看不到"为什么产生";reasoning 字段被 prompt 要求但写库丢弃(bug);increment_reuse_count 只 +1 不记去向;无状态变更历史。用户要求呈现知识完整生命周期:产生→审核→引用→归档,且约束异步不阻塞、代码简洁易扩展。
**A. 存储层(df-storage,V10 迁移)**:
- V10:幂等补 `knowledges.reasoning TEXT` 列 + 新建 `knowledge_events` 审计表(id/knowledge_id/event_type/source_ref/context_json/timestamp + 3 索引)
- `KnowledgeRecord` 加 `reasoning: Option<String>`;新增 `KnowledgeEventRecord`(6 字段)
- `KnowledgeEventsRepo`(impl_repo! 宏 7 方法 + 扩展 `list_by_knowledge`/`list_by_knowledge_type`);5 处显式 SELECT 列列举 + from_row + insert/update body 全线接 reasoning
**B. 后端埋点(src-tauri)**:
- **新建 `commands/knowledge_timeline.rs`** — `KnowledgeTimeline` 独立记录器(持 Arc<Database>),便捷方法 record_extracted/referenced/status_change/created,内部 `fire()` 吞错误 fire-and-forget。各业务流程只调一行,不内联写 SQL(代码简洁约束)
- `extract_knowledge_from_conversation`:回填 reasoning(修 bug)+ 查对话标题 + insert 后记 extracted 事件
- `build_knowledge_context`:加 conv_id 参数 + spawn 块内 increment_reuse_count 后记 referenced 事件(conv_id+query);调用点上移 conv_id 快照
- `knowledge_create/update_status`:记 created/status_changed 事件(归档复用 status_changed,删 dead code EVENT_ARCHIVED/record_archived)
- 3 新 IPC command:`knowledge_get_detail`(聚合 knowledge+events)/`knowledge_update`(部分更新编辑)/`knowledge_events`(查生命线);AppState 加 `knowledge_events` Repo;lib.rs 注册
**C. 前端**:
- types.ts:KnowledgeRecord 加 reasoning + 新增 KnowledgeEventRecord/KnowledgeDetailPayload/UpdateKnowledgeInput
- api/store:各加 getDetail/update/getEvents
- **Knowledge.vue 全面重构**:Ideas 式左右分栏(左卡片列表 @click 选中 / 右详情四分区 — ①基本信息+编辑 ②溯源(产生方式/来源对话/AI reasoning) ③引用记录(复用次数+最近 N 条) ④生命周期时间线)。可编辑 title/content/tags/confidence
**关键决策**(详见 docs/02-架构设计/功能决策记录.md):① 独立 events 表非 JSON 嵌主表(引用数百次防行膨胀);② 独立 KnowledgeTimeline 记录器(不散落埋点);③ 归档复用 status_changed(防冗余);④ reasoning 回填(修 prompt 要求但丢弃的 bug)
**代码变更**:df-storage(migrations/models/crud)+ src-tauri(state/knowledge_timeline 新建/ai/knowledge/lib/mod)+ 前端(types/api/knowledge.ts/stores/knowledge.ts/Knowledge.vue)= 12 文件
**验证**:cargo check --workspace ✓(仅既有警告)/ vue-tsc --noEmit ✓ 零错误 / cargo test -p df-storage ✓ 21 单测全过。**未 tauri dev 运行时实测**
**遗留问题 / 下一步**:
1. **#54 GUI 实测**:AI 对话结束→提炼→收件箱→点详情看 reasoning+来源;发布→生命线 status_changed;新对话注入→referenced 事件;编辑 candidate→保存
2. 老库历史 candidate 无 reasoning/events,详情页降级展示(手动录入/无溯源)——属预期兼容
3. knowledge_events 纯追加表,长期可清理 >90 天 referenced 事件(本次不做)
### [Sprint 19] 2026-06-14 — list_directory 防爆 + localStorage→SQLite 统一持久化 + token/发送 bug 修复
**工作内容**:用户报多个 AiChat bug + 提需求:list_directory 扫出 13782 项塞爆对话;token 显示"0 in";发送卡住/留框;LLM 并发上限太小;localStorage 散落要统一入 SQLite。同时用户并行主导 ai.rs→ai/ + ai.ts→composables 模块拆分(我观察配合,不接管)。
**A. list_directory 递归防爆(Part A)**:
- `list_dir_recursive`(ai/tool_registry.rs)加噪音目录剪枝(`.git`/`node_modules`/`target`/`dist`/`build`/`.next`/`.cache`/`__pycache__`/`.venv`/`venv`/`.idea`)——**列出但不深入内部**;硬上限 1000 条 + `truncated` 标志;默认 max_depth 3→2;加 `skip_noise_dirs` 参数(默认 true,AI 看编译产物 dist/target 时传 false)
- 根因:AI 广扫项目根传 `recursive:true`,噪音目录铺平致 13782 项塞进对话 message(UI 卡 + token 爆)
**B. localStorage → SQLite 统一持久化(Part B)**:
- 新建 `app_settings` KV 表(V13 迁移)+ 手写 `SettingsRepo`(get/set/get_all/delete,不走 `impl_repo!` 宏因 KV 无固定 schema);4 IPC(settings_get/set/get_all/delete);AppState 加 settings 字段
- 前端 `appSettings` store(reactive 缓存 + 300ms debounce + `useSetting` ref)+ api 封装
- **11 key 迁移**:敏感 `df-connections` + UI 偏好(theme/language/ai-width/ai-ui/token/concurrency)+ `df-ai-active-conv`;**例外 `df-ai-gen`/`df-ai-text`**(流式临时快照,高频写留 localStorage)。App.vue onMounted 一次性迁移旧 localStorage
**C. Bug 修复**:
- **token prompt=0**:GLM 流式 usage 带 completion 不带 prompt → 加估算兜底(provider 返回 0 时按请求消息 `TokenEstimator::estimate_message` 求和)
- **发送卡住/留框**:`sendMessage` IPC 失败时无 catch → streaming 永真(光标卡死)+ handleSend 回填输入(消息已显示);改 try/catch 回滚 streaming + 清空气泡占位
- **LLM 并发上限**:三层(模板 max / JS Math.min / 后端 .clamp)全去上限,保留 min=1 + `per-conv ≤ global` 约束
**D. token 暴增诊断 + 清理**:
- 查 SQLite 实数据:对话 e4365d47 累积 in=115万 / out=2万 / 消息体 1.6MB
- 根因:**单条 list_directory 工具结果 1.2MB**(13782 项,Part A 修复前产生)落库后每轮重发 + save 累加 → 暴增;token 估算准,膨胀是真(非估算锅)
- 删除污染对话(`ai_conversations` 1 行 + `ai_tool_executions` 123 行)
**E. 模块拆分(用户主导,本会话发生,我配合不接管)**:
- `ai.rs`(2651 行)→ `ai/` 11 子模块(agentic/audit/commands/conversation/knowledge_inject/mod/prompt/skills/stream_recv/title/tool_registry),旧 ai.rs 删
- `stores/ai.ts`(749 行)→ 薄壳 + `composables/ai/*`(events/stream/send/conversations/window/panel)
- `df-evolve` crate 移除(workspace/代码零引用)
- 踩坑(glob 重导出命令宏符号 / super 路径失效 / 0 字节残留触发 E0761)已记入经验记录.md
**关键决策**(详见功能决策记录 / 经验记录):① list_directory 剪枝 + skip_noise 保留访问能力(定点查/强制递归两路通);② KV 表统一持久化复用 SettingsRepo(兑现经验记录预言);③ token 估算兜底(provider 不报时按消息估);④ 发送失败回滚 streaming;⑤ 并发去上限保 per-conv≤global
**代码变更**:src-tauri(`commands/ai/` 新模块 + `commands/settings.rs` + state.rs + lib.rs)+ crates/df-storage(migrations V13 + crud SettingsRepo)+ 前端(api/settings.ts + stores/appSettings.ts + App.vue/Settings.vue/composables/ai/* + i18n)≈ 25 文件
**验证**:cargo check ✓ / vue-tsc ✓ 零错 / tauri dev 启动 ✓ 运行;token/发送/并发/list_directory 已实测
**遗留 / 下一步**:
1. **Fix 1(待用户最终定)**:工具结果入库前截断 50KB(堵 `read_file` 1MB 洞 + 防御);Fix 2(放宽旧工具结果裁剪)因 Part A 已杀主源,延后不做
2. Settings.vue(1037 行)拆 panel 子组件——早前建议,用户已做 ai.rs,此件待评估
3. 诊断日志清理(handleEvent/approveToolCall 早期调试 console.log 残留)
### [Sprint 20] 2026-06-14 — 项目管理模块代码审查 + 全局核对(3-agent review 验证)
**工作内容**: 应用户要求审查「项目管理 / 项目导入 / 生命周期可用性」(后端 commands/project.rs + ai/tool_registry.rs + df-project/scan.rs + 前端 Projects.vue / ProjectDetail.vue)。先 3-agent 并行 review 出 15 项发现,剔幻觉 2 项(read_readme 命名误判 / conflict.name null 过度防御)后剩 13 项;再应要求**全局核对**(grep 全仓库 6 维度)确认每项发现是孤例还是系统性,保证全局成立有效。**本次仅审查 + 核对,未改代码。**
**验证结论**: 13/13 发现**事实全部成立**,无幻觉。4 处行号偏差(① 249→263 / ⑤ 113→136 / ⑫ 130→145 / ③ 未标→271);2 处范围需补(⑧ 实 8 处非 3 处 / ③ 漏 relocate:212)。
**🔴 必须修复(3)**:
1. **AI delete_project 硬删绕过软删**(tool_registry.rs:263 `repo.delete` 物理 DELETE vs IPC project.rs:120 `soft_delete` 进回收站)—— 同一操作两种后果,AI 对话删 = 数据丢失不可恢复,UI 删 = 可恢复。**数据安全缺口**
2. **AI 缺回收站生命周期工具**(restore/purge/list_deleted 全无)—— ProjectRepo 有完整方法(crud.rs:572/591/610/634)+ IPC 全注册(project.rs:125-145),AI 层零覆盖,割裂工作流
3. **collect_sample 同步 IO 阻塞 async**(project.rs:271 在 async fn 内直接同步调 df-project::scan)—— 对比 scan_project_stack(:182)用了 spawn_blocking,不一致
**🟡 建议改进(6)**: ④ normalize_path 两份重复(project.rs:154 + tool_registry.rs:180)/ ⑤ update 白名单双份不同步(tool_registry:136 硬编码 vs crud.rs:287 allowed_columns,idea_id 字段不同步)/ ⑥ AI 扫描 + 目录状态文案硬编码未走 $t(Projects.vue:33/195 + ProjectDetail.vue:93)/ ⑦ parseStack 两份重复(Projects.vue:143 + ProjectDetail.vue:321)/ ⑧ 原生 alert/confirm(全局 8 处非仅 ProjectDetail 3 处,Ideas.vue:378/391 同病)/ ⑨ collect_sample 无文件大小预检(scan.rs:151/205 read 全文后截断)
**⚪ 可选(3)**: ⑩ LLM 无超时(project.rs:297 占 llm_concurrency permit 无限 await)/ ⑪ JSON 提取取首尾花括号(project.rs:368-369 多 JSON 场景错)/ ⑫ create_project 无 path 参数(tool_registry:145 需 create+bind 两步)
**全局核对关键定性**(6 维度 grep 全仓库):
- **① 是 project 特例但最该修**: soft_delete/回收站机制**全仓库仅 project 一张表有**(task/idea/branch/release/workflow 本就物理删,无语义不一致);正因 project 唯一设计回收站,AI 硬删直接击穿其数据安全设计
- **⑧ 系统性**: 全仓库 8 处原生弹窗(ProjectDetail 4 + Projects 2 + Ideas 2),非局部
- **③ 范围补报**: 不只 :271,relocate_project_path:212 同步 detect_stack 同样阻塞,3 处未包 spawn_blocking vs 1 处包了
- **src-tauri 层自身干净**: `std::fs` 同步调用零匹配,阻塞全在跨 crate 调同步的 df-project::scan
- **④⑤⑦ 精准**: 全仓库确为两份/一处,无更多副本;validate_path/resolve_workspace_path 是安全校验,职责不同非 normalize 重复
**未改代码**(待用户定推进批次)
**遗留 / 下一步(修复批次建议)**:
- **批1 数据安全**: ①(AI delete 改 soft_delete)+ ②(补 restore/purge/list_trash 三工具,复用 ProjectRepo 对应方法)
- **批2 阻塞 + 去重**: ③(:270/:271/:212 三处统一 spawn_blocking)+ ④(抽公共 normalize_path 到 df-project/df-core)+ ⑤(tool_registry 复用 allowed_columns_for)
- **批3 前端**: ⑥(补 i18n key)+ ⑦(抽 src/utils/project.ts)+ ⑧(全局 8 处 alert/confirm 换 Arco Modal)
- ⚪ 可选: ⑨⑩⑪⑫ 按需
---
### [设计] 2026-06-14 — B-03 人工审批响应机制设计
**工作内容**:df-workflow HumanNode 审批响应机制设计(B-260614-03)。勘察确认链路基础设施 90% 已通(EventBus broadcast / `HumanApprovalResponse` 事件 / `approve_human_approval` IPC / 前端 store 已接通),唯一缺口为 `human_node.rs:55` 发完请求直接 return "同意"。设计:订阅时序铁律(`subscribe()` 先于 `send(Request)`)+ `tokio::select!` 循环(响应 / 超时 / 取消)+ execution_id+node_id 双键过滤 + Lagged 容忍。澄清 B-06/B-07 前置真实影响(B-06=并发隔离,非单流功能前置;B-07=取消必要非充分),拆 **B-03a**(响应等待 + 超时)/ **B-03b**(取消机制:`set_cancelled` + cancel IPC + 前端按钮)。
**产出**:完整设计文档 [B-03-人工审批响应机制.md](./docs/02-架构设计/B-03-人工审批响应机制.md);功能决策记录加「工作流人工审批节点(B-03)」摘要章节。
**下一步**:B-06(execution_id 下沉)/ B-07(共享 StateMachine)前置修复 → 落 B-03a 实现 → B-03b 取消机制补全。
### [Sprint 21] 2026-06-14 — Workflow 编排推进 Sprint 20 批次 + 审批闭环实施
**工作内容**: 以 workflow(实施 + 对抗验证两阶段,文件域隔离 + prompt 钉死约束)并行推进 Sprint 20 审查发现 + df-workflow 审批闭环。先 commit 基线 cf017f8(Phase2 阶段收尾 167 文件,排除无关的 `src-tauri/儿童每日打卡应用/`),后 A/B/C/D 四 workflow 各独立 commit 隔离可审。失控教训内化:workflow 确定控制流替代 background agent stop hook 循环,全程禁碰决策记录 + 禁自主续推。
**A 数据安全**(commit 3f0839a): AI `delete_project` 由 `repo.delete` 物理删改 `soft_delete` + 新增 `restore_project`/`purge_project`/`list_trash` 三工具(复用 ProjectRepo 回收站方法)+ `list_projects` 改 `list_active` 排除回收站(验证捕的漏网:LLM 原能看到已软删项目)。tool_registry.rs。
**B 阻塞去重**(commit d5a6417): `collect_sample`/`detect_stack` 四处 `spawn_blocking`(含验证捕的 bind_directory 漏网)+ `normalize_path` 抽公共到 `df-project/scan` + tool_registry `update_project` 复用 crud `is_allowed_column`(删硬编码白名单,与 CRUD 同源)。scan.rs/crud.rs/project.rs/tool_registry.rs。
**C 前端**(commit 02ff88f): 扫描/目录状态文案走 `$t`(zh-CN+en)+ `parseStack` 抽公共 `src/utils/project.ts` + 8 处原生 `alert`/`confirm` 全换(confirm→ConfirmDialog / alert→Arco Message),控制流语义不变。vue-tsc 0 error。8 文件。
**D 审批闭环**(commit 22964a2): B-06 DagExecutor 接收 `execution_id` 下沉 NodeContext(消除 dummy)+ B-07 `node_status` 共享 `self.state_machine.clone()` + B-03a HumanNode `execute` 重写 `subscribe→send→select!`(响应/超时/取消 + execution_id+node_id 双键过滤 + Lagged 容忍,照 B-03 设计)+ eventbus 删 `try_recv_human_approval` 死代码 + human_node 7 单测。executor.rs/workflow.rs/human_node.rs/eventbus.rs。
**验证**: cargo check 0 error / df-storage 32 + df-project 4 + df-workflow 11 + df-nodes 14 test 全过 / vue-tsc 0 error。A/B 各 1 issue(list_projects 泄漏 / bind_directory 漏网)主代理补修;C/D 验证 safe 0 issue。
**遗留 / 下一步**: B-03b 取消机制(`set_cancelled` + cancel IPC + 前端按钮)待做;T-09/T-10 非阻断记 todo;架构前置 df-ai-core(F-07)待设计解锁 F-03。
### [Sprint 22] 2026-06-14 — Wave1 收尾清债(workflow 推进 + 主代理补完 agent 半成品)
**工作内容**: Wave1 四任务并行 pipeline(实现 + Explore 对抗审查,文件域隔离),收尾 Sprint 21 遗留的 B-03b 审批取消机制 + 三个 P2 清债项。每个 Agent 钉死约束(只碰白名单 + 禁碰决策记录/构想文档 + 禁自主续推 + cargo check 自验)。
**B-03b 审批取消端到端**(commit 4aa689e): agent 初版完成三件基础设施(`set_cancelled` + cancel IPC + 前端取消按钮)但留**半成品**——`workflow_cancel_state` 全局孤立 StateMachine 实例,IPC `set_cancelled` 写入后无法达运行中 HumanNode 的 `ctx.node_status`(executor.state_machine 的 clone,B-07 仅 clone 传递未共享底层 HashMap)。**Explore 审查 agent(无 Edit 权限,只验编译 + 白名单)漏抓此语义缺陷**。主代理补完:StateMachine 内部 `HashMap`→`Arc<Mutex<HashMap>>`(clone 即共享底层)+ AppState `workflow_state_registry`(execution_id→StateMachine)+ run_workflow 注册执行器状态机/完成清理 + cancel IPC 经 execution_id 取共享引用 `set_cancelled` 直达 HumanNode `is_cancelled` 轮询。`clone_shares` 单测验证共享语义。state.rs/executor.rs/state.rs(tauri)/workflow.rs/lib.rs/project.ts/ProjectDetail.vue。
**T-09 级联删**(commit 89da9fa): idea.rs:149 `delete`→`purge_with_descendants`(防孤儿子记录),1 行签名兼容。
**T-10 canonicalize**(commit 89da9fa): 判定已解决——`normalize_path` 已含 canonicalize 优先 + 词法回退,`find_binding_conflict` 两端对称已用,零改动。
**T-12 df-ideas 死代码**(commit 89da9fa): capture.rs 删 `CaptureInput`/`IdeaCapture` 死码,保留 `Idea`/`IdeaScores` 共享实体;promotion/scoring/adversarial 内"两套 Recommendation/PromotionPolicy 死枚举"嫌疑未确认存在/保留为对外契约,本次未动待复查。
**验证**: cargo check 0 error / cargo test 全过(0 failed,含 `clone_shares` 新测)/ vue-tsc 0 error。
**教训**: workflow 对抗审查用 Explore agent 能防越权 + 验编译 + 验白名单,但抓不到"功能实际不工作"类语义缺陷(IPC 写了读不到、共享未通)——这类需主代理读实现逻辑复核,不能只信审查 verdict。
**遗留 / 下一步**: Wave2 候选 F-07(df-ai-core trait 下沉,解锁 F-03,需先定 trait 边界);T-12 df-ideas 其余嫌疑项待复查。
### [Sprint 23] 2026-06-15 — 全量待办编排 + 首批 A+B 清债热身
**工作内容**: 全量 `todo.md`(~40 未完成项)汇总按可执行性分 8 组(A 卫生 / B 零风险减法 / C 需用户输入 / D 功能 / E 架构 / F 全局 review P2 / G 测试 / H 长期池),加「编排推进总览」章节供后续会话全局视图。首批推进 A+B 组。
**ARC-08 转自研块级 memo**(前会话遗留补记,待 commit): 流式 Markdown 渲染——方案 D(markstream-vue)试装后样式还原成本高且脆(代码块 chrome / 暗色 `--ms-*` 变量 / prose 作用域 4 处对接随库漂移),转自研块级 memo(方案 B 增强版):保留 marked+DOMPurify+.ai-md 原样式零对接 + splitBlocks 块级 memo O(末块) + rAF 节流 + 末块不缓存处理未闭合 token。退役 AR-1 流式纯文本短路。详见 [aichat流式Markdown渲染调研-2026-06-15.md](docs/02-架构设计/构想审查/aichat流式Markdown渲染调研-2026-06-15.md) §5。
**A todo 卫生**:
- AR-1 标退役(被 ARC-08 取代)
- AR-8 重评:前端 rAF 已被 ARC-08 覆盖,剩后端 50ms 合批 + 滚动,降优先级
- ARC-03 降级重评:df-execute **非空壳**(shell.rs `execute()` 完整 + 被 `run_command` 复用),原「空壳合并」前提失效,转架构维护决策非清债
**B 清死链**(ARC-260615-02): 删 App.vue secondaryNav `/decisions` 死链项(router 无此路由,决策治理 = F-260614-08 长期项入口提前占位)+ 删 `nav.decisions` i18n key(zh/en)。`dashboard.recentDecisions`(Dashboard.vue)不同命名空间保留。
**代码变更**: `src/App.vue` / `src/i18n/{zh-CN,en}/nav.ts` / `docs/todo.md`(编排总览 + AR-1/AR-8/ARC-02/ARC-03 状态回写)。
**验证**: vue-tsc EXIT=0。
**B-03b-R8 human 端到端集成测试**(待 commit): human_node.rs 加 2 端到端测(`end_to_end_human_approval_completes_workflow` / `end_to_end_human_approval_cancelled`),覆盖 executor 驱动 a(SleepNode)→b(HumanNode) 两层 DAG 的 Request 真发出 + outputs 收集 + 双节点 Completed,以及外部 set_cancelled → human cancel_tick → executor 跳过 set_failed + 状态保持 Cancelled。封死 R6/R7 回归土壤。10 测全过(8 现有单测 + 2 新端到端)。
**遗留 / 下一步**: 下批方向三选一(D 功能增强 F-15-01~04 / E 架构重投入 F-14-01·07 / G 测试 dev 验证 ARC-08 + T-14-01·02);C 组阻塞项(S-260615-01 curl 测 / S-260614-01 多开澄清)等用户输入。
### [Sprint 24] 2026-06-15 — P0 generating 状态机加固 + 独立项批量
**工作内容**: 多代理编排推进——P0 generating 卡 true 根治组合(B-09/10/11)串行 + 5 独立项 workflow 并行 + 3 项 read-only 勘察下一波。主代理全程核查(失控防护)。
**P0 三项**(generating 卡 true 根治,agentic.rs + commands.rs):
- B-09: GeneratingGuard RAII(new/reset().await 幂等/Drop spawn 兜底)+ run_agentic_loop 入口实例化 + 5 处手动复位替换 + try_continue:318 保留手动(should_continue=false 须保 generating=true 待审批)
- B-10: ai_conversation_create 加 app:AppHandle(Tauri 自动注入)+ generating=true 软复位(记 old_conv→复位→清 pending→stop_flag→释锁→emit 零 token AiCompleted→relock 续建)+ 硬拦移除
- B-11: 两处 conv_id 一致性校验(循环顶部 + push 块内,防软复位后旧 loop 污染新对话)
**独立 7 项**(B-19 根因指针随 09~18 销账):
- B-12: SessionState enum 视图(Idle/Streaming/AwaitingApproval)+ session_state() 读侧收敛(mod.rs,三调用点注释待后续替换)
- B-18: pending_approvals 单例设计文档注释(mod.rs)
- R-PD-4: keyring 迁移失败阈值告警(MIGRATION_FAIL_THRESHOLD=3 + sidecar 计数 + 达阈值升级 warn,secret.rs,不改兼容时序)
- R-PD-5: approve IPC 加 decision∈options 校验(⚠️半完成:前端 stores/project.ts:252 未传 options,校验形同虚设,待前端补传触发)
- R-PD-13: run_workflow Lagged 结构化 warn + 风险注释(workflow.rs)
- B-20: handleSend catch 加 reactive toast(AiChat.vue 复用 Settings 模式,文案待 i18n)
- B-21: sendMessage catch 一并回滚 user message(useAiSend.ts)
**勘察下一波**(read-only workflow wxflofhf2): F-260615-02 TaskDetail(已完成核查通过:get_task_by_id IPC + /tasks/:id 路由 + TaskDetail.vue 11 字段 + 列表跳转,cargo check/vue-tsc 0 err)/ F-260615-04 ToolCard折叠(plan 11 步复杂+改核心 ai 文件+细节有误,拆小或留待)/ AR-11 数据联动(勘察 implPlan 含伪代码错误 std::env::var/.match,暂缓待重设计 emit 点)。
**代码变更**: agentic.rs / commands.rs / mod.rs / secret.rs / workflow.rs / AiChat.vue / useAiSend.ts。
**验证**: cargo check 0 error / vue-tsc 0 error / df-nodes 17 test pass(含 2 human 端到端测,P0 不破坏)。主代理独立核查(非 agent 自报):git diff --stat 文件边界 + 编译复核 + R-PD-4 落地核查(agent 报告夸大称改 75 行实际在更早基线,功能存在即销账)。
**失控防护**: 每 agent prompt 锁死文件 + 禁碰决策记录 + 禁自主扩展 + 返回 schema。R-PD-4 agent 把已有代码当自己的(边界检查不准,非造假,同类 WF-G AR-7)。
**遗留 / 下一步**:
- R-PD-5 半完成:前端补传 options 触发新校验
- B-13~17 待续(stop 兜底/Notify 即时打断/heartbeat 提外/MAX_AGENT_ITERATIONS 配置化/key_len 复用)
- F-260615-02 TaskDetail 实施中(后台 agent)
- AR-11 暂缓(等本批提交后重设计 emit 点,audit.rs app_handle 可达性待核查)/ F-260615-04 拆小或留待
### [Sprint 25] 2026-06-15 — P0 收尾 + 下一波编排
**B-15/16/17 P0 收尾**(零行为变优化):
- B-15: stream_recv.rs heartbeat interval 提到 loop 外(省每轮重建,节拍不变)
- B-16: agentic.rs MAX_AGENT_ITERATIONS pub(crate)→pub const + 配置接入点注释(无现成配置槽,未引入 AppState 字段)
- B-17: agentic.rs key_len 复用 resolve_provider_secret(去重复 keyring resolve,build_provider 内联因 secret.rs 锁边界,DRY 隐患注释标注等价性)
- 核查:cargo check 0 err / df-nodes 17 test pass
**下一波编排 workflow wcvigw3z4**(3 实施 + 4 勘察,循环链路):
- 实施(文件隔离):B-13 stop 兜底(commands.rs)/ R-PD-5 前端补 options 闭环(stores/project.ts)/ CR-05+07 块级 memo 低风险子项 parseBlock DRY + escapeHtml 抽(AiChat.vue,禁碰 splitBlocks/loadMarkdown 流式核心)
- 勘察 read-only(为下一波确定):B-260615-22 状态同步两方案对比 / B-14 stop_flag→Notify 即时打断跨4文件设计 / F-260615-01 HumanNode 多选审批契约变更影响 / ARC-06 composables/ai 循环依赖拆点
**遗留 / 下一步**: 等勘察结果派下一波实施;AR-11 emit 点已查明(audit.rs process_tool_calls 有 app_handle)待本批提交后派;F-260615-03 breaking change / F-260615-04 需定交互 待用户确认;改动堆积(git diff 100+ 文件)建议择机提交收口。
### 2026-06-18 03:08 — sweep-fix 6 域走查 + P3 小修(Code-Working 会话·workflow wd2fnjh3s)
**工作内容**:
- workflow wd2fnjh3s 6 域并行走查(rust-ai/dag/storage/ipc + fe-aichat/fe-other,12.4 分钟·6 agent·651k tokens):修 12 处确定性低风险 bug + 新发现 27 todo(去重 24 入 todo.md)
- 主代独立核查(审查防污染不信 agent 自报):cargo check --workspace EXIT 0 + vue-tsc EXIT 0 + grep 抽查 scan/audit/commands 源码形态全正确;**拦截 1 瞬态误报**(fe-other vue-tsc 抓 ToolCard.vue:653 cmdOutput TS6133 never read,实为 fe-aichat 改 ToolCard 中间态被并发 vue-tsc 抓到,最终态模板已引用,全量 vue-tsc 复核 EXIT 0 确认非 bug)
- 主代 P3 小修 5 处(各步自测通过):SW-18 AiChat wasNearBottom 切会话重置 / SW-21 TaskDetail wfResult timer 清理 / SW-23 Ideas neutral CSS / SW-15 ai_node schema required 对齐 / SW-24 dead_code 标注(finish_reason/call_type 2 处 #[allow],cargo df-ai warning 消除)
- 文档:todo.md 24 newTodos 去重追加 + F-260616-15 矛盾项销账 + 5 P3/SW 销账;待审查.md CR-260618-01 登记 17 处改动交其他会话审查
**代码变更**:
- df-ai: context.rs(parts token 预算,F-05 回归)/ anthropic_compat.rs(Image move)/ openai_compat.rs(2 处 #[allow(dead_code)])
- df-project: scan.rs(to_ascii_lowercase 索引对齐 + is_monorepo null 判定)
- df-ideas: adversarial.rs(MockProvider 补 reasoning_content)
- src-tauri: audit.rs(audit_finalize 吞错分流 + PENDING_APPROVAL_PLACEHOLDER 常量)/ commands.rs(ai_conversation_delete retain)
- df-nodes: ai_node.rs(schema required=[] 对齐 execute)
- 前端: ToolCard.vue(isFailed/cmdOutput computed)/ AiChat.vue(虚拟滚动死注释清理 + wasNearBottom 重置)/ Projects.vue(_toastTimer onUnmounted)/ Knowledge.vue(parseContext 单次)/ TaskDetail.vue(wfResult timer 清理)/ Ideas.vue(neutral CSS)
**遗留问题**:
- 1. **UX-2025-12 分页懒加载(高价值中风险)**:多角度论证暂缓——①必须 limit+滚顶两全(limit 单独引入"看不到 >limit 旧消息"回归,之前全量可见)/ ②token 紧张 180 行 7 文件大工程易出错(borrow/sentinel 时序) / ③长对话卡顿是渲染体验问题 cargo/vue-tsc 只验编译类型,滚动流畅度需用户实测 / ④工作区已累积 17 处 commit-policy-deferred 不提交再加 7 文件增合并冲突。留 todo 待充分资源/用户在场推进。
- 2. SW-260618-01/02 两个 P1(executor Ok 路径取消事件不一致 / 审批占位 tool_result 5 处 .clear() 未终态化):均行为变更需确认设计意图,记 todo 未改。
- 3. sweep P2 多为重构/防御/改进(AuditLog i18n / ai_node DRY / reqwest Client DRY / 双 watch 合并 / Anthropic url 防御),记 todo 待攒批。
- 4. 待审查 CR-260618-01(17 处)待审查会话独立 grep 核验。
**下一步**:
- 1. UX-2025-12 分页(用户在场体验验证 或 新会话充分资源推进)
- 2. SW-01/02 P1 确认设计意图后修
- 3. 待审查 CR-260618-01 交审查会话
- 4. 工作区累积 17 处(commit-policy-deferred 不提交,等架构级重构建基线)
### 2026-06-18 03:26 — SW-10 reqwest Client 构建 DRY(Code-Working 会话续推)
**工作内容**:
- sweep P2 SW-260618-10:openai_compat/anthropic_compat Provider::new reqwest Client 构建(connect_timeout 30s + 回退 Client::new + warn)7 行逐字重复,抽 `lib.rs::build_provider_client()` pub(crate) fn 收敛 DRY,两处调 `crate::build_provider_client()`
- 主代自测(审查防污染独立核验):cargo check -p df-ai EXIT 0 无 warning / cargo test -p df-ai 112 passed 0 failed(不回归)+ grep 核验 Client/warn/Duration import 替换后仍多处用(Client 用于 struct 字段 / warn openai:503 + anthropic 7 处 / Duration 用于 :484/:481 timeout),无 unused warning
**代码变更**:
- crates/df-ai/src/lib.rs(+14 build_provider_client pub(crate) fn)/ openai_compat.rs(-8+2)/ anthropic_compat.rs(-6+2)
**遗留问题**:
- 1. sweep 剩余低风险项 ROI 递减:SW-06/07 双 watch 合并需谨慎 immediate 语义边界 / SW-19 combineAndTruncateLines DRY 价值低(结构特殊带 total/truncated)/ SW-04 knowledge_events query 无活跃调用方(防御性)/ SW-09 ai_node DRY 架构级 / SW-16 审计 status 透传需查审计表中复杂度(find_cached 返回 Option<String> 仅 content,来源 status 在 ai_tool_executions 表非 ChatMessage)暂缓
- 2. 待审查 CR-260618-03(SW-10 单项)+ CR-260618-01/02 交审查会话独立 grep 核验
**下一步**:
- 1. SW-01/02 P1 待确认设计意图(executor Ok 路径取消事件不一致 / 审批占位 5 处 .clear() 未终态化)
- 2. 工作区累积 18 处(commit-policy-deferred 不提交,等架构级重构建基线)
- 3. 待用户回归决策 UX-2025-12 分页 / SW P1 设计意图
### 2026-06-18 03:39 — SW-16 审计 status 透传 + SW-06 ToolCard watch 合并(Code-Working 会话续推)
**工作内容**:
- SW-260618-16(P3 审计精度):find_cached_high_risk_result 加 audit_repo 参数 + async + 返回 (content,status),命中旧 tool_result 后查 AiToolExecutionRepo::find_by_tool_call_id(**已存在** crud.rs:1256,复用零新增)拿缓存来源真实 status(completed/rejected/failed)透传 audit_tool_call 替代固定 "completed"。审计缺失/查询失败 fallback completed 不阻塞去重。
- SW-260618-06(P2 DRY):ToolCard 双 watch(props.tc.status) 合并。放原 watch2 位置(approving/approvingTimer/cmdOutputExpanded 三 ref 已定义避 TDZ)+ immediate(原 watch1 语义),callback 内 cmdOutputExpanded + approving 复位合并。
- 主代自测:cargo check --workspace EXIT 0(仅 4 pre-existing dead_code 基线)/ cargo test -p devflow 92 passed 0 failed / vue-tsc EXIT 0 + grep 独立核验源码形态
**代码变更**:
- src-tauri/src/commands/ai/audit.rs(find_cached 签名+返回+status 查询 / process_tool_calls :625 调用点 .await 拆 status / :639 &status 透传)
- src/components/ToolCard.vue(删原 watch1 + watch2 替合并 watch)
**遗留问题**:
- sweep 剩余可自主:SW-07 AiChat 双 watch 合并(同 SW-06 模式可复用)/ SW-17 ToolCardList Set 重置(低价值内存慢增长)/ SW-19/20 DRY(低价值)/ SW-05/11/12/13/14(防御/确认意图/权衡,需勘察或确认)
- 高价值待用户:B-260618-03 路由解耦 cost/intel(用户已决策主链大改需在场)/ SW-01·02 P1(行为变更需确认设计意图)/ UX-2025-12 分页(需体验实测)
**下一步**:
- 继续 sweep 可自主项(SW-07 watch 合并模式复用 SW-06)/ 工作区累积 20 处 commit-policy-deferred / 心跳 a096aa7f 20 分钟监测推进
### 2026-06-18 03:45 — SW-07 watch 合并 + B-03 路由解耦勘察 + SW-17 评估(Code-Working 会话续推)
**工作内容**:
- SW-260618-07(P2 DRY):AiChat 双 watch(currentText) 合并。删原 :2040 watch(currentText→onContentChange) 并入 :2047 watch(currentText→scheduleStreamParse) 单 callback,保持原注册顺序(onContentChange 滚动先 → scheduleStreamParse rAF 分块后)。vue-tsc EXIT 0。
- B-260618-03 路由解耦 cost/intel 勘察完成:9 调用点(project×2/compress/knowledge_inject×2/agentic/title/ai_node/adversarial)+ router 核心(TaskRequirements 字段 + 步骤4/5 filter + 步骤7 Reverse(cost_tier))+ 多 test。**自主裁决:记待决策作专项**(用户已全局决策去掉 cost/intel,但工程大 ~30 Edit + 主链路由行为变更需 cargo test router 全验证 + 在场核对 title/compress 选模型退化,本轮 token 不够完整推进,半途中断风险 > 暂缓)。附完整实施清单供专项续。
- SW-260618-17 评估:**不做**。切会话清 4 Set 会破坏 UX-260616-03 记忆态设计(userExpandedCards/userCollapsedCards 跨会话保留是 feature——切回 A 恢复展开记忆),「内存慢增长」是记忆保留代价可忽略(tc.id 全局唯一无错配,描述自承认)。
**代码变更**:
- src/components/AiChat.vue(删 :2040 watch + :2047 合并 callback)
**遗留问题**:
- B-260618-03 待决策.md(专项推进,附实施清单)
- SW-17 评估不做(破坏 UX-260616-03 记忆态)
- sweep 剩余:SW-19(combineAndTruncateLines DRY 低价值可做)/ SW-20(parseTags DRY 签名变更中复杂度)/ SW-05/11/12/13/14(防御/确认/权衡)/ SW-08(AuditLog i18n 重构级)/ SW-09(ai_node DRY 架构级)
**下一步**:
- 推进 SW-19(纯 DRY 低风险)/ 工作区累积 21 处 commit-policy-deferred / 心跳 a096aa7f 20 分钟监测
### 2026-06-18 04:12 — SW-19/20 DRY + sweep 评估销账批次(Code-Working 会话续推)
**工作内容**:
- SW-260618-19(P3 DRY):ToolCard combineOutputs 抽函数,combineAndTruncateLines + cmdOutput computed 复用。vue-tsc EXIT 0。
- SW-260618-20(P3 DRY):Ideas 删本地 parseTags(IdeaRecord),import stores/knowledge.ts parseTags,3 调用点传 .tags。vue-tsc EXIT 0。
- sweep 评估销账 6 项:SW-05(占位内容匹配·PENDING_APPROVAL_PLACEHOLDER 集中常量有效暂缓)/ SW-11(OpenAI 图片构造·当前不可达暂缓)/ SW-12(pinned/archived 不对称·设计合理确认)/ SW-13(find_path_conflict 规范化·文档+测试守护够暂缓)/ SW-14(keyring current_dir·权衡已承认不做)/ SW-22(Knowledge 模板重复求值·影响可忽略暂缓)。
- SW-260618-03 评估暂缓:url 模式当前不可达(前端 Phase2b 只产 base64)+ anthropic_compat 已 warn;加 commands 层校验需追 parts 全链路(IPC→ContextManager→provider)定校验点有歧义 + 误拒合法 url 风险,待 url→base64 预拉落地后变可达再加。
**代码变更**:
- src/components/ToolCard.vue(combineOutputs 抽函数 :463 + :472/:652 调用)
- src/views/Ideas.vue(parseTags import :214 + 3 调用点传 .tags)
**遗留问题**:
- sweep 可自主低风险项已全扫净(SW-19/20 已修 + 6 项评估销账 + SW-03 评估暂缓)
- 剩余候选:SW-08(AuditLog i18n 重构级 15+ key 可自主中工程)/ SW-01·02 P1(行为变更需确认设计意图)/ SW-04(knowledge_events query 无调用方暂缓)/ SW-09(ai_node DRY 架构级)/ B-260618-03(路由解耦待决策专项)
**下一步**:
- 勘察 SW-08 AuditLog i18n 可行性 / 工作区累积 23 处 commit-policy-deferred / 心跳 a096aa7f 20 分钟监测
### 2026-06-18 04:16 — SW-08 AuditLog.vue i18n 化(Code-Working 会话续推)
**工作内容**:
- SW-260618-08(P2 重构):AuditLog.vue 全硬编码中文 i18n 化。新建 auditLog 命名空间(en/zh-CN:title/refresh/desc/loading/empty + col×7 + pager×4 + risk×3 + status×6 + decided×2),AuditLog.vue 引入 useI18n + template 15 处文本 t() 化 + riskLabel/statusLabel/decidedLabel 改 t(`auditLog.xxx.${val}`)。class 映射保留不国际化。模式对齐 Dashboard/Knowledge/Ideas。vue-tsc EXIT 0。
**代码变更**:
- src/i18n/en/auditLog.ts + src/i18n/zh-CN/auditLog.ts(新建命名空间)
- src/views/AuditLog.vue(useI18n + template t() + label 函数 t())
**遗留问题**:
- sweep 可自主项已全扫净(SW-19/20 DRY + SW-08 i18n 重构均完成 + 7 项评估销账)
- 剩余候选均非纯自主:SW-01·02 P1(行为变更需确认设计意图)/ SW-04(knowledge_events query 无调用方暂缓)/ SW-09(ai_node DRY 架构级)/ B-260618-03(路由解耦待决策专项)
**下一步**:
- 勘察 SW-09 ai_node DRY 可行性 / 工作区累积 26 处 commit-policy-deferred / 心跳 a096aa7f 20 分钟监测
### 2026-06-18 04:32 — SW-09 ai_node provider 三件套 DRY(Code-Working 会话续推)
**工作内容**:
- SW-260618-09(P2 架构级 DRY):AiNode/AiSelfReviewNode execute provider 三件套逐字重复消除。抽 2 helper:resolve_and_parse(合并 resolve_provider+parse_params)+ provider_from_params(封装 build_provider 5 行)。AiNode 9 行→2 行;SelfReview parse+build→2 调用(中间 task 读保留,p 生命周期正确)。messages/temperature 差异不抽(各节点自有)。cargo check df-nodes EXIT 0 / cargo test 82 passed 0 failed。
**代码变更**:
- crates/df-nodes/src/ai_node.rs(2 helper 新增 + 2 节点 execute 改)
**遗留问题**:
- sweep 全域可自主项已尽(SW-19/20 DRY + SW-08 i18n 重构 + SW-09 架构级 DRY 均完成 + 7 项评估销账 SW-03/05/11/12/13/14/22)
- 剩余候选均需用户介入:SW-01·02 P1(行为变更需确认设计意图)/ SW-04(knowledge_events query 无调用方暂缓)/ B-260618-03(路由解耦待决策专项)
**下一步**:
- 可自主项已扫净,状态报告待用户 / 工作区累积 27 处 commit-policy-deferred / 心跳 a096aa7f 20 分钟监测
### 2026-06-18 04:35 — UX-260618-04 前端删 cost/intel 标签(Code-Working 会话续推)
**工作内容**:
- UX-260618-04(P2):前端删 cost/intel 瞎猜档位标签(落地用户「去掉 cost/intel」决策)。自主裁决全删(用户决策明确 + 独立于后端 B-03 路由 + grep 确认仅 2 组件引用 + 回退易)。ProviderPanel 删 tag-cost/tag-intel span;AiChat modelOptionLabel 删 intel/cost 拼接;i18n en/zh settings.ts 删 tagCost/tagIntel + 注释更新。vue-tsc EXIT 0 / grep 无残留。
**代码变更**:
- src/components/settings/ProviderPanel.vue + src/components/AiChat.vue
- src/i18n/en/settings.ts + src/i18n/zh-CN/settings.ts
**遗留问题**:
- ModelConfig.cost_tier/.intelligence 字段保留(后端 B-260618-03 解耦后清)
- sweep + UX 可自主项已尽,剩余均需用户:SW-01·02 P1(行为变更)/ SW-04(无调用方暂缓)/ F-260616-09(架构级立项)/ B-260618-03(待决策专项)
**下一步**:
- 可自主项已扫净,状态报告待用户 / 工作区累积 31 处 commit-policy-deferred / 心跳 a096aa7f 20 分钟监测
### 2026-06-18 06:50 — workflow 6项编码批·router解耦+model_probe+executor TOCTOU+审批占位+knowledge_events+CostTier(workflow wexu1isx1·6 agent·主代核查)
**工作内容**:
- 用户回归复盘彻夜低产(串行手改+过度保守+心跳空转 1.5h),改 workflow 编排提速
- workflow wexu1isx1 6 agent(10.7 分钟·294k tokens):B-03 router 解耦 cost/intel / B-04 model_probe 去瞎填 / SW-01 executor TOCTOU / SW-02 审批占位终态化 / SW-04 knowledge_events query / B-05 CostTier Free 清理
- 主代审查防污染核查:cargo check --workspace EXIT 0(5 pre-existing warning) + cargo test df-ai 109/df-ai-core 24/df-workflow 23/df-storage 全 passed + vue-tsc EXIT 0
- **主代修 agent E0502 借用冲突**:SW-02 helper finalize_pending_placeholders 签名(&mut ContextManager, &HashMap, &str) 函数参数列表 disjoint borrow 冲突(agent riskNote 预判"应无问题"错误)→ 改 &mut AiSession 整体(函数体内 disjoint borrow 合法)+ 调用点 &mut *session(DerefMut 解 guard)。印证:agent 不跑 cargo 的 riskNote 是猜测,主代 cargo 必验(同 Sprint 22 教训)
- 主代补 B-05 前端同步:types.ts CostTier 删 'free' 对齐 Rust(防类型不一致 + DB "free" 反序列化边界)
**代码变更**:
- df-ai: router.rs(struct 删 min_intelligence/max_cost + select 删步骤4/5 + max_by_key 纯 weight 删 Reverse + test 改写) / model_probe.rs(heuristic_infer 中性默认 Medium/Standard) / presets/models.json(17 条删 cost_tier/intelligence)
- df-ai-core: model.rs(CostTier 删 Free + 注释 + test 同步)
- df-workflow: executor.rs(Ok 分支 emit NodeCompleted 移入 !is_cancelled + 取消 emit NodeCancelled 对齐 Err)
- df-storage: crud.rs(KnowledgeEventsRepo::list_recent ORDER BY timestamp 兜底)
- src-tauri: commands/ai/commands.rs(finalize_pending_placeholders helper + 5 处 .clear() 前终态化,主代修借用) + title/compress/knowledge_inject/agentic/project.rs(删 TaskRequirements min_intelligence/max_cost 传参 + import 清理)
- df-nodes: ai_node.rs / df-ideas: adversarial.rs(同删传参) / 前端: api/types.ts(CostTier 删 'free')
**遗留问题**:
- 1. B-03 行为变更:title/compress 纯 weight 选模型,同 weight 并列 max_by_key 返回最后一个(rust Iterator 语义),需用户核对 Settings weight 配置(用户已全局决策接受)
- 2. SW-02 另 2 处 pending_approvals.retain(switch/delete)未改:语义不同(switch DB 占位残留需另案修持久层;delete 非活跃对话占位不在 session.messages 内存)。agent riskNote 标注建议另开任务
**下一步**:
- 工作区累积 37 处 commit-policy-deferred(等架构级重构建基线)
- 待审查 CR-260618-10 交审查会话独立 grep 核验
### 2026-06-18 — workflow CR-260618-11 审查回流 5 项低风险编码(workflow w999qdu86·3 agent·主代核查)
**工作内容**:
- workflow w999qdu86 3 agent(1.7 分钟·83k tokens):BUG-260618-11 ai_approve 吞错修(P0) + CR-11#2 context.rs chars_ratio 注释 + CR-11#3 types.ts IntelligenceTier 注释 + CR-11#4 system role 完备性 grep 核验 + CR-11#5 audit.rs find_cached 性能注记
- 文件域零冲突(src-tauri/commands/ai · df-ai/context.rs · 前端 types.ts),agent 不跑 cargo 主代统一验证
- 主代审查防污染核查:cargo check --workspace EXIT 0(4 pre-existing dead_code warning)+ vue-tsc EXIT 0 + 独立 grep 印证 agent selfCheck 无污染
**代码变更**:
- src-tauri/commands/ai/commands.rs(:289-303 ai_approve None 分支 match 三路分流对齐 audit_finalize,原 unwrap_or_default 消)
- src-tauri/commands/ai/audit.rs(:402 find_cached_high_risk_result 性能注记)
- crates/df-ai/src/context.rs(:56 chars_ratio 0.35 注释,值未变)
- src/api/types.ts(:209 IntelligenceTier 注释)
**遗留问题**:
- 1. BUG-11 行为变更:DB 故障现返明确错误(原误为「未找到挂起」),同属 Err 分支语义不变
- 2. CR-11#2 chars_ratio 降值(0.10~0.15)属行为变更影响裁剪边界,本次仅标注未做
- 3. CR-11#5 find_cached 批量预取属架构改签名,本次仅加注记未做
**下一步**:
- 剩余 11 项需用户/架构级定夺(F-09 多会话/T-01·02 实测/S-01·02 澄清/ARC-07 排期/UX-12 二选一/B-260617-11·17 意图+运行时),workflow 推不动,交用户
### 2026-06-18 — workflow ToolCard.vue 工具结果渲染 8 项(workflow w2drz3ppo·8 agent 并行核验+主代统筹实施·commit-policy-deferred)
**工作内容**:
- workflow w2drz3ppo 8 agent(5.1 分钟·301k tokens·132 tool_uses):并行核验 8 工具(run_workflow/patch_file/delete_file/read_file/advance_task/list_trash/list_directory/list_*)后端 tool_registry.rs 返回 vs 前端 ToolCard.vue 渲染,8/8 isReal=true,每份产出精确 old/new 实施清单 + selfCheck grep 证据
- 主代统筹实施(单文件域 ToolCard.vue 不能并行改):合并冲突(UX-10 被 UX-12 吸收)+ 修死代码位置(write_file 改模板分支非 formatToolResult case)+ type 13 字段去重合并 + 补 task_id 漏列
- 主代审查防污染核查:vue-tsc --noEmit EXIT 0 + 独立 grep 印证 ToolCard.vue 22 处新字段 + i18n 18 处新 key 全落地
**代码变更**:
- src/components/ToolCard.vue:ToolResult type 补 13 字段(task_id/execution_id/target_status/note/diff/matches_found/backup_path/returned_lines/items/modified/old_size/encoding/cross_volume)+ 模板 read_file has_more 截断 / list_directory truncated / write_file old_size·encoding / 新增 patch_file diff 分支 + resultDiffLines computed + formatToolResult switch(run_workflow/list_*/advance_task/delete_file/rename_file)+ toolResultSummary switch(run_workflow/list_*重写修误判+list_trash/advance_task/delete_file/rename_file)+ formatFileInfoResult modified
- src/i18n/{en,zh-CN}/aiTool.ts:加 workflowTriggered/Short/patchedMatches/bytesUnit/linesTruncated/dirTruncated/advancedTaskWithRounds/trashCount/listHasMore/renamedWithBytes/CrossVolume/writeOverwrite/encodingLabel/fileInfoModified + 改 deleteFileSoft 加 backup_path
**遗留问题**:
- 1. list_directory/list_* 截断文案硬编码 {n:1000}(对齐后端 tool_registry.rs:880 max_entries=1000),后端变更需同步前端
- 2. list_directory 的 ai-tool-file-meta--warn CSS class 未定义(功能 v-if 生效,样式可后续补)
- 3. 本批 vue-tsc 类型层验证通过,运行时渲染待 T-260614-01 tauri dev 实测批
**下一步**:
- 待审查 CR-260618-12 交审查会话独立 grep 核验
- 工作区累积 45 处 commit-policy-deferred(等架构级重构建基线)
- 剩余需用户项同 CR-11 段(11 项 workflow 推不动)
### 2026-06-18 — workflow SMELL-P0-1 吞错降级加 warn + B-260618-06 表格 CSS(workflow w5siwnipj·6 agent 并行核验+主代统筹实施·commit-policy-deferred)
**工作内容**:
- workflow w5siwnipj 6 agent(135s·190k tokens·62 tool_uses):并行核验 SMELL-P0-1 吞错 5 高危(agentic/knowledge_inject×3/idea/audit)+ B-260618-06 表格 CSS + coordinator 空壳,产出 currentCode/callers/verdict/fixType/proposedFix/behaviorChange/risk/selfCheck 结构化清单
- 主代统筹裁决:4 项实施(降级加 warn 类无行为变更 + CSS 确定性)+ 2 项 no-action(audit 误报 CR-11 已修 / coordinator 已标注保留)
- 主代审查防污染核查:独立 Read ai-md.css/knowledge_inject.rs/idea.rs 印证 agent selfCheck 6/6 准确,cargo check -p devflow EXIT 0 + vue-tsc EXIT 0
**代码变更**:
- B-260618-06:src/styles/ai-md.css:59 移除 display:block/overflow-x:auto(width:100%→max-width:100%)+ src/components/AiChat.vue:3366 移除 display:block/width:max-content/overflow-x:auto,table 留 max-width:100%,气泡加 .ai-msg-bubble--ai.ai-md { overflow-x:auto }(CSS spec table-display 下 overflow 须由 block 父容器承担)
- SMELL-P0-1:agentic.rs:348(provider 池)+ knowledge_inject.rs:130,139,312(检索/消息解析)+ idea.rs:292-296(tags 解析)unwrap_or_default → match + tracing::warn 降级,Err 返空 Vec 走原降级路径零行为变更
**遗留问题**:
- 1. SMELL-P0-1 audit.rs:344 误报(audit_finalize CR-11 已修三路 match),todo line 漂移+旧版本,workflow 独立核验拦截
- 2. 全局 ai-md.css 移除 display:block 后其他引用页(ProjectDetail/Ideas/Knowledge/TaskDetail)宽表格滚动需各自容器,当前仅 AiChat 气泡补 overflow,其他页 markdown 少宽表可接受
- 3. SMELL-P0-1 58 处全量降 P2(5 高危已清,余低危非紧急)
**下一步**:
- 待审查 CR-260618-13 交审查会话独立 grep 核验
- 工作区累积 commit-policy-deferred 项 +5(等架构级基线)
- 剩余需用户项同前(11 项 workflow 推不动)
<!--
### [Sprint N] YYYY-MM-DD — 简述
@@ -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::*;
+469
View File
@@ -0,0 +1,469 @@
//! 模型能力数据模型 — F-01 阶段1
//!
//! 单模型的完整描述(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),
/// B-260618-05 删除 Free 死档变体。
///
/// 路由已解耦(2026-06-18 B-260618-03):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。
///
/// 路由已解耦(2026-06-18 B-260618-03):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,
// ── 探测元数据(只读,由 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(),
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 变体已删除(B-260618-05:预设/启发式从不赋 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,
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);
}
}
+439
View File
@@ -0,0 +1,439 @@
//! 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, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
}
pub fn user(content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
}
pub fn assistant(content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
}
pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
}
pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::Tool, content: content.into(), parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
}
/// 多模态 user 消息:content 文本 + parts(含 Image 片)。
/// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。
pub fn user_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: Some(parts), tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, 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。
///
/// 正面白名单(F-15 §3.2):仅认 None / "active",新状态
/// (如阶段2 引入的 "archived_segment" / "compressed")自动落入不 active 分支,
/// 无需每加一个状态就来这里改。当前取值 None/Some("active")/Some("truncated")
/// 行为与旧反面排除完全等价(None=true / "active"=true / "truncated"=false)。
pub fn is_active(&self) -> bool {
matches!(self.status.as_deref(), None | Some("active"))
}
}
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 },
}
}
}
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() },
}
}
}
/// 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() {
// F-15 §3.2 正面白名单:仅 None / "active" 为 true,其余一律 false。
// 零行为变化:None / "active" / "truncated" 与旧反面排除完全等价;
// "archived_segment" / "compressed"(阶段2 引入,当前代码未赋值)由白名单
// 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("active".to_string());
assert!(m.is_active(), "Some(active) 应 active");
// "truncated" — 当前取值,与旧实现等价(false)
let mut m = ChatMessage::user("hi");
m.status = Some("truncated".to_string());
assert!(!m.is_active(), "truncated 应不 active");
// "archived_segment" — 阶段2 待引入,白名单自动隔离
let mut m = ChatMessage::user("hi");
m.status = Some("archived_segment".to_string());
assert!(!m.is_active(), "archived_segment 应不 active(白名单隔离)");
// "compressed" — 阶段2 待引入,白名单自动隔离
let mut m = ChatMessage::user("hi");
m.status = Some("compressed".to_string());
assert!(!m.is_active(), "compressed 应不 active(白名单隔离)");
}
// ---------- F-260614-05 Phase 2a 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,
tool_call_id: None,
tool_calls: None,
model: None,
status: None,
reasoning_content: 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,
tool_call_id: None,
tool_calls: None,
model: None,
status: None,
reasoning_content: Some("thinking process".to_string()),
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()));
}
// ---------- F-260619-04 消息级溯源: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,
tool_call_id: None,
tool_calls: None,
model: None,
status: None,
reasoning_content: 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()),
};
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 },
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()));
}
}
+247
View File
@@ -0,0 +1,247 @@
//! 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 忽略)。
///
/// F-260614-05 Phase 2a 后端: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>,
},
}
/// 聊天消息
///
/// ⚠️ **迁移耦合点**:下方各字段名(`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(向前兼容)。
/// 消息拆分存储(F-260619-03)后,此 ID 即 `ai_messages.id` 列主键。
/// 临时/派生消息(如 title 摘要)可显式赋 None(不溯源不落库)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub role: MessageRole,
pub content: String,
/// 多模态内容片(F-260614-05 Phase 2a)。
///
/// `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/"active" 正常可见;
/// "truncated" 软删(编辑某条 user 消息后其后续消息标记,保留 DB 可追溯但不进 LLM 上下文、前端视图过滤)
/// 默认 None(向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
/// 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>,
}
/// 当前 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,
}
/// 工具定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDefinition {
#[serde(rename = "type")]
pub tool_type: String,
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: String,
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 用量
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TokenUsage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_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>,
/// 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>>;
+3 -2
View File
@@ -4,10 +4,11 @@ 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 }
+138
View File
@@ -0,0 +1,138 @@
[
{
"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": "embedding-3",
"enabled": true,
"modalities": [],
"capabilities": ["embedding"],
"weight": 50,
"context_window": 8192
}
]
+94 -21
View File
@@ -96,6 +96,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 +131,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 +156,84 @@ 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");
}
}
+660 -205
View File
@@ -5,79 +5,34 @@
//!
//! 与 OpenAI 协议的关键差异由本模块内部完成转换,对外仍暴露统一的 LlmProvider trait,
//! 上层 (Agentic Loop / AiNode) 无需感知协议。
//!
//! 协议数据结构(请求/响应 struct)与 SSE 事件解析纯函数已抽至 `anthropic_helpers`,
//! 本模块仅保留 Provider struct + impl(HTTP 调用),Rust impl 块不可跨文件故作此切分。
use async_trait::async_trait;
use eventsource_stream::Eventsource;
use futures::{Stream, StreamExt};
use futures::StreamExt;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use tracing::{debug, error, warn};
use crate::provider::{
CompletionRequest, CompletionResponse, LlmProvider, MessageRole, ProviderFeatures,
StreamChunk, StreamResult, TokenUsage, ToolCall, ToolCallDelta,
CompletionRequest, CompletionResponse, LlmProvider, MessageRole,
StreamResult, TokenUsage, ToolCall,
};
// ChatMessage 仅单测构造 CompletionRequest 用,避免非 test 构建的 unused import 警告。
#[cfg(test)]
use crate::provider::ChatMessage;
use crate::retry::{
retry_with_backoff, AttemptOutcome, is_reqwest_error_retryable, is_status_retryable,
};
// ============================================================
// Anthropic API 请求/响应结构体
// ============================================================
/// Anthropic 请求体
#[derive(Debug, Serialize)]
struct AnthropicRequest {
model: String,
messages: Vec<serde_json::Value>,
max_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")]
system: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<AnthropicToolDef>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_choice: Option<serde_json::Value>,
}
/// Anthropic 工具定义(input_schema 对应 OpenAI 的 parameters)
#[derive(Debug, Serialize)]
struct AnthropicToolDef {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
input_schema: serde_json::Value,
}
/// Anthropic 同步响应
#[derive(Debug, Deserialize)]
struct AnthropicResponse {
#[allow(dead_code)]
id: String,
model: String,
content: Vec<AnthropicContentBlock>,
#[allow(dead_code)]
stop_reason: Option<String>,
usage: AnthropicUsage,
}
/// 响应 content 块(text 或 tool_use)
#[derive(Debug, Deserialize)]
struct AnthropicContentBlock {
#[serde(rename = "type")]
block_type: String,
#[serde(default)]
text: Option<String>,
/// tool_use 块字段
id: Option<String>,
name: Option<String>,
input: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
struct AnthropicUsage {
input_tokens: u32,
output_tokens: u32,
}
// 协议数据结构 + SSE 纯解析(apply_anthropic_event / AnthropicRequest 等)抽至 anthropic_helpers,
// 此处 use 引入以保持本模块内引用路径不变(零行为变更搬迁)。
use crate::anthropic_helpers::{
apply_anthropic_event, AnthropicRequest, AnthropicResponse, AnthropicToolDef,
ANTHROPIC_VERSION, DEFAULT_MAX_TOKENS,
};
// ============================================================
// Provider 实现
@@ -91,11 +46,6 @@ pub struct AnthropicCompatProvider {
default_model: String,
}
/// Anthropic 流式协议版本头
const ANTHROPIC_VERSION: &str = "2023-06-01";
/// Anthropic max_tokens 必填,缺省时的兜底值
const DEFAULT_MAX_TOKENS: u32 = 4096;
impl AnthropicCompatProvider {
/// 创建 Provider
///
@@ -103,13 +53,8 @@ impl AnthropicCompatProvider {
/// - `api_key`: API 密钥(Anthropic 用 x-api-key 头,非 Bearer)
/// - `default_model`: 默认模型名称
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, default_model: impl Into<String>) -> Self {
let client = Client::builder()
.connect_timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_else(|e| {
warn!("reqwest builder 失败,降级为默认 client: {}", e);
Client::new()
});
// SW-260618-10: reqwest Client 构建抽 crate::build_provider_client(与 OpenAI 共用 DRY)。
let client = crate::build_provider_client();
Self {
client,
api_key: api_key.into(),
@@ -171,16 +116,75 @@ impl AnthropicCompatProvider {
match m.role {
MessageRole::System => continue,
MessageRole::Tool => {
// 累积 tool_result 块,遇到非 Tool 消息时 flush
pending_tool_results.push(serde_json::json!({
"type": "tool_result",
"tool_use_id": m.tool_call_id,
"content": m.content,
}));
// 累积 tool_result 块,遇到非 Tool 消息时 flush。
// tool_use_id 为 None 时绝不能发 null(GLM anthropic 端点会 500
// 报 'ClaudeContentBlockToolResult' object has no attribute 'id',
// 致会话卡死持续 500),跳过该块并告警。
match &m.tool_call_id {
Some(tid) if !tid.is_empty() => {
pending_tool_results.push(serde_json::json!({
"type": "tool_result",
"tool_use_id": tid,
"content": m.content,
}));
}
_ => {
warn!(
content_preview = %m.content.chars().take(80).collect::<String>(),
"Anthropic tool_result 缺少 tool_use_id,已跳过该块(发 null 会触发 GLM 500)"
);
}
}
}
MessageRole::User => {
Self::flush_tool_results(&mut messages, &mut pending_tool_results);
messages.push(serde_json::json!({ "role": "user", "content": m.content }));
// F-260614-05 Phase 2a: 多模态 user 消息 → content blocks 数组(text/image)。
// 含图时把 content + parts 拍平成 blocks:Text 片 → {type:text},
// Image 片 → {type:image, source:{type:base64, media_type, data}}。
// Anthropic 协议要求 image 必须内嵌 base64(不接受 URL 直传)。
// 现状:前端(Phase2b)只产 base64 模式图片片,url 模式当前不可达。
// 未来若加 url 图片输入,必须在 commands 层补 url→base64 预拉
//(provider 不发额外 HTTP),否则下方兜底会发空 data 致 Anthropic 400。
// 纯文本消息(无图)保持原字符串简写,与现有端点零回归。
if m.has_image() {
let blocks: Vec<serde_json::Value> = m
.flattened_parts()
.into_iter()
.map(|p| match p {
crate::provider::ContentPart::Text { text } => serde_json::json!({
"type": "text",
"text": text,
}),
crate::provider::ContentPart::Image { url, base64, media_type, alt: _ } => {
// p 已被 match 取得所有权,直接 move media_type/base64 避免大 base64 clone。
let mt = media_type.unwrap_or_else(|| "image/png".into());
let data = base64.unwrap_or_else(|| {
// 完整性兜底:当前 url 模式不可达(前端 Phase2b 只产 base64 图片片)。
// 若未来接入 url 图片输入而 commands 层未补 url→base64 预拉,
// 此处会发空 data 致 Anthropic 400,warn 留痕但不阻塞(避免静默吞数据)。
if url.is_some() {
warn!(
url = ?url,
"Anthropic user 消息含 Image(url) 但 base64 缺失,将发空 data(commands 层未补 url→base64 预拉)"
);
}
String::new()
});
serde_json::json!({
"type": "image",
"source": {
"type": "base64",
"media_type": mt,
"data": data,
}
})
}
})
.collect();
messages.push(serde_json::json!({ "role": "user", "content": blocks }));
} else {
messages.push(serde_json::json!({ "role": "user", "content": m.content }));
}
}
MessageRole::Assistant => {
Self::flush_tool_results(&mut messages, &mut pending_tool_results);
@@ -190,8 +194,11 @@ impl AnthropicCompatProvider {
}
if let Some(calls) = &m.tool_calls {
for tc in calls {
let input: serde_json::Value =
serde_json::from_str(&tc.function.arguments).unwrap_or(serde_json::Value::Null);
// B-260618-25: arguments 非法 JSON(流式中断残留 / ToolCall::new 默认空串)
// → 空 object 兜底。Anthropic/GLM 要求 tool_use.input 必为 object,
// null 直触发 1214「messages 参数非法」。
let input: serde_json::Value = serde_json::from_str(&tc.function.arguments)
.unwrap_or_else(|_| serde_json::json!({}));
content.push(serde_json::json!({
"type": "tool_use",
"id": tc.id,
@@ -209,12 +216,24 @@ impl AnthropicCompatProvider {
}
Self::flush_tool_results(&mut messages, &mut pending_tool_results);
// B-260619-03: 合并相邻 user 块。Anthropic 协议要求 user/assistant 严格交替,连续 user
// 触发 GLM 1214。场景:drainQueue 续发(前一轮以 tool_result 结尾 + 新 user)→ flush 把
// tool_result 转成 user 后紧跟 push 新 user → 连续两 user。合并成一条 user 含
// [tool_result..., text] blocks(Anthropic 允许一条 user 多 blocks),打破恶性循环。
Self::merge_consecutive_users(&mut messages);
let tools = req.tools.map(|defs| {
defs.into_iter()
.map(|d| AnthropicToolDef {
name: d.function.name,
description: Some(d.function.description).filter(|s| !s.is_empty()),
input_schema: d.function.parameters,
// B-260618-25: input_schema 非 object(未来误用)→ 兜底 {"type":"object"},
// 防 Anthropic 拒非法 tool schema(当前全走 object_schema 恒 object,纯防御)。
input_schema: if d.function.parameters.is_object() {
d.function.parameters
} else {
serde_json::json!({"type": "object"})
},
})
.collect()
});
@@ -243,6 +262,140 @@ impl AnthropicCompatProvider {
messages.push(serde_json::json!({ "role": "user", "content": blocks }));
}
/// B-260619-03: 合并相邻 user 消息为一条(content 拼成 blocks 数组)。
/// Anthropic 协议要求 user/assistant 严格交替,连续 user 触发 1214。
/// 触发场景:flush_tool_results 把 tool_result 转 user 后紧跟新 user(drainQueue 续发,
/// 前一轮以 tool_result 结尾)。合并成一条 user 含 [tool_result..., text] blocks,合法。
/// content 形态:string(简写)或 array(blocks),统一规范化为 array 后拼接。
fn merge_consecutive_users(messages: &mut Vec<serde_json::Value>) {
let is_user = |m: &serde_json::Value| m.get("role").and_then(|r| r.as_str()) == Some("user");
let mut i = 0;
while i + 1 < messages.len() {
if is_user(&messages[i]) && is_user(&messages[i + 1]) {
let next_content = messages[i + 1].get("content").cloned().unwrap_or(serde_json::Value::Null);
let cur = &mut messages[i];
// 当前 user content 规范化为 array
let mut blocks: Vec<serde_json::Value> = match cur.get("content").cloned() {
Some(serde_json::Value::String(s)) => vec![serde_json::json!({ "type": "text", "text": s })],
Some(serde_json::Value::Array(a)) => a,
_ => vec![],
};
// 并入下一个 user content
match next_content {
serde_json::Value::String(s) => blocks.push(serde_json::json!({ "type": "text", "text": s })),
serde_json::Value::Array(a) => blocks.extend(a),
_ => {}
}
cur["content"] = serde_json::Value::Array(blocks);
messages.remove(i + 1);
// 不增 i:继续合并 i 与新 i+1(可能多个连续 user)
} else {
i += 1;
}
}
}
/// 生成 messages 诊断摘要(每条 role + content 形态 + tool 标记),不含敏感数据。
/// B-260618-27: 1214 类错误时随 bail 文案直达前端 raw,定位哪条/字段非法。
fn summarize_messages(messages: &[serde_json::Value]) -> String {
let lines: Vec<String> = messages
.iter()
.enumerate()
.map(|(i, m)| {
let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("?");
let desc = match m.get("content") {
Some(serde_json::Value::String(s)) => format!("text({}B)", s.len()),
Some(serde_json::Value::Array(blocks)) => {
let parts: Vec<String> = blocks
.iter()
.map(|b| {
let ty = b.get("type").and_then(|t| t.as_str()).unwrap_or("?");
match ty {
"text" => format!(
"text({}B)",
b.get("text").and_then(|t| t.as_str()).map(|s| s.len()).unwrap_or(0)
),
"tool_use" => format!(
"tool_use[id={},input_obj={}]",
b.get("id").and_then(|t| t.as_str()).unwrap_or(""),
b.get("input").map(|v| v.is_object()).unwrap_or(false)
),
"tool_result" => format!(
"tool_result[tid={}]",
b.get("tool_use_id").and_then(|t| t.as_str()).unwrap_or("")
),
"image" => "image".to_string(),
_ => ty.to_string(),
}
})
.collect();
format!("[{}]", parts.join(","))
}
_ => "?".to_string(),
};
format!("#{}:{} {}", i, role, desc)
})
.collect();
format!("{} msgs: {}", lines.len(), lines.join(" | "))
}
/// B-260618-27: 协议预检——扫 messages 发现确定非法形态,命中返回原因(仅诊断不修复)。
/// 覆盖:首条非 user / 连续同 role / tool_use input 非 object / 空 content / orphan tool_result
/// (tool_use_id 无前置 tool_use,常见于裁剪/过滤后 assistant 被删但 tool_result 留)。
fn precheck_messages(messages: &[serde_json::Value]) -> Result<(), String> {
if messages.is_empty() {
return Err("messages 为空".into());
}
let first_role = messages[0].get("role").and_then(|r| r.as_str()).unwrap_or("");
if first_role != "user" {
return Err(format!("首条 role={} 非法(须 user)", first_role));
}
for w in messages.windows(2) {
let r0 = w[0].get("role").and_then(|r| r.as_str()).unwrap_or("");
let r1 = w[1].get("role").and_then(|r| r.as_str()).unwrap_or("");
if r0 == r1 && (r0 == "user" || r0 == "assistant") {
return Err(format!("连续同 role={}", r0));
}
}
let mut tool_use_ids: Vec<&str> = Vec::new();
for (i, m) in messages.iter().enumerate() {
let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("");
match m.get("content") {
Some(serde_json::Value::String(s)) if s.is_empty() && role == "user" => {
return Err(format!("#{} user content 空", i));
}
Some(serde_json::Value::Array(blocks)) => {
if blocks.is_empty() && role == "user" {
return Err(format!("#{} user content 空数组", i));
}
for b in blocks {
match b.get("type").and_then(|t| t.as_str()).unwrap_or("") {
"tool_use" => {
let id = b.get("id").and_then(|t| t.as_str()).unwrap_or("");
tool_use_ids.push(id);
if !b.get("input").map(|v| v.is_object()).unwrap_or(false) {
return Err(format!("#{} tool_use input 非 object", i));
}
}
"tool_result" => {
let tid = b.get("tool_use_id").and_then(|t| t.as_str()).unwrap_or("");
if !tid.is_empty() && !tool_use_ids.contains(&tid) {
return Err(format!(
"#{} orphan tool_result(tid={} 无前置 tool_use)",
i, tid
));
}
}
_ => {}
}
}
}
_ => {}
}
}
Ok(())
}
/// 统一鉴权头:x-api-key + anthropic-version
fn auth_headers(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
rb.header("x-api-key", &self.api_key)
@@ -258,58 +411,122 @@ impl LlmProvider for AnthropicCompatProvider {
req.stream = false;
let body = self.convert_request(req);
// B-260618-27: 协议预检——命中非法 bail 含 messages 摘要,把 GLM 模糊 1214 转明确诊断
if let Err(reason) = Self::precheck_messages(&body.messages) {
let summary = Self::summarize_messages(&body.messages);
warn!(%reason, %summary, "Anthropic messages 协议预检失败");
anyhow::bail!("messages 预检失败: {} | 摘要: {}", reason, summary);
}
debug!(model = %body.model, "Anthropic 同步调用");
let resp = self
.auth_headers(self.client.post(self.messages_url()))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
error!(%status, %text, "Anthropic API 调用失败");
anyhow::bail!("Anthropic API 错误 {}: {}", status, text);
}
let resp: AnthropicResponse = resp.json().await?;
// content 块中拼接 text,收集 tool_use
let mut text = String::new();
let mut tool_calls: Vec<ToolCall> = Vec::new();
for block in resp.content {
match block.block_type.as_str() {
"text" => {
if let Some(t) = block.text {
text.push_str(&t);
// 指数退避重试(B-260616-07): 包裹 send + 状态码判定。
// 同时补 FR-R4 遗漏: Anthropic 同步路径此前无单请求 timeout(建连后挂起会无限 hang),
// 此处加 60s timeout,与 OpenAI 路径对齐。
let label = format!("Anthropic[{}]", body.model);
retry_with_backoff(&label, move |_| {
let client = self.client.clone();
let url = self.messages_url();
let api_key = self.api_key.clone();
let body = body.clone();
async move {
// send: 补 60s 单请求 timeout(此前缺失,FR-R4 遗漏)
let rb = client
.post(url)
.header("x-api-key", &api_key)
.header("anthropic-version", ANTHROPIC_VERSION)
.header("Content-Type", "application/json")
.timeout(Duration::from_secs(60))
.version(reqwest::Version::HTTP_11)
.json(&body);
let resp = match rb.send().await {
Ok(r) => r,
Err(e) => {
// B-260618-26: 记 reqwest 错误源因链(is_*/source)。原仅 Display
// "error sending request for url" 无法定位 reset/TLS/超时/body 真因。
tracing::error!(
is_timeout = e.is_timeout(),
is_connect = e.is_connect(),
is_body = e.is_body(),
is_request = e.is_request(),
url = ?e.url(),
source = ?std::error::Error::source(&e),
"Anthropic 同步 send 失败"
);
if is_reqwest_error_retryable(&e) {
return AttemptOutcome::Retryable(format!("请求失败(可重试): {}", e));
}
return AttemptOutcome::Fatal(format!(
"请求失败(不可重试): {} [timeout={} connect={} body={} src={:?}]",
e,
e.is_timeout(),
e.is_connect(),
e.is_body(),
std::error::Error::source(&e)
));
}
};
if !resp.status().is_success() {
let status = resp.status().as_u16();
let text = resp.text().await.unwrap_or_default();
let msg = format!("Anthropic API 错误 {}: {}", status, text);
if is_status_retryable(status) {
warn!(%status, "Anthropic 同步调用可重试状态码");
return AttemptOutcome::Retryable(msg);
}
error!(%status, %text, "Anthropic API 调用失败(不可重试)");
return AttemptOutcome::Fatal(msg);
}
let resp: AnthropicResponse = match resp.json().await {
Ok(r) => r,
Err(e) => return AttemptOutcome::Fatal(format!("响应解析失败: {}", e)),
};
// content 块中拼接 text,收集 tool_use
let mut text = String::new();
let mut tool_calls: Vec<ToolCall> = Vec::new();
for block in resp.content {
match block.block_type.as_str() {
"text" => {
if let Some(t) = block.text {
text.push_str(&t);
}
}
"tool_use" => {
let id = match block.id {
Some(id) if !id.is_empty() => id,
_ => {
warn!(
name = ?block.name,
"Anthropic tool_use 块缺少 id,已跳过(空 id 会回传空 tool_use_id 触发 500)"
);
continue;
}
};
let name = block.name.unwrap_or_default();
let args = block
.input
.map(|v| serde_json::to_string(&v).unwrap_or_default())
.unwrap_or_default();
tool_calls.push(ToolCall::new(id, name, args));
}
other => warn!(block_type = other, "Anthropic 响应含未知 content 块类型,已忽略"),
}
}
"tool_use" => {
let id = block.id.unwrap_or_default();
let name = block.name.unwrap_or_default();
let args = block
.input
.map(|v| serde_json::to_string(&v).unwrap_or_default())
.unwrap_or_default();
tool_calls.push(ToolCall::new(id, name, args));
}
other => warn!(block_type = other, "Anthropic 响应含未知 content 块类型,已忽略"),
let usage = TokenUsage {
prompt_tokens: resp.usage.input_tokens,
completion_tokens: resp.usage.output_tokens,
total_tokens: resp.usage.input_tokens + resp.usage.output_tokens,
};
AttemptOutcome::Ok(CompletionResponse {
text,
model: resp.model,
usage,
tool_calls: if tool_calls.is_empty() { None } else { Some(tool_calls) },
reasoning_content: None,
})
}
}
let usage = TokenUsage {
prompt_tokens: resp.usage.input_tokens,
completion_tokens: resp.usage.output_tokens,
total_tokens: resp.usage.input_tokens + resp.usage.output_tokens,
};
Ok(CompletionResponse {
text,
model: resp.model,
usage,
tool_calls: if tool_calls.is_empty() { None } else { Some(tool_calls) },
})
.await
}
async fn stream(&self, request: CompletionRequest) -> anyhow::Result<StreamResult> {
@@ -317,13 +534,46 @@ impl LlmProvider for AnthropicCompatProvider {
req.stream = true;
let body = self.convert_request(req);
// B-260618-27: 协议预检——命中非法 bail 含 messages 摘要,把 GLM 模糊 1214 转明确诊断
if let Err(reason) = Self::precheck_messages(&body.messages) {
let summary = Self::summarize_messages(&body.messages);
warn!(%reason, %summary, "Anthropic messages 协议预检失败");
anyhow::bail!("messages 预检失败: {} | 摘要: {}", reason, summary);
}
debug!(model = %body.model, "Anthropic 流式调用");
let resp = self
let resp = match self
.auth_headers(self.client.post(self.messages_url()))
.json(&body)
.version(reqwest::Version::HTTP_11)
.send()
.await?;
.await
{
Ok(r) => r,
Err(e) => {
// B-260618-26: 记 reqwest 错误源因链。原 ? 转 anyhow 仅 Display
// "error sending request for url" 无法定位 reset/TLS/超时/body 真因。
tracing::error!(
is_timeout = e.is_timeout(),
is_connect = e.is_connect(),
is_body = e.is_body(),
is_request = e.is_request(),
url = %self.messages_url(),
source = ?std::error::Error::source(&e),
"Anthropic 流式 send 失败"
);
anyhow::bail!(
"send 失败: {} [timeout={} connect={} body={} request={} src={:?}]",
e,
e.is_timeout(),
e.is_connect(),
e.is_body(),
e.is_request(),
std::error::Error::source(&e)
);
}
};
if !resp.status().is_success() {
let status = resp.status();
@@ -332,80 +582,34 @@ impl LlmProvider for AnthropicCompatProvider {
anyhow::bail!("Anthropic 流式 API 错误 {}: {}", status, text);
}
// 流式解析:eventsource 逐事件处理,按 type 字段分发转 StreamChunk
// 流式解析:eventsource 逐事件处理,按 type 字段分发转 StreamChunk。
// 事件解析/usage 累积逻辑抽到 apply_anthropic_event 纯函数,便于单测;此处闭包只负责传 data。
// usage 累积:message_start 给 input_tokens,message_delta 给累计 output_tokens(非增量),message_stop 带出。
let mut usage_accum: Option<TokenUsage> = None;
// B-260618-28: MidStream error(如 GLM 1214 messages 非法)时附 messages 摘要定位哪条非法。
// precheck(Init 路径,发送前)漏的 case,靠此在 SSE error 事件暴露实际 messages 结构到前端 raw。
let messages_summary = Self::summarize_messages(&body.messages);
let stream = resp
.bytes_stream()
.eventsource()
.map(move |event| match event {
Ok(ev) => {
// 解析 data 中的 JSON,按 type 字段决定如何转 StreamChunk
let v: serde_json::Value = match serde_json::from_str(&ev.data) {
Ok(v) => v,
Err(_) => return Ok(StreamChunk { delta: String::new(), finished: false, tool_calls: None }),
};
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
match ty {
// 文本增量
"content_block_delta" => {
if let Some(delta) = v.get("delta") {
if delta.get("type").and_then(|t| t.as_str()) == Some("text_delta") {
let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string();
return Ok(StreamChunk { delta: text, finished: false, tool_calls: None });
}
// 工具入参增量
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 Ok(StreamChunk {
delta: String::new(),
finished: false,
tool_calls: Some(vec![ToolCallDelta {
index: idx,
id: None,
function_name: None,
function_arguments: Some(partial),
}]),
});
}
}
Ok(StreamChunk { delta: String::new(), finished: false, tool_calls: None })
}
// 工具块开始:带 id + name
"content_block_start" => {
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 id = cb.get("id").and_then(|t| t.as_str()).map(|s| s.to_string());
let name = cb.get("name").and_then(|t| t.as_str()).map(|s| s.to_string());
return Ok(StreamChunk {
delta: String::new(),
finished: false,
tool_calls: Some(vec![ToolCallDelta {
index: idx,
id,
function_name: name,
function_arguments: None,
}]),
});
}
}
Ok(StreamChunk { delta: String::new(), finished: false, tool_calls: None })
}
// 消息结束
"message_stop" => Ok(StreamChunk { delta: String::new(), finished: true, tool_calls: None }),
// 错误事件
"error" => {
let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error");
error!(%msg, "Anthropic 流式错误事件");
Ok(StreamChunk { delta: String::new(), finished: true, tool_calls: None })
}
// message_start / content_block_stop / message_delta 等不产出 chunk
_ => Ok(StreamChunk { delta: String::new(), finished: false, tool_calls: None }),
let mut chunk = apply_anthropic_event(&ev.data, &mut usage_accum);
// GLM 中途 error(如 1214)→ chunk.error 附 messages 摘要,经 stream_recv MidStream
// 路径 emit AiError raw,前端直接看到实际 messages 结构定位非法字段。
if let Some(err) = chunk.error.as_mut() {
*err = format!("{} | messages 摘要: {}", err, messages_summary);
}
Ok(chunk)
}
Err(e) => {
// 保留 #[source] 因果链: anyhow!("...{}", e) 仅把 e 的 Display 塞进 message,
// 丢掉 source(无法 downcast/遍历)。改用 Error::from(e).context(...):
// Display 不变(仍为 "Anthropic SSE 错误: {e}"), 且 e 作为 .source() 可追溯。
// 顺序: 先 format(e) 构造 context 文案, 再 Error::from(e) move e 进 source。
let ctx = format!("Anthropic SSE 错误: {}", e);
error!(error = %e, "Anthropic SSE 事件流错误");
Err(anyhow::anyhow!("Anthropic SSE 错误: {}", e))
Err(anyhow::Error::from(e).context(ctx))
}
});
@@ -416,11 +620,262 @@ impl LlmProvider for AnthropicCompatProvider {
"anthropic-compat"
}
fn supported_features(&self) -> ProviderFeatures {
ProviderFeatures {
streaming: true,
function_calling: true,
vision: false,
}
fn endpoint(&self) -> String {
self.messages_url()
}
}
// ============================================================
// 单测(不发真实 HTTP,喂构造的 SSE data 字符串序列)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
// apply_anthropic_event / TokenUsage 经 super::*(含 anthropic_helpers::apply_anthropic_event 的 use)可见。
/// 辅助:构造 message_start 事件
fn message_start(input_tokens: u32) -> String {
format!(
r#"{{"type":"message_start","message":{{"usage":{{"input_tokens":{},"output_tokens":0}}}}}}"#,
input_tokens
)
}
/// 辅助:构造 message_delta 事件(output_tokens 为累计值)
fn message_delta(output_tokens: u32) -> String {
format!(
r#"{{"type":"message_delta","delta":{{"stop_reason":"end_turn"}},"usage":{{"output_tokens":{}}}}}"#,
output_tokens
)
}
/// 辅助:构造文本增量 content_block_delta
fn text_delta(text: &str) -> String {
format!(
r#"{{"type":"content_block_delta","index":0,"delta":{{"type":"text_delta","text":"{}"}}}}"#,
text
)
}
/// 辅助:构造 message_stop 事件
fn message_stop() -> &'static str {
r#"{"type":"message_stop"}"#
}
/// 完整流:message_start 初始化 input + 多次 message_delta 累计覆盖 output + message_stop 带出
#[test]
fn anthropic_full_stream_accumulates_usage() {
let mut acc: Option<TokenUsage> = None;
// 1) message_start:input=42,output=0
let c = apply_anthropic_event(&message_start(42), &mut acc);
assert!(!c.finished);
assert!(c.usage.is_none());
let a = acc.as_ref().expect("message_start 应初始化累加器");
assert_eq!(a.prompt_tokens, 42);
assert_eq!(a.completion_tokens, 0);
assert_eq!(a.total_tokens, 42);
// 2) 文本增量不影响 usage
let c = apply_anthropic_event(&text_delta("Hello"), &mut acc);
assert_eq!(c.delta, "Hello");
assert!(!c.finished);
assert_eq!(acc.as_ref().unwrap().completion_tokens, 0, "文本增量不应改 output");
// 3) message_delta:output_tokens=10(累计值,覆盖)
let c = apply_anthropic_event(&message_delta(10), &mut acc);
assert!(!c.finished);
let a = acc.as_ref().unwrap();
assert_eq!(a.prompt_tokens, 42, "input 保持");
assert_eq!(a.completion_tokens, 10, "output 被覆盖为累计值");
assert_eq!(a.total_tokens, 52, "total 重算 = input+output");
// 4) 再次 message_delta:output_tokens=30(更大累计值,再覆盖)
let _ = apply_anthropic_event(&message_delta(30), &mut acc);
let a = acc.as_ref().unwrap();
assert_eq!(a.completion_tokens, 30, "后续累计值覆盖前值");
assert_eq!(a.total_tokens, 72);
// 5) message_stop:带出累积 usage,finished=true,累加器清空
let c = apply_anthropic_event(message_stop(), &mut acc);
assert!(c.finished);
let u = c.usage.expect("message_stop 应带出累积 usage");
assert_eq!(u.prompt_tokens, 42);
assert_eq!(u.completion_tokens, 30);
assert_eq!(u.total_tokens, 72);
assert!(acc.is_none(), "take() 后累加器应清空");
}
/// message_delta 在没有 message_start 时也能补全累加器(get_or_insert 兜底)
#[test]
fn anthropic_message_delta_without_start_uses_default_input() {
let mut acc: Option<TokenUsage> = None;
let _ = apply_anthropic_event(&message_delta(15), &mut acc);
let a = acc.as_ref().unwrap();
assert_eq!(a.prompt_tokens, 0, "无 message_start 时 input 兜底为 0");
assert_eq!(a.completion_tokens, 15);
assert_eq!(a.total_tokens, 15);
}
/// message_delta 的 output_tokens 必须是累计覆盖(非累加):连续两个 delta 5 和 8,结果应是 8 不是 13
#[test]
fn anthropic_message_delta_output_is_cumulative_not_incremental() {
let mut acc: Option<TokenUsage> = None;
apply_anthropic_event(&message_start(100), &mut acc);
apply_anthropic_event(&message_delta(5), &mut acc);
apply_anthropic_event(&message_delta(8), &mut acc);
let c = apply_anthropic_event(message_stop(), &mut acc);
let u = c.usage.unwrap();
assert_eq!(u.completion_tokens, 8, "output_tokens 是累计值,覆盖而非累加");
assert_eq!(u.total_tokens, 108);
}
/// 无 usage 字段的流:message_stop 时 usage 为 None
#[test]
fn anthropic_message_stop_without_any_usage() {
let mut acc: Option<TokenUsage> = None;
let _ = apply_anthropic_event(&text_delta("hi"), &mut acc);
assert!(acc.is_none(), "文本增量不初始化累加器");
let c = apply_anthropic_event(message_stop(), &mut acc);
assert!(c.finished);
assert!(c.usage.is_none(), "无 usage 时 message_stop usage 为 None");
}
/// content_block_start (tool_use) 带 id+name
#[test]
fn anthropic_content_block_start_tool_use() {
let mut acc: Option<TokenUsage> = None;
let data = r#"{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"tool_1","name":"get_weather"}}"#;
let c = apply_anthropic_event(data, &mut acc);
assert!(acc.is_none(), "content_block_start 不动 usage");
let tcs = c.tool_calls.expect("应有 tool_calls");
assert_eq!(tcs.len(), 1);
assert_eq!(tcs[0].index, 1);
assert_eq!(tcs[0].id.as_deref(), Some("tool_1"));
assert_eq!(tcs[0].function_name.as_deref(), Some("get_weather"));
assert!(tcs[0].function_arguments.is_none());
assert!(!c.finished);
}
/// content_block_delta (input_json_delta) → 工具入参增量
#[test]
fn anthropic_content_block_delta_input_json() {
let mut acc: Option<TokenUsage> = None;
let data = r#"{"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"q\":"}}"#;
let c = apply_anthropic_event(data, &mut acc);
let tcs = c.tool_calls.expect("应有 tool_calls 增量");
assert_eq!(tcs[0].index, 2);
assert_eq!(tcs[0].function_arguments.as_deref(), Some("{\"q\":"));
assert!(tcs[0].id.is_none());
assert_eq!(c.delta, "");
assert!(!c.finished);
}
/// error 事件 → error=Some + finished=false(R-P1-1:避免残缺响应被当正常完成入库,
/// 由 stream_llm 识别 error 非空发 AiError + 丢弃残缺,与 OpenAI 路径 Err 一致)
#[test]
fn anthropic_error_event_yields_error_not_finished() {
let mut acc: Option<TokenUsage> = None;
apply_anthropic_event(&message_start(10), &mut acc);
let c = apply_anthropic_event(r#"{"type":"error","error":{"message":"overloaded"}}"#, &mut acc);
assert!(!c.finished, "error 不走 finished 完成路径,否则残缺响应会被当正常完成");
assert_eq!(c.error.as_deref(), Some("overloaded"), "error 事件应携带错误消息");
assert!(c.usage.is_none(), "error 不带出 usage");
assert!(c.delta.is_empty(), "error 不带文本增量");
assert!(acc.is_some(), "error 不应清空已累积的 usage(与原实现一致)");
}
/// error 事件无 error.message 字段时兜底为 "stream error"
#[test]
fn anthropic_error_event_missing_message_falls_back() {
let mut acc: Option<TokenUsage> = None;
let c = apply_anthropic_event(r#"{"type":"error"}"#, &mut acc);
assert_eq!(c.error.as_deref(), Some("stream error"));
assert!(!c.finished);
}
/// ping / content_block_stop 等事件 → 空且不 finished
#[test]
fn anthropic_ping_and_block_stop_yield_empty_chunk() {
let mut acc: Option<TokenUsage> = None;
let c = apply_anthropic_event(r#"{"type":"ping"}"#, &mut acc);
assert!(!c.finished);
assert_eq!(c.delta, "");
assert!(acc.is_none());
let c = apply_anthropic_event(r#"{"type":"content_block_stop","index":0}"#, &mut acc);
assert!(!c.finished);
assert_eq!(c.delta, "");
}
/// 非法 JSON → 空 chunk,不 panic
#[test]
fn anthropic_malformed_json_yields_empty_chunk() {
let mut acc: Option<TokenUsage> = None;
let c = apply_anthropic_event("not json", &mut acc);
assert!(!c.finished);
assert_eq!(c.delta, "");
assert!(acc.is_none());
}
// ---------- F-260614-05 Phase 2a 多模态 convert_request ----------
/// 含图 user 消息 → content blocks(text + image source.base64)
#[test]
fn anthropic_convert_multimodal_user_blocks() {
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages: vec![ChatMessage::user_parts(
"看图",
vec![crate::provider::ContentPart::image_base64("image/png", "iVBOR")],
)],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let body = provider.convert_request(req);
// user 消息 content 应为数组形态
let user_msg = body
.messages
.iter()
.find(|m| m.get("role").and_then(|r| r.as_str()) == Some("user"))
.expect("应有 user 消息");
let content = user_msg.get("content").and_then(|c| c.as_array()).expect("user content 应为数组");
// 顺序:content "看图" → text 块,image 片 → image 块
assert_eq!(content.len(), 2);
assert_eq!(content[0]["type"], "text");
assert_eq!(content[0]["text"], "看图");
assert_eq!(content[1]["type"], "image");
assert_eq!(content[1]["source"]["type"], "base64");
assert_eq!(content[1]["source"]["media_type"], "image/png");
assert_eq!(content[1]["source"]["data"], "iVBOR");
}
/// 纯文本 user 消息 → content 仍是字符串简写(无图不数组化)
#[test]
fn anthropic_convert_text_only_user_remains_string() {
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages: vec![ChatMessage::user("hello")],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let body = provider.convert_request(req);
let user_msg = body
.messages
.iter()
.find(|m| m.get("role").and_then(|r| r.as_str()) == Some("user"))
.expect("应有 user 消息");
// 纯文本 → 字符串简写(非数组)
assert_eq!(user_msg.get("content").and_then(|c| c.as_str()), Some("hello"));
}
}
+223
View File
@@ -0,0 +1,223 @@
//! 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::{StreamChunk, TokenUsage, ToolCallDelta};
// ============================================================
// Anthropic API 请求/响应结构体
// ============================================================
/// 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<String>,
#[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 {
// SW-260618-25: Anthropic 响应反序列化字段,保留以对齐响应结构(响应 id),标注意图消除 dead_code warning
#[allow(dead_code)]
id: String,
pub model: String,
pub content: Vec<AnthropicContentBlock>,
// SW-260618-25: Anthropic 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning
#[allow(dead_code)]
stop_reason: Option<String>,
pub usage: AnthropicUsage,
}
/// 响应 content 块(text 或 tool_use)
#[derive(Debug, Deserialize)]
pub(crate) struct AnthropicContentBlock {
#[serde(rename = "type")]
pub block_type: String,
#[serde(default)]
pub text: 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 流式协议版本头
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) → 文本/工具入参增量。
/// - `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 }
}
};
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
match ty {
// 消息开始:取 input_tokens 初始化累积器(output 此时未知,置 0)
"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())
{
*usage_accum = Some(TokenUsage {
prompt_tokens: inp as u32,
completion_tokens: 0,
total_tokens: inp as u32,
});
}
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
}
// 消息增量: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 { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
acc.completion_tokens = out as u32;
acc.total_tokens = acc.prompt_tokens + acc.completion_tokens;
}
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
}
// 文本增量
"content_block_delta" => {
if let Some(delta) = v.get("delta") {
if delta.get("type").and_then(|t| t.as_str()) == Some("text_delta") {
let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string();
return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None };
}
// 工具入参增量
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,
};
}
}
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
}
// 工具块开始:带 id + name
"content_block_start" => {
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());
// id 缺失时用占位 id 兜底:流式后续 input_json_delta 按 index 累加,
// 中途无法整体跳过;占位 id 保证回传的 tool_use_id 非空,避免 GLM 500。
let id = match cb.get("id").and_then(|t| t.as_str()).map(|s| s.to_string()) {
Some(id) if !id.is_empty() => Some(id),
_ => {
let placeholder = format!("tool_missing_{}", idx);
warn!(%placeholder, name = ?name, "Anthropic 流式 tool_use 块缺少 id,已填占位 id(原样回传会触发 GLM 500)");
Some(placeholder)
}
};
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,
};
}
}
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
}
// 消息结束:带出累积 usage
"message_stop" => StreamChunk {
delta: String::new(),
finished: true,
tool_calls: None,
usage: usage_accum.take(),
error: None,
reasoning_content: None,
},
// 错误事件:流中途出错。不走 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 }
}
// content_block_stop / ping 等不产出 chunk
_ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None },
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,4 +1,6 @@
//! Agent 协调器 — 管理多 Agent 协作
//!
//! ⚠ B 路线占位:当前单链 ReAct 够用,多 Agent 协作待 B 路线立项。有意保留空壳,勿删。
/// Agent 协调器
///
File diff suppressed because it is too large Load Diff
+71 -2
View File
@@ -1,10 +1,79 @@
//! df-ai: AI 编排 — LLM Provider、模型路由、Agent 协调、上下文管理、流式处理、工具注册
//! df-ai: AI 编排 — LLM Provider、Agent 协调、上下文管理、流式处理、工具注册
pub mod ai_tools;
pub mod anthropic_compat;
pub mod anthropic_helpers;
pub mod context;
pub mod context_helpers;
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 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;
// 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;
pub mod provider;
pub mod router;
pub mod stream;
// CR-30-1: 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用,
// stream_recv/agentic 流前重试需复用 backoff_delay(jitter)+is_status_retryable(Fatal 分类)
// 避免重写退避/分类逻辑(对齐决策 F-260616-07 a1)。改 pub mod 后对外仅暴露纯函数 + 常量。
pub mod retry;
use provider::LlmProvider;
use reqwest::Client;
// df-ai-core 直接暴露,供需要直接引用 trait crate 的下游(可选)。
pub use df_ai_core;
/// SW-260618-10: 构建 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))
.build()
.unwrap_or_else(|e| {
tracing::warn!("reqwest builder 失败,回退默认 client: {}", e);
Client::new()
})
}
/// 按 provider_type 构建 LLM Provider 实例(统一选择逻辑,消除调用方重复 match)
///
/// `anthropic` 协议走 AnthropicCompatProvider(GLM 订阅端点 / Claude 官方),
/// 其余(openai / glm / deepseek 等 OpenAI 兼容)走 OpenAICompatProvider。
/// 调用方(AI Chat 的 run_agentic_loop、df-nodes 的 AiNode)统一引用此工厂,
/// 新增 provider 只改这一处。
pub fn build_provider(
provider_type: &str,
base_url: &str,
api_key: &str,
model: &str,
) -> Box<dyn LlmProvider> {
match provider_type {
"anthropic" => Box::new(anthropic_compat::AnthropicCompatProvider::new(
base_url, api_key, model,
)),
_ => Box::new(openai_compat::OpenAICompatProvider::new(
base_url, api_key, model,
)),
}
}
+176
View File
@@ -0,0 +1,176 @@
//! 厂商模型列表拉取 — F-01 阶段3
//!
//! 按 `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 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, ...}]}`。中转站通常同构。
let body: ModelsList = resp
.json()
.await
.map_err(|e| anyhow!("openai_compat 响应解析失败({url}):{e}"))?;
Ok(filter_chat_models(body.into_ids()))
}
/// 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)。
let body: ModelsList = resp
.json()
.await
.map_err(|e| anyhow!("anthropic_compat 响应解析失败({url}):{e}"))?;
Ok(filter_chat_models(body.into_ids()))
}
// ────────────────────────────────────────────────────────────
// 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}");
}
}
+400
View File
@@ -0,0 +1,400 @@
//! 厂商模型列表拉取 — 纯逻辑(无 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
/// - 语音: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 路由用得着)。
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")
// 语音(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_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"]);
}
}
+282
View File
@@ -0,0 +1,282 @@
//! 模型探测器 — F-01 阶段2
//!
//! 给定模型名,产出完整 `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. 预设表模糊匹配(双向子串包含,大小写不敏感)
// 多个候选命中时,选预设 model_id 最长者(最具体:glm-4v > glm-4)。
let needle = model_id.to_lowercase();
let fuzzy = presets()
.iter()
.filter(|m| {
let cand = m.model_id.to_lowercase();
!cand.is_empty() && (cand.contains(&needle) || needle.contains(&cand))
})
.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]);
// B-260618-04:预设表不再写死 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 模态"
);
}
// ── 预设模糊匹配 ──
#[test]
fn probe_preset_fuzzy_match_glm4v_variant() {
// "glm-4v-x" 不在预设表精确命中,但 "glm-4v" 是其子串 → 模糊命中
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 模态"
);
}
// ── 启发式: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));
// B-260618-04: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);
}
// ── 启发式:默认(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" 精确命中预设表;B-260618-04 后预设/启发式档位都中性,
// 此处仅校验 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}"
);
}
}
}
+119
View File
@@ -0,0 +1,119 @@
//! 模型探测器 — 纯逻辑子模块(F-01 阶段2)
//!
//! 从 `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)。
///
/// 取舍:B-260618-04 — 模型名关键词猜档位(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();
// 中性默认:不靠模型名猜档位(B-260618-04)
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,
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,
probe_source: None,
}
}
// —— 词素判定助手(命名约定式启发式,集中维护便于增删) ——
/// Vision 词素:`-v`(尾缀如 glm-4v)、以 `v` 结尾、含 `vl` / `vision`。
/// 不直接 `contains('v')`(避免误伤 review/verbal 等)。
pub(super) fn has_vision_token(name: &str) -> bool {
name.contains("vision") || name.contains("vl") || name.ends_with('v') || name.contains("-v")
}
+412 -225
View File
@@ -3,122 +3,30 @@
//! 覆盖: OpenAI / GLM (open.bigmodel.cn) / DeepSeek / Claude OpenAI 兼容模式
//! 支持: 同步调用 + SSE 流式 + Function Calling / Tool Use
use std::pin::Pin;
use std::time::Duration;
use async_trait::async_trait;
use eventsource_stream::Eventsource;
use futures::{Stream, StreamExt};
use futures::StreamExt;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use tracing::{debug, error, info, warn};
use tracing::{debug, error, warn};
use crate::provider::{
CompletionRequest, CompletionResponse, LlmProvider, ProviderFeatures, StreamChunk, StreamResult,
TokenUsage, ToolCall, ToolCallDelta,
CompletionRequest, CompletionResponse, LlmProvider, StreamResult, TokenUsage, ToolCall,
};
// ChatMessage 仅单测构造 CompletionRequest 用,避免非 test 构建的 unused import 警告。
#[cfg(test)]
use crate::provider::ChatMessage;
use crate::retry::{
retry_with_backoff, AttemptOutcome, is_reqwest_error_retryable, is_status_retryable,
};
// ============================================================
// OpenAI API 请求/响应结构体
// ============================================================
/// OpenAI 兼容请求体
#[derive(Debug, Serialize)]
struct OpenAiRequest {
model: String,
messages: Vec<OpenAiMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<u32>,
stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_choice: Option<serde_json::Value>,
}
/// OpenAI 消息格式
#[derive(Debug, Serialize, Deserialize)]
struct OpenAiMessage {
role: String,
content: String,
#[serde(skip_serializing_if = "Option::is_none")]
tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_calls: Option<Vec<serde_json::Value>>,
}
/// OpenAI 同步响应
#[derive(Debug, Deserialize)]
struct OpenAiResponse {
choices: Vec<OpenAiChoice>,
model: String,
usage: Option<OpenAiUsage>,
}
#[derive(Debug, Deserialize)]
struct OpenAiChoice {
message: OpenAiMessageResp,
finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
struct OpenAiMessageResp {
content: Option<String>,
tool_calls: Option<Vec<OpenAiToolCallResp>>,
}
#[derive(Debug, Deserialize)]
struct OpenAiToolCallResp {
id: String,
#[serde(rename = "type")]
call_type: String,
function: OpenAiFunctionResp,
}
#[derive(Debug, Deserialize)]
struct OpenAiFunctionResp {
name: String,
arguments: String,
}
#[derive(Debug, Deserialize)]
struct OpenAiUsage {
prompt_tokens: u32,
completion_tokens: u32,
total_tokens: u32,
}
/// SSE 流式响应 chunk
#[derive(Debug, Deserialize)]
struct OpenAiStreamChunk {
choices: Vec<OpenAiStreamChoice>,
}
#[derive(Debug, Deserialize)]
struct OpenAiStreamChoice {
delta: OpenAiStreamDelta,
finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
struct OpenAiStreamDelta {
content: Option<String>,
tool_calls: Option<Vec<OpenAiStreamToolCall>>,
}
#[derive(Debug, Deserialize)]
struct OpenAiStreamToolCall {
index: u32,
id: Option<String>,
function: Option<OpenAiStreamFunction>,
}
#[derive(Debug, Deserialize)]
struct OpenAiStreamFunction {
name: Option<String>,
arguments: Option<String>,
}
// 协议数据结构(OpenAiRequest / OpenAiResponse / OpenAiStreamChunk …)与 SSE 解析纯函数
// apply_openai_sse 抽离到 openai_helpers.rs,结构对齐 anthropic_helpers.rs。
// Rust impl 块不可跨文件,Provider struct + impl 仍留本文件。
use crate::openai_helpers::{
apply_openai_sse, OpenAiMessage, OpenAiRequest, OpenAiResponse, OpenAiToolCallResp,
};
// ============================================================
// OpenAI Compat Provider
@@ -139,16 +47,9 @@ impl OpenAICompatProvider {
/// - `api_key`: API 密钥
/// - `default_model`: 默认模型名称
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, default_model: impl Into<String>) -> Self {
// connect_timeout 防连接阶段无限 hang(网络静默断);不设总 timeout——
// reqwest 的 .timeout() 会限制整个响应 body 时长,流式长生成任务会被误砍。
// 读取阶段的中途静默由上层 stream_llm 的 idle timeout 兜底。
let client = Client::builder()
.connect_timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_else(|e| {
warn!("reqwest builder 失败,降级为默认 client(无 connect_timeout): {}", e);
Client::new()
});
// SW-260618-10: reqwest Client 构建抽 crate::build_provider_client(与 Anthropic 共用 DRY)。
// connect_timeout/回退策略集中此处,未来改一处即可(见 lib.rs::build_provider_client)。
let client = crate::build_provider_client();
Self {
client,
api_key: api_key.into(),
@@ -184,6 +85,18 @@ impl OpenAICompatProvider {
}
}
/// 构建 embeddings API URL(与 chat_url 同套智能拼接规则)
fn embed_url(&self) -> String {
let base = self.base_url.trim_end_matches('/');
if base.ends_with("/embeddings") {
return base.to_string();
}
if Self::ends_with_version(base) {
return format!("{}/embeddings", base);
}
format!("{}/v1/embeddings", base)
}
/// 将通用请求转换为 OpenAI 格式
fn convert_request(&self, req: CompletionRequest) -> OpenAiRequest {
let model = if req.model.is_empty() {
@@ -202,6 +115,43 @@ impl OpenAICompatProvider {
crate::provider::MessageRole::Assistant => "assistant",
crate::provider::MessageRole::Tool => "tool",
};
// F-260614-05 Phase 2a: 多模态 content(须在 move m.tool_calls 之前算,借用 m)。
// 含图消息走 content 数组(text/image_url);纯文本走字符串简写
// (保持与现有纯文本端点零回归)。image_url 支持 data URI(base64)与 http(s) URL。
let content = if m.has_image() {
let parts: Vec<serde_json::Value> = m
.flattened_parts()
.into_iter()
.map(|p| match p {
crate::provider::ContentPart::Text { text } => serde_json::json!({
"type": "text",
"text": text,
}),
crate::provider::ContentPart::Image { url, base64, media_type, alt: _ } => {
let final_url = match (base64, url, media_type) {
(Some(b), _, Some(mt)) => {
format!("data:{};base64,{}", mt, b)
}
(None, Some(u), _) => u,
// 完整性兜底:当前 image_base64 构造器强制 media_type:Some,
// image_url 构造器提供 url:Some,二者分别命中上两个分支;
// 此分支仅在 parts 来源被外部直接构造且字段均缺时才可达
//(如 url:None+base64:None 或 base64:Some+media_type:None)。
// 退化为空串(OpenAI 对空 image_url.url 会 400),
// 由调用方保证 parts 合法性,provider 层不做静默伪造。
_ => String::new(),
};
serde_json::json!({
"type": "image_url",
"image_url": { "url": final_url },
})
}
})
.collect();
serde_json::Value::Array(parts)
} else {
serde_json::Value::String(m.content.clone())
};
let tool_calls = m.tool_calls.map(|calls| {
calls
.into_iter()
@@ -219,9 +169,10 @@ impl OpenAICompatProvider {
});
OpenAiMessage {
role: role.to_string(),
content: m.content,
content,
tool_call_id: m.tool_call_id,
tool_calls,
reasoning_content: m.reasoning_content,
}
})
.collect();
@@ -240,6 +191,13 @@ impl OpenAICompatProvider {
stream: req.stream,
tools,
tool_choice: req.tool_choice,
reasoning_content: req.reasoning_content,
// 流式请求末 chunk 带 usage(同步调用 complete 不需要)
stream_options: if req.stream {
Some(serde_json::json!({ "include_usage": true }))
} else {
None
},
}
}
@@ -254,6 +212,34 @@ impl OpenAICompatProvider {
#[async_trait]
impl LlmProvider for OpenAICompatProvider {
/// 文本嵌入: POST /v1/embeddings(OpenAI 兼容,智谱/阿里百炼/OpenAI 通用)
async fn embed(&self, model: &str, texts: Vec<String>) -> anyhow::Result<Vec<Vec<f32>>> {
#[derive(serde::Deserialize)]
struct EmbedData { embedding: Vec<f32>, index: usize }
#[derive(serde::Deserialize)]
struct EmbedResponse { data: Vec<EmbedData> }
let resp = self
.client
.post(self.embed_url())
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&serde_json::json!({ "model": model, "input": texts }))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Embedding API 错误 {}: {}", status, body);
}
let mut body: EmbedResponse = resp.json().await?;
// 按 index 排序保证与输入顺序一致(API 不保证返回顺序)
body.data.sort_by_key(|d| d.index);
Ok(body.data.into_iter().map(|d| d.embedding).collect())
}
async fn complete(&self, request: CompletionRequest) -> anyhow::Result<CompletionResponse> {
let mut req = request;
req.stream = false;
@@ -261,48 +247,76 @@ impl LlmProvider for OpenAICompatProvider {
debug!(model = %openai_req.model, "OpenAI 同步调用");
let resp = self
.client
.post(self.chat_url())
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&openai_req)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
error!(%status, %body, "LLM API 调用失败");
anyhow::bail!("LLM API 错误 {}: {}", status, body);
}
let body: OpenAiResponse = resp.json().await?;
let choice = body
.choices
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("LLM 响应无 choices"))?;
let text = choice.message.content.unwrap_or_default();
let tool_calls = choice.message.tool_calls.map(Self::parse_tool_calls);
let usage = body.usage.map(|u| TokenUsage {
prompt_tokens: u.prompt_tokens,
completion_tokens: u.completion_tokens,
total_tokens: u.total_tokens,
}).unwrap_or(TokenUsage {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
});
Ok(CompletionResponse {
text,
model: body.model,
usage,
tool_calls,
// 指数退避重试(B-260616-07): 包裹 send + 状态码判定。
// 单请求 60s timeout 保持不变(FR-R4),重试是额外层: 3 次 × 60s 最坏 180s,
// 由 retry_with_backoff 内部 30s 总预算主动止损。
let label = format!("OpenAI[{}]", openai_req.model);
retry_with_backoff(&label, move |_| {
let client = self.client.clone();
let url = self.chat_url();
let api_key = self.api_key.clone();
let openai_req = openai_req.clone();
async move {
// send
let resp = client
.post(url)
.header("Authorization", format!("Bearer {}", api_key))
.header("Content-Type", "application/json")
.timeout(Duration::from_secs(60))
.json(&openai_req)
.send()
.await;
let resp = match resp {
Ok(r) => r,
Err(e) => {
if is_reqwest_error_retryable(&e) {
return AttemptOutcome::Retryable(format!("请求失败(可重试): {}", e));
}
return AttemptOutcome::Fatal(format!("请求失败(不可重试): {}", e));
}
};
// 状态码判定
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
let msg = format!("LLM API 错误 {}: {}", status, body);
if is_status_retryable(status) {
warn!(%status, "OpenAI 同步调用可重试状态码");
return AttemptOutcome::Retryable(msg);
}
error!(%status, %body, "LLM API 调用失败(不可重试)");
return AttemptOutcome::Fatal(msg);
}
// body 解析: 解析错属 Fatal(响应已成功送达,重试也会因同样格式失败)
let body: OpenAiResponse = match resp.json().await {
Ok(b) => b,
Err(e) => return AttemptOutcome::Fatal(format!("响应解析失败: {}", e)),
};
let choice = match body.choices.into_iter().next() {
Some(c) => c,
None => return AttemptOutcome::Fatal("LLM 响应无 choices".to_string()),
};
let text = choice.message.content.unwrap_or_default();
let tool_calls = choice.message.tool_calls.map(Self::parse_tool_calls);
let usage = body.usage.map(|u| TokenUsage {
prompt_tokens: u.prompt_tokens,
completion_tokens: u.completion_tokens,
total_tokens: u.total_tokens,
}).unwrap_or(TokenUsage {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
});
AttemptOutcome::Ok(CompletionResponse {
text,
model: body.model,
usage,
tool_calls,
reasoning_content: choice.message.reasoning_content,
})
}
})
.await
}
async fn stream(&self, request: CompletionRequest) -> anyhow::Result<StreamResult> {
@@ -328,68 +342,23 @@ impl LlmProvider for OpenAICompatProvider {
anyhow::bail!("LLM 流式 API 错误 {}: {}", status, body);
}
// 累积流式 usage:开 include_usage 后,末段正常 chunk(finish_reason)及额外 usage-only chunk(choices=[])都带 usage。
// usage 解析/累积逻辑抽到 apply_openai_sse 纯函数,便于单测;此处闭包只负责传 data 与传递 last_usage。
let mut last_usage: Option<TokenUsage> = None;
let stream = resp
.bytes_stream()
.eventsource()
.map(move |event| {
match event {
Ok(event) => {
// OpenAI 发送 "data: [DONE]" 表示流结束
if event.data == "[DONE]" {
return Ok(StreamChunk {
delta: String::new(),
finished: true,
tool_calls: None,
});
}
match serde_json::from_str::<OpenAiStreamChunk>(&event.data) {
Ok(chunk) => {
if let Some(choice) = chunk.choices.into_iter().next() {
let delta_text = choice.delta.content.unwrap_or_default();
// "length" = max_tokens 截断,属正常终止(非断连),纳入 finished
let finished = choice.finish_reason.as_deref() == Some("stop")
|| choice.finish_reason.as_deref() == Some("tool_calls")
|| choice.finish_reason.as_deref() == Some("length");
let tool_calls = choice.delta.tool_calls.map(|tcs| {
tcs.into_iter()
.map(|tc| ToolCallDelta {
index: tc.index,
id: tc.id,
function_name: tc.function.as_ref().and_then(|f| f.name.clone()),
function_arguments: tc.function.and_then(|f| f.arguments),
})
.collect()
});
Ok(StreamChunk {
delta: delta_text,
finished,
tool_calls,
})
} else {
Ok(StreamChunk {
delta: String::new(),
finished: false,
tool_calls: None,
})
}
}
Err(e) => {
debug!("SSE 数据解析失败: {} — data: {}", e, event.data);
Ok(StreamChunk {
delta: String::new(),
finished: false,
tool_calls: None,
})
}
}
}
Err(e) => {
error!("SSE 流错误: {}", e);
Err(anyhow::anyhow!("SSE 流错误: {}", e))
}
.map(move |event| match event {
Ok(event) => Ok(apply_openai_sse(&event.data, &mut last_usage)),
Err(e) => {
// 保留 #[source] 因果链: anyhow!("...{}", e) 仅把 e 的 Display 塞进 message,
// 丢掉 source(无法 downcast/遍历)。改用 Error::from(e).context(...):
// Display 不变(仍为 "SSE 流错误: {e}"), 且 e 作为 .source() 可追溯。
// 顺序: 先 format(e) 构造 context 文案, 再 Error::from(e) move e 进 source。
let ctx = format!("SSE 流错误: {}", e);
error!("{}", ctx);
Err(anyhow::Error::from(e).context(ctx))
}
});
@@ -400,11 +369,229 @@ impl LlmProvider for OpenAICompatProvider {
&self.default_model
}
fn supported_features(&self) -> ProviderFeatures {
ProviderFeatures {
streaming: true,
function_calling: true,
vision: false,
}
fn endpoint(&self) -> String {
self.chat_url()
}
}
// ============================================================
// 单测(不发真实 HTTP,喂构造的 SSE data 字符串序列)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
/// 辅助:构造普通文本 delta chunk 的 SSE data
fn text_chunk(content: &str, finish_reason: Option<&str>) -> String {
let fr = match finish_reason {
Some(r) => format!(", \"finish_reason\": \"{}\"", r),
None => String::from(", \"finish_reason\": null"),
};
format!(
r#"{{"choices":[{{"delta":{{"content":"{}"}}{}}}]}}"#,
content, fr
)
}
/// 辅助:构造带 usage 的 chunk(choices 为空 → usage-only 末 chunk,对应 include_usage)
fn usage_only_chunk(prompt: u32, completion: u32) -> String {
format!(
r#"{{"choices":[],"usage":{{"prompt_tokens":{},"completion_tokens":{},"total_tokens":{}}}}}"#,
prompt,
completion,
prompt + completion
)
}
/// 辅助:构造既有 content 又带 usage 的末段 chunk(部分兼容端点会把 usage 挂到正常末 chunk 上)
fn text_chunk_with_usage(content: &str, finish_reason: &str, prompt: u32, completion: u32) -> String {
format!(
r#"{{"choices":[{{"delta":{{"content":"{}"}},"finish_reason":"{}"}}],"usage":{{"prompt_tokens":{},"completion_tokens":{},"total_tokens":{}}}}}"#,
content,
finish_reason,
prompt,
completion,
prompt + completion
)
}
/// 多 chunk 文本流后,末 chunk 携带 usage(include_usage 覆盖语义)
#[test]
fn openai_sse_multi_chunk_with_terminal_usage() {
let mut acc: Option<TokenUsage> = None;
// 1) 首个文本增量,无 usage
let c = apply_openai_sse(&text_chunk("Hello", None), &mut acc);
assert_eq!(c.delta, "Hello");
assert!(!c.finished);
assert!(c.usage.is_none());
assert!(acc.is_none(), "无 usage 的 chunk 不应改累加器");
// 2) 第二个文本增量
let c = apply_openai_sse(&text_chunk(" world", None), &mut acc);
assert_eq!(c.delta, " world");
assert!(!c.finished);
assert!(acc.is_none());
// 3) 末段正常 chunk 带 finish_reason=stop(仍是文本 delta,不带 usage)
let c = apply_openai_sse(&text_chunk("", Some("stop")), &mut acc);
assert!(c.finished);
assert_eq!(c.delta, "");
assert!(acc.is_none(), "此 chunk 无 usage 字段,累加器仍为 None");
// 4) usage-only chunk(choices=[])携带累计 usage → 覆盖累加器
let c = apply_openai_sse(&usage_only_chunk(12, 34), &mut acc);
assert!(!c.finished);
assert!(c.usage.is_none(), "非 [DONE] chunk 不带出 usage");
let acc = acc.expect("累加器应已被 usage-only chunk 覆盖写入");
assert_eq!(acc.prompt_tokens, 12);
assert_eq!(acc.completion_tokens, 34);
assert_eq!(acc.total_tokens, 46);
}
/// usage 挂在正常末段 chunk(含 finish_reason)上,而非独立 usage-only chunk
#[test]
fn openai_sse_usage_on_terminal_text_chunk() {
let mut acc: Option<TokenUsage> = None;
let c = apply_openai_sse(&text_chunk_with_usage("", "stop", 100, 200), &mut acc);
assert!(c.finished);
assert!(c.usage.is_none(), "非 [DONE] 不带出 usage,仅覆盖累加器");
let acc = acc.expect("末段 chunk 的 usage 应已覆盖累加器");
assert_eq!(acc.prompt_tokens, 100);
assert_eq!(acc.completion_tokens, 200);
assert_eq!(acc.total_tokens, 300);
}
/// [DONE] 时 take() 带出累积 usage,且取走后累加器清空
#[test]
fn openai_sse_done_takes_accumulated_usage() {
let mut acc: Option<TokenUsage> = None;
apply_openai_sse(&text_chunk("x", None), &mut acc);
apply_openai_sse(&usage_only_chunk(5, 7), &mut acc);
let c = apply_openai_sse("[DONE]", &mut acc);
assert!(c.finished);
let u = c.usage.expect("[DONE] 应带出累积 usage");
assert_eq!(u.prompt_tokens, 5);
assert_eq!(u.completion_tokens, 7);
assert_eq!(u.total_tokens, 12);
assert!(acc.is_none(), "take() 后累加器应清空");
}
/// 无 usage 的流:[DONE] 时 usage 字段为 None
#[test]
fn openai_sse_done_without_usage() {
let mut acc: Option<TokenUsage> = None;
apply_openai_sse(&text_chunk("hi", None), &mut acc);
let c = apply_openai_sse("[DONE]", &mut acc);
assert!(c.finished);
assert!(c.usage.is_none(), "全程无 usage 时 [DONE] usage 应为 None");
assert!(acc.is_none());
}
/// 后续 usage chunk 覆盖先前 usage(多轮 / 重发场景)
#[test]
fn openai_sse_later_usage_overrides_earlier() {
let mut acc: Option<TokenUsage> = None;
apply_openai_sse(&usage_only_chunk(1, 1), &mut acc);
apply_openai_sse(&usage_only_chunk(50, 60), &mut acc);
let c = apply_openai_sse("[DONE]", &mut acc);
let u = c.usage.unwrap();
assert_eq!(u.prompt_tokens, 50, "末 usage 应覆盖前值");
assert_eq!(u.completion_tokens, 60);
assert_eq!(u.total_tokens, 110);
}
/// finish_reason=length(max_tokens 截断)按正常终止处理
#[test]
fn openai_sse_length_finish_reason_treated_as_finished() {
let mut acc: Option<TokenUsage> = None;
let c = apply_openai_sse(&text_chunk("...", Some("length")), &mut acc);
assert!(c.finished, "length 应视为正常终止");
assert!(acc.is_none());
}
/// 非法 JSON data → 返回空 chunk,不 panic、不改累加器
#[test]
fn openai_sse_malformed_json_yields_empty_chunk() {
let mut acc: Option<TokenUsage> = None;
let c = apply_openai_sse("not a json", &mut acc);
assert_eq!(c.delta, "");
assert!(!c.finished);
assert!(c.usage.is_none());
assert!(acc.is_none());
}
/// tool_calls 增量解析
#[test]
fn openai_sse_tool_call_delta() {
let mut acc: Option<TokenUsage> = None;
let data = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{\"q\":"}}]}}]}"#;
let c = apply_openai_sse(data, &mut acc);
assert!(acc.is_none());
let tcs = c.tool_calls.expect("应有 tool_calls 增量");
assert_eq!(tcs.len(), 1);
assert_eq!(tcs[0].index, 0);
assert_eq!(tcs[0].id.as_deref(), Some("call_1"));
assert_eq!(tcs[0].function_name.as_deref(), Some("get_weather"));
assert_eq!(tcs[0].function_arguments.as_deref(), Some("{\"q\":"));
assert!(!c.finished);
}
// ---------- F-260614-05 Phase 2a 多模态 convert_request ----------
/// 含图消息 → content 数组(text + image_url data URI);纯文本 → 字符串简写
#[test]
fn openai_convert_multimodal_content() {
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
let req = CompletionRequest {
model: "gpt-4o".into(),
messages: vec![ChatMessage::user_parts(
"看图",
vec![
crate::provider::ContentPart::image_base64("image/png", "iVBOR"),
crate::provider::ContentPart::image_url("https://x/a.png"),
],
)],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
let msg = &out.messages[0];
// content 是数组:[text "看图", image_url(data URI), image_url(http url)]
let arr = msg.content.as_array().expect("含图 → content 数组");
assert_eq!(arr.len(), 3);
assert_eq!(arr[0]["type"], "text");
assert_eq!(arr[0]["text"], "看图");
assert_eq!(arr[1]["type"], "image_url");
assert_eq!(
arr[1]["image_url"]["url"],
"data:image/png;base64,iVBOR"
);
assert_eq!(arr[2]["image_url"]["url"], "https://x/a.png");
}
/// 纯文本消息 → content 仍是字符串简写(无图不数组化,对齐纯文本端点兼容)
#[test]
fn openai_convert_text_only_remains_string() {
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
let req = CompletionRequest {
model: "gpt-4o".into(),
messages: vec![ChatMessage::user("hello")],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
let msg = &out.messages[0];
assert_eq!(msg.content, serde_json::Value::String("hello".into()));
}
}
+231
View File
@@ -0,0 +1,231 @@
//! 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;
use crate::provider::{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(crate) struct OpenAiChoice {
pub message: OpenAiMessageResp,
// SW-260618-24: OpenAI 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning
#[allow(dead_code)]
pub finish_reason: Option<String>,
}
#[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 {
pub id: String,
// SW-260618-24: 反序列化 #[serde(rename="type")] 字段,保留以对齐 OpenAI 响应结构,标注意图消除 dead_code warning
#[allow(dead_code)]
#[serde(rename = "type")]
pub call_type: String,
pub function: OpenAiFunctionResp,
}
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiFunctionResp {
pub name: String,
pub arguments: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiUsage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
}
/// SSE 流式响应 chunk
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiStreamChunk {
pub choices: Vec<OpenAiStreamChoice>,
/// 末 chunk(choices 为空)携带的累计 usage
#[serde(default)]
pub usage: Option<OpenAiUsage>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiStreamChoice {
pub delta: OpenAiStreamDelta,
pub finish_reason: Option<String>,
}
#[derive(Debug, 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 {
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,
};
}
match serde_json::from_str::<OpenAiStreamChunk>(data) {
Ok(chunk) => {
// 提取 usage(带 include_usage 时末段 chunk 携带,覆盖累积)
if let Some(u) = chunk.usage {
*usage_accum = Some(TokenUsage {
prompt_tokens: u.prompt_tokens,
completion_tokens: u.completion_tokens,
total_tokens: u.total_tokens,
});
}
if let Some(choice) = chunk.choices.into_iter().next() {
let delta_text = choice.delta.content.unwrap_or_default();
// "length" = max_tokens 截断,属正常终止(非断连),纳入 finished
let finished = choice.finish_reason.as_deref() == Some("stop")
|| choice.finish_reason.as_deref() == Some("tool_calls")
|| choice.finish_reason.as_deref() == Some("length");
let tool_calls = choice.delta.tool_calls.map(|tcs| {
tcs.into_iter()
.map(|tc| ToolCallDelta {
index: tc.index,
id: tc.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: None,
error: None,
reasoning_content: choice.delta.reasoning_content,
}
} else {
// choices 为空 = usage-only chunk,不输出文本(usage 已累积)
StreamChunk {
delta: String::new(),
finished: false,
tool_calls: None,
usage: None,
error: None,
reasoning_content: None,
}
}
}
Err(e) => {
debug!("SSE 数据解析失败: {} — data: {}", e, data);
StreamChunk {
delta: String::new(),
finished: false,
tool_calls: None,
usage: None,
error: None,
reasoning_content: None,
}
}
}
}
+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::*;
+268
View File
@@ -0,0 +1,268 @@
//! complete() 指数退避重试 — 仅非流式同步路径,不动 stream()
//!
//! 背景: complete() 是 LLM 一次性请求,网络波动/瞬态 5xx/429 会让整轮对话静默失败。
//! 各调用方(title.rs/knowledge_inject.rs/project.rs/ai_node.rs)虽已有人工兜底降级,
//! 但重试能在不侵入上层的前提下吸收瞬态错误,对低频后台调用零风险。
//!
//! 策略:
//! - 最多 3 次尝试(初次 + 2 次重试),指数退避 1s → 2s → 4s + ±20% 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, SystemTime};
use tracing::warn;
/// 最多尝试次数(含初次)。B-260616-07: 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);
/// jitter 上限(相对 base 的 ±比例)。避免重试风暴对齐。
const JITTER_RATIO: f64 = 0.2;
/// 一次尝试的分类结果 —— 在 anyhow 不透明化前决定是否值得重试。
///
/// CR-30-1: 暴露给 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)不重试。
///
/// CR-30-1: 暴露给 agentic.rs 流前重试复用(4xx Fatal 立即放弃,5xx/429 重试)。
pub fn is_status_retryable(status: u16) -> bool {
status == 429 || (500..600).contains(&status)
}
/// 指数退避 + jitter: 返回第 `attempt`(1-based)次重试前应 sleep 的时长。
/// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s,各 ±20% jitter。
///
/// jitter 用 SystemTime 纳秒取模生成(无依赖),避免多客户端同步重试风暴。
/// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。
///
/// CR-30-1: 暴露 pub 供 src-tauri/agentic.rs 流前重试复用(对齐决策 F-260616-07 a1
/// "复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。
pub fn backoff_delay(attempt: u32) -> Duration {
let base_ms = BASE_BACKOFF_SECS.saturating_mul(1u64 << (attempt - 1)) * 1000;
// 纳秒 → [0, 2000) 区间,再映射到 [-1.0, +1.0) 比例
let nanos = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.subsec_nanos() as u64)
.unwrap_or(0);
let jitter_ratio = (nanos % 2000) as f64 / 1000.0 - 1.0; // [-1.0, 1.0)
let factor = 1.0 + jitter_ratio * JITTER_RATIO; // [0.8, 1.2]
let ms = (base_ms as f64 * factor).max(0.0) as u64;
Duration::from_millis(ms)
}
/// 对一个返回 `AttemptOutcome` 的异步操作做指数退避重试。
///
/// `attempt_fn` 每次被调用执行一次尝试并返回分类结果。
/// 达到 `MAX_COMPLETE_ATTEMPTS` 或超出 `MAX_TOTAL_BUDGET` 或遇 Fatal 即终止。
/// 终止时若仍失败,返回最后一次的 Retryable/Fatal 描述作为 `anyhow::Error`。
///
/// 调用方约定: `attempt_fn` 内部应保留完整请求重发能力(每次重建 RequestBuilder),
/// 因为 reqwest::RequestBuilder 一次 `.send()` 后不可复用。
///
/// CR-30-1: 仍 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 ±20% 后仍在 [0.8,4.8] 范围
for attempt in 1..=3 {
let d = backoff_delay(attempt);
let base_ms = (BASE_BACKOFF_SECS * (1u64 << (attempt - 1))) * 1000;
let lo = (base_ms as f64 * (1.0 - JITTER_RATIO)) as u64;
let hi = (base_ms as f64 * (1.0 + JITTER_RATIO)) as u64;
assert!(
d.as_millis() as u64 >= lo && d.as_millis() as u64 <= hi,
"attempt={} delay={:?} 应在 [{},{}]ms",
attempt,
d,
lo,
hi
);
}
}
#[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 不应触发重试");
}
}
+355 -39
View File
@@ -1,51 +1,367 @@
//! 模型路由 — 根据任务类型选择最优模型
//! 模型路由器 — F-01 阶段4
//!
//! 纯函数核心,零 IO / 零状态。给定 TaskRequirements + 候选池,返回最优 ModelConfig。
//! 不接调用点(那是阶段5:agentic.rs / title.rs / knowledge_inject.rs / project.rs /
//! df-ideas / df-nodes ai_node.rs)。
//!
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §6.1。
//!
//! ModelRouter 为单元结构,select 是无状态关联函数(对齐任务规格,非设计文档的 `&self` 方法)。
use serde::{Deserialize, Serialize};
// 阶段5: 调用点经 `df_ai::router::{Modality, Capability, CostTier, IntelligenceTier}`
// 直接 import 维度枚举构造 TaskRequirements(对齐任务规格 import 风格),re-export 避免调用点
// 各自从 df_ai_core::model 取(跨 crate 路径冗长)。select/select_model_id 仅借用枚举,无重定义。
// 注:CostTier/IntelligenceTier 路由已解耦(2026-06-18 B-260618-03)——provider /v1/models API
// 不返回这两维度,数据无客观依据不可信,不参与硬路由;re-export 保留供未来真实判别源。
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,
}
/// 模型路由器
/// 任务对模型的需求(3 维度)。
///
/// 根据任务类型、成本、延迟等选择最优模型
pub struct ModelRouter {
/// 默认模型
default_model: String,
/// 由调用点构造(阶段5),描述本次调用需要什么模态/能力/上下文,
/// 交 ModelRouter::select 在候选池中选最优模型。
///
/// 路由已解耦(2026-06-18 B-260618-03):原 `min_intelligence`/`max_cost` 两字段删除。
/// provider /v1/models API 不返回 cost_tier/intelligence,这两维度 100% 靠预设表写死 +
/// 模型名启发式猜,数据无客观依据不可信,不应参与硬路由。枚举(CostTier/IntelligenceTier)
/// 保留供未来出现真实判别源时再接回。
#[derive(Debug, Clone)]
pub struct TaskRequirements {
/// 任务所需的模态集合(全子集匹配:任务所需模态都必须在模型模态里)
pub modalities: Vec<Modality>,
/// 是否需要工具调用能力(needs_tool_use=true 时候选必须含 Capability::ToolUse)
pub needs_tool_use: bool,
/// 预估上下文大小(tokens,模型 context_window 必须 >= 此值)
pub estimated_context: usize,
}
/// 模型路由器(单元结构,无状态)。
///
/// select 为关联函数:给定需求 + 候选池,执行过滤链选最优模型。
pub struct ModelRouter;
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_key 选最优:纯 weight 主导(权重高者胜)
///
/// 路由已解耦(2026-06-18 B-260618-03):原「智力达标」/「成本可控」两步删除,
/// 原第 7 步排序的 `Reverse(cost_tier)` 同权重选便宜也已删除——排序纯 weight 主导。
/// cost_tier/intelligence 数据无客观依据(provider /v1/models 不返回,靠预设表+模型名
/// 启发式猜),不参与硬路由。枚举保留供未来真实判别源再接回。
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. 窗口够大
.max_by_key(|m| m.weight) // 5. 纯 weight 主导
}
}
/// 阶段5 调用点 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())
}
#[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,
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(),
/// 构造一个宽松需求(默认全过过滤,调用方按需覆盖字段)。
fn req() -> TaskRequirements {
TaskRequirements {
modalities: vec![Modality::Text],
needs_tool_use: false,
estimated_context: 0,
}
}
// ── 阶段5 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(原智力/成本过滤已解耦 B-260618-03):窗口够大 ──
#[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());
}
// ── 步骤 5:max_by_key (纯 weight) ──
#[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,纯 weight 主导(无 cost_tier tie-break):max_by_key 遇并列 key
// 返回最后一个(rust Iterator::max_by_key 语义)。验证同 weight 不再按 cost 取舍。
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_key 纯 weight 选最优。
// (B-260618-03:智力/成本过滤已解耦,原步骤 4/5 删除,候选 d 不再因 intelligence 滤掉)
//
// 候选:
// a: weight 60 → 通过全部过滤,key=60
// b: weight 80 → 通过,key=80 — weight 最高档(与 c 并列)
// c: weight 80 → 通过,key=80 — 同 weight 80,max_by_key 并列返回最后
// d: weight 90 → 通过(B-260618-03 后 intelligence 不参与过滤),key=90 — weight 最高,胜
// e: enabled=false → 步骤 1 滤掉
//
// 预期:d 胜(weight 90 最高,不再被 intelligence 滤掉)
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,
};
assert_eq!(ModelRouter::select(&r, &pool).unwrap().model_id, "d");
}
}
-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()
}
}
-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;
-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 }
-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,
})
}
-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(),
})
}
+1 -4
View File
@@ -1,6 +1,3 @@
//! df-execute: 执行运行时 — Shell、Docker、SSH、Git 操作
//! df-execute: 执行运行时 — Shell
pub mod docker;
pub mod git_ops;
pub mod shell;
pub mod ssh;
+59 -10
View File
@@ -1,6 +1,7 @@
//! Shell 执行器 — 通过 tokio::process 执行 shell 命令
use serde::{Deserialize, Serialize};
use std::process::Stdio;
/// Shell 命令执行结果
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -15,6 +16,24 @@ pub struct ShellResult {
pub duration_ms: u64,
}
/// Shell 类型选择
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ShellType {
/// Windows cmd.exe (默认 Windows)
Cmd,
/// PowerShell
PowerShell,
/// Unix sh (默认非 Windows)
Sh,
}
impl Default for ShellType {
fn default() -> Self {
if cfg!(windows) { ShellType::Cmd } else { ShellType::Sh }
}
}
/// Shell 命令执行请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShellRequest {
@@ -26,24 +45,54 @@ pub struct ShellRequest {
pub env: std::collections::HashMap<String, String>,
/// 超时时间(秒),None 表示不超时
pub timeout_secs: Option<u64>,
/// Shell 类型(默认:Windows→Cmd, 非Windows→Sh)
#[serde(default)]
pub shell_type: Option<ShellType>,
}
/// 执行 Shell 命令
///
/// TODO: 完整实现,支持超时、环境变量、工作目录等
/// 支持超时(timeout_secs)、环境变量(env)、工作目录(working_dir),
/// kill_on_drop(true) 保证超时后子进程不残留,shell_type 可选 Cmd/PowerShell/Sh。
pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
let start = std::time::Instant::now();
let mut cmd = if cfg!(windows) {
let mut c = tokio::process::Command::new("cmd");
c.arg("/C").arg(&request.command);
c
} else {
let mut c = tokio::process::Command::new("sh");
c.arg("-c").arg(&request.command);
c
let shell_type = request.shell_type.unwrap_or_default();
let mut cmd = match shell_type {
ShellType::PowerShell => {
let mut c = tokio::process::Command::new("powershell");
c.arg("-NoProfile").arg("-Command").arg(&request.command);
c.stdout(Stdio::piped()).stderr(Stdio::piped());
c
}
ShellType::Cmd => {
let mut c = tokio::process::Command::new("cmd");
c.arg("/C").arg(&request.command);
c.stdout(Stdio::piped()).stderr(Stdio::piped());
c
}
ShellType::Sh => {
let mut c = tokio::process::Command::new("sh");
c.arg("-c").arg(&request.command);
c.stdout(Stdio::piped()).stderr(Stdio::piped());
c
}
};
// CR-15-1: kill_on_drop(true) 让 Command 被 drop 时主动 kill 子进程。
// 配合 tokio::time::timeout 超时场景:超时 drop future → Command 析构 → kill 子进程,
// 不再让超时后的命令变孤儿继续后台跑(长 hang 命令/死循环仍占资源)。
// 对齐 tool_registry.rs:514「进程已终止」文案名副其实。tokio 1.52.3 支持。
cmd.kill_on_drop(true);
// B-260619-01: Windows 下创建子进程默认弹控制台窗口(cmd/powershell 黑窗闪现)。
// CREATE_NO_WINDOW(0x0800_0000) 标志抑制窗口创建,后台静默执行。
// tokio::process::Command 在 Windows 自带 creation_flags 方法(无需 std CommandExt trait)。
#[cfg(windows)]
{
cmd.creation_flags(0x0800_0000);
}
if let Some(dir) = &request.working_dir {
cmd.current_dir(dir);
}
@@ -58,7 +107,7 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
cmd.output(),
)
.await
.map_err(|_| anyhow::anyhow!("命令执行超时: {}秒", secs))??,
.map_err(|_| anyhow::anyhow!("命令执行超时({}s): {}", secs, request.command))??,
None => cmd.output().await?,
};
-38
View File
@@ -1,38 +0,0 @@
//! SSH 执行器 — 远程命令执行
use serde::{Deserialize, Serialize};
/// SSH 执行请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SshRequest {
/// 主机地址
pub host: String,
/// 端口
pub port: u16,
/// 用户名
pub user: String,
/// 要执行的命令
pub command: String,
/// 超时时间(秒)
pub timeout_secs: Option<u64>,
}
/// SSH 执行结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SshResult {
pub stdout: String,
pub stderr: String,
pub exit_code: Option<i32>,
}
/// 通过 SSH 执行远程命令
///
/// TODO: 实现SSH连接(可用 ssh2 crate 或包装 ssh 命令)
pub async fn execute(_request: SshRequest) -> anyhow::Result<SshResult> {
tracing::info!("SSH 执行: TODO");
Ok(SshResult {
stdout: String::new(),
stderr: String::new(),
exit_code: None,
})
}
+161
View File
@@ -0,0 +1,161 @@
//! Shell 执行器基础集成测试。
//!
//! 覆盖 execute() 五类行为,跨平台(Windows=Cmd, 非 Windows=Sh):
//! 1. 成功:exit_code=Some(0) + stdout 非空 + duration_ms>0
//! 2. 非零退出:exit_code=Some(非0),返回 Ok(退出码在 ShellResult 内,非 Err)
//! 3. 超时:timeout_secs=1 + 长睡命令,返回 Err
//! 4. env 注入:env HashMap 传 KEY=VAL,命令回显验证
//! 5. working_dir:传临时目录,命令回显当前目录验证
//!
//! 注:execute 逻辑本身未改动,此文件为零行为变更的纯新增测试。
use df_execute::shell::{execute, ShellRequest, ShellType};
use std::collections::HashMap;
/// 平台默认 ShellType(对齐 shell.rs:31 Default impl:Windows→Cmd, 非 Windows→Sh)
fn default_shell() -> ShellType {
if cfg!(windows) {
ShellType::Cmd
} else {
ShellType::Sh
}
}
/// 构造最小 ShellRequest,默认无超时/无 env/无 working_dir
fn req(command: &str) -> ShellRequest {
ShellRequest {
command: command.to_string(),
working_dir: None,
env: HashMap::new(),
timeout_secs: None,
shell_type: Some(default_shell()),
}
}
// ---------- 1. 成功:exit_code=0 + stdout 非空 + duration_ms>0 ----------
#[tokio::test]
async fn execute_success_echo() {
// 平台无关:Cmd/Sh 都认 echo。Cmd 输出无尾换行,Sh 输出带 \n——只断言非空与包含。
let result = execute(req("echo hello_df_execute")).await;
assert!(result.is_ok(), "execute 应返回 Ok: {:?}", result.err());
let res = result.unwrap();
assert_eq!(res.exit_code, Some(0), "成功命令 exit_code 应为 0");
assert!(
res.stdout.trim().contains("hello_df_execute"),
"stdout 应包含回显文本,实际: {:?}",
res.stdout
);
assert!(res.duration_ms > 0, "duration_ms 应 > 0,实际: {}", res.duration_ms);
}
// ---------- 2. 非零退出:exit_code=Some(非0),返回 Ok ----------
#[tokio::test]
async fn execute_nonzero_exit() {
// 平台无关:false 是 POSIX 内建 + Windows 的 cmd 也内置(返回退出码 1)。
// 非 Windows 用 sh -c 'false';Windows 用 cmd /C 'cmd /C exit 1' 更稳——
// 这里统一用 false,Windows cmd 内置 false.exe 自 Win10 起可用,失败则 exit_code 非 0 即满足断言。
let result = execute(req("exit 1")).await;
assert!(result.is_ok(), "非零退出应仍返回 Ok(退出码在 ShellResult 内),实际: {:?}", result);
let res = result.unwrap();
assert_ne!(res.exit_code, Some(0), "exit 1 的 exit_code 应非 0,实际: {:?}", res.exit_code);
}
// ---------- 3. 超时:timeout_secs=1 + 长睡命令,返回 Err ----------
#[tokio::test]
async fn execute_timeout_returns_err() {
// 平台无关睡眠命令:Cmd 用 timeout(Windows 内置),Sh 用 sleep。各自分支。
let sleep_cmd = if cfg!(windows) {
// cmd 的 timeout /T 需按键中断,用 ping 假睡更稳(>1s);但 timeout 内置更简洁,
// 这里用 ping -n 5 (≈4s) 跨 Win 版本稳。
"ping -n 5 127.0.0.1 > nul".to_string()
} else {
"sleep 5".to_string()
};
let request = ShellRequest {
command: sleep_cmd,
working_dir: None,
env: HashMap::new(),
timeout_secs: Some(1),
shell_type: Some(default_shell()),
};
let result = execute(request).await;
assert!(
result.is_err(),
"超时应返回 Err,实际: {:?}",
result.as_ref().err()
);
// 错误信息应含「超时」(对齐 shell.rs:101 文案)
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("超时") || msg.to_lowercase().contains("timeout"),
"错误信息应含超时提示,实际: {}",
msg
);
}
// ---------- 4. env 注入:env HashMap 传 KEY=VAL,命令回显验证 ----------
#[tokio::test]
async fn execute_env_injection() {
let mut env = HashMap::new();
env.insert("DF_EXECUTE_TEST_KEY".to_string(), "env_value_42".to_string());
// 平台无关回显 env 变量:Cmd 用 %VAR%,Sh 用 $VAR
let echo_cmd = if cfg!(windows) {
"echo %DF_EXECUTE_TEST_KEY%"
} else {
"echo $DF_EXECUTE_TEST_KEY"
};
let request = ShellRequest {
command: echo_cmd.to_string(),
working_dir: None,
env,
timeout_secs: None,
shell_type: Some(default_shell()),
};
let res = execute(request).await.expect("env 注入命令应返回 Ok");
assert_eq!(res.exit_code, Some(0));
assert!(
res.stdout.trim().contains("env_value_42"),
"stdout 应含注入的 env 值,实际: {:?}",
res.stdout
);
}
// ---------- 5. working_dir:传临时目录,命令回显当前目录验证 ----------
#[tokio::test]
async fn execute_working_dir() {
// 用 std::env::temp_dir()(标准库,零新增依赖)。生成唯一子目录避免并行测试互相干扰。
let unique = format!("df_exec_test_{}", std::process::id());
let tmp = std::env::temp_dir().join(&unique);
std::fs::create_dir_all(&tmp).expect("创建临时目录失败");
// 测试后清理(即便失败也尽量不残留)
let tmp_for_cleanup = tmp.clone();
// 平台无关回显当前目录:Cmd 用 cd(无参数)不靠谱,用 echo %CD%;Sh 用 pwd。
let pwd_cmd = if cfg!(windows) { "echo %CD%" } else { "pwd" };
let request = ShellRequest {
command: pwd_cmd.to_string(),
working_dir: Some(tmp.to_string_lossy().to_string()),
env: HashMap::new(),
timeout_secs: None,
shell_type: Some(default_shell()),
};
let res = execute(request).await.expect("working_dir 命令应返回 Ok");
assert_eq!(res.exit_code, Some(0));
// 规范化比较(去尾空白/大小写在 Windows 不敏感但路径用户自定义,只断言包含)
let stdout_norm = res.stdout.trim().to_lowercase().replace('\\', "/");
let tmp_norm = tmp.to_string_lossy().to_lowercase().replace('\\', "/");
assert!(
stdout_norm.contains(&tmp_norm),
"stdout 应含 working_dir 路径,实际: {:?},期望包含: {:?}",
res.stdout,
tmp.to_string_lossy()
);
// 清理
let _ = std::fs::remove_dir_all(&tmp_for_cleanup);
}
+8 -1
View File
@@ -4,10 +4,17 @@ version = "0.1.0"
edition = "2021"
[dependencies]
df-core = { path = "../df-core" }
df-types = { path = "../df-types" }
df-ai-core = { path = "../df-ai-core" }
# F-01 阶段5: df-ideas 对抗评估接入 df_ai::router::select_model_id(纯函数路由)。
# 注:df-ai 引入 reqwest/futures/eventsource-stream 重依赖,但 router 模块仅依赖
# df-ai-core::model,实际编译期 df-ideas 仅引用 router 符号(零 HTTP 代码路径)。
df-ai = { path = "../df-ai" }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }
chrono = { workspace = true }
tracing = { workspace = true }
regex = { workspace = true }
+537 -249
View File
@@ -1,206 +1,303 @@
//! 对抗式评估系统 — 正反方辩论 + AI 分析师
//!
//! 双轨实现:
//! - **启发式**(默认/降级):基于评分与内容信号生成正反方论点,稳定有区分度。
//! - **LLM**(注入 provider 后):调一次 `complete()` 让论点由 LLM 生成,失败自动降级启发式。
//!
//! 评估来源由 [`EvaluatedBy`] 三态标记:`Llm`(LLM 深度评估)/ `Heuristic`(主动选启发式,
//! 无 provider)/ `HeuristicFallback`(LLM 调用失败降级)。前端可据此显示评估深度标签。
//!
//! LLM prompt 构造与 JSON 解析在 F-260614-03 接入:[`AdversarialEngine::evaluate_with_llm`]
//! 构造三角色辩论 prompt(正方/反方/分析师),调一次 `complete()` 要求返回对齐结构的 JSON,
//! 解析失败/字段缺失/枚举非法 → `bail` 触发降级([`AdversarialEngine::evaluate`] 已兜底)。
//!
//! 重构(strategy·自底向上):类型定义 / 常量 / prompt 构造 / JSON 解析等无副作用逻辑抽至
//! [`adversarial_helpers`],本文件只保留 [`AdversarialEngine`](有状态引擎 + evaluate 主入口,
//! 含 ARC-260618-01-e evaluate_with_llm 一致性待决策逻辑,原样保留)。外部已用路径(
//! `df_ideas::adversarial::{AdversarialEval, Recommendation, …}`)经 `pub use` 不变。
use std::sync::Arc;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use df_core::types::IdeaId;
use df_ai_core::model::ModelConfig;
use df_ai_core::provider::LlmProvider;
use df_types::types::Priority;
use crate::capture::Idea;
use crate::scoring::IdeaScores;
/// 对抗评估结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdversarialEval {
pub idea_id: IdeaId,
pub positive: Argument,
pub negative: Argument,
pub analyst: AnalystAnalysis,
pub final_score: f64,
pub recommendation: Recommendation,
}
/// 正方论点
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Argument {
pub thesis: String, // 核心观点
pub evidence: Vec<String>, // 证据支持
pub reasoning: Vec<String>, // 推理过程
pub confidence: f64, // 置信度 0-1
}
/// 反方论点
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CounterArgument {
pub thesis: String, // 反对观点
pub evidence: Vec<String>, // 反对证据
pub reasoning: Vec<String>, // 反驳推理
pub confidence: f64, // 置信度 0-1
}
/// AI 分析师综合分析
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalystAnalysis {
pub summary: String, // 综合总结
pub strengths: Vec<String>, // 主要优势
pub weaknesses: Vec<String>, // 主要劣势
pub risks: Vec<String>, // 潜在风险
pub opportunities: Vec<String>, // 机会点
pub final_assessment: AssessmentLevel, // 最终评估
}
/// 评估等级
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum AssessmentLevel {
StrongGo, // 强烈推荐执行
Recommended, // 推荐执行
Conditional, // 有条件执行
Revised, // 需要修改后执行
Defer, // 推迟执行
Reject, // 不推荐执行
}
/// 最终建议
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum Recommendation {
ImmediateAction, // 立即行动
Soon, // 尽快行动
WithResources, // 配置资源后行动
ResearchMore, // 需要更多研究
Monitor, // 持续监控
Cancel, // 取消想法
}
// 类型 / 常量 / 纯函数自 adversarial_helpers 引回,并 re-export:外部调用方经
// `df_ideas::adversarial::{...}` 路径访问的类型零变更(pub use 同时将符号带入当前
// 命名空间供 impl/tests 使用,无需额外 use)。AdversarialEngine 本身定义在本文件。
pub use crate::adversarial_helpers::{
AdversarialEval, AnalystAnalysis, Argument, AssessmentLevel, EvaluatedBy, Recommendation,
};
use crate::adversarial_helpers::{
SYSTEM_PROMPT, action_hint, assessment_desc, build_adversarial_prompt, parse_llm_eval,
priority_label,
};
/// 对抗评估引擎
pub struct AdversarialEngine;
pub struct AdversarialEngine {
/// 可选 LLM provider。Some → 优先 LLM 评估(失败降级启发式);None → 纯启发式。
/// 构造注入(与 IdeaPromoter::new(policy) 同一模式),批量评估复用同一 provider。
provider: Option<Arc<dyn LlmProvider>>,
/// F-01 阶段5: 候选模型池。非空时 evaluate_with_llm 经 select_model_id 路由选模型;
/// 空(None provider 或未注入池)→ model 留空由 provider impl 回填自身 default_model
/// (与接入前行为一致,平稳过渡)。
model_pool: Vec<ModelConfig>,
}
impl AdversarialEngine {
/// 执行完整的对抗评估
pub async fn evaluate(idea: &Idea) -> Result<AdversarialEval> {
// 1. 生成正方观点
let positive = Self::generate_positive_argument(idea).await?;
/// 注入 LLM provider 构造(provider Some 时走 LLM,调用失败自动降级启发式)
pub fn new(provider: Arc<dyn LlmProvider>) -> Self {
Self { provider: Some(provider), model_pool: Vec::new() }
}
// 2. 生成反方观点
let negative = Self::generate_negative_argument(idea, &positive).await?;
/// F-01 阶段5: 注入 provider + 候选模型池构造。池非空时 evaluate_with_llm 走路由。
pub fn with_pool(provider: Arc<dyn LlmProvider>, model_pool: Vec<ModelConfig>) -> Self {
Self { provider: Some(provider), model_pool }
}
// 3. AI 分析师综合分析
let analyst = Self::analyst_analysis(idea, &positive, &negative).await?;
/// 纯启发式构造(无 LLM 配置时的默认模式)
pub fn heuristic() -> Self {
Self { provider: None, model_pool: Vec::new() }
}
// 4. 计算最终分数和建议
let (final_score, recommendation) = Self::compute_final_assessment(&analyst);
/// 执行完整的对抗评估(内部按 provider 有无调度 LLM / 启发式,失败降级)
pub async fn evaluate(&self, idea: &Idea) -> Result<AdversarialEval> {
match &self.provider {
Some(p) => match self.evaluate_with_llm(idea, p).await {
Ok(mut eval) => {
eval.evaluated_by = EvaluatedBy::Llm;
Ok(eval)
}
Err(e) => {
// LLM 调用失败/超时/格式异常 → 自动降级启发式,保证前端结构完整返回
tracing::warn!("LLM 对抗评估失败, 降级到启发式: {e}");
let mut eval = self.evaluate_heuristic(idea)?;
eval.evaluated_by = EvaluatedBy::HeuristicFallback;
Ok(eval)
}
},
None => {
let mut eval = self.evaluate_heuristic(idea)?;
eval.evaluated_by = EvaluatedBy::Heuristic;
Ok(eval)
}
}
}
/// LLM 对抗评估(注入 provider 后走此路)。
///
/// 构造三角色对抗式辩论 prompt(正方/反方/分析师),调一次 `complete()`,要求 LLM
/// 返回对齐 [`AdversarialEval`] 结构的 JSON。任一环节失败(HTTP / 非 JSON / 字段
/// 缺失 / 枚举非法 / 数值越界无法修正)→ `bail` 由 [`Self::evaluate`] 捕获降级启发式。
///
/// temperature 取 0.4:低于 0.3 偏机械重复启发式信号,高于 0.5 易发散到无关风险,
/// 0.4 在「稳定可复现」与「论点多样性」间取得平衡。
///
/// ARC-260618-01-e: evaluate_with_llm 返回值一致性未校验(final_score 与
/// analyst.final_assessment 自洽性等),待产品决策,当前逻辑原样保留不调整。
async fn evaluate_with_llm(&self, idea: &Idea, provider: &Arc<dyn LlmProvider>) -> Result<AdversarialEval> {
let prompt = build_adversarial_prompt(idea);
// F-01 阶段5: 智能路由 — 对抗评估 TaskRequirements(Standard,无工具)。
// 池非空 → select_model_id 选最优 model_id;池空/无匹配 → 留空由 provider impl
// 回填自身 default_model(与接入前行为一致,平稳过渡)。
let eval_req = df_ai::router::TaskRequirements {
modalities: vec![df_ai_core::model::Modality::Text],
needs_tool_use: false,
estimated_context: 0,
};
let model = df_ai::router::select_model_id(&eval_req, &self.model_pool).unwrap_or_default();
let request = df_ai_core::provider::CompletionRequest {
// 路由命中 → 用 model_id;否则留空让 provider impl 回填自身 default_model。
// (OpenAICompatProvider::convert_request 在 req.model.is_empty() 时回退 default_model)
model,
messages: vec![
df_ai_core::provider::ChatMessage::system(SYSTEM_PROMPT),
df_ai_core::provider::ChatMessage::user(prompt),
],
temperature: Some(0.4),
max_tokens: Some(2048),
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let resp = provider
.complete(request)
.await
.map_err(|e| anyhow::anyhow!("LLM complete 调用失败: {e}"))?;
// 数值 clamp 由 parse_llm_eval 单点收口(final_score∈[0,10]、confidence∈[0,1],
// 均在 parse 内对所有 Ok 路径完成),此处不再重复 clamp(CR-40-1 去冗余)。
parse_llm_eval(&resp.text, &idea.id)
}
/// 启发式评估(基于评分与内容信号,稳定有区分度)
fn evaluate_heuristic(&self, idea: &Idea) -> Result<AdversarialEval> {
// 先做多维评分,作为正反方论点与置信度的依据
let scores = crate::scoring::ScoringEngine::compute_default(idea);
let positive = self.generate_positive_argument(idea, &scores)?;
let negative = self.generate_negative_argument(idea, &scores)?;
let analyst = self.analyst_analysis(idea, &scores)?;
let recommendation = self.recommendation_for(&analyst.final_assessment);
Ok(AdversarialEval {
idea_id: idea.id.clone(),
positive,
negative,
analyst,
final_score,
final_score: scores.overall,
recommendation,
// 由 evaluate() 调用方按调度路径覆盖(Heuristic / HeuristicFallback)
evaluated_by: EvaluatedBy::Heuristic,
})
}
/// 生成正方观点(支持执行)
async fn generate_positive_argument(idea: &Idea) -> Result<Argument> {
// TODO: 接入 AI 生成正方观点
// 当前使用启发式模板
/// 生成正方观点(支持执行)— confidence 由可行性 + 影响力驱动
/// 注:返回 Result 为后续 LLM 注入失败预留,启发式阶段恒 Ok
fn generate_positive_argument(&self, idea: &Idea, scores: &IdeaScores) -> Result<Argument> {
let desc = idea.description.trim();
let mut evidence = Vec::new();
evidence.push(format!("优先级:{}", priority_label(&idea.priority)));
if desc.is_empty() {
evidence.push("需求待补充(建议补全描述)".to_string());
} else {
let head: String = desc.chars().take(60).collect();
evidence.push(format!("明确需求:{}", head));
}
if idea.tags.is_empty() {
evidence.push("关联领域待界定".to_string());
} else {
evidence.push(format!("关联领域:{}", idea.tags.join("、")));
}
if scores.impact >= 7.0 {
evidence.push("业务价值显著,影响面较广".to_string());
}
let title = &idea.title;
let desc = &idea.description;
// 正方置信度:可行性+影响力等权折算到 [0.1, 0.95],满分≈0.95 留质疑余地
let confidence =
((scores.feasibility * 0.5 + scores.impact * 0.5) / 10.0).clamp(0.1, 0.95);
let reasoning = vec![
format!("可行性评分 {:.1}/10,路径相对清晰", scores.feasibility),
format!("影响力评分 {:.1}/10,预期回报可观", scores.impact),
"整体风险可控,适合推进".to_string(),
];
Ok(Argument {
thesis: format!("{} 具有很高的价值和可行性,应该优先执行", title),
evidence: vec![
format!("满足业务需求:{}", desc),
"投入产出比高".to_string(),
"技术实现可行".to_string(),
"时间窗口合适".to_string(),
],
reasoning: vec![
"能够解决现有痛点".to_string(),
"竞争优势明显".to_string(),
"风险可控".to_string(),
],
confidence: 0.75,
thesis: format!("「{}」具备明确价值与可行性,建议优先推进", idea.title),
evidence,
reasoning,
confidence,
})
}
/// 生成反方观点(反对或谨慎)
async fn generate_negative_argument(idea: &Idea, positive: &Argument) -> Result<CounterArgument> {
// TODO: 接入 AI 生成反方观点,考虑正方观点
/// 生成反方观点(反对或谨慎)— 论点基于想法实际缺陷,confidence 随风险上升
fn generate_negative_argument(&self, idea: &Idea, scores: &IdeaScores) -> Result<Argument> {
let desc = idea.description.trim();
let mut evidence = Vec::new();
if desc.is_empty() {
evidence.push("描述过于简略,需求边界不清".to_string());
} else if desc.chars().count() < 50 {
evidence.push("描述偏短,实现细节尚未论证".to_string());
}
if idea.tags.is_empty() {
evidence.push("缺少标签,影响范围未界定".to_string());
}
if scores.feasibility < 6.0 {
evidence.push(format!("可行性 {:.1}/10 偏低,实现路径存疑", scores.feasibility));
}
if matches!(idea.priority, Priority::Low) {
evidence.push("优先级偏低,可能非当前关键路径".to_string());
}
if evidence.is_empty() {
evidence.push("机会成本需权衡,可能存在更优替代方案".to_string());
}
let title = &idea.title;
// 反方强度:feasibility 每降 1 分 +0.04,impact 每降 1 分 +0.03,基线 0.25(满分也保留最低质疑),clamp [0.1, 0.9]
let confidence = ((10.0 - scores.feasibility) * 0.04 + (10.0 - scores.impact) * 0.03 + 0.25)
.clamp(0.1, 0.9);
Ok(CounterArgument {
thesis: format!("{} 需要谨慎评估,存在一定风险", title),
evidence: vec![
"资源投入较大".to_string(),
"市场不确定性高".to_string(),
"技术挑战存在".to_string(),
"机会成本高".to_string(),
],
reasoning: vec![
"ROI 需要进一步验证".to_string(),
"优先级可能过高".to_string(),
"存在更优替代方案".to_string(),
],
confidence: 0.65,
let reasoning = vec![
format!("资源投入与当前综合评分 {:.1} 需匹配", scores.overall),
"ROI 需进一步验证".to_string(),
"需评估是否存在更优解".to_string(),
];
Ok(Argument {
thesis: format!("「{}」需谨慎评估,存在风险与机会成本", idea.title),
evidence,
reasoning,
confidence,
})
}
/// AI 分析师综合分析
async fn analyst_analysis(
idea: &Idea,
positive: &Argument,
negative: &CounterArgument,
) -> Result<AnalystAnalysis> {
// TODO: 接入 AI 进行深度分析
/// AI 分析师综合分析 — 评估等级由综合评分决定,优势/劣势按维度动态生成
fn analyst_analysis(&self, idea: &Idea, scores: &IdeaScores) -> Result<AnalystAnalysis> {
let final_assessment = match scores.overall {
x if x >= 7.5 => AssessmentLevel::StrongGo,
x if x >= 6.0 => AssessmentLevel::Recommended,
x if x >= 4.5 => AssessmentLevel::Conditional,
x if x >= 3.0 => AssessmentLevel::Revised,
_ => AssessmentLevel::Defer,
};
let positive_strengths = vec![
"方向正确,符合业务战略".to_string(),
"技术创新性较强".to_string(),
"用户价值明确".to_string(),
];
let mut strengths = Vec::new();
if scores.impact >= 6.0 {
strengths.push("业务价值明确".to_string());
}
if scores.feasibility >= 6.0 {
strengths.push("技术路径清晰".to_string());
}
if scores.urgency >= 7.0 {
strengths.push("时间窗口合适".to_string());
}
if strengths.is_empty() {
strengths.push("方向值得探索".to_string());
}
let weaknesses = vec![
"资源需求评估不足".to_string(),
"风险控制需要加强".to_string(),
"时间规划可能过于乐观".to_string(),
];
let mut weaknesses = Vec::new();
if scores.feasibility < 6.0 {
weaknesses.push("可行性论证不足".to_string());
}
if idea.description.trim().is_empty() {
weaknesses.push("需求描述缺失".to_string());
}
if scores.urgency < 4.0 {
weaknesses.push("紧急度偏低,易被搁置".to_string());
}
if weaknesses.is_empty() {
weaknesses.push("资源需求待评估".to_string());
}
// 启发式占位:固定风险模板,与具体想法无关,接 LLM 后改动态生成
let risks = vec![
"技术实现难度超出预期".to_string(),
"市场竞争加剧".to_string(),
"用户接受度不确定".to_string(),
"技术实现难度可能超出预期".to_string(),
"优先级与资源争夺".to_string(),
"需求范围蔓延".to_string(),
];
let opportunities = vec![
"可能形成新的竞争优势".to_string(),
"技术积累价值显著".to_string(),
"市场机会窗口良好".to_string(),
"可能形成可复用能力".to_string(),
"积累技术资产".to_string(),
];
// 基于正反方观点的强度计算
let positive_strength = positive.confidence;
let negative_strength = negative.confidence;
let net_positive = (positive_strength - negative_strength + 1.0) / 2.0;
let final_assessment = if net_positive > 0.7 {
AssessmentLevel::StrongGo
} else if net_positive > 0.5 {
AssessmentLevel::Recommended
} else if net_positive > 0.3 {
AssessmentLevel::Conditional
} else if net_positive > 0.1 {
AssessmentLevel::Revised
} else {
AssessmentLevel::Defer
};
let summary = format!(
"「{}」综合评分 {:.1}/10,{}。建议{}。",
idea.title,
scores.overall,
assessment_desc(&final_assessment),
action_hint(&final_assessment)
);
Ok(AnalystAnalysis {
summary: format!(
"该想法整体价值评估中等偏上,建议在有条件的情况下执行。主要价值在于{},需要关注{}。",
idea.title,
if net_positive > 0.5 { "风险控制" } else { "价值验证" }
),
strengths: positive_strengths,
summary,
strengths,
weaknesses,
risks,
opportunities,
@@ -208,101 +305,292 @@ impl AdversarialEngine {
})
}
/// 计算最终评估分数和建议
fn compute_final_assessment(analyst: &AnalystAnalysis) -> (f64, Recommendation) {
// 基于评估等级映射分数
let base_score = match analyst.final_assessment {
AssessmentLevel::StrongGo => 8.5,
AssessmentLevel::Recommended => 7.0,
AssessmentLevel::Conditional => 5.5,
AssessmentLevel::Revised => 4.0,
AssessmentLevel::Defer => 2.5,
AssessmentLevel::Reject => 1.0,
};
// 根据优劣势微调分数
let strength_count = analyst.strengths.len() as f64;
let weakness_count = analyst.weaknesses.len() as f64;
let score_adjustment = (strength_count - weakness_count) * 0.3;
let final_score = (base_score + score_adjustment).clamp(0.0, 10.0);
let recommendation = match analyst.final_assessment {
/// 评估等级 → 最终建议
fn recommendation_for(&self, level: &AssessmentLevel) -> Recommendation {
match level {
AssessmentLevel::StrongGo => Recommendation::ImmediateAction,
AssessmentLevel::Recommended => Recommendation::Soon,
AssessmentLevel::Conditional => Recommendation::WithResources,
AssessmentLevel::Revised => Recommendation::ResearchMore,
AssessmentLevel::Defer => Recommendation::Monitor,
AssessmentLevel::Reject => Recommendation::Cancel,
};
(final_score, recommendation)
}
}
/// 评估结果展示格式
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvalDisplay {
pub idea_title: String,
pub positive_strength: f64,
pub negative_strength: f64,
pub net_sentiment: f64, // -1 到 1,正为正面
pub assessment_level: String,
pub key_takeaways: Vec<String>,
pub action_items: Vec<String>,
}
impl From<AdversarialEval> for EvalDisplay {
fn from(eval: AdversarialEval) -> Self {
let net_sentiment = (eval.positive.confidence - eval.negative.confidence) as f64;
let key_takeaways = vec![
format!("优势:{}", eval.analyst.strengths.join("、")),
format!("风险:{}", eval.analyst.risks.join("、")),
format!("建议:{:?}", eval.recommendation),
];
let action_items = match eval.recommendation {
Recommendation::ImmediateAction => vec![
"立即组建项目团队".to_string(),
"制定详细执行计划".to_string(),
"分配必要资源".to_string(),
],
Recommendation::Soon => vec![
"下周启动项目".to_string(),
"准备资源需求".to_string(),
"制定时间表".to_string(),
],
Recommendation::WithResources => vec![
"确认资源预算".to_string(),
"评估ROI".to_string(),
"制定风险预案".to_string(),
],
Recommendation::ResearchMore => vec![
"进行市场调研".to_string(),
"收集用户反馈".to_string(),
"验证技术可行性".to_string(),
],
Recommendation::Monitor => vec![
"持续跟踪相关指标".to_string(),
"定期评估进展".to_string(),
"等待更好的时机".to_string(),
],
Recommendation::Cancel => vec![
"记录归档原因".to_string(),
"释放相关资源".to_string(),
"提取经验教训".to_string(),
],
};
EvalDisplay {
idea_title: eval.positive.thesis.split(' ').take(3).collect::<Vec<_>>().join(" "),
positive_strength: eval.positive.confidence,
negative_strength: eval.negative.confidence,
net_sentiment,
assessment_level: format!("{:?}", eval.analyst.final_assessment),
key_takeaways,
action_items,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capture::Idea;
use crate::scoring::ScoringEngine;
use df_types::types::{IdeaStatus, Priority};
fn make_idea(title: &str, desc: &str, priority: Priority, tags: Vec<&str>) -> Idea {
Idea {
id: "test-id".to_string(),
title: title.to_string(),
description: desc.to_string(),
status: IdeaStatus::Draft,
priority,
scores: None,
tags: tags.into_iter().map(String::from).collect(),
source: None,
related_ids: Vec::new(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
}
}
#[tokio::test]
async fn a1_high_score_immediate_action() {
let desc = "面向用户的核心功能,带来显著增长,大幅提升效率。集成成熟方案,复用已有组件。".repeat(3);
let idea = make_idea("AI增长引擎", &desc, Priority::Critical, vec!["增长", "核心"]);
let scores = ScoringEngine::compute_default(&idea);
let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap();
println!("\n[a1] 高分想法 → 期望 ImmediateAction");
println!(" scores: feas={:.2} impact={:.2} urg={:.2} overall={:.2}", scores.feasibility, scores.impact, scores.urgency, scores.overall);
println!(" eval: final_score={:.2} recommendation={:?}", eval.final_score, eval.recommendation);
println!(" 正方 confidence={:.2} 反方 confidence={:.2}", eval.positive.confidence, eval.negative.confidence);
assert!(eval.final_score >= 7.5, "final_score 应≥7.5, 实际 {:.2}", eval.final_score);
assert_eq!(eval.recommendation, Recommendation::ImmediateAction);
}
#[tokio::test]
async fn a2_mid_score_soon() {
let desc = "面向用户的功能,集成已有方案,提升体验".to_string();
let idea = make_idea("体验优化", &desc, Priority::Medium, vec!["体验"]);
let scores = ScoringEngine::compute_default(&idea);
let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap();
println!("\n[a2] 中分想法 → 期望 Soon");
println!(" scores overall={:.2} eval final_score={:.2} recommendation={:?}", scores.overall, eval.final_score, eval.recommendation);
assert_eq!(eval.recommendation, Recommendation::Soon);
}
#[tokio::test]
async fn a3_low_score_monitor() {
let desc = "重构迁移大规模分布式重写从零全新架构高并发底层".to_string();
let idea = make_idea("过度工程", &desc, Priority::Low, vec![]);
let scores = ScoringEngine::compute_default(&idea);
let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap();
println!("\n[a3] 低分想法 → 期望 Monitor");
println!(" scores overall={:.2} eval final_score={:.2} recommendation={:?}", scores.overall, eval.final_score, eval.recommendation);
assert!(eval.final_score < 3.0, "final_score 应<3.0, 实际 {:.2}", eval.final_score);
assert_eq!(eval.recommendation, Recommendation::Monitor);
}
#[tokio::test]
async fn a4_confidence_ranges() {
let idea = make_idea("普通想法", "一般描述", Priority::Medium, vec!["标签"]);
let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap();
println!("\n[a4] confidence 区间校验");
println!(" 正方={:.2} (应∈[0.1, 0.95]) 反方={:.2} (应∈[0.1, 0.9])", eval.positive.confidence, eval.negative.confidence);
assert!(eval.positive.confidence >= 0.1 && eval.positive.confidence <= 0.95);
assert!(eval.negative.confidence >= 0.1 && eval.negative.confidence <= 0.9);
}
#[tokio::test]
async fn a5_positive_thesis_contains_title() {
let idea = make_idea("独家创意", "描述内容", Priority::High, vec![]);
let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap();
println!("\n[a5] 正方论点含标题");
println!(" thesis: {}", eval.positive.thesis);
assert!(eval.positive.thesis.contains("独家创意"), "正方 thesis 应含标题");
}
#[tokio::test]
async fn a6_negative_evidence_nonempty() {
let idea = make_idea("待质疑想法", "短", Priority::Low, vec![]);
let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap();
println!("\n[a6] 反方证据非空 ({} 条)", eval.negative.evidence.len());
for (i, e) in eval.negative.evidence.iter().enumerate() {
println!(" 证据{}: {}", i + 1, e);
}
assert!(!eval.negative.evidence.is_empty(), "反方 evidence 不应为空");
}
#[tokio::test]
async fn a7_final_score_consistency() {
let desc = "面向用户的核心功能".to_string();
let idea = make_idea("一致性测试", &desc, Priority::High, vec!["核心"]);
let scores = ScoringEngine::compute_default(&idea);
let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap();
println!("\n[a7] final_score == scores.overall 一致性");
println!(" scores.overall={:.2} eval.final_score={:.2}", scores.overall, eval.final_score);
println!(" analyst.summary: {}", eval.analyst.summary);
assert!((eval.final_score - scores.overall).abs() < 0.001, "final_score 应等于 overall");
assert!(eval.analyst.summary.contains("一致性测试"), "summary 应含标题");
}
// ────────────────────────────────────────────────────────────
// LLM 路径测试(F-260614-03)
// ────────────────────────────────────────────────────────────
/// mock LlmProvider:按构造时给定的响应文本回放,仅供 adversarial 单测。
///
/// `complete` 返回预设 `text`(或预设错误),`stream` 不被 adversarial 路径调用,
/// 返回 Err 即可。不引入 futures 依赖。
struct MockProvider {
text: String,
}
#[async_trait::async_trait]
impl LlmProvider for MockProvider {
async fn complete(
&self,
_request: df_ai_core::provider::CompletionRequest,
) -> anyhow::Result<df_ai_core::provider::CompletionResponse> {
Ok(df_ai_core::provider::CompletionResponse {
text: self.text.clone(),
model: "mock-model".to_string(),
usage: df_ai_core::provider::TokenUsage::default(),
tool_calls: None,
// 对抗评估路径不消费推理内容,留 None 即可(CompletionResponse 新增字段,
// 老构造点漏填 → 测试编译失败,回归补齐)。
reasoning_content: None,
})
}
async fn stream(
&self,
_request: df_ai_core::provider::CompletionRequest,
) -> anyhow::Result<df_ai_core::provider::StreamResult> {
anyhow::bail!("MockProvider 不支持 stream(adversarial 路径不调用)")
}
fn name(&self) -> &str {
"mock-model"
}
}
/// LLM mock 返回结构良好的 JSON → 解析正确,evaluated_by=Llm
#[tokio::test]
async fn a8_llm_mock_parse_success() {
let idea = make_idea(
"AI 增长引擎",
"面向用户的核心功能,带来显著增长。集成成熟方案。",
Priority::Critical,
vec!["增长"],
);
// 一份对齐 schema 的合法 LLM 返回(含 ```json 围栏,验证容错)
let llm_text = r#"```json
{
"positive": {
"thesis": "该想法价值高,路径清晰,应立即推进",
"evidence": ["用户增长是当前关键路径", "已有成熟方案可复用"],
"reasoning": ["可行性高", "ROI 明显"],
"confidence": 0.9
},
"negative": {
"thesis": "资源投入与替代方案需权衡",
"evidence": ["机会成本存在"],
"reasoning": ["需验证更优解"],
"confidence": 0.35
},
"analyst": {
"summary": "综合双方,价值明确且路径清晰,建议立即推进",
"strengths": ["业务价值明确", "技术路径清晰"],
"weaknesses": ["资源需求待评估"],
"risks": ["需求范围蔓延"],
"opportunities": ["可形成可复用能力"],
"final_assessment": "StrongGo"
},
"final_score": 8.5,
"recommendation": "ImmediateAction"
}
```"#;
let provider = Arc::new(MockProvider { text: llm_text.to_string() });
let engine = AdversarialEngine::new(provider);
let eval = engine.evaluate(&idea).await.unwrap();
println!("\n[a8] LLM mock 成功路径");
println!(" evaluated_by = {:?}", eval.evaluated_by);
println!(" final_score = {:.2} recommendation = {:?}", eval.final_score, eval.recommendation);
println!(" positive.confidence = {:.2} negative.confidence = {:.2}",
eval.positive.confidence, eval.negative.confidence);
println!(" analyst.final_assessment = {:?}", eval.analyst.final_assessment);
assert_eq!(eval.evaluated_by, EvaluatedBy::Llm, "成功路径应为 Llm");
assert!((eval.final_score - 8.5).abs() < 1e-9);
assert_eq!(eval.recommendation, Recommendation::ImmediateAction);
assert_eq!(eval.analyst.final_assessment, AssessmentLevel::StrongGo);
assert!((eval.positive.confidence - 0.9).abs() < 1e-9);
assert!((eval.negative.confidence - 0.35).abs() < 1e-9);
assert_eq!(eval.positive.evidence.len(), 2);
assert!(eval.positive.thesis.contains("立即推进"));
}
/// LLM mock 返回非法 JSON → bail → 降级 HeuristicFallback(evaluated_by 标记)
#[tokio::test]
async fn a9_llm_mock_bad_json_fallback() {
let idea = make_idea("坏想法", "测试降级", Priority::Medium, vec![]);
let provider = Arc::new(MockProvider { text: "这不是 JSON".to_string() });
let engine = AdversarialEngine::new(provider);
let eval = engine.evaluate(&idea).await.unwrap();
println!("\n[a9] LLM mock 非 JSON → 降级");
println!(" evaluated_by = {:?} (期望 HeuristicFallback)", eval.evaluated_by);
assert_eq!(eval.evaluated_by, EvaluatedBy::HeuristicFallback, "非 JSON 应降级");
}
/// LLM mock 返回枚举非法值 → bail → 降级
#[tokio::test]
async fn a10_llm_mock_bad_enum_fallback() {
let idea = make_idea("枚举非法", "测试枚举映射", Priority::High, vec![]);
let llm_text = r#"{
"positive": {"thesis":"x","evidence":[],"reasoning":[],"confidence":0.5},
"negative": {"thesis":"y","evidence":[],"reasoning":[],"confidence":0.5},
"analyst": {"summary":"s","strengths":[],"weaknesses":[],"risks":[],"opportunities":[],"final_assessment":"GoNow"},
"final_score": 5.0,
"recommendation": "Soon"
}"#;
let provider = Arc::new(MockProvider { text: llm_text.to_string() });
let engine = AdversarialEngine::new(provider);
let eval = engine.evaluate(&idea).await.unwrap();
println!("\n[a10] LLM mock 枚举非法 → 降级");
println!(" evaluated_by = {:?} (期望 HeuristicFallback)", eval.evaluated_by);
assert_eq!(eval.evaluated_by, EvaluatedBy::HeuristicFallback);
}
/// 数值越界 → clamp(confidence>1 / final_score>10)应被截断,不触发降级
#[tokio::test]
async fn a11_llm_mock_value_clamp() {
let idea = make_idea("越界测试", "数值超出区间", Priority::Medium, vec![]);
let llm_text = r#"{
"positive": {"thesis":"p","evidence":[],"reasoning":[],"confidence":1.5},
"negative": {"thesis":"n","evidence":[],"reasoning":[],"confidence":-0.3},
"analyst": {"summary":"s","strengths":[],"weaknesses":[],"risks":[],"opportunities":[],"final_assessment":"Recommended"},
"final_score": 99.0,
"recommendation": "Soon"
}"#;
let provider = Arc::new(MockProvider { text: llm_text.to_string() });
let engine = AdversarialEngine::new(provider);
let eval = engine.evaluate(&idea).await.unwrap();
println!("\n[a11] LLM mock 数值越界 clamp");
println!(" positive.confidence={:.2} (原 1.5 → 期望 1.0)", eval.positive.confidence);
println!(" negative.confidence={:.2} (原 -0.3 → 期望 0.0)", eval.negative.confidence);
println!(" final_score={:.2} (原 99 → 期望 10.0)", eval.final_score);
assert_eq!(eval.evaluated_by, EvaluatedBy::Llm, "仅越界不降级");
assert!((eval.positive.confidence - 1.0).abs() < 1e-9, "confidence>1 应 clamp 到 1.0");
assert!((eval.negative.confidence - 0.0).abs() < 1e-9, "confidence<0 应 clamp 到 0.0");
assert!((eval.final_score - 10.0).abs() < 1e-9, "final_score>10 应 clamp 到 10.0");
}
/// LLM 返回「前缀文字 + ```json + {...} + ```」(围栏不在开头)→ extract_json
/// 正则兜底提取首个 JSON 对象 → 解析成功不降级(CR-40-2 兜底路径)。
#[tokio::test]
async fn a12_llm_mock_fenced_not_at_start() {
let idea = make_idea("围栏不在开头", "测试正则兜底", Priority::High, vec![]);
let llm_text = "好的,以下是我的评估:\n```json\n{\n \"positive\": {\"thesis\":\"p\",\"evidence\":[],\"reasoning\":[],\"confidence\":0.7},\n \"negative\": {\"thesis\":\"n\",\"evidence\":[],\"reasoning\":[],\"confidence\":0.4},\n \"analyst\": {\"summary\":\"s\",\"strengths\":[],\"weaknesses\":[],\"risks\":[],\"opportunities\":[],\"final_assessment\":\"Recommended\"},\n \"final_score\": 7.0,\n \"recommendation\": \"Soon\"\n}\n```\n希望对你有帮助。";
let provider = Arc::new(MockProvider { text: llm_text.to_string() });
let engine = AdversarialEngine::new(provider);
let eval = engine.evaluate(&idea).await.unwrap();
println!("\n[a12] 围栏不在开头 → 正则兜底提取");
println!(" evaluated_by = {:?} (期望 Llm,不应降级)", eval.evaluated_by);
assert_eq!(eval.evaluated_by, EvaluatedBy::Llm, "围栏不在开头应正则兜底解析成功");
assert!((eval.final_score - 7.0).abs() < 1e-9);
assert_eq!(eval.recommendation, Recommendation::Soon);
}
}
+366
View File
@@ -0,0 +1,366 @@
//! 对抗评估 — 纯函数 / 类型 / 常量(自 adversarial.rs 抽离,纯搬迁,零行为变更)。
//!
//! 本文件承载 [`AdversarialEval`] 及其字段类型、LLM prompt 构造与 JSON 解析等无副作用
//! 逻辑;有状态的引擎 [`AdversarialEngine`](含 evaluate / evaluate_with_llm 主入口与
//! ARC-260618-01-e 待决策逻辑)仍留在 `adversarial.rs`,二者经 [`use`] 互相引用。
//!
//! 抽离边界:类型定义(非 impl)+ 常量 + 顶层 free function。impl 主体不动,外部
//! 已用路径(`df_ideas::adversarial::{AdversarialEngine, AdversarialEval, Recommendation}` 等)
//! 由 adversarial.rs 通过 `pub use` 重新导出,调用方零变更。
use anyhow::Result;
use serde::{Deserialize, Serialize};
use df_types::types::{IdeaId, Priority};
use crate::capture::Idea;
/// 评估来源标记
///
/// `Default = Heuristic`:老数据(F-07 之前)序列化时无 evaluated_by 字段,
/// 反序列化回落启发式(与 F-07 之前行为一致)。
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub enum EvaluatedBy {
/// LLM 深度评估
Llm,
/// 启发式评估(无 LLM 配置时的默认模式,也是老数据反序列化默认值)
#[default]
Heuristic,
/// 启发式降级(LLM 调用失败后 fallback)
HeuristicFallback,
}
/// 对抗评估结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdversarialEval {
pub idea_id: IdeaId,
pub positive: Argument,
pub negative: Argument,
pub analyst: AnalystAnalysis,
pub final_score: f64,
pub recommendation: Recommendation,
/// 评估来源(Llm / Heuristic / HeuristicFallback),前端据此显示评估深度标签
#[serde(default)]
pub evaluated_by: EvaluatedBy,
}
/// 论点(正方/反方共用同一结构)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Argument {
pub thesis: String, // 核心观点
pub evidence: Vec<String>, // 证据支持
pub reasoning: Vec<String>, // 推理过程
pub confidence: f64, // 置信度 0-1
}
/// AI 分析师综合分析
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalystAnalysis {
pub summary: String, // 综合总结
pub strengths: Vec<String>, // 主要优势
pub weaknesses: Vec<String>, // 主要劣势
pub risks: Vec<String>, // 潜在风险
pub opportunities: Vec<String>, // 机会点
pub final_assessment: AssessmentLevel, // 最终评估
}
/// 评估等级
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum AssessmentLevel {
StrongGo, // 强烈推荐执行
Recommended, // 推荐执行
Conditional, // 有条件执行
Revised, // 需要修改后执行
Defer, // 推迟执行
}
/// 最终建议
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum Recommendation {
ImmediateAction, // 立即行动
Soon, // 尽快行动
WithResources, // 配置资源后行动
ResearchMore, // 需要更多研究
Monitor, // 持续监控
}
// ============================================================
// LLM 对抗评估 — prompt 构造 / JSON 解析(F-260614-03)
// ============================================================
/// LLM 角色 / 输出契约的系统级约束。
///
/// 放为 const 便于在 prompt 头部注入,与 [`build_adversarial_prompt`] 的「想法上下文」
/// 分离:角色设定稳定不变,想法上下文按评估对象动态拼。
pub(crate) const SYSTEM_PROMPT: &str = "\
你是一位资深的技术产品决策顾问。你将主持一场三角色对抗式辩论,对一个软件想法做结构化评估:
- 正方(Advocate):论证为什么应该立即推进该想法,挖掘价值与可行性证据。
- 反方(Skeptic):质疑该想法,挖掘风险、机会成本与替代方案,不放过任何隐患。
- 分析师(Analyst):综合正反方观点,给出客观、平衡的最终裁决。
规则:
1. 你必须只输出一个 JSON 对象,不要输出任何解释性文字、Markdown 代码块标记或前后缀。
2. 所有枚举字段只能取下方 schema 列出的字符串字面量(区分大小写)。
3. 数值字段必须落在指定区间内。
4. 论点(thesis / evidence / reasoning / summary 等)用中文表达,简洁有信息密度。";
/// 构造对抗评估 prompt(三角色辩论 + 严格 JSON schema)。
///
/// 输出 prompt 包含:想法上下文(title/description/priority/tags)+ 角色 + JSON 输出
/// 契约(含字段说明与枚举字面量),让 LLM 单轮产出可解析的结构化评估。
pub(crate) fn build_adversarial_prompt(idea: &Idea) -> String {
// 描述截断防 prompt 过长:> 800 字按 800 截并附省略号提示。
let desc_raw = idea.description.trim();
let description = if desc_raw.chars().count() > 800 {
let head: String = desc_raw.chars().take(800).collect();
format!("{head}……(描述已截断,原长超过 800 字)")
} else if desc_raw.is_empty() {
"(描述为空,需求边界待补充)".to_string()
} else {
desc_raw.to_string()
};
let tags = if idea.tags.is_empty() {
"(无标签)".to_string()
} else {
idea.tags.join("、")
};
format!(
"\
请对以下软件想法进行三角色对抗式评估。
【想法上下文】
标题:{title}
描述:{description}
优先级:{priority}
标签:{tags}
【输出 JSON schema】(只输出该 JSON 对象,字段名与枚举字面量严格一致)
{{
\"positive\": {{
\"thesis\": \"正方核心观点(一句话)\",
\"evidence\": [\"证据 1\", \"证据 2\"],
\"reasoning\": [\"推理 1\", \"推理 2\"],
\"confidence\": 0.0到1.0之间的浮点数,正方置信度
}},
\"negative\": {{
\"thesis\": \"反方核心观点(一句话)\",
\"evidence\": [\"证据 1\"],
\"reasoning\": [\"推理 1\"],
\"confidence\": 0.0到1.0之间的浮点数,反方置信度
}},
\"analyst\": {{
\"summary\": \"分析师综合总结(一段话)\",
\"strengths\": [\"主要优势 1\"],
\"weaknesses\": [\"主要劣势 1\"],
\"risks\": [\"潜在风险 1\"],
\"opportunities\": [\"机会点 1\"],
\"final_assessment\": \"StrongGo | Recommended | Conditional | Revised | Defer\"
}},
\"final_score\": 0到10之间的浮点数,综合评分,
\"recommendation\": \"ImmediateAction | Soon | WithResources | ResearchMore | Monitor\"
}}
枚举字段说明:
- final_assessment: StrongGo=强烈推荐执行 / Recommended=推荐执行 / Conditional=有条件执行 / Revised=需修改后执行 / Defer=推迟执行
- recommendation: ImmediateAction=立即行动 / Soon=尽快行动 / WithResources=配置资源后行动 / ResearchMore=需更多研究 / Monitor=持续监控
要求:
- positive 与 negative 的论点必须真实基于上方想法上下文,不要泛泛而谈。
- analyst.summary 必须总结双方并给出明确倾向,不要模棱两可。
- final_score 与 recommendation 应与 analyst.final_assessment 自洽(如 Defer 对应低分与 Monitor)。
- 只输出 JSON,不要任何额外文字。",
title = idea.title,
description = description,
priority = priority_label(&idea.priority),
tags = tags,
)
}
/// LLM 返回的非类型化 JSON 表示(与 [`AdversarialEval`] 结构对齐,但枚举为字符串、
/// evaluated_by 字段省略——由 [`AdversarialEngine::evaluate`] 调用方覆盖)。
///
/// 采用独立中间结构而非直接 serde 到 [`AdversarialEval`]:便于在枚举非法时给出
/// 字段级错误信息(`final_assessment: "GoNow" 不在合法集合`),而非整条记录丢弃;
/// 同时留出数值 clamp 的统一收口。
#[derive(Debug, Deserialize)]
pub(crate) struct LlmEvalRaw {
pub(crate) positive: ArgumentRaw,
pub(crate) negative: ArgumentRaw,
pub(crate) analyst: AnalystRaw,
pub(crate) final_score: f64,
pub(crate) recommendation: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ArgumentRaw {
pub(crate) thesis: String,
#[serde(default)]
pub(crate) evidence: Vec<String>,
#[serde(default)]
pub(crate) reasoning: Vec<String>,
#[serde(default)]
pub(crate) confidence: f64,
}
#[derive(Debug, Deserialize)]
pub(crate) struct AnalystRaw {
#[serde(default)]
pub(crate) summary: String,
#[serde(default)]
pub(crate) strengths: Vec<String>,
#[serde(default)]
pub(crate) weaknesses: Vec<String>,
#[serde(default)]
pub(crate) risks: Vec<String>,
#[serde(default)]
pub(crate) opportunities: Vec<String>,
pub(crate) final_assessment: String,
}
/// 解析 LLM 返回文本为 [`AdversarialEval`]。
///
/// 容错策略(任一失败 `bail` → 由 [`AdversarialEngine::evaluate`] 降级启发式):
/// 1. 剥离 ```json … ``` 代码块围栏与首尾空白(部分 LLM 会无视「只输出 JSON」要求)。
/// 2. `serde_json` 反序列化为 [`LlmEvalRaw`];字段类型错 / 缺失必填 → bail。
/// 3. 枚举字符串映射([`parse_assessment_level`] / [`parse_recommendation`]);非法值 → bail。
/// 4. 数值 clamp:confidence ∈ [0,1],final_score ∈ [0,10]。
pub(crate) fn parse_llm_eval(text: &str, idea_id: &str) -> Result<AdversarialEval> {
let json_str = extract_json(text);
let raw: LlmEvalRaw = serde_json::from_str(&json_str).map_err(|e| {
anyhow::anyhow!("LLM 返回非合法 JSON 或字段缺失: {e}")
})?;
let final_assessment = parse_assessment_level(&raw.analyst.final_assessment)?;
let recommendation = parse_recommendation(&raw.recommendation)?;
let positive = Argument {
thesis: raw.positive.thesis,
evidence: raw.positive.evidence,
reasoning: raw.positive.reasoning,
confidence: raw.positive.confidence.clamp(0.0, 1.0),
};
let negative = Argument {
thesis: raw.negative.thesis,
evidence: raw.negative.evidence,
reasoning: raw.negative.reasoning,
confidence: raw.negative.confidence.clamp(0.0, 1.0),
};
let analyst = AnalystAnalysis {
summary: raw.analyst.summary,
strengths: raw.analyst.strengths,
weaknesses: raw.analyst.weaknesses,
risks: raw.analyst.risks,
opportunities: raw.analyst.opportunities,
final_assessment,
};
Ok(AdversarialEval {
idea_id: idea_id.to_string(),
positive,
negative,
analyst,
// final_score clamp 单点收口于此(evaluate_with_llm 不再重复 clamp,CR-40-1)
final_score: raw.final_score.clamp(0.0, 10.0),
recommendation,
// 由 evaluate() 按 LLM 调度路径覆盖为 EvaluatedBy::Llm
evaluated_by: EvaluatedBy::Llm,
})
}
/// 从 LLM 返回文本中提取 JSON 主体。
///
/// 提取优先级:
/// 1. 代码围栏在开头(```json … ``` 或 ``` … ```)→ 快路径剥离围栏返回主体。
/// 2. 围栏不在开头(LLM 输出「前缀文字 + ```json + {...} + ```」)→ 正则
/// `(?s)\{.*\}` 兜底提取首个 `{` 到最后一个 `}` 的片段(增强命中率)。
///
/// 注意:兜底返回的是「原始文本中首个 JSON 对象字面量」,**不再** trim 后整段交给
/// serde——由 [`parse_llm_eval`] 的 serde 步骤校验合法性,失败即 bail 降级。
pub(crate) fn extract_json(text: &str) -> String {
let trimmed = text.trim();
// 快路径:围栏在开头(```json ... ``` 或 ``` ... ```)
if let Some(rest) = trimmed.strip_prefix("```") {
// 跳过语言标记(json/JSON 等)到首个换行
let after_lang = match rest.find('\n') {
Some(idx) => &rest[idx + 1..],
None => rest,
};
let body = after_lang.trim_end();
if let Some(body_inner) = body.strip_suffix("```") {
return body_inner.trim().to_string();
}
// 围栏闭合缺失(如 JSON 与 ``` 同行 / 闭合后仍有文字 / 多行 JSON 末尾混噪声):
// 快路径无法干净剥离 → 落到正则兜底提取首个 JSON 对象,提升命中率。
// (原实现在此处 return body.trim() 会把语言标记/尾随文字一起喂 serde,
// 必然失败降级启发式,即便正则本可救回。)
}
// 兜底:围栏不在开头或混杂前后文字 → 正则提取首个 JSON 对象(CR-40-2)。
// (?s) 让 . 匹配换行,贪婪 {*} 取首 { 到末 },覆盖嵌套对象。
// 提取失败(无 { })则返回 trimmed 走原文 serde 报错降级,语义不变。
static JSON_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
let re = JSON_RE.get_or_init(|| regex::Regex::new(r"(?s)\{.*\}").expect("合法静态正则"));
match re.captures(trimmed) {
Some(c) => c.get(0).map(|m| m.as_str().to_string()).unwrap_or_else(|| trimmed.to_string()),
None => trimmed.to_string(),
}
}
/// 枚举字面量 → [`AssessmentLevel`]。区分大小写匹配 schema 文档约定。
pub(crate) fn parse_assessment_level(s: &str) -> Result<AssessmentLevel> {
match s.trim() {
"StrongGo" => Ok(AssessmentLevel::StrongGo),
"Recommended" => Ok(AssessmentLevel::Recommended),
"Conditional" => Ok(AssessmentLevel::Conditional),
"Revised" => Ok(AssessmentLevel::Revised),
"Defer" => Ok(AssessmentLevel::Defer),
other => anyhow::bail!(
"final_assessment 枚举值非法: {:?}(合法: StrongGo|Recommended|Conditional|Revised|Defer)",
other
),
}
}
/// 枚举字面量 → [`Recommendation`]。区分大小写匹配 schema 文档约定。
pub(crate) fn parse_recommendation(s: &str) -> Result<Recommendation> {
match s.trim() {
"ImmediateAction" => Ok(Recommendation::ImmediateAction),
"Soon" => Ok(Recommendation::Soon),
"WithResources" => Ok(Recommendation::WithResources),
"ResearchMore" => Ok(Recommendation::ResearchMore),
"Monitor" => Ok(Recommendation::Monitor),
other => anyhow::bail!(
"recommendation 枚举值非法: {:?}(合法: ImmediateAction|Soon|WithResources|ResearchMore|Monitor)",
other
),
}
}
pub(crate) fn priority_label(p: &Priority) -> &'static str {
match p {
Priority::Critical => "紧急",
Priority::High => "高",
Priority::Medium => "中",
Priority::Low => "低",
}
}
pub(crate) fn assessment_desc(level: &AssessmentLevel) -> &'static str {
match level {
AssessmentLevel::StrongGo => "价值高且可行性强",
AssessmentLevel::Recommended => "整体值得推进",
AssessmentLevel::Conditional => "有条件地推进",
AssessmentLevel::Revised => "需调整后再评估",
AssessmentLevel::Defer => "建议暂缓",
}
}
pub(crate) fn action_hint(level: &AssessmentLevel) -> &'static str {
match level {
AssessmentLevel::StrongGo => "立即立项启动",
AssessmentLevel::Recommended => "尽快排期",
AssessmentLevel::Conditional => "配置资源后启动",
AssessmentLevel::Revised => "补充信息后重新评估",
AssessmentLevel::Defer => "持续观察时机",
}
}
+3 -56
View File
@@ -1,25 +1,8 @@
//! 想法捕获 — 快速记录与管理
//! 想法实体与评分类型 — 供 scoring/adversarial/promotion 共享
use serde::{Deserialize, Serialize};
use df_core::types::{IdeaId, Priority};
/// 捕获一个新想法的输入
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CaptureInput {
/// 标题
pub title: String,
/// 详细描述
pub description: String,
/// 优先级
#[serde(default)]
pub priority: Priority,
/// 标签
#[serde(default)]
pub tags: Vec<String>,
/// 来源(如 "用户输入"、"AI 生成"、"会议记录")
pub source: Option<String>,
}
use df_types::types::{IdeaId, Priority};
/// 想法实体
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -31,7 +14,7 @@ pub struct Idea {
/// 详细描述
pub description: String,
/// 当前状态
pub status: df_core::types::IdeaStatus,
pub status: df_types::types::IdeaStatus,
/// 优先级
pub priority: Priority,
/// 评分
@@ -60,39 +43,3 @@ pub struct IdeaScores {
/// 综合评分 (加权平均)
pub overall: f64,
}
/// 想法捕获器
pub struct IdeaCapture;
impl IdeaCapture {
/// 捕获一个新想法
///
/// TODO: 接入存储层持久化
pub fn capture(input: CaptureInput) -> Idea {
let now = chrono::Utc::now();
Idea {
id: df_core::types::new_id(),
title: input.title,
description: input.description,
status: df_core::types::IdeaStatus::Draft,
priority: input.priority,
scores: None,
tags: input.tags,
source: input.source,
related_ids: Vec::new(),
created_at: now,
updated_at: now,
}
}
/// 快速捕获(仅标题)
pub fn quick_capture(title: String) -> Idea {
Self::capture(CaptureInput {
title,
description: String::new(),
priority: Priority::default(),
tags: Vec::new(),
source: None,
})
}
}
-79
View File
@@ -1,79 +0,0 @@
//! 想法评估器 — 对想法进行多维度评估
use anyhow::Result;
use df_core::types::IdeaId;
use crate::adversarial::{AdversarialEngine, AdversarialEval};
use crate::capture::Idea;
use crate::scoring::IdeaScores;
/// 评估维度
#[derive(Debug, Clone, Copy)]
pub enum EvalDimension {
/// 可行性
Feasibility,
/// 影响力
Impact,
/// 紧急度
Urgency,
}
/// 评估结果
#[derive(Debug, Clone)]
pub struct EvalResult {
pub idea_id: IdeaId,
pub scores: IdeaScores,
pub recommendation: Recommendation,
pub comments: Vec<String>,
}
/// 评估建议
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Recommendation {
/// 强烈推荐立即执行
StrongApprove,
/// 推荐执行
Approve,
/// 需要更多信息
NeedsInfo,
/// 建议推迟
Defer,
/// 不推荐
Reject,
}
/// 想法评估器
pub struct IdeaEvaluator;
impl IdeaEvaluator {
/// 评估一个想法 - 使用对抗式评估
pub async fn evaluate_adversarial(idea: &Idea) -> Result<AdversarialEval> {
AdversarialEngine::evaluate(idea).await
}
/// 评估一个想法 - 保持向后兼容
pub fn evaluate(idea: &Idea) -> Result<EvalResult> {
// 使用简单评分作为后备
let scores = crate::scoring::ScoringEngine::compute_default(idea);
let recommendation = if scores.overall >= 8.0 {
Recommendation::StrongApprove
} else if scores.overall >= 6.0 {
Recommendation::Approve
} else if scores.overall >= 4.0 {
Recommendation::NeedsInfo
} else if scores.overall >= 2.0 {
Recommendation::Defer
} else {
Recommendation::Reject
};
Ok(EvalResult {
idea_id: idea.id.clone(),
scores,
recommendation,
comments: Vec::new(),
})
}
}
-76
View File
@@ -1,76 +0,0 @@
//! 想法关联图 — 管理想法之间的关系
use std::collections::HashMap;
use df_core::types::IdeaId;
/// 想法之间的关系类型
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RelationKind {
/// 相似(语义相近)
Similar,
/// 依赖(A 依赖 B)
DependsOn,
/// 衍生(A 衍生自 B)
DerivedFrom,
/// 互补(A 和 B 可以互补)
Complementary,
}
/// 想法关系边
#[derive(Debug, Clone)]
pub struct Relation {
pub source_id: IdeaId,
pub target_id: IdeaId,
pub kind: RelationKind,
pub strength: f64, // 0.0 ~ 1.0
}
/// 想法关联图
pub struct IdeaGraph {
/// 邻接表(idea_id -> 相关关系列表)
edges: HashMap<IdeaId, Vec<Relation>>,
}
impl IdeaGraph {
/// 创建空图
pub fn new() -> Self {
Self {
edges: HashMap::new(),
}
}
/// 添加关系
pub fn add_relation(&mut self, source_id: IdeaId, target_id: IdeaId, kind: RelationKind, strength: f64) {
let relation = Relation {
source_id: source_id.clone(),
target_id: target_id.clone(),
kind,
strength,
};
self.edges.entry(source_id).or_default().push(relation.clone());
self.edges.entry(target_id).or_default().push(relation);
}
/// 获取与指定想法相关的所有关系
pub fn get_relations(&self, idea_id: &IdeaId) -> Vec<&Relation> {
self.edges.get(idea_id).map(|r| r.iter().collect()).unwrap_or_default()
}
/// 查找相似想法
pub fn find_similar(&self, idea_id: &IdeaId) -> Vec<&Relation> {
self.get_relations(idea_id)
.into_iter()
.filter(|r| r.kind == RelationKind::Similar)
.collect()
}
// TODO: 基于向量相似度的自动关联发现
// TODO: 图遍历、聚类算法
}
impl Default for IdeaGraph {
fn default() -> Self {
Self::new()
}
}
+2 -3
View File
@@ -1,8 +1,7 @@
//! df-ideas: 想法池 — 捕获、评估、评分、关联图、晋升
//! df-ideas: 想法池 — 捕获、评估、评分、晋升
pub mod adversarial;
mod adversarial_helpers;
pub mod capture;
pub mod evaluator;
pub mod graph;
pub mod promotion;
pub mod scoring;
+7 -82
View File
@@ -1,91 +1,16 @@
//! 想法晋升 — 将想法转为项目
//! 想法晋升 — PromotionResult(promote_idea IPC 返回类型)。
//! 实际晋升路径:commands/idea.rs::promote_idea → df-project::ProjectManager::create_from_idea。
//! 历史空壳 IdeaPromoter/PromotionPolicy 已删(R-PD-14)。
use anyhow::Result;
use serde::Serialize;
use df_core::types::{IdeaId, ProjectId};
use df_types::types::{IdeaId, ProjectId};
use crate::capture::Idea;
use crate::evaluator::Recommendation;
/// 晋升结果
#[derive(Debug, Clone)]
/// 晋升结果(promote_idea IPC 返回类型)
#[derive(Debug, Clone, Serialize)]
pub struct PromotionResult {
pub idea_id: IdeaId,
pub project_id: ProjectId,
pub promoted: bool,
pub reason: String,
}
/// 晋升策略
#[derive(Debug, Clone, Copy)]
pub enum PromotionPolicy {
/// 自动晋升(评分达标时自动转为项目)
Auto,
/// 手动确认(需要人工审批)
Manual,
/// 半自动(AI 评估 + 人工确认)
SemiAuto,
}
/// 想法晋升器
pub struct IdeaPromoter {
policy: PromotionPolicy,
}
impl IdeaPromoter {
/// 创建晋升器
pub fn new(policy: PromotionPolicy) -> Self {
Self { policy }
}
/// 评估并尝试晋升想法
///
/// TODO: 接入 df-project 创建项目
pub fn try_promote(&self, idea: &Idea, recommendation: &Recommendation) -> Result<PromotionResult> {
match self.policy {
PromotionPolicy::Auto => {
if matches!(recommendation, Recommendation::StrongApprove | Recommendation::Approve) {
self.do_promote(idea)
} else {
Ok(PromotionResult {
idea_id: idea.id.clone(),
project_id: String::new(),
promoted: false,
reason: format!("评估建议 {:?},未达到自动晋升标准", recommendation),
})
}
}
PromotionPolicy::Manual => {
Ok(PromotionResult {
idea_id: idea.id.clone(),
project_id: String::new(),
promoted: false,
reason: "手动模式,等待人工审批".to_string(),
})
}
PromotionPolicy::SemiAuto => {
// TODO: AI 评估 + 人工确认流程
Ok(PromotionResult {
idea_id: idea.id.clone(),
project_id: String::new(),
promoted: false,
reason: "半自动模式,等待确认".to_string(),
})
}
}
}
/// 执行晋升操作
fn do_promote(&self, idea: &Idea) -> Result<PromotionResult> {
let project_id = df_core::types::new_id();
// TODO: 调用 df-project 创建项目
tracing::info!("想法 {} 晋升为项目 {}", idea.id, project_id);
Ok(PromotionResult {
idea_id: idea.id.clone(),
project_id,
promoted: true,
reason: "评估达标,自动晋升".to_string(),
})
}
}
+272 -19
View File
@@ -1,10 +1,16 @@
//! 评分引擎 — 多维度加权评分
//! 评分引擎 — 基于想法内容的多维度启发式评分
//!
//! 各维度分数均为 0-10(IPC 层会 *10 缩放为 0-100 以匹配前端)。
//! 启发式依据:优先级、描述充实度、标签、关键词信号——保证稳定且有区分度。
//! TODO: 接入 AI 做语义级深度评分。
use crate::capture::Idea;
/// 想法评分详情(重新导出 capture 模块中的定义)
pub use crate::capture::IdeaScores;
use df_types::types::Priority;
/// 评分权重配置
#[derive(Debug, Clone)]
pub struct ScoringWeights {
@@ -28,17 +34,12 @@ pub struct ScoringEngine;
impl ScoringEngine {
/// 使用默认权重计算评分
///
/// TODO: 接入 AI 进行深度评分,当前返回基于启发式的分数
pub fn compute_default(idea: &Idea) -> IdeaScores {
let weights = ScoringWeights::default();
Self::compute(idea, &weights)
Self::compute(idea, &ScoringWeights::default())
}
/// 使用指定权重计算评分
pub fn compute(idea: &Idea, weights: &ScoringWeights) -> IdeaScores {
// TODO: 基于想法内容、历史数据、AI 分析等多维度评分
// 当前使用基于启发式的占位评分
let feasibility = Self::heuristic_feasibility(idea);
let impact = Self::heuristic_impact(idea);
let urgency = Self::heuristic_urgency(idea);
@@ -55,21 +56,273 @@ impl ScoringEngine {
}
}
/// 启发式可行性评分
fn heuristic_feasibility(_idea: &Idea) -> f64 {
// TODO: 基于描述复杂度、资源需求等评估
5.0
/// 启发式可行性评分(描述充实度 + 技术/资源信号词)
fn heuristic_feasibility(idea: &Idea) -> f64 {
let mut score = 5.0_f64;
let desc = idea.description.trim();
if !desc.is_empty() {
score += 1.5;
}
let len = desc.chars().count();
if (50..=500).contains(&len) {
score += 1.0;
} else if len > 500 {
// 过长描述通常意味着实现复杂度上升
score -= 0.5;
}
// 可行性正向信号
let pos = count_any(desc, &[
"复用", "已有", "简单", "集成", "支持", "成熟", "基于", "现成", "脚手架", "模板",
]);
score += (pos as f64) * 0.5;
// 复杂度负向信号
let neg = count_any(desc, &[
"重构", "迁移", "大规模", "分布式", "重写", "从零", "全新架构", "高并发", "底层",
]);
score -= (neg as f64) * 0.6;
score.clamp(0.0, 10.0)
}
/// 启发式影响力评分
fn heuristic_impact(_idea: &Idea) -> f64 {
// TODO: 基于业务价值、用户影响等评估
5.0
/// 启发式影响力评分(优先级 + 价值信号词 + 标签广度)
fn heuristic_impact(idea: &Idea) -> f64 {
let mut score = match idea.priority {
Priority::Critical => 8.0,
Priority::High => 6.5,
Priority::Medium => 5.0,
Priority::Low => 3.5,
};
if !idea.tags.is_empty() {
score += 0.5;
// 标签越多影响面越广,上限 +1.0
score += (idea.tags.len().min(4) as f64) * 0.25;
}
let desc = idea.description.trim();
let value_hits = count_any(desc, &[
"用户", "增长", "收入", "效率", "体验", "核心", "关键", "痛点", "竞品", "留存",
]);
score += (value_hits as f64) * 0.5;
if desc.chars().count() > 100 {
score += 0.5;
}
score.clamp(0.0, 10.0)
}
/// 启发式紧急度评分
fn heuristic_urgency(_idea: &Idea) -> f64 {
// TODO: 基于优先级、时间窗口等评估
5.0
/// 启发式紧急度评分(优先级 + 时效信号词)
fn heuristic_urgency(idea: &Idea) -> f64 {
let mut score = match idea.priority {
Priority::Critical => 9.0,
Priority::High => 7.0,
Priority::Medium => 5.0,
Priority::Low => 3.0,
};
let desc = idea.description.trim();
let time_hits = count_any(desc, &[
"立即", "马上", "紧急", "尽快", "本周", "上线", "deadline", "截止", "先行", "阻塞",
]);
score += (time_hits as f64) * 0.5;
score.clamp(0.0, 10.0)
}
}
/// 统计 text 中命中任一关键词的数量(小写匹配,兼顾中英文)
/// 局限:纯子串匹配,不识别"不复用""无用户增长"等否定前缀,接 LLM 后由语义层修正
/// 单字否定前缀:关键词紧邻这些字时该命中不计(「不复用」不计入「复用」正向命中)。
///
/// 注意「别」单字歧义大(别具/别的/特别 均非否定),故不进单字清单——
/// 祈使否定「别」改用两字清单兜底(别去/别再/别要/别用 等,见 NEGATION_PREFIXES_2)。
const NEGATION_PREFIXES_1: &[char] = &['不', '无', '非', '未', '没', '勿'];
/// 两字否定前缀:紧邻关键词的前两字符为这些时该命中不计(「没有复用」「并非简单」「别去复用」)。
const NEGATION_PREFIXES_2: &[&str] = &[
"没有", "并非", "毫无", "不用", "不是",
// 「别X」祈使否定:双字形式可精确匹配,规避「别具/别的/特别」等单字误伤
"别去", "别再", "别要", "别用", "别做", "别给",
];
/// 关键词在 text 的 `end` 字节位置前是否处于否定上下文(紧邻否定词)。
///
/// `end` 是关键词起始字节位置(由 `str::find` 返回,必在 char 边界)。取 `text[..end]`
/// 末尾 1-2 个 char 判否定:单字否定(不/无/非/...)或两字否定(没有/并非/...)。
///
/// 仅取末尾 ≤2 个 char(rev().take(2) 流式),省去每次命中的 `Vec<char>` 分配。
fn is_negated_before(text: &str, end: usize) -> bool {
if end == 0 {
return false;
}
let before = &text[..end];
// 末尾最多 2 char,按 [次末, 末] 顺序收集(n=1 时仅末位)
let tail: Vec<char> = before.chars().rev().take(2).collect();
if tail.is_empty() {
return false;
}
// 单字否定:末位 char(tail[0]) 命中
if NEGATION_PREFIXES_1.contains(&tail[0]) {
return true;
}
// 两字否定:需凑齐 2 char,顺序为 [末, 次末] 反转回原序 [次末, 末]
if tail.len() == 2 {
let last2: String = [tail[1], tail[0]].iter().collect();
if NEGATION_PREFIXES_2.contains(&last2.as_str()) {
return true;
}
}
false
}
/// 统计 text 中命中任一关键词的数量(小写匹配,兼顾中英文)。
///
/// 否定前缀处理:关键词紧邻否定词(不复用/无增长/非紧急...)时该命中不计,
/// 避免「不复用」反向计入「复用」正向分。双重否定(「不是不复用」)属罕见边缘,
/// 不处理(接 LLM 后语义层兜底)。
fn count_any(text: &str, keywords: &[&str]) -> usize {
let lower = text.to_lowercase();
keywords
.iter()
.filter(|kw| {
// 至少一个非否定上下文的出现即计为命中(同一词多次出现,一次有效即可)
let mut idx = 0usize;
while let Some(rel) = lower[idx..].find(*kw) {
let abs = idx + rel;
if !is_negated_before(&lower, abs) {
return true;
}
idx = abs + kw.len();
}
false
})
.count()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capture::Idea;
use df_types::types::{IdeaStatus, Priority};
/// 辅助工厂:构造测试用 Idea(时间/ID 用默认值,不影响评分)
fn make_idea(title: &str, desc: &str, priority: Priority, tags: Vec<&str>) -> Idea {
Idea {
id: "test-id".to_string(),
title: title.to_string(),
description: desc.to_string(),
status: IdeaStatus::Draft,
priority,
scores: None,
tags: tags.into_iter().map(String::from).collect(),
source: None,
related_ids: Vec::new(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
}
}
#[test]
fn s1_empty_idea_baseline() {
let idea = make_idea("测试想法", "", Priority::Medium, vec![]);
let s = ScoringEngine::compute_default(&idea);
println!("\n[s1] 空想法 (Medium / 无描述 / 无标签)");
println!(" 可行性={:.2} 影响力={:.2} 紧急度={:.2} 综合={:.2}", s.feasibility, s.impact, s.urgency, s.overall);
assert!((s.overall - 5.0).abs() < 0.01, "空想法 overall 应为 5.0, 实际 {:.2}", s.overall);
}
#[test]
fn s2_high_priority_value_desc() {
let desc = "面向用户的核心功能,带来显著增长,大幅提升效率。集成成熟方案,复用已有组件,快速交付价值。".repeat(3);
let idea = make_idea("增长引擎", &desc, Priority::Critical, vec!["增长", "核心"]);
let s = ScoringEngine::compute_default(&idea);
println!("\n[s2] 高优先级 + 价值描述 (Critical / ~120字 / 含价值词)");
println!(" 可行性={:.2} 影响力={:.2} 紧急度={:.2} 综合={:.2}", s.feasibility, s.impact, s.urgency, s.overall);
assert!(s.impact >= 7.0, "impact 应≥7.0, 实际 {:.2}", s.impact);
assert!(s.urgency >= 8.0, "urgency 应≥8.0, 实际 {:.2}", s.urgency);
}
#[test]
fn s3_low_priority_short_desc() {
let idea = make_idea("小优化", "一句话", Priority::Low, vec![]);
let s = ScoringEngine::compute_default(&idea);
println!("\n[s3] 低优先级 + 短描述 (Low / 3字)");
println!(" 可行性={:.2} 影响力={:.2} 紧急度={:.2} 综合={:.2}", s.feasibility, s.impact, s.urgency, s.overall);
assert!((s.overall - 4.6).abs() < 0.01, "overall 应为 4.6, 实际 {:.2}", s.overall);
}
#[test]
fn s4_feasibility_positive_signals() {
let idea = make_idea("复用方案", "复用已有组件,简单集成现成脚手架", Priority::Medium, vec![]);
let s = ScoringEngine::compute_default(&idea);
println!("\n[s4] 可行性正向信号 (含 复用/已有/简单/集成/现成/脚手架)");
println!(" 可行性={:.2} (预期 9.5)", s.feasibility);
assert!((s.feasibility - 9.5).abs() < 0.01, "正向信号 feasibility 应为 9.5, 实际 {:.2}", s.feasibility);
}
#[test]
fn s5_feasibility_negative_signals() {
let idea = make_idea("大重构", "大规模重构迁移,分布式重写从零开始", Priority::Medium, vec![]);
let s = ScoringEngine::compute_default(&idea);
println!("\n[s5] 可行性负向信号 (含 大规模/重构/迁移/分布式/重写/从零)");
println!(" 可行性={:.2} (预期 ≤5.0)", s.feasibility);
assert!(s.feasibility <= 5.0, "负向信号 feasibility 应≤5.0, 实际 {:.2}", s.feasibility);
}
#[test]
fn s6_custom_weights() {
let idea = make_idea("高可行低影响", "复用已有简单集成现成", Priority::Low, vec![]);
let custom = ScoringWeights { feasibility: 0.7, impact: 0.2, urgency: 0.1 };
let s_custom = ScoringEngine::compute(&idea, &custom);
let s_default = ScoringEngine::compute_default(&idea);
let manual = s_custom.feasibility * 0.7 + s_custom.impact * 0.2 + s_custom.urgency * 0.1;
println!("\n[s6] 自定义权重 (feas:0.7 / impact:0.2 / urg:0.1)");
println!(" 自定义综合={:.2} 默认综合={:.2} 手算加权={:.2}", s_custom.overall, s_default.overall, manual);
assert!((s_custom.overall - manual).abs() < 0.01, "overall 应等于手算加权");
assert!(s_custom.overall > s_default.overall, "高 feas 配高权重应让综合更高");
}
#[test]
fn s7_clamp_upper_bound() {
let desc = "复用已有简单集成现成成熟基于脚手架模板支持".repeat(20);
let idea = make_idea("满分想法", &desc, Priority::Critical, vec!["a", "b", "c", "d"]);
let s = ScoringEngine::compute_default(&idea);
println!("\n[s7] clamp 上限 (堆正向词 + 超长描述 + Critical)");
println!(" 可行性={:.2} 影响力={:.2} 紧急度={:.2} 综合={:.2}", s.feasibility, s.impact, s.urgency, s.overall);
assert!(s.feasibility <= 10.0 && s.impact <= 10.0 && s.urgency <= 10.0, "所有维度应≤10");
}
#[test]
fn s8_clamp_lower_bound() {
let desc = "重构迁移大规模分布式重写从零全新架构高并发底层".repeat(20);
let idea = make_idea("灾难想法", &desc, Priority::Low, vec![]);
let s = ScoringEngine::compute_default(&idea);
println!("\n[s8] clamp 下限 (堆负向词 + Low)");
println!(" 可行性={:.2} 影响力={:.2} 紧急度={:.2} 综合={:.2}", s.feasibility, s.impact, s.urgency, s.overall);
assert!(s.feasibility >= 0.0 && s.impact >= 0.0 && s.urgency >= 0.0, "所有维度应≥0");
}
#[test]
fn s9_single_char_negation_suppresses_hit() {
// 「不复用已有组件」:复用 被「不」否定不计,已有 命中 → feasibility 低于「复用已有组件」(复用+已有 双命中)
let neg = make_idea("不复用", "不复用已有组件", Priority::Medium, vec![]);
let pos = make_idea("复用", "复用已有组件", Priority::Medium, vec![]);
let s_neg = ScoringEngine::compute_default(&neg);
let s_pos = ScoringEngine::compute_default(&pos);
println!("\n[s9] 单字否定前缀抑制命中");
println!(" 不复用已有组件 feasibility={:.2} 复用已有组件 feasibility={:.2}", s_neg.feasibility, s_pos.feasibility);
assert!(
s_neg.feasibility < s_pos.feasibility,
"单字否定应抑制正向命中致 feasibility 更低: neg={} pos={}",
s_neg.feasibility, s_pos.feasibility
);
}
#[test]
fn s10_two_char_negation_suppresses_hit() {
// 「没有复用」:两字否定 没有 抑制 复用 命中;「确实复用」无否定 复用 命中
let neg = make_idea("t", "没有复用", Priority::Medium, vec![]);
let pos = make_idea("t", "确实复用", Priority::Medium, vec![]);
let s_neg = ScoringEngine::compute_default(&neg);
let s_pos = ScoringEngine::compute_default(&pos);
println!("\n[s10] 两字否定前缀抑制命中");
println!(" 没有复用 feasibility={:.2} 确实复用 feasibility={:.2}", s_neg.feasibility, s_pos.feasibility);
assert!(
s_neg.feasibility < s_pos.feasibility,
"两字否定「没有」应抑制命中: neg={} pos={}", s_neg.feasibility, s_pos.feasibility
);
}
}
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "df-mcp"
version = "0.1.0"
edition = "2021"
description = "DevFlow MCP Server — 对外暴露数据层工具(JSON-RPC 2.0 over stdio)"
[dependencies]
df-storage = { path = "../df-storage" }
df-types = { path = "../df-types" }
# df-nodes: advance_task 复用推进链唯一 status 写入路径(advance_task_atomic,
# 含 is_valid_state+can_transition+同态拒绝三层校验),避免 MCP 直调底层
# advance_status_atomic 绕过状态机(防止外部客户端非法跳态 todo→done)。
df-nodes = { path = "../df-nodes" }
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
anyhow.workspace = true
tracing.workspace = true
uuid.workspace = true
futures = "0.3"
[dev-dependencies]
tokio = { workspace = true, features = ["full", "test-util"] }
+19
View File
@@ -0,0 +1,19 @@
//! df-mcp: DevFlow MCP Server — 对外暴露数据层工具
//!
//! 形态:独立 stdio JSON-RPC 2.0 server。手写最小化协议层(不引外部 MCP SDK),
//! 复用 df-storage Repo 做实际数据读写,不重复 CRUD 实现。
//!
//! 安全降级(对抗评估反方核心):
//! - High 风险(delete/purge/run_workflow)默认拒绝,返友好提示
//! - Medium 风险(create/update/bind)默认允许 + 日志(tracing::warn)
//! - `--read-only` 仅暴露 list/get 工具
//! - 不暴露文件系统工具(防绕过应用内路径校验)
//! - 仅 stdio / 本地,不暴露网络;无认证(依赖本地隔离)
//!
//! 入口: [`run_server`] — 由 Tauri CLI 子命令 `devflow mcp-server` 调用。
pub mod protocol;
pub mod server;
pub mod tools;
pub use server::run_server;
+201
View File
@@ -0,0 +1,201 @@
//! JSON-RPC 2.0 over stdio 协议层
//!
//! 职责:
//! - 按行读 stdin,解析 JSON-RPC Request/Notification
//! - 构造 Response / Error 对象并序列化为单行 JSON 写 stdout
//! - MCP 三类方法路由(initialize / tools/list / tools/call)由上层 dispatch
//!
//! 不解析 batch 请求(MCP 客户端实测逐条发,单条足够)。
//! 错误码遵循 JSON-RPC 2.0 + MCP 自定义段(-32xxx 协议层 / 0xffff 工具层)。
use serde::{Deserialize, Serialize};
use serde_json::Value;
// ============================================================
// JSON-RPC 2.0 错误码
// ============================================================
/// 协议层错误(JSON-RPC 2.0 预留段 -32000..-32099)
pub const PARSE_ERROR: i32 = -32700;
pub const INVALID_REQUEST: i32 = -32600;
pub const METHOD_NOT_FOUND: i32 = -32601;
pub const INVALID_PARAMS: i32 = -32602;
pub const INTERNAL_ERROR: i32 = -32603;
// ============================================================
// 请求 / 响应数据结构
// ============================================================
/// JSON-RPC 2.0 请求(请求与通知共用,Notification 时 id=None)
#[derive(Debug, Clone, Deserialize)]
pub struct Request {
pub jsonrpc: Option<String>,
pub id: Option<Value>,
pub method: String,
#[serde(default)]
pub params: Value,
}
/// JSON-RPC 2.0 错误对象
#[derive(Debug, Clone, Serialize)]
pub struct ErrorObject {
pub code: i32,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
/// JSON-RPC 2.0 响应
#[derive(Debug, Clone, Serialize)]
pub struct Response {
pub jsonrpc: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<ErrorObject>,
}
impl Response {
/// 成功响应
pub fn ok(id: Option<Value>, result: Value) -> Self {
Self {
jsonrpc: "2.0",
id,
result: Some(result),
error: None,
}
}
/// 错误响应
pub fn err(id: Option<Value>, code: i32, message: impl Into<String>, data: Option<Value>) -> Self {
Self {
jsonrpc: "2.0",
id,
result: None,
error: Some(ErrorObject {
code,
message: message.into(),
data,
}),
}
}
}
// ============================================================
// MCP 方法路由 — 暴露给上层 dispatch 的 enum
// ============================================================
/// MCP 协议层方法(已剥离 JSON-RPC 包装)。未知方法返回 `Unknown`,由 dispatch 回 METHOD_NOT_FOUND。
#[derive(Debug, Clone)]
pub enum McpMethod {
/// initialize 握手:返 server info + capabilities
Initialize { params: Value },
/// initialized 通知:客户端确认握手完成,无需响应
Initialized,
/// tools/list:列出所有工具(MCP Tool schema)
ToolsList,
/// tools/call:调用某工具
ToolsCall { name: String, arguments: Value },
/// ping 心跳(MCP 2025 规范),回空 result
Ping,
/// 未识别的方法(回 METHOD_NOT_FOUND)
Unknown(String),
}
impl McpMethod {
/// 从 Request 路由出 MCP 语义方法。
pub fn from_request(req: &Request) -> Self {
match req.method.as_str() {
"initialize" => McpMethod::Initialize { params: req.params.clone() },
"notifications/initialized" => McpMethod::Initialized,
"tools/list" => McpMethod::ToolsList,
"tools/call" => {
let name = req
.params
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned();
let arguments = req.params.get("arguments").cloned().unwrap_or(Value::Null);
McpMethod::ToolsCall { name, arguments }
}
"ping" => McpMethod::Ping,
other => McpMethod::Unknown(other.to_owned()),
}
}
}
// ============================================================
// MCP 协议数据结构(initialize 握手 / Tool schema / CallToolResult)
// ============================================================
/// MCP initialize 握手响应结构(server info + capabilities)
#[derive(Debug, Clone, Serialize)]
pub struct InitializeResult {
#[serde(rename = "protocolVersion")]
pub protocol_version: &'static str,
pub capabilities: Capabilities,
#[serde(rename = "serverInfo")]
pub server_info: ServerInfo,
}
#[derive(Debug, Clone, Serialize)]
pub struct Capabilities {
pub tools: ToolsCapability,
}
#[derive(Debug, Clone, Serialize)]
pub struct ToolsCapability {
#[serde(rename = "listChanged")]
pub list_changed: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct ServerInfo {
pub name: &'static str,
pub version: &'static str,
}
/// MCP Tool 描述符(tools/list 返回数组元素)
#[derive(Debug, Clone, Serialize)]
pub struct Tool {
pub name: &'static str,
pub description: &'static str,
#[serde(rename = "inputSchema")]
pub input_schema: Value,
}
/// MCP tools/call 成功响应(CallToolResult)
#[derive(Debug, Clone, Serialize)]
pub struct CallToolResult {
pub content: Vec<ContentBlock>,
#[serde(rename = "isError", skip_serializing_if = "Option::is_none")]
pub is_error: Option<bool>,
}
/// MCP 内容块(本 server 仅用 text)
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum ContentBlock {
Text { text: String },
}
impl CallToolResult {
/// 文本成功响应
pub fn text(text: impl Into<String>) -> Self {
Self {
content: vec![ContentBlock::Text { text: text.into() }],
is_error: None,
}
}
/// 文本错误响应(is_error=true 让客户端区分「工具业务错」与「协议错」)
pub fn error(text: impl Into<String>) -> Self {
Self {
content: vec![ContentBlock::Text { text: text.into() }],
is_error: Some(true),
}
}
}
+343
View File
@@ -0,0 +1,343 @@
//! MCP server 主循环 — stdio JSON-RPC 2.0
//!
//! 协议流程:
//! 1. 客户端发 initialize → 回 serverInfo + capabilities.tools
//! 2. 客户端发 notifications/initialized(通知,不回)
//! 3. 客户端发 tools/list → 回工具清单(read-only 模式过滤)
//! 4. 客户端发 tools/call → dispatch 到 handler
//!
//! 高风险(High)工具:tools/list 不暴露(从清单剔除),tools/call 即便绕过也由 handler 兜底拒绝。
//! read-only:tools/list 仅留 Low,tools/call Medium/High 一律拒绝。
use std::path::Path;
use std::sync::Arc;
use df_storage::db::Database;
use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use crate::protocol::{
CallToolResult, Capabilities, InitializeResult, McpMethod, Request, Response, ServerInfo,
ToolsCapability,
};
use crate::tools::{self, Ctx, RiskLevel};
/// 协议版本(MCP 2025-06-18)
const PROTOCOL_VERSION: &str = "2025-06-18";
const SERVER_NAME: &str = "devflow-mcp";
const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
/// 启动 MCP server。
///
/// - `db_path`:SQLite 数据库文件路径(应用同库,开 WAL 模式)
/// - `read_only`:true 则仅暴露 list/get 工具
///
/// 复用 [`Database::open`](df_storage::Database::open)(已含 `PRAGMA journal_mode=WAL`),
/// 故 SQLite WAL 状态:随 df-storage 一起已启用,无需额外处理。
pub async fn run_server(db_path: &Path, read_only: bool) -> anyhow::Result<()> {
let db = Arc::new(Database::open(db_path).await?);
let ctx = Ctx::new(db);
let stdin = tokio::io::stdin();
let stdout = tokio::io::stdout();
main_loop(stdin, stdout, &ctx, read_only).await
}
/// 可单测的主循环(参数化 stdin/stdout)。
///
/// 协议正确性:每行一个 JSON-RPC 消息,Response 单行写回(末尾 \n)。
/// Notification(id=None)不回响应。
pub async fn main_loop<R, W>(
stdin: R,
stdout: W,
ctx: &Ctx,
read_only: bool,
) -> anyhow::Result<()>
where
R: tokio::io::AsyncRead + Unpin,
W: tokio::io::AsyncWrite + Unpin,
{
let mut reader = BufReader::new(stdin);
let mut writer = stdout;
let mut line = String::new();
loop {
line.clear();
let n = reader.read_line(&mut line).await?;
if n == 0 {
// EOF(stdin 关闭),优雅退出
break;
}
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
// 解析 JSON-RPC 请求
let req: Request = match serde_json::from_str(trimmed) {
Ok(r) => r,
Err(e) => {
// 解析失败:无 id 时无法回响应,只能 log;有 id(尽力猜)回 PARSE_ERROR
tracing::warn!(target: "df_mcp", line = %trimmed, err = %e, "解析 JSON-RPC 行失败");
let resp = Response::err(None, crate::protocol::PARSE_ERROR, "Parse error", None);
write_response(&mut writer, &resp).await?;
continue;
}
};
let is_notification = req.id.is_none();
let method = McpMethod::from_request(&req);
// Notification 不回响应(除解析错外)
if is_notification {
match method {
McpMethod::Initialized => {
tracing::debug!(target: "df_mcp", "客户端 initialized 通知已收");
}
_ => {
tracing::debug!(target: "df_mcp", m = ?method, "忽略未识别通知");
}
}
continue;
}
let resp = dispatch(ctx, read_only, req.id.clone(), method).await;
write_response(&mut writer, &resp).await?;
}
Ok(())
}
/// 方法分发 → 构造 Response。
///
/// `id`:JSON-RPC 请求 id(回响应时原样回填;通知由 main_loop 已过滤)。
async fn dispatch(ctx: &Ctx, read_only: bool, id: Option<Value>, method: McpMethod) -> Response {
match method {
McpMethod::Initialize { .. } => {
let result = InitializeResult {
protocol_version: PROTOCOL_VERSION,
capabilities: Capabilities {
tools: ToolsCapability { list_changed: false },
},
server_info: ServerInfo {
name: SERVER_NAME,
version: SERVER_VERSION,
},
};
Response::ok(
id,
serde_json::to_value(result).unwrap_or(Value::Null),
)
}
McpMethod::Initialized => {
// 通知分支(由 main_loop 已过滤);到这里说明带 id 的 initialized,回空 result
Response::ok(id, Value::Object(Default::default()))
}
McpMethod::ToolsList => {
let tools: Vec<_> = tools::all_tools()
.into_iter()
.filter(|t| visible(read_only, t.risk))
.map(|t| serde_json::to_value(&t.tool).unwrap_or(Value::Null))
.collect();
Response::ok(id, json!({ "tools": tools }))
}
McpMethod::Ping => Response::ok(id, Value::Object(Default::default())),
McpMethod::ToolsCall { name, arguments } => {
// 查工具
let Some(spec) = tools::find(&name) else {
return Response::ok(
id,
serde_json::to_value(CallToolResult::error(format!("未知工具: {name}")))
.unwrap_or(Value::Null),
);
};
// read-only 模式:Medium/High 全拒
if read_only && spec.risk != RiskLevel::Low {
let r = CallToolResult::error(format!(
"只读模式拒绝执行 {name}(风险等级 {:?})",
spec.risk
));
return Response::ok(id, serde_json::to_value(r).unwrap_or(Value::Null));
}
// 非 read-only:High 兜底拒绝(handler 内也会拒,双保险)
if spec.risk == RiskLevel::High {
let r = CallToolResult::error(format!(
"High 风险操作 {name} 默认拒绝,请在 DevFlow 应用内执行。"
));
return Response::ok(id, serde_json::to_value(r).unwrap_or(Value::Null));
}
// Low / Medium:执行
let result = (spec.handler)(ctx, arguments).await;
Response::ok(id, serde_json::to_value(result).unwrap_or(Value::Null))
}
McpMethod::Unknown(m) => Response::err(
id,
crate::protocol::METHOD_NOT_FOUND,
format!("Method not found: {m}"),
None,
),
}
}
/// 工具可见性:read-only 仅 Low,否则 Low + Medium(High 永不可见)
fn visible(read_only: bool, risk: RiskLevel) -> bool {
if read_only {
risk == RiskLevel::Low
} else {
risk != RiskLevel::High
}
}
/// 单行写 Response(末尾 \n,MCP 客户端按行 framing)
async fn write_response<W: tokio::io::AsyncWrite + Unpin>(
writer: &mut W,
resp: &Response,
) -> anyhow::Result<()> {
let mut buf = serde_json::to_string(resp)?;
buf.push('\n');
writer.write_all(buf.as_bytes()).await?;
writer.flush().await?;
Ok(())
}
// ============================================================
// 单测:协议 framing / initialize / tools/list / tools/call / High 拒绝 / read-only 过滤
// ============================================================
//
// 测试驱动 dispatch()(协议层逻辑核心),不跑真实 stdio(真实 stdio 需集成测试)。
// main_loop 的 framing(write_response 单行 +\n、notification 不回)由 dispatch 的输入输出间接覆盖。
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::RiskLevel;
/// 构造内存 DB + Ctx
async fn test_ctx() -> Ctx {
let db = Arc::new(Database::open_in_memory().await.unwrap());
Ctx::new(db)
}
/// 直接驱动 dispatch:喂一行 → 收一行(逐行,notification 跳过)
async fn run_io_lines(lines: &[&str], read_only: bool) -> Vec<String> {
let ctx = test_ctx().await;
let mut out = Vec::new();
for line in lines {
let req: Request = serde_json::from_str(line).unwrap();
let is_notif = req.id.is_none();
let method = McpMethod::from_request(&req);
if is_notif {
continue;
}
let resp = dispatch(&ctx, read_only, req.id.clone(), method).await;
out.push(serde_json::to_string(&resp).unwrap());
}
out
}
#[tokio::test]
async fn initialize_returns_server_info_and_capabilities() {
let input = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#;
let out = run_io_lines(&[input], false).await;
assert_eq!(out.len(), 1);
let v: Value = serde_json::from_str(&out[0]).unwrap();
assert_eq!(v["jsonrpc"], "2.0");
assert_eq!(v["id"], 1);
assert_eq!(v["result"]["protocolVersion"], PROTOCOL_VERSION);
assert_eq!(v["result"]["serverInfo"]["name"], SERVER_NAME);
// capabilities.tools 存在
assert!(v["result"]["capabilities"]["tools"].is_object());
}
#[tokio::test]
async fn tools_list_excludes_high_risk_by_default() {
let input = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#;
let out = run_io_lines(&[input], false).await;
let v: Value = serde_json::from_str(&out[0]).unwrap();
let tools = v["result"]["tools"].as_array().unwrap();
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
// 默认(非 read-only):Low + Medium 可见,High 不可见
assert!(names.contains(&"list_projects"));
assert!(names.contains(&"create_project")); // Medium
assert!(!names.contains(&"delete_project")); // High
assert!(!names.contains(&"run_workflow")); // High
// MCP Tool schema:name + inputSchema
let first = &tools[0];
assert!(first["name"].is_string());
assert!(first["inputSchema"]["type"] == "object");
}
#[tokio::test]
async fn tools_list_read_only_keeps_only_low() {
let input = r#"{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}"#;
let out = run_io_lines(&[input], true).await;
let v: Value = serde_json::from_str(&out[0]).unwrap();
let tools = v["result"]["tools"].as_array().unwrap();
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
assert!(names.contains(&"list_projects"));
assert!(!names.contains(&"create_project")); // Medium 被过滤
assert!(!names.contains(&"delete_project")); // High
}
#[tokio::test]
async fn tools_call_unknown_method_returns_method_not_found() {
let input = r#"{"jsonrpc":"2.0","id":4,"method":"bogus/method","params":{}}"#;
let out = run_io_lines(&[input], false).await;
let v: Value = serde_json::from_str(&out[0]).unwrap();
assert_eq!(v["error"]["code"], crate::protocol::METHOD_NOT_FOUND);
}
#[tokio::test]
async fn tools_call_high_risk_is_rejected() {
let input =
r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"delete_project","arguments":{"id":"x"}}}"#;
let out = run_io_lines(&[input], false).await;
let v: Value = serde_json::from_str(&out[0]).unwrap();
let content = v["result"]["content"].as_array().unwrap();
assert_eq!(content[0]["type"], "text");
let text = content[0]["text"].as_str().unwrap();
assert!(text.contains("拒绝") || text.contains("High"));
assert_eq!(v["result"]["isError"], true);
}
#[tokio::test]
async fn tools_call_read_only_rejects_medium() {
let input =
r#"{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"t","description":"d"}}}"#;
let out = run_io_lines(&[input], true).await;
let v: Value = serde_json::from_str(&out[0]).unwrap();
let text = v["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("只读模式拒绝"));
}
#[tokio::test]
async fn tools_call_create_then_list_project_roundtrip() {
let create =
r#"{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"McpProj","description":"via mcp"}}}"#;
let list =
r#"{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"list_projects","arguments":{}}}"#;
let out = run_io_lines(&[create, list], false).await;
let created: Value = serde_json::from_str(&out[0]).unwrap();
let id = created["result"]["content"][0]["text"]
.as_str()
.unwrap();
let created_v: Value = serde_json::from_str(id).unwrap();
assert!(created_v["id"].is_string());
let listed: Value = serde_json::from_str(&out[1]).unwrap();
let list_text = listed["result"]["content"][0]["text"].as_str().unwrap();
let list_v: Value = serde_json::from_str(list_text).unwrap();
assert_eq!(list_v["count"], 1);
assert_eq!(list_v["projects"][0]["name"], "McpProj");
}
#[tokio::test]
async fn visible_predicate() {
assert!(visible(false, RiskLevel::Low));
assert!(visible(false, RiskLevel::Medium));
assert!(!visible(false, RiskLevel::High));
assert!(visible(true, RiskLevel::Low));
assert!(!visible(true, RiskLevel::Medium));
assert!(!visible(true, RiskLevel::High));
}
}
+714
View File
@@ -0,0 +1,714 @@
//! 工具暴露层 — 从 df-storage Repo 复用 CRUD,转 MCP Tool schema
//!
//! 不重复实现数据层逻辑,只做:① 工具元数据(name/description/inputSchema)声明;
//! ② 工具调用 → df-storage Repo 方法 → 序列化为 MCP CallToolResult。
//!
//! 安全降级:
//! - [`RiskLevel::High`]:默认拒绝,返「请在 DevFlow 应用内执行」
//! - [`RiskLevel::Medium`]:默认允许 + tracing::warn 审计日志
//! - [`RiskLevel::Low`]:默认允许,只读无副作用
//! - `--read-only`:dispatch 阶段过滤,仅 Low 工具可见
//!
//! handler 形态:`fn(&Ctx, Value) -> BoxFuture<CallToolResult>`(函数指针 + async 块),
//! 避免闭包捕获带来的 Box<dyn> 开销与生命周期问题。
use std::sync::Arc;
use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo};
use df_storage::db::Database;
use df_storage::models::{IdeaRecord, ProjectRecord, TaskRecord};
use df_types::types::new_id;
use futures::future::BoxFuture;
use serde_json::{json, Value};
use crate::protocol::{CallToolResult, Tool};
// ============================================================
// 风险等级
// ============================================================
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RiskLevel {
/// 只读无副作用:list/get
Low,
/// 有副作用但可逆:create/update/bind/restore/advance
Medium,
/// 不可逆或高破坏:delete/purge/run_workflow → 默认拒绝
High,
}
// ============================================================
// 工具元数据 + 上下文
// ============================================================
/// 工具元数据 + 风险等级 + handler 函数指针。
pub struct ToolSpec {
pub tool: Tool,
pub risk: RiskLevel,
pub handler: HandlerFn,
}
/// handler 函数指针类型:接收上下文引用 + 参数,返回 boxed future。
pub type HandlerFn = fn(&Ctx, Value) -> BoxFuture<'static, CallToolResult>;
/// 工具执行上下文 — 持有所有 Repo 句柄。每个 handler 内部重新构造 Repo(零开销,Repo 仅持 Arc)。
///
/// 不预存 Repo 是因为 Repo::new 借用 &Database,生命周期管理麻烦;Arc<Database> clone 廉价。
pub struct Ctx {
pub db: Arc<Database>,
}
impl Ctx {
pub fn new(db: Arc<Database>) -> Self {
Self { db }
}
}
// ============================================================
// inputSchema 构造助手
// ============================================================
fn object_schema(properties: Value, required: &[&str]) -> Value {
json!({
"type": "object",
"properties": properties,
"required": required,
"additionalProperties": false
})
}
fn str_field(desc: &str) -> Value {
json!({ "type": "string", "description": desc })
}
fn opt_str_field(desc: &str) -> Value {
json!({ "type": "string", "description": desc })
}
fn int_field(desc: &str) -> Value {
json!({ "type": "integer", "description": desc })
}
// ============================================================
// 工具清单
// ============================================================
/// 返回全部已注册工具(只读模式由 dispatch 过滤 High/Medium)。
pub fn all_tools() -> Vec<&'static ToolSpec> {
use RiskLevel::*;
vec![
// ─── 项目 ───
spec("list_projects", "列出所有未删除项目", object_schema(json!({}), &[]), Low, list_projects),
spec("get_project", "按 ID 获取项目", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Low, get_project),
spec("create_project", "创建项目(Medium 风险,默认允许+审计日志)", object_schema(json!({"name": str_field("项目名"), "description": str_field("描述"), "status": opt_str_field("状态(默认 active)")}), &["name", "description"]), Medium, create_project),
spec("update_project", "更新项目(整体替换 description/status/path/stack)", object_schema(json!({"id": str_field("项目 ID"), "name": str_field("项目名"), "description": str_field("描述"), "status": opt_str_field("状态")}), &["id", "name", "description"]), Medium, update_project),
spec("delete_project", "软删项目(进回收站,可恢复)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), High, delete_project),
spec("bind_directory", "为项目绑定本地代码目录(会做路径冲突检测,Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID"), "path": str_field("本地目录绝对路径")}), &["id", "path"]), Medium, bind_directory),
// ─── 任务 ───
spec("list_tasks", "列出所有未删除任务(可按 project_id/status 过滤)", object_schema(json!({"project_id": opt_str_field("按项目过滤(可空)"), "status": opt_str_field("按状态过滤(todo/in_progress/in_review/testing/blocked/done/cancelled,可空)")}), &[]), Low, list_tasks),
spec("create_task", "创建任务(Medium 风险,默认允许+审计日志)", object_schema(json!({"project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)")}), &["project_id", "title", "description"]), Medium, create_task),
spec("update_task", "更新任务(整体替换)", object_schema(json!({"id": str_field("任务 ID"), "project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述")}), &["id", "project_id", "title", "description"]), Medium, update_task),
spec("advance_task", "推进任务状态(传目标 status,内部读当前态+状态机校验,Medium 风险+审计日志)", object_schema(json!({"id": str_field("任务 ID"), "to": str_field("目标 status(todo/in_progress/in_review/testing/blocked/done/cancelled)")}), &["id", "to"]), Medium, advance_task),
spec("delete_task", "软删任务(进回收站)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("任务 ID")}), &["id"]), High, delete_task),
// ─── 灵感 ───
spec("list_ideas", "列出所有想法/灵感", object_schema(json!({}), &[]), Low, list_ideas),
spec("create_idea", "创建想法(Medium 风险,默认允许+审计日志)", object_schema(json!({"title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)")}), &["title", "description"]), Medium, create_idea),
spec("update_idea", "更新想法(整体替换)", object_schema(json!({"id": str_field("想法 ID"), "title": str_field("标题"), "description": str_field("描述")}), &["id", "title", "description"]), Medium, update_idea),
spec("delete_idea", "软删想法——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), High, delete_idea),
spec("evaluate_idea", "对想法做启发式评估(只读,基于 description/title 计算 feasibility/impact/urgency/overall)", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), Low, evaluate_idea),
// ─── 工作流(High) ───
spec("run_workflow", "触发工作流——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"project_id": str_field("项目 ID"), "task_id": opt_str_field("任务 ID(可空)")}), &["project_id"]), High, run_workflow),
// ─── 回收站 ───
spec("list_trash", "列出回收站(deleted_at IS NOT NULL 的项目与任务)", object_schema(json!({}), &[]), Low, list_trash),
spec("restore_project", "从回收站恢复项目(Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Medium, restore_project),
]
}
/// 工具元数据构造助手 — Box::leak 静态化(进程生命周期,启动一次性构造)。
#[allow(clippy::too_many_arguments)]
fn spec(
name: &'static str,
description: &'static str,
input_schema: Value,
risk: RiskLevel,
handler: HandlerFn,
) -> &'static ToolSpec {
Box::leak(Box::new(ToolSpec {
tool: Tool {
name,
description,
input_schema,
},
risk,
handler,
}))
}
// ============================================================
// 工具查找
// ============================================================
/// 按 name 查找工具(线性扫描,工具数 19,O(n) 足够)。
pub fn find(name: &str) -> Option<&'static ToolSpec> {
all_tools().into_iter().find(|t| t.tool.name == name)
}
// ============================================================
// handler 公共助手
// ============================================================
fn now_millis() -> String {
df_types::now_millis().to_string()
}
fn json_ok(v: Value) -> CallToolResult {
CallToolResult::text(serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".into()))
}
fn err_str(e: impl std::fmt::Display) -> CallToolResult {
CallToolResult::error(format!("内部错误: {e}"))
}
fn medium_audit(name: &str, args_summary: &str) {
tracing::warn!(target: "df_mcp_audit", tool = name, args = %args_summary, "MCP Medium 风险工具被外部调用");
}
/// 取必填字符串参数
fn arg_str(args: &Value, key: &str) -> Result<String, CallToolResult> {
args.get(key)
.and_then(|v| v.as_str())
.map(|s| s.to_owned())
.ok_or_else(|| CallToolResult::error(format!("缺少必填参数: {key}")))
}
/// 取可选字符串参数(默认值)
fn arg_str_or(args: &Value, key: &str, default: &str) -> String {
args.get(key)
.and_then(|v| v.as_str())
.unwrap_or(default)
.to_owned()
}
/// 取可选整数参数(默认值)
fn arg_int_or(args: &Value, key: &str, default: i32) -> i32 {
args.get(key)
.and_then(|v| v.as_i64())
.map(|i| i as i32)
.unwrap_or(default)
}
// ============================================================
// handler 实现 — 项目
// ============================================================
fn list_projects(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
let db = ctx.db.clone();
Box::pin(async move {
let repo = ProjectRepo::new(&db);
match repo.list_active().await {
Ok(list) => json_ok(json!({ "projects": list, "count": list.len() })),
Err(e) => err_str(e),
}
})
}
fn get_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
let db = ctx.db.clone();
let id = match arg_str(&args, "id") {
Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)),
};
Box::pin(async move {
let repo = ProjectRepo::new(&db);
match repo.get_by_id(&id).await {
Ok(Some(p)) => json_ok(json!(p)),
Ok(None) => CallToolResult::error(format!("项目不存在: {id}")),
Err(e) => err_str(e),
}
})
}
fn create_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
let db = ctx.db.clone();
let name = match arg_str(&args, "name") {
Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)),
};
let description = arg_str_or(&args, "description", "");
let status = arg_str_or(&args, "status", "active");
medium_audit("create_project", &name);
Box::pin(async move {
let now = now_millis();
let rec = ProjectRecord {
id: new_id(),
name,
description,
status,
idea_id: None,
path: None,
stack: None,
created_at: now.clone(),
updated_at: now,
};
let repo = ProjectRepo::new(&db);
match repo.insert(rec).await {
Ok(id) => {
let created = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "project": created }))
}
Err(e) => err_str(e),
}
})
}
fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
let db = ctx.db.clone();
let id = match arg_str(&args, "id") {
Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)),
};
let name = arg_str_or(&args, "name", "");
let description = arg_str_or(&args, "description", "");
let status = arg_str_or(&args, "status", "active");
medium_audit("update_project", &id);
Box::pin(async move {
let repo = ProjectRepo::new(&db);
// 先读现有保留 path/stack/idea_id
let existing = match repo.get_by_id(&id).await {
Ok(Some(p)) => p,
Ok(None) => return CallToolResult::error(format!("项目不存在: {id}")),
Err(e) => return err_str(e),
};
let now = now_millis();
let rec = ProjectRecord {
id: id.clone(),
name,
description,
status,
idea_id: existing.idea_id,
path: existing.path,
stack: existing.stack,
created_at: existing.created_at,
updated_at: now,
};
match repo.update_full(&rec).await {
Ok(true) => {
let updated = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "project": updated }))
}
Ok(false) => CallToolResult::error(format!("项目不存在: {id}")),
Err(e) => err_str(e),
}
})
}
fn delete_project(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
// High 风险:默认拒绝(dispatch 兜底 + 此处二次防御,防 dispatch 漏判)
Box::pin(std::future::ready(CallToolResult::error(
"High 风险操作(delete_project)默认拒绝,请在 DevFlow 应用内执行。",
)))
}
fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
let db = ctx.db.clone();
let id = match arg_str(&args, "id") {
Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)),
};
let path = match arg_str(&args, "path") {
Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)),
};
medium_audit("bind_directory", &format!("{id} <- {path}"));
Box::pin(async move {
let repo = ProjectRepo::new(&db);
let norm = normalize_path(&path);
// 路径冲突检测
if let Some(conflict) = repo.find_path_conflict(&norm, Some(&id)).await.ok().flatten() {
return CallToolResult::error(format!(
"路径已被项目「{}」({})绑定,请先解绑",
conflict.name, conflict.id
));
}
// 仅更新 path 字段(用 update_field,保留其它)
if !repo.update_field(&id, "path", &path).await.unwrap_or(false) {
return CallToolResult::error(format!("项目不存在: {id}"));
}
let updated = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "project": updated }))
})
}
// ============================================================
// handler 实现 — 任务
// ============================================================
fn list_tasks(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
let db = ctx.db.clone();
let project_id_filter = args.get("project_id").and_then(|v| v.as_str()).map(|s| s.to_owned());
let status_filter = args.get("status").and_then(|v| v.as_str()).map(|s| s.to_owned());
Box::pin(async move {
let repo = TaskRepo::new(&db);
match repo.list_active().await {
Ok(mut list) => {
if let Some(pid) = &project_id_filter {
list.retain(|t| t.project_id == *pid);
}
if let Some(st) = &status_filter {
list.retain(|t| t.status == *st);
}
json_ok(json!({ "tasks": list, "count": list.len() }))
}
Err(e) => err_str(e),
}
})
}
fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
let db = ctx.db.clone();
let project_id = match arg_str(&args, "project_id") {
Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)),
};
let title = match arg_str(&args, "title") {
Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)),
};
let description = arg_str_or(&args, "description", "");
let priority = arg_int_or(&args, "priority", 0);
medium_audit("create_task", &format!("{project_id}/{title}"));
Box::pin(async move {
let now = now_millis();
let rec = TaskRecord {
id: new_id(),
project_id,
title,
description,
status: "todo".to_owned(),
priority,
branch_name: None,
assignee: None,
workflow_def_id: None,
base_branch: None,
review_rounds: 0,
output_json: None,
idea_id: None,
created_at: now.clone(),
updated_at: now,
};
let repo = TaskRepo::new(&db);
match repo.insert(rec).await {
Ok(id) => {
let created = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "task": created }))
}
Err(e) => err_str(e),
}
})
}
fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
let db = ctx.db.clone();
let id = match arg_str(&args, "id") {
Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)),
};
let project_id = match arg_str(&args, "project_id") {
Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)),
};
let title = match arg_str(&args, "title") {
Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)),
};
let description = arg_str_or(&args, "description", "");
medium_audit("update_task", &id);
Box::pin(async move {
let repo = TaskRepo::new(&db);
let existing = match repo.get_by_id(&id).await {
Ok(Some(t)) => t,
Ok(None) => return CallToolResult::error(format!("任务不存在: {id}")),
Err(e) => return err_str(e),
};
let now = now_millis();
let rec = TaskRecord {
id: id.clone(),
project_id,
title,
description,
// 不允许经 MCP 改状态(状态机收口,须走 advance_task)
status: existing.status,
priority: existing.priority,
branch_name: existing.branch_name,
assignee: existing.assignee,
workflow_def_id: existing.workflow_def_id,
base_branch: existing.base_branch,
review_rounds: existing.review_rounds,
output_json: existing.output_json,
idea_id: existing.idea_id,
created_at: existing.created_at,
updated_at: now,
};
match repo.update_full(&rec).await {
Ok(true) => {
let updated = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "task": updated }))
}
Ok(false) => CallToolResult::error(format!("任务不存在: {id}")),
Err(e) => err_str(e),
}
})
}
fn advance_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
let db = ctx.db.clone();
let id = match arg_str(&args, "id") {
Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)),
};
// 不再接收 from:推进链内部读当前态 + 三层校验(is_valid_state / can_transition /
// 同态拒绝),避免外部客户端直调 advance_status_atomic 绕过状态机非法跳态(todo→done)。
let to = match arg_str(&args, "to") {
Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)),
};
medium_audit("advance_task", &format!("{id} -> {to}"));
Box::pin(async move {
let repo = TaskRepo::new(&db);
// 复用推进链唯一 status 写入路径(与 IPC advance_task 同源):
// - is_valid_state + can_transition + 同态拒绝三层校验
// - CAS 防 TOCTOU
// - is_regression 自动判定 bump review_rounds(取代旧内联 bump 副本)
// - 错误类型(NotFound/Validation/InvalidState)由 thiserror Display 串化
match df_nodes::task_advance_node::advance_task_atomic(&repo, &id, &to).await {
Ok(updated) => json_ok(json!({ "id": id, "task": updated })),
Err(e) => err_str(e),
}
})
}
fn delete_task(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
Box::pin(std::future::ready(CallToolResult::error(
"High 风险操作(delete_task)默认拒绝,请在 DevFlow 应用内执行。",
)))
}
// ============================================================
// handler 实现 — 灵感
// ============================================================
fn list_ideas(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
let db = ctx.db.clone();
Box::pin(async move {
let repo = IdeaRepo::new(&db);
match repo.list_all().await {
Ok(list) => json_ok(json!({ "ideas": list, "count": list.len() })),
Err(e) => err_str(e),
}
})
}
fn create_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
let db = ctx.db.clone();
let title = match arg_str(&args, "title") {
Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)),
};
let description = arg_str_or(&args, "description", "");
let priority = arg_int_or(&args, "priority", 0);
medium_audit("create_idea", &title);
Box::pin(async move {
let now = now_millis();
let rec = IdeaRecord {
id: new_id(),
title,
description,
status: "draft".to_owned(),
priority,
score: None,
tags: None,
source: Some("mcp".to_owned()),
promoted_to: None,
ai_analysis: None,
scores: None,
related_ids: None,
created_at: now.clone(),
updated_at: now,
};
let repo = IdeaRepo::new(&db);
match repo.insert(rec).await {
Ok(id) => {
let created = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "idea": created }))
}
Err(e) => err_str(e),
}
})
}
fn update_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
let db = ctx.db.clone();
let id = match arg_str(&args, "id") {
Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)),
};
let title = arg_str_or(&args, "title", "");
let description = arg_str_or(&args, "description", "");
medium_audit("update_idea", &id);
Box::pin(async move {
let repo = IdeaRepo::new(&db);
let existing = match repo.get_by_id(&id).await {
Ok(Some(i)) => i,
Ok(None) => return CallToolResult::error(format!("想法不存在: {id}")),
Err(e) => return err_str(e),
};
let now = now_millis();
let rec = IdeaRecord {
id: id.clone(),
title,
description,
status: existing.status,
priority: existing.priority,
score: existing.score,
tags: existing.tags,
source: existing.source,
promoted_to: existing.promoted_to,
ai_analysis: existing.ai_analysis,
scores: existing.scores,
related_ids: existing.related_ids.clone(),
created_at: existing.created_at,
updated_at: now,
};
match repo.update_full(&rec).await {
Ok(true) => {
let updated = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "idea": updated }))
}
Ok(false) => CallToolResult::error(format!("想法不存在: {id}")),
Err(e) => err_str(e),
}
})
}
fn delete_idea(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
Box::pin(std::future::ready(CallToolResult::error(
"High 风险操作(delete_idea)默认拒绝,请在 DevFlow 应用内执行。",
)))
}
fn evaluate_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
let db = ctx.db.clone();
let id = match arg_str(&args, "id") {
Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)),
};
Box::pin(async move {
let repo = IdeaRepo::new(&db);
let idea = match repo.get_by_id(&id).await {
Ok(Some(i)) => i,
Ok(None) => return CallToolResult::error(format!("想法不存在: {id}")),
Err(e) => return err_str(e),
};
// 启发式评分(本地确定性,不调 LLM)
let scores = heuristic_scores(&idea.title, &idea.description);
let now = now_millis();
// 写回 scores 字段(整体更新)
let mut rec = idea.clone();
rec.scores = Some(serde_json::to_string(&scores).unwrap_or_default());
rec.updated_at = now;
if let Err(e) = repo.update_full(&rec).await {
return err_str(e);
}
json_ok(json!({ "id": id, "idea": rec, "scores": scores }))
})
}
/// 启发式评分:基于标题长度/描述详细度/关键词,产出 feasibility/impact/urgency/overall 0-10 分。
/// 确定性纯函数,与 df-ideas 评估器对齐维度但不依赖 df-ai。
fn heuristic_scores(title: &str, description: &str) -> Value {
let desc_len = description.chars().count();
let title_len = title.chars().count();
// feasibility:描述越详细越可行(评估前已有思考)
let feasibility = ((desc_len as f64 / 200.0).min(1.0) * 6.0 + 3.0).min(9.0);
// impact:含「核心/关键/重要」等关键词加权
let impact_keywords = ["核心", "关键", "重要", "紧急", "blocker", "critical", "core"];
let kw_hits = impact_keywords.iter().filter(|k| title.contains(*k) || description.contains(*k)).count();
let impact = (5.0 + kw_hits as f64 * 1.5).min(9.0);
// urgency:priority 字段不在此,用关键词近似
let urgency_kw = ["紧急", "urgent", "asap", "立即", "马上"];
let urgency_hits = urgency_kw.iter().filter(|k| title.contains(*k) || description.contains(*k)).count();
let urgency = (4.0 + urgency_hits as f64 * 2.0).min(9.0);
// overall:加权平均(feasibility/impact/urgency = 0.4/0.4/0.2)
let overall = feasibility * 0.4 + impact * 0.4 + urgency * 0.2;
let _ = title_len; // 标题长度暂不入分(避免短标题被低估)
json!({
"feasibility": (feasibility * 10.0).round() / 10.0,
"impact": (impact * 10.0).round() / 10.0,
"urgency": (urgency * 10.0).round() / 10.0,
"overall": (overall * 10.0).round() / 10.0
})
}
// ============================================================
// handler 实现 — 工作流(High,拒绝)
// ============================================================
fn run_workflow(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
Box::pin(std::future::ready(CallToolResult::error(
"High 风险操作(run_workflow)默认拒绝。工作流涉及代码生成/审查/分支操作,请在 DevFlow 应用内执行。",
)))
}
// ============================================================
// handler 实现 — 回收站
// ============================================================
fn list_trash(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
let db = ctx.db.clone();
Box::pin(async move {
let projects = match ProjectRepo::new(&db).list_deleted().await {
Ok(v) => v,
Err(e) => return err_str(e),
};
let tasks = match TaskRepo::new(&db).list_deleted().await {
Ok(v) => v,
Err(e) => return err_str(e),
};
json_ok(json!({
"projects": projects,
"tasks": tasks,
"project_count": projects.len(),
"task_count": tasks.len()
}))
})
}
fn restore_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
let db = ctx.db.clone();
let id = match arg_str(&args, "id") {
Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)),
};
medium_audit("restore_project", &id);
Box::pin(async move {
match ProjectRepo::new(&db).restore(&id).await {
Ok(true) => json_ok(json!({ "id": id, "restored": true })),
Ok(false) => CallToolResult::error(format!("恢复失败(项目不在回收站): {id}")),
Err(e) => err_str(e),
}
})
}
// ============================================================
// 路径规范化(镜像 df_project::scan::normalize_path,本 crate 不依赖 df-project)
// ============================================================
fn normalize_path(p: &str) -> String {
match std::path::Path::new(p).canonicalize() {
Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(),
Err(_) => p
.trim_end_matches(['\\', '/'])
.replace('\\', "/")
.to_lowercase(),
}
}
+2 -1
View File
@@ -4,10 +4,11 @@ version = "0.1.0"
edition = "2021"
[dependencies]
df-core = { path = "../df-core" }
df-types = { path = "../df-types" }
df-execute = { path = "../df-execute" }
df-workflow = { path = "../df-workflow" }
df-ai = { path = "../df-ai" }
df-storage = { path = "../df-storage" }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
+345 -89
View File
@@ -5,114 +5,72 @@
//!
//! 与 AI Chat(侧边栏交互对话)的区别:AI Node 由 DAG Executor 自动驱动,
//! 适合嵌入自动化链路(如 想法 → AI分析 → 脚本落地 → 人工审批)。
//!
//! 拆分边界(纯结构,逻辑等价):
//! - provider 解析/参数构造/自审 JSON 解析等纯逻辑 → `ai_node_helpers`
//! - AiSelfReviewNode(自审闭环独立节点) → `ai_self_review_node`
//! - 本文件:仅 AiNode 的 struct + impl + tests。
use std::sync::Arc;
use async_trait::async_trait;
use df_ai::anthropic_compat::AnthropicCompatProvider;
use df_ai::openai_compat::OpenAICompatProvider;
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
use df_storage::crud::TaskRepo;
use df_storage::db::Database;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
// AiNode::execute 用的纯函数(resolve_and_parse / provider_from_params);
// 其余 helpers(parse_review_json/gate_should_block/REVIEW_SYSTEM_PROMPT/ResolvedProvider/
// truncate_for_summary)由 ai_self_review_node 与 tests 引入,不在此处 glob。
use crate::ai_node_helpers::{provider_from_params, resolve_and_parse};
/// AI 节点
pub struct AiNode;
///
/// 持有 Arc<Database> 在 NodeRegistry 注册时注入(state.rs build_registry 工厂闭包 move 捕获
/// db.clone(),对齐 TaskAdvanceNode 先例)。execute 从 NodeContext.config 读 provider 配置 +
/// 可选 task_id:有 task_id 时把产出落 task.output_json(AiNode 自审闭环,决策 a)。
pub struct AiNode {
db: Arc<Database>,
}
impl AiNode {
/// 构造节点(注册表工厂调用,注入数据库句柄)
pub fn new(db: Arc<Database>) -> Self {
Self { db }
}
}
#[async_trait]
impl Node for AiNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult {
tracing::info!("AiNode 执行: node_id={}", ctx.node_id);
// ── provider 配置(必填)──
let base_url = ctx
.config
.get("base_url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: base_url"))?
.to_string();
let api_key = ctx
.config
.get("api_key")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: api_key"))?
.to_string();
// ── prompt(必填):优先取上游节点 "prompt" 输出,回退 config.prompt ──
let prompt = ctx
.inputs
.get("prompt")
.and_then(|o| o.data.as_str())
.map(|s| s.to_string())
.or_else(|| {
ctx.config
.get("prompt")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: prompt(config 或上游输入均无)"))?;
// ── 可选参数 ──
let model = ctx
.config
.get("model")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let temperature = ctx
.config
.get("temperature")
.and_then(|v| v.as_f64())
.map(|f| f as f32);
let max_tokens = ctx
.config
.get("max_tokens")
.and_then(|v| v.as_u64())
.map(|n| n as u32);
let system_prompt = ctx
.config
.get("system_prompt")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// 协议类型:默认 openai_compat,可设 anthropic(GLM 订阅端点 / Claude 官方)
let protocol = ctx
.config
.get("protocol")
.and_then(|v| v.as_str())
.unwrap_or("openai_compat")
.to_string();
// default_model:留空时给一个占位,避免 provider 构造 panic
let default_model = if model.is_empty() {
"gpt-4o-mini".to_string()
} else {
model.clone()
};
let provider: Box<dyn LlmProvider> = match protocol.as_str() {
"anthropic" => Box::new(AnthropicCompatProvider::new(&base_url, &api_key, &default_model)),
_ => Box::new(OpenAICompatProvider::new(&base_url, &api_key, &default_model)),
};
// FR-S1 注入链:provider 经 df_storage::secret 在 AiNode 内存解析,api_key 不进 config。
let p = resolve_and_parse(&self.db, &ctx.config, &ctx.inputs).await?;
let provider: Box<dyn LlmProvider> = provider_from_params(&p);
// ── 构建消息 ──
let mut messages = Vec::with_capacity(2);
if let Some(sys) = system_prompt {
if let Some(sys) = p.system_prompt {
messages.push(ChatMessage::system(sys));
}
messages.push(ChatMessage::user(prompt));
messages.push(ChatMessage::user(p.prompt));
let request = CompletionRequest {
model: model.clone(),
model: p.model.clone(),
messages,
temperature,
max_tokens,
temperature: p.temperature,
max_tokens: p.max_tokens,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
tracing::info!(
"AiNode 调用 LLM: model={}, base_url={}",
default_model,
base_url
p.provider.default_model,
p.provider.base_url
);
let response = provider.complete(request).await?;
@@ -123,7 +81,7 @@ impl Node for AiNode {
response.usage.completion_tokens
);
Ok(NodeOutput::from_value(serde_json::json!({
let output = serde_json::json!({
"text": response.text,
"model": response.model,
"usage": {
@@ -131,7 +89,25 @@ impl Node for AiNode {
"completion_tokens": response.usage.completion_tokens,
"total_tokens": response.usage.total_tokens,
},
})))
});
// 决策 a(AiNode 自审闭环):有 task_id 时落产出到 task.output_json。
// 走通用 update_field(output_json 已在 tasks 白名单,crud/mod.rs update_field 宏(impl_repo!)),
// 非 status 状态机收口字段,合法可写。落库失败不阻断节点返回(产出已在内存,工作流可继续)。
if let Some(task_id) = ctx.config.get("task_id").and_then(|v| v.as_str()) {
if let Ok(json_str) = serde_json::to_string(&output) {
let repo = TaskRepo::new(&self.db);
if let Err(e) = repo.update_field(task_id, "output_json", &json_str).await {
tracing::warn!(
task_id = task_id,
error = %e,
"AiNode 落 output_json 失败(产出已在内存,节点继续)"
);
}
}
}
Ok(NodeOutput::from_value(output))
}
fn schema(&self) -> NodeSchema {
@@ -141,14 +117,15 @@ impl Node for AiNode {
"properties": {
"prompt": { "type": "string", "description": "用户提示词(若无则取上游 prompt 输出)" },
"system_prompt": { "type": "string", "description": "系统提示词(可选)" },
"protocol": { "type": "string", "description": "协议类型:openai_compat(默认)或 anthropic(GLM订阅/Claude官方)" },
"base_url": { "type": "string", "description": "API 地址,OpenAI 兼容如 https://api.deepseek.com;Anthropic 如 https://open.bigmodel.cn/api/anthropic" },
"api_key": { "type": "string", "description": "API 密钥" },
"model": { "type": "string", "description": "模型名(可选,留空用默认)" },
"provider_id": { "type": "string", "description": "AI Provider ID(FR-S1:密钥经 df_storage::secret 解析,不进 config;留空走默认 provider)" },
"model": { "type": "string", "description": "模型名(可选,留空用 record.default_model)" },
"temperature": { "type": "number", "description": "温度 0.0~2.0(可选)" },
"max_tokens": { "type": "integer", "description": "最大生成 token(可选,anthropic 协议无值时默认 4096)" }
"max_tokens": { "type": "integer", "description": "最大生成 token(可选,anthropic 协议无值时默认 4096)" },
"base_url": { "type": "string", "description": "(已废弃过渡)明文 API 地址,改用 provider_id" },
"api_key": { "type": "string", "description": "(已废弃过渡)明文 API 密钥,改用 provider_id;FR-S1 下经 secret 解析" }
},
"required": ["prompt", "base_url", "api_key"]
// SW-260618-15: prompt/provider_id 均"留空走兜底"(prompt 取上游、provider_id 走默认 provider),与 required 矛盾。改 required=[] 对齐 execute 运行时,防前端按 schema 误拒合法配置。
"required": []
}),
output: serde_json::json!({
"type": "object",
@@ -165,3 +142,282 @@ impl Node for AiNode {
"ai"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ai_node_helpers::{parse_params, resolve_provider, ResolvedProvider};
use df_storage::crud::AiProviderRepo;
use df_storage::db::Database;
use df_storage::models::AiProviderRecord;
use serde_json::json;
use std::collections::HashMap;
/// 测试用 ResolvedProvider 桩:固定 protocol/base_url/api_key/default_model。
fn provider_stub() -> ResolvedProvider {
ResolvedProvider {
protocol: "openai_compat".to_string(),
base_url: "https://api.example.com".to_string(),
api_key: "sk-test".to_string(),
default_model: "gpt-4o-mini".to_string(),
model_pool: Vec::new(),
}
}
/// 造带基础 prompt 的 config(provider 经 resolve_provider 解析,parse_params 只看 prompt),
/// overrides 覆盖或追加。
fn config_with(overrides: serde_json::Value) -> serde_json::Value {
let mut base = json!({
"prompt": "config-prompt"
});
if let (serde_json::Value::Object(b), serde_json::Value::Object(o)) = (&mut base, overrides) {
for (k, v) in o {
b.insert(k, v);
}
}
base
}
fn empty_inputs() -> HashMap<String, NodeOutput> {
HashMap::new()
}
#[test]
fn missing_prompt_errors() {
let config = json!({});
let err = parse_params(&config, &empty_inputs(), provider_stub())
.unwrap_err()
.to_string();
assert!(err.contains("prompt"), "缺 prompt 应报错, 实际: {}", err);
}
#[test]
fn prompt_from_config_when_no_upstream() {
let p =
parse_params(&config_with(json!({})), &empty_inputs(), provider_stub()).unwrap();
assert_eq!(p.prompt, "config-prompt");
}
#[test]
fn prompt_prefers_upstream_input_over_config() {
let mut inputs = empty_inputs();
inputs.insert(
"prompt".to_string(),
NodeOutput::from_value(json!("upstream-prompt")),
);
let p =
parse_params(&config_with(json!({})), &inputs, provider_stub()).unwrap();
assert_eq!(p.prompt, "upstream-prompt", "上游输入应优先于 config.prompt");
}
#[test]
fn optional_fields_parsed_when_present() {
let p = parse_params(
&config_with(json!({
"model": "glm-4",
"temperature": 0.3,
"max_tokens": 1024,
"system_prompt": "你是助手"
})),
&empty_inputs(),
provider_stub(),
)
.unwrap();
assert_eq!(p.model, "glm-4");
assert_eq!(p.provider.protocol, "openai_compat");
assert_eq!(p.temperature, Some(0.3));
assert_eq!(p.max_tokens, Some(1024));
assert_eq!(p.system_prompt.as_deref(), Some("你是助手"));
}
// ============================================================
// resolve_provider 双路径测试(FR-S1 注入链核心)
// ============================================================
/// 内存 DB 插 provider(可选 is_default),返回 (db, provider_id)。
async fn setup_provider_db(
provider_id: &str,
is_default: bool,
api_key: &str,
) -> (Database, String) {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiProviderRepo::new(&db);
repo.insert(AiProviderRecord {
id: provider_id.to_string(),
name: "测试 Provider".to_string(),
provider_type: "openai_compat".to_string(),
api_key: api_key.to_string(),
base_url: "https://api.example.com".to_string(),
default_model: "glm-4-flash".to_string(),
models: None,
model_configs: Vec::new(),
is_default,
config: None,
created_at: "0".to_string(),
updated_at: "0".to_string(),
enabled: true,
weight: 50,
})
.await
.expect("insert provider");
(db, provider_id.to_string())
}
/// 路径 1:provider_id 优先 → resolve_provider 走下沉解析(DB api_key 非空→直接返回)。
#[tokio::test]
async fn resolve_provider_by_id_prefers_db_key() {
let (db, pid) = setup_provider_db("prov-1", true, "sk-from-db").await;
let config = json!({ "provider_id": pid });
let resolved = resolve_provider(&Arc::new(db), &config).await.unwrap();
assert_eq!(resolved.api_key, "sk-from-db", "DB api_key 非空应直接返回");
assert_eq!(resolved.base_url, "https://api.example.com");
assert_eq!(resolved.protocol, "openai_compat");
assert_eq!(
resolved.default_model, "glm-4-flash",
"无 config.model 时用 record.default_model"
);
}
/// 路径 1:config.model 覆盖 record.default_model。
#[tokio::test]
async fn resolve_provider_config_model_overrides_record() {
let (db, pid) = setup_provider_db("prov-1m", true, "sk-db").await;
let config = json!({ "provider_id": pid, "model": "glm-4" });
let resolved = resolve_provider(&Arc::new(db), &config).await.unwrap();
assert_eq!(resolved.default_model, "glm-4");
}
/// 路径 1:provider_id 不存在 → 友好错误。
#[tokio::test]
async fn resolve_provider_unknown_id_errors() {
let (db, _pid) = setup_provider_db("prov-2", true, "sk-db").await;
let config = json!({ "provider_id": "non-existent" });
let err = resolve_provider(&Arc::new(db), &config)
.await
.unwrap_err()
.to_string();
assert!(err.contains("不存在"), "未知 provider_id 应报错, 实际: {}", err);
}
/// 路径 1:provider_id 指向但密钥空(DB 空 + keyring 无)→ ensure_resolved_key 报错。
#[tokio::test]
async fn resolve_provider_empty_key_errors() {
// api_key 空:DB 无 → keyring 读不到 → 空 → ensure 报错
let (db, pid) = setup_provider_db("prov-empty", true, "").await;
let config = json!({ "provider_id": pid });
let err = resolve_provider(&Arc::new(db), &config)
.await
.unwrap_err()
.to_string();
assert!(
err.contains("密钥") || err.contains("未读取到"),
"空密钥应报错, 实际: {}",
err
);
}
/// 路径 2:老明文路径兼容(base_url+api_key 显式注入)→ 返回明文 + 不报错。
#[tokio::test]
async fn resolve_provider_legacy_plain_path_compat() {
// 无 provider 且无 provider_id → 老明文路径兜底
let db = Database::open_in_memory().await.expect("open_in_memory");
let config = json!({
"base_url": "https://legacy.example.com",
"api_key": "sk-legacy"
});
let resolved = resolve_provider(&Arc::new(db), &config).await.unwrap();
assert_eq!(resolved.api_key, "sk-legacy");
assert_eq!(resolved.base_url, "https://legacy.example.com");
assert_eq!(resolved.default_model, "gpt-4o-mini", "无 model 时占位兜底");
}
/// 路径 2:老明文路径空 key → ensure_resolved_key 报错。
#[tokio::test]
async fn resolve_provider_legacy_empty_key_errors() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let config = json!({ "base_url": "https://x", "api_key": "" });
let err = resolve_provider(&Arc::new(db), &config)
.await
.unwrap_err()
.to_string();
assert!(err.contains("密钥"), "老路径空 key 应报错, 实际: {}", err);
}
/// 路径 3:空兜底 → 无 provider_id 无明文 → 取 is_default=true 首条。
#[tokio::test]
async fn resolve_provider_fallback_default_provider() {
let (db, _pid) = setup_provider_db("prov-default", true, "sk-default").await;
let config = json!({}); // 无 provider_id 无明文
let resolved = resolve_provider(&Arc::new(db), &config).await.unwrap();
assert_eq!(resolved.api_key, "sk-default", "兜底应取 is_default 首条");
}
/// 路径 3:无任何 provider → 友好错误「未配置 AI Provider」。
#[tokio::test]
async fn resolve_provider_no_providers_errors() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let config = json!({});
let err = resolve_provider(&Arc::new(db), &config)
.await
.unwrap_err()
.to_string();
assert!(
err.contains("未配置") || err.contains("Provider"),
"无 provider 应友好报错, 实际: {}",
err
);
}
/// 路径 3:兜底无 is_default 时取首条(对齐 idea.rs build_default_provider)。
#[tokio::test]
async fn resolve_provider_fallback_first_when_no_default() {
let (db, _pid) = setup_provider_db("prov-first", false, "sk-first").await;
let config = json!({});
let resolved = resolve_provider(&Arc::new(db), &config).await.unwrap();
assert_eq!(resolved.api_key, "sk-first", "无 is_default 时取首条");
}
/// 真调 GLM 验证 provider 调用层(parse_params 已由上方单测覆盖,此处补 complete 端到端)
///
/// `#[ignore]`:需真实 GLM 配置(env var),默认不跑。
/// 跑法:`GLM_BASE_URL=... GLM_API_KEY=... GLM_MODEL=glm-4-flash \
/// cargo test -p df-nodes --lib glm_live_complete -- --ignored --nocapture`
/// env var 缺失 → 跳过(非失败)。
#[ignore = "需真实 GLM 配置(env var)"]
#[tokio::test]
async fn glm_live_complete() {
let base_url = std::env::var("GLM_BASE_URL").ok().filter(|s| !s.is_empty());
let api_key = std::env::var("GLM_API_KEY").ok().filter(|s| !s.is_empty());
let (base_url, api_key) = match (base_url, api_key) {
(Some(b), Some(k)) => (b, k),
_ => {
eprintln!("跳过: 未设 GLM_BASE_URL / GLM_API_KEY env var");
return;
}
};
let model = std::env::var("GLM_MODEL").unwrap_or_else(|_| "glm-4-flash".to_string());
let provider: Box<dyn LlmProvider> =
df_ai::build_provider("openai_compat", &base_url, &api_key, &model);
let request = CompletionRequest {
model: model.clone(),
messages: vec![ChatMessage::user("只回复两个字:通过")],
temperature: Some(0.0),
max_tokens: Some(16),
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let response = provider.complete(request).await.expect("GLM 调用失败");
assert!(!response.text.is_empty(), "GLM 返回空文本");
println!(
"GLM 响应: model={}, text={}, usage={:?}",
response.model, response.text, response.usage
);
}
// 自审闭环相关单测(parse_review_json / truncate_for_summary / gate_should_block /
// build_review_prompt / update_field_writes_output_json)已随 AiSelfReviewNode 迁移至
// ai_self_review_node.rs(与被测代码同位,纯搬运)。
}
+298
View File
@@ -0,0 +1,298 @@
//! AI 节点辅助函数/类型 — provider 解析、参数构造、自审 JSON 解析等纯逻辑
//!
//! 从 ai_node.rs 抽离的纯函数/类型(execute 与解析解耦,便于单测覆盖)。
//! AiNode / AiSelfReviewNode 的 struct + impl 仍保留在 ai_node.rs(impl 块约束)。
//!
//! SW-09 已抽 resolve_and_parse / provider_from_params helper(本文件随之搬迁)。
use std::collections::HashMap;
use std::sync::Arc;
use df_ai::df_ai_core::model::{Modality, ModelConfig};
use df_ai::provider::LlmProvider;
// F-01 阶段5: AiNode 路由 — 节点 config.model_id 优先;否则按 TaskRequirements 路由
// (默认 Standard + needs_tool_use=true)。池空/无匹配兜底 record.default_model。
use df_ai::router::{select_model_id, TaskRequirements};
use df_storage::crud::AiProviderRepo;
use df_storage::db::Database;
use df_storage::models::AiProviderRecord;
use df_storage::secret::{ensure_resolved_key, resolve_provider_secret};
use df_workflow::node::{NodeOutput};
/// AI 节点解析后的参数(execute 与参数解析解耦,便于单测覆盖取值/默认/校验逻辑)
///
/// provider 配置(base_url/api_key/protocol/default_model)经 `resolve_provider` 从 DB
/// ai_providers 表查 record + 经 df_storage::secret 解析密钥得到,**不进 config(FR-S1)**。
/// api_key 仅存于本结构体内存(AiNode 进程内存),不落 NodeContext.config / NodeOutput.data。
#[derive(Debug)]
pub(crate) struct AiNodeParams {
/// 解析后的 provider 构造要素(含明文 api_key,仅 AiNode 内存可见)
pub provider: ResolvedProvider,
pub prompt: String,
pub system_prompt: Option<String>,
pub model: String,
pub temperature: Option<f32>,
pub max_tokens: Option<u32>,
}
/// 经 `resolve_provider` 从 ai_providers 表 + df_storage::secret 解析后的 provider 构造要素。
///
/// api_key 字段:明文,FR-S1 下全程不出 AiNode 进程内存(不进 config/output/schema)。
#[derive(Debug, Clone)]
pub(crate) struct ResolvedProvider {
/// 协议类型:openai_compat(默认)/ anthropic(GLM 订阅 / Claude 官方)— 从 record.provider_type 映射
pub protocol: String,
pub base_url: String,
/// 明文 api_key,经 `resolve_provider_secret`(DB 优先→keyring) 解析;仅 AiNode 内存可见
pub api_key: String,
/// model 为空时的占位(record.default_model 或 "gpt-4o-mini"),避免 provider 构造 panic
pub default_model: String,
/// F-01 阶段5: 候选模型池(来自 record.model_configs)。parse_params 路由用:
/// config.model 留空时经 select_model_id 选最优;池空兜底 default_model。
pub model_pool: Vec<ModelConfig>,
}
/// 经 ai_providers 表 + df_storage::secret 解析 provider 构造要素(FR-S1 注入链核心)。
///
/// 三路径(优先级从高到低):
/// 1. **provider_id 优先**:config["provider_id"] 存在 → `AiProviderRepo::get_by_id` 查 record →
/// `resolve_provider_secret`(DB 优先→keyring) → `ensure_resolved_key` 空键早失败。
/// 2. **老路径兼容(过渡)**:config 显式含 `base_url` + `api_key` 明文(老调用方)→ 走原路径
/// + `tracing::warn!`(明文 api_key 经 config 注入已废弃)。兼容期保留,后续移除。
/// 3. **空兜底**:无 provider_id 也无明文 → 取 `is_default=true` 首条 provider(对齐
/// src-tauri commands/idea.rs build_default_provider 模式),走同 resolve+ensure 链。
/// 无任何 provider → 友好错误「未配置 AI Provider」。
///
/// model:config["model"] 非空用之,否则 record.default_model,再否则 "gpt-4o-mini" 占位。
/// SW-260618-09: provider 三件套 DRY —— resolve_provider + parse_params 合并
/// (AiNode/AiSelfReviewNode execute 逐字重复)。
pub(crate) async fn resolve_and_parse(
db: &Arc<Database>,
config: &serde_json::Value,
inputs: &HashMap<String, NodeOutput>,
) -> anyhow::Result<AiNodeParams> {
let provider_cfg = resolve_provider(db, config).await?;
parse_params(config, inputs, provider_cfg)
}
/// SW-260618-09: build_provider 5 行封装 DRY(AiNode/AiSelfReviewNode execute 逐字重复)。
pub(crate) fn provider_from_params(p: &AiNodeParams) -> Box<dyn LlmProvider> {
df_ai::build_provider(
&p.provider.protocol,
&p.provider.base_url,
&p.provider.api_key,
&p.provider.default_model,
)
}
pub(crate) async fn resolve_provider(
db: &Arc<Database>,
config: &serde_json::Value,
) -> anyhow::Result<ResolvedProvider> {
let repo = AiProviderRepo::new(db);
let config_model = config
.get("model")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
// ── 路径 1:provider_id 优先 ──
if let Some(pid) = config.get("provider_id").and_then(|v| v.as_str()) {
if !pid.is_empty() {
let record = repo
.get_by_id(pid)
.await
.map_err(|e| anyhow::anyhow!("AiNode 查 provider 失败: {}", e))?
.ok_or_else(|| anyhow::anyhow!("AiNode provider_id={} 不存在", pid))?;
return resolve_from_record(&record, &config_model);
}
}
// ── 路径 2:老明文路径兼容(过渡,warn) ──
// base_url / api_key 各解析一次(复用于 has 判定与取值,避免 DRY 重复解析同字段)。
let plain_base = config.get("base_url").and_then(|v| v.as_str());
let plain_key = config.get("api_key").and_then(|v| v.as_str());
if let (Some(base_url_str), Some(api_key_str)) = (plain_base, plain_key) {
tracing::warn!(
"AiNode 明文 api_key/base_url 经 config 注入已废弃, 改用 provider_id (FR-S1). \
老路径将在后续版本移除"
);
let base_url = base_url_str.to_string();
let api_key = api_key_str.to_string();
ensure_resolved_key("(明文注入)", &api_key)
.map_err(anyhow::Error::msg)?;
let protocol = config
.get("protocol")
.and_then(|v| v.as_str())
.unwrap_or("openai_compat")
.to_string();
let default_model = if config_model.is_empty() {
"gpt-4o-mini".to_string()
} else {
config_model.clone()
};
return Ok(ResolvedProvider {
protocol,
base_url,
api_key,
default_model,
// 老明文路径无 record,候选池空(无路由能力,兜底 default_model)。
model_pool: Vec::new(),
});
}
// ── 路径 3:兜底 is_default=true 首条 provider ──
let providers = repo
.list_all()
.await
.map_err(|e| anyhow::anyhow!("AiNode 列 provider 失败: {}", e))?;
let picked = providers
.iter()
.find(|p| p.is_default)
.cloned()
.or_else(|| providers.into_iter().next())
.ok_or_else(|| anyhow::anyhow!("未配置 AI Provider,请在设置中添加并保存密钥"))?;
resolve_from_record(&picked, &config_model)
}
/// 从 record 解析 provider 构造要素:resolve_provider_secret(DB 优先→keyring) + ensure 空键早失败。
/// protocol 从 record.provider_type 映射;default_model 取 config_model > record.default_model > 占位。
pub(crate) fn resolve_from_record(
record: &AiProviderRecord,
config_model: &str,
) -> anyhow::Result<ResolvedProvider> {
let api_key = resolve_provider_secret(record);
ensure_resolved_key(&record.name, &api_key).map_err(anyhow::Error::msg)?;
let default_model = if !config_model.is_empty() {
config_model.to_string()
} else if !record.default_model.is_empty() {
record.default_model.clone()
} else {
"gpt-4o-mini".to_string()
};
Ok(ResolvedProvider {
protocol: record.provider_type.clone(),
base_url: record.base_url.clone(),
api_key,
default_model,
model_pool: record.model_configs.clone(),
})
}
/// 从节点 config + 上游输入解析 AI 节点 prompt 与可选参数(provider 经 `resolve_provider` 异步解析)。
///
/// prompt 取值优先级:上游 `inputs["prompt"]` > `config.prompt`,两者皆无则报错。
pub(crate) fn parse_params(
config: &serde_json::Value,
inputs: &HashMap<String, NodeOutput>,
provider: ResolvedProvider,
) -> anyhow::Result<AiNodeParams> {
// ── prompt(必填):优先取上游节点 "prompt" 输出,回退 config.prompt ──
let prompt = inputs
.get("prompt")
.and_then(|o| o.data.as_str())
.map(|s| s.to_string())
.or_else(|| {
config
.get("prompt")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: prompt(config 或上游输入均无)"))?;
// ── 可选参数 ──
// model 解析优先级(F-01 阶段5):config.model 显式指定 > 路由选优(provider.model_pool 非空时)
// > 空(CompletionRequest.model 留空由 provider impl 回填 default_model,行为不变)。
// 注:provider.model_pool 在 provider move 进 AiNodeParams 前先借引用路由,选中的 model_id
// 填入 CompletionRequest.model;provider.default_model 仍是 build_provider 兜底用。
let config_model = config
.get("model")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let model = if !config_model.is_empty() {
config_model
} else {
// F-01 阶段5: AiNode 默认路由 — needs_tool_use=true(工作流无人值守 AI 步骤
// 常含工具调用,如检索/生成;无需工具的节点应在 config 显式指定 model)。
// select_model_id None(池空/无匹配)→ 空串(由 provider impl 回填 default_model)。
let node_req = TaskRequirements {
modalities: vec![Modality::Text],
needs_tool_use: true,
estimated_context: 0,
};
select_model_id(&node_req, &provider.model_pool).unwrap_or_default()
};
let temperature = config
.get("temperature")
.and_then(|v| v.as_f64())
.map(|f| f as f32);
let max_tokens = config
.get("max_tokens")
.and_then(|v| v.as_u64())
.map(|n| n as u32);
let system_prompt = config
.get("system_prompt")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Ok(AiNodeParams {
provider,
prompt,
system_prompt,
model,
temperature,
max_tokens,
})
}
/// 自审四维度 system prompt:严格审查员角色 + 只输出 JSON 强约束。
pub(crate) const REVIEW_SYSTEM_PROMPT: &str = "\
你是严格的代码/产出审查员。审查任务产出是否符合需求,按四维度给出结构化结论。\
只输出 JSON,不要任何额外文字、不要 markdown 代码块包裹。";
/// 解析 LLM 自审输出为结构化 review JSON。
///
/// 成功路径:serde_json::from_str 得到 Object 且含 verdict 字段 → 原样返回。
/// 兜底路径:解析失败 / 非 Object / 缺 verdict → 返回 verdict=unknown + summary=原文,
/// 防 LLM 不按要求输出导致下游崩溃。dimensions 留空对象(前端容缺展示)。
pub(crate) fn parse_review_json(raw: &str) -> serde_json::Value {
// 先尝试整段解析;LLM 偶尔会包 markdown 代码块,剥离 ```json ... ``` 后重试一次。
let trimmed = raw.trim();
let cleaned = trimmed
.strip_prefix("```json")
.or_else(|| trimmed.strip_prefix("```"))
.map(|s| s.trim_end_matches("```").trim())
.unwrap_or(trimmed);
if let Ok(v) = serde_json::from_str::<serde_json::Value>(cleaned) {
if v.is_object() && v.get("verdict").and_then(|x| x.as_str()).is_some() {
return v;
}
}
// 兜底:保留原文供人查阅,verdict=unknown 不阻断流程(自审辅助,人定)。
serde_json::json!({
"verdict": "unknown",
"dimensions": {},
"summary": format!("(自审输出解析失败,原文: {})", truncate_for_summary(cleaned)),
"suggestions": [],
})
}
/// summary 截断(防原文过长撑爆 output_json / 审批卡)。
pub(crate) fn truncate_for_summary(s: &str) -> String {
const MAX: usize = 300;
if s.chars().count() <= MAX {
return s.to_string();
}
let truncated: String = s.chars().take(MAX).collect();
format!("{truncated}…")
}
/// 阶段3 自审闸门决策(纯函数,便于单测覆盖各 verdict/gate 组合)。
///
/// 仅当 `gate==true` 且 `verdict=="fail"` 时阻断。verdict="unknown"(LLM 输出不可靠)
/// 与 "pass" 均不阻断 —— unknown 保持人定权(阶段2 保守语义不变)。
pub(crate) fn gate_should_block(gate: bool, verdict: &str) -> bool {
gate && verdict == "fail"
}
+447
View File
@@ -0,0 +1,447 @@
//! AI 自审节点(决策 a 步骤③)— AiSelfReviewNode
//!
//! 从 ai_node.rs 拆出的独立节点(纯结构搬运,逻辑等价)。复用 AiNode 的 provider
//! 调用链(resolve_and_parse + provider_from_params),差异在 prompt 模板/JSON 解析/
//! verdict 闸门。provider 解析与自审 JSON 解析等纯逻辑见 `ai_node_helpers`。
//!
//! 节点类型注册 key:`ai_self_review`(state.rs build_registry)。
//! 模板对齐:task_workflow_templates.rs testing 模板 `ai_self_review` 节点 + gate:true。
use std::sync::Arc;
use async_trait::async_trait;
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
use df_storage::crud::TaskRepo;
use df_storage::db::Database;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
// 抽离的纯函数/类型(与 AiNode 共用)。
use crate::ai_node_helpers::{
gate_should_block, parse_review_json, provider_from_params, resolve_and_parse,
REVIEW_SYSTEM_PROMPT,
};
// AiSelfReviewNode 节点 — AI 自审闭环(决策 a 步骤③)
//
// 复用 AiNode 的 provider 调用逻辑(parse_params + build_provider + complete),差异:
// 1. prompt 固定四维度审查模板(需求符合度/完整性/正确性/边界),不从 config/inputs 取 prompt
// 2. system_prompt 强约束「只输出 JSON」+ temperature=0
// 3. LLM 输出按结构化 JSON 解析,失败兜底 verdict=unknown(防 LLM 不按要求输出)
// 4. 自审输入:task.description(需求,经 ctx.config["task_id"] 读 task) + task.output_json.text(产出)
// 5. 自审结果写回 task.output_json 加 review 子字段(不覆盖 text,保留对照)
// 6. review 摘要塞 NodeOutput.data,供下游 human_review 经 DAG inputs["ai_self_review"] 读
//
// verdict 处理(首版保守):自审是辅助,最终决策权在人。verdict=fail 不主动 Err,
// 节点返 Ok 让 human_review 继续,人在审批卡看到 fail 结论再定(human reject 走 Err→工作流
// failed→②-4 回调退回 in_review)。
/// AI 自审节点(决策 a 步骤③)
pub struct AiSelfReviewNode {
db: Arc<Database>,
}
impl AiSelfReviewNode {
pub fn new(db: Arc<Database>) -> Self {
Self { db }
}
/// 拼装自审 user prompt:任务需求 + 待审产出 + 四维度审查要求 + 输出格式。
/// description / output_text 缺失时给占位(不报错,信任调用方注入合法 task_id)。
fn build_review_prompt(description: &str, output_text: &str) -> String {
format!(
"\
## 任务需求
{description}
## 待审产出
{output_text}
## 审查维度
1. 需求符合度:产出是否覆盖需求描述的所有要点
2. 产出完整性:是否有遗漏、未完成的部分
3. 正确性:逻辑/事实/语法是否正确
4. 边界处理:异常输入、空值、错误路径是否考虑
## 输出格式(严格 JSON)
{{\"verdict\":\"pass\"|\"fail\",\"dimensions\":{{\"requirement_fit\":{{\"score\":0-10,\"issues\":[\"...\"]}},\"completeness\":{{\"score\":0-10,\"issues\":[\"...\"]}},\"correctness\":{{\"score\":0-10,\"issues\":[\"...\"]}},\"boundary\":{{\"score\":0-10,\"issues\":[\"...\"]}}}},\"summary\":\"一句话总结\",\"suggestions\":[\"改进建议\"]}}
verdict=fail 当且仅当任一维度 score<6 或有阻断性 issue。"
)
}
}
#[async_trait]
impl Node for AiSelfReviewNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult {
tracing::info!("AiSelfReviewNode 执行: node_id={}", ctx.node_id);
// FR-S1 注入链:provider 经 df_storage::secret 在 AiNode 内存解析,api_key 不进 config。
let p = resolve_and_parse(&self.db, &ctx.config, &ctx.inputs).await?;
// ── 读任务(需求 + 产出) ──
// task_id 必填(自审对象是具体任务的产出);缺失报错而非静默跳过。
let task_id = ctx
.config
.get("task_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("AiSelfReviewNode 缺少必填参数: task_id"))?;
let repo = TaskRepo::new(&self.db);
let task = repo
.get_by_id(task_id)
.await
.map_err(|e| anyhow::anyhow!("自审读任务失败: {}", e))?
.ok_or_else(|| anyhow::anyhow!("自审任务 {} 不存在", task_id))?;
// output_json 可能无(ai_execute 未跑或失败) → 待审产出占位提示,不报错。
let output_text = task
.output_json
.as_deref()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| v.get("text").and_then(|t| t.as_str()).map(String::from))
.unwrap_or_else(|| "(无产出,仅依据需求审查)".to_string());
let prompt = Self::build_review_prompt(&task.description, &output_text);
let provider: Box<dyn LlmProvider> = provider_from_params(&p);
let request = CompletionRequest {
model: p.model.clone(),
messages: vec![
ChatMessage::system(REVIEW_SYSTEM_PROMPT),
ChatMessage::user(prompt),
],
temperature: Some(0.0), // 强约束确定性输出
max_tokens: p.max_tokens,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
tracing::info!(
"AiSelfReviewNode 调用 LLM: model={}, base_url={}",
p.provider.default_model,
p.provider.base_url
);
let response = provider.complete(request).await?;
// ── 解析自审 JSON(失败兜底 verdict=unknown) ──
let review = parse_review_json(&response.text);
let verdict = review
.get("verdict")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
// summary / suggestions 在 move review 前先取走(供 NodeOutput.data 平铺)
let summary = review
.get("summary")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let suggestions = review
.get("suggestions")
.cloned()
.unwrap_or(serde_json::json!([]));
tracing::info!(
"AiSelfReviewNode 完成: verdict={}, model={}",
verdict,
response.model
);
// ── 写回 task.output_json 加 review 子字段(不覆盖 text) ──
// 读现有 output_json(若有),合并 review 子字段后整体写回。
let mut output_obj: serde_json::Map<String, serde_json::Value> = task
.output_json
.as_deref()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| v.as_object().cloned())
.unwrap_or_default();
// review 子字段:自审结论 + reviewed_at + reviewer_model(便于前端展示与人对照)
let mut review_with_meta = match review {
serde_json::Value::Object(m) => m,
other => {
let mut m = serde_json::Map::new();
m.insert("verdict".into(), other);
m
}
};
review_with_meta.insert(
"reviewed_at".into(),
serde_json::Value::Number(df_types::now_millis().into()),
);
review_with_meta.insert("reviewer_model".into(), serde_json::json!(response.model));
output_obj.insert("review".into(), serde_json::Value::Object(review_with_meta));
let merged = serde_json::Value::Object(output_obj);
if let Ok(json_str) = serde_json::to_string(&merged) {
if let Err(e) = repo.update_field(task_id, "output_json", &json_str).await {
tracing::warn!(
task_id = task_id,
error = %e,
"AiSelfReviewNode 落 review 子字段失败(摘要已在 NodeOutput,节点继续)"
);
}
}
// ── NodeOutput.data 塞 review 摘要,供下游 human_review 经 inputs["ai_self_review"] 读 ──
// human_review(HumanNode)从 config/inputs 拼 description;此处把 verdict/summary/suggestions
// 平铺进 data,HumanNode 零改动即可透传(前端/模板层可据此拼审批卡描述)。
let output = NodeOutput::from_value(serde_json::json!({
"verdict": verdict,
"summary": summary,
"suggestions": suggestions,
"review": merged.get("review").cloned().unwrap_or(serde_json::Value::Null),
"model": response.model,
}));
// ── 阶段3: 自审闸门(F-260616-07 决策 a 步骤③) ──
// config["gate"]==true 时,AiSelfReviewNode 从「自审辅助」升级为「DAG 节点闸门」:
// verdict="fail" → 返回 Err → executor first_err 中止后续层(下游 human_review 不跑)
// → 工作流 failed → ②-4 回调退回 in_review(对齐工作流失败语义)
// verdict="unknown" → 保持 Ok(LLM 输出不可靠时不阻断,人定权)
// verdict="pass" → Ok 放行下游
//
// 落库在前(已 update_field output_json),故闸门 fail 时 task.output_json.review
// 已含结论,前端/二次工作流可读自审详情(不丢失审查痕迹)。
//
// 设计选型(三方案对比,见 commit/设计文档):
// A) AiNode 内部门控(本方案):1 处 return Err,复用 executor first_err + ②-4 回调,
// 零 executor 核心改动,零依赖暂缓的条件引擎(T-260614-11)。最简。
// B) DAG edges 条件 + ConditionEngine 求值:依赖 T-260614-11(暂缓),executor 当前
// 完全不评估 edge.condition(topological_layers 无条件收录所有边),须先做条件
// 引擎 Phase1+2 才能用 → 拆波。
// C) executor 闸门检查钩子(节点 execute 后 executor 读 output.verdict):改 DagExecutor
// 核心循环,牵动所有节点,风险/范围不符「最简不破坏」。
// 选 A:语义最贴近「自审结果作为闸门」(自审节点自行决定放行/阻断),且 gate 可按节点
// config 开关(默认 false = 阶段2 行为不变,模板/前端零强制改动,向后兼容)。
let gate_enabled = ctx
.config
.get("gate")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if gate_should_block(gate_enabled, &verdict) {
tracing::info!(
node_id = %ctx.node_id,
task_id,
"AiSelfReviewNode 闸门触发: verdict=fail, 阻断下游(工作流将 failed)"
);
return Err(anyhow::anyhow!(
"AI 自审闸门未通过(verdict=fail): {}",
if summary.is_empty() { "自审结论 fail" } else { &summary }
));
}
Ok(output)
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"task_id": { "type": "string", "description": "自审目标任务 ID(必填)" },
"provider_id": { "type": "string", "description": "AI Provider ID(FR-S1:密钥经 secret 解析不进 config;留空走默认 provider)" },
"model": { "type": "string", "description": "模型名(可选,留空用 record.default_model)" },
"max_tokens": { "type": "integer" },
"gate": { "type": "boolean", "description": "阶段3 闸门开关:false(默认)=自审辅助,verdict 仅透传展示;true=自审结果作 DAG 闸门,verdict=fail 返回 Err 阻断下游(工作流 failed → ②-4 退回),verdict=unknown/pass 放行" }
},
"required": ["task_id", "provider_id"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"verdict": { "type": "string", "enum": ["pass", "fail", "unknown"] },
"summary": { "type": "string" },
"suggestions": { "type": "array", "items": { "type": "string" } },
"review": { "type": "object" },
"model": { "type": "string" }
}
}),
}
}
fn node_type(&self) -> &str {
"ai_self_review"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ai_node_helpers::{gate_should_block, parse_review_json, truncate_for_summary};
use df_storage::crud::{ProjectRepo, TaskRepo};
use df_storage::db::Database;
use df_storage::models::{ProjectRecord, TaskRecord};
use serde_json::json;
// ============================================================
// 决策 a 自审闭环单测 — output_json 写回 + review JSON 解析兜底
// ============================================================
/// 内存 DB 构造 helper:插父项目 + 指定 task,返回 (db, task_id)。
async fn setup_task_db(task_output_json: Option<&str>) -> (Database, String) {
let db = Database::open_in_memory().await.expect("open_in_memory");
ProjectRepo::new(&db)
.insert(ProjectRecord {
id: "p1".to_string(),
name: "proj".to_string(),
description: "".to_string(),
status: "planning".to_string(),
idea_id: None,
path: None,
stack: None,
created_at: "0".to_string(),
updated_at: "0".to_string(),
})
.await
.expect("insert project");
TaskRepo::new(&db)
.insert(TaskRecord {
id: "t1".to_string(),
project_id: "p1".to_string(),
title: "t1".to_string(),
description: "实现登录接口".to_string(),
status: "testing".to_string(),
priority: 2,
branch_name: None,
assignee: None,
workflow_def_id: None,
base_branch: None,
review_rounds: 0,
output_json: task_output_json.map(String::from),
idea_id: None,
created_at: "0".to_string(),
updated_at: "0".to_string(),
})
.await
.expect("insert task");
(db, "t1".to_string())
}
/// 决策 a 步骤②:AiNode.execute 落产出验证 — 有 task_id 时 output_json 写入。
/// 用 mock provider 避免真调 LLM:自定义 Node 替换不便,改为直接验证 update_field 路径
/// (execute 内部调 provider.complete 需真 LLM,此处降级验证 parse_review_json 兜底 +
/// 直接 repo.update_field 路径,与 execute 落库逻辑同一条 crud.update_field)。
#[tokio::test]
async fn update_field_writes_output_json() {
// 验证 output_json 在 tasks 白名单(update_field 不报「不允许的字段名」)。
// 这正是 AiNode.execute / AiSelfReviewNode.execute 落产出的底层路径。
let (db, task_id) = setup_task_db(None).await;
let repo = TaskRepo::new(&db);
let payload = r#"{"text":"产出内容","model":"glm-4","usage":{}}"#;
let ok = repo
.update_field(&task_id, "output_json", payload)
.await
.expect("update_field output_json");
assert!(ok, "update_field 应返回 affected>0=true");
let after = repo.get_by_id(&task_id).await.unwrap().unwrap();
assert_eq!(
after.output_json.as_deref(),
Some(payload),
"output_json 应被写入"
);
// 原始 text 字段(产出对照)保留
let parsed: serde_json::Value = serde_json::from_str(after.output_json.as_deref().unwrap()).unwrap();
assert_eq!(parsed["text"], json!("产出内容"));
}
/// 步骤③:parse_review_json 合规 JSON 直通(保留 verdict/dimensions/summary/suggestions)。
#[test]
fn parse_review_json_valid_passes_through() {
let raw = r#"{"verdict":"pass","dimensions":{"requirement_fit":{"score":9,"issues":[]}},"summary":"符合需求","suggestions":["可加单测"]}"#;
let v = parse_review_json(raw);
assert_eq!(v["verdict"], json!("pass"));
assert_eq!(v["summary"], json!("符合需求"));
assert_eq!(v["suggestions"], json!(["可加单测"]));
assert_eq!(v["dimensions"]["requirement_fit"]["score"], json!(9));
}
/// 步骤③:parse_review_json 非 JSON / 缺 verdict → 兜底 verdict=unknown + summary 含原文。
#[test]
fn parse_review_json_invalid_falls_back_unknown() {
// 非 JSON
let v = parse_review_json("这不是 JSON,LLM 没按要求输出");
assert_eq!(v["verdict"], json!("unknown"));
assert!(v["summary"].as_str().unwrap().contains("不是 JSON"));
assert!(v["summary"].as_str().unwrap().contains("解析失败"));
// JSON 但缺 verdict
let v = parse_review_json(r#"{"foo":"bar"}"#);
assert_eq!(v["verdict"], json!("unknown"));
// JSON 非 object(数组)
let v = parse_review_json("[1,2,3]");
assert_eq!(v["verdict"], json!("unknown"));
}
/// 步骤③:LLM 偶尔包 ```json 代码块,parse_review_json 应剥离后解析成功。
#[test]
fn parse_review_json_strips_markdown_code_fence() {
let raw = "```json\n{\"verdict\":\"fail\",\"summary\":\"缺边界处理\"}\n```";
let v = parse_review_json(raw);
assert_eq!(v["verdict"], json!("fail"));
assert_eq!(v["summary"], json!("缺边界处理"));
}
/// 步骤③:truncate_for_summary 长文截断。
#[test]
fn truncate_for_summary_long_text() {
let short = "短文";
assert_eq!(truncate_for_summary(short), "短文");
let long: String = "字".repeat(500);
let t = truncate_for_summary(&long);
assert!(t.ends_with("…"));
assert!(t.chars().count() <= 302, "截断后含省略号应 ≈300 字");
}
/// 步骤③:AiSelfReviewNode.build_review_prompt 含需求 + 产出 + 四维度。
#[test]
fn build_review_prompt_contains_inputs() {
let p = AiSelfReviewNode::build_review_prompt("登录接口", "已实现 /login");
assert!(p.contains("登录接口"), "prompt 应含需求");
assert!(p.contains("已实现 /login"), "prompt 应含产出");
assert!(p.contains("需求符合度"), "prompt 应含四维度");
assert!(p.contains("边界处理"));
assert!(p.contains("verdict"), "prompt 应含输出格式约束");
}
// ============================================================
// 阶段3 自审闸门(gate_should_block)单测
// ============================================================
//
// gate 决策矩阵:
// gate=false(默认,阶段2 行为) → 任何 verdict 都放行(辅助模式)
// gate=true + verdict=pass → 放行
// gate=true + verdict=unknown → 放行(LLM 不可靠时不阻断,人定权)
// gate=true + verdict=fail → 阻断(返回 Err,工作流 failed)
#[test]
fn gate_disabled_never_blocks_any_verdict() {
// gate 默认 false(阶段2 兼容):无论 verdict 如何都不阻断,自审仅辅助展示
assert!(!gate_should_block(false, "fail"), "gate 关闭时 fail 也不阻断");
assert!(!gate_should_block(false, "pass"), "gate 关闭时 pass 放行");
assert!(!gate_should_block(false, "unknown"), "gate 关闭时 unknown 放行");
}
#[test]
fn gate_enabled_blocks_only_on_fail() {
// gate 开启:仅 fail 阻断
assert!(gate_should_block(true, "fail"), "gate 开 + fail 应阻断");
assert!(!gate_should_block(true, "pass"), "gate 开 + pass 放行");
assert!(
!gate_should_block(true, "unknown"),
"gate 开 + unknown 放行(LLM 不可靠时保人定权)"
);
}
#[test]
fn gate_unknown_verdict_does_not_block() {
// 防御:verdict 非标准值(如解析异常)不应触发阻断(对齐 unknown 保守不阻断语义)
assert!(
!gate_should_block(true, "weird"),
"非标准 verdict 不应阻断(对齐 unknown 保守)"
);
assert!(!gate_should_block(true, ""), "空 verdict 不应阻断");
}
}
-41
View File
@@ -1,41 +0,0 @@
//! Docker 节点 — 在容器中执行任务
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// Docker 节点
pub struct DockerNode;
#[async_trait]
impl Node for DockerNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 接入 df-execute 的 Docker 执行器
tracing::info!("DockerNode 执行: 在容器中运行");
Ok(NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"image": { "type": "string" },
"command": { "type": "string" },
"env": { "type": "object" }
},
"required": ["image"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"stdout": { "type": "string" },
"exit_code": { "type": "integer" }
}
}),
}
}
fn node_type(&self) -> &str {
"docker"
}
}
-41
View File
@@ -1,41 +0,0 @@
//! Git 节点 — 执行 Git 操作(克隆、提交、推送、合并等)
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// Git 节点
pub struct GitNode;
#[async_trait]
impl Node for GitNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 接入 df-execute 的 Git 操作
tracing::info!("GitNode 执行: Git 操作");
Ok(NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"action": { "type": "string", "enum": ["clone", "commit", "push", "merge", "checkout"] },
"repo": { "type": "string" },
"branch": { "type": "string" }
},
"required": ["action"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"success": { "type": "boolean" },
"message": { "type": "string" }
}
}),
}
}
fn node_type(&self) -> &str {
"git"
}
}
-43
View File
@@ -1,43 +0,0 @@
//! HTTP 节点 — 发起 HTTP 请求
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// HTTP 节点
pub struct HttpNode;
#[async_trait]
impl Node for HttpNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 实现HTTP请求逻辑
tracing::info!("HttpNode 执行: 发送 HTTP 请求");
Ok(NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"method": { "type": "string", "enum": ["GET", "POST", "PUT", "DELETE"] },
"url": { "type": "string" },
"headers": { "type": "object" },
"body": {}
},
"required": ["method", "url"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"status": { "type": "integer" },
"body": {},
"headers": { "type": "object" }
}
}),
}
}
fn node_type(&self) -> &str {
"http"
}
}
+767 -32
View File
@@ -1,10 +1,17 @@
//! 人工审批节点 — 阻塞工作流,等待人工确认或输入
//!
//! 拒绝语义化关键字判定等纯逻辑见 `human_node_helpers`(本文件只保留
//! HumanNode 的 struct + impl,impl 块约束)。
use async_trait::async_trait;
use std::time::Duration;
use tokio::time;
use tokio::sync::broadcast;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
use df_core::events::WorkflowEvent;
use df_types::events::{SelectType, WorkflowEvent};
// 抽离的纯函数/常量(glob 引入,tests `use super::*` 间接可见)
#[allow(unused_imports)]
use crate::human_node_helpers::{contains_reject, is_reject_decision};
/// 人工审批节点(阻塞节点)
pub struct HumanNode;
@@ -21,44 +28,131 @@ impl Node for HumanNode {
.and_then(|v| v.as_str())
.unwrap_or("");
let options = config.get("options")
let options: Vec<String> = config.get("options")
.and_then(|v| v.as_array())
.map(|arr| arr.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>())
.unwrap_or_else(|| vec!["同意", "拒绝"]);
.map(String::from)
.collect())
.unwrap_or_else(|| vec!["同意".into(), "拒绝".into()]);
// 发送人工审批请求到事件总线
let _ = ctx.event_bus.send(WorkflowEvent::HumanApprovalRequest {
execution_id: ctx.execution_id.clone(),
node_id: ctx.node_id.clone(),
title: title.to_string(),
description: description.to_string(),
options: options.iter().map(|s| s.to_string()).collect(),
});
let timeout_secs = config.get("timeout_secs")
.and_then(|v| v.as_u64())
.unwrap_or(3600);
// 等待用户响应(最多等待 1 小时)
let timeout = Duration::from_secs(3600);
let start_time = std::time::Instant::now();
// F-260615-01: 解析 select_type(缺省 Single,向后兼容)。非 "multiple" 一律按 Single 处理。
let select_type = match config.get("select_type").and_then(|v| v.as_str()) {
Some("multiple") => SelectType::Multiple,
_ => SelectType::Single,
};
// 1. 先订阅,再发请求 —— broadcast 不回放历史消息,
// 若 subscribe 晚于 send,Response 会在 receiver 建位前发出而丢失,节点死等到超时。
let mut rx = ctx.event_bus.subscribe();
// 2. 发送人工审批请求到事件总线
// send 是 async fn(broadcast 同步发但 async 包装),必须 await 否则 Future 不 poll、Request 不进 channel(B-03b-R6)
let _ = ctx.event_bus
.send(WorkflowEvent::HumanApprovalRequest {
execution_id: ctx.execution_id.clone(),
node_id: ctx.node_id.clone(),
title: title.to_string(),
description: description.to_string(),
options: options.clone(),
select_type,
})
.await;
// 3. select! 循环:Response / 超时 / 取消
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
let mut cancel_tick = tokio::time::interval(Duration::from_millis(500));
cancel_tick.tick().await; // 丢弃首个立即触发的 tick
loop {
// 检查是否超时
if start_time.elapsed() > timeout {
return Err(anyhow::anyhow!("人工审批超时"));
tokio::select! {
recv = rx.recv() => match recv {
Ok(WorkflowEvent::HumanApprovalResponse {
execution_id, node_id, decision, decisions, comment,
}) if execution_id == ctx.execution_id && node_id == ctx.node_id => {
// F-260615-01: 归一化决策集合(优先用 decisions 数组,空则回退兼容 decision 单值)
// select_type=Single → 决策数必须 =1
// select_type=Multiple → 决策数必须 ≥1
// options 空 → 允许自由文本(仅受数量约束);
// options 非空 → 每项必须 ∈ options
// 兼容旧调用方:decisions 空但 decision 非空时,按 [decision] 单值处理。
let mut picked: Vec<String> = decisions;
if picked.is_empty() && !decision.is_empty() {
picked.push(decision.clone());
}
let count_ok = match select_type {
SelectType::Single => picked.len() == 1,
SelectType::Multiple => !picked.is_empty(),
};
let each_valid = picked.iter().all(|d| !d.is_empty())
&& (options.is_empty() || picked.iter().all(|d| options.contains(d)));
if count_ok && each_valid {
// F-260616-06 阶段2: 拒绝语义化。
// 审批拒绝此前与同意一样返 Ok —— 语义反转(审批被拒却报"成功"),
// 下游无法据 failed 触发退回/重做。
// 现:decision 命中拒绝关键字(见 REJECT_KEYWORDS)→ 返 Err
// "人工审批被拒绝(用户选择: <decision>)",executor Err 分支 set_failed
// → 工作流 failed 状态 → 阶段2 推进链可据 failed 触发退回。
// 行为变更:审批拒绝从 Ok → Err,标注(同步通知主代理)。
if contains_reject(&picked) {
let primary = picked.first().cloned().unwrap_or_default();
let comment_str = comment.unwrap_or_default();
return Err(anyhow::anyhow!(
"人工审批被拒绝(用户选择: {}){}",
primary,
if comment_str.is_empty() {
String::new()
} else {
format!(";意见: {}", comment_str)
}
));
}
// 输出统一含 decisions 数组;保留 decision 取首项(向后兼容下游消费者)
let primary = picked.first().cloned().unwrap_or_default();
return Ok(NodeOutput::from_value(serde_json::json!({
"decision": primary,
"decisions": picked,
"comment": comment.unwrap_or_default(),
})));
}
tracing::warn!(
node_id = %ctx.node_id,
execution_id = %ctx.execution_id,
select_type = ?select_type,
decision = %decision,
decisions = ?picked,
options = ?options,
"审批决策非法,忽略并继续等待下一条合法 Response(由超时兜底)"
);
continue;
}
Ok(_) => continue, // 其他节点/类型的事件,忽略
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(
"HumanNode {} 漏收 {} 条事件(可能错过自身响应,继续等待)",
ctx.node_id, n
);
// 容量 256 + 审批低频,漏自身 Response 概率极低;
// 丢弃则可能误判超时,故 continue。
continue;
}
Err(broadcast::error::RecvError::Closed) => {
return Err(anyhow::anyhow!("事件总线关闭,审批无法完成"));
}
},
_ = tokio::time::sleep_until(deadline) => {
return Err(anyhow::anyhow!("人工审批超时({}s)", timeout_secs));
}
_ = cancel_tick.tick() => {
if ctx.node_status.is_cancelled(&ctx.node_id) {
return Err(anyhow::anyhow!("人工审批被取消"));
}
}
}
// 检查节点是否被取消
if ctx.node_status.is_cancelled(&ctx.node_id) {
return Err(anyhow::anyhow!("人工审批被取消"));
}
// TODO: 检查审批响应(需要前端实现)
// 目前直接返回同意
let output = serde_json::json!({
"decision": "同意",
"comment": "",
});
return Ok(NodeOutput::from_value(output));
}
}
@@ -72,6 +166,11 @@ impl Node for HumanNode {
"options": {
"type": "array",
"items": { "type": "string" }
},
"select_type": {
"type": "string",
"enum": ["single", "multiple"],
"default": "single"
}
},
"required": ["title"]
@@ -80,6 +179,7 @@ impl Node for HumanNode {
"type": "object",
"properties": {
"decision": { "type": "string" },
"decisions": { "type": "array", "items": { "type": "string" } },
"comment": { "type": "string" }
}
}),
@@ -94,3 +194,638 @@ impl Node for HumanNode {
"human"
}
}
#[cfg(test)]
mod tests {
use super::*;
use df_types::types::NodeStatus;
use df_workflow::dag::Dag;
use df_workflow::eventbus::EventBus;
use df_workflow::executor::DagExecutor;
use df_workflow::state::StateMachine;
use serde_json::json;
use std::collections::HashMap;
use std::time::Duration;
/// 构造 NodeContext:自定义 execution_id / node_id / config,共享同一 EventBus。
fn make_ctx(
event_bus: &EventBus,
execution_id: &str,
node_id: &str,
config: serde_json::Value,
) -> NodeContext {
NodeContext {
node_id: node_id.to_string(),
inputs: HashMap::new(),
config,
execution_id: execution_id.to_string(),
event_bus: event_bus.clone(),
node_status: StateMachine::new(),
}
}
/// 发一条审批响应到事件总线(模拟前端 approve_human_approval IPC 走完后的链路)。
/// F-260615-01: 单选调用方仅填 decision;多选调用方填 decisions。
async fn send_response(
event_bus: &EventBus,
execution_id: &str,
node_id: &str,
decision: &str,
comment: Option<&str>,
) {
event_bus
.send(WorkflowEvent::HumanApprovalResponse {
execution_id: execution_id.to_string(),
node_id: node_id.to_string(),
decision: decision.to_string(),
decisions: vec![],
comment: comment.map(String::from),
})
.await;
}
/// F-260615-01: 多选响应发送助手(填 decisions 数组,decision 留空)
async fn send_response_multi(
event_bus: &EventBus,
execution_id: &str,
node_id: &str,
decisions: &[&str],
comment: Option<&str>,
) {
event_bus
.send(WorkflowEvent::HumanApprovalResponse {
execution_id: execution_id.to_string(),
node_id: node_id.to_string(),
decision: String::new(),
decisions: decisions.iter().map(|s| s.to_string()).collect(),
comment: comment.map(String::from),
})
.await;
}
#[tokio::test]
async fn normal_approval_returns_decision() {
// 正常审批:发匹配(exec_id+node_id) Response → 返回该 decision
let bus = EventBus::new();
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({}));
let bus_clone = bus.clone();
let handle = tokio::spawn(async move {
HumanNode.execute(ctx).await
});
// 给 execute 一点时间 subscribe + send Request
tokio::time::sleep(Duration::from_millis(50)).await;
send_response(&bus_clone, "exec-1", "node-h", "同意", Some("好的")).await;
let out = handle.await.unwrap().unwrap();
assert_eq!(out.data["decision"], json!("同意"));
assert_eq!(out.data["comment"], json!("好的"));
}
#[tokio::test]
async fn request_is_emitted_to_bus() {
// B-03b-R8: 验证 HumanNode 真发出 HumanApprovalRequest 到事件总线
// (R6 修复前 execute 内 event_bus.send().await 缺 await → Request 未进 channel,此测会超时失败)
let bus = EventBus::new();
let mut rx = bus.subscribe(); // subscribe 先于 execute send(broadcast 不回放)
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "timeout_secs": 1 }));
let bus_clone = bus.clone();
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
// execute send Request 后 rx 应收到
let received = tokio::time::timeout(Duration::from_millis(500), rx.recv()).await;
assert!(
received.is_ok(),
"应收到 HumanApprovalRequest(R6 Request 真发出),实际超时"
);
match received.unwrap().unwrap() {
WorkflowEvent::HumanApprovalRequest {
execution_id,
node_id,
title,
..
} => {
assert_eq!(execution_id, "exec-1");
assert_eq!(node_id, "node-h");
assert_eq!(title, "请确认");
}
other => panic!("期望 HumanApprovalRequest,收到 {:?}", other),
}
// 发 Response 让 execute 正常返回(防 task 泄漏)
send_response(&bus_clone, "exec-1", "node-h", "同意", None).await;
let _ = handle.await;
}
#[tokio::test]
async fn mismatched_execution_id_filtered_then_timeout() {
// execution_id 不匹配:Response 被过滤,短超时验证 → Err 超时
let bus = EventBus::new();
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "timeout_secs": 1 }));
let bus_clone = bus.clone();
let handle = tokio::spawn(async move {
HumanNode.execute(ctx).await
});
tokio::time::sleep(Duration::from_millis(50)).await;
// 错配的 execution_id
send_response(&bus_clone, "exec-OTHER", "node-h", "同意", None).await;
let err = handle.await.unwrap().unwrap_err().to_string();
assert!(err.contains("超时"), "不匹配 exec_id 应被过滤后超时, 实际: {}", err);
}
#[tokio::test]
async fn mismatched_node_id_filtered_then_timeout() {
// node_id 不匹配:Response 被过滤,短超时验证 → Err 超时
let bus = EventBus::new();
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "timeout_secs": 1 }));
let bus_clone = bus.clone();
let handle = tokio::spawn(async move {
HumanNode.execute(ctx).await
});
tokio::time::sleep(Duration::from_millis(50)).await;
// 错配的 node_id
send_response(&bus_clone, "exec-1", "node-OTHER", "同意", None).await;
let err = handle.await.unwrap().unwrap_err().to_string();
assert!(err.contains("超时"), "不匹配 node_id 应被过滤后超时, 实际: {}", err);
}
#[tokio::test]
async fn timeout_when_no_response() {
// 无 Response → sleep_until(deadline) 触发超时
let bus = EventBus::new();
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "timeout_secs": 1 }));
let err = HumanNode.execute(ctx).await.unwrap_err().to_string();
assert!(err.contains("超时"), "应报超时, 实际: {}", err);
assert!(err.contains("1s"), "超时消息应带秒数, 实际: {}", err);
}
#[tokio::test]
async fn invalid_decision_ignored_then_timeout() {
// R-P1-6: options 非空且 decision ∉ options → 不立即 Err(一次手误不应杀死节点),
// warn 记录 + continue 续等下一条合法 Response;此处仅发一条非法的,短超时验证 → Err 超时。
let bus = EventBus::new();
let ctx = make_ctx(
&bus,
"exec-1",
"node-h",
json!({ "options": ["同意", "拒绝"], "timeout_secs": 1 }),
);
let bus_clone = bus.clone();
let handle = tokio::spawn(async move {
HumanNode.execute(ctx).await
});
tokio::time::sleep(Duration::from_millis(50)).await;
send_response(&bus_clone, "exec-1", "node-h", "随便", None).await;
let err = handle.await.unwrap().unwrap_err().to_string();
assert!(err.contains("超时"), "非法 decision 应被忽略后超时, 实际: {}", err);
}
#[tokio::test]
async fn empty_options_allows_free_text() {
// options 空 → 允许任意自由文本 decision(不校验)
let bus = EventBus::new();
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "options": [] }));
let bus_clone = bus.clone();
let handle = tokio::spawn(async move {
HumanNode.execute(ctx).await
});
tokio::time::sleep(Duration::from_millis(50)).await;
send_response(&bus_clone, "exec-1", "node-h", "改成先做B方案", None).await;
let out = handle.await.unwrap().unwrap();
assert_eq!(
out.data["decision"],
json!("改成先做B方案"),
"options 空应允许自由文本 decision"
);
}
#[tokio::test]
async fn empty_decision_ignored_then_timeout_even_with_empty_options() {
// R-P1-6: 空 decision 始终非法,但同样不立即 Err——warn + continue 续等,短超时验证 → Err 超时。
let bus = EventBus::new();
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "options": [], "timeout_secs": 1 }));
let bus_clone = bus.clone();
let handle = tokio::spawn(async move {
HumanNode.execute(ctx).await
});
tokio::time::sleep(Duration::from_millis(50)).await;
send_response(&bus_clone, "exec-1", "node-h", "", None).await;
let err = handle.await.unwrap().unwrap_err().to_string();
assert!(err.contains("超时"), "空 decision 应被忽略后超时, 实际: {}", err);
}
// Closed 分支覆盖:execute 持有的 ctx 含 EventBus clone(即一个 sender),
// 在 execute 运行期间通道无法 Closed(至少该 sender 存活)。
// 该分支仅在所有 sender drop 后可达,需独立集成测试,此处不强造。
// ===== B-03b-R8: executor 级端到端集成测试 =====
// 上述单测均为 HumanNode.execute 直接调用级,不覆盖 executor 驱动含 human 的 DAG。
// 端到端覆盖 NodeStarted/Completed 事件流转、共享 state_machine、Request 经 executor 驱动真发出、
// outputs 收集,是 R6(send 缺 await)/R7(契约失配)的存活土壤。
/// 简单测试节点:sleep 指定毫秒后返回空输出(仿 executor.rs SleepNode,作 human 的前驱)
struct SleepNode {
sleep_ms: u64,
}
#[async_trait]
impl Node for SleepNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
tokio::time::sleep(Duration::from_millis(self.sleep_ms)).await;
Ok(NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::Value::Null,
output: serde_json::Value::Null,
}
}
fn node_type(&self) -> &str {
"sleep"
}
}
/// B-03b-R8: executor 驱动 a(SleepNode) → b(HumanNode) 两层 DAG 端到端。
/// a 完成 → b 阻塞等审批 → 外部发 Response(模拟 approve_human_approval IPC) → b Ok → 工作流完成。
/// 验证:Request 经 executor 驱动真发出(R6)、outputs 收集两层、共享 state_machine 两节点皆 Completed。
#[tokio::test]
async fn end_to_end_human_approval_completes_workflow() {
let bus = EventBus::new();
let mut rx = bus.subscribe(); // subscribe 先于 executor send(broadcast 不回放)
let exec_id = "exec-e2e-1";
let mut dag = Dag::new();
dag.add_node("a".to_string(), Box::new(SleepNode { sleep_ms: 10 }));
dag.add_node("b".to_string(), Box::new(HumanNode));
dag.add_edge("a".to_string(), "b".to_string());
let mut executor = DagExecutor::new(bus.clone(), exec_id.to_string());
let sm = executor.state_machine(); // 共享状态机(spawn 后仍可读)
let run_handle = tokio::spawn(async move {
executor
.run(
&dag,
json!({ "title": "确认发布", "options": ["同意", "拒绝"], "timeout_secs": 5 }),
)
.await
});
// 等 a 层完成、b 层 human subscribe + send Request。
// executor 先发 NodeStarted(a)/NodeStarted(b),再发 human 的 Request —— 过滤跳过非 Request 事件
let request = tokio::time::timeout(Duration::from_millis(1000), async {
loop {
if let Ok(ev @ WorkflowEvent::HumanApprovalRequest { .. }) = rx.recv().await {
return ev;
}
// NodeStarted/NodeCompleted 等其他事件,继续等
}
})
.await;
assert!(
request.is_ok(),
"应收到 b 的 HumanApprovalRequest(R6 端到端),实际超时"
);
match request.unwrap() {
WorkflowEvent::HumanApprovalRequest { node_id, title, options, .. } => {
assert_eq!(node_id, "b");
assert_eq!(title, "确认发布");
assert_eq!(options, vec!["同意".to_string(), "拒绝".to_string()]);
}
other => panic!("期望 HumanApprovalRequest,收到 {:?}", other),
}
// 外部 IPC 模拟:审批通过
send_response(&bus, exec_id, "b", "同意", Some("可以发布")).await;
let outputs = run_handle.await.unwrap().unwrap();
assert!(outputs.contains_key("a"), "outputs 应含前驱节点 a");
assert_eq!(outputs["b"].data["decision"], json!("同意"));
assert_eq!(outputs["b"].data["decisions"], json!(["同意"]));
assert_eq!(outputs["b"].data["comment"], json!("可以发布"));
assert_eq!(sm.get(&"a".to_string()), NodeStatus::Completed);
assert_eq!(sm.get(&"b".to_string()), NodeStatus::Completed);
}
/// B-03b-R8: executor 驱动 human DAG,外部 set_cancelled(模拟 cancel_workflow_node IPC)
/// → human cancel_tick 命中 is_cancelled → Err → executor 跳过 set_failed(Cancelled 已终态,R1)
/// → run 返回取消 Err + 状态保持 Cancelled。覆盖取消在真实 HumanNode 上的端到端(R2)。
#[tokio::test]
async fn end_to_end_human_approval_cancelled() {
let bus = EventBus::new();
let mut rx = bus.subscribe(); // subscribe 先于 executor send
let exec_id = "exec-e2e-cancel";
let mut dag = Dag::new();
dag.add_node("h".to_string(), Box::new(HumanNode));
let mut executor = DagExecutor::new(bus.clone(), exec_id.to_string());
let sm = executor.state_machine();
let run_handle = tokio::spawn(async move {
executor.run(&dag, json!({ "timeout_secs": 5 })).await
});
// 等 human 发出 Request(确认已 subscribe + send + 进入 select! 轮询)
let saw_request = tokio::time::timeout(Duration::from_millis(1000), async {
while !matches!(rx.recv().await, Ok(WorkflowEvent::HumanApprovalRequest { .. })) {}
})
.await
.is_ok();
assert!(saw_request, "human 应先发出 HumanApprovalRequest");
// 外部 IPC 模拟:取消节点(写共享 state_machine,human cancel_tick 会读到)
sm.set_cancelled("h".to_string());
let result = run_handle.await.unwrap();
assert!(result.is_err(), "取消应致 run 返回 Err");
let err = result.unwrap_err().to_string();
// executor 把节点 Err 包成 "节点 h 执行失败"(context),原 "人工审批被取消" 在 source chain
assert!(
err.contains("取消") || err.contains("执行失败"),
"应返回取消相关错误,实际: {}",
err
);
// 状态保持 Cancelled(未被 set_failed 覆盖为 Failed)—— R1 修法端到端验证
assert_eq!(sm.get(&"h".to_string()), NodeStatus::Cancelled);
}
// ===== F-260615-01: 多选审批覆盖 =====
/// F-260615-01: select_type=multiple + 多 decisions(均∈options) → 返回 decisions 数组
#[tokio::test]
async fn multiple_select_returns_decisions_array() {
let bus = EventBus::new();
let ctx = make_ctx(
&bus,
"exec-multi",
"node-h",
json!({
"options": ["A", "B", "C"],
"select_type": "multiple"
}),
);
let bus_clone = bus.clone();
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
tokio::time::sleep(Duration::from_millis(50)).await;
send_response_multi(&bus_clone, "exec-multi", "node-h", &["A", "C"], Some("多选")).await;
let out = handle.await.unwrap().unwrap();
assert_eq!(out.data["decision"], json!("A"), "decision 取首项");
assert_eq!(out.data["decisions"], json!(["A", "C"]));
assert_eq!(out.data["comment"], json!("多选"));
}
/// F-260615-01: select_type=single 缺省 + decisions 多个 → 校验失败(count!=1)忽略后超时
#[tokio::test]
async fn single_select_rejects_multiple_decisions_then_timeout() {
let bus = EventBus::new();
let ctx = make_ctx(
&bus,
"exec-single-multi",
"node-h",
json!({ "options": ["A", "B"], "timeout_secs": 1 }), // 缺省 single
);
let bus_clone = bus.clone();
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
tokio::time::sleep(Duration::from_millis(50)).await;
// single 模式却发多个 → 非法
send_response_multi(&bus_clone, "exec-single-multi", "node-h", &["A", "B"], None).await;
let err = handle.await.unwrap().unwrap_err().to_string();
assert!(err.contains("超时"), "single 下多 decisions 应被忽略后超时, 实际: {}", err);
}
/// F-260615-01: select_type=multiple 但 decisions 含 ∉ options 的项 → 非法忽略后超时
#[tokio::test]
async fn multiple_select_invalid_option_ignored_then_timeout() {
let bus = EventBus::new();
let ctx = make_ctx(
&bus,
"exec-multi-bad",
"node-h",
json!({
"options": ["A", "B"],
"select_type": "multiple",
"timeout_secs": 1
}),
);
let bus_clone = bus.clone();
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
tokio::time::sleep(Duration::from_millis(50)).await;
// 一项合法一项不合法 → 整批非法
send_response_multi(&bus_clone, "exec-multi-bad", "node-h", &["A", "X"], None).await;
let err = handle.await.unwrap().unwrap_err().to_string();
assert!(err.contains("超时"), "含非法 option 应被忽略后超时, 实际: {}", err);
}
/// F-260615-01: 兼容旧调用方 —— 不填 select_type(缺省 single) + 只填 decision 单值,应正常通过
/// (即所有未改造的现有 Request 均按 single 解析,零改动)
#[tokio::test]
async fn default_single_with_legacy_decision_single_value() {
let bus = EventBus::new();
// 注意:不传 select_type,验证缺省=Single
let ctx = make_ctx(&bus, "exec-leg", "node-h", json!({ "options": ["同意", "拒绝"] }));
let bus_clone = bus.clone();
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
tokio::time::sleep(Duration::from_millis(50)).await;
// 旧链路只填 decision,decisions 为空 → HumanNode 兼容回退
send_response(&bus_clone, "exec-leg", "node-h", "同意", None).await;
let out = handle.await.unwrap().unwrap();
assert_eq!(out.data["decision"], json!("同意"));
assert_eq!(out.data["decisions"], json!(["同意"]), "兼容回退后 decisions 应含单值");
}
// ===== F-260616-06 阶段2: 审批拒绝语义化(行为变更: 拒绝从 Ok → Err) =====
/// F-260616-06: 默认 options `["同意","拒绝"]` 下选「拒绝」→ Err(不再 Ok)。
/// 阶段2 推进链依赖工作流 failed 触发退回,故拒绝必须让节点返 Err → executor set_failed。
#[tokio::test]
async fn reject_decision_returns_error() {
let bus = EventBus::new();
let ctx = make_ctx(
&bus,
"exec-reject",
"node-h",
json!({ "options": ["同意", "拒绝"] }),
);
let bus_clone = bus.clone();
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
tokio::time::sleep(Duration::from_millis(50)).await;
send_response(&bus_clone, "exec-reject", "node-h", "拒绝", Some("方案不行")).await;
let err = handle.await.unwrap().unwrap_err().to_string();
assert!(
err.contains("拒绝") && err.contains("人工审批"),
"拒绝 decision 应返 Err「人工审批被拒绝...」, 实际: {}",
err
);
assert!(
err.contains("方案不行"),
"拒绝 Err 应携带 comment, 实际: {}",
err
);
}
/// F-260616-06: 同一 options 下选「同意」→ Ok(通过路径不回归)。
#[tokio::test]
async fn approve_decision_still_ok() {
let bus = EventBus::new();
let ctx = make_ctx(
&bus,
"exec-approve",
"node-h",
json!({ "options": ["同意", "拒绝"] }),
);
let bus_clone = bus.clone();
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
tokio::time::sleep(Duration::from_millis(50)).await;
send_response(&bus_clone, "exec-approve", "node-h", "同意", None).await;
let out = handle.await.unwrap().unwrap();
assert_eq!(out.data["decision"], json!("同意"));
}
/// F-260616-06: 英文 reject 关键字同样识别为拒绝 → Err(归一化大小写/空白)。
/// 多关键字覆盖走 reject_keyword_detection_normalized 纯单元测试,此处仅验证端到端一条。
#[tokio::test]
async fn english_reject_keyword_returns_error() {
let bus = EventBus::new();
let ctx = make_ctx(
&bus,
"exec-en",
"node-h",
json!({ "options": ["approve", "reject"], "timeout_secs": 5 }),
);
let bus_clone = bus.clone();
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
tokio::time::sleep(Duration::from_millis(50)).await;
send_response(&bus_clone, "exec-en", "node-h", "reject", None).await;
let err = handle.await.unwrap().unwrap_err().to_string();
assert!(
err.contains("拒绝"),
"英文 reject 关键字应返 Err, 实际: {}",
err
);
}
/// F-260616-06: 多选场景,picked 含一项拒绝 → 整单拒绝 → Err
/// (选了「驳回」即驳回,即便同时选了「同意」)。
#[tokio::test]
async fn multiple_select_with_one_reject_returns_error() {
let bus = EventBus::new();
let ctx = make_ctx(
&bus,
"exec-multi-reject",
"node-h",
json!({
"options": ["同意", "驳回", "备注"],
"select_type": "multiple"
}),
);
let bus_clone = bus.clone();
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
tokio::time::sleep(Duration::from_millis(50)).await;
send_response_multi(
&bus_clone,
"exec-multi-reject",
"node-h",
&["同意", "驳回"],
Some("驳回其中一项"),
)
.await;
let err = handle.await.unwrap().unwrap_err().to_string();
assert!(err.contains("拒绝"), "多选含拒绝项应返 Err, 实际: {}", err);
}
/// F-260616-06: options 空的自由文本场景 —— 明确拒绝词("拒绝")仍返 Err,
/// 其余自由文本(非拒绝词)仍按通过处理(向后兼容,不阻断自由反馈)。
#[tokio::test]
async fn empty_options_free_text_reject_keyword_still_errors() {
let bus = EventBus::new();
let ctx = make_ctx(&bus, "exec-free-reject", "node-h", json!({ "options": [] }));
let bus_clone = bus.clone();
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
tokio::time::sleep(Duration::from_millis(50)).await;
send_response(&bus_clone, "exec-free-reject", "node-h", "拒绝", None).await;
let err = handle.await.unwrap().unwrap_err().to_string();
assert!(err.contains("拒绝"), "自由文本明确为拒绝词仍应 Err, 实际: {}", err);
}
/// F-260616-06: options 空的自由文本场景 —— 非拒绝词自由文本仍返 Ok(不误伤自由反馈)。
/// (empty_options_allows_free_text 已覆盖 "改成先做B方案" → Ok,此处补一条非拒绝中文短句。)
#[tokio::test]
async fn empty_options_non_reject_free_text_still_ok() {
let bus = EventBus::new();
let ctx = make_ctx(&bus, "exec-free-ok", "node-h", json!({ "options": [] }));
let bus_clone = bus.clone();
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
tokio::time::sleep(Duration::from_millis(50)).await;
send_response(&bus_clone, "exec-free-ok", "node-h", "再讨论一下", None).await;
let out = handle.await.unwrap().unwrap();
assert_eq!(out.data["decision"], json!("再讨论一下"));
}
/// F-260616-06 单元: 关键字判定函数归一化(去空白+小写)与边界。
#[test]
fn reject_keyword_detection_normalized() {
assert!(is_reject_decision("拒绝"));
assert!(is_reject_decision(" 拒绝 "));
assert!(is_reject_decision("Reject"));
assert!(is_reject_decision(" decline "));
assert!(is_reject_decision("驳回"));
assert!(is_reject_decision("退回"));
assert!(!is_reject_decision("同意"));
assert!(!is_reject_decision("approve"));
assert!(!is_reject_decision("再讨论"));
assert!(!is_reject_decision(""));
// 不是关键字开头,是整词匹配 —— "拒绝啦" 不应误判
assert!(!is_reject_decision("拒绝啦"));
assert!(contains_reject(&["同意".into()]) == false);
assert!(contains_reject(&["同意".into(), "拒绝".into()]) == true);
}
}
+32
View File
@@ -0,0 +1,32 @@
//! 人工审批节点辅助函数/类型 — 拒绝语义化关键字判定等纯逻辑
//!
//! 从 human_node.rs 抽离的纯函数/常量(execute 与关键字判定解耦,便于单测覆盖)。
//! HumanNode 的 struct + impl 仍保留在 human_node.rs(impl 块约束)。
//!
//! B-260615-05 / CR-260618-15: 拒绝语义化保留(executor set_failed → 推进链退回)。
/// F-260616-06 阶段2: 拒绝语义化关键字。
/// decision 归一化(去空白 + 小写)后命中此集合 → 审批拒绝 → 节点返 Err(触发工作流 failed)。
///
/// 识别范围(避免误伤):
/// - 仅当节点 options 非空且该 decision ∈ options 时拒绝 —— 前端从受控选项中选择,语义可信;
/// - options 空(自由文本)时同样按关键字判定 —— 仅当文本明确为拒绝词才拒绝,
/// 其余自由文本一律视为通过(向后兼容,不阻断自由反馈)。
///
/// 关键字集合与前端/IPC 的 options 命名约定对齐:「拒绝/驳回/退回」中文 +
/// 「reject/decline/deny/no/block」英文,覆盖默认 options `["同意","拒绝"]`。
pub(super) const REJECT_KEYWORDS: &[&str] = &[
"拒绝", "驳回", "退回", "否决",
"reject", "decline", "declined", "deny", "denied", "no", "block",
];
/// 判定单个 decision 是否为拒绝选项(归一化后命中 REJECT_KEYWORDS)。
pub(super) fn is_reject_decision(d: &str) -> bool {
let normalized = d.trim().to_lowercase();
REJECT_KEYWORDS.iter().any(|k| normalized == *k)
}
/// 判定 picked 集合是否含拒绝选项:任一项命中即视为整单拒绝(多选场景「选了驳回」即驳回)。
pub(super) fn contains_reject(picked: &[String]) -> bool {
picked.iter().any(|d| is_reject_decision(d))
}
+7 -6
View File
@@ -1,10 +1,11 @@
//! df-nodes: 内置节点集合 — AI、脚本、Docker、Git、人工审批、HTTP、子流程、通知
//! df-nodes: 内置节点集合 — AI、脚本、人工审批
pub mod ai_node;
pub mod docker_node;
pub mod git_node;
pub mod http_node;
pub mod ai_self_review_node;
mod ai_node_helpers;
pub mod human_node;
pub mod notify_node;
mod human_node_helpers;
pub mod script_node;
pub mod subflow_node;
pub mod task_advance_node;
pub mod task_state_machine;
pub mod task_workflow_templates;
-41
View File
@@ -1,41 +0,0 @@
//! 通知节点 — 发送通知(邮件、飞书、钉钉等)
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// 通知节点
pub struct NotifyNode;
#[async_trait]
impl Node for NotifyNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 实现通知发送逻辑
tracing::info!("NotifyNode 执行: 发送通知");
Ok(NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"channel": { "type": "string", "enum": ["email", "feishu", "dingtalk", "webhook"] },
"to": { "type": "string" },
"title": { "type": "string" },
"body": { "type": "string" }
},
"required": ["channel", "to", "body"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"success": { "type": "boolean" }
}
}),
}
}
fn node_type(&self) -> &str {
"notify"
}
}
+1
View File
@@ -36,6 +36,7 @@ impl Node for ScriptNode {
working_dir,
env: std::collections::HashMap::new(),
timeout_secs,
shell_type: Default::default(),
};
tracing::info!("ScriptNode 执行命令: {}", command);
-39
View File
@@ -1,39 +0,0 @@
//! 子流程节点 — 嵌套执行另一个工作流
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// 子流程节点
pub struct SubflowNode;
#[async_trait]
impl Node for SubflowNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 实现子工作流加载与执行
tracing::info!("SubflowNode 执行: 启动子工作流");
Ok(NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"workflow_id": { "type": "string" },
"inputs": { "type": "object" }
},
"required": ["workflow_id"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"outputs": { "type": "object" }
}
}),
}
}
fn node_type(&self) -> &str {
"subflow"
}
}
+524
View File
@@ -0,0 +1,524 @@
//! 任务推进节点 — advance_task 推进链触发器(F-260616-02)
//!
//! 实现推进链的唯一 status 写入路径(D-260616-03 落 df-nodes Node):
//! 1. 读当前 TaskRecord(取 from status)
//! 2. 状态机校验 can_transition(from, to)(task_state_machine)
//! 3. 原子写:下沉 SQL `WHERE id AND status=expected` 防 TOCTOU,退回转换一并
//! review_rounds+=1(见 df_storage::crud::TaskRepo::advance_status_atomic)
//!
//! 两条调用路径复用同一推进逻辑:
//! - DAG 工作流:TaskAdvanceNode 实现 Node trait,从 NodeContext.config 读 task_id /
//! target_status,持有 Arc<Database> 构造 TaskRepo。
//! - IPC 直驱:advance_task_atomic 公开 async fn,src-tauri commands::task::advance_task
//! thin 入口直接调用(无需构造 NodeContext,避免 EventBus/StateMachine 等重依赖)。
use std::sync::Arc;
use async_trait::async_trait;
use df_storage::crud::TaskRepo;
use df_storage::db::Database;
use df_storage::models::TaskRecord;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
use crate::task_state_machine::{can_transition, is_regression, is_valid_state, ALL_STATES};
/// 推进任务到目标状态(核心逻辑,DAG 节点与 IPC 入口共用)。
///
/// 入参:
/// - `repo`:df-storage TaskRepo
/// - `id`:任务 ID
/// - `target_status`:目标状态(7 态之一,snake_case)
///
/// 流程:
/// 1. 任务存在性:找不到 → NotFound 错误(前端可据此提示)
/// 2. target_status 合法性:非 7 态 → Validation 错误(防脏数据直入推进链)
/// 3. 状态机(三类拒绝,错误区分供前端分辨):
/// - 同态拒绝(from==to):Validation「相同状态,无需推进」(非状态机违例,是空操作)
/// - 非法转换(跳态/终态后继等):InvalidState「非法状态转换 X→Y」(含 from/to 上下文)
/// - can_transition 闸门矩阵判否即此分支
/// 4. 原子写:advance_status_atomic CAS,to 是退回转换时 bump_rounds=true
/// 5. CAS 失败(affected==0):状态已被并发改动 → InvalidState 错误(防 TOCTOU 静默成功)
///
/// 返回:成功推进后的最新 TaskRecord(含新 status / 累加后的 review_rounds / 新 updated_at)。
pub async fn advance_task_atomic(
repo: &TaskRepo,
id: &str,
target_status: &str,
) -> df_types::error::Result<TaskRecord> {
// 1. target 合法性(7 态之一)。先于读库校验:即便任务不存在,也先拒绝非法状态值。
if !is_valid_state(target_status) {
return Err(df_types::error::Error::Validation(format!(
"非法 target_status {:?},合法值: {}",
target_status,
ALL_STATES.join("/")
)));
}
// 2. 读当前任务(取 from status)。
let current = repo
.get_by_id(id)
.await?
.ok_or_else(|| df_types::error::Error::NotFound(format!("任务 {} 不存在", id)))?;
// 3. 状态机校验(三类拒绝,错误类型区分供前端分辨):
// - 同态(from==to):Validation「相同状态,无需推进」(空操作,非状态机违例)
// - 非法转换(跳态/终态无后继等):InvalidState「非法状态转换 X→Y」
let from = current.status.as_str();
if from == target_status {
return Err(df_types::error::Error::Validation(format!(
"相同状态 {from:?},无需推进"
)));
}
if !can_transition(from, target_status) {
return Err(df_types::error::Error::InvalidState {
current: format!("{from}→{target_status}(非法状态转换)"),
expected: target_status.to_string(),
});
}
// 4. 原子 CAS 写。退回转换一并 review_rounds+=1。
let bump = is_regression(from, target_status);
let updated = repo
.advance_status_atomic(id, from, target_status, bump)
.await?;
// 5. CAS 失败:并发已改动 status(或任务被删),拒绝静默成功。
updated.ok_or_else(|| df_types::error::Error::InvalidState {
current: "已变更(并发推进或旁路修改)".to_string(),
expected: from.to_string(),
})
}
// ============================================================
// DAG 工作流节点 — TaskAdvanceNode(推进链在 DAG 内的形态)
// ============================================================
/// 任务推进节点 — DAG 工作流内触发 advance_task。
///
/// 持有 Arc<Database> 在 NodeRegistry 注册时注入(state.rs build_registry),
/// Node::execute 从 NodeContext.config 读 task_id / target_status,调 advance_task_atomic。
/// 当前推进链 F-01~04 阶段为手动推进(IPC 直驱),DAG 形态为阶段 2(工作流联动)预留。
pub struct TaskAdvanceNode {
db: Arc<Database>,
}
impl TaskAdvanceNode {
/// 构造节点(注册表工厂调用,注入数据库句柄)
pub fn new(db: Arc<Database>) -> Self {
Self { db }
}
}
#[async_trait]
impl Node for TaskAdvanceNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult {
let task_id = ctx
.config
.get("task_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("TaskAdvanceNode 缺少必填参数: task_id"))?;
let target_status = ctx
.config
.get("target_status")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("TaskAdvanceNode 缺少必填参数: target_status"))?;
let repo = TaskRepo::new(&self.db);
let updated = advance_task_atomic(&repo, task_id, target_status)
.await
.map_err(|e| anyhow::anyhow!("推进任务失败: {}", e))?;
Ok(NodeOutput::from_value(serde_json::json!({
"task": updated,
"task_id": updated.id,
"status": updated.status,
"review_rounds": updated.review_rounds,
})))
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"task_id": { "type": "string" },
"target_status": {
"type": "string",
"enum": ["todo", "in_progress", "in_review", "testing", "done", "blocked", "cancelled"]
}
},
"required": ["task_id", "target_status"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"task_id": { "type": "string" },
"status": { "type": "string" },
"review_rounds": { "type": "integer" }
}
}),
}
}
fn node_type(&self) -> &str {
"task_advance"
}
}
// ============================================================
// 单元测试 — advance_task_atomic 状态机集成(内存 DB)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
use df_storage::crud::ProjectRepo;
use df_storage::models::{ProjectRecord, TaskRecord};
fn rec(id: &str, status: &str) -> TaskRecord {
TaskRecord {
id: id.to_string(),
project_id: "p1".to_string(),
title: format!("t-{id}"),
description: "".to_string(),
status: status.to_string(),
priority: 2,
branch_name: None,
assignee: None,
workflow_def_id: None,
base_branch: None,
review_rounds: 0,
output_json: None,
idea_id: None,
created_at: "0".to_string(),
updated_at: "0".to_string(),
}
}
async fn setup() -> TaskRepo {
let db = Database::open_in_memory().await.expect("open_in_memory");
// 先插父项目,满足 tasks.project_id FK 约束(PRAGMA foreign_keys=ON)。
ProjectRepo::new(&db)
.insert(ProjectRecord {
id: "p1".to_string(),
name: "proj".to_string(),
description: "".to_string(),
status: "planning".to_string(),
idea_id: None,
path: None,
stack: None,
created_at: "0".to_string(),
updated_at: "0".to_string(),
})
.await
.expect("insert project");
TaskRepo::new(&db)
}
#[tokio::test]
async fn forward_path_todo_to_done() {
let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap();
// 主路径逐级推进
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
assert_eq!(r.status, "in_progress");
assert_eq!(r.review_rounds, 0);
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
assert_eq!(r.status, "in_review");
assert_eq!(r.review_rounds, 0);
let r = advance_task_atomic(&repo, "t1", "testing").await.unwrap();
assert_eq!(r.status, "testing");
let r = advance_task_atomic(&repo, "t1", "done").await.unwrap();
assert_eq!(r.status, "done");
assert_eq!(r.review_rounds, 0);
}
#[tokio::test]
async fn regression_in_review_to_in_progress_bumps_rounds() {
let repo = setup().await;
repo.insert(rec("t1", "in_review")).await.unwrap();
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
assert_eq!(r.status, "in_progress");
assert_eq!(r.review_rounds, 1, "退回应 +1");
}
#[tokio::test]
async fn regression_testing_to_in_review_bumps_rounds() {
let repo = setup().await;
repo.insert(rec("t1", "testing")).await.unwrap();
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
assert_eq!(r.status, "in_review");
assert_eq!(r.review_rounds, 1, "退回应 +1");
}
#[tokio::test]
async fn multiple_regressions_accumulate() {
let repo = setup().await;
repo.insert(rec("t1", "in_review")).await.unwrap();
// in_review → in_progress (+1) → in_review (前向,不动) → in_progress (+1=2)
advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
assert_eq!(r.review_rounds, 2);
}
#[tokio::test]
async fn illegal_skip_rejected() {
// CR-01-D: 非法转换(跳态)归 InvalidState,且消息含 from→to 上下文与「非法状态转换」。
let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "done").await.unwrap_err();
match err {
df_types::error::Error::InvalidState { current, expected } => {
assert!(current.contains("todo→done"), "非法转换消息应含 from→to,实际: {current}");
assert!(current.contains("非法状态转换"), "消息应含「非法状态转换」,实际: {current}");
assert_eq!(expected, "done");
}
other => panic!("非法转换应是 InvalidState,实际: {other:?}"),
}
}
#[tokio::test]
async fn terminal_done_no_successor() {
// CR-01-D: 终态无后继也是非法转换路径,归 InvalidState(同 illegal_skip_rejected)。
let repo = setup().await;
repo.insert(rec("t1", "done")).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "todo").await.unwrap_err();
match err {
df_types::error::Error::InvalidState { current, .. } => {
assert!(current.contains("done→todo"), "终态后继消息应含 from→to,实际: {current}");
}
other => panic!("终态后继应是 InvalidState,实际: {other:?}"),
}
}
#[tokio::test]
async fn same_status_rejected() {
// CR-01-D: 同态拒绝(from==to)与非法转换(跳态)错误类型区分。
// 同态属空操作,归 Validation「相同状态,无需推进」;
// 非法转换归 InvalidState「非法状态转换 X→Y」(见 illegal_skip_rejected)。
let repo = setup().await;
repo.insert(rec("t1", "in_progress")).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap_err();
match err {
df_types::error::Error::Validation(msg) => {
assert!(msg.contains("相同状态"), "同态消息应含「相同状态」,实际: {msg}");
}
other => panic!("同态拒绝应是 Validation,实际: {other:?}"),
}
}
#[tokio::test]
async fn invalid_target_rejected() {
let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "merged").await.unwrap_err();
assert!(matches!(err, df_types::error::Error::Validation(_)));
}
#[tokio::test]
async fn not_found_rejected() {
let repo = setup().await;
let err = advance_task_atomic(&repo, "nope", "in_progress").await.unwrap_err();
assert!(matches!(err, df_types::error::Error::NotFound(_)));
}
#[tokio::test]
async fn advance_succeeds_after_prior_status_change() {
// 前序旁路改 status 后,推进链基于「当前」status 判定合法转换并正常写入。
// 注:这并非 CAS 并发失败场景(真 CAS 失败由 cas_returns_none_when_status_mismatch 覆盖),
// 而是验证读后改路径在 from=当前库内 status 时正常推进。
let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap();
// 另一路推进把 status 改成 in_progress(模拟并发推进,走 CAS 合法路径;
// F-03 收口后 status 不在 update_field 白名单,模拟并发改态须走 advance_status_atomic)
repo.advance_status_atomic("t1", "todo", "in_progress", false)
.await
.unwrap();
// 读出来是 in_progress,推进到 in_review 合法 → 正常成功
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
assert_eq!(r.status, "in_review");
}
#[tokio::test]
async fn cas_returns_none_when_status_mismatch() {
let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap();
// 直接调底层:expected 传错(模拟读到 todo 但实际已被改成 in_progress)
let r = repo
.advance_status_atomic("t1", "todo", "in_review", false)
.await
.unwrap();
// 此时 status 确实是 todo,CAS 成功,返回 Some
assert!(r.is_some());
// 再用错误的 expected todo(实际已是 in_review)→ CAS 失败 None
let r = repo
.advance_status_atomic("t1", "todo", "done", false)
.await
.unwrap();
assert!(r.is_none(), "expected 与库内不符应 CAS 失败");
}
#[tokio::test]
async fn blocked_round_trip_does_not_bump() {
let repo = setup().await;
repo.insert(rec("t1", "in_progress")).await.unwrap();
let r = advance_task_atomic(&repo, "t1", "blocked").await.unwrap();
assert_eq!(r.status, "blocked");
assert_eq!(r.review_rounds, 0, "进 blocked 不累加");
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
assert_eq!(r.status, "in_progress");
assert_eq!(r.review_rounds, 0, "解除 blocked 不累加");
}
// ============================================================
// CR-06 观察项 ② 测试补强 — ②-3(completed)/②-4(failed) 回调推进覆盖
//
// workflow.rs 的 spawn 闭包回调依赖三段纯计算:
// 1. template_for(target) 选模板(已由 task_workflow_templates.rs 测试覆盖)
// 2. regression_target(target) 推失败退回态(已收敛到 task_state_machine::regression_target
// pub 契约,workflow.rs 与本测试均从此导入,单一可信源;映射表由 task_state_machine
// 测试模块锁定)
// 3. advance_task_atomic(repo, tid, to) 落库(本模块核心,下方覆盖各回调 to)
//
// workflow.rs spawn 闭包集成测试需 mock AppState/AppHandle/EventBus,df-nodes crate
// 无此基础设施 → 降级为纯函数级 + 状态机集成级(零 mock,稳)。回调三段计算全覆盖,
// 唯一未覆盖的是「闭包组装 + tracing」胶水代码(非业务逻辑)。
// ============================================================
/// 镜像 workflow.rs spawn 闭包的回调决策(completed→target / failed→regression_target)。
/// 抽出纯函数便于直接断言,与 workflow.rs 闭包内 match 逻辑一一对应。
///
/// regression_target 现已收敛为 df-nodes::task_state_machine::regression_target 的
/// pub 契约(单一可信源),此处直接复用,不再镜像副本(防 DRY 漂移)。
///
/// 返回 Option<String>(非 &str):completed 分支回传入的 target_status(非 'static),
/// failed 分支回 regression_target 的 &'static str 常量。统一 String 避免生命周期冲突。
fn callback_advance_target(status: &str, target_status: &str) -> Option<String> {
match status {
"completed" => Some(target_status.to_string()),
"failed" => crate::task_state_machine::regression_target(target_status)
.map(String::from),
_ => None,
}
}
#[test]
fn callback_completed_advances_to_target() {
// ②-3:工作流 completed → 推进到 target_status(无论 target 是哪个)
assert_eq!(
callback_advance_target("completed", "in_progress").as_deref(),
Some("in_progress")
);
assert_eq!(
callback_advance_target("completed", "testing").as_deref(),
Some("testing")
);
assert_eq!(
callback_advance_target("completed", "done").as_deref(),
Some("done")
);
}
#[test]
fn callback_failed_advances_to_regression_target() {
// ②-4:工作流 failed → 推进到 regression_target(target)
assert_eq!(
callback_advance_target("failed", "testing").as_deref(),
Some("in_review")
);
assert_eq!(
callback_advance_target("failed", "in_review").as_deref(),
Some("in_progress")
);
// CR-13-O1-b: in_progress 失败不退回(状态机禁止→todo),回调返回 None 跳过推进
assert_eq!(callback_advance_target("failed", "in_progress"), None);
// failed + done:无退回映射 → None(回调跳过,workflow.rs 会 warn 跳过日志)
assert_eq!(callback_advance_target("failed", "done"), None);
}
#[test]
fn callback_non_terminal_status_skips() {
// 工作流非终态(running/cancelled 等)不应触发回调 — workflow.rs match 的 _ 分支。
// (cancelled 在 workflow.rs 实际归入 failed 路径因 status 字段取值,这里锁定
// 唯一终态语义:仅 "completed"/"failed" 两态触发推进。)
for non_terminal in ["running", "pending", ""] {
assert_eq!(
callback_advance_target(non_terminal, "in_progress"),
None,
"非终态 {non_terminal:?} 不应触发回调推进"
);
}
}
/// ②-3 回调落库验证:completed → advance_task_atomic(target) 成功推进 + 不累加 rounds。
/// 覆盖三个 target 的前向推进(in_progress/testing/done)从各自合法 from 态触发。
#[tokio::test]
async fn callback_completed_advance_lands_in_db() {
// in_progress: todo → in_progress(②-3 in_progress 模板完成)
let repo = setup().await;
repo.insert(rec("c1", "todo")).await.unwrap();
let to = callback_advance_target("completed", "in_progress").unwrap();
let r = advance_task_atomic(&repo, "c1", &to).await.unwrap();
assert_eq!(r.status, "in_progress");
assert_eq!(r.review_rounds, 0, "②-3 前向推进不累加 review_rounds");
// testing: in_review → testing(②-3 testing 模板自审+核对通过)
repo.insert(rec("c2", "in_review")).await.unwrap();
let to = callback_advance_target("completed", "testing").unwrap();
let r = advance_task_atomic(&repo, "c2", &to).await.unwrap();
assert_eq!(r.status, "testing");
assert_eq!(r.review_rounds, 0);
// done: testing → done(②-3 done 模板最终核对通过)
repo.insert(rec("c3", "testing")).await.unwrap();
let to = callback_advance_target("completed", "done").unwrap();
let r = advance_task_atomic(&repo, "c3", &to).await.unwrap();
assert_eq!(r.status, "done");
assert_eq!(r.review_rounds, 0);
}
/// ②-4 回调落库验证:failed → advance_task_atomic(regression_target) 退回 + 累加 rounds。
/// 关键差异:②-4 退回转换是 regression,review_rounds += 1(与 ②-3 前向推进 0 增量对比)。
#[tokio::test]
async fn callback_failed_regression_lands_in_db_with_rounds_bump() {
// testing 模板失败:任务当前 testing → 退回 in_review(rounds+1)
let repo = setup().await;
repo.insert(rec("f1", "testing")).await.unwrap();
let to = callback_advance_target("failed", "testing").unwrap();
assert_eq!(to, "in_review", "testing 失败应退回 in_review");
let r = advance_task_atomic(&repo, "f1", &to).await.unwrap();
assert_eq!(r.status, "in_review");
assert_eq!(r.review_rounds, 1, "②-4 退回应累加 review_rounds(+1)");
// in_review 模板失败(注:in_review 非 callback target,但映射存在性仍锁定):
// in_review → in_progress。此处验证 regression_target 对 in_review 的映射,
// 即便当前推进链 testing 模板失败也可能退到 in_progress(链式退回)。
repo.insert(rec("f2", "in_review")).await.unwrap();
let to = callback_advance_target("failed", "in_review").unwrap();
assert_eq!(to, "in_progress");
let r = advance_task_atomic(&repo, "f2", &to).await.unwrap();
assert_eq!(r.status, "in_progress");
assert_eq!(r.review_rounds, 1);
// in_progress 模板失败:regression_target("in_progress")=None(CR-13-O1-b),
// 回调返回 None → 跳过推进,任务保留 in_progress 等人介入。
// 验证:callback 返回 None,不调 advance_task_atomic。
repo.insert(rec("f3", "in_progress")).await.unwrap();
let to = callback_advance_target("failed", "in_progress");
assert_eq!(to, None, "in_progress 失败应跳过推进(None),实际: {to:?}");
// 任务状态未被改动(仍是 in_progress)
let still = repo.get_by_id("f3").await.unwrap().unwrap();
assert_eq!(still.status, "in_progress", "None 退回不应改动 status");
}
/// ②-4 回调 failed+done 无退回映射验证:callback_advance_target 返回 None。
/// workflow.rs 此分支仅 tracing::warn! 跳过,不调 advance_task_atomic。
/// 锁定:done 失败不会推进任务(终态前向目标失败,无可退态)。
#[tokio::test]
async fn callback_failed_done_skips_advance() {
let to = callback_advance_target("failed", "done");
assert_eq!(to, None, "done 失败无退回映射 → 回调跳过(None)");
// 不调 advance_task_atomic(workflow.rs L294-302 的 else 分支)
}
}
+362
View File
@@ -0,0 +1,362 @@
//! 任务推进状态机 — 7 态合法转换定义(F-260616-01)
//!
//! 独立模块,非挂在 df-types::TaskStatus enum 上(对齐 D-260616-03「推进链业务逻辑落
//! df-nodes」)。本模块只做「给定 from/to 是否合法」的纯函数判定,不触碰存储层
//! (原子写 SQL 在 task_advance_node.rs 完成,见 F-260616-02)。
//!
//! 7 态(与 df-types::TaskStatus / 前端对齐,D-260616-01):
//! todo / in_progress / in_review / testing / done / blocked / cancelled
//!
//! 闸门链主路径: todo → in_progress → in_review → testing → done
//!
//! 合法转换矩阵(以报告 docs/05-代码审查/任务执行与推进能力分析-2026-06-16.md §8
//! 与决策 docs/02-架构设计/专项设计/任务推进链实施路径-2026-06-16.md F-01 为准):
//! - todo → in_progress(开始), cancelled(取消)
//! - in_progress → in_review(提交审查), blocked(阻塞), cancelled
//! - in_review → testing(审查通过进测试), in_progress(退回修改, review_rounds+1),
//! blocked, cancelled
//! - testing → done(测试通过), in_review(退回重审, review_rounds+1),
//! blocked, cancelled
//! - done → 终态(原则上不可变;若需重开走 cancelled 或新任务)
//! - blocked → in_progress(解除阻塞继续), cancelled
//! - cancelled → 终态
// ============================================================
// 状态字符串常量 — 与 df-types::TaskStatus::as_str 一一对应
// ============================================================
//
// 不复用 df-types::TaskStatus enum(独立模块定位 + 避免推进链判定耦合存储枚举类型),
// 但字符串值严格对齐(df-types::TaskStatus::as_str 产出的小写 snake_case),
// 保证状态机判定的 from/to 与数据库 status 列存值语义一致。
/// 待开始
pub const TODO: &str = "todo";
/// 进行中
pub const IN_PROGRESS: &str = "in_progress";
/// 代码审查中
pub const IN_REVIEW: &str = "in_review";
/// 测试中
pub const TESTING: &str = "testing";
/// 已完成(终态)
pub const DONE: &str = "done";
/// 已阻塞
pub const BLOCKED: &str = "blocked";
/// 已取消(终态)
pub const CANCELLED: &str = "cancelled";
/// 全部合法状态值(供输入校验与错误提示复用)
pub const ALL_STATES: &[&str] = &[
TODO,
IN_PROGRESS,
IN_REVIEW,
TESTING,
DONE,
BLOCKED,
CANCELLED,
];
/// 字符串是否为合法状态值
pub fn is_valid_state(s: &str) -> bool {
ALL_STATES.contains(&s)
}
/// 判定从 `from` 到 `to` 的状态转换是否合法(状态机核心)。
///
/// 终态(done/cancelled)无任何合法后继;非法或未知状态入参一律返回 false
/// (调用方 advance_task 在前置校验已拦截非法 status,此处保守拒绝防漏)。
///
/// 注意:仅判「是否合法」,不判「是否是退回」——退回(导致 review_rounds+1)
/// 的识别见 [`is_regression`],advance_task 据此决定是否一并 SET review_rounds+=1。
pub fn can_transition(from: &str, to: &str) -> bool {
use std::sync::OnceLock;
// 转换表静态构造一次(运行时常量,无锁开销分摊)。行=from,列=to。
// 表条目为 true 即合法转换。未列入的 (from, to) 一律 false。
static TABLE: OnceLock<std::collections::HashMap<(&'static str, &'static str), bool>> =
OnceLock::new();
let table = TABLE.get_or_init(|| {
let mut m = std::collections::HashMap::new();
let allowed: &[(&str, &str)] = &[
// todo → in_progress(开始), cancelled(取消)
(TODO, IN_PROGRESS),
(TODO, CANCELLED),
// in_progress → in_review(提交审查), blocked(阻塞), cancelled
(IN_PROGRESS, IN_REVIEW),
(IN_PROGRESS, BLOCKED),
(IN_PROGRESS, CANCELLED),
// in_review → testing(审查通过进测试), in_progress(退回修改),
// blocked, cancelled
(IN_REVIEW, TESTING),
(IN_REVIEW, IN_PROGRESS),
(IN_REVIEW, BLOCKED),
(IN_REVIEW, CANCELLED),
// testing → done(测试通过), in_review(退回重审), blocked, cancelled
(TESTING, DONE),
(TESTING, IN_REVIEW),
(TESTING, BLOCKED),
(TESTING, CANCELLED),
// done → 终态,无后继
// blocked → in_progress(解除阻塞继续), cancelled
(BLOCKED, IN_PROGRESS),
(BLOCKED, CANCELLED),
// cancelled → 终态,无后继
];
for (f, t) in allowed {
m.insert((*f, *t), true);
}
m
});
table.get(&(from, to)).copied().unwrap_or(false)
}
/// 判定一次转换是否为「退回」(review_rounds 应 +1)。
///
/// 退回语义:任务从前向推进阶段回退到更早的推进阶段,意味着上一轮产出未过闸门、
/// 需重做。具体两类:
/// - in_review → in_progress(审查退回修改)
/// - testing → in_review(测试退回重审)
///
/// 其余合法转换(前向推进 / 进出 blocked / 进 cancelled)均不累加 review_rounds。
pub fn is_regression(from: &str, to: &str) -> bool {
matches!((from, to), (IN_REVIEW, IN_PROGRESS) | (TESTING, IN_REVIEW))
}
/// 工作流联动任务「失败退一步」的目标态映射(F-260616-06 ②-4)。
///
/// 工作流失败时,任务不应停留在失败前向目标态,需回退到上一闸门重做。映射表:
/// - testing → in_review(测试失败退回重审)
/// - in_review → in_progress(审查失败退回修改)
/// - in_progress → None(状态机禁止 in_progress→todo,见 [`can_transition`] 的
/// backward_to_todo_rejected;失败退回 todo 会被 advance_task_atomic
/// 的 InvalidState 拦截,任务原地保留 in_progress 无信号。改为 None
/// 则回调跳过推进,留 in_progress 等人介入 — 决策 CR-13-O1-b)
/// - 其他(done/blocked/cancelled/todo 等非推进链前向目标) → None(无退回映射)
///
/// 设计归属:此映射属状态机业务契约(失败退一步的目标态),居 df-nodes 与
/// [`can_transition`] / [`is_regression`] 同位,统一管理推进链的状态语义。
/// 此前该函数在 workflow.rs(app crate 私有) 与 task_advance_node.rs(测试镜像)
/// 各存一份(DRY 漂移),现收敛为单一可信源,调用方均从此导入。
pub fn regression_target(target: &str) -> Option<&'static str> {
match target {
TESTING => Some(IN_REVIEW),
IN_REVIEW => Some(IN_PROGRESS),
// in_progress 失败不自动退回(状态机不允许→todo),留 in_progress 等人介入
IN_PROGRESS => None,
_ => None,
}
}
// ============================================================
// 单元测试 — 转换矩阵 + 边界
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
// ---------- can_transition 闸门主路径 ----------
#[test]
fn main_path_todo_to_done_all_forward() {
assert!(can_transition(TODO, IN_PROGRESS));
assert!(can_transition(IN_PROGRESS, IN_REVIEW));
assert!(can_transition(IN_REVIEW, TESTING));
assert!(can_transition(TESTING, DONE));
}
#[test]
fn cancel_from_any_non_terminal() {
for s in [TODO, IN_PROGRESS, IN_REVIEW, TESTING, BLOCKED] {
assert!(can_transition(s, CANCELLED), "应允许 {s} → cancelled");
}
}
#[test]
fn blocked_round_trip() {
assert!(can_transition(IN_PROGRESS, BLOCKED));
assert!(can_transition(BLOCKED, IN_PROGRESS));
assert!(can_transition(IN_REVIEW, BLOCKED));
assert!(can_transition(TESTING, BLOCKED));
}
#[test]
fn regression_paths_allowed() {
assert!(can_transition(IN_REVIEW, IN_PROGRESS));
assert!(can_transition(TESTING, IN_REVIEW));
}
// ---------- 非法转换被拒 ----------
#[test]
fn illegal_forward_skips_rejected() {
// 不允许跳过闸门(直奔 done)
assert!(!can_transition(TODO, DONE));
assert!(!can_transition(TODO, TESTING));
assert!(!can_transition(TODO, IN_REVIEW));
assert!(!can_transition(IN_PROGRESS, DONE));
assert!(!can_transition(IN_PROGRESS, TESTING));
assert!(!can_transition(IN_REVIEW, DONE));
}
#[test]
fn terminal_states_have_no_successors() {
// done / cancelled 是终态,任何后继都拒绝
for term in [DONE, CANCELLED] {
for to in ALL_STATES {
assert!(!can_transition(term, to), "终态 {term} 不应有后继 → {to}");
}
}
}
#[test]
fn backward_to_todo_rejected() {
// 不允许回退到 todo(开始即不可撤回)
for s in [IN_PROGRESS, IN_REVIEW, TESTING, BLOCKED] {
assert!(!can_transition(s, TODO), "不应允许 {s} → todo");
}
}
#[test]
fn blocked_only_to_in_progress_or_cancelled() {
assert!(can_transition(BLOCKED, IN_PROGRESS));
assert!(can_transition(BLOCKED, CANCELLED));
// 解除阻塞不能直接跳到 in_review/testing/done
assert!(!can_transition(BLOCKED, IN_REVIEW));
assert!(!can_transition(BLOCKED, TESTING));
assert!(!can_transition(BLOCKED, DONE));
}
// ---------- 未知状态 ----------
#[test]
fn unknown_states_rejected() {
assert!(!can_transition("unknown", TODO));
assert!(!can_transition(TODO, "unknown"));
assert!(!can_transition("", ""));
}
// ---------- is_regression ----------
#[test]
fn regression_only_on_review_back_edges() {
assert!(is_regression(IN_REVIEW, IN_PROGRESS));
assert!(is_regression(TESTING, IN_REVIEW));
// 前向推进不累加
assert!(!is_regression(TODO, IN_PROGRESS));
assert!(!is_regression(IN_PROGRESS, IN_REVIEW));
assert!(!is_regression(IN_REVIEW, TESTING));
assert!(!is_regression(TESTING, DONE));
// 进出 blocked 不累加
assert!(!is_regression(IN_PROGRESS, BLOCKED));
assert!(!is_regression(BLOCKED, IN_PROGRESS));
// 进 cancelled 不累加
assert!(!is_regression(IN_REVIEW, CANCELLED));
// 不合法的"退回"(实际上不存在的转换)也不应判定为 regression
assert!(!is_regression(TESTING, IN_PROGRESS));
}
// ---------- regression_target(失败退一步映射) ----------
#[test]
fn regression_target_mapping_matches_contract() {
// 锁定 ②-4 失败退回映射表(逐项)
assert_eq!(regression_target(TESTING), Some(IN_REVIEW));
assert_eq!(regression_target(IN_REVIEW), Some(IN_PROGRESS));
// CR-13-O1-b: in_progress 失败不退回(状态机禁止→todo),留 in_progress 等人介入
assert_eq!(regression_target(IN_PROGRESS), None);
// done 是终态前向目标,失败无可退态 → None
assert_eq!(regression_target(DONE), None);
// todo 是起点态无可退;blocked/cancelled 非推进链目标 → None
assert_eq!(regression_target(TODO), None);
assert_eq!(regression_target(BLOCKED), None);
assert_eq!(regression_target(CANCELLED), None);
// 未知态防御性 → None
assert_eq!(regression_target("merged"), None);
assert_eq!(regression_target(""), None);
}
#[test]
fn regression_target_returned_state_is_valid_constant() {
// 防漂移的真正增量(上例已逐项锁值,本例锁性质):
// 任何非 None 返回值都必须是 ALL_STATES 内的合法状态常量。
// 一旦 regression_target 误返回非状态字符串(拼写漂移 / 拼了历史态如 "merged"),
// 该字符串过不了 is_valid_state,本测试立即失败定位。
// 覆盖全部 7 态入参,不依赖上例已锁定的具体期望值。
for target in ALL_STATES {
match regression_target(target) {
Some(ret) => assert!(
is_valid_state(ret),
"regression_target({target:?}) 返回 {ret:?} 不是合法状态常量(ALL_STATES 漂移)"
),
None => {}
}
}
}
// ---------- is_valid_state ----------
#[test]
fn is_valid_state_accepts_7_known() {
for s in ALL_STATES {
assert!(is_valid_state(s));
}
}
#[test]
fn is_valid_state_rejects_unknown() {
assert!(!is_valid_state(""));
assert!(!is_valid_state("unknown"));
assert!(!is_valid_state("TODO")); // 大写
assert!(!is_valid_state("merged")); // 历史 5 态残留
assert!(!is_valid_state("abandoned"));
}
#[test]
fn all_states_has_seven_entries() {
assert_eq!(ALL_STATES.len(), 7);
}
// ---------- 双源一致性校验 ----------
//
// task_state_machine 维护一份独立的 7 态字符串常量集(本模块 ALL_STATES / TODO / ...),
// 与 df-types::TaskStatus::as_str / valid_values() 同语义但不复用枚举(独立模块定位,
// 推进链判定不耦合存储枚举)。两源无编译期绑定,若任一处改拼写或增删状态值,
// 状态机判定会与数据库 status 列存值静默脱节(按常量判合法但落库值对不上)。
//
// 此处锁定「值必须严格一致」契约:ALL_STATES 必须与 df-types::TaskStatus::valid_values()
// 逐项相等(顺序与值)。df-nodes 单向依赖 df-types,无环依赖风险。一旦未来某方改动
// 导致两源漂移,本测试立即失败并精确定位,避免运行时静默脱节。
#[test]
fn all_states_matches_df_types_valid_values() {
use df_types::types::TaskStatus;
let expected: &[&str] = TaskStatus::valid_values();
// 逐项比对(顺序敏感):位置 i 的值必须与 df-types 一致,失败时报具体索引便于定位
assert_eq!(
ALL_STATES.len(),
expected.len(),
"ALL_STATES 与 TaskStatus::valid_values 长度不一致(状态值数量漂移)"
);
for (i, (got, want)) in ALL_STATES.iter().zip(expected.iter()).enumerate() {
assert_eq!(
got, want,
"ALL_STATES[{i}] = {got:?} 与 TaskStatus::valid_values()[{i}] = {want:?} 不一致(状态值漂移)"
);
}
}
#[test]
fn each_state_constant_matches_task_status_as_str() {
// 逐变体锁定:常量值必须与对应 TaskStatus 变体的 as_str 产出严格相等。
// 比上面整表比对更精细,单点漂移能直接指向具体常量行,加速定位。
use df_types::types::TaskStatus;
assert_eq!(TODO, TaskStatus::Todo.as_str());
assert_eq!(IN_PROGRESS, TaskStatus::InProgress.as_str());
assert_eq!(IN_REVIEW, TaskStatus::InReview.as_str());
assert_eq!(TESTING, TaskStatus::Testing.as_str());
assert_eq!(DONE, TaskStatus::Done.as_str());
assert_eq!(BLOCKED, TaskStatus::Blocked.as_str());
assert_eq!(CANCELLED, TaskStatus::Cancelled.as_str());
}
}
@@ -0,0 +1,228 @@
//! 任务推进链 DAG 模板(F-260616-06 阶段2 / D-260616-03)
//!
//! 推进链前向转换的工作流拓扑描述(声明式,纯数据)。DagDef 只描述节点与边,
//! 执行逻辑靠 DagExecutor 驱动各 Node trait 的 execute —— 模板本身不跑逻辑。
//!
//! 三模板对应推进链三个前向转换点:
//! - "in_progress": todo→in_progress,单 AiNode 执行任务
//! - "testing": in_review→testing,AiNode 自审 → HumanNode 核对(human reject 走 Err
//! 触发工作流 failed → ②-4 回调退回 in_review)
//! - "done": testing→done,单 HumanNode 最终核对
//!
//! 不建 workflow_defs 表(模板少且稳定,硬编码;tasks.workflow_def_id 留 None)。
//!
//! config 设计:节点级 config 不硬编码 task_id / provider 等运行时参数 —— 全部留空。
//! 运行时 run_workflow(task_id, target_status) 调用方注入全局 config(task_id /
//! target_status / provider 配置),由 ④-1 deep_merge 与节点级 config 合并
//! (节点级覆盖全局)。模板只描述拓扑,不绑定具体任务。
use df_workflow::dag_def::DagDef;
/// 按目标状态返回推进链工作流模板。
///
/// `target_status` 取推进链前向目标状态(in_progress / testing / done)。
/// 未匹配返回 None —— 调用方降级(如直接 IPC 推进,不启工作流),不 panic。
///
/// 返回的 DagDef 节点级 config 为空对象或仅含拓扑级元信息(label 等),
/// 运行时参数由调用方全局 config 注入 + deep_merge。
pub fn template_for(target_status: &str) -> Option<DagDef> {
match target_status {
"in_progress" => Some(in_progress_template()),
"testing" => Some(testing_template()),
"done" => Some(done_template()),
_ => None,
}
}
/// todo → in_progress:单 AiNode 执行任务。
///
/// 拓扑:1 个 ai 节点。完成回调(②-3)推进 status 到 in_progress。
fn in_progress_template() -> DagDef {
let mut dag = DagDef::new();
// config 留空:prompt / provider / task_id 均由 run_workflow 全局 config 注入
// (④-1 deep_merge 合并),模板只描述拓扑。
dag.add_node(
"ai_execute",
"ai",
serde_json::json!({}),
);
dag
}
/// in_review → testing:AiNode 自审 → HumanNode 核对。
///
/// 拓扑:ai → human 串行。阶段3(本批)起 ai_self_review 启用 gate:true:
/// - verdict=fail → AiSelfReviewNode 返回 Err → 工作流 failed(不经 human_review)
/// → ②-4 失败回调退回 in_review(review_rounds+=1)。
/// - verdict=unknown/pass → 放行 human_review,人定最终是否推进。
/// HumanNode reject 走 Err(②-5 已做)→ 工作流 failed → ②-4 失败回调退回 in_review。
/// 通过则完成回调(②-3)推进 status 到 testing。
fn testing_template() -> DagDef {
let mut dag = DagDef::new();
// 决策 a 步骤③:ai_self_review 节点类型对齐 state.rs 注册的独立自审节点
// (四维度 prompt + JSON 解析兜底 + 写回 output_json 加 review 子字段)。
// 阶段3:gate=true 启用自审闸门(verdict=fail 阻断下游,工作流 failed)。
dag.add_node(
"ai_self_review",
"ai_self_review",
serde_json::json!({ "gate": true }),
);
dag.add_node(
"human_review",
"human",
serde_json::json!({
// 拓扑级元信息(非运行时参数):审批卡片默认文案。options 含「拒绝」
// 触发 ②-5 reject → Err → 工作流 failed。
"title": "核对 AI 自审结果",
"options": ["同意", "拒绝"],
}),
);
dag.add_edge("ai_self_review", "human_review");
dag
}
/// testing → done:单 HumanNode 最终核对。
///
/// 拓扑:1 个 human 节点。通过则完成回调(②-3)推进 status 到 done。
/// reject 同理走 Err → failed → ②-4 退回。
fn done_template() -> DagDef {
let mut dag = DagDef::new();
dag.add_node(
"human_final_review",
"human",
serde_json::json!({
"title": "最终核对",
"options": ["同意", "拒绝"],
}),
);
dag
}
// ============================================================
// 单元测试 — 模板拓扑断言
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unknown_status_returns_none() {
assert!(template_for("unknown").is_none());
assert!(template_for("").is_none());
assert!(template_for("merged").is_none());
}
#[test]
fn in_progress_template_single_ai_node() {
let dag = template_for("in_progress").expect("in_progress 模板存在");
assert_eq!(dag.nodes.len(), 1, "in_progress: 单节点");
assert!(dag.edges.is_empty(), "in_progress: 无边");
let node = dag.nodes.get("ai_execute").expect("ai_execute 节点存在");
assert_eq!(node.node_type, "ai");
// config 留空(运行时注入)
assert!(
node.config.as_object().map(|o| o.is_empty()).unwrap_or(true),
"ai_execute config 应为空对象"
);
}
#[test]
fn testing_template_ai_to_human_serial() {
let dag = template_for("testing").expect("testing 模板存在");
assert_eq!(dag.nodes.len(), 2, "testing: ai + human 两节点");
assert_eq!(dag.edges.len(), 1, "testing: 单串行边");
// 节点类型(决策 a 步骤③:ai_self_review 独立节点类型)
let ai = dag.nodes.get("ai_self_review").expect("ai_self_review 存在");
assert_eq!(ai.node_type, "ai_self_review");
// 阶段3:gate=true 启用自审闸门(verdict=fail 阻断下游)
let gate = ai
.config
.get("gate")
.and_then(|v| v.as_bool())
.expect("ai_self_review config 应含 gate");
assert!(gate, "testing 模板 ai_self_review 应启用 gate:true(阶段3 闸门)");
let human = dag.nodes.get("human_review").expect("human_review 存在");
assert_eq!(human.node_type, "human");
// 边方向:ai → human
let edge = &dag.edges[0];
assert_eq!(edge.source, "ai_self_review");
assert_eq!(edge.target, "human_review");
// human options 含「拒绝」(触发 ②-5 reject)
let options = human
.config
.get("options")
.and_then(|v| v.as_array())
.expect("human_review options 存在");
let opts: Vec<&str> = options.iter().filter_map(|v| v.as_str()).collect();
assert!(opts.contains(&"拒绝"), "options 应含「拒绝」触发 reject");
}
#[test]
fn done_template_single_human_node() {
let dag = template_for("done").expect("done 模板存在");
assert_eq!(dag.nodes.len(), 1, "done: 单节点");
assert!(dag.edges.is_empty(), "done: 无边");
let node = dag.nodes.get("human_final_review").expect("human_final_review 存在");
assert_eq!(node.node_type, "human");
}
// ---------- ②-3 / ②-4 回调语义对齐(CR-06 观察项 ② 测试补强) ----------
//
// 工作流完成(②-3)/失败(②-4)回调在 src-tauri/src/commands/workflow.rs spawn 闭包里,
// 回调的核心计算是:template_for 选模板 + regression_target(target) 推退回态 +
// advance_task_atomic 落库。这里锁定 template_for 三模板对回调语义的约束,
// 保证回调侧(workflow.rs)的 target_status 取值与模板存在性一致 ——
// 即回调只会对 template_for 返回 Some 的三个 target 触发,其余 target 模板不存在、
// 工作流压根不会启动,回调自然不触发。
/// 三模板对应的 target_status 集合(workflow.rs ②-3/②-4 回调仅对这些 target 触发)
const CALLBACK_TARGETS: &[&str] = &["in_progress", "testing", "done"];
#[test]
fn callback_targets_all_have_templates() {
// ②-3/②-4 回调依赖 template_for(target) 非空 —— 凡是能进回调的 target 必有模板。
// 若新增一个回调 target 但忘了加模板,这里会先红。
for target in CALLBACK_TARGETS {
assert!(
template_for(target).is_some(),
"回调 target {target:?} 必须有对应模板(template_for 非 None)"
);
}
}
#[test]
fn non_callback_targets_have_no_template() {
// 反向:非推进链 target(todo/in_review/blocked/cancelled/未知) 无模板,
// 工作流不启动,②-3/②-4 回调不触发 —— 锁定回调边界。
for non_target in ["todo", "in_review", "blocked", "cancelled", "merged", ""] {
assert!(
template_for(non_target).is_none(),
"非推进链 target {non_target:?} 不应有模板(否则回调边界被扩大)"
);
}
}
#[test]
fn testing_and_done_templates_have_reject_path() {
// ②-4 失败回调依赖模板内 human reject 走 Err → 工作流 failed → regression_target。
// testing(done 同理)模板的 human 节点 options 必须含「拒绝」,否则失败回调不可达。
// (in_progress 模板只有 ai 节点,失败是 ai 异常,无 reject 路径 —— 单独验证。)
for target in ["testing", "done"] {
let dag = template_for(target).unwrap_or_else(|| panic!("{target} 模板应存在"));
let human = dag.nodes.values().find(|n| n.node_type == "human").unwrap_or_else(|| {
panic!("{target} 模板应含 human 节点(承载 reject → failed → ②-4 回调)")
});
let options = human
.config
.get("options")
.and_then(|v| v.as_array())
.unwrap_or_else(|| panic!("{target} human 节点 config 应有 options 数组"));
let opts: Vec<&str> = options.iter().filter_map(|v| v.as_str()).collect();
assert!(
opts.contains(&"拒绝"),
"{target} human options 应含「拒绝」(否则 ②-4 失败回调路径不可达)"
);
}
}
}
-14
View File
@@ -1,14 +0,0 @@
[package]
name = "df-plugin"
version = "0.1.0"
edition = "2021"
[dependencies]
df-core = { path = "../df-core" }
df-workflow = { path = "../df-workflow" }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
-66
View File
@@ -1,66 +0,0 @@
//! 插件宿主环境 — 管理插件生命周期与沙箱
use std::collections::HashMap;
use df_core::types::PluginId;
use df_workflow::node::Node;
use crate::loader::{LoadedPlugin, PluginLoader, PluginMetadata};
/// 插件宿主环境
pub struct PluginHost {
/// 插件加载器
loader: PluginLoader,
/// 已加载的插件
plugins: HashMap<PluginId, LoadedPlugin>,
}
impl PluginHost {
/// 创建宿主环境
pub fn new(loader: PluginLoader) -> Self {
Self {
loader,
plugins: HashMap::new(),
}
}
/// 初始化:加载所有插件
pub async fn initialize(&mut self) -> anyhow::Result<()> {
self.plugins = self.loader.load_all()?;
tracing::info!("插件宿主环境初始化完成,加载了 {} 个插件", self.plugins.len());
Ok(())
}
/// 获取所有已注册的节点
pub fn all_nodes(&self) -> Vec<(&PluginId, &Box<dyn Node>)> {
let mut nodes = Vec::new();
for (plugin_id, plugin) in &self.plugins {
for node in &plugin.nodes {
nodes.push((plugin_id, node));
}
}
nodes
}
/// 获取指定插件的元数据
pub fn get_metadata(&self, plugin_id: &PluginId) -> Option<&PluginMetadata> {
self.plugins.get(plugin_id).map(|p| &p.metadata)
}
/// 卸载指定插件
///
/// TODO: 实现插件卸载(清理资源、取消注册节点等)
pub fn unload(&mut self, plugin_id: &PluginId) -> anyhow::Result<()> {
if let Some(plugin) = self.plugins.remove(plugin_id) {
tracing::info!("插件 {} 已卸载", plugin.metadata.name);
}
Ok(())
}
/// 关闭宿主环境,卸载所有插件
pub fn shutdown(&mut self) {
let count = self.plugins.len();
self.plugins.clear();
tracing::info!("插件宿主环境关闭,卸载了 {} 个插件", count);
}
}
-5
View File
@@ -1,5 +0,0 @@
//! df-plugin: 插件系统 — 插件加载、宿主环境、SDK
pub mod host;
pub mod loader;
pub mod sdk;
-94
View File
@@ -1,94 +0,0 @@
//! 插件加载器 — 发现、加载、注册插件
use std::collections::HashMap;
use std::path::PathBuf;
use df_core::types::PluginId;
use df_workflow::node::Node;
/// 插件元数据
#[derive(Debug, Clone)]
pub struct PluginMetadata {
/// 插件 ID
pub id: PluginId,
/// 插件名称
pub name: String,
/// 版本
pub version: String,
/// 描述
pub description: String,
/// 作者
pub author: Option<String>,
/// 入口文件路径
pub entry_path: PathBuf,
}
/// 已加载的插件
pub struct LoadedPlugin {
pub metadata: PluginMetadata,
pub nodes: Vec<Box<dyn Node>>,
}
/// 插件加载器
pub struct PluginLoader {
/// 插件搜索目录
search_dirs: Vec<PathBuf>,
}
impl PluginLoader {
/// 创建插件加载器
pub fn new() -> Self {
Self {
search_dirs: Vec::new(),
}
}
/// 添加插件搜索目录
pub fn add_search_dir(&mut self, dir: PathBuf) {
self.search_dirs.push(dir);
}
/// 扫描并发现所有可用插件
///
/// TODO: 实现文件系统扫描、manifest 解析
pub fn discover(&self) -> anyhow::Result<Vec<PluginMetadata>> {
tracing::info!("扫描插件目录: {:?}", self.search_dirs);
// TODO: 实现插件发现逻辑
Ok(Vec::new())
}
/// 加载指定插件
///
/// TODO: 实现动态库加载(dlopen/LoadLibrary)或 WASM 加载
pub fn load(&self, _metadata: &PluginMetadata) -> anyhow::Result<LoadedPlugin> {
// TODO: 实现插件加载
tracing::warn!("插件加载尚未实现");
Err(anyhow::anyhow!("插件加载尚未实现"))
}
/// 批量加载所有发现的插件
pub fn load_all(&self) -> anyhow::Result<HashMap<PluginId, LoadedPlugin>> {
let metadata_list = self.discover()?;
let mut loaded = HashMap::new();
for meta in metadata_list {
match self.load(&meta) {
Ok(plugin) => {
tracing::info!("插件 {} v{} 加载成功", meta.name, meta.version);
loaded.insert(meta.id.clone(), plugin);
}
Err(e) => {
tracing::error!("插件 {} 加载失败: {}", meta.name, e);
}
}
}
Ok(loaded)
}
}
impl Default for PluginLoader {
fn default() -> Self {
Self::new()
}
}
-90
View File
@@ -1,90 +0,0 @@
//! 插件 SDK — 插件开发者使用的工具与trait
use async_trait::async_trait;
use df_workflow::node::Node;
/// 插件入口 trait — 所有插件必须实现
#[async_trait]
pub trait Plugin: Send + Sync {
/// 插件 ID
fn id(&self) -> &str;
/// 插件名称
fn name(&self) -> &str;
/// 插件版本
fn version(&self) -> &str;
/// 注册的所有节点
fn nodes(&self) -> Vec<Box<dyn Node>>;
/// 插件初始化(可选)
async fn initialize(&self) -> anyhow::Result<()> {
Ok(())
}
/// 插件销毁(可选)
async fn shutdown(&self) -> anyhow::Result<()> {
Ok(())
}
}
/// 插件构建器 — 简化插件定义
pub struct PluginBuilder {
id: String,
name: String,
version: String,
nodes: Vec<Box<dyn Node>>,
}
impl PluginBuilder {
/// 创建插件构建器
pub fn new(id: impl Into<String>, name: impl Into<String>, version: impl Into<String>) -> Self {
Self {
id: id.into(),
name: name.into(),
version: version.into(),
nodes: Vec::new(),
}
}
/// 注册节点
pub fn node(mut self, node: Box<dyn Node>) -> Self {
self.nodes.push(node);
self
}
/// 获取插件 ID
pub fn id(&self) -> &str {
&self.id
}
/// 获取插件名称
pub fn name(&self) -> &str {
&self.name
}
/// 获取插件版本
pub fn version(&self) -> &str {
&self.version
}
/// 构建并获取所有节点
pub fn build(self) -> Vec<Box<dyn Node>> {
self.nodes
}
}
/// 声明插件的宏(简化版)
///
/// TODO: 实现完整的声明宏
/// 使用示例:
/// ```
/// declare_plugin!("my-plugin", "My Plugin", "0.1.0");
/// ```
#[macro_export]
macro_rules! declare_plugin {
($id:expr, $name:expr, $version:expr) => {
// TODO: 实现宏
};
}
+1 -1
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
df-core = { path = "../df-core" }
df-types = { path = "../df-types" }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
-52
View File
@@ -1,52 +0,0 @@
//! 项目上下文 — 项目运行时的环境与配置信息
use serde::{Deserialize, Serialize};
use df_core::types::ProjectId;
/// 项目上下文
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectContext {
/// 项目 ID
pub project_id: ProjectId,
/// 项目根目录(本地文件系统路径)
pub root_path: Option<String>,
/// Git 仓库 URL
pub repo_url: Option<String>,
/// 当前分支
pub current_branch: Option<String>,
/// 环境变量
pub env_vars: std::collections::HashMap<String, String>,
/// 技术栈
pub tech_stack: Vec<String>,
/// AI 上下文(项目相关的 AI 记忆)
pub ai_context: Option<AiContext>,
}
/// AI 上下文信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AiContext {
/// 项目摘要
pub summary: String,
/// 架构描述
pub architecture: Option<String>,
/// 关键决策记录
pub decisions: Vec<String>,
/// 最近修改摘要
pub recent_changes: Vec<String>,
}
impl ProjectContext {
/// 创建空上下文
pub fn new(project_id: ProjectId) -> Self {
Self {
project_id,
root_path: None,
repo_url: None,
current_branch: None,
env_vars: std::collections::HashMap::new(),
tech_stack: Vec::new(),
ai_context: None,
}
}
}
+2 -4
View File
@@ -1,6 +1,4 @@
//! df-project: 项目管理 — 项目创建、调度、上下文、时间线
//! df-project: 项目管理 — 项目创建、技术栈探测
pub mod context;
pub mod manager;
pub mod scheduler;
pub mod timeline;
pub mod scan;
+4 -14
View File
@@ -2,7 +2,7 @@
use serde::{Deserialize, Serialize};
use df_core::types::{IdeaId, Priority, ProjectId, ProjectStatus};
use df_types::types::{IdeaId, Priority, ProjectId, ProjectStatus};
/// 项目实体
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -43,13 +43,12 @@ pub struct CreateProjectInput {
pub struct ProjectManager;
impl ProjectManager {
/// 创建新项目
///
/// TODO: 接入存储层持久化
/// 创建新项目 — 构造领域实体(不落库);持久化由调用方经 storage 层 ProjectRecord 映射完成
/// (见 commands/idea.rs::promote_idea)。领域层不依赖 storage,保持分层。
pub fn create(input: CreateProjectInput) -> Project {
let now = chrono::Utc::now();
Project {
id: df_core::types::new_id(),
id: df_types::types::new_id(),
name: input.name,
description: input.description,
status: ProjectStatus::Planning,
@@ -72,13 +71,4 @@ impl ProjectManager {
})
}
/// 更新项目状态
///
/// TODO: 状态转换校验
pub fn transition_status(project: &mut Project, new_status: ProjectStatus) -> anyhow::Result<()> {
// TODO: 校验状态转换是否合法
project.status = new_status;
project.updated_at = chrono::Utc::now();
Ok(())
}
}
+741
View File
@@ -0,0 +1,741 @@
//! 项目技术栈探测 — 浅读根目录标志文件识别技术栈
//!
//! 纯函数、零状态、零外部依赖(仅 std + serde_json + anyhow)。
//! 只读根目录标志文件,不递归遍历(控制性能与安全)。
//!
//! 供 commands 层薄封装暴露为 IPC 命令,新建/导入项目时自动填充 `ProjectRecord.stack`;
//! 未来 df-ai/df-workflow 亦可复用以感知「项目是什么技术栈」。
//!
//! README 噪音处理与图片收集已抽离至 `scan_helpers`(2026-06-19 自底向上拆分),
//! 此处通过 `pub use` 重导出 `ImageRef` 等保持外部路径 `df_project::scan::*` 不变。
use std::path::Path;
use anyhow::{Context, Result};
#[path = "scan_helpers.rs"]
mod scan_helpers;
pub use scan_helpers::ImageRef;
use scan_helpers::{collect_images, strip_readme_noise};
/// 规范化路径用于比较:canonicalize 解析绝对规范路径(失败降级),
/// 统一正斜杠 + 小写。防 `C:\a\b` vs `C:/a/b/` 绕过重复检查。
/// 注:仅用于比较,存库保留用户输入的原始可读路径。
pub fn normalize_path(p: &str) -> String {
match Path::new(p).canonicalize() {
Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(),
Err(_) => p
.trim_end_matches(['\\', '/'])
.replace('\\', "/")
.to_lowercase(),
}
}
/// 探测目录的技术栈
///
/// 返回去重后的技术栈标签数组(如 `["rust","vue","tauri","typescript"]`)。
/// 空数组表示未识别出任何已知标志(空目录或非常规项目)。非目录返回 Err。
pub fn detect_stack(root: &Path) -> Result<Vec<String>> {
if !root.is_dir() {
anyhow::bail!("路径不是目录: {}", root.display());
}
let mut stack: Vec<String> = Vec::new();
// ── 后端/系统语言 ──
if root.join("Cargo.toml").exists() {
push_unique(&mut stack, "rust");
}
if root.join("go.mod").exists() {
push_unique(&mut stack, "go");
}
if root.join("pom.xml").exists()
|| root.join("build.gradle").exists()
|| root.join("build.gradle.kts").exists()
{
push_unique(&mut stack, "java");
}
if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() {
push_unique(&mut stack, "python");
}
// C#: 根目录存在 .csproj 文件(仅一层)
if has_file_with_ext(root, "csproj") {
push_unique(&mut stack, "csharp");
}
// ── Tauri 桌面应用(通常含 src-tauri 目录) ──
if root.join("src-tauri").is_dir() {
push_unique(&mut stack, "tauri");
}
// ── 前端/Node:解析 package.json 的依赖推断框架 ──
if root.join("package.json").exists() {
if let Ok(deps) = read_package_deps(root.join("package.json")) {
if deps.iter().any(|d| d == "vue") {
push_unique(&mut stack, "vue");
}
if deps.iter().any(|d| d == "react" || d == "react-dom") {
push_unique(&mut stack, "react");
}
if deps.iter().any(|d| d == "@angular/core") {
push_unique(&mut stack, "angular");
}
if deps.iter().any(|d| d == "svelte") {
push_unique(&mut stack, "svelte");
}
if deps.iter().any(|d| d == "next") {
push_unique(&mut stack, "next");
}
if deps.iter().any(|d| d == "vite") {
push_unique(&mut stack, "vite");
}
if deps.iter().any(|d| d == "typescript") {
push_unique(&mut stack, "typescript");
}
if deps.iter().any(|d| d == "express" || d == "koa" || d == "fastify") {
push_unique(&mut stack, "node");
}
}
}
Ok(stack)
}
// ============================================================
// 历史项目发现 — monorepo 识别 + 子项目展开(纯规则,不跑 LLM)
// ============================================================
/// monorepo 工作区配置文件名(JS 生态主流:pnpm/lerna/turbo/nx)
const MONOREPO_MARKERS: &[&str] = &[
"pnpm-workspace.yaml",
"lerna.json",
"turbo.json",
"nx.json",
];
/// 判定目录是否为 monorepo 根(JS 生态主流工作区管理器)。
///
/// 命中任一即视为 monorepo:
/// - pnpm-workspace.yaml / lerna.json / turbo.json / nx.json 存在
/// - package.json 含 `workspaces` 字段(npm/yarn workspaces)
pub fn is_monorepo(root: &Path) -> bool {
if !root.is_dir() {
return false;
}
for marker in MONOREPO_MARKERS {
if root.join(marker).is_file() {
return true;
}
}
// package.json workspaces 字段(npm/yarn)。数组(`["packages/*"]`)或对象
// (`{"packages":[...]}`,Yarn)均为真正的工作区;JSON 显式 null 表示「无」,
// 不应误判为 monorepo(`.is_some()` 对 key 存在但值为 null 仍返回 true → 误报)。
let pkg_path = root.join("package.json");
if pkg_path.is_file() {
if let Ok(content) = std::fs::read_to_string(&pkg_path) {
if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&content) {
if pkg.get("workspaces").is_some_and(|v| !v.is_null()) {
return true;
}
}
}
}
false
}
/// 单个发现的候选项目(monorepo 子项目或独立项目)
#[derive(Debug, Clone)]
pub struct DiscoveredProject {
/// 项目根目录绝对路径
pub path: String,
/// 推断的项目名(目录名)
pub name: String,
/// 规则探测的技术栈(空=未识别)
pub stack: Vec<String>,
/// 是否为 monorepo 根(便于前端标记)
pub is_monorepo: bool,
}
/// 在指定根目录下发现候选项目。
///
/// 策略(只展开一层,不做深递归):
/// 1. 根目录本身有项目标志(Cargo.toml/package.json/go.mod 等)→ 根为独立项目
/// 2. 根目录是 monorepo → 展开 packages/\*/apps/\* 直接子目录(各子目录跑 detect_stack 过滤空)
/// 3. 否则:扫根的直接子目录,凡 detect_stack 非空的视为候选项目
///
/// 不跑 LLM(快),不读源码。空 stack 的目录在 monorepo 展开/子目录扫描时被过滤。
pub fn discover_projects(root: &Path) -> Result<Vec<DiscoveredProject>> {
if !root.is_dir() {
anyhow::bail!("路径不是目录: {}", root.display());
}
let mut out: Vec<DiscoveredProject> = Vec::new();
let mono = is_monorepo(root);
// 1. 根目录自身是项目(有 manifest 标志)
if has_project_manifest(root) {
let stack = detect_stack(root).unwrap_or_default();
out.push(DiscoveredProject {
path: root.to_string_lossy().to_string(),
name: root
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| root.to_string_lossy().to_string()),
stack,
is_monorepo: mono,
});
}
// 2. monorepo → 展开 packages/* apps/* 直接子目录
// 3. 普通目录 → 扫直接子目录,凡 detect_stack 非空的入选
let scan_globs: &[&str] = if mono {
&["packages", "apps"]
} else {
&[""]
};
for glob in scan_globs {
let target = if glob.is_empty() {
root.to_path_buf()
} else {
root.join(glob)
};
if !target.is_dir() {
continue;
}
let Ok(entries) = std::fs::read_dir(&target) else {
continue;
};
for e in entries.flatten() {
let p = e.path();
if !p.is_dir() {
continue;
}
let name = e.file_name().to_string_lossy().to_string();
if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) || name.starts_with('.') {
continue;
}
// 必须有项目标志 + detect_stack 非空
if !has_project_manifest(&p) {
continue;
}
let stack = match detect_stack(&p) {
Ok(s) => s,
Err(_) => continue,
};
if stack.is_empty() {
continue;
}
out.push(DiscoveredProject {
path: p.to_string_lossy().to_string(),
name,
stack,
is_monorepo: false,
});
}
}
Ok(out)
}
/// 目录是否含任一项目清单标志文件
fn has_project_manifest(dir: &Path) -> bool {
const MARKS: &[&str] = &[
"Cargo.toml",
"package.json",
"go.mod",
"pyproject.toml",
"requirements.txt",
"pom.xml",
"build.gradle",
"build.gradle.kts",
];
MARKS.iter().any(|m| dir.join(m).is_file()) || has_file_with_ext(dir, "csproj")
}
/// 解析 package.json,合并 dependencies + devDependencies 的包名
fn read_package_deps(path: impl AsRef<Path>) -> Result<Vec<String>> {
let content = std::fs::read_to_string(path.as_ref())
.with_context(|| format!("读取 package.json 失败: {}", path.as_ref().display()))?;
let pkg: serde_json::Value = serde_json::from_str(&content).context("解析 package.json 失败")?;
let mut names = Vec::new();
for key in &["dependencies", "devDependencies"] {
if let Some(obj) = pkg.get(key).and_then(|v| v.as_object()) {
for k in obj.keys() {
names.push(k.clone());
}
}
}
Ok(names)
}
/// 目录下是否存在指定扩展名的文件(仅一层)
fn has_file_with_ext(dir: &Path, ext: &str) -> bool {
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
entries.flatten().any(|e| {
e.path()
.extension()
.and_then(|x| x.to_str())
.map(|x| x == ext)
.unwrap_or(false)
})
}
// ============================================================
// 项目采样(供 LLM 分析基础信息) — 纯 IO,控 token 不读源码
// ============================================================
/// 提取项目描述 — 读 README 首段(首个非标题非空段落,截断 200 字)。
///
/// 用于「导入历史项目」时自动填充 description。无 README 或解析失败返回 None。
/// 首段定义:跳过开头标题行(# / ## …)、空行、HTML 注释与 badge 图片/HTML 行等噪声,
/// 取首个含实质文本的段落(连续多行直到空行);按字符截断至 200 字避免超长。
pub fn extract_description(root: &Path) -> Option<String> {
// 复用 read_readme_raw 的查找逻辑(支持 README.md / README.zh.md 等变体)
let content = read_readme_raw(root)?;
let mut text = String::new();
let mut started = false;
for raw_line in content.lines() {
let line = raw_line.trim();
if line.is_empty() {
if started {
break; // 段落结束
}
continue; // 首段尚未开始,跳过开头空行
}
// 段落开始后不再跳行,直接累加
if !started {
// 跳过标题 / HTML 注释 / badge 图片 / HTML 标签等噪声前导行
if line.starts_with('#')
|| line.starts_with("<!--")
|| line.starts_with('!')
|| line.starts_with('<')
{
continue;
}
started = true;
}
if !text.is_empty() {
text.push(' ');
}
text.push_str(line);
}
let desc = text.trim().to_string();
if desc.is_empty() {
return None;
}
Some(truncate_chars(&desc, EXTRACT_DESC_MAX))
}
/// extract_description 最大字符数
const EXTRACT_DESC_MAX: usize = 200;
/// 项目采样结果 — README(剥噪音后) + 目录树(2层) + 清单文件片段 + 内容图引用
#[derive(Debug, Clone)]
pub struct ProjectSample {
pub readme: Option<String>,
pub tree: Vec<String>,
/// (文件名, 截断内容)
pub manifests: Vec<(String, String)>,
/// README 内的内容图引用(架构图/截图等,跳徽章)
pub images: Vec<ImageRef>,
}
const SAMPLE_README_MAX: usize = 8000;
const SAMPLE_MANIFEST_MAX: usize = 1500;
const SAMPLE_TREE_MAX: usize = 80;
/// 目录树过滤的噪音目录(依赖产物/构建/缓存/IDE)
const SAMPLE_IGNORED_DIRS: &[&str] = &[
"node_modules", "target", ".git", "dist", "build", ".next", "venv", ".venv",
"__pycache__", ".idea", ".vscode", ".cache", "out", "coverage", ".svelte-kit",
".turbo", ".angular", ".gradle", "vendor",
];
/// 读 README 原始内容(不做处理)
fn read_readme_raw(root: &Path) -> Option<String> {
for name in &["README.md", "README.MD", "README", "README.zh.md", "README_zh.md", "README_EN.md", "readme.md"] {
let p = root.join(name);
if p.is_file() {
if let Ok(content) = std::fs::read_to_string(&p) {
return Some(content);
}
}
}
None
}
/// 采集项目采样(README + 目录树 + 清单 + 内容图),供 LLM 分析填基础信息
pub fn collect_sample(root: &Path) -> Result<ProjectSample> {
if !root.is_dir() {
anyhow::bail!("路径不是目录: {}", root.display());
}
let raw = read_readme_raw(root);
let (readme, images) = match raw {
Some(text) => {
let images = collect_images(&text);
let cleaned = strip_readme_noise(&text);
if cleaned.trim().is_empty() {
(None, images)
} else {
(Some(truncate_chars(&cleaned, SAMPLE_README_MAX)), images)
}
}
None => (None, Vec::new()),
};
Ok(ProjectSample {
readme,
tree: collect_tree(root),
manifests: collect_manifests(root),
images,
})
}
/// 目录树(根 + 一层子目录),过滤噪音目录,控条目数
fn collect_tree(root: &Path) -> Vec<String> {
let mut lines = Vec::new();
let mut count = 0usize;
collect_tree_level(root, "", &mut lines, &mut count, false);
lines
}
fn collect_tree_level(dir: &Path, prefix: &str, lines: &mut Vec<String>, count: &mut usize, is_sub: bool) {
if *count >= SAMPLE_TREE_MAX {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else { return };
let mut items: Vec<_> = entries.flatten().collect();
items.sort_by_key(|e| e.file_name());
for e in items {
if *count >= SAMPLE_TREE_MAX {
return;
}
let name = e.file_name().to_string_lossy().to_string();
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
if is_dir {
if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) {
continue;
}
lines.push(format!("{}{}/", prefix, name));
*count += 1;
// 仅根目录的子目录展开一层(is_sub=true 不再递归)
if !is_sub {
collect_tree_level(&e.path(), &format!("{} ", prefix), lines, count, true);
}
} else {
// 跳过隐藏文件(保留 .gitignore 作 git 标识)
if name.starts_with('.') && name != ".gitignore" {
continue;
}
lines.push(format!("{}{}", prefix, name));
*count += 1;
}
}
}
fn collect_manifests(root: &Path) -> Vec<(String, String)> {
let mut out = Vec::new();
for name in &["package.json", "Cargo.toml", "go.mod", "pyproject.toml", "pom.xml", "build.gradle", "build.gradle.kts"] {
let p = root.join(name);
if let Ok(content) = std::fs::read_to_string(&p) {
out.push(((*name).to_string(), truncate_chars(&content, SAMPLE_MANIFEST_MAX)));
}
}
out
}
/// 按字符数截断(避免截断 UTF-8 多字节边界)
fn truncate_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let truncated: String = s.chars().take(max).collect();
format!("{}…(已截断)", truncated)
}
/// 去重 push
fn push_unique(stack: &mut Vec<String>, s: &str) {
if !stack.iter().any(|x| x == s) {
stack.push(s.to_string());
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
/// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径
fn scratch(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("df-project-scan-{}-{}", name, std::process::id()));
let _ = fs::remove_dir_all(&p);
fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn non_dir_errors() {
let r = detect_stack(Path::new("definitely-not-exist-xyz-123"));
assert!(r.is_err());
}
#[test]
fn detects_rust() {
let d = scratch("rust");
fs::write(d.join("Cargo.toml"), "").unwrap();
let s = detect_stack(&d).unwrap();
assert!(s.contains(&"rust".to_string()));
fs::remove_dir_all(&d).ok();
}
#[test]
fn detects_full_stack() {
// 模拟 DevFlow 自身:rust + tauri + vue + vite + typescript
let d = scratch("full");
fs::write(d.join("Cargo.toml"), "").unwrap();
fs::create_dir(d.join("src-tauri")).unwrap();
fs::write(
d.join("package.json"),
r#"{"dependencies":{"vue":"^3.5.0"},"devDependencies":{"vite":"^6.0.0","typescript":"~5.6.0"}}"#,
)
.unwrap();
let s = detect_stack(&d).unwrap();
assert!(s.contains(&"rust".to_string()));
assert!(s.contains(&"tauri".to_string()));
assert!(s.contains(&"vue".to_string()));
assert!(s.contains(&"vite".to_string()));
assert!(s.contains(&"typescript".to_string()));
fs::remove_dir_all(&d).ok();
}
#[test]
fn empty_dir_returns_empty() {
let d = scratch("empty");
let s = detect_stack(&d).unwrap();
assert!(s.is_empty());
fs::remove_dir_all(&d).ok();
}
#[test]
fn collects_sample() {
let d = scratch("sample");
fs::write(
d.join("README.md"),
"# Test\n\n![badge](https://img.shields.io/badge/x-y-green)\n\n![arch](./docs/arch.png)\n\nA test project.\nMore.",
)
.unwrap();
fs::write(d.join("package.json"), r#"{"name":"x","dependencies":{"vue":"3"}}"#).unwrap();
fs::create_dir(d.join("src")).unwrap();
fs::write(d.join("src/main.ts"), "x").unwrap();
fs::create_dir(d.join("node_modules")).unwrap();
fs::write(d.join("node_modules/junk.json"), "x").unwrap();
let s = collect_sample(&d).unwrap();
// 徽章行剥,内容图 + 正文保留
let readme = s.readme.as_deref().unwrap_or("");
assert!(readme.contains("test project"), "readme={readme}");
assert!(!readme.contains("shields.io"), "徽章未剥: {readme}");
assert!(readme.contains("docs/arch.png"), "内容图丢失: {readme}");
// 内容图收集(badge 跳,arch 留)
assert_eq!(s.images.len(), 1);
assert_eq!(s.images[0].src, "./docs/arch.png");
assert!(s.manifests.iter().any(|(n, _)| n == "package.json"));
assert!(s.tree.iter().any(|t| t.contains("src")));
// node_modules 应被过滤
assert!(s.tree.iter().all(|t| !t.contains("node_modules")));
fs::remove_dir_all(&d).ok();
}
#[test]
fn strips_frontmatter_and_toc_and_badges() {
let d = scratch("noise");
let readme = "---\ntitle: Foo\n---\n\n# Foo\n\n<!-- hidden comment -->\n\n[Install](#install)\n\n- [Usage](#usage)\n\n![build](https://img.shields.io/badge/build-passing)\n\nThis is the real intro.\n";
fs::write(d.join("README.md"), readme).unwrap();
let s = collect_sample(&d).unwrap();
let r = s.readme.as_deref().unwrap_or("");
assert!(r.contains("# Foo"), "标题应保留: {r}");
assert!(r.contains("real intro"), "正文应保留: {r}");
assert!(!r.contains("hidden comment"), "HTML 注释未剥: {r}");
assert!(!r.contains("shields.io"), "徽章未剥: {r}");
assert!(!r.contains("[Install](#install)"), "TOC 锚点未剥: {r}");
assert!(!r.contains("[Usage](#usage)"), "TOC 列表项未剥: {r}");
assert!(!r.contains("title: Foo"), "frontmatter 未剥: {r}");
fs::remove_dir_all(&d).ok();
}
#[test]
fn detects_monorepo_pnpm() {
let d = scratch("mono-pnpm");
fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap();
assert!(is_monorepo(&d));
fs::remove_dir_all(&d).ok();
}
#[test]
fn detects_monorepo_npm_workspaces() {
let d = scratch("mono-npm");
fs::write(
d.join("package.json"),
r#"{"name":"root","workspaces":["packages/*"]}"#,
)
.unwrap();
assert!(is_monorepo(&d));
fs::remove_dir_all(&d).ok();
}
#[test]
fn detects_non_monorepo() {
let d = scratch("nonmono");
fs::write(d.join("package.json"), r#"{"name":"x"}"#).unwrap();
assert!(!is_monorepo(&d));
fs::remove_dir_all(&d).ok();
}
#[test]
fn discover_monorepo_children() {
let d = scratch("discover-mono");
fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap();
// 子项目:packages/web(有 package.json + vue)、packages/cli(有 Cargo.toml)
fs::create_dir_all(d.join("packages/web")).unwrap();
fs::write(
d.join("packages/web/package.json"),
r#"{"name":"web","dependencies":{"vue":"3"}}"#,
)
.unwrap();
fs::create_dir_all(d.join("packages/cli")).unwrap();
fs::write(d.join("packages/cli/Cargo.toml"), "[package]\nname=\"cli\"\n").unwrap();
// 空 stack 子目录应过滤
fs::create_dir_all(d.join("packages/empty")).unwrap();
fs::write(d.join("packages/empty/x.txt"), "x").unwrap();
let found = discover_projects(&d).unwrap();
// 根自身无 manifest 不入选;packages/web + packages/cli 入选;empty 过滤
let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"web"), "names={names:?}");
assert!(names.contains(&"cli"), "names={names:?}");
assert!(!names.contains(&"empty"), "空 stack 未过滤: {names:?}");
fs::remove_dir_all(&d).ok();
}
#[test]
fn discover_flat_children() {
// 非 monorepo:扫根直接子目录中 detect_stack 非空的
let d = scratch("discover-flat");
fs::create_dir_all(d.join("proj-a")).unwrap();
fs::write(d.join("proj-a/Cargo.toml"), "").unwrap();
fs::create_dir_all(d.join("not-a-project")).unwrap();
fs::write(d.join("not-a-project/readme.txt"), "x").unwrap();
let found = discover_projects(&d).unwrap();
let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"proj-a"), "names={names:?}");
assert!(!names.contains(&"not-a-project"), "空 stack 未过滤: {names:?}");
fs::remove_dir_all(&d).ok();
}
#[test]
fn extract_desc_skips_title_badge() {
let d = scratch("desc");
// 标题 + badge 图片行应跳过,首段为正文
fs::write(
d.join("README.md"),
"# My Project\n\n![badge](https://x.io/badge.svg)\n\n这是一个示例项目,用于演示。\n第二行正文。\n\n## 安装",
)
.unwrap();
let desc = extract_description(&d).unwrap();
assert!(desc.contains("示例项目"));
assert!(desc.contains("第二行正文"));
assert!(!desc.contains("My Project"));
assert!(!desc.contains("badge"));
assert!(!desc.contains("安装"));
fs::remove_dir_all(&d).ok();
}
#[test]
fn extract_desc_no_readme_returns_none() {
let d = scratch("nodesc");
assert!(extract_description(&d).is_none());
fs::remove_dir_all(&d).ok();
}
#[test]
fn extract_desc_truncates_long() {
let d = scratch("longdesc");
let long = "啊".repeat(500);
fs::write(d.join("README.md"), format!("# T\n\n{long}")).unwrap();
let desc = extract_description(&d).unwrap();
// 截断到 200 字 + 末尾「…(已截断)」标记
assert!(desc.chars().count() <= 210);
assert!(desc.contains("已截断"));
fs::remove_dir_all(&d).ok();
}
// ── normalize_path 跨平台归一化 ──
// 锁定:① 反斜杠→正斜杠 ② 小写 ③ 尾部分隔符裁剪 ④ 相同逻辑路径相等(防重复录入绕过)。
// 注:canonicalize 成功路径在不同 OS 返回不同形态(Windows 带前缀),故只断言跨平台不变性,
// 不断言精确串,避免与平台耦合。
#[test]
fn normalize_path_uses_forward_slash_and_lowercase() {
// 不存在的路径走降级分支(trim + replace + lowercase)
let n = normalize_path(r"C:\Foo\Bar\");
assert!(
!n.contains('\\'),
"反斜杠未归一: {n}"
);
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
assert!(
!n.ends_with('/') && !n.ends_with('\\'),
"尾部分隔符未裁剪: {n}"
);
}
#[test]
fn normalize_path_equivalent_inputs_equal() {
// 两种写法应归一为同一串(去重场景)
let a = normalize_path(r"C:\Foo\Bar");
let b = normalize_path("C:/Foo/Bar/");
assert_eq!(a, b, "等价路径归一不等: {a} vs {b}");
}
#[test]
fn normalize_path_relative_is_lowercase_and_forward_slash() {
let n = normalize_path(r"..\Some\Path");
assert!(!n.contains('\\'), "反斜杠未归一: {n}");
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
}
// ── is_monorepo workspaces:null 防回归(wd2fnjh3s) ──
// .is_some_and(!is_null) 修复点:key 存在但值为 JSON null 时不得判为 monorepo。
#[test]
fn is_monorepo_workspaces_null_not_misclassified() {
let d = scratch("ws-null");
fs::write(
d.join("package.json"),
r#"{"name":"x","workspaces":null}"#,
)
.unwrap();
assert!(
!is_monorepo(&d),
"workspaces: null 不应判为 monorepo(回归 wd2fnjh3s)"
);
fs::remove_dir_all(&d).ok();
}
#[test]
fn is_monorepo_workspaces_object_treated_as_real() {
// Yarn 形式 {"packages":[...]} —— 非 null,应判为 monorepo
let d = scratch("ws-obj");
fs::write(
d.join("package.json"),
r#"{"name":"y","workspaces":{"packages":["packages/*"]}}"#,
)
.unwrap();
assert!(is_monorepo(&d), "Yarn workspaces 对象形式应判为 monorepo");
fs::remove_dir_all(&d).ok();
}
#[test]
fn is_monorepo_non_dir_returns_false() {
assert!(!is_monorepo(Path::new("definitely-not-exist-xyz-456")));
}
}
+324
View File
@@ -0,0 +1,324 @@
//! README 噪音处理与图片收集 — 纯函数,无 IO 依赖。
//!
//! 从 `scan.rs` 抽离(2026-06-19 自底向上拆分):徽章判定、内容图收集、
//! frontmatter/TOC/纯徽章行剥离等纯文本规则。无文件系统访问,便于单测与复用。
//!
//! 外部路径不变:`scan` 模块通过 `pub use` 重导出 `ImageRef` 等。
/// 内容图引用(README 内的架构图/截图等,喂 vision 用)。
/// 采样层只收集 alt+src,Phase 2 上线后由 commands 层读 base64 喂 vision。
/// 当前 ChatMessage.content:String(F-260614-05 未做)走纯文本降级,
/// 此结构仅为采样层留接口,不读 base64。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageRef {
pub alt: String,
pub src: String,
}
/// 徽章图域名(纯徽章图,跳过不喂 LLM)
const BADGE_HOSTS: &[&str] = &[
"img.shields.io",
"shields.io",
"badge.fury.io",
"badgen.net",
"badges.frapsoft.com",
"github.com/workflows", // GitHub Actions 工作流状态徽章
"travis-ci.org",
"coveralls.io",
"codecov.io",
"app.codacy.com",
"david-dm.org",
"circleci.com",
"ci.appveyor.com",
];
/// 徽章关键词(图 alt/src 含这些视为纯徽章图)
const BADGE_KEYWORDS: &[&str] = &[
"build",
"version",
"license",
"coverage",
"downloads",
"download",
"npm",
"pypi",
"crates.io",
"codeclimate",
"maintainability",
"stars",
"forks",
"issues",
"contributors",
"dependencies",
"devdependencies",
"circleci",
"travis",
"appveyor",
"coveralls",
"codecov",
];
/// 判定 markdown 图片 `![alt](src)` 是否为纯徽章(架构图/截图等保留)
fn is_badge_image(alt: &str, src: &str) -> bool {
// 域名命中(shields.io / badge.fury / GitHub Actions 等)
let src_lower = src.to_lowercase();
if BADGE_HOSTS.iter().any(|h| src_lower.contains(h)) {
return true;
}
// 关键词命中(alt 或 src 含 build/version/license/coverage 等)
let hay = format!("{alt} {src}").to_lowercase();
if BADGE_KEYWORDS.iter().any(|k| hay.contains(k)) {
return true;
}
false
}
/// 从 README 收集内容图(跳徽章),保留 markdown 原样引用
pub(crate) fn collect_images(content: &str) -> Vec<ImageRef> {
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
// 匹配 ![alt](src) —— 简单行级扫描,够用且无 regex 依赖
for line in content.lines() {
let mut rest = line;
while let Some(start) = rest.find("![") {
let after_bracket = &rest[start + 2..];
let Some(alt_end) = after_bracket.find("](") else { break };
let alt = after_bracket[..alt_end].to_string();
let after_paren = &after_bracket[alt_end + 2..];
let Some(paren_end) = after_paren.find(')') else { break };
let src = after_paren[..paren_end].to_string();
rest = &after_paren[paren_end + 1..];
if is_badge_image(&alt, &src) {
continue;
}
// 同 src 去重
if seen.insert(src.clone()) {
out.push(ImageRef { alt, src });
}
if out.len() >= 20 {
return out;
}
}
}
out
}
/// 剥 README 噪音:frontmatter(YAML/TOML 块) / HTML 注释 / TOC(纯链接目录行) /
/// 纯徽章图片行 / 纯徽章 HTML 行。保留标题、正文、内容图 markdown 原样。
pub(crate) fn strip_readme_noise(content: &str) -> String {
let mut out = Vec::new();
let mut lines = content.lines().peekable();
while let Some(line) = lines.next() {
let trimmed = line.trim();
// ── frontmatter 块(YAML `---` / TOML `+++`)──
if trimmed == "---" || trimmed == "+++" {
// 开头处的 frontmatter:跳到下一个匹配分隔符
let delim = trimmed;
let mut consumed_any = false;
for next in lines.by_ref() {
consumed_any = true;
if next.trim() == delim {
break;
}
}
let _ = consumed_any;
continue;
}
// ── HTML 注释块(<!-- ... -->,可能跨行)──
if trimmed.starts_with("<!--") {
if trimmed.contains("-->") {
// 单行注释,整行跳过
continue;
}
// 多行:吃到含 --> 的行
for next in lines.by_ref() {
if next.contains("-->") {
break;
}
}
continue;
}
// ── 纯徽章行:整行只剩图片/HTML 徽章(可能多个 ![..](..) 连排)──
if is_pure_badge_line(trimmed) {
continue;
}
// ── TOC:纯目录行(整行只有 [..](#anchor) 锚点链接,或 markdown 列表项仅锚点)──
if is_toc_line(trimmed) {
continue;
}
out.push(line.to_string());
}
out.join("\n")
}
/// 判定整行是否为纯徽章行(仅含图片/HTML badge,无其它实质文本)。
/// 如 `![build](https://img.shields.io/x) ![license](...) <a href="..."><img.../></a>`
pub(crate) fn is_pure_badge_line(line: &str) -> bool {
if line.is_empty() {
return false;
}
// 仅含图片语法/HTML 标签/空白/[alt] 片段,且至少有一个图片或 HTML img 标签
let mut content_found = false;
let mut text_only: String = String::new();
let mut rest = line;
loop {
// 找下一个 ![ 或 <img 或 <a
// 注:HTML 标签为纯 ASCII,用 to_ascii_lowercase 做「不区分大小写」匹配即可;
// 若用 to_lowercase(),非 ASCII 字符(如 İ→i̇、ß→ss)在小写化时改变字节长度,
// 返回的索引会偏离原 rest 的字节边界,后续 &rest[..pos]/&rest[pos..] 切到错位/panic。
// to_ascii_lowercase 仅改 ASCII 字母、保持非 ASCII 字节不变 → 字节长度不变 → 索引对齐。
let img_md = rest.find("![");
let img_html = rest.to_ascii_lowercase().find("<img");
let anchor_html = rest.to_ascii_lowercase().find("<a ");
let earliest = [img_md, img_html, anchor_html]
.into_iter()
.flatten()
.min();
let Some(pos) = earliest else {
// 剩余纯文本
text_only.push_str(rest);
break;
};
// pos 之前的文本进 text_only
text_only.push_str(&rest[..pos]);
let suffix = &rest[pos..];
let consumed = if suffix.starts_with("![") {
// markdown 图片:吃到 )
if let Some(end) = suffix.find(')') {
let alt = &suffix[2..suffix.find("](").unwrap_or(end)];
let src = &suffix[suffix.find("](").map(|i| i + 2).unwrap_or(end)..end];
if !is_badge_image(alt, src) {
// 内容图混在行里 → 非纯徽章行
return false;
}
content_found = true;
end + 1
} else {
// 不闭合,当普通文本
return false;
}
} else {
// HTML <img 或 <a 标签:视为徽章载体(HTML 行常见于此)
// 找 > 闭合
if let Some(end) = suffix.find('>') {
content_found = true;
end + 1
} else {
return false;
}
};
rest = &suffix[consumed..];
}
// 剩余 text_only 必须为空或纯空白/标点
let residual = text_only
.chars()
.filter(|c| !c.is_whitespace() && *c != '|' && *c != '-')
.collect::<String>();
content_found && residual.is_empty()
}
/// 判定 TOC 行:markdown 列表项仅含锚点链接 `- [..](#..)` 或整行仅锚点链接
fn is_toc_line(line: &str) -> bool {
if line.is_empty() {
return false;
}
let t = line.trim_start_matches(['-', '*', '+', ' ']);
// 形如 [text](#anchor) 或 [text](#anchor "title")
if !(t.starts_with('[') && t.contains("](")) {
return false;
}
// 取 ]( 后到行尾或空格,须以 # 开头
let Some(close) = t.find("](") else { return false };
let after = &t[close + 2..];
let target = after.trim_end_matches(')').split_whitespace().next().unwrap_or("");
target.starts_with('#')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn image_collection_skips_badges() {
let content = "![build](https://img.shields.io/x)\n![arch](./a.png)\n![version](https://badge.fury.io/js/y)\n![screenshot](screens/b.png)\n";
let imgs = collect_images(content);
// 只有 arch + screenshot,build/version 跳
assert_eq!(imgs.len(), 2);
assert!(imgs.iter().any(|i| i.src == "./a.png"));
assert!(imgs.iter().any(|i| i.src == "screens/b.png"));
assert!(imgs.iter().all(|i| !i.src.contains("shields.io")));
assert!(imgs.iter().all(|i| !i.src.contains("badge.fury")));
}
#[test]
fn collect_images_filters_badge_by_keyword() {
// 非徽章域名,但 alt/src 含关键词(build/license/coverage)应过滤
let content = "![build status](https://example.com/build.svg)\n![my license](https://example.com/lic.svg)\n![coverage](https://example.com/cov.svg)\n![architecture](./arch.png)\n";
let imgs = collect_images(content);
assert!(imgs.iter().all(|i| !i.alt.contains("build")), "build 关键词徽章未过滤");
assert!(imgs.iter().all(|i| !i.alt.contains("license")), "license 关键词徽章未过滤");
assert!(imgs.iter().all(|i| !i.alt.contains("coverage")), "coverage 关键词徽章未过滤");
assert_eq!(imgs.len(), 1, "应仅保留架构图: {imgs:?}");
assert_eq!(imgs[0].src, "./arch.png");
}
#[test]
fn collect_images_dedups_same_src() {
// 同 src 不同 alt 去重,只留首个
let content = "![first](./dup.png)\n![second](./dup.png)\n";
let imgs = collect_images(content);
assert_eq!(imgs.len(), 1, "同 src 应去重: {imgs:?}");
assert_eq!(imgs[0].alt, "first");
}
#[test]
fn collect_images_handles_multiple_in_one_line() {
// 同行多图 + 徽章混排:徽章过滤、内容图保留
let content = "![build](https://img.shields.io/x) ![shot](./shot.png) ![alt2](./two.png)\n";
let imgs = collect_images(content);
let srcs: Vec<_> = imgs.iter().map(|i| i.src.as_str()).collect();
assert!(srcs.contains(&"./shot.png"), "同行内容图丢失: {srcs:?}");
assert!(srcs.contains(&"./two.png"), "同行内容图丢失: {srcs:?}");
assert!(!srcs.contains(&"https://img.shields.io/x"), "同行徽章未过滤: {srcs:?}");
}
#[test]
fn collect_images_empty_and_malformed() {
// 无图片 / 不闭合语法不 panic,返回空
assert!(collect_images("plain text no images").is_empty());
assert!(collect_images("![unclosed alt").is_empty());
assert!(collect_images("![alt](no-close-paren").is_empty());
}
#[test]
fn is_pure_badge_line_true_for_shields_row() {
assert!(is_pure_badge_line(
"![build](https://img.shields.io/badge/x-y-green) ![license](https://img.shields.io/badge/l-mit-blue)"
));
}
#[test]
fn is_pure_badge_line_false_when_text_present() {
// 行内含实质文本 → 非纯徽章行
assert!(!is_pure_badge_line("![build](https://img.shields.io/x) and some real text"));
}
#[test]
fn is_pure_badge_line_false_for_content_image_only() {
// 纯内容图行(非徽章)不剥,保留 → 返回 false
assert!(!is_pure_badge_line("![arch](./docs/arch.png)"));
}
#[test]
fn strip_readme_noise_removes_frontmatter_htmlcomment_toc_badge() {
let readme = "---\ntitle: Foo\n---\n\n# Foo\n\n<!-- hidden comment -->\n\n[Install](#install)\n\n- [Usage](#usage)\n\n![build](https://img.shields.io/badge/build-passing)\n\nThis is the real intro.\n";
let cleaned = strip_readme_noise(readme);
assert!(cleaned.contains("# Foo"), "标题应保留: {cleaned}");
assert!(cleaned.contains("real intro"), "正文应保留: {cleaned}");
assert!(!cleaned.contains("hidden comment"), "HTML 注释未剥: {cleaned}");
assert!(!cleaned.contains("shields.io"), "徽章未剥: {cleaned}");
assert!(!cleaned.contains("[Install](#install)"), "TOC 锚点未剥: {cleaned}");
assert!(!cleaned.contains("[Usage](#usage)"), "TOC 列表项未剥: {cleaned}");
assert!(!cleaned.contains("title: Foo"), "frontmatter 未剥: {cleaned}");
}
}
-66
View File
@@ -1,66 +0,0 @@
//! 项目调度器 — 自动化任务分配与调度
use df_core::types::{ProjectId, TaskId};
/// 调度策略
#[derive(Debug, Clone, Copy)]
pub enum SchedulingStrategy {
/// FIFO(先进先出)
Fifo,
/// 按优先级调度
Priority,
/// 最短任务优先
ShortestFirst,
/// AI 智能调度
AiDriven,
}
/// 调度决策
#[derive(Debug, Clone)]
pub struct SchedulingDecision {
pub project_id: ProjectId,
pub task_order: Vec<TaskId>,
pub strategy: SchedulingStrategy,
}
/// 项目调度器
pub struct ProjectScheduler {
strategy: SchedulingStrategy,
}
impl ProjectScheduler {
/// 创建调度器
pub fn new(strategy: SchedulingStrategy) -> Self {
Self { strategy }
}
/// 为项目生成调度计划
///
/// TODO: 实现基于策略的调度算法
pub fn schedule(&self, project_id: &ProjectId) -> anyhow::Result<SchedulingDecision> {
match self.strategy {
SchedulingStrategy::Fifo => {
// TODO: 按创建时间排序
tracing::info!("FIFO 调度,项目: {}", project_id);
}
SchedulingStrategy::Priority => {
// TODO: 按优先级排序
tracing::info!("优先级调度,项目: {}", project_id);
}
SchedulingStrategy::ShortestFirst => {
// TODO: 按预估工作量排序
tracing::info!("最短任务优先调度,项目: {}", project_id);
}
SchedulingStrategy::AiDriven => {
// TODO: 接入 AI 进行智能调度
tracing::info!("AI 智能调度,项目: {}", project_id);
}
}
Ok(SchedulingDecision {
project_id: project_id.clone(),
task_order: Vec::new(),
strategy: self.strategy,
})
}
}
-64
View File
@@ -1,64 +0,0 @@
//! 项目时间线 — 里程碑与进度追踪
use serde::{Deserialize, Serialize};
use df_core::types::ProjectId;
/// 里程碑
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Milestone {
/// 唯一 ID
pub id: String,
/// 所属项目 ID
pub project_id: ProjectId,
/// 里程碑名称
pub name: String,
/// 描述
pub description: String,
/// 计划完成时间
pub due_date: Option<chrono::DateTime<chrono::Utc>>,
/// 实际完成时间
pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
/// 进度百分比 (0-100)
pub progress: u8,
/// 是否已完成
pub completed: bool,
}
/// 项目时间线
pub struct Timeline {
pub project_id: ProjectId,
pub milestones: Vec<Milestone>,
}
impl Timeline {
/// 创建空时间线
pub fn new(project_id: ProjectId) -> Self {
Self {
project_id,
milestones: Vec::new(),
}
}
/// 添加里程碑
pub fn add_milestone(&mut self, milestone: Milestone) {
self.milestones.push(milestone);
}
/// 计算整体进度
pub fn overall_progress(&self) -> f64 {
if self.milestones.is_empty() {
return 0.0;
}
let total: f64 = self.milestones.iter().map(|m| m.progress as f64).sum();
total / self.milestones.len() as f64
}
/// 获取下一个待完成的里程碑
pub fn next_milestone(&self) -> Option<&Milestone> {
self.milestones
.iter()
.filter(|m| !m.completed)
.min_by_key(|m| m.due_date)
}
}
-14
View File
@@ -1,14 +0,0 @@
[package]
name = "df-stages"
version = "0.1.0"
edition = "2021"
[dependencies]
df-core = { path = "../df-core" }
df-workflow = { path = "../df-workflow" }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
-50
View File
@@ -1,50 +0,0 @@
//! 编码阶段 — 代码生成与审查阶段模板
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeResult, NodeSchema};
/// 代码生成节点
pub struct CodeGenNode;
#[async_trait]
impl Node for CodeGenNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 调用 AI 生成代码
tracing::info!("代码生成节点执行");
Ok(df_workflow::node::NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({"type": "object"}),
output: serde_json::json!({"type": "object"}),
}
}
fn node_type(&self) -> &str {
"stage.coding.codegen"
}
}
/// 代码审查节点
pub struct CodeReviewNode;
#[async_trait]
impl Node for CodeReviewNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 调用 AI 审查代码
tracing::info!("代码审查节点执行");
Ok(df_workflow::node::NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({"type": "object"}),
output: serde_json::json!({"type": "object"}),
}
}
fn node_type(&self) -> &str {
"stage.coding.review"
}
}
-57
View File
@@ -1,57 +0,0 @@
//! 想法阶段 — 想法捕获与评估阶段模板
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeResult, NodeSchema};
/// 想法捕获节点
pub struct IdeaCaptureNode;
#[async_trait]
impl Node for IdeaCaptureNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 调用 df-ideas 的捕获功能
tracing::info!("想法捕获节点执行");
Ok(df_workflow::node::NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"title": { "type": "string" },
"description": { "type": "string" }
},
"required": ["title"]
}),
output: serde_json::json!({"type": "object"}),
}
}
fn node_type(&self) -> &str {
"stage.idea.capture"
}
}
/// 想法评估节点
pub struct IdeaEvalNode;
#[async_trait]
impl Node for IdeaEvalNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 调用 df-ideas 的评估功能
tracing::info!("想法评估节点执行");
Ok(df_workflow::node::NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({"type": "object"}),
output: serde_json::json!({"type": "object"}),
}
}
fn node_type(&self) -> &str {
"stage.idea.eval"
}
}
-7
View File
@@ -1,7 +0,0 @@
//! df-stages: 阶段插件 — 开发流程各阶段的节点模板注册
pub mod coding;
pub mod idea;
pub mod release;
pub mod requirement;
pub mod testing;
-73
View File
@@ -1,73 +0,0 @@
//! 发布阶段 — 构建、部署与发布阶段模板
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeResult, NodeSchema};
/// 构建节点
pub struct BuildNode;
#[async_trait]
impl Node for BuildNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 调用 df-execute 执行构建命令
tracing::info!("构建节点执行");
Ok(df_workflow::node::NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({"type": "object"}),
output: serde_json::json!({"type": "object"}),
}
}
fn node_type(&self) -> &str {
"stage.release.build"
}
}
/// 部署节点
pub struct DeployNode;
#[async_trait]
impl Node for DeployNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 调用 df-execute 执行部署命令
tracing::info!("部署节点执行");
Ok(df_workflow::node::NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({"type": "object"}),
output: serde_json::json!({"type": "object"}),
}
}
fn node_type(&self) -> &str {
"stage.release.deploy"
}
}
/// 发布公告节点
pub struct ReleaseNotesNode;
#[async_trait]
impl Node for ReleaseNotesNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 调用 AI 生成发布公告
tracing::info!("发布公告节点执行");
Ok(df_workflow::node::NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({"type": "object"}),
output: serde_json::json!({"type": "object"}),
}
}
fn node_type(&self) -> &str {
"stage.release.notes"
}
}
-50
View File
@@ -1,50 +0,0 @@
//! 需求阶段 — 需求分析与文档生成阶段模板
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeResult, NodeSchema};
/// 需求分析节点
pub struct RequirementAnalysisNode;
#[async_trait]
impl Node for RequirementAnalysisNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 调用 AI 分析需求
tracing::info!("需求分析节点执行");
Ok(df_workflow::node::NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({"type": "object"}),
output: serde_json::json!({"type": "object"}),
}
}
fn node_type(&self) -> &str {
"stage.requirement.analysis"
}
}
/// 需求文档生成节点
pub struct RequirementDocNode;
#[async_trait]
impl Node for RequirementDocNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 调用 AI 生成需求文档
tracing::info!("需求文档生成节点执行");
Ok(df_workflow::node::NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({"type": "object"}),
output: serde_json::json!({"type": "object"}),
}
}
fn node_type(&self) -> &str {
"stage.requirement.doc"
}
}
-50
View File
@@ -1,50 +0,0 @@
//! 测试阶段 — 测试生成与执行阶段模板
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeResult, NodeSchema};
/// 测试用例生成节点
pub struct TestGenNode;
#[async_trait]
impl Node for TestGenNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 调用 AI 生成测试用例
tracing::info!("测试用例生成节点执行");
Ok(df_workflow::node::NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({"type": "object"}),
output: serde_json::json!({"type": "object"}),
}
}
fn node_type(&self) -> &str {
"stage.testing.testgen"
}
}
/// 测试执行节点
pub struct TestRunNode;
#[async_trait]
impl Node for TestRunNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 调用 df-execute 执行测试命令
tracing::info!("测试执行节点执行");
Ok(df_workflow::node::NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({"type": "object"}),
output: serde_json::json!({"type": "object"}),
}
}
fn node_type(&self) -> &str {
"stage.testing.run"
}
}
+6 -1
View File
@@ -4,10 +4,15 @@ version = "0.1.0"
edition = "2021"
[dependencies]
df-core = { path = "../df-core" }
df-types = { path = "../df-types" }
# F-01 阶段1:ModelConfig/deserialize_model_configs 向后兼容反序列化(老字符串数组/新对象数组/null/空)
df-ai-core = { path = "../df-ai-core" }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true }
rusqlite = { workspace = true }
tracing = { workspace = true }
# keyring:FR-S1 密钥解析下沉,纯密钥逻辑(get/set/delete/resolve/ensure/migrate)的唯一源。
# build_provider_for 不下沉(依赖 df-ai 会引循环),留 src-tauri 转发壳。
keyring = { workspace = true }
-614
View File
@@ -1,614 +0,0 @@
//! Repository 模式的 CRUD 操作
//!
//! 为 7 张表各提供一个 Repo 结构体,统一实现 insert / get_by_id / list_all / query / update_field / delete。
use std::sync::Arc;
use rusqlite::{params, Connection, OptionalExtension, Row};
use tokio::sync::Mutex;
use df_core::error::{Error, Result};
use crate::db::Database;
use crate::models::{
AiConversationRecord, AiProviderRecord, AiToolExecutionRecord, BranchRecord, IdeaRecord,
NodeExecutionRecord, ProjectRecord, ReleaseRecord, TaskRecord, WorkflowRecord,
};
// ============================================================
// 辅助宏 — 消除 6 个 Repo 的重复样板
// ============================================================
/// 一次性为 7 张表生成完整的 Repo 结构体及其 CRUD 实现。
///
/// 用法: `impl_repo!(RepoName, ModelType, "table_name", from_row_body, insert_body);`
macro_rules! impl_repo {
(
$(#[$meta:meta])*
$name:ident, $record:ident, $table:literal,
from_row => |$row:ident| $from_body:expr,
insert => |$conn:ident, $rec:ident| $insert_body:expr
) => {
$(#[$meta])*
pub struct $name {
conn: Arc<Mutex<Connection>>,
}
impl $name {
pub fn new(db: &Database) -> Self {
Self { conn: db.conn() }
}
pub async fn insert(&self, record: $record) -> Result<String> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let id = record.id.clone();
let $conn = &*guard;
let $rec = &record;
$insert_body.map_err(|e: rusqlite::Error| Error::Storage(e.to_string()))?;
Ok(id)
})
.await
.map_err(|e| Error::Storage(e.to_string()))?
}
pub async fn get_by_id(&self, id: &str) -> Result<Option<$record>> {
let conn = self.conn.clone();
let id = id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(&format!("SELECT * FROM {} WHERE id = ?1", $table))
.map_err(|e| Error::Storage(e.to_string()))?;
let row = stmt
.query_row(params![id], |$row| $from_body)
.optional()
.map_err(|e| Error::Storage(e.to_string()))?;
Ok(row)
})
.await
.map_err(|e| Error::Storage(e.to_string()))?
}
pub async fn list_all(&self) -> Result<Vec<$record>> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(&format!("SELECT * FROM {} ORDER BY created_at DESC", $table))
.map_err(|e| Error::Storage(e.to_string()))?;
let rows = stmt
.query_map([], |$row| $from_body)
.map_err(|e| Error::Storage(e.to_string()))?;
let mut results = Vec::new();
for r in rows {
results.push(
r.map_err(|e| Error::Storage(e.to_string()))?,
);
}
Ok(results)
})
.await
.map_err(|e| Error::Storage(e.to_string()))?
}
pub async fn query(&self, field: &str, value: &str) -> Result<Vec<$record>> {
// 白名单校验,防止 SQL 注入
validate_column_name(field)?;
let conn = self.conn.clone();
let sql = format!("SELECT * FROM {} WHERE {} = ?1 ORDER BY created_at DESC", $table, field);
let value = value.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(&sql)
.map_err(|e| Error::Storage(e.to_string()))?;
let rows = stmt
.query_map(params![value], |$row| $from_body)
.map_err(|e| Error::Storage(e.to_string()))?;
let mut results = Vec::new();
for r in rows {
results.push(
r.map_err(|e| Error::Storage(e.to_string()))?,
);
}
Ok(results)
})
.await
.map_err(|e| Error::Storage(e.to_string()))?
}
pub async fn update_field(&self, id: &str, field: &str, value: &str) -> Result<bool> {
validate_column_name(field)?;
let conn = self.conn.clone();
let sql = format!("UPDATE {} SET {} = ?1, updated_at = ?2 WHERE id = ?3", $table, field);
let id = id.to_owned();
let value = value.to_owned();
let now = now_iso();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(&sql, params![value, now, id])
.map_err(|e| Error::Storage(e.to_string()))?;
Ok(affected > 0)
})
.await
.map_err(|e| Error::Storage(e.to_string()))?
}
pub async fn delete(&self, id: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(&format!("DELETE FROM {} WHERE id = ?1", $table), params![id])
.map_err(|e| Error::Storage(e.to_string()))?;
Ok(affected > 0)
})
.await
.map_err(|e| Error::Storage(e.to_string()))?
}
}
};
}
// ============================================================
// 列名白名单 — 防止 SQL 注入
// ============================================================
/// 所有表允许用于 query / update_field 的列名
const ALLOWED_COLUMNS: &[&str] = &[
// ideas
"id",
"title",
"description",
"status",
"priority",
"score",
"tags",
"source",
"promoted_to",
"ai_analysis",
"scores",
"created_at",
"updated_at",
// projects / tasks 额外
"name",
"idea_id",
"project_id",
"branch_name",
"assignee",
"workflow_def_id",
"base_branch",
// branches 额外
"task_id",
"base",
"merged_at",
// releases 额外
"version",
"task_ids",
"changelog",
"released_at",
// workflow / node 额外
"dag_json",
"triggered_by",
"completed_at",
"workflow_id",
"node_id",
"node_type",
"input_json",
"output_json",
"error_message",
"started_at",
// AI 相关
"provider_type",
"api_key",
"base_url",
"default_model",
"models",
"is_default",
"config",
"conversation_id",
"tool_call_id",
"tool_name",
"arguments",
"result",
"risk_level",
"requested_at",
"executed_at",
"decided_by",
];
fn validate_column_name(field: &str) -> Result<()> {
if ALLOWED_COLUMNS.contains(&field) {
Ok(())
} else {
Err(Error::Storage(format!("不允许的字段名: {}", field)))
}
}
// ============================================================
// 时间工具
// ============================================================
fn now_iso() -> String {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.to_string()
}
// ============================================================
// from_row 辅助函数
// ============================================================
fn idea_from_row(row: &Row<'_>) -> std::result::Result<IdeaRecord, rusqlite::Error> {
Ok(IdeaRecord {
id: row.get("id")?,
title: row.get("title")?,
description: row.get("description")?,
status: row.get("status")?,
priority: row.get("priority")?,
score: row.get("score")?,
tags: row.get("tags")?,
source: row.get("source")?,
promoted_to: row.get("promoted_to")?,
ai_analysis: row.get("ai_analysis")?,
scores: row.get("scores")?,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
})
}
fn project_from_row(row: &Row<'_>) -> std::result::Result<ProjectRecord, rusqlite::Error> {
Ok(ProjectRecord {
id: row.get("id")?,
name: row.get("name")?,
description: row.get("description")?,
status: row.get("status")?,
idea_id: row.get("idea_id")?,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
})
}
fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Error> {
Ok(TaskRecord {
id: row.get("id")?,
project_id: row.get("project_id")?,
title: row.get("title")?,
description: row.get("description")?,
status: row.get("status")?,
priority: row.get("priority")?,
branch_name: row.get("branch_name")?,
assignee: row.get("assignee")?,
workflow_def_id: row.get("workflow_def_id")?,
base_branch: row.get("base_branch")?,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
})
}
fn release_from_row(row: &Row<'_>) -> std::result::Result<ReleaseRecord, rusqlite::Error> {
Ok(ReleaseRecord {
id: row.get("id")?,
project_id: row.get("project_id")?,
version: row.get("version")?,
status: row.get("status")?,
task_ids: row.get("task_ids")?,
changelog: row.get("changelog")?,
created_at: row.get("created_at")?,
released_at: row.get("released_at")?,
})
}
fn workflow_from_row(row: &Row<'_>) -> std::result::Result<WorkflowRecord, rusqlite::Error> {
Ok(WorkflowRecord {
id: row.get("id")?,
name: row.get("name")?,
dag_json: row.get("dag_json")?,
status: row.get("status")?,
triggered_by: row.get("triggered_by")?,
project_id: row.get("project_id")?,
task_id: row.get("task_id")?,
created_at: row.get("created_at")?,
completed_at: row.get("completed_at")?,
})
}
fn branch_from_row(row: &Row<'_>) -> std::result::Result<BranchRecord, rusqlite::Error> {
Ok(BranchRecord {
id: row.get("id")?,
project_id: row.get("project_id")?,
task_id: row.get("task_id")?,
name: row.get("name")?,
base: row.get("base")?,
status: row.get("status")?,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
merged_at: row.get("merged_at")?,
})
}
fn node_execution_from_row(
row: &Row<'_>,
) -> std::result::Result<NodeExecutionRecord, rusqlite::Error> {
Ok(NodeExecutionRecord {
id: row.get("id")?,
workflow_id: row.get("workflow_id")?,
node_id: row.get("node_id")?,
node_type: row.get("node_type")?,
status: row.get("status")?,
input_json: row.get("input_json")?,
output_json: row.get("output_json")?,
error_message: row.get("error_message")?,
started_at: row.get("started_at")?,
completed_at: row.get("completed_at")?,
})
}
// ============================================================
// 7 个 Repo 实现
// ============================================================
impl_repo!(
/// 想法表 CRUD
IdeaRepo,
IdeaRecord,
"ideas",
from_row => |row| idea_from_row(row),
insert => |conn, rec| {
conn.execute(
"INSERT INTO ideas (id, title, description, status, priority, score, tags, source, promoted_to, ai_analysis, scores, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
params![
rec.id, rec.title, rec.description, rec.status, rec.priority,
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
rec.scores, rec.created_at, rec.updated_at
],
)
}
);
impl_repo!(
/// 项目表 CRUD
ProjectRepo,
ProjectRecord,
"projects",
from_row => |row| project_from_row(row),
insert => |conn, rec| {
conn.execute(
"INSERT INTO projects (id, name, description, status, idea_id, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
rec.id, rec.name, rec.description, rec.status, rec.idea_id,
rec.created_at, rec.updated_at
],
)
}
);
impl_repo!(
/// 任务表 CRUD
TaskRepo,
TaskRecord,
"tasks",
from_row => |row| task_from_row(row),
insert => |conn, rec| {
conn.execute(
"INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![
rec.id, rec.project_id, rec.title, rec.description, rec.status, rec.priority,
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
rec.created_at, rec.updated_at
],
)
}
);
impl_repo!(
/// 分支表 CRUD
BranchRepo,
BranchRecord,
"branches",
from_row => |row| branch_from_row(row),
insert => |conn, rec| {
conn.execute(
"INSERT INTO branches (id, project_id, task_id, name, base, status, created_at, updated_at, merged_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
rec.id, rec.project_id, rec.task_id, rec.name, rec.base,
rec.status, rec.created_at, rec.updated_at, rec.merged_at
],
)
}
);
impl_repo!(
/// 发布表 CRUD
ReleaseRepo,
ReleaseRecord,
"releases",
from_row => |row| release_from_row(row),
insert => |conn, rec| {
conn.execute(
"INSERT INTO releases (id, project_id, version, status, task_ids, changelog, created_at, released_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
rec.id, rec.project_id, rec.version, rec.status, rec.task_ids,
rec.changelog, rec.created_at, rec.released_at
],
)
}
);
impl_repo!(
/// 工作流执行表 CRUD
WorkflowRepo,
WorkflowRecord,
"workflow_executions",
from_row => |row| workflow_from_row(row),
insert => |conn, rec| {
conn.execute(
"INSERT INTO workflow_executions (id, name, dag_json, status, triggered_by, project_id, task_id, created_at, completed_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
rec.id, rec.name, rec.dag_json, rec.status, rec.triggered_by,
rec.project_id, rec.task_id, rec.created_at, rec.completed_at
],
)
}
);
impl_repo!(
/// 节点执行表 CRUD
NodeExecutionRepo,
NodeExecutionRecord,
"node_executions",
from_row => |row| node_execution_from_row(row),
insert => |conn, rec| {
conn.execute(
"INSERT INTO node_executions (id, workflow_id, node_id, node_type, status, input_json, output_json, error_message, started_at, completed_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![
rec.id, rec.workflow_id, rec.node_id, rec.node_type, rec.status,
rec.input_json, rec.output_json, rec.error_message, rec.started_at, rec.completed_at
],
)
}
);
// ============================================================
// AI 相关 Repo (V3)
// ============================================================
fn ai_provider_from_row(row: &Row<'_>) -> std::result::Result<AiProviderRecord, rusqlite::Error> {
Ok(AiProviderRecord {
id: row.get("id")?,
name: row.get("name")?,
provider_type: row.get("provider_type")?,
api_key: row.get("api_key")?,
base_url: row.get("base_url")?,
default_model: row.get("default_model")?,
models: row.get("models")?,
is_default: row.get::<_, i32>("is_default")? != 0,
config: row.get("config")?,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
})
}
fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversationRecord, rusqlite::Error> {
Ok(AiConversationRecord {
id: row.get("id")?,
title: row.get("title")?,
messages: row.get("messages")?,
provider_id: row.get("provider_id")?,
model: row.get("model")?,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
})
}
fn ai_tool_execution_from_row(row: &Row<'_>) -> std::result::Result<AiToolExecutionRecord, rusqlite::Error> {
Ok(AiToolExecutionRecord {
id: row.get("id")?,
conversation_id: row.get("conversation_id")?,
tool_call_id: row.get("tool_call_id")?,
tool_name: row.get("tool_name")?,
arguments: row.get("arguments")?,
result: row.get("result")?,
status: row.get("status")?,
risk_level: row.get("risk_level")?,
requested_at: row.get("requested_at")?,
executed_at: row.get("executed_at")?,
decided_by: row.get("decided_by")?,
})
}
impl_repo!(
/// AI 提供商配置表 CRUD
AiProviderRepo,
AiProviderRecord,
"ai_providers",
from_row => |row| ai_provider_from_row(row),
insert => |conn, rec| {
let is_default = if rec.is_default { 1i32 } else { 0i32 };
conn.execute(
"INSERT OR REPLACE INTO ai_providers (id, name, provider_type, api_key, base_url, default_model, models, is_default, config, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
params![
rec.id, rec.name, rec.provider_type, rec.api_key, rec.base_url,
rec.default_model, rec.models, is_default, rec.config, rec.created_at, rec.updated_at
],
)
}
);
impl_repo!(
/// AI 对话历史表 CRUD
AiConversationRepo,
AiConversationRecord,
"ai_conversations",
from_row => |row| ai_conversation_from_row(row),
insert => |conn, rec| {
conn.execute(
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
rec.id, rec.title, rec.messages, rec.provider_id, rec.model,
rec.created_at, rec.updated_at
],
)
}
);
impl_repo!(
/// AI 工具执行审计表 CRUD
AiToolExecutionRepo,
AiToolExecutionRecord,
"ai_tool_executions",
from_row => |row| ai_tool_execution_from_row(row),
insert => |conn, rec| {
conn.execute(
"INSERT INTO ai_tool_executions (id, conversation_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
params![
rec.id, rec.conversation_id, rec.tool_call_id, rec.tool_name,
rec.arguments, rec.result, rec.status, rec.risk_level,
rec.requested_at, rec.executed_at, rec.decided_by
],
)
}
);
// ============================================================
// AiConversationRepo 扩展方法(绕过 update_field 白名单限制)
// ============================================================
impl AiConversationRepo {
/// 整体更新对话记录(title + messages + updated_at)
pub async fn update_full(&self, record: &AiConversationRecord) -> Result<bool> {
let conn = self.conn.clone();
let id = record.id.clone();
let title = record.title.clone();
let messages = record.messages.clone();
let provider_id = record.provider_id.clone();
let model = record.model.clone();
let updated_at = record.updated_at.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard.execute(
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, updated_at = ?5 WHERE id = ?6",
params![title, messages, provider_id, model, updated_at, id],
).map_err(|e| Error::Storage(e.to_string()))?;
Ok(affected > 0)
})
.await
.map_err(|e| Error::Storage(e.to_string()))?
}
}

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