Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

详见 PROGRESS.md。src-tauri/儿童每日打卡应用/ 与本项目无关,已排除。
2026-06-14 14:08:20 +08:00
502 changed files with 130959 additions and 13135 deletions
+5
View File
@@ -0,0 +1,5 @@
# musl linux 交叉编译(Windows 主机无 cc/musl-gcc,用 rust-lld self-contained link)
# df-relay 云端部署:本地交叉产 static linux binary → scp 测试机普通运行
[target.x86_64-unknown-linux-musl]
linker = "rust-lld"
rustflags = ["-C", "link-self-contained=y"]
+105
View File
@@ -0,0 +1,105 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "cc"
version = "1.2.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "regex"
version = "1.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "tree-sitter"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0203df02a3b6dd63575cc1d6e609edc2181c9a11867a271b25cfd2abff3ec5ca"
dependencies = [
"cc",
"regex",
"regex-syntax",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-language"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
[[package]]
name = "tree-sitter-rust"
version = "0.23.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca8ccb3e3a3495c8a943f6c3fd24c3804c471fd7f4f16087623c7fa4c0068e8a"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "ts-bench"
version = "0.1.0"
dependencies = [
"tree-sitter",
"tree-sitter-rust",
]
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "ts-bench"
version = "0.1.0"
edition = "2021"
[dependencies]
tree-sitter = "0.23"
tree-sitter-rust = "0.23"
[profile.release]
opt-level = 3
[workspace]
+184
View File
@@ -0,0 +1,184 @@
use std::env;
use std::fs;
use std::hint::black_box;
use std::time::Instant;
use tree_sitter::{Node, Parser, Query, QueryCursor};
fn char_to_token_est(chars: usize) -> usize {
(chars + 3) / 4
}
fn extract_skeleton(root: Node, src: &str, q: &Query) -> (String, usize) {
let mut out = String::new();
let mut count = 0usize;
let mut cursor = QueryCursor::new();
let matches = cursor.matches(q, root, src.as_bytes());
for mat in matches {
for cap in mat.captures {
let n = cap.node;
let start_byte = n.start_byte();
let header_end = {
let from_start = &src[start_byte..];
let mut depth: i32 = 0;
let mut end = from_start.len();
for (i, b) in from_start.char_indices() {
match b {
'{' => {
if depth == 0 {
end = i;
break;
}
depth += 1;
}
';' => {
end = i;
break;
}
_ => {}
}
let _ = depth;
}
start_byte + end.min(from_start.len())
};
let header = &src[start_byte..header_end].trim_end();
out.push_str(header);
out.push_str(" { ... }\n");
count += 1;
}
}
(out, count)
}
fn main() {
let args: Vec<String> = env::args().collect();
let path = args.get(1).expect("usage: ts-bench <file.rs>");
let src = fs::read_to_string(path).expect("read");
let src_chars = src.chars().count();
let src_tokens_est = char_to_token_est(src.len());
let mut parser = Parser::new();
parser.set_language(&tree_sitter_rust::LANGUAGE.into()).unwrap();
// warmup
let _ = parser.parse(&src, None).unwrap();
// ---- PURE PARSE timing ----
let n = 200;
let mut times_us = Vec::with_capacity(n);
for _ in 0..n {
let t0 = Instant::now();
let tree = parser.parse(black_box(&src), None).unwrap();
let t1 = Instant::now();
drop(tree);
times_us.push(t1.duration_since(t0).as_nanos() as f64 / 1000.0);
}
times_us.sort_by(|a, b| a.partial_cmp(b).unwrap());
let min = times_us[0];
let med = times_us[n / 2];
let p95 = times_us[(n as f64 * 0.95) as usize];
let mean = times_us.iter().sum::<f64>() / n as f64;
// ---- INCREMENTAL PARSE (edit one byte at end) ----
let mut src_edited = src.clone();
src_edited.push_str("\n// comment\n");
let tree0 = parser.parse(&src, None).unwrap();
let mut times_inc = Vec::with_capacity(n);
for _ in 0..n {
let t0 = Instant::now();
let t2 = parser.parse(&src_edited, Some(&tree0)).unwrap();
let t1 = Instant::now();
drop(t2);
times_inc.push(t1.duration_since(t0).as_nanos() as f64 / 1000.0);
}
times_inc.sort_by(|a, b| a.partial_cmp(b).unwrap());
let inc_med = times_inc[n / 2];
// ---- Query compilation timing ----
let query_text = r#"
[
(function_item name: (identifier) @name)
(struct_item name: (type_identifier) @name)
(enum_item name: (type_identifier) @name)
(trait_item name: (type_identifier) @name)
(impl_item trait: (type_identifier)? @trait type: (type_identifier)? @type)
(const_item name: (identifier) @name)
(static_item name: (identifier) @name)
(type_item name: (type_identifier) @name)
(mod_item name: (identifier) @name)
(macro_definition name: (identifier) @name)
]
"#;
let mut qtimes = Vec::with_capacity(n);
for _ in 0..n {
let t0 = Instant::now();
let q = Query::new(&tree_sitter_rust::LANGUAGE.into(), query_text).unwrap();
let t1 = Instant::now();
drop(q);
qtimes.push(t1.duration_since(t0).as_nanos() as f64 / 1000.0);
}
qtimes.sort_by(|a, b| a.partial_cmp(b).unwrap());
let q_med = qtimes[n / 2];
// ---- Skeleton extraction (compiled query reused) ----
let tree = parser.parse(&src, None).unwrap();
let root = tree.root_node();
let q = Query::new(&tree_sitter_rust::LANGUAGE.into(), query_text).unwrap();
let m = 200;
let mut ext_us = Vec::with_capacity(m);
let mut last_skel = String::new();
let mut last_count = 0;
for _ in 0..m {
let t0 = Instant::now();
let (skel, cnt) = extract_skeleton(root, &src, &q);
let t1 = Instant::now();
ext_us.push(t1.duration_since(t0).as_nanos() as f64 / 1000.0);
last_skel = skel;
last_count = cnt;
}
ext_us.sort_by(|a, b| a.partial_cmp(b).unwrap());
let ext_med = ext_us[m / 2];
let skel_chars = last_skel.chars().count();
let skel_tokens_est = char_to_token_est(last_skel.len());
println!("=== FILE ===");
println!("path : {}", path);
println!("lines : {}", src.lines().count());
println!("chars : {}", src_chars);
println!("bytes : {}", src.len());
println!("tokens est (~/4c) : {}", src_tokens_est);
println!();
println!("=== PARSE SPEED (pure tree-sitter-rust, release, {} iters) ===", n);
println!("min={:.2}us median={:.2}us p95={:.2}us mean={:.2}us", min, med, p95, mean);
println!("median ms : {:.3} ms", med / 1000.0);
println!();
println!("=== INCREMENTAL PARSE (small edit) ===");
println!("median : {:.2}us ({:.3}ms)", inc_med, inc_med / 1000.0);
println!();
println!("=== QUERY COMPILATION (once, should be cached) ===");
println!("median : {:.2}us ({:.3}ms)", q_med, q_med / 1000.0);
println!();
println!("=== SKELETON EXTRACTION (compiled query reused) ===");
println!("symbols extracted : {}", last_count);
println!("skeleton chars : {}", skel_chars);
println!("skeleton tokens : {}", skel_tokens_est);
println!("extract median : {:.2}us ({:.3}ms)", ext_med, ext_med / 1000.0);
println!();
println!("=== DENSITY (full vs skeleton) ===");
println!(
"chars : {:.1}x ({:.1}% reduction)",
src_chars as f64 / skel_chars as f64,
(1.0 - skel_chars as f64 / src_chars as f64) * 100.0
);
println!(
"tokens : {:.1}x ({:.1}% reduction)",
src_tokens_est as f64 / skel_tokens_est as f64,
(1.0 - skel_tokens_est as f64 / src_tokens_est as f64) * 100.0
);
println!();
println!("=== SKELETON PREVIEW (first 30 lines) ===");
for line in last_skel.lines().take(30) {
println!("{}", line);
}
}
+11
View File
@@ -0,0 +1,11 @@
# docker build 排除(docker context = repo 根,仅 df-relay 编译需 workspace 源码)
target
node_modules
dist
dist-ssr
.git
*.db
*.log
apps/df-miniapp/node_modules
apps/df-miniapp/dist
src-tauri/target
+16
View File
@@ -10,6 +10,9 @@ node_modules/
dist/
dist-ssr/
# uni-app 编译产物(微信小程序/H5 打包输出)
unpackage/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
@@ -47,3 +50,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
+904 -62
View File
File diff suppressed because it is too large Load Diff
+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 — 简述
+21
View File
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,minimum-scale=1.0,user-scalable=no" />
<title>DevFlow Mini</title>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.documentElement.style.fontSize = window.innerWidth / 20 + 'px'
})
</script>
<link rel="stylesheet" href="<%= BASE_URL %>static/index.<%= VUE_APP_INDEX_CSS_HASH %>.css" />
</head>
<body>
<noscript>
<strong>本应用需 JavaScript 启用。</strong>
</noscript>
<div id="app"></div>
</body>
</html>
+13924
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "df-miniapp",
"version": "0.1.0",
"description": "DevFlow 微信小程序(uni-app Vue3)—— 远程 AI Chat 操作桌面端",
"private": true,
"scripts": {
"dev:h5": "uni",
"dev:mp-weixin": "uni -p mp-weixin",
"build:h5": "uni build",
"build:mp-weixin": "uni build -p mp-weixin",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-components": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-h5": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-mp-weixin": "3.0.0-alpha-4080720251125001",
"marked": "^18.0.5",
"vue": "^3.4.0",
"vue-i18n": "^9.0.0"
},
"devDependencies": {
"@dcloudio/types": "^3.4.0",
"@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001",
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001",
"@types/node": "^20.0.0",
"@vue/runtime-core": "^3.4.0",
"@vue/tsconfig": "^0.5.0",
"sass": "^1.70.0",
"typescript": "^5.3.0",
"vite": "^5.2.8",
"vue-tsc": "^2.0.0"
}
}
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
import { useAiChat } from '@/composables/useAiChat'
// 应用启动:连接 relay。useAiChat 单例管理 WS 生命周期 + 状态。
const aiChat = useAiChat()
onLaunch(() => {
console.log('[DevFlow Mini] 应用启动')
aiChat.connect()
})
onShow(() => {
// 切回前台:重启心跳/重连 + 恢复流式看门狗(P1-9/P1-10)
aiChat.resumeBackground()
})
onHide(() => {
// 切后台:停心跳 + 流式看门狗,防后台定时器节流触发写状态(P1-9/P1-10)
console.log('[DevFlow Mini] 应用进入后台')
aiChat.pauseBackground()
})
</script>
<style lang="scss">
page {
background-color: $uni-bg-color;
color: $uni-text-color;
font-size: $uni-font-size-base;
}
</style>
+586
View File
@@ -0,0 +1,586 @@
/**
* df-relay WS 客户端(uni-app 跨端适配)
*
* 协议(对齐 crates/df-relay/src/relay.rs handle_connection):
* 1. 建立 WS 连接到 `/ws/miniapp`
* 2. 首帧发 Hello{kind:'miniapp', device_id, token} 身份宣告(relay.rs:197-208,10s 超时)
* 3. 校验通过后进入收发循环;失败 relay 发 Error 帧 + Close
*
* 跨端适配:
* - 微信小程序:uni.connectSocket(微信原生 API,非浏览器 WebSocket)
* - H5:uni.connectSocket 封装浏览器 WebSocket
* - 统一经 uni API 封装,业务层不直接 new WebSocket
*
* 连接生命周期(P0/P1 审计后重构,根本性,非补丁):
* - 所有"建连"经 reconnectNow() 幂等入口:clear 退避 timer + cleanup + openSocket
* connect()/resumeIfDisconnected()/scheduleReconnect 回调全部经此,杜绝双连竞态 + socket 泄漏
* - openSocket() 幂等:首行 destroySocket 残留 socket,保证 this.socket 引用唯一
* - destroySocket(task):显式注销 onOpen/onMessage/onClose/onError(覆盖空函数,微信无 offXxx)
* + close,防旧 socket 闭包回调污染新连接(单例 this 句柄被多 socket 捕获)
* - cleanup() 彻底化:destroySocket + 停心跳 + 清所有 timer + 清 pendingQueue
* 清队列防跨连接/跨会话陈旧命令补发(旧命令上下文已失效)
* - 超时三重兜底:连接超时(onOpen 未到)/握手超时(hello_ack 丢失,>relay 10s)/
* onError 兜底(微信 socket onError 不保证 onClose 跟随,DNS/TLS/域名未加白场景)
* - 心跳活性检测:lastInboundAt + 2x 间隔判定半开死连接(NAT 超时/网络切换)
* - pendingQueue 上限 + TTL:防握手永卡时无限堆积内存泄漏
*
* 重连:指数退避(baseDelay * 2^min(n,6),封顶 maxDelay,带随机抖动)
* 心跳:固定间隔发 ControlMessage{control_kind:'ping'},防空闲断开 + 在线状态
* JSON 透传:入站解析为 BroadcastMessage,payload 是 AiChatEvent JSON
* 出站直接发 Command JSON(relay 自动包 payload,见 relay.rs:332-365)
*/
import { getConfig } from '@/config'
import type { BroadcastMessage, Hello, MiniCommand, ControlMessage } from '@/types/relay'
/** WS 连接状态 */
export type WsStatus = 'disconnected' | 'connecting' | 'connected' | 'handshaking' | 'reconnecting'
/** 入站事件回调(Event/Control 消息分派) */
export interface WsHandlers {
/** 收到 Event 消息(payload 已解为 AiChatEvent JSON,业务层按 type discriminator 处理) */
onEvent: (msg: BroadcastMessage) => void
/** 收到 Control 消息(心跳响应 / 在线状态等) */
onControl: (msg: ControlMessage) => void
/** 状态变更(连接 / 断开 / 重连中) */
onStatus: (status: WsStatus, detail?: string) => void
}
/** pendingQueue 带入队时间戳,flush 时按 TTL 丢弃陈旧命令 */
interface QueuedCmd {
cmd: MiniCommand
ts: number
}
/** 握手超时(>relay.rs:321 服务端 10s,留余量) */
const HANDSHAKE_TIMEOUT_MS = 12000
/** 连接超时(openSocket 后 onOpen 未到则重连;治 onError 不触发 onClose 场景) */
const CONNECT_TIMEOUT_MS = 8000
/** pendingQueue 上限(防握手永卡时无限堆积内存泄漏) */
const PENDING_MAX = 50
/** pendingQueue 单条 TTL(flush 时超时丢弃,防跨连接陈旧命令补发) */
const PENDING_TTL_MS = 60000
/** 心跳活性判定因子:超过 N 倍心跳间隔无入站消息判半开死连接 */
const HEARTBEAT_DEAD_FACTOR = 2
/** 最大重连次数(永久性故障如 relayHost 错/中继下线,停止无限退避,提示用户检查) */
const MAX_RECONNECT_ATTEMPTS = 20
/**
* WS 客户端单例(miniapp 全局一个连接)
*
* 设计:单 socket + 单 handlers 回调集合(composable 订阅,业务层不直接持 socket)。
* 握手/心跳/重连机制内部封装,业务层仅关心 send(Command) + onEvent。
*/
class WsClient {
private socket: UniApp.SocketTask | null = null
private handlers: WsHandlers | null = null
private status: WsStatus = 'disconnected'
/** 握手是否已完成(收到首个广播或握手成功信号) */
private handshaked = false
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
/** 握手超时定时器(handleOpen 发 Hello 后启,收 hello_ack 后清) */
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
/** 连接超时定时器(openSocket 启,onOpen 到达后清) */
private connectTimer: ReturnType<typeof setTimeout> | null = null
private reconnectAttempts = 0
/** 主动关闭标志(用户调用 disconnect 时置 true,不再触发重连) */
private manualClose = false
/**
* 握手完成前积压的命令队列(send 在 !handshaked 时不丢弃,入队等 hello_ack 后补发)。
* 治「握手成功到首帧竞态丢命令」:握手刚翻 true 时 relay 设备路由表可能未就绪,
* 业务层快速重发会被 send 守卫放行后静默丢失;入队保证握手稳定后顺序补发。
* 带时间戳 + 上限 + TTL,防握手永卡时无限堆积(见 PENDING_MAX/PENDING_TTL_MS)。
*/
private pendingQueue: QueuedCmd[] = []
/** 最后入站消息时间戳(心跳活性检测,relay 无 pong 故用任意入站消息替代) */
private lastInboundAt = 0
/** 订阅(单订阅,composable 注册一次) */
subscribe(h: WsHandlers): void {
this.handlers = h
}
/** 读取当前状态 */
getStatus(): WsStatus {
return this.status
}
/** 主动连接 */
connect(): void {
if (this.status === 'connected' || this.status === 'connecting' || this.status === 'handshaking') {
return
}
this.manualClose = false
// 用户主动连接复位重连计数(可与达上限后的自动退避区分,允许手动重试)
this.reconnectAttempts = 0
// 统一经 reconnectNow(幂等 clear timer + cleanup + openSocket),杜绝 reconnecting 态双连
this.reconnectNow()
}
/** 主动断开(用户手动,不重连) */
disconnect(): void {
this.manualClose = true
this.reconnectAttempts = 0
// cleanup 内部清 pendingQueue + 注销 socket + 清所有 timer
this.cleanup()
this.setStatus('disconnected', 'manual close')
}
/**
* 若已断开或正在退避重连则立即重连(onShow 时调用,小程序后台回前台)。
*
* 覆盖 `disconnected` 与 `reconnecting` 两种态:
* - disconnected:onClose 已触发,直接重连;
* - reconnecting:退避计时未到,抢占退避立即重连(治「切后台回前台卡在 reconnecting」头号症状)。
*
* 统一经 reconnectNow(幂等清理),保证抢占时不残留旧 socket/timer。
*/
resumeIfDisconnected(): void {
if (this.manualClose) return
if (this.status === 'disconnected' || this.status === 'reconnecting') {
// 用户触发的立即重连(onShow 抢占退避)复位计数,允许达上限后手动恢复
this.reconnectAttempts = 0
this.reconnectNow()
}
}
/**
* 暂停后台定时器(小程序 onHide 调,P1-9):停心跳 setInterval。
*
* 微信后台定时器节流但仍触发,心跳持续发 ping 浪费 + 后台无入站致半开检测误判。
* 不 disconnect(disconnect 清队列 + manualClose,不适合后台暂离),仅停心跳保留连接。
* 重连 setTimeout 若后台断连触发,单次非循环,影响可接受(MVP 不暂停)。
*/
pauseBackground(): void {
this.stopHeartbeat()
}
/**
* 恢复前台(小程序 onShow 调):连接在则重启心跳,断则重连。
*/
resumeBackground(): void {
if (this.status === 'connected' && this.handshaked) {
this.startHeartbeat()
} else {
this.resumeIfDisconnected()
}
}
/**
* 发送 Command(miniapp → device)
*
* 不包装 BroadcastMessage —— relay.rs:332-365 入站文本帧直接当 payload 包成
* BroadcastMessage(device_id/kind/source/from 由 relay 填),故客户端仅发业务 JSON。
*/
send(cmd: MiniCommand): boolean {
console.log('[dbg:ws] send', cmd.cmd, 'socket=', !!this.socket, 'handshaked=', this.handshaked)
if (!this.socket || !this.handshaked) {
// 握手未完成不丢弃,入队待 hello_ack 后补发(治握手成功到首帧竞态丢命令)。
// 仅在未主动断开(manualClose=false)且连接尚有恢复预期时入队,
// 否则(manualClose=true / 长期 disconnected)不堆积陈旧命令。
if (this.manualClose) {
console.warn('[WsClient] 已主动断开,丢弃命令', cmd.cmd)
return false
}
this.enqueuePending(cmd)
return false
}
try {
const text = JSON.stringify(cmd)
this.socket.send({ data: text })
return true
} catch (e) {
console.error('[WsClient] send 失败', e)
return false
}
}
/** 入队(带上限 + 时间戳,TTL 丢弃见 flushPendingQueue) */
private enqueuePending(cmd: MiniCommand): void {
if (this.pendingQueue.length >= PENDING_MAX) {
// 超上限丢弃最旧,防握手永卡时无限堆积内存泄漏
this.pendingQueue.shift()
console.warn('[WsClient] pendingQueue 超上限,丢弃最旧命令')
}
this.pendingQueue.push({ cmd, ts: Date.now() })
console.log('[WsClient] 握手未完成,命令入队待补发', cmd.cmd, 'queue=', this.pendingQueue.length)
}
/**
* 补发握手期间积压的命令队列(收到 hello_ack 后调)。
*
* list_conversations 已由握手逻辑自己发(不进队列),此处仅补发业务 send 积压。
* 补发前置 handshaked=true,send 守卫放行,逐条直发 socket。
* TTL 丢弃:入队超 PENDING_TTL_MS 的视为陈旧(上下文已失效),不补发。
*/
private flushPendingQueue(): void {
if (this.pendingQueue.length === 0) return
const now = Date.now()
console.log('[WsClient] 补发握手积压命令', this.pendingQueue.length)
while (this.pendingQueue.length > 0) {
const item = this.pendingQueue.shift()!
if (now - item.ts > PENDING_TTL_MS) {
console.warn('[WsClient] 补发时丢弃超时陈旧命令', item.cmd.cmd)
continue
}
try {
if (this.socket) {
this.socket.send({ data: JSON.stringify(item.cmd) })
}
} catch (e) {
console.error('[WsClient] 补发命令失败', item.cmd.cmd, e)
}
}
}
// ─── 连接生命周期(统一入口 + 幂等清理) ───────────────────
/**
* 幂等立即建连入口(clear 退避 timer + cleanup + openSocket)。
*
* 所有需要"立即开新连接"的路径(connect/resumeIfDisconnected/scheduleReconnect 回调)
* 统一经此,保证:
* - 不残留挂起的 reconnectTimer(防退避 timer 与 openSocket 双触发建两个 socket);
* - 旧 socket 被 cleanup 注销+关闭(防 this.socket 引用覆盖后句柄泄漏);
* - pendingQueue 被清(防跨连接/跨会话陈旧命令补发)。
*/
private reconnectNow(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
this.cleanup()
this.openSocket()
}
private openSocket(): void {
// 幂等:残留 socket 先注销+关闭(reconnectNow 已 cleanup,此为双保险)
if (this.socket) {
this.destroySocket(this.socket)
this.socket = null
}
const cfg = getConfig()
this.setStatus('connecting')
console.log('[WsClient] 连接', cfg.relayHost)
let task: UniApp.SocketTask
try {
task = uni.connectSocket({
url: cfg.relayHost,
complete: () => {
// complete 回调在 uni-app 仅表示"任务创建完成",非连接完成(onOpen 才是)
},
})
} catch (e) {
console.error('[WsClient] connectSocket 异常', e)
this.scheduleReconnect()
return
}
this.socket = task
task.onOpen(() => this.handleOpen())
task.onMessage((res) => this.handleMessage(res))
task.onClose((res) => this.handleClose(res))
task.onError((res) => this.handleError(res))
// 连接超时:onOpen 未到则重连(治 onError 不触发 onClose 的场景:
// DNS 解析失败/TLS 握手失败/relayHost 不可达/wss 域名未加白名单)
this.connectTimer = setTimeout(() => {
this.connectTimer = null
if (this.status === 'connecting') {
console.warn('[WsClient] 连接超时,onOpen 未到,重连')
this.cleanup()
this.scheduleReconnect()
}
}, CONNECT_TIMEOUT_MS)
}
/** WS 已建立连接 → 立即发 Hello 握手帧 + 启握手超时 */
private handleOpen(): void {
// onOpen 到达,取消连接超时
if (this.connectTimer) {
clearTimeout(this.connectTimer)
this.connectTimer = null
}
this.setStatus('handshaking')
const cfg = getConfig()
const hello: Hello = {
kind: 'miniapp',
device_id: cfg.deviceId,
token: cfg.token,
}
try {
this.socket?.send({ data: JSON.stringify(hello) })
} catch (e) {
console.error('[WsClient] 发送 Hello 失败', e)
// 失败先 cleanup(关坏 socket + 清监听器)再退避,与 handleHandshakeResponse 失败路径对齐
this.cleanup()
this.scheduleReconnect()
return
}
// 握手超时:hello_ack 丢失(网络抖动/relay send_text 失败吞错 relay.rs:253-257)则重连。
// >relay.rs:321 服务端 10s,留余量。握手成功/失败时 handleHandshakeResponse 会清此 timer。
this.handshakeTimer = setTimeout(() => {
this.handshakeTimer = null
if (!this.handshaked) {
console.warn('[WsClient] 握手超时,hello_ack 未收到,重连')
this.cleanup()
this.scheduleReconnect()
}
}, HANDSHAKE_TIMEOUT_MS)
}
/** 入站消息分派:握手阶段首帧=控制(握手成功/失败),握手后=BroadcastMessage */
private handleMessage(res: { data: string | ArrayBuffer }): void {
// 协议纯 JSON 文本帧(relay 发文本),ArrayBuffer 非预期 —— 加 warn 可观测
// (防二进制帧静默丢弃致握手期 hello_ack 被吃却无日志)
if (typeof res.data !== 'string') {
console.warn('[WsClient] 收到非预期二进制帧,丢弃', (res.data as ArrayBuffer).byteLength)
return
}
const text = res.data
if (!text) return
// 活性:任意入站帧更新(relay 无 pong,用心跳期间入站消息替代判活)
this.lastInboundAt = Date.now()
// 握手阶段可能收到 relay 发的错误控制帧(relay.rs:200-238)
if (!this.handshaked) {
this.handleHandshakeResponse(text)
return
}
// 解析 BroadcastMessage(device 事件经 relay 包成完整 BroadcastMessage 下发)
let msg: BroadcastMessage
try {
msg = JSON.parse(text) as BroadcastMessage
} catch (e) {
console.warn('[WsClient] 入站消息 JSON 解析失败', e, text.slice(0, 200))
return
}
// 按 kind 分派:Event → 业务层;Control → 心跳响应等
if (msg.kind === 'event') {
this.handlers?.onEvent(msg)
} else if (msg.kind === 'control') {
this.handlers?.onControl(msg.payload as ControlMessage)
}
// command 是 device→miniapp 反向(不应在此端收到,忽略)
}
/** 处理握手响应(relay 发 hello_ack 显式 ack;失败时发 error 帧 + 关连接) */
private handleHandshakeResponse(text: string): void {
// 结构化解析(优先):对齐 relay.rs:255 实发 hello_ack 字面量
// 成功:{"kind":"control","payload":{"control_kind":"hello_ack"}}
// 失败:{"kind":"control","error":"handshake_failed|kind_mismatch|auth_failed"} (relay.rs:200-238)
// 注:hello_ack 的 control_kind 在 payload 内(与 error 顶层字段结构不同,须分别判)。
let parsedOk = false
let parsed: { kind?: string; error?: string; payload?: { control_kind?: string } } | null = null
try {
parsed = JSON.parse(text) as { kind?: string; error?: string; payload?: { control_kind?: string } }
parsedOk = true
} catch (e) {
// JSON.parse 失败:降级回旧 includes 兜底,保证非 JSON 帧不误判
console.warn('[WsClient] 握手响应 JSON 解析失败,降级 includes 兜底', e, text.slice(0, 200))
}
if (parsedOk && parsed) {
// 显式 error 字段存在即握手失败(对齐 relay.rs:203/220/233 error 顶层字段)
if (parsed.error !== undefined) {
console.error('[WsClient] 握手失败', parsed.error, text)
this.setStatus('disconnected', `handshake failed: ${parsed.error}`)
this.clearHandshakeTimer()
// 握手失败不重连(token 错则重连也错),等用户修配置后手动 connect
this.cleanup()
return
}
// 显式 ack 信号:kind==='control' 且 payload.control_kind==='hello_ack'
// (对齐 relay.rs:255 实发字面量;ControlMessage 枚举未含 hello_ack 故不强转类型)
if (parsed.kind === 'control' && parsed.payload?.control_kind === 'hello_ack') {
this.clearHandshakeTimer()
this.handshaked = true
this.reconnectAttempts = 0
this.setStatus('connected')
this.startHeartbeat()
// F-#95:握手成功后立即发 list_conversations —— device 在线探测 + 首屏会话列表同步。
// device 在线则 route_list_conversations 推回 AiConversationList(useAiChat 置 deviceOnline=true);
// device 离线则无响应,文案保持「已连接中继」诚实表述。
// 此时 handshaked=true,send 守卫放行;relay 纯透传入站文本帧作 payload。
this.send({ cmd: 'list_conversations', args: {} })
// 补发握手期间积压的业务命令(治握手成功到首帧竞态丢命令)
this.flushPendingQueue()
// hello_ack 是纯控制帧无业务 payload,无需转 handleMessage 处理
return
}
// 解析成功但既非 error 也非 hello_ack:异常帧,丢弃等下一条(保守不误判握手通过)
console.warn('[WsClient] 握手阶段收到非 ack/error 控制帧,丢弃', text.slice(0, 200))
return
}
// 降级兜底(JSON.parse 失败走此分支):保留旧 includes 判定,防协议演进/非 JSON 帧
if (text.includes('"error"')) {
console.error('[WsClient] 握手失败(includes 兜底)', text)
this.setStatus('disconnected', `handshake failed: ${text}`)
this.clearHandshakeTimer()
this.cleanup()
return
}
// 兜底:非错误消息视为握手通过(覆盖旧实现行为,防解析失败的 hello_ack 帧)
this.clearHandshakeTimer()
this.handshaked = true
this.reconnectAttempts = 0
this.setStatus('connected')
this.startHeartbeat()
this.send({ cmd: 'list_conversations', args: {} })
// 补发握手期间积压的业务命令(降级兜底路径同样补发)
this.flushPendingQueue()
this.handleMessage({ data: text })
}
private handleClose(res: { code: number; reason: string }): void {
console.log('[WsClient] 连接关闭', res.code, res.reason)
this.cleanup()
if (!this.manualClose) {
this.scheduleReconnect()
} else {
this.setStatus('disconnected', 'manual close')
}
}
private handleError(res: { errMsg: string }): void {
console.error('[WsClient] socket 错误', res.errMsg)
// 微信 socket onError 不保证 onClose 跟随(DNS/TLS 失败/域名未加白名单/onOpen 也不触发)。
// 若尚在 connecting/handshaking(未进过 connected),主动兜底重连,防永卡。
// connected 态的半开死连接由心跳活性检测(P1-2)兜底,此处不重连(可能 onClose 随后跟随)。
if (this.status === 'connecting' || this.status === 'handshaking') {
this.cleanup()
this.scheduleReconnect()
}
}
/** 启动心跳(固定间隔发 ControlMessage{control_kind:'heartbeat'} + 半开死连接检测) */
private startHeartbeat(): void {
this.stopHeartbeat()
const cfg = getConfig()
// 连接成功初始化活性时间(首次心跳检查不立即判死)
this.lastInboundAt = Date.now()
this.heartbeatTimer = setInterval(() => {
// 死连接检测:NAT 超时/网络切换致 TCP 半开(onClose 不触发,socket 仍 open)。
// relay 无 pong,用「任意入站消息」更新 lastInboundAt;超过 N 倍间隔无入站判死,主动重连。
const now = Date.now()
if (now - this.lastInboundAt > cfg.heartbeatInterval * HEARTBEAT_DEAD_FACTOR) {
console.warn('[WsClient] 心跳活性检测:连接疑似半开死连接,主动重连')
this.cleanup()
this.scheduleReconnect()
return
}
const ping: ControlMessage = { control_kind: 'ping' }
// 直接发 ControlMessage JSON(relay 透传,device 可回 pong)
try {
this.socket?.send({ data: JSON.stringify(ping) })
} catch (e) {
console.warn('[WsClient] 心跳发送失败', e)
}
// 注:cfg.heartbeatInterval 读但不显式用(已通过 setInterval 传入),保 cfg 引用
void cfg
}, cfg.heartbeatInterval)
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
}
/**
* 指数退避重连(baseDelay * 2^min(attempt,6),封顶 maxDelay,带随机抖动)。
*
* - attempt 软上限 6:超 6 不再翻倍,防长尾退避爬到 maxDelay 后卡死;
* - 抖动因子 0.5~1.0:防多客户端重连风暴(雷同退避同步触发);
* - manualClose/disconnect 时复位 reconnectAttempts(见 disconnect)。
*
* 回调统一经 reconnectNow(幂等清理 + openSocket),不裸调 openSocket。
*/
private scheduleReconnect(): void {
if (this.reconnectTimer) return
// 永久性故障上限:relayHost 错/中继下线时停止无限退避,避免长跑空耗 + 误导用户「重连中」。
// 用户手动重连(connect/resumeIfDisconnected)复位计数后可重新开始。
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
console.warn(`[WsClient] 已达最大重连次数 ${MAX_RECONNECT_ATTEMPTS},停止重连`)
this.setStatus('disconnected', '已达最大重连次数,请检查中继地址/网络后手动重连')
this.cleanup()
return
}
const cfg = getConfig()
const attempt = this.reconnectAttempts++
const exp = Math.min(attempt, 6)
const base = Math.min(cfg.reconnectBaseDelay * Math.pow(2, exp), cfg.reconnectMaxDelay)
const jitter = 0.5 + Math.random() * 0.5
const delay = Math.round(base * jitter)
this.setStatus('reconnecting', `attempt ${attempt + 1}, delay ${delay}ms`)
console.log(`[WsClient] ${delay}ms 后重连(attempt ${attempt + 1})`)
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.reconnectNow()
}, delay)
}
/** 清握手超时定时器(握手成功/失败时调) */
private clearHandshakeTimer(): void {
if (this.handshakeTimer) {
clearTimeout(this.handshakeTimer)
this.handshakeTimer = null
}
}
/**
* 注销并关闭指定 socket task(防旧 socket 闭包回调污染新连接)。
*
* 微信 SocketTask 无 offXxx API,用「覆盖空函数」解绑:onXxx 多次调用覆盖最后者。
* 必须对传入 task 操作(非 this.socket),避免 this.socket 时序指向新 socket 时误注销。
*/
private destroySocket(task: UniApp.SocketTask): void {
try { task.onOpen(() => {}) } catch { /* 注销忽略 */ }
try { task.onMessage(() => {}) } catch { /* 注销忽略 */ }
try { task.onClose(() => {}) } catch { /* 注销忽略 */ }
try { task.onError(() => {}) } catch { /* 注销忽略 */ }
try {
task.close({})
} catch {
// 已关闭的 socket close 抛错忽略
}
}
/**
* 彻底清理:停心跳 + 清所有 timer + 注销关闭 socket + 清队列 + 复位 handshaked。
*
* 清 pendingQueue:断连/重连/抢占时,旧命令上下文已失效,不补发到新连接(防串话)。
*/
private cleanup(): void {
this.stopHeartbeat()
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
this.clearHandshakeTimer()
if (this.connectTimer) {
clearTimeout(this.connectTimer)
this.connectTimer = null
}
if (this.socket) {
this.destroySocket(this.socket)
this.socket = null
}
this.handshaked = false
this.pendingQueue = []
}
private setStatus(status: WsStatus, detail?: string): void {
this.status = status
this.handlers?.onStatus(status, detail)
}
}
/** 全局单例(miniapp 进程内一个连接) */
export const wsClient = new WsClient()
@@ -0,0 +1,36 @@
<template>
<!-- rich-text 小程序原生组件(无子组件依赖,稳)。marked md→html → rich-text :nodes 渲染。
alpha 根因已查实(uni-app 2026 alpha 致 tap 失效),rich-text 非元凶,降级后正常用。 -->
<rich-text :nodes="html" :selectable="true" />
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { marked } from 'marked'
import { styleMarkdown } from '@/utils/mdRenderer'
defineOptions({ name: 'MdView' })
/**
* Markdown 渲染气泡(miniapp,rich-text 原生方案)。
*
* 链路:marked.parse(md) → styleMarkdown 注入 inline style → rich-text :nodes(原生渲染 html 节点)。
* - breaks:true 聊天换行(单换行转 <br>)
* - P1-G:styleMarkdown 给代码块/链接/表格加 inline style(rich-text 不认 class)
* - parse 失败回退原文
* - 空内容返空(rich-text 不渲染)
*
* 流式:currentText 变化 → html computed 重算 → rich-text 重渲染。
*/
const props = defineProps<{ content: string }>()
const html = computed(() => {
const src = props.content || ''
if (!src) return ''
try {
return styleMarkdown(marked.parse(src, { breaks: true, async: false }) as string)
} catch {
return src
}
})
</script>
@@ -0,0 +1,133 @@
<template>
<!-- @/ 联想弹层(MVP)。
固定占位列表:不接真实技能路由/实体检索,仅作 chip 插入。
/file /web /task /memory + @file @web @task 四类技能占位。
渲染对齐 chat/index.vue 的 mp-weixin 稳定风格(原生 view/text + 绝对定位浮层)。 -->
<view v-if="visible" class="mention-mask" @tap="onClose">
<view class="mention-pop" @tap.stop>
<view class="mention-head">
<text class="mention-title">{{ trigger === '/' ? '技能' : '提及' }}</text>
<text class="mention-hint">MVP 占位 · 点击插入</text>
</view>
<scroll-view class="mention-list" scroll-y>
<view
v-for="(item, idx) in items"
:key="idx"
class="mention-item"
@tap="onSelect(item)"
>
<text class="mention-item-label">{{ item.label }}</text>
<text class="mention-item-insert">{{ item.insert.trim() }}</text>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup lang="ts">
/**
* MentionInput 联想弹层组件(MVP)。
*
* 设计:
* - 纯展示:接收 visible/trigger/items,选中后 emit select(insert) + close
* - 不查后端/不调路由:items 由父组件固定注入
* - 触发字符 `/`(技能)或 `@`(提及)由父组件依据光标位置判定
* - 选中即在 inputText 末尾插入对应字面量(如 `/file `),发送时整体纯文本走 ai.send
*
* props:
* - visible:是否显示
* - trigger:触发类型(`/` 技能 / `@` 提及)
* - items:占位项列表 {label, insert}
* emits:
* - select(insert):插入文本
* - close():关闭弹层
*/
defineOptions({ name: 'MentionInput' })
interface MentionItem {
label: string
insert: string
}
defineProps<{
visible: boolean
trigger: '/' | '@'
items: MentionItem[]
}>()
const emit = defineEmits<{
(e: 'select', insert: string): void
(e: 'close'): void
}>()
function onSelect(item: MentionItem): void {
emit('select', item.insert)
}
function onClose(): void {
emit('close')
}
</script>
<style scoped>
.mention-mask {
position: fixed;
left: 0;
right: 0;
bottom: 0;
top: 0;
background-color: rgba(0, 0, 0, 0.3);
z-index: 999;
}
.mention-pop {
position: absolute;
left: 12px;
right: 12px;
bottom: 56px;
max-height: 260px;
background-color: #1f1f1f;
border: 1px solid #333333;
border-radius: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.mention-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background-color: #262626;
border-bottom: 1px solid #2e2e2e;
}
.mention-title {
color: #e0e0e0;
font-size: 13px;
font-weight: bold;
}
.mention-hint {
color: #777777;
font-size: 11px;
}
.mention-list {
max-height: 220px;
}
.mention-item {
display: flex;
flex-direction: column;
padding: 10px 12px;
border-bottom: 1px solid #2a2a2a;
}
.mention-item:last-child {
border-bottom: none;
}
.mention-item-label {
color: #e0e0e0;
font-size: 14px;
}
.mention-item-insert {
color: #4a9eff;
font-size: 12px;
margin-top: 2px;
}
</style>
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
/**
* miniapp 运行时配置(host/device_id/token 占位,由用户在设置页或 build env 填)
*
* 这些值在真实部署前是占位:
* - RELAY_HOST:ws/wss 地址,需替换为实际部署的 df-relay 地址
* - DEVICE_ID:用户首次配置桌面端时生成的设备 ID
* - TOKEN:对齐 df-relay 的 DF_RELAY_TOKEN(生产建议每 device 独立 token)
*/
export interface MiniappConfig {
/** relay WS 地址(含协议),如 'ws://192.168.1.100:8080/ws/miniapp' 或 'wss://relay.example.com/ws/miniapp' */
relayHost: string
/** 配对绑定的设备 ID */
deviceId: string
/** 配对 token(对齐 df-relay DF_RELAY_TOKEN) */
token: string
/** 心跳间隔(ms) */
heartbeatInterval: number
/** 重连初始间隔(ms,指数退避) */
reconnectBaseDelay: number
/** 重连最大间隔(ms) */
reconnectMaxDelay: number
}
/**
* 默认配置(占位值,需替换)
*
* 真实部署:用户在设置页填 relayHost/deviceId/token,或通过 build-time env 注入。
* MVP 阶段留占位让类型自洽,实际连接会失败(token 校验不通过 → relay 关连接,
* 这符合预期,提示用户配置)。
*/
export const defaultConfig: MiniappConfig = {
// 测试服 wss(u-work.1216.top → nginx 终止 TLS → df-relay :9180 明文 ws)
// 微信小程序真机要求 wss + socket 合法域名白名单(微信公众平台→开发设置→服务器域名)
relayHost: 'wss://u-work.1216.top/ws/miniapp',
// 配对桌面端 device_id(查桌面端 KV app_settings:device_id;填错则握手过但路由无对端)
// 当前填本机桌面端 device_id(联调期硬编码,后续做配对绑定流程)
deviceId: '1b92cbbd-d03e-4a0c-b226-9fadf8dcf0f0',
token: 'devflow-relay-default-token',
heartbeatInterval: 30000,
reconnectBaseDelay: 1000,
reconnectMaxDelay: 30000,
}
/** 单例配置(miniapp 内存配置,后续接 storage 持久化) */
let _config: MiniappConfig = { ...defaultConfig }
export function getConfig(): MiniappConfig {
return _config
}
export function setConfig(patch: Partial<MiniappConfig>): void {
_config = { ..._config, ...patch }
}
+13
View File
@@ -0,0 +1,13 @@
import { createSSRApp } from 'vue'
import App from './App.vue'
/**
* uni-app 入口:createSSRApp 规范(uni-app Vue3 必需,非 createApp)。
* 桌面端 main.ts 用 createApp(Tauri 浏览器环境),小程序用 createSSRApp 对齐 uni 跨端。
*/
export function createApp() {
const app = createSSRApp(App)
return {
app,
}
}
+53
View File
@@ -0,0 +1,53 @@
{
"name": "DevFlow",
"appid": "PLACEHOLDER",
"description": "DevFlow 远程 AI Chat — 跨端操作桌面端开发助手",
"versionName": "0.1.0",
"versionCode": 100,
"transformPx": false,
"app-plus": {
"usingComponents": true,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
},
"modules": {},
"distribute": {
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.INTERNET\"/>"
]
},
"ios": {},
"sdkConfigs": {}
}
},
"quickapp": {},
"mp-weixin": {
"appid": "PLACEHOLDER",
"setting": {
"urlCheck": false,
"es6": true,
"minified": true,
"postcss": true
},
"usingComponents": true,
"permission": {},
"requiredBackgroundModes": [],
"requiredPrivateInfos": []
},
"h5": {
"title": "DevFlow Mini",
"router": {
"mode": "hash",
"base": "./"
},
"devServer": {
"port": 8081,
"https": false
}
},
"vueVersion": "3"
}
+41
View File
@@ -0,0 +1,41 @@
{
"pages": [
{
"path": "pages/chat/index",
"style": {
"navigationBarTitleText": "AI 对话",
"enablePullDownRefresh": false
}
},
{
"path": "pages/conversations/index",
"style": {
"navigationBarTitleText": "会话列表",
"enablePullDownRefresh": true
}
}
],
"globalStyle": {
"navigationBarTextStyle": "white",
"navigationBarTitleText": "DevFlow",
"navigationBarBackgroundColor": "#1a1a1a",
"backgroundColor": "#0f0f0f",
"backgroundTextStyle": "light"
},
"tabBar": {
"color": "#999999",
"selectedColor": "#4a9eff",
"backgroundColor": "#1a1a1a",
"borderStyle": "black",
"list": [
{
"pagePath": "pages/chat/index",
"text": "对话"
},
{
"pagePath": "pages/conversations/index",
"text": "会话"
}
]
}
}
+993
View File
@@ -0,0 +1,993 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { marked } from 'marked'
import { styleMarkdown } from '@/utils/mdRenderer'
import { useAiChat } from '@/composables/useAiChat'
import type { MentionSpan } from '@/types/relay'
import type { ProjectRecord, TaskRecord, IdeaRecord, AiToolCallInfo } from '@/types/events'
// 组件内联(绕过微信工具 3.15.2 组件解析缓存 bug):独立组件恒报 Component not found,
// inline 进页面后 chat/index.json usingComponents 空,工具无需解析 components/,问题消失。
// MdView/MentionInput 源保留备用(工具修复后可拆回)。
/** md→html 渲染 + 缓存(避免 v-for 每次 render 重算 marked.parse 同步阻塞) */
const mdCache = new Map<string, string>()
function renderMd(src: string): string {
if (!src) return ''
const cached = mdCache.get(src)
if (cached !== undefined) return cached
let html: string
try {
html = marked.parse(src, { breaks: true, async: false }) as string
html = styleMarkdown(html) // P1-G:代码块/链接/表格 inline style(rich-text 不认 class)
} catch {
html = src
}
mdCache.set(src, html)
return html
}
const ai = useAiChat()
const inputText = ref('')
// —— 联想弹层状态(技能 / 与 @ 实体复用同一浮层结构,detectTrigger 决定 trigger) ——
// 弹层可见 + 当前触发类型。trigger='skill' = `/` 开头技能联想;trigger='entity' = 末尾 `@` 实体联想。
const mentionVisible = ref(false)
const mentionTrigger = ref<'skill' | 'entity'>('skill')
// —— pendingSkill(`/<skill>` 选中后 chip 展示,handleSend 时透传 ai.send skill 参数) ——
// 选中技能后清 inputText 并置 chip,后续用户输入技能参数;发送时 skill.name 透传后端注入 SKILL.md。
// 类型与 useAiChat.skills 元素一致(对齐 relay.ts SkillInfo)。
type SkillInfoLike = { name: string; description: string; argument_hint?: string }
const pendingSkill = ref<SkillInfoLike | null>(null)
// —— pendingMentionSpans(@项目/@任务/@灵感 选中后累积,handleSend 时透传 ai.send spans 参数) ——
// 每条对应 inputText 中一段 `[类型:名]` 文本区间(start/length 为字符偏移)。
// 用户可连续 @ 多个实体,发送时整体透传,后端 resolve 投影成 Augmentation 注入。
const pendingMentionSpans = ref<MentionSpan[]>([])
// —— P1-12 自动滚底 + P1-7 生成中关联想 + P1-13 渲染上限 ——
/** scroll-into-view 锚点(同值不重滚,故 toggle ''→id 触发) */
const scrollAnchor = ref('')
function scrollToBottom(): void {
scrollAnchor.value = ''
// setTimeout(0) 等新消息 DOM 渲染完(mp-weixin nextTick 兼容)
setTimeout(() => { scrollAnchor.value = 'list-bottom-anchor' }, 0)
}
let scrollTimer: ReturnType<typeof setTimeout> | null = null
/** 流式增量高频,throttle 防滚底抖动 */
function throttledScrollToBottom(): void {
if (scrollTimer) return
scrollTimer = setTimeout(() => {
scrollTimer = null
scrollToBottom()
}, 120)
}
// 新消息 push / 切会话历史回流 → 立即滚底;流式增量 → throttled 滚底
watch(() => ai.messages.length, () => scrollToBottom())
watch(() => ai.currentText.value, throttledScrollToBottom)
// P1-7:生成中关闭联想弹层,防 input disabled 时仍点插入(input disabled 不阻止弹层点击)
watch(() => ai.generating.value, (g) => { if (g) mentionVisible.value = false })
/**
* P1-13 渲染上限兜底:仅渲染最近 N 条,防长会话(100+)rich-text 全量渲染卡顿/内存膨胀。
* TODO 真虚拟化(滚动入视口才 marked.parse / 窗口化),当前截断旧消息简化兜底。
*/
const MAX_RENDER_MSGS = 200
const visibleMessages = computed(() => ai.messages.slice(-MAX_RENDER_MSGS))
/**
* 输入变化时检测联想触发条件。
*
* 两类触发:
* - 技能(`skill`):inputText 以 `/` 开头,且尚未选 pendingSkill(已选则不再触发,
* 因 chip 已在输入栏,用户输入的是技能参数)→ listSkills() 拉技能 + 显技能浮层。
* - 实体(`entity`):末尾连续非空白段恰好为单字符 `@`(避免匹配 url/邮箱)→
* listEntities() 拉实体 + 显实体浮层。
*
* 二者优先级:技能(行首 /)优先于实体(末尾 @),因 `/` 开头整行属技能模式。
* 已选 pendingSkill 时屏蔽 `/` 重复触发(同上,用户在输入参数)。
*/
function detectTrigger(): void {
const text = inputText.value
if (!text) {
mentionVisible.value = false
return
}
// 技能触发:行首 / 且未选技能。空格后(/file xxx)不再是行首 /,自动关闭浮层。
if (text.startsWith('/') && !pendingSkill.value) {
mentionTrigger.value = 'skill'
mentionVisible.value = true
ai.listSkills()
return
}
// 实体触发:末尾连续非空白段恰好单字符 @
const match = text.match(/([^\s]+)\s*$/)
const last = match ? match[1] : ''
if (last === '@') {
mentionTrigger.value = 'entity'
mentionVisible.value = true
ai.listEntities()
return
}
mentionVisible.value = false
}
/** 监听输入:mp-weixin textarea 的 @input 在 v-model 后触发,可直接检测(P3-A input→textarea) */
function onInput(): void {
detectTrigger()
}
/**
* 选中技能(技能浮层):置 pendingSkill chip + 清 inputText。
*
* 清 inputText 因技能模式语义是「选技能后输参数」,选完即重置输入框等用户输参数文本。
* 技能参数提示(argument_hint,如 "<url>")随 chip 展示,引导用户输入。
* 不透传 skill 到 inputText(避免 `/技能名` 文本污染参数输入),skill 仅经 handleSend 透传。
*/
function onSkillSelect(skill: SkillInfoLike): void {
pendingSkill.value = skill
inputText.value = ''
mentionVisible.value = false
}
/**
* 选中实体(实体浮层):替换末尾 `@` 为 `[类型:名]` 文本 + 累积 MentionSpan。
*
* span.start/length:实体插入文本(inputText 中 `[类型:名]` 段)的字符偏移,
* 后端据 start/length 在原文中切出区间注入对应实体上下文。
* kind/refId/label 对齐后端 MentionSpanDto:project/task/idea + id + 展示名。
* 连续 @ 多个实体时累积(每次 push 一条),发送时整体透传。
*/
function onEntitySelect(
entity: ProjectRecord | TaskRecord | IdeaRecord,
kind: 'project' | 'task' | 'idea',
): void {
const text = inputText.value
// 去末尾触发字符 `@`(detectTrigger 已保证末段是 @,这里兜底 replace 防御)
const stripped = text.replace(/@\s*$/, '')
// 类型标签中文映射(对齐桌面端 chip 展示习惯:项目/任务/灵感)
const kindLabel = kind === 'project' ? '项目' : kind === 'task' ? '任务' : '灵感'
// 名:project 用 name,task/idea 用 title
const name = (entity as ProjectRecord).name || (entity as TaskRecord | IdeaRecord).title
const insertText = `[${kindLabel}:${name}]`
const start = stripped.length
inputText.value = stripped + insertText
// 累积 mention span(字符偏移,后端透传不解释单位)
pendingMentionSpans.value.push({
start,
length: insertText.length,
kind,
refId: entity.id,
label: name,
})
mentionVisible.value = false
}
/** 清除已选技能 chip(handleSend 后或用户点 × 清除) */
function clearPendingSkill(): void {
pendingSkill.value = null
}
/** 关闭弹层(点遮罩) */
function onMentionClose(): void {
mentionVisible.value = false
}
/**
* 发送:对齐 useAiChat.send(text, skill?, spans?) 新签名。
*
* - skill:已选 pendingSkill 时透传 name(后端注入 SKILL.md);否则 undefined(普通对话)。
* - spans:有 pendingMentionSpans 时透传(后端 resolve 实体上下文);否则 undefined。
* - 发送后清 inputText + pendingSkill + pendingMentionSpans,复位待下一次输入。
*
* 保留原有守卫:空文本忽略 / generating 中忽略 / 关闭联想浮层。
*/
function handleSend(): void {
console.log('[v2:send] enter text=', JSON.stringify(inputText.value), 'gen=', ai.generating.value)
const text = inputText.value.trim()
if (!text) {
console.log('[v2:send] 空文本,忽略')
return
}
if (ai.generating.value) {
// 审批挂起/流式生成中后端拒新 send:提示用户(去审批或等待),避免点了没反应困惑
uni.showToast({
title: ai.pendingApprovals.length > 0 ? '有待审批项,请先处理' : '生成中,请稍候',
icon: 'none',
})
return
}
mentionVisible.value = false
const skill = pendingSkill.value ? pendingSkill.value.name : undefined
const spans = pendingMentionSpans.value.length > 0 ? pendingMentionSpans.value : undefined
ai.send(text, skill, spans)
inputText.value = ''
pendingSkill.value = null
pendingMentionSpans.value = []
}
// —— 工具卡片 + 审批 UI(对齐桌面端 ToolCard,适配 mp-weixin 深色风格) ——
// 后端推 AiToolCallStarted/AiApprovalRequired/AiDirAuthRequired → useAiChat 收进
// 消息 m.toolCalls(状态机)+ pendingApprovals。消息内联渲染工具状态 + 待审批按钮,
// miniapp 成轻量审批终端(单一渲染源,对齐桌面 ToolCard 嵌消息气泡)。
// 高危工具白名单(approve 前二次确认,对齐桌面 ToolCard.vue HIGH_RISK_TOOLS)
const HIGH_RISK_TOOLS = new Set<string>([
'delete_task',
'delete_project',
'restore_project',
'purge_project',
'delete_file',
'run_command',
'http_request',
])
// 工具状态 → 中文文案 + 颜色 class(渲染状态点/标签)
const STATUS_META: Record<string, { label: string; cls: string }> = {
running: { label: '执行中', cls: 'st-running' },
completed: { label: '已完成', cls: 'st-completed' },
failed: { label: '失败', cls: 'st-failed' },
pending_approval: { label: '待审批', cls: 'st-pending' },
rejected: { label: '已拒绝', cls: 'st-rejected' },
}
/** 截断长字符串(参数值/结果展示防撑爆气泡) */
function truncate(s: string, max = 120): string {
return s.length > max ? s.slice(0, max) + '…' : s
}
/** 格式化工具参数为 [{key,val}] 行数组(args 任意结构,最多 4 项防长) */
function formatArgs(args: unknown): { key: string; val: string }[] {
if (!args || typeof args !== 'object') return []
const obj = args as Record<string, unknown>
return Object.entries(obj)
.filter(([, v]) => v !== undefined && v !== null && v !== '')
.slice(0, 4)
.map(([k, v]) => ({
key: k,
val: truncate(typeof v === 'string' ? v : JSON.stringify(v)),
}))
}
/** 格式化执行结果摘要(completed/failed 展示,result 可能任意结构) */
function formatResult(result: unknown): string {
if (result === undefined || result === null) return ''
if (typeof result === 'string') return truncate(result)
try {
return truncate(JSON.stringify(result))
} catch {
return ''
}
}
/**
* 识别后端审批占位 message(对齐 crates/df-ai/context_helpers.rs:498 PENDING_MARKER_PREFIX)。
*
* 后端工具挂起审批时往消息流写占位 assistant message,content 形如
* 「需要用户审批,等待确认__PENDING__:call_xxx」。桌面端识别占位转工具审批卡片;
* miniapp 不识别会原样渲染成气泡(暴露 __PENDING__ 技术标识)。此处识别后隐藏文本,
* 审批 UI 由消息内联 m.toolCalls 工具卡片单独渲染(单一渲染源,不重复)。
*/
const PENDING_MARKER = '__PENDING__:'
const PENDING_PLACEHOLDER_LEGACY = '需要用户审批,等待确认'
function isPendingPlaceholder(content: string | undefined | null): boolean {
if (!content) return false
if (content.includes(PENDING_MARKER)) return true
return content === PENDING_PLACEHOLDER_LEGACY
}
/**
* 识别纯 JSON 工具结果 message(content 是工具执行结果 JSON,如 list_tasks 的
* {"has_more":false,"items":[],...})。弱化为灰小字折叠,不占主气泡,不刺眼。
*/
function isToolResultJson(content: string | undefined | null): boolean {
if (!content) return false
const t = content.trim()
if (!(t.startsWith('{') || t.startsWith('['))) return false
try {
JSON.parse(t)
return true
} catch {
return false
}
}
/** 审批通过(risk 类):高危工具先 uni.showModal 二次确认 */
function onApprove(tc: AiToolCallInfo): void {
if (HIGH_RISK_TOOLS.has(tc.name)) {
uni.showModal({
title: '高危操作确认',
content: `「${tc.name}」属高危操作,确认批准执行?`,
confirmColor: '#e85a4f',
success: (r) => {
if (r.confirm) ai.approve(tc.id, true)
},
})
return
}
ai.approve(tc.id, true)
}
/** 审批拒绝(risk 类) */
function onReject(tc: AiToolCallInfo): void {
ai.approve(tc.id, false)
}
/** 路径授权(path 类:once=本次 / always=永久 / deny=拒绝) */
function onAuthorize(tc: AiToolCallInfo, decision: 'once' | 'always' | 'deny'): void {
ai.authorizeDir(tc.id, decision)
}
/** 连接状态中文文案(F21:裸枚举字符串 disconnected/connecting 不可读) */
const STATUS_TEXT: Record<string, string> = {
connecting: '连接中…',
handshaking: '握手中…',
reconnecting: '重连中…·点此立即重连',
disconnected: '已断开·点此重连',
}
const statusText = computed(() => {
if (ai.isWsConnected.value) {
return ai.deviceOnline.value ? '已连接桌面端' : '已连接中继'
}
return STATUS_TEXT[ai.wsStatus.value] || ai.wsStatus.value
})
const canManualReconnect = computed(
() => ai.wsStatus.value === 'disconnected' || ai.wsStatus.value === 'reconnecting',
)
function onStatusTap(): void {
if (canManualReconnect.value) ai.resumeIfDisconnected()
}
/** 停止生成(wires F9 终态兜底;生成中 send 禁用无停止入口,用户无法中断) */
function onStop(): void {
ai.stop()
}
/** 继续循环(P1-E:MaxRounds 挂起后再跑一轮) */
function onContinueLoop(): void {
ai.continueLoop()
}
/** 停止循环(P1-E:MaxRounds 挂起后彻底终止多轮 agent) */
function onStopLoop(): void {
ai.stopLoop()
}
/** 重发最后一条 user 消息(P1-F:regenerate 入口) */
function onRegenerate(): void {
ai.regenerate()
}
/** 长按消息复制全文(P1-F:assistant 复制 markdown 源,user 复制原文) */
function onCopyMessage(m: { content?: string }): void {
if (!m.content) return
uni.setClipboardData({
data: m.content,
success: () => uni.showToast({ title: '已复制', icon: 'none' }),
})
}
</script>
<template>
<view class="page">
<!-- 状态栏 + 待审批徽标(F21:断开/重连中可点状态栏手动重连) -->
<view class="top">
<text class="st" :class="{ 'st-tap': canManualReconnect }" @tap="onStatusTap">{{ statusText }}</text>
<view class="top-right">
<text v-if="ai.tokenUsage.value" class="token-usage">🪙 {{ ai.tokenUsage.value.total }}</text>
<view v-if="ai.pendingApprovals.length > 0" class="pending-badge">
<text>⏳ {{ ai.pendingApprovals.length }} 待审批</text>
</view>
</view>
</view>
<!-- 达最大轮次面板(P1-E:继续/停止循环,对齐桌面 MaxRounds) -->
<view v-if="ai.maxRoundsActive.value" class="maxrounds-panel">
<text class="maxrounds-text">⚠ 已达最大轮次</text>
<view class="maxrounds-actions">
<button class="approval-btn approve" size="mini" @tap="onContinueLoop">继续一轮</button>
<button class="approval-btn reject" size="mini" @tap="onStopLoop">停止</button>
</view>
</view>
<!-- 待审批面板(从 pendingApprovals 渲染,与 messages 解耦:req3 重连 load_messages 替换 messages 不影响审批卡) -->
<view v-if="ai.pendingApprovals.length > 0" class="pending-panel">
<view class="pending-panel-head">
<text>⏳ {{ ai.pendingApprovals.length }} 项待审批</text>
</view>
<view v-for="p in ai.pendingApprovals" :key="p.id" class="tool-card">
<view class="tool-head">
<text class="tool-name">🔧 {{ p.name }}</text>
<text class="tool-status st-pending">待审批</text>
</view>
<view v-if="formatArgs(p.args).length > 0" class="tool-args">
<view v-for="a in formatArgs(p.args)" :key="a.key" class="tool-arg">
<text class="tool-arg-key">{{ a.key }}:</text>
<text class="tool-arg-val">{{ a.val }}</text>
</view>
</view>
<view v-if="p.kind === 'path' && p.dir" class="tool-dir">
<text>📁 {{ p.dir }}</text>
</view>
<view v-if="p.reason" class="tool-reason">
<text>⚠ {{ p.reason }}</text>
</view>
<!-- risk 类:批准/拒绝 -->
<view v-if="p.kind !== 'path'" class="approval-actions">
<button class="approval-btn approve" size="mini" @tap="onApprove(p)">批准</button>
<button class="approval-btn reject" size="mini" @tap="onReject(p)">拒绝</button>
</view>
<!-- path 类:本次/永久/拒绝 -->
<view v-else class="approval-actions">
<button class="approval-btn approve" size="mini" @tap="onAuthorize(p, 'once')">本次</button>
<button class="approval-btn always" size="mini" @tap="onAuthorize(p, 'always')">永久</button>
<button class="approval-btn reject" size="mini" @tap="onAuthorize(p, 'deny')">拒绝</button>
</view>
</view>
</view>
<!-- 消息列表(scroll-view 标准滚动,纯 text 渲染) -->
<scroll-view class="list" scroll-y :scroll-into-view="scrollAnchor" :scroll-with-animation="true">
<view v-if="ai.messages.length === 0" class="empty">
<text>暂无消息</text>
</view>
<view v-for="m in visibleMessages" :key="m.id" class="msg" :class="m.role" @longpress="onCopyMessage(m)">
<text v-if="m.role === 'user'" user-select>{{ m.content }}</text>
<template v-else>
<!-- 后端审批占位 message(「需要…__PENDING__:call_xxx」)隐藏文本,审批走工具卡片 -->
<rich-text
v-if="m.content && !isPendingPlaceholder(m.content) && !isToolResultJson(m.content)"
:nodes="renderMd(m.content)"
selectable
/>
<!-- 纯 JSON 工具结果弱化(灰小字折叠,不占主气泡) -->
<view v-else-if="m.content && isToolResultJson(m.content)" class="msg-toolresult">
<text>📋 {{ m.content }}</text>
</view>
<!-- 占位 message(isPendingPlaceholder)不渲染文本,仅下方工具卡片承载审批 UI -->
<!-- 工具调用卡片(对齐桌面 ToolCard,assistant 消息内联渲染状态 + 审批按钮) -->
<view v-for="tc in m.toolCalls || []" :key="tc.id" class="tool-card">
<view class="tool-head">
<text class="tool-name">🔧 {{ tc.name }}</text>
<text class="tool-status" :class="(STATUS_META[tc.status] || {}).cls">
{{ (STATUS_META[tc.status] || {}).label || tc.status }}
</text>
</view>
<!-- 参数 -->
<view v-if="formatArgs(tc.args).length > 0" class="tool-args">
<view v-for="a in formatArgs(tc.args)" :key="a.key" class="tool-arg">
<text class="tool-arg-key">{{ a.key }}:</text>
<text class="tool-arg-val">{{ a.val }}</text>
</view>
</view>
<!-- path 授权待授权目录 -->
<view v-if="tc.kind === 'path' && tc.status === 'pending_approval' && tc.dir" class="tool-dir">
<text>📁 {{ tc.dir }}</text>
</view>
<!-- 风险说明 -->
<view v-if="tc.reason" class="tool-reason">
<text>⚠ {{ tc.reason }}</text>
</view>
<!-- 审批按钮已移至顶部 pendingApprovals 面板(与 messages 解耦,req3 重连恢复) -->
<!-- 执行结果摘要(completed/failed) -->
<view
v-if="(tc.status === 'completed' || tc.status === 'failed') && formatResult(tc.result)"
class="tool-result"
>
<text>{{ formatResult(tc.result) }}</text>
</view>
</view>
</template>
</view>
<!-- 流式生成中的当前文本 -->
<view v-if="ai.generating.value && ai.currentText.value" class="msg assistant">
<text user-select>{{ ai.currentText.value }}</text>
</view>
<!-- P1-12 滚底锚点(scroll-into-view 目标) -->
<view id="list-bottom-anchor"></view>
</scroll-view>
<!-- 联想弹层(技能 / 与 @ 实体复用同一浮层结构,detectTrigger 决定 trigger) -->
<view v-if="mentionVisible && !ai.generating.value" class="mention-mask" @tap="onMentionClose">
<view class="mention-pop" @tap.stop>
<view class="mention-head">
<text class="mention-title">{{ mentionTrigger === 'skill' ? '技能' : '提及' }}</text>
<text class="mention-hint">点击选中</text>
</view>
<scroll-view class="mention-list" scroll-y>
<!-- 技能列表(`/` 触发,name/description/argument_hint 展示) -->
<template v-if="mentionTrigger === 'skill'">
<view v-if="ai.skills.length === 0" class="mention-empty">
<text>加载中或无技能...</text>
</view>
<view
v-for="(s, idx) in ai.skills"
:key="'sk-' + idx"
class="mention-item"
@tap="onSkillSelect(s)"
>
<view class="mention-item-main">
<text class="mention-item-label">/{{ s.name }}</text>
<text class="mention-item-source">{{ s.source }}</text>
</view>
<text class="mention-item-desc">{{ s.description }}</text>
<text v-if="s.argument_hint" class="mention-item-insert">参数:{{ s.argument_hint }}</text>
</view>
</template>
<!-- 实体列表(`@` 触发,按 项目/任务/灵感 分组) -->
<template v-else>
<view v-if="ai.entities.projects.length === 0 && ai.entities.tasks.length === 0 && ai.entities.ideas.length === 0" class="mention-empty">
<text>加载中或无数据...</text>
</view>
<view v-if="ai.entities.projects.length > 0" class="mention-group-title">
<text>项目</text>
</view>
<view
v-for="(p, idx) in ai.entities.projects"
:key="'p-' + idx"
class="mention-item"
@tap="onEntitySelect(p, 'project')"
>
<text class="mention-item-label">{{ p.name }}</text>
<text class="mention-item-desc">{{ p.description }}</text>
</view>
<view v-if="ai.entities.tasks.length > 0" class="mention-group-title">
<text>任务</text>
</view>
<view
v-for="(t, idx) in ai.entities.tasks"
:key="'t-' + idx"
class="mention-item"
@tap="onEntitySelect(t, 'task')"
>
<text class="mention-item-label">{{ t.title }}</text>
<text class="mention-item-desc">{{ t.description }}</text>
</view>
<view v-if="ai.entities.ideas.length > 0" class="mention-group-title">
<text>灵感</text>
</view>
<view
v-for="(i, idx) in ai.entities.ideas"
:key="'i-' + idx"
class="mention-item"
@tap="onEntitySelect(i, 'idea')"
>
<text class="mention-item-label">{{ i.title }}</text>
<text class="mention-item-desc">{{ i.description }}</text>
</view>
</template>
</scroll-view>
</view>
</view>
<!-- 输入 + 发送(原生 button @tap) -->
<view class="bar">
<!-- pendingSkill chip(选中技能后展示名+参数提示,× 清除) -->
<view v-if="pendingSkill" class="skill-chip">
<text class="skill-chip-name">/{{ pendingSkill.name }}</text>
<text v-if="pendingSkill.argument_hint" class="skill-chip-hint">{{ pendingSkill.argument_hint }}</text>
<text class="skill-chip-close" @tap="clearPendingSkill">×</text>
</view>
<textarea
v-model="inputText"
class="ipt"
:placeholder="ai.generating.value ? '生成中,完成后发送…' : pendingSkill ? '输入技能参数' : '输入消息(输入 / 或 @ 触发联想)'"
:auto-height="true"
:adjust-position="true"
confirm-type="send"
@confirm="handleSend"
@input="onInput"
/>
<!-- 重发最后一条 user(P1-F,非生成中且有消息时显) -->
<button
v-if="!ai.generating.value && ai.messages.length > 0"
class="regen-btn"
@tap="onRegenerate"
>
重发
</button>
<!-- 生成中显停止按钮(wires F9),否则发送 -->
<button v-if="ai.generating.value" class="send-btn stop-btn" @tap="onStop">停止</button>
<button v-else class="send-btn" @tap="handleSend">发送</button>
</view>
</view>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #0f0f0f;
}
.top {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background-color: #1a1a1a;
}
.st {
color: #999999;
font-size: 12px;
}
.st-tap {
color: #4a9eff;
}
.top-right {
display: flex;
flex-direction: row;
align-items: center;
}
.token-usage {
color: #777777;
font-size: 11px;
margin-right: 8px;
}
.list {
flex: 1;
min-height: 0;
padding: 12px;
}
.empty {
text-align: center;
padding: 80px 0;
}
.empty text {
color: #666666;
font-size: 13px;
}
.msg {
margin-bottom: 12px;
padding: 10px 14px;
border-radius: 10px;
background-color: #2a2a2a;
}
.msg text {
color: #e0e0e0;
font-size: 14px;
line-height: 1.5;
}
.msg.user {
background-color: #4a9eff;
}
.msg.user text {
color: #ffffff;
}
.msg.system text {
color: #999999;
font-size: 12px;
}
.bar {
flex-shrink: 0;
display: flex;
align-items: flex-end;
padding: 8px 12px;
background-color: #1a1a1a;
}
.ipt {
flex: 1;
background-color: #2a2a2a;
color: #e0e0e0;
padding: 8px 12px;
min-height: 36px;
max-height: 120px;
box-sizing: border-box;
overflow-y: auto;
border-radius: 6px;
font-size: 14px;
line-height: 20px;
}
.send-btn {
margin-left: 8px;
background-color: #4a9eff;
color: #ffffff;
font-size: 14px;
}
.send-btn.stop-btn {
background-color: #5a2a2a;
color: #e85a4f;
}
.regen-btn {
margin-left: 8px;
background-color: #2a2a2a;
color: #c0c0c0;
font-size: 13px;
}
/* @/ 联想弹层(内联自 MentionInput,绕过工具组件解析 bug) */
.mention-mask {
position: fixed;
left: 0;
right: 0;
bottom: 0;
top: 0;
background-color: rgba(0, 0, 0, 0.3);
z-index: 999;
}
.mention-pop {
position: absolute;
left: 12px;
right: 12px;
bottom: 56px;
max-height: 260px;
background-color: #1f1f1f;
border: 1px solid #333333;
border-radius: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.mention-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background-color: #262626;
border-bottom: 1px solid #2e2e2e;
}
.mention-title {
color: #e0e0e0;
font-size: 13px;
font-weight: bold;
}
.mention-hint {
color: #777777;
font-size: 11px;
}
.mention-list {
max-height: 220px;
}
.mention-item {
display: flex;
flex-direction: column;
padding: 10px 12px;
border-bottom: 1px solid #2a2a2a;
}
.mention-item-main {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.mention-item-label {
color: #e0e0e0;
font-size: 14px;
}
.mention-item-source {
color: #777777;
font-size: 10px;
margin-left: 8px;
}
.mention-item-desc {
color: #999999;
font-size: 12px;
margin-top: 2px;
}
.mention-item-insert {
color: #4a9eff;
font-size: 12px;
margin-top: 2px;
}
.mention-empty {
padding: 24px 12px;
text-align: center;
}
.mention-empty text {
color: #666666;
font-size: 12px;
}
.mention-group-title {
padding: 6px 12px;
background-color: #232323;
border-bottom: 1px solid #2a2a2a;
}
.mention-group-title text {
color: #888888;
font-size: 11px;
font-weight: bold;
}
/* pendingSkill chip(选中技能后展示名+参数提示+× 清除) */
.skill-chip {
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
padding: 0 8px;
height: 28px;
margin-right: 6px;
background-color: #1f3a5f;
border: 1px solid #4a9eff;
border-radius: 14px;
}
.skill-chip-name {
color: #4a9eff;
font-size: 12px;
}
.skill-chip-hint {
color: #7799bb;
font-size: 10px;
margin-left: 6px;
}
.skill-chip-close {
color: #4a9eff;
font-size: 16px;
margin-left: 6px;
padding: 0 2px;
}
/* 工具卡片 + 审批 UI(对齐桌面 ToolCard,深色风格) */
.pending-badge {
flex-shrink: 0;
margin: 0 8px;
padding: 2px 8px;
background-color: #3a2f1f;
border: 1px solid #f0c75e;
border-radius: 10px;
}
.pending-badge text {
color: #f0c75e;
font-size: 11px;
}
/* 待审批面板(从 pendingApprovals 渲染,与 messages 解耦避重连竞态) */
.pending-panel {
flex-shrink: 0;
max-height: 40vh;
overflow-y: auto;
padding: 4px 12px 8px;
background-color: #1a1a1a;
border-bottom: 1px solid #2a2a2a;
}
.pending-panel-head {
padding: 4px 0;
}
.pending-panel-head text {
color: #f0c75e;
font-size: 12px;
font-weight: bold;
}
/* 达最大轮次面板(P1-E:继续/停止循环) */
.maxrounds-panel {
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background-color: #2a2218;
border-bottom: 1px solid #3a2f1f;
}
.maxrounds-text {
color: #f0c75e;
font-size: 13px;
}
.maxrounds-actions {
display: flex;
flex-direction: row;
}
.tool-card {
margin-top: 8px;
padding: 8px 10px;
background-color: #1f1f1f;
border: 1px solid #333333;
border-radius: 8px;
}
.tool-head {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.tool-name {
color: #e0e0e0;
font-size: 13px;
font-weight: bold;
}
.tool-status {
padding: 1px 6px;
border-radius: 8px;
font-size: 10px;
}
.tool-status.st-running {
background-color: #2a3a5a;
color: #6bb6ff;
}
.tool-status.st-completed {
background-color: #1f3a2a;
color: #6bd99a;
}
.tool-status.st-failed {
background-color: #3a1f1f;
color: #e85a4f;
}
.tool-status.st-pending {
background-color: #3a2f1f;
color: #f0c75e;
}
.tool-status.st-rejected {
background-color: #2a2a2a;
color: #999999;
}
.tool-args {
margin-top: 6px;
}
.tool-arg {
display: flex;
flex-direction: row;
margin-top: 2px;
}
.tool-arg-key {
color: #7799bb;
font-size: 12px;
margin-right: 4px;
}
.tool-arg-val {
color: #c0c0c0;
font-size: 12px;
flex: 1;
}
.tool-dir {
margin-top: 4px;
}
.tool-dir text {
color: #6bd99a;
font-size: 12px;
}
.tool-reason {
margin-top: 6px;
padding: 4px 6px;
background-color: #2a2218;
border-radius: 4px;
}
.tool-reason text {
color: #f0c75e;
font-size: 12px;
}
.approval-actions {
display: flex;
flex-direction: row;
margin-top: 8px;
}
.approval-btn {
margin-right: 6px;
padding: 0 12px;
height: 30px;
line-height: 30px;
font-size: 12px;
border-radius: 6px;
}
.approval-btn.approve {
background-color: #2a5a3a;
color: #6bd99a;
}
.approval-btn.always {
background-color: #2a3a5a;
color: #6bb6ff;
}
.approval-btn.reject {
background-color: #5a2a2a;
color: #e85a4f;
margin-right: 0;
}
.tool-result {
margin-top: 6px;
padding: 4px 6px;
background-color: #181818;
border-radius: 4px;
}
.tool-result text {
color: #999999;
font-size: 11px;
}
/* 纯 JSON 工具结果 message 弱化(灰小字折叠,避免刺眼占用主气泡) */
.msg-toolresult {
margin-top: 4px;
padding: 6px 8px;
background-color: #181818;
border-radius: 6px;
max-height: 80px;
overflow: hidden;
}
.msg-toolresult text {
color: #777777;
font-size: 11px;
}
</style>
@@ -0,0 +1,189 @@
<script setup lang="ts">
import { onPullDownRefresh } from '@dcloudio/uni-app'
import { useAiChat } from '@/composables/useAiChat'
import type { Conversation } from '@/types/events'
const ai = useAiChat()
/** 点击会话项切换 */
function handleSelect(convId: string): void {
ai.switchConversation(convId)
// 切换后跳回对话页
uni.switchTab({ url: '/pages/chat/index' })
}
/** 新建会话 */
function handleNew(): void {
ai.newConversation()
uni.switchTab({ url: '/pages/chat/index' })
}
/** 长按会话项重命名(对齐桌面端改会话名) */
function handleRename(conv: Conversation): void {
uni.showModal({
title: '重命名会话',
editable: true,
placeholderText: '输入新名称',
content: conv.title || '',
success: (r) => {
if (r.confirm && r.content) {
const newTitle = r.content.trim()
if (newTitle && newTitle !== conv.title) {
ai.renameConversation(conv.id, newTitle)
}
}
},
})
}
/** 下拉刷新:发 list_conversations 同步 device 会话列表(F-#95)。
* 命令异步回流经 AiConversationList 事件替换 ai.conversations(handleEvent),
* 此处仅发命令 + 收尾停止下拉动画(列表更新非同步返回,故立即停止动画即可)。 */
onPullDownRefresh(() => {
ai.refreshConversations()
uni.stopPullDownRefresh()
})
/** 格式化时间戳为可读字符串 */
function formatTime(ts?: number): string {
if (!ts) return ''
const d = new Date(ts)
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const mi = String(d.getMinutes()).padStart(2, '0')
return `${mm}-${dd} ${hh}:${mi}`
}
</script>
<template>
<view class="conv-page">
<!-- 顶部新建按钮 -->
<view class="new-bar" @click="handleNew">
<text class="new-icon">+</text>
<text class="new-text">新建会话</text>
</view>
<!-- 会话列表 -->
<scroll-view class="conv-list" scroll-y>
<view v-if="ai.conversations.length === 0" class="empty-hint">
<text>暂无历史会话</text>
<text class="empty-sub">在桌面端创建后,此处同步</text>
</view>
<view
v-for="conv in ai.conversations"
:key="conv.id"
class="conv-item"
:class="{ active: conv.id === ai.activeConversationId.value }"
@click="handleSelect(conv.id)"
@longpress="handleRename(conv)"
>
<view class="conv-main">
<text class="conv-title">{{ conv.title || '(未命名会话)' }}</text>
<text v-if="conv.lastMessage" class="conv-last">{{ conv.lastMessage }}</text>
</view>
<text v-if="conv.updatedAt" class="conv-time">{{ formatTime(conv.updatedAt) }}</text>
</view>
<view v-if="ai.conversations.length > 0" class="list-hint">
<text>长按会话可重命名</text>
</view>
</scroll-view>
</view>
</template>
<style lang="scss" scoped>
.conv-page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: $uni-bg-color;
}
.new-bar {
display: flex;
align-items: center;
padding: 24rpx;
background-color: $uni-bg-color-dark;
border-bottom: 1rpx solid $uni-border-color;
}
.new-icon {
font-size: 40rpx;
color: $uni-color-primary;
margin-right: 16rpx;
}
.new-text {
font-size: $uni-font-size-base;
color: $uni-color-primary;
}
.conv-list {
flex: 1;
}
.empty-hint {
display: flex;
flex-direction: column;
align-items: center;
padding: 200rpx 24rpx;
text {
color: $uni-text-color-disable;
font-size: $uni-font-size-base;
}
.empty-sub {
margin-top: 12rpx;
font-size: $uni-font-size-sm;
}
}
.conv-item {
display: flex;
align-items: center;
padding: 24rpx;
border-bottom: 1rpx solid $uni-border-color;
&.active {
background-color: $uni-bg-color-light;
border-left: 6rpx solid $uni-color-primary;
}
}
.conv-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.conv-title {
font-size: $uni-font-size-base;
color: $uni-text-color;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conv-last {
margin-top: 8rpx;
font-size: $uni-font-size-sm;
color: $uni-text-color-grey;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conv-time {
font-size: $uni-font-size-sm;
color: $uni-text-color-disable;
margin-left: 16rpx;
}
.list-hint {
padding: 24rpx;
text-align: center;
}
.list-hint text {
color: $uni-text-color-disable;
font-size: $uni-font-size-sm;
}
</style>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="@dcloudio/types" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
export default component
}
+196
View File
@@ -0,0 +1,196 @@
/**
* AiChatEvent 类型镜像(对齐 DevFlow 后端 Rust 定义)
*
* 类型证据:
* - Rust 定义:src-tauri/src/commands/ai/mod.rs:105-216 `pub enum AiChatEvent`
* (19 个变体,#[serde(tag = "type")] → TS 用 type discriminator 镜像)
* - Rust 字段 snake_case:Rust 默认 snake_case 字段,TS 直接对齐(不加 camelCase 转换)
* - 桌面 TS:src/api/types.ts(未在本批核验,但变体名/字段从 Rust 源码逐字对齐)
*
* 注意:TS 联合类型 discriminator 名为 `type`,与 Rust #[serde(tag = "type")] 一致。
* 变体名 PascalCase(Rust enum 变体名原样,serde 默认 PascalCase 序列化)。
*/
// SkillInfo 复用 relay.ts 同协议层定义(`/` 联想浮层渲染 + 选中后 name 透传 SendMessageArgs.skill)
import type { SkillInfo } from './relay'
/** 错误源分类(对齐 Rust ErrorType,mod.rs:89-102,snake_case 序列化) */
export type ErrorType = 'auth' | 'network' | 'timeout' | 'provider_config' | 'unknown'
/** 会话生命周期状态(对齐 Rust ConvState,L2 统一状态机,snake_case 序列化) */
export type ConvState = 'idle' | 'generating' | 'stopping' | 'error' | 'compressed'
/**
* AI 聊天事件联合类型(19 个变体,严格对齐 Rust mod.rs:107-216)
*
* 字段全部对齐 Rust serde(rename_all 默认 snake_case 字段名,tag = "type")。
* 变体名 PascalCase 来自 Rust enum 变体名(serde 默认 PascalCase 序列化 enum variant)。
*
* 注:AiConversationList 的 conversations 字段对齐 Rust ConvSummary(#[serde(rename_all="camelCase")]),
* 元素结构同本文件 Conversation interface(id/title/lastMessage?/updatedAt?/createdAt?)。
*/
export type AiChatEvent =
| { type: 'AiTextDelta'; delta: string; conversation_id?: string | null }
| { type: 'AiToolCallStarted'; id: string; name: string; args: unknown; conversation_id?: string | null }
| { type: 'AiToolCallCompleted'; id: string; result: unknown; conversation_id?: string | null }
| { type: 'AiToolAutoApproved'; id: string; tool: string; dir: string; conversation_id?: string | null }
| {
type: 'AiApprovalRequired'
id: string
name: string
args: unknown
reason: string
diff?: string | null
conversation_id?: string | null
}
| { type: 'AiApprovalResult'; id: string; approved: boolean; conversation_id?: string | null }
| {
type: 'AiCompleted'
total_tokens: number
prompt_tokens: number
completion_tokens: number
incomplete?: boolean | null
conversation_id?: string | null
}
| { type: 'AiError'; error: string; error_type?: ErrorType | null; conversation_id?: string | null }
| { type: 'AiUserMessage'; message: string; conversation_id?: string | null }
| { type: 'AiAgentRound'; round: number; conversation_id?: string | null }
| { type: 'AiHeartbeat'; conversation_id?: string | null }
| { type: 'AiMaxRoundsReached'; conversation_id?: string | null }
| { type: 'AiStreamRetry'; attempt: number; max_attempts: number; conversation_id?: string | null }
| {
type: 'AiDirAuthRequired'
id: string
tool: string
path: string
dir: string
conversation_id?: string | null
}
| { type: 'AiContextCleared'; conversation_id?: string | null }
| { type: 'AiCompressing'; conversation_id?: string | null }
// 治 Task#1:拆分手动/自动两变体(字段相同仅 type 字面量不同),桌面按 type 分流,
// miniapp 两变体都插摘要气泡(历史可见性,见 useAiChat.ts:618 case fallthrough)。
| { type: 'AiManualCompressed'; conversation_id?: string | null; summary: string }
| { type: 'AiAutoCompressed'; conversation_id?: string | null; summary: string }
| {
type: 'AiHelpRequired'
reason: string
context: string
options: string[]
conversation_id?: string | null
}
| { type: 'AiConvStateChanged'; conv_state: ConvState; conversation_id?: string | null }
| { type: 'AiConversationList'; conversations: Conversation[] }
| { type: 'AiMessageHistory'; conversation_id: string; messages: ChatMessage[] }
| { type: 'AiSkillList'; skills: SkillInfo[] }
| {
type: 'AiEntityList'
projects: ProjectRecord[]
tasks: TaskRecord[]
ideas: IdeaRecord[]
}
/** AiChatEvent 的 type 字面量集合(用于 narrowing / 分派) */
export type AiChatEventType = AiChatEvent['type']
/** 会话信息(对齐桌面端 list_conversations 返回结构,字段名 camelCase 兼容桌面 IPC 约定) */
export interface Conversation {
id: string
title: string
/** 最近一条消息摘要(可选) */
lastMessage?: string
/** 最近更新时间戳(ms) */
updatedAt?: number
/** 创建时间(ms) */
createdAt?: number
}
/** 本地渲染消息气泡(对齐桌面端 AiMessage 结构,简化为 miniapp 必需字段) */
export interface ChatMessage {
id: string
role: 'user' | 'assistant' | 'system'
content: string
timestamp: number
isError?: boolean
/** 工具调用列表(对齐 AiToolCallInfo) */
toolCalls?: AiToolCallInfo[]
/** 不完整标记(AiCompleted{incomplete:true} 对应系统提示气泡) */
incomplete?: boolean
}
/** 工具调用信息(对齐桌面 useAiEvents.ts:399 的 AiToolCallInfo) */
export interface AiToolCallInfo {
id: string
name: string
args: unknown
result?: unknown
status: 'running' | 'completed' | 'failed' | 'pending_approval' | 'rejected'
reason?: string
diff?: string
/** 审批类型(risk 普通 / path 路径授权,对齐后端 ApprovalKind) */
kind?: 'risk' | 'path'
dir?: string
path?: string
}
/** 挂起审批项(复用 AiToolCallInfo 结构,miniapp 不实现审批 UI 仅展示) */
export type PendingApproval = AiToolCallInfo
/** Token 用量(对齐桌面端 state.lastTokenUsage) */
export interface TokenUsage {
prompt: number
completion: number
total: number
}
// ============================================================
// 实体列表类型(F-#95 联想扩展,AiEntityList 事件载荷)
// ============================================================
//
// 精简字段对齐桌面端 src/api/types.ts(后端 Record 全量字段经 Tauri command Serialize 直传,
// miniapp 仅声明联想浮层展示所需字段;多余字段 JSON 解析保留不报错)。
// SkillInfo 从 ./relay 复用(同协议层定义,避免重复)。
/** 项目记录(对齐桌面端 src/api/types.ts:80 ProjectRecord,miniapp 精简版) */
export interface ProjectRecord {
id: string
name: string
description: string
status: string
idea_id: string | null
/** 绑定的本地代码目录绝对路径(null=未绑定) */
path: string | null
/** 技术栈 JSON 数组字符串(如 ["rust","vue"],null=未探测) */
stack: string | null
created_at: string
updated_at: string
}
/** 任务记录(对齐桌面端 src/api/types.ts:135 TaskRecord,miniapp 精简版) */
export interface TaskRecord {
id: string
project_id: string
title: string
description: string
status: string
/** 优先级:0=critical, 1=high, 2=medium, 3=low */
priority: number
branch_name: string | null
assignee: string | null
created_at: string
updated_at: string
}
/** 灵感记录(对齐桌面端 src/api/types.ts:11 IdeaRecord,miniapp 精简版) */
export interface IdeaRecord {
id: string
title: string
description: string
status: string
priority: number
score: number | null
tags: string | null
source: string | null
created_at: string
updated_at: string
}
+204
View File
@@ -0,0 +1,204 @@
/**
* df-relay 协议类型镜像(对齐 Rust crates/df-relay/src/)
*
* 类型证据:
* - ClientKind / MessageKind:crates/df-relay/src/broadcast.rs:21-41
* (#[serde(rename_all = "snake_case")] → 'device'/'miniapp', 'event'/'command'/'control')
* - BroadcastMessage:broadcast.rs:48-62(device_id/kind/source/from/payload/ts)
* - Hello 握手帧:crates/df-relay/src/relay.rs:44-53(kind/device_id/token)
* kind 用 ClientKindWire(relay.rs:56-61),序列化为 'device'/'miniapp'
* - 路由端点:relay.rs:177-183 `/ws/miniapp`(小程序专用端点)
*
* 关键协议点(relay.rs:332-365 handle_inbound_text):
* relay 不解析 payload —— miniapp 入站文本帧直接当 payload 包成 BroadcastMessage 路由给 device。
* 故 miniapp 发送时,直接序列化 Command JSON(relay 自动包 payload),不需客户端构造 BroadcastMessage。
* 接收时,device 事件经 relay 包成完整 BroadcastMessage 序列化下发,miniapp 需先解 BroadcastMessage
* 再从 payload 取 AiChatEvent。
*/
/** 客户端类型(对齐 Rust ClientKind,snake_case 序列化,broadcast.rs:21-28) */
export type ClientKind = 'device' | 'miniapp'
/** 消息语义分类(对齐 Rust MessageKind,snake_case 序列化,broadcast.rs:35-41) */
export type MessageKind = 'event' | 'command' | 'control'
/** 在线状态(对齐 Rust PresenceState,snake_case 序列化,broadcast.rs:119-126) */
export type PresenceState = 'online' | 'offline'
/** ConnId 是 Rust u64(对齐 conn.rs ConnId struct),TS 用 number */
export type ConnId = number
/**
* 中继路由消息骨架(对齐 Rust BroadcastMessage,broadcast.rs:48-62)
*
* miniapp 接收时 payload 为 AiChatEvent JSON(relay 透传不解析)。
*/
export interface BroadcastMessage {
/** 配对绑定的设备 ID */
device_id: string
/** 消息语义 */
kind: MessageKind
/** 消息来源连接 ID(relay 分配,u64) */
source: ConnId
/** 来源客户端类型 */
from: ClientKind
/** 载荷(原始 JSON,relay 透传不解析 —— Event 时为 AiChatEvent) */
payload: unknown
/** 时间戳(ms) */
ts: number
}
/**
* 客户端首消息:身份宣告(对齐 Rust Hello,relay.rs:44-53)
*
* 小程序连 `/ws/miniapp` 后首帧必须发此结构,否则 relay 关连接(relay.rs:197-208)。
* kind 恒为 'miniapp'(与端点匹配,relay.rs:212-225 校验,不匹配则关连接)。
*/
export interface Hello {
/** 客户端类型,小程序恒 'miniapp'(对齐 ClientKindWire) */
kind: 'miniapp' | 'device'
/** 配对绑定的设备 ID(用户首次配置时生成) */
device_id: string
/** 配对 token(env DF_RELAY_TOKEN,fallback "devflow-relay-default-token") */
token: string
}
/**
* 控制面消息(对齐 Rust ControlMessage,broadcast.rs:132-145,tag = "control_kind")
*
* miniapp 主要发 Heartbeat 心跳 + Ping;Pair 在首次绑定时用(本批未实现绑定流程)。
*/
export type ControlMessage =
| { control_kind: 'pair'; device_id: string; token: string }
| { control_kind: 'heartbeat'; device_id: string }
| { control_kind: 'presence'; device_id: string; state: PresenceState }
| { control_kind: 'ping' }
| { control_kind: 'pong' }
/**
* 小程序 → 桌面端 Command 协议(miniapp 发起操作)
*
* 类型为对齐 Tauri command 名,relay 透传 device_id 路由到桌面端,桌面端 invoke 对应 command。
* 字段:
* - cmd:命令名(对齐 src-tauri command 函数名)
* - args:命令参数(对齐各 command 的参数)
*
* 路由表全覆盖(14 条):
* - send_message / stop / switch_conversation / regenerate(MVP 已发)
* - approve / authorize_dir / continue_loop / stop_loop(阶段3 补齐,见 useAiChat.ts)
* - list_conversations / load_messages(F-#95 扩展,会话列表/历史消息读取)
* - list_skills / list_entities(F-#95 联想扩展,技能/项目·任务·灵感列表,见 useAiChat.ts)
* - rename_conversation(会话重命名,2026-06-23)/ sync_pending(重连审批恢复,2026-06-23)
*/
export interface MiniCommand {
/** Tauri command 名 */
cmd: string
/** 命令参数(原始对象,对齐各 command 参数签名) */
args: Record<string, unknown>
}
/** send_message 命令参数(对齐 src-tauri ai_chat_send command) */
export interface SendMessageArgs {
message: string
conversation_id?: string | null
model_override?: string | null
/**
* 选中技能名(`/<skill>` 联想选中后透传,对齐 ai_chat_send 的 skill 参数)。
* null/undefined=无技能(普通对话);非空=后端注入对应 SKILL.md 全文到 prompt。
*/
skill?: string | null
/**
* @ mention 区间元数据(用户选中 @项目/@任务/@灵感 后透传,
* 对齐 ai_chat_send 的 mention_spans 参数,后端 resolve 投影成 Augmentation 注入)。
* null/undefined/空=无 mention(纯文本消息)。
*/
mention_spans?: MentionSpan[] | null
}
/**
* 用户消息内 mention 区间的元数据
* (对齐后端 MentionSpanDto,crates/df-types/src/augmentation.rs:235)。
*
* 字段名对齐后端 serde:refId(camelCase,后端 serde rename)。
* 前端按 {start, length} 在原文中切出区间替换为 chip。
*/
export interface MentionSpan {
/** 区间起点(字符偏移,前端约定,后端仅透传不解释) */
start: number
/** 区间长度 */
length: number
/** kind 标签:project / task / idea / skill(对齐后端 MentionKind serde snake_case) */
kind: 'project' | 'task' | 'idea' | 'skill'
/** 引用稳定标识(Project/Task/Idea 为 id,Skill 为 name),前端用于反查 */
refId: string
/** chip 展示文本(项目名/任务标题/技能名) */
label: string
}
/**
* 技能信息(对齐桌面端 src/api/types.ts:614 SkillInfo + 后端 SkillInfo,
* src-tauri/src/commands/ai/skills.rs:19-34)。
*
* 供 `/` 联想浮层渲染 + 选中后 name 透传 SendMessageArgs.skill。
* duplicates 可选:后端 #[serde(skip_serializing_if=Option::is_none)] 无冲突不序列化,
* miniapp 加 `?` 兜底防 prod 反序列化宽松报错。
*/
export interface SkillInfo {
/** 技能名(联想浮层展示 + 选中后透传 skill 参数) */
name: string
/** 技能描述(浮层副标题) */
description: string
/** 参数提示(可选,如 "<url>") */
argument_hint?: string
/** skill | command | plugin(来源分类) */
source: string
/** SKILL.md 绝对路径(后端注入时读全文) */
path: string
/** 同名冲突的其他来源路径(可选,无冲突不序列化) */
duplicates?: string[]
}
/** switch_conversation 命令参数 */
export interface SwitchConversationArgs {
conversation_id: string
}
/** stop 命令参数(对齐 ai_chat_stop) */
export interface StopArgs {
conversation_id?: string | null
}
/** approve 命令参数(对齐 src-tauri ai_approve,Phase3 §2.2 路由表) */
export interface ApproveArgs {
/** 待审批的工具调用 id(对应 AiApprovalRequired 事件的 id) */
tool_call_id: string
/** 是否批准(true=批准执行,false=拒绝) */
approved: boolean
}
/**
* authorize_dir 命令参数(对齐 src-tauri ai_authorize_dir,Phase3 §2.2 路由表)
*
* decision 取值:对齐 src-tauri ai_authorize_dir 的 decision 参数
* - 'once':本次授权(下次同路径仍需问)
* - 'always':永久授权(写入会话级信任目录)
* - 'deny':拒绝(本次不授权)
*/
export interface AuthorizeDirArgs {
/** 待授权的工具调用 id(对应 AiDirAuthRequired 事件的 id) */
tool_call_id: string
/** 授权决策('once' / 'always' / 'deny') */
decision: 'once' | 'always' | 'deny'
}
/** continue_loop 命令参数(对齐 src-tauri ai_continue_loop,Phase3 §2.2 路由表) */
export interface ContinueLoopArgs {
/** 需继续运行的会话 id */
conversation_id: string
}
/** stop_loop 命令参数(对齐 src-tauri ai_stop_loop,Phase3 §2.2 路由表) */
export interface StopLoopArgs {
/** 霷停止循环的会话 id */
conversation_id: string
}
+33
View File
@@ -0,0 +1,33 @@
/**
* 全局样式变量(uni-app 规范:uni.scss 由 uni-app 自动引入到各页面/组件)
*
* 颜色基调对齐桌面端暗色主题(便于跨端视觉一致)。
*/
/* 主色调 */
$uni-color-primary: #4a9eff;
$uni-color-success: #4cd964;
$uni-color-warning: #f0ad4e;
$uni-color-error: #dd524d;
/* 中性色(暗色背景适配) */
$uni-bg-color: #0f0f0f;
$uni-bg-color-dark: #1a1a1a;
$uni-bg-color-light: #2a2a2a;
$uni-text-color: #e0e0e0;
$uni-text-color-grey: #999999;
$uni-text-color-disable: #666666;
$uni-border-color: #333333;
/* 字号 */
$uni-font-size-sm: 24rpx;
$uni-font-size-base: 28rpx;
$uni-font-size-lg: 32rpx;
/* 气泡角色配色 */
$uni-bubble-user-bg: #4a9eff;
$uni-bubble-user-text: #ffffff;
$uni-bubble-assistant-bg: #2a2a2a;
$uni-bubble-assistant-text: #e0e0e0;
$uni-bubble-error-bg: #3a1a1a;
$uni-bubble-error-text: #ff6b6b;
+62
View File
@@ -0,0 +1,62 @@
/**
* marked HTML 后处理:为代码块/内联 code/链接/表格注入 inline style(P1-G)。
*
* 为什么后处理而非 marked 自定义 renderer:
* - mp-weixin rich-text 不链接页面 CSS class,仅认节点 inline style → 必须把样式写进 HTML 属性。
* - 纯字符串后处理规避 marked renderer 版本签名差异(v5 前 (text,info) / v5+ Token 对象),
* 版本无关,build 不依赖 renderer TS 签名。
*
* mp-weixin rich-text 限制(已核官方文档,非臆测):
* - <a> 节点事件屏蔽,无法 bindtap 跳转 → 链接仅展示 + 长按复制(rich-text selectable=true);
* 跳转需第三方 mp-html(本次不做,记后续)。
* - rich-text 非滚动容器,overflow-y:auto/max-height 不创建滚动区 → 长代码靠
* white-space:pre-wrap + word-break:break-all 换行防撑爆,不限高滚动。
*/
/** 给指定标签注入 inline style(已有 style 则合并,无则新增)。 */
function styleTag(html: string, tag: string, style: string): string {
const re = new RegExp(`<${tag}(\\s[^>]*)?>`, 'g')
return html.replace(re, (m, attrs: string) => {
if (attrs && /\sstyle\s*=/.test(attrs)) {
// 已有 style:追加(在现有 style 值末尾加分号 + 新 style)
return m.replace(/style\s*=\s*"([^"]*)"/, (_full, prev: string) => {
const merged = prev.endsWith(';') ? prev : `${prev};`
return `style="${merged}${style}"`
})
}
return `<${tag}${attrs || ''} style="${style}">`
})
}
/**
* 对 marked.parse 产出的 HTML 注入 inline style。
*
* 设计取舍:pre(块代码)给深色背景;<code> 不给背景(透明),仅等宽+橙色 —— 避免块代码内层 code
* 与 pre 背景冲突的双层色块(块代码内 code 透明继承 pre 深底,内联 code 橙色等宽在消息底上也可读)。
*/
export function styleMarkdown(html: string): string {
let out = html
// 块代码 <pre>:深色背景 + 等宽 + 圆角 + pre-wrap 换行防撑爆
out = styleTag(
out,
'pre',
'display:block;background-color:#1a1a1a;color:#d4d4d4;padding:10px;border-radius:6px;white-space:pre-wrap;word-break:break-all;font-family:monospace;font-size:12px;margin:6px 0',
)
// code(块内 + 内联):等宽橙色,透明背景(避与 pre 双层色块)
out = styleTag(
out,
'code',
'font-family:monospace;color:#e0a070;background-color:transparent;font-size:12px',
)
// 链接:蓝下划线(rich-text 不可点击,仅展示 + selectable 长按复制)
out = styleTag(out, 'a', 'color:#4a9eff;text-decoration:underline')
// 表格:border + 块级 + 横向溢出处理(rich-text 非滚动,靠 word-break 兜底;border 提升可读)
out = styleTag(
out,
'table',
'display:block;border-collapse:collapse;width:100%;font-size:12px;word-break:break-all',
)
out = styleTag(out, 'th', 'border:1px solid #333;padding:4px 6px;background-color:#1a1a1a')
out = styleTag(out, 'td', 'border:1px solid #333;padding:4px 6px')
return out
}
+25
View File
@@ -0,0 +1,25 @@
{
"extends": "@vue/tsconfig/tsconfig.json",
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"jsx": "preserve",
"isolatedModules": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"skipLibCheck": true,
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM"],
"types": ["@dcloudio/types", "@types/node"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"exclude": ["node_modules", "dist", "unpackage"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
// uni-app Vue3 vite 配置:vite-plugin-uni 注入跨端编译(mp-weixin / h5)。
// Phase2 落地遗漏本文件致 `uni build -p mp-weixin` 走纯 vite h5(只产 index.html),
// 补回后 -p mp-weixin 正确产出小程序文件(app.js/app.json/pages/*)。
export default defineConfig({
plugins: [uni()],
})
@@ -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");
}
}
File diff suppressed because it is too large Load Diff
+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")
}
+498 -226
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() {
@@ -192,7 +105,7 @@ impl OpenAICompatProvider {
req.model
};
let messages: Vec<OpenAiMessage> = req
let mut messages: Vec<OpenAiMessage> = req
.messages
.into_iter()
.map(|m| {
@@ -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,13 +169,20 @@ 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();
// B-260626-01: 保证首条 user/system(OpenAI 协议要求首条非 assistant/tool)。
// 对齐 AnthropicCompatProvider::ensure_leading_user:上游绕过 sanitize 的调用方
// (标题生成/知识注入/工作流 AI 节点等直构造 CompletionRequest 的路径)可能传入首条
// assistant 的序列(会话恢复/续发/片段截取),补 user 占位保留上下文,首条合法。
Self::ensure_leading_user(&mut messages);
let tools = req.tools.map(|defs| {
defs.into_iter()
.map(|d| serde_json::to_value(d).unwrap_or_default())
@@ -240,9 +197,46 @@ 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
},
}
}
/// B-260626-01: 保证 messages 首条为 user/system(OpenAI 协议要求首条非 assistant/tool)。
///
/// 对齐 `AnthropicCompatProvider::ensure_leading_user`。上游绕过 `ContextManager::sanitize_messages`
/// 的调用方(标题生成/知识注入/工作流 AI 节点等直构造 CompletionRequest 的路径)可能传入首条
/// assistant 的序列——会话恢复、续发或历史片段截取时,真正的首条 user 已被裁剪/压缩掉。
///
/// **用"补"而非"砍"**:开头插一条 user 占位,保留全部上下文(砍会丢工具调用历史,多轮砍到空)。
/// 占位 user 紧贴原首条,不破坏 user/assistant 交替;仅异常路径触发(正常首条本就是 user)。
fn ensure_leading_user(messages: &mut Vec<OpenAiMessage>) {
let first_role = messages.first().map(|m| m.role.as_str()).unwrap_or("");
if first_role == "user" || first_role == "system" {
return;
}
warn!(
first_role,
msg_count = messages.len(),
"ensure_leading_user: 首条非 user/system,补 user 占位(保留上下文,防 OpenAI 首条 assistant/tool 非法)"
);
messages.insert(
0,
OpenAiMessage {
role: "user".into(),
content: serde_json::Value::String("(continued from previous context)".into()),
tool_call_id: None,
tool_calls: None,
reasoning_content: None,
},
);
}
/// 解析同步响应中的工具调用
fn parse_tool_calls(calls: Vec<OpenAiToolCallResp>) -> Vec<ToolCall> {
calls
@@ -254,6 +248,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 +283,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 +378,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 +405,278 @@ 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()));
}
// ---------- B-260626-01: ensure_leading_user(首条非 user/system → 补 user 占位,OpenAI 对称 Anthropic)----------
/// B-260626-01: 首条 assistant → 补 user 占位(对齐 Anthropic)。上游绕过 sanitize 的
/// 调用方(title/knowledge_inject/工作流节点)可能传入首条 assistant 序列,补占位保留上下文。
#[test]
fn openai_ensure_leading_user_first_assistant_gets_placeholder() {
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
let req = CompletionRequest {
model: "gpt-4o".into(),
messages: vec![
ChatMessage::assistant("我来帮你"),
ChatMessage::user("继续"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
assert_eq!(out.messages.len(), 3, "占位 + 原 2 条");
assert_eq!(out.messages[0].role.as_str(), "user", "首条应为 user(补占位)");
assert_eq!(out.messages[1].role.as_str(), "assistant");
assert_eq!(out.messages[2].role.as_str(), "user");
}
/// B-260626-01: 正常序列(user 开头)不补占位——零回归。
#[test]
fn openai_ensure_leading_user_normal_unchanged() {
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
let req = CompletionRequest {
model: "gpt-4o".into(),
messages: vec![
ChatMessage::user("hello"),
ChatMessage::assistant("hi"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
assert_eq!(out.messages.len(), 2, "正常序列不补占位");
assert_eq!(out.messages[0].role.as_str(), "user");
}
}
+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;
+97 -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,55 @@ 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 5.x(Windows 自带,不支持 && 运算符)
PowerShell,
/// PowerShell 7(pwsh,支持 && 运算符;运行时探测,未装回退 PowerShell)
Pwsh,
/// Unix sh (默认非 Windows)
Sh,
}
impl Default for ShellType {
fn default() -> Self {
// L1 环境感知:Windows 默认 PowerShell 系(非 Cmd)。PowerShell 对引号/$变量/Unicode 处理
// 远优于 cmd,从根上避 kms 类引号转义地狱(seq26-52 撞墙 20+ 次)。AI 写文件执行见 env_profile。
// BUG-260623-04:优先 pwsh(PS7,支持 && 运算符)——LLM 训练数据 Unix 多,普遍生成 `cd x && y`,
// PS5 不支持 && 致命令失败(实测会话 6acb7f9b `cd ... && git init` InvalidEndOfLine)。
// 探测失败(未装 pwsh)回退 PS5。探测结果 OnceLock 缓存(只探一次)。
if cfg!(windows) {
if probe_pwsh() { ShellType::Pwsh } else { ShellType::PowerShell }
} else {
ShellType::Sh
}
}
}
/// 探测 pwsh(PowerShell 7)是否可用(OnceLock 缓存,只探一次)。
///
/// LLM 普遍生成 `&&`(Unix 习惯),仅 PS7+ 支持,Windows 自带 PS5 不支持。
/// 探测:成功 spawn `pwsh -Command exit 0` 即可用。同步阻塞仅一次(spawn 极快),
/// Windows 加 CREATE_NO_WINDOW 防黑窗闪现。
fn probe_pwsh() -> bool {
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*CACHE.get_or_init(|| {
let mut cmd = std::process::Command::new("pwsh");
cmd.arg("-NoProfile").arg("-Command").arg("exit 0");
cmd.stdout(Stdio::null()).stderr(Stdio::null());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
cmd.status().map(|s| s.success()).unwrap_or(false)
})
}
/// Shell 命令执行请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShellRequest {
@@ -26,24 +76,61 @@ 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::Pwsh => {
// PS7(支持 && 运算符),同 PS5 参数语义。-NoProfile 避加载用户 profile(速度+确定性)
let mut c = tokio::process::Command::new("pwsh");
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 +145,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()
}
}
+3 -3
View File
@@ -1,8 +1,8 @@
//! 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;
pub mod scoring_keywords;
+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(),
})
}
}
+273 -19
View File
@@ -1,10 +1,25 @@
//! 评分引擎 — 多维度加权评分
//! 评分引擎 — 基于想法内容的多维度启发式评分
//!
//! 各维度分数均为 0-10(IPC 层会 *10 缩放为 0-100 以匹配前端)。
//! 启发式依据:优先级、描述充实度、标签、关键词信号——保证稳定且有区分度。
//! TODO: 接入 AI 做语义级深度评分。
use crate::capture::Idea;
/// 想法评分详情(重新导出 capture 模块中的定义)
pub use crate::capture::IdeaScores;
use df_types::types::Priority;
// ─── 启发式关键词(从 `scoring_keywords` 模块导入)───
//
// 关键词常量已拆至独立文件 `scoring_keywords.rs`。
// 启发式是过渡方案,对抗式评估上线后按需升级为 JSON 配置。
pub use crate::scoring_keywords::{
FEASIBILITY_POSITIVE_KEYWORDS, FEASIBILITY_NEGATIVE_KEYWORDS,
IMPACT_KEYWORDS, URGENCY_KEYWORDS,
};
/// 评分权重配置
#[derive(Debug, Clone)]
pub struct ScoringWeights {
@@ -28,17 +43,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 +65,265 @@ 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, FEASIBILITY_POSITIVE_KEYWORDS);
score += (pos as f64) * 0.5;
// 复杂度负向信号
let neg = count_any(desc, FEASIBILITY_NEGATIVE_KEYWORDS);
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, IMPACT_KEYWORDS);
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, URGENCY_KEYWORDS);
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
);
}
}
+24
View File
@@ -0,0 +1,24 @@
//! 评分引擎关键词 — 从 `scoring.rs` 拆出的独立常量文件。
//!
//! 启发式评分是过渡方案(对抗式评估上线后按需升级为 JSON 配置),
//! 此处仅做最小改动:将硬编码关键词集中于此,保持 `scoring.rs` 逻辑清晰。
/// 可行性正向信号词(描述充实度外的技术/资源信号),命中每个 +0.5
pub const FEASIBILITY_POSITIVE_KEYWORDS: &[&str] = &[
"复用", "已有", "简单", "集成", "支持", "成熟", "基于", "现成", "脚手架", "模板",
];
/// 可行性负向信号词(复杂度上升信号),命中每个 -0.6
pub const FEASIBILITY_NEGATIVE_KEYWORDS: &[&str] = &[
"重构", "迁移", "大规模", "分布式", "重写", "从零", "全新架构", "高并发", "底层",
];
/// 影响力价值信号词(用户/增长/收入...),命中每个 +0.5
pub const IMPACT_KEYWORDS: &[&str] = &[
"用户", "增长", "收入", "效率", "体验", "核心", "关键", "痛点", "竞品", "留存",
];
/// 紧急度时效信号词(立即/马上/紧急...),命中每个 +0.5
pub const URGENCY_KEYWORDS: &[&str] = &[
"立即", "马上", "紧急", "尽快", "本周", "上线", "deadline", "截止", "先行", "阻塞",
];
+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));
}
}
+720
View File
@@ -0,0 +1,720 @@
//! 工具暴露层 — 从 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,
queue: "todo".to_string(),
parent_id: None,
content_json: 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,
queue: existing.queue,
parent_id: existing.parent_id,
content_json: existing.content_json,
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"
}
+450
View File
@@ -0,0 +1,450 @@
//! 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,
queue: "todo".to_string(),
parent_id: None,
content_json: 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"
}
}

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