Compare commits

..

96 Commits

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

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

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

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

- ErrorBoundary: 堆栈详情 → error.stackDetails

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

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

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

- EnrichmentPanel: 取消关联 → aiChat.enrichmentDismiss

- PlanProgress: 复用已有 aiChat.conflictsPending

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

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

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

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

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

- Knowledge: 列表加载态

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- TemplateInfo 结构体(IPC传输用)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- models: PlanRecord/SubTaskRecord/ConflictRecord 结构体

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

修复方案:

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

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

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

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

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

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

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

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

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

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

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

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

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

本次补充后端兜底机制:

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

同时更新待办文档,把已实现项标记为完成。
2026-06-28 18:16:34 +08:00
3fac0a618d 修复: system_prompt 追加行为准则,减少 LLM 写脚本偏好 2026-06-28 17:21:35 +08:00
9839935782 新增: AI 上下文信息面板(系统提示/增强上下文可见) 2026-06-28 14:13:20 +08:00
4a396f4f88 新增: EnvSnapshot 环境感知注入 system_prompt
- EnvSnapshot 结构: OS/shell/工具版本/编码/路径分隔符
- OnceLock 全局缓存,启动时 spawn_blocking 探测一次
- to_prompt() 生成环境摘要注入 agentic loop system_prompt
- 探测: Windows 注册表/macOS sw_vers/Linux os-release + chcp 编码
- 工具版本: python/node/rust/go/docker/git 逐个探测,失败不阻塞
- 8 个单测覆盖版本号解析 + 平台 fallback
2026-06-28 14:05:16 +08:00
7adaf97377 修复: PowerShell 路径转义 + shell 失败提示 + 标题合并同 role + L0 防抖
- run_command 路径反斜杠传递修正
- 命令失败追加 PowerShell 适配提示引导 LLM 改正
- 标题生成前合并连续同 role 消息避免 Anthropic 1214
- V33 迁移追加 workflow_executions.updated_at 列
- ai-client-ready 3 秒防抖避免重复握手
2026-06-28 13:57:53 +08:00
6ffcba7e4d 新增: 审批超时可配置 + ScriptNode 命令白/黑名单
- 审批超时: Settings 下拉选择(不限时/5/15/30/60min),默认 15min
- getApprovalTimeoutMs() 从 KV 读取,startApprovalTimer 动态取值
- ScriptNode: 黑名单 > 白名单策略,从环境变量读取
- Settings 加命令执行安全面板(白/黑名单文本框)
- i18n 中英文案 + 搜索索引补全
2026-06-28 13:49:19 +08:00
b157bc9077 修复: relay token 改环境变量 + api_key 脱敏 + 路径规范化 + create_project 自动建目录
- relay token 必设 DF_RELAY_TOKEN 环境变量,移除硬编码默认
- AiProviderRecord Debug 脱敏 api_key/model_configs/config
- ScriptNode 危险命令告警(rm -rf/DROP TABLE 等)
- bind_directory 路径规范化,拒绝含 .. 的原始路径
- create_project 目录不存在时自动创建
- probe_pwsh OnceLock 全局缓存,异步探测不阻塞 tokio
- retry jitter 改 rand,范围扩大到 ±50%
- EventBus send 错误改 tracing::warn
- StateMachine 文档警告不得在 await 期间持锁
2026-06-28 13:39:22 +08:00
f776336eb1 更新: 对话诊断待办项
- 最新对话 d9aef24f 分析: 审批死锁 + Windows 兼容 + 标题失败
- P0: pending 超时自动取消 + create_project auto_create_dir
- P1: run_command shell 适配提示 + PowerShell 反斜杠转义
- P2: 标题摘要合并连续同 role 防 GLM 1214
- P3: migration V33 + L0-handshake 防抖
2026-06-28 13:14:54 +08:00
273 changed files with 29077 additions and 8196 deletions

View File

@@ -1,5 +1,12 @@
# musl linux 交叉编译(Windows 主机无 cc/musl-gcc,用 rust-lld self-contained link)
# df-relay 云端部署:本地交叉产 static linux binary → scp 测试机普通运行
#
# ⚠️ 默认不要跑交叉编译,产物体积大且会污染 target/ 影响日常 build/rust-analyzer 性能。
# 需要部署 df-relay 时再显式执行,并把产物隔离到独立 target dir:
#
# CARGO_TARGET_DIR=target-musl cargo build --release --target x86_64-unknown-linux-musl -p df-relay
#
# 这样 musl 产物落到 target-musl/(已 gitignore),与主 target/ 完全隔离。
[target.x86_64-unknown-linux-musl]
linker = "rust-lld"
rustflags = ["-C", "link-self-contained=y"]

23
.gitignore vendored
View File

@@ -25,6 +25,7 @@ unpackage/
# Rust / Cargo
target/
target-musl/ # df-relay 交叉编译产物隔离目录(见 .cargo/config.toml)
!Cargo.lock
# Tauri
@@ -59,6 +60,28 @@ tmp/
# AI 编排脚本(Claude Code Workflow 临时产物,非产品代码)
workflows/
# 临时分析脚本(团队分析/报告生成,非产品代码根目录级)
/analysis_*.txt
/analysis_*.py
/core_domains.txt
/core_domains.py
/final_report.txt
/grand_output.txt
/im_live_verify.txt
/liang_deep.py
/liangxianyou_deep.txt
/member_profiles.py
/read_all.py
/report_p2.txt
/report_wall.txt
/team_analysis.py
/team_deep_analysis.py
/team_member_profiles.txt
/team_report_final.txt
/tmp_*.py
/tmp_*.txt
/verify_im_live*.py
# MCP/SQLite 测试库(mcp_test2 --db 残留)
devflow-dev.db
devflow-dev.db-shm

View File

@@ -0,0 +1,48 @@
# DevFlow 提交规范
## 提交注释格式
```
<动词>: <简短描述>
<详细说明(可选,仅在需要补充上下文时)>
```
## 动词词表
| 动词 | 使用场景 |
|------|---------|
| `新增` | 新功能、新特性、新文件 |
| `修复` | Bug 修复、问题修正 |
| `更新` | 文档更新、配置变更、依赖升级、非功能性的已有内容修改 |
| `重构` | 代码重构(行为不变的结构调整) |
| `删除` | 移除功能、删除文件 |
| `优化` | 性能优化、体验改进 |
| `合并` | 分支合并 |
## 规范要求
1. **动词 + 冒号 + 空格**`修复: 审批按钮永久转圈`
2. **主题行不超过 72 字符**
3. **主语用中文**,描述清楚改动内容
4. **不要用内部代号**:禁止 Batch/P1/P2/L1/F-XX 等代号
5. **不要用分隔线**:禁止 `---``===` 等装饰线
6. **不要写无意义提交**:如"更新"、"修复"、"提交"等无法看出内容的单字提交
7. **改动较多的提交**:主题行概括后,空一行用列表展开具体条目
8. **文档和代码合并提交**:文档更新和对应代码改动放在同一个提交中,不分散
## 示例
```
修复: 审批按钮永久转圈和等待计数不停
```
```
新增: 文件浏览器增强(窗口分离/Git 变更/行号/Diff/分页提交历史)
- 窗口分离: FileExplorer 可弹出独立 Tauri 窗口
- 行号显示: 文件预览左侧显示行号列
- Diff 视图: 有 Git 变更的文件可切换 diff 红绿视图
- 提交详情: 点击提交查看变更文件列表及文件级 diff
- 中文编码修复: git 命令注入 LC_ALL 环境变量
```

View File

@@ -0,0 +1,29 @@
# DevFlow 开发规则
## 技术栈
- 桌面端: Tauri v2 + Vue 3 + TypeScript + Pinia
- 后端: Rust Workspace (8 crate) / SQLite
- 包管理: bun (前端) / cargo (Rust)
- 路径: `E:/wk-lab/devflow`
## 架构原则
1. **治本不治标**:修复问题时找到根因,不做表面补丁
2. **DRY 原则**:不重复代码,抽象出可复用逻辑
3. **长远考虑**:不追求局部最优,考虑整体架构演进
4. **修改收敛**:同类改动合并到一个批次提交,不零散提交
## 代码规范
1. **Rust 后端**:提交前运行 `cd src-tauri && cargo check`
2. **前端 TypeScript/Vue**:提交前运行 `npx vue-tsc --noEmit`
3. **完整构建**:提交前运行 `npx vite build`(验证构建不报错)
4. **TypeScript 文件名**camelCase`fileExplorer.ts`
5. **Vue 组件文件名**PascalCase`FileExplorer.vue`
## i18n
- 中英文双语言同步:`src/i18n/en/``src/i18n/zh-CN/`
- 新增组件时同时添加对应语言的翻译文件
- 国际化文件自动聚合(`src/i18n/*/index.ts` 自动导入所有模块文件)

64
.zed/settings.json Normal file
View File

@@ -0,0 +1,64 @@
{
// 项目级 Zed 配置:优化 rust-analyzer 性能
//
// 背景:本 workspace 含 src-tauri + 7 个 df-* crate,
// flycheck 单次产出 600+ 行 artifact JSON,Zed 解析+渲染会冻结 UI。
// 核心策略:关掉保存即 check、限定 check 范围、隔离 target 目录。
"lsp": {
"rust-analyzer": {
"initialization_options": {
"cargo": {
"features": [], // 不编 default features 之外的重头
"allTargets": false, // 只为 host target 解析,跳过 musl/交叉
"targetDir": null, // 留空用 cargo 默认,避免双写
"buildScripts": {
"enable": true,
"rerun": "on-save"
}
},
"check": {
"onSave": false, // ★ 关键!关闭保存即 check(Zed 卡死主因)
"workspace": false, // 即便手动 check 也只查当前 crate
"command": "check", // 用 cargo check(非 clippy),首次开销低
"features": [],
"allTargets": false
},
"diagnostics": {
"enable": true, // 保留 rust-analyzer 自身诊断
"experimental": {
"enable": false
}
},
"procMacro": {
"enable": true, // 必须开,否则 tauri::generate_context 等会报红
"attributes": {
"enable": true
}
},
"workspace": {
"symbol": {
"search": {
"kind": "only_types",
"limit": 64
}
}
},
"completion": {
"callable": {
"snippets": "fill_arguments"
},
"fullFunction": {
"enable": false
}
},
"interpret": {
"tests": false
},
"files": {
"excludeDirs": [".cargo", "target", "target-musl", "node_modules", "dist", ".zed"]
}
}
}
}
}

View File

@@ -1,6 +1,6 @@
# DevFlow — 产研全流程工作流平台
> 版本: v0.1.0 | 创建: 2026-06-10 | 状态: 设计阶段
> 版本: v0.1.0 | 创建: 2026-06-10 | 状态: Phase 2 本地优先开发流程验证
## 一、项目定位
@@ -155,7 +155,7 @@ DevFlow 的终极交互模型是 **AI 驱动 (AI Working)****AI 是系统的
devflow/
├── Cargo.toml # Workspace 根
├── crates/
│ ├── df-core/ # 核心类型、错误、常量、事件
│ ├── df-types/ # 核心类型、错误、常量、事件、augmentation
│ ├── df-workflow/ # 工作流 DAG 引擎 (核心)
│ ├── df-nodes/ # 内置节点集合 (AI/Script/Human)
│ ├── df-ai/ # AI 编排层 (Multi-Provider/Coordinator)
@@ -320,8 +320,9 @@ pub struct Decision {
- **LlmProvider trait**:统一接口,各模型实现(`provider.rs`)✅
- **ModelRouter**:按任务类型路由到最优模型 + 降级链 — ❌ 已删R-PD-7模型选择改由调用方在 `LlmProvider` 实现间直接指定
- **AgentCoordinator**:多 Agent 协作Planner/Coder/Reviewer/Fixer— ⚠️ 骨架空壳(`coordinator.rs`,仅单元结构体 + 日志,未实现实际协调)
- **ContextManager**Token 预算管理(`context.rs`,完整实现)✅
- **AgentCoordinator**:多 Agent 协作(Plan DAG 拆解 + JoinSet 层内并行 + Token 预算池 + Reviewer 仲裁) — ✅ 已实现
- **Git Worktree 隔离**:子 Agent 文件隔离(git worktree + 独立分支) — ✅ 已实现
- **ContextManager**Token 预算管理(`context/mod.rs`,拆分为 sanitize/manager_tests 子模块)✅
- **ToolRegistry**:工具注册(`ai_tools.rs`,供 Agent 调用)✅
### 5.5 内置节点 (df-nodes)
@@ -331,13 +332,13 @@ pub struct Decision {
| AINode | 调用 LLM支持流式输出、工具调用 | ✅ 已实现 |
| ScriptNode | Shell/脚本执行 | ✅ 已实现 |
| HumanNode | 人工审批/确认 (阻塞) | ✅ 已实现 |
| DockerNode | Docker 容器操作 | ❌ 未实现 |
| GitNode | Git 操作 (libgit2) | ❌ 未实现 |
| NotifyNode | 通知 (桌面/飞书/Webhook) | ❌ 未实现 |
| HTTPNode | HTTP 请求 | ❌ 未实现 |
| SubflowNode | 嵌套子工作流 | ❌ 未实现 |
| GitNode | Git 操作(branch/checkout/commit/merge/push/status/log) | ✅ 已实现 |
| HTTPNode | HTTP 请求(GET/POST/PUT/DELETE) | ✅ 已实现 |
| NotifyNode | 通知(桌面/Webhook) | ✅ 已实现 |
| SubflowNode | 嵌套子工作流(深度限制防递归) | ✅ 已实现 |
| DockerNode | Docker 容器内构建/测试(环境检测+授权) | ✅ 已实现 |
> `crates/df-nodes/src/` 实际仅 ai_node / script_node / human_node 3 文件Docker/Git/Notify/HTTP/Subflow 为设计预留,尚未实现)。详见 [df-nodes 模块文档](docs/03-模块文档/df-nodes-节点集合-2026-06-12.md)。
> `crates/df-nodes/src/` ai_node / script_node / human_node / git_node / http_node / notify_node / subflow_node / docker_node 8 文件(全部已实现)。
## 六、数据模型
@@ -552,7 +553,7 @@ CREATE TABLE app_settings (
## 八、Phase 规划
### Phase 1 — 引擎骨架 (4-6 周)
- df-core + df-workflow (DAG + Node trait + Executor)
- df-types + df-workflow (DAG + Node trait + Executor)
- df-storage (SQLite 基础表)
- df-execute (Shell 执行)
- 最小前端:项目列表 + 工作流执行日志

553
Batch.md Normal file
View File

@@ -0,0 +1,553 @@
# DevFlow 批次推进记录
> 记录每个批次的提交 hash、改动内容和交付价值。
> 最后更新: 2026-07-02 | 最新提交: `4624688`
---
## Batch 1 — 审批超时后端兜底
- **提交**: `b923d70`(后重写为 `9dd5a5a`)
- **内容**:
- 新增 `agentic/approval_timeout.rs`,在审批恢复入口检测超时,超时自动取消并写入审计
- 待审批记录新增创建时刻字段,用于超时判定
- 应用启动时从设置恢复超时配置(默认 15 分钟0 为禁用)
- 新增读写超时配置的接口,设置页可改
- 与前端共用同一个配置项,保证单一真相源
- **验证**: cargo check 通过
---
## Batch 2 — 工具卡片拆分核验
- **提交**: `e95a5cd`(后重写为 `45fc493`)
- **内容**:
- 核验发现工具卡片拆分任务实际已完成,文档标记滞后
- ToolCard.vue 从 1527 行降至 373 行
- 审批状态机独立为 useToolApproval.ts220 行)
- 头部逻辑、渲染逻辑、通用工具函数各自独立
- 大型文件拆分三项全部完成(主聊天组件/命令注册/通用 CRUD
- **验证**: 文档核验,无代码改动
---
## Batch 3 — 条件表达式 UI 翻译补齐
- **提交**: `e460e8d`(后重写为 `d79c816`)
- **内容**:
- 工作流条件表达式引擎和执行器集成早已完成
- 前端边条件编辑入口已存在,但翻译缺失(用中文兜底)
- 补齐中英文翻译(工作流结构标题/层标签/空态/边条件编辑器)
- 核验确认条件引擎全部链路均已落地
- **验证**: vue-tsc 通过
---
## Batch 4 — 知识提炼切读消息表
- **提交**: `6aac7ef`(后重写为 `f57c37e`)
- **内容**:
- 知识提炼是最后一个仍读旧消息 JSON 列的路径
- 改为优先读消息表,表空时回退旧 JSON老库兼容
- 复用既有的记录转消息映射函数,与其他读路径同源
- 至此消息存储拆分全部完成
- **验证**: cargo check 通过
---
## Batch 5 — 关闭已完成待办项
- **提交**: `59b7188`(后重写为 `694f24f`)
- **内容**:
- 工具卡片分组标题增强(文件路径摘要展示)已落地
- 重试持锁问题核验无影响(多会话改造后全局锁已移除)
- 对抗式评估批量命令暂不做(单项已接入,批量无消费方)
- 残留诊断代码已清理(无硬编码绝对路径)
- 工程内路径授权政策确认为白名单制
- **验证**: 文档核验
---
## Batch 6 — 应用状态拆分 + 事件分发统一 + 路径授权治本
- **提交**: `996f1d9`
- **内容**:
- **状态文件拆分**: 1416 行单文件 → 按职责分模块(授权白名单/知识库配置/并发控制)
- **事件分发器统一**: 5 类生命周期事件纳入主事件联合类型,删除第二监听器,一个事件源一个监听器一个分发器
- **路径授权去硬编码**: 不再硬塞编译期路径到白名单,工程根授权靠项目绑定目录 + 设置页配置
- **补丁**: `cc3ef5b` — 修复设置页面崩溃(模块循环依赖导致初始化失败)
- **验证**: cargo check + vue-tsc 通过
---
## Batch 7 — 小程序配置持久化与心跳闭环
- **提交**: `cb7e1fc`
- **内容**:
- **配置持久化**: 运行时配置接入 storage懒加载恢复新增重置函数
- **心跳 pong 闭环**: 中继服务识别小程序心跳 ping 直接回 pong不经桌面端透传小程序看门狗据此判活
- **文档同步**: 关闭条件引擎/streaming per-conv/workspace_root 三项已完成的待决策
- **验证**: cargo checkdevflow + df-relay通过
---
## Batch 8 — 消息分页懒加载与事件总线空转优化
- **提交**: `3c18dea`
- **内容**:
- **消息分页**: 消息仓库新增分页查询方法(从尾部取最近 N 条,支持游标),切换对话返回最近 50 条 + 游标,新增加载更多接口
- **事件总线优化**: 发布事件前先检查订阅者数量,无订阅者跳过序列化(消除空转开销)
- **核验关闭**: 对话上下文透明化三项均已落地(目标可见/项目增强预览/完整上下文面板)
- **验证**: cargo check 通过
---
## Batch 9 — 项目多工程系统(数据层)
- **提交**: `b72df78`
- **内容**:
- **数据层**: project_modules 表V34 迁移)+ ProjectModuleRepo 完整 CRUD
- **后端接口**: 工程增删改查 + Git 状态查询(实时跑 git 命令返回分支/改动/提交10s 超时)
- **创建项目适配**: 新建项目时自动创建一个工程(目录=绑定路径,技术栈=探测结果)
- **AI 工具**: list_project_modulesAI 了解项目工程结构)
- **设计文档**: 新增工程系统设计方案
- **验证**: cargo check 通过
---
## Batch 10 — 项目文件浏览器(前端 UI
- **提交**: `50aad37`
- **内容**:
- **后端接口**: 文件树查询(列目录 + Git 状态合并 + 噪音过滤 + 路径穿越防御)+ 文件读取(文本/图片/二进制1MB 上限)
- **文件树组件**: 递归树形展示懒加载子目录Git 状态标记(橙=修改/绿=新增/灰=未跟踪)
- **文件预览组件**: 代码文本/图片/二进制三分支,加载状态
- **主容器**: 工程选择(单工程隐藏)+ 面包屑导航 + 刷新
- **集成**: 项目详情页新增"文件"Tab
- **国际化**: 中英文同步
- **验证**: cargo check + vue-tsc 通过
---
## Batch 11 — Git 只读 AI 工具
- **提交**: `7b5b62d`
- **内容**:
- **git_status**Low 自动执行): 工作区状态结构化(分支 + 改动文件列表 + 状态标记)
- **git_diff**Low: 改动详情(统计 + patch 截断到 8000 字符防 token 爆)
- **git_log**Low: 提交历史结构化(哈希/作者/消息/日期)
- 所有 git 命令在工程目录执行10s 超时,无 Git 仓库返回空状态
- **验证**: cargo check 通过
---
## Batch 12 — Git 写操作 AI 工具
- **提交**: `0cfea6d`
- **内容**:
- **git_commit**Medium 审批): 提交工作区改动,提交前检查敏感文件(.env/.key/.pem
- **git_branch**Medium 审批): 分支列表/创建/切换
- **git_merge**High 审批): 合并分支,自动检测冲突返回冲突文件列表
- 安全边界: 禁止 push / force / reset --hard
- **验证**: cargo check 通过
---
## 架构设计文档状态同步
- **提交**: `8d18918`
- **内容**: 9 份设计文档状态批量更新(从"待实施"改为"已落地"1 份归档(规格契约自检被其他机制覆盖)
---
## 提交注释规范重写
- **内容**: Batch 1-5 的提交注释经 filter-branch 重写消除内部代号Batch/P1/P2/L1/F-09 等),统一为"两字动词 + 冒号"格式(新增/修复/更新)
---
## Batch 13 — 文件浏览器增强(行号/图标/Diff/Git 变更面板/窗口分离)
- **提交**: `c6df455`
- **内容**:
- **行号显示**: 文件预览左侧行号列
- **后缀图标**: 20+ 文件类型彩色 SVG 图标(rs/ts/js/vue/css/html/json/md/sh/sql 等)
- **Diff 视图**: 有 Git 变更的文件可切换内容/Diff 红绿视图
- **Git 变更面板**: 文件树侧栏切换至变更视图,变更文件列表(按目录缩进分组)+提交历史(分页 50 条)
- **提交详情**: 点击提交行展开变更文件列表,点文件查看该文件 diff
- **提交时间显示**: 相对时间(刚刚/N分钟前)+超过 30 天显示具体日期
- **窗口分离**: FileExplorer 可弹出独立 Tauri 窗口
- **中文编码修复**: git 命令注入 LANG/LC_ALL UTF-8 环境变量,修复中文乱码
- **多工程管理 UI**: 工具栏添加工程按钮+弹窗表单
- **文件变更自动刷新**: write_file/patch_file 触发 df-data-changed 事件,AI 写入后自动刷新
- **面包屑不丢预览**: 面包屑导航时保留当前预览文件
- **刷新保留选中文件**: 只清树缓存不清预览
- **自适应布局**: 弹性 flex 布局,窄窗口自适应
- **审批超时 130s->30s**: 按钮级 loading 超时缩短
- **阶段进度条隐藏**: 装饰性元素,待接入真实状态机后启用
- **验证**: cargo check + vue-tsc + vite build 通过
---
## Batch 14 — 文件浏览器收尾
- **提交**: `53e6442`
- **内容**:
- 提交之前未提交的滚动条与布局修复
- 移除阶段进度条死代码(模板+CSS+常量)
- 文件预览空状态加图标引导
- 变更视图加载骨架屏
- 工程选择器改为自定义下拉(显示工程路径)
---
## Batch 15 — 多工程管理
- **提交**: `d8d6226`
- **内容**:
- 工程编辑入口:工具栏编辑按钮+弹窗(名称/路径/Git 地址)
- 工程删除二次确认:防误删
- 记住上次选中工程:localStorage 持久化
- 自动扫描子仓库:下拉菜单加扫描入口,发现 .git 子目录自动建工程
---
## Batch 16 — Git 提交历史作者
- **提交**: `b19b0f3`
- **内容**:
- 后端 git log format 加 %an 作者名(状态查询+分页查询)
- 提交列表每行显示 hash/作者/主题/时间
- 提交详情头部补充作者显示
- 分支切换按钮占位(后续接入分支列表 IPC)
---
## 渲染崩溃修复
- **提交**: `bce2fea`
- **内容**:
- AdvancedSection clampApprovalTimeout 常量移到 reactive 之前(打包后 const 初始化顺序 TDZ)
- @/i18n 去掉对 @/stores/appSettings 的 import,改直读 localStorage
- Tauri capability 加 ai-detached-* / fe-detached-* 通配符(分离窗口 listen 权限)
---
## Batch 17 — Git 能力闭环
- **提交**: `15eaa0c`
- **内容**:
- Diff 行号解析:解析 @@ -a,b +c,d @@ 头,计算旧/新行号(原恒空字符串)
- 提交详情增强:显示父提交哈希/作者/日期/完整消息
- 分支只读展示:list_branches IPC + 变更面板顶部下拉查看分支
- AI 工具写入后自动跳转变更视图:监听 df-data-changed entity=file 切 Tab + 刷新
---
## Batch 18 — 小程序审批超时计时器
- **提交**: `773ebb6`
- **内容**:
- 对齐桌面端 aiShared.startApprovalTimer,补齐小程序缺失的审批超时机制
- AiApprovalRequired/AiDirAuthRequired 触发 startApprovalTimer(每条独立计时)
- AiToolCallCompleted/AiApprovalResult 触发 clearApprovalTimer(审批落定)
- 切会话/新建/断连/重连/终态触发 clearAllApprovalTimers(防跨会话污染)
- 超时自动拒绝(approve(id,false))+ toast 提示用户
---
## Batch 19 — 任务管理 UX 重构 + 项目卡片工程数
- **提交**: `2780f6a` + `aa4d521`
- **内容**:
- 筛选改下拉:项目/状态从多按钮改为 select,节省垂直空间
- 新增搜索框:标题/描述关键词搜索,300ms 防抖
- 新增排序选择器:更新/创建时间/优先级/状态
- 分组可折叠:点击组头切换,记忆状态到 localStorage
- 分页默认开启:pageSize=20(原 0=全量)
- 紧凑布局:任务行密度优化,显示总条数
- 空状态引导:图标 + 新建按钮
- 模态框样式收敛:去内联 style
- 桌面快捷键:Ctrl+N 新建,Ctrl+F 聚焦搜索
- 项目卡片显示工程数(异步拉取 module 列表)
---
## Batch 20 — coordinator.rs 空壳删除
- **提交**: `948d6e6`
- **内容**:
- 删除 crates/df-ai/src/coordinator.rs(零调用的 deprecated 空壳)
- lib.rs 移除 pub mod coordinator 声明
- 消除编译 deprecated 警告
---
## 任务行快捷操作菜单
- **提交**: `27eb0a9`
- **内容**:
- 任务行 hover 显示齿轮按钮,点击展开快捷菜单
- 快捷改状态(6 态子菜单)/改优先级(4 级)/删除(二次确认)
- 点击外部自动关闭菜单
---
## stores/ai TDZ 防御
- **提交**: `76aec62`
- **内容**:
- __bindMessages 包装 try/catch,aiShared 未就绪时延迟到微任务重试
---
## Plan DAG 分层执行器
- **提交**: `4dac9fe`
- **内容**:
- PlanExecutor 按 Plan::to_layers 层间串行/层内并行调度子任务
- feature flag 门控(默认关,未接入主 loop)
- 3 个单元测试覆盖:线性链/并行层/失败容错
---
## 变更区完善
- **提交**: `78ef5dc`
- **内容**:
- 变更文件选中后展开 Diff 红绿预览
- 分支切换按钮实现只读列表弹窗(拉取分支列表 + 当前分支标记)
---
## 任务列表后端真分页
- **提交**: `3c2fa91`
- **内容**:
- 后端新增 count_by_query 方法(复用 list_by_query 的 WHERE 逻辑)
- 前端 buildTaskQuery 传 limit/offset 到后端(SQL 下推分页)
- 去除前端客户端 slice(后端已分页,直接分组)
---
## Batch 25 — 工程依赖图数据层
- **提交**: `3758bea`
- **内容**:
- 后端 module_dependencies 表(V35 迁移)+ ModuleDependencyRepo CRUD
- IPC:add/remove/list_module_dependencies
- 前端 API 封装 + DependencyGraph 接入真实边数据
- 依赖类型颜色区分(library/api/mq/shared/custom)
- 小地图插件(MiniMap)大图概览导航
- 点击节点跳转项目详情
---
## Batch 26 — 依赖图增强
- **提交**: `dd0e059`
- **内容**:
- 添加依赖入口:工具栏"+ 依赖"按钮,弹窗选择源/目标工程 + 依赖类型
- dagre 层次布局替换网格布局(LR 方向,自动排布工程节点)
- 依赖边按类型着色
---
## Batch 28 — 架构债收尾
- **提交**: `8991215`
- **内容**:
- ScriptNode 白/黑名单注释清理(标注实际读取方式)
- 双监听器核查结论:df-data-changed 多监听器是正常 pub-sub,非 bug
---
## Batch 29 — 环形检测 + PNG 导出 + 真实总数
- **提交**: `36ea090`
- **内容**:
- 后端 detect_module_cycles IPC(DFS 三色标记法检测环形依赖)
- 前端环检测按钮:高亮参与环的节点(红色边框)
- 图导出 PNG(X6 toPNG 回调模式)
- 后端 count_tasks IPC + 前端 taskApi.count()
- 任务列表所有筛选/搜索/排序/翻页均拉真实 total
---
## Batch 30 — 代码质量收尾
- **提交**: `6771d39`
- **内容**:
- 设置页搜索索引补全(新增 dataDir 项)
- 编译警告清理(df-storage OptionalExtension + df-ai join_set)
- i18n 核验(移除 zh-CN/fileExplorer.ts 未使用的 loadMore key)
- String 替 newtype(5 个 branded ID 类型:ProjectId/TaskId/ConvId/ModuleId/MessageId)
- **验证**: cargo check + vue-tsc + vite build 通过
## Batch 31 — God 文件拆分第一步(MessageList)
- **提交**: `2c2b2d7`
- **内容**:
- 提取 useMessageScroll.ts(滚动跟随/锁存/回底按钮,89 行)
- 提取 MessageItem.vue(单条消息按 role 渲染,171 行)
- MessageList.vue 从 1552 行降至 1386 行(-166 行)
- **验证**: vue-tsc + vite build 通过
## Batch 32 — God 文件拆分第二步(ChatInput)
- **提交**: `3abdfb4`
- **内容**:
- 提取 SkillMention.vue(/@ 联想浮层,63 行)
- 提取 ImageInput.vue(图片粘贴/拖拽预览,33 行)
- ChatInput.vue 从 1183 行降至 1155 行
- **验证**: vue-tsc + vite build 通过
## Batch 34 — 产品完善
- **提交**: `2af24f0`
- **内容**:
- 小程序 device 在线心跳加固(早前已完成)
- ScriptNode 白名单跨 crate 接口打通:OnceLock 全局态 + IPC set_script_safety
- AI 写入后文件树自动刷新(早前已完成)
- Projects 列表分页(后端 list_projects 已支持 limit/offset)
- **验证**: cargo check + vue-tsc 通过
## Batch 35 — 人设系统(P0·第一步)
- **提交**: `0a65ea2`
- **内容**:
- AgentPersona 数据结构(能力标签/工具白名单/prompt 模板)
- PersonaRegistry 注册表(5 内置人设: coder/reviewer/architect/tester/analyst)
- 按意图推荐人设(启发式关键词匹配合适角色)
- build_prompt 注入上下文和目标钉扎
- **验证**: 10 个单元测试通过
## Batch 36 — Coordinator 接入 + audit 拆分
- **提交**: `fa410e6`
- **内容**:
- audit/mod.rs 拆分出 approval/record 子模块(process_tool_calls 从 ~500→~200 行)
- coordinator.rs 移除 deprecated 标记,正式可用
- Coordinator 接入 run_agentic_loop 入口(plan_execution_enabled 时分解意图→输出 Plan)
- **验证**: cargo check 通过
## Batch 37 — 代码卫生与质量提升(String→newtype + 文件清理 + 文档同步)
- **提交**: `4624688`
- **内容**:
- ExecutionId/ToolCallType/ToolType 裸 String → newtype(IPC 边界仍透明序列化为字符串)
- ChatMessage.status 从 `Option<String>``Option<MessageStatus>` 枚举(Active/Truncated/Compressed/ArchivedSegment)
- .gitignore 添加分析脚本,tmp 文件清理
- Batch.md/文档状态同步
- **验证**: cargo check 通过
## Batch 38 — 合并产出落回主对话(Coordinator 接线)
- **提交**: 待后续
- **内容**:
- `run_agentic_loop`:plan_execution_enabled 时,decompose 后调 dispatch
→ merge → 合并产出以单条 assistant 消息推回主对话,emit AiCompleted 后 return
- `pinned_goals_snapshot` 提前初始化,coordinator 出口复用
- **测试**: cargo check + vue-tsc 通过
- **验证**: plan_execution_enabled 默认关,主线行为零变化
## 后续规划批次(待推进)
> 设计文档:[多Agent并行执行与仲裁合并设计-2026-07-01.md](docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md)
### 数据层 + Git worktree 隔离 + 并行执行 ✅
- **提交**: `ae6d3d0``4483358``3c9077b`
- **测试**: 48 个全绿(7 Repo + 7 worktree + 27 Coordinator + 4 Token + 3 merge)
- **验证**: cargo check(0 警告) + vue-tsc + vite build
- **已完成任务**:
| # | 任务 | 文件 |
|---|------|------|
| 1 | V36 迁移(ai_plans/ai_subtasks/ai_conflicts 3 新表 + ai_messages/ai_tool_executions 加 subtask_id + subtasks.branch + conflicts.conflict_type) | migrations.rs |
| 2 | PlanRepo / SubTaskRepo / ConflictRepo CRUD + models 结构体 | 新 repo 文件 |
| 3 | Git worktree 生命周期管理(create/commit/merge/remove) | 新 git_worktree.rs |
| 4 | Coordinator.dispatch JoinSet 层内并行(每 SubTask 绑 worktree) | coordinator.rs |
| 5 | Token 预算池(AtomicU64 CAS,超限降级串行) | coordinator.rs |
| 6 | 子 Agent 独立 ContextManager + fork 快照 + worktree_path | coordinator.rs |
| 7 | 层间 merge 到 plan 分支 + 下一层基于 plan 创建 worktree | coordinator.rs |
| 8 | 4 个新事件 + 事件双写(emit + publish_event) | AiChatEvent |
| 9 | 前端类型定义(PlanRecord/SubTaskRecord/ConflictRecord) | api/types.ts |
| 10 | PlanProgress 接入真实状态 + 发送即展示 | PlanProgress.vue |
| 11 | 工具卡按 subtask_id 折叠分组 + persona 徽章 | MessageList.vue |
| 12 | 编译警告清理(coordinator_plan unused / audit 子模块 unused imports) | 各文件 |
### 仲裁合并 + 冲突 UI + 编译检查 ✅
- **提交**: `1d580dc``6e44878`
- **测试**: 7 个 merge 冲突检测测试全绿
- **已完成任务**:
| # | 任务 | 状态 |
|---|------|------|
| 1 | Coordinator.merge 冲突检测(同文件路径 + 跳过失败 SubTask) | ✅ |
| 2 | extract_written_files 辅助函数(从 output 提取写入路径) | ✅ |
| 3 | ConflictResolver.vue(双栏 diff + 接受A/B/合并/手动按钮) | ✅ |
| 4 | i18n 冲突翻译键(中英文) | ✅ |
| 5 | agentic/mod.rs 拆分(提取自动压缩到 context_lifecycle.rs,2222→2071行) | ✅ |
| 6 | Reviewer Agent 仲裁(persona.rs 扩展) | 待下一批 |
| 7 | 命令互斥锁(run_command 同目录 mutex) | 待下一批 |
| 8 | context.rs 拆分(1956行) | 待下一批 |
### 模板系统 + 节点补齐 ✅
- **提交**: `f40287b``27b4268`
- **测试**: 8 个模板加载 + 37 个节点 + 5 个 SubflowNode 测试全绿
- **已完成任务**:
| # | 任务 | 状态 |
|---|------|------|
| 1 | YAML 模板格式定义 + 加载器(YAML→DagDef + 校验:空节点/未知类型/环/边引用) | ✅ |
| 2 | GitNode(分支/checkout/commit/merge/push/status/log) | ✅ |
| 3 | HTTPNode(GET/POST/PUT/DELETE + headers/body/timeout) | ✅ |
| 4 | NotifyNode(桌面通知 + Webhook) | ✅ |
| 5 | SubflowNode(嵌套子工作流 + 深度限制) | ✅ |
| 6 | 内置模板 3 个预设(代码审查/Bug修复/功能开发) | ✅ |
| 7 | 前端模板选择入口 + 模板 CRUD IPC | 待下一批 |
| 8 | MessageList.vue 继续拆分 | 待下一批 |
### Git/CI 集成 + DockerNode + God 文件收尾 ✅
| # | 任务 | 状态 |
|---|------|------|
| 1 | DockerNode(环境检测+授权) | ✅ |
| 2 | GitNode push(plan分支推送) | ✅ GitNode 含 push action |
| 3 | commit status 读取(Gitea API) | ✅ ci_status.rs |
| 4 | CIStatus.vue 前端面板 | ✅ |
| 5 | ChatInput.vue 拆分(1155→1007行) | ✅ |
| 6 | context.rs 拆分(1956→538行) | ✅ |
### 审批政策 + 小程序完善 + 体验收尾 ✅
| # | 任务 | 状态 |
|---|------|------|
| 1 | 审批策略 Persona 维度 | ✅ |
| 2 | 小程序 MentionInput 接真实数据 | ✅ 核验已完成 |
| 3 | 小程序长会话虚拟化 | ✅ 截断200条合理兜底 |
| 4 | 快捷键完善(Ctrl+N/Ctrl+R) | ✅ |
| 5 | ARCHITECTURE.md 状态同步 | ✅ |
### 批次 C 前序散项 ✅
| # | 任务 | 状态 |
|---|------|------|
| 1 | Reviewer Agent 仲裁 | ✅ |
| 2 | 命令互斥锁 | ✅ |
| 3 | resolve_conflict IPC | ✅ |
| 4 | 模板 CRUD IPC | ✅ |
| 5 | MessageList.vue 拆分(1386→1148行) | ✅ |
| 6 | AiChat 进度条(completedTools 计数器) | ✅ |
| 7 | df-types augmentation 测试修复 | ✅ |
| 8 | 前端 UI 审查修复(badge/confirm/加载态/CSS) | ✅ |
| 9 | 合并产出落回主对话 | 待接线 |
---
## 未纳入批次的其他会话改动
- `src/composables/ai/aiShared.ts` + `src/stores/ai.ts` — 循环依赖修复Batch 6 补丁 `cc3ef5b` 纳入提交)

202
Cargo.lock generated
View File

@@ -779,6 +779,20 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "dashmap"
version = "6.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
dependencies = [
"cfg-if",
"crossbeam-utils",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]]
name = "data-encoding"
version = "2.11.0"
@@ -835,6 +849,7 @@ dependencies = [
"async-trait",
"base64 0.22.1",
"chrono",
"dashmap",
"df-ai",
"df-execute",
"df-ideas",
@@ -877,13 +892,15 @@ version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"bytes",
"df-ai-core",
"df-types",
"eventsource-stream",
"futures",
"rand 0.8.6",
"reqwest 0.12.28",
"serde",
"serde_json",
"tempfile",
"tokio",
"tracing",
]
@@ -953,6 +970,7 @@ dependencies = [
"df-storage",
"df-types",
"df-workflow",
"reqwest 0.12.28",
"serde",
"serde_json",
"tokio",
@@ -968,6 +986,7 @@ dependencies = [
"df-types",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
]
@@ -1039,6 +1058,7 @@ dependencies = [
"futures",
"serde",
"serde_json",
"serde_yaml",
"tokio",
"tracing",
]
@@ -1290,17 +1310,6 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "eventsource-stream"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab"
dependencies = [
"futures-core",
"nom",
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
@@ -1640,8 +1649,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -1651,9 +1662,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1989,6 +2002,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
@@ -2490,6 +2504,12 @@ version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "markup5ever"
version = "0.38.0"
@@ -2537,12 +2557,6 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2645,16 +2659,6 @@ dependencies = [
"memoffset",
]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -3344,6 +3348,61 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.4",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.45"
@@ -3372,8 +3431,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
@@ -3383,7 +3452,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
@@ -3395,6 +3474,15 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -3495,6 +3583,8 @@ dependencies = [
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
@@ -3502,6 +3592,7 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -3511,6 +3602,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
"webpki-roots",
]
[[package]]
@@ -3634,6 +3726,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
@@ -3646,6 +3739,7 @@ version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
]
@@ -3757,7 +3851,7 @@ dependencies = [
"generic-array",
"num",
"once_cell",
"rand",
"rand 0.8.6",
"serde",
"zbus 4.4.0",
]
@@ -3978,6 +4072,19 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.14.0",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "serialize-to-javascript"
version = "0.1.2"
@@ -5231,7 +5338,7 @@ dependencies = [
"httparse",
"log",
"native-tls",
"rand",
"rand 0.8.6",
"sha1",
"thiserror 1.0.69",
"utf-8",
@@ -5249,7 +5356,7 @@ dependencies = [
"http",
"httparse",
"log",
"rand",
"rand 0.8.6",
"sha1",
"thiserror 1.0.69",
"utf-8",
@@ -5337,6 +5444,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -5604,6 +5717,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.4"
@@ -5660,6 +5783,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -6397,7 +6529,7 @@ dependencies = [
"hex",
"nix",
"ordered-stream",
"rand",
"rand 0.8.6",
"serde",
"serde_repr",
"sha1",

View File

@@ -22,3 +22,7 @@ regex = "1"
# Win+mac 启用原生后端;linux 用 async-persistent 变体避同步阻塞 Secret Service。
# 由 df-storage(密钥解析下沉层) + src-tauri(build_provider_for 转发壳)共同引用。
keyring = { version = "3", features = ["windows-native", "apple-native", "linux-native-async-persistent"] }
# dashmap: ConvStateStore 无锁并发 HashMap(session 锁重构方案 B-Phase0)。
# ConvState 提到 ConvStateStore(DashMap),guard.reset/ai_is_generating 不依赖 session lock,
# 治卡死连环(AiCompleted 延迟/工具后中断/第二条进队列同源根因)。详见 workflow session-lock-redesign。
dashmap = "6"

View File

@@ -1,6 +1,6 @@
# DevFlow — 项目进展与工作交接
> 创建: 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)
> 创建: 2026-06-10 | 最后更新: 2026-07-02 | 当前阶段: 多 Agent 并行执行架构落地完成Plan DAG + worktree 隔离 + JoinSet 并行 + Reviewer 仲裁 + 模板系统 + 全节点补齐),详见 [Batch.md](Batch.md)
---
@@ -11,9 +11,9 @@
| 定位 | AI 原生创作流程驾驶舱,从想法到创作成果的全流程管理 |
| 技术栈 | Tauri v2 + Vue 3 + TypeScript + Pinia / Rust Workspace (8 crate) / SQLite |
| 路径 | `E:/wk-lab/devflow/` |
| 架构文档 | `ARCHITECTURE.md` (22,745 字) |
| Git 状态 | 已纳入版本控制(完整 commit 历史,当前分支 feat/batch-260615-workflow-unlock) |
| AI 能力 | df-ai OpenAI/Anthropic 双协议 Provider + 12 工具 + Agentic Loop |
| 架构文档 | `ARCHITECTURE.md` + `docs/02-架构设计/` (含多 Agent 设计文档) |
| Git 状态 | 已纳入版本控制(main 分支) |
| AI 能力 | OpenAI/Anthropic 双协议 + 40+ 工具 + Agentic Loop + 多 Agent 并行 + 人设系统 |
---
@@ -23,7 +23,7 @@
| Crate | 文件数 | 总行数 | 有效行 | 实现程度 |
|-------|--------|--------|--------|---------|
| df-core | 4 | 429 | ~260 | ✅ 完整 — 错误/事件/状态枚举/ID生成 |
| df-types | 4 | 429 | ~260 | ✅ 完整 — 错误/事件/状态枚举/ID生成/augmentation |
| df-workflow | 9 | 908 | ~620 | ✅ 核心 — DAG拓扑排序/执行器/状态机/事件总线 可用 |
| df-storage | 5 | ~2,100 | ~1,400 | ✅ 完整 — 连接/迁移/建表(V1-V8) + impl_repo! 宏 CRUD + KnowledgeReposearch/list_by_status/set_embedding/search_vector 等)+ 余弦相似度工具函数 |
| df-execute | 5 | 203 | 129 | ✅ Shell 执行器真实可用tokio::process被 run_command 复用) |
@@ -59,7 +59,7 @@
```
真实可用路径(有端到端逻辑):
df-core (类型基础)
df-types (类型基础)
→ df-workflow (DAG 拓扑排序 + 执行器 + 状态机 + EventBus)
→ df-execute/shell (tokio::process Shell 执行)
→ df-storage (SQLite 连接 + 迁移 + 建表)
@@ -98,7 +98,7 @@
| 任务 | 状态 | 说明 |
|------|------|------|
| df-core 类型系统 | ✅ 完成 | 错误/事件/状态枚举/ID生成 |
| df-types 类型系统 | ✅ 完成 | 错误/事件/状态枚举/ID生成/augmentation |
| df-workflow DAG 引擎 | ✅ 完成 | 拓扑排序/执行器/状态机/EventBus |
| df-storage SQLite 基础表 | ✅ 完成 | 6 张表 + 4 索引,缺 CRUD |
| df-execute Shell 执行 | ✅ 完成 | tokio::process 实现 |
@@ -190,7 +190,7 @@
### [Sprint 3] 2026-06-11 — Migrations V2 + Executor 并行化 + Tauri IPC三代理并行
**工作内容**(三个子代理并行,目录互不重叠):
- **Migrations V2**df-storage/df-core):版本推进到 2 — ideas 补 promoted_to/ai_analysis/scores、tasks 补 workflow_def_id/base_branch、workflow_executions 补 project_id/task_id、新建 branches 表(含 2 索引BranchRecord/BranchRepo/BranchStatus 枚举同步落地ALLOWED_COLUMNS 白名单 +8 列
- **Migrations V2**df-storage/df-types):版本推进到 2 — ideas 补 promoted_to/ai_analysis/scores、tasks 补 workflow_def_id/base_branch、workflow_executions 补 project_id/task_id、新建 branches 表(含 2 索引BranchRecord/BranchRepo/BranchStatus 枚举同步落地ALLOWED_COLUMNS 白名单 +8 列
- **Executor 并行化**df-workflow同层节点改 futures::join_all 并发三阶段模式串行准备→并发执行→串行收尾规避借用冲突StateMachine 增加转换校验(仅 Pending→Running、Running→Completed/Failed 合法set_xxx 改返回 Result4 个单测全绿(含并行耗时断言 <180ms、失败中止下游层
- **Tauri IPC**src-tauriAppStatedb + 6 Repo + EventBus + NodeRegistry+ 17 个 commandproject/task/idea CRUD + run_workflow/list/get executionsrun_workflow 先落库 status=running 立即返回 execution_id事件经 app.emit("workflow-event") 转发前端完成后回写状态。注意NodeRegistry 不能用 default()script 工厂是 unimplemented! 占位),改为 new() + 手动注册真实 ScriptNode
@@ -665,7 +665,7 @@
**遗留 / 下一步(修复批次建议)**:
- **批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
- **批2 阻塞 + 去重**: ③(:270/:271/:212 三处统一 spawn_blocking+ ④(抽公共 normalize_path 到 df-project/df-types+ ⑤tool_registry 复用 allowed_columns_for
- **批3 前端**: ⑥(补 i18n key+ ⑦(抽 src/utils/project.ts+ ⑧(全局 8 处 alert/confirm 换 Arco Modal
- ⚪ 可选: ⑨⑩⑪⑫ 按需
@@ -1031,6 +1031,47 @@
---
### 2026-07-19 — ConvStateStore 无锁重构 + 前端 P0 修复 + DeepSeek 400 修复 + 标题策略改进
> 本轮Zed Agent 接管)完成 B-Phase 全量迁移、前端 P0、多轮 bug 修复、代码审查清理。
> 代码变更 50 文件,+2454/-1251 行,未提交。
**B-Phase ConvStateStore 无锁重构(根治 session lock 竞争)**
| Phase | 内容 |
|---|---|
| Phase0-1 | DashMap 骨架 + AppState 接入Claude Code 完成)|
| Phase2 | guard/L0/chat IPC 写侧全量切 conv_states |
| Phase3 | 读侧迁移 + 删 PerConvState.conv_state 字段 → 单源收敛 |
| Phase4 | conversation_delete 同步 conv_states.remove |
**前端修复**
- P0-1: watchdog `convStates.clear()``delete(activeConversationId)`
- P0-2: switch/deleteConversation 入口加 `clearAllApprovalTimers()` + 注释修正
- 每轮 token 独立显示(🔣 XXX in · YYY out
- STREAM_TIMEOUT_MS 45s → 90s
**后端修复**
- DeepSeek 400「insufficient tool messages」→ 末尾 orphan tool_calls 自动剥离
- `env_snapshot.rs` 5s 超时兜底Windows Store alias 卡死)
- L0 握手始终推 AiConvStateChanged{idle} 防前端状态残留
- `openai_compat.rs` send 阶段 60s timeout
- 原生 SSE 解析器替代 eventsource-streamWindows 兼容)
- run_agentic_loop 20s AiHeartbeat 保活
- grep 正则自动修复(未分组 `|``(?:...)` 包裹)
- read_file 路径不存在提示 list_directory
**标题策略改进**
- extract_title: 取前 5 条 user 消息中最长的一条(替代第一条截取)
- extract 不再落库 → LLM 每次都有重试机会,不再永久锁定差标题
**代码审查清理**
- 修复 lib.rs 重复 handler 注册(🔴 严重)
- 清理 unused import / [STUCK] 调试日志 / 过时注释
- 340 tests passed, vue-tsc clean
**待提交**50 文件改动,含 B-Phase 重构 + 前端 P0 + 多轮 bug 修复
## 七、开发约定
### 构建命令

View File

@@ -341,7 +341,7 @@ class WsClient {
}
const text = res.data
if (!text) return
// 活性:任意入站帧更新(relay pong,用心跳期间入站消息替代判活)
// 活性:任意入站帧更新(relay 回的 pong 心跳响应,用于半连接检测)
this.lastInboundAt = Date.now()
// 握手阶段可能收到 relay 发的错误控制帧(relay.rs:200-238)

View File

@@ -215,6 +215,44 @@ function resetWatchdog(): void {
streamTimer = setTimeout(onStreamTimeout, STREAM_TIMEOUT_MS)
}
// ── 审批超时计时器(miniapp 补齐,对齐桌面 aiShared.startApprovalTimer)──
// 治「审批永久挂起」:用户没在 5min 内处理审批 → 自动拒绝(对应桌面端 APPROVAL_TIMEOUT_DEFAULT_MS=900000,
// miniapp 取 5min=300000 更符合移动端使用习惯)。每条审批独立计时,approve/拒绝/完成/切会话 清除。
const APPROVAL_TIMEOUT_MS = 300_000 // 5 分钟
const _approvalTimers = new Map<string, ReturnType<typeof setTimeout>>()
/** 启动审批超时计时器(AiApprovalRequired/AiDirAuthRequired 触发)。 */
function startApprovalTimer(tcId: string, toolName: string): void {
if (_approvalTimers.has(tcId)) return // 幂等
const timer = setTimeout(() => {
_approvalTimers.delete(tcId)
// 超时自动拒绝(对齐桌面端:到点调 aiApi.approve(id,false))
const idx = pendingApprovals.findIndex((p) => p.id === tcId)
if (idx >= 0) {
pendingApprovals.splice(idx, 1)
uni.showToast({ title: `审批超时已自动拒绝:${toolName}`, icon: 'none' })
// 发拒绝命令(device 收到后走 reject 链路,工具不执行,emit AiApprovalResult approved=false)
approve(tcId, false)
}
}, APPROVAL_TIMEOUT_MS)
_approvalTimers.set(tcId, timer)
}
/** 清除审批超时计时器(approve/拒绝/完成/切会话/断连调用)。 */
function clearApprovalTimer(tcId: string): void {
const t = _approvalTimers.get(tcId)
if (t) {
clearTimeout(t)
_approvalTimers.delete(tcId)
}
}
/** 清除所有审批计时器(切会话/断连/重连/终态)。 */
function clearAllApprovalTimers(): void {
for (const t of _approvalTimers.values()) clearTimeout(t)
_approvalTimers.clear()
}
/** 清除看门狗(终态/挂起/断连触发,timer 彻底停) */
function clearWatchdog(): void {
if (streamTimer) clearTimeout(streamTimer)
@@ -468,6 +506,8 @@ export function handleEvent(event: AiChatEvent): void {
// 移除对应挂起审批(若有)
const idx = pendingApprovals.findIndex((p) => p.id === event.id)
if (idx >= 0) pendingApprovals.splice(idx, 1)
// 审批计时器:工具完成 → 清(审批已生效)
clearApprovalTimer(event.id)
// P2-E:工具执行完成 → 清回填标志(审批已生效执行,不再回填)
if (lastApprovedTc.value && lastApprovedTc.value.id === event.id) {
lastApprovedTc.value = null
@@ -495,6 +535,7 @@ export function handleEvent(event: AiChatEvent): void {
pendingApprovals.push(info)
}
clearWatchdog() // 挂起等审批,停看门狗(用户操作后续生成会 reset 重启)
startApprovalTimer(event.id, event.name) // 5min 超时自动拒绝兜底
const tc = findToolCall(event.id)
if (tc) {
tc.status = 'pending_approval'
@@ -511,6 +552,7 @@ export function handleEvent(event: AiChatEvent): void {
}
const idx = pendingApprovals.findIndex((p) => p.id === event.id)
if (idx >= 0) pendingApprovals.splice(idx, 1)
clearApprovalTimer(event.id) // 审批结果落定 → 清计时器
// P2-E:审批结果确认(批准/拒绝均落定)→ 清回填标志(该 id 已定,不再回填)
if (lastApprovedTc.value && lastApprovedTc.value.id === event.id) {
lastApprovedTc.value = null
@@ -520,6 +562,7 @@ export function handleEvent(event: AiChatEvent): void {
case 'AiDirAuthRequired': {
clearWatchdog() // 挂起等授权,停看门狗
startApprovalTimer(event.id, event.tool) // 5min 超时自动拒绝
// F-#95 P0-3:路径授权进 pendingApprovals(kind=path),chat 页渲染 once/always/deny 按钮
// (替代旧 system 文案降级「请在桌面端处理」,miniapp 成轻量审批终端,治 loop 卡死)。
const authInfo: AiToolCallInfo = {
@@ -578,7 +621,7 @@ export function handleEvent(event: AiChatEvent): void {
generating.value = false
agentRound.value = 0
clearWatchdog() // 终态:停看门狗
pendingApprovals.splice(0, pendingApprovals.length)
pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
// P2-E:审批失败回填 —— 刚 approve(approved=true)成功发出但 device 返 AiError(执行失败/已被
// 桌面端处理),回填 pendingApprovals 让用户重试,避免 UI 误导「已批准」实际未生效。
if (lastApprovedTc.value) {
@@ -674,7 +717,14 @@ function subscribeWs(): void {
}
},
onControl: (msg: ControlMessage) => {
// 心跳响应等,miniapp 仅 console(不维持在线状态 UI)
// 心跳 pong 响应:relay 收到 miniapp ping 后直接回 pong(不经 device),
// miniapp 据此更新 lastPongTime 看门狗(ws.ts 已维护 lastInboundAt,但该路径不经过
// onControl,这里记录到变量供日志诊断)。pong 闭环治移动网络 TCP 半连接挂死。
if (msg.control_kind === 'pong') {
console.debug('[useAiChat] 收到心跳 pong(relay 响应,连接健康)')
return
}
// 其他 control 消息(hello_ack / pair 等)仅 console 不维持在线状态 UI
console.log('[useAiChat] control', msg)
},
onStatus: (status: WsStatus, detail?: string) => {
@@ -928,7 +978,7 @@ function switchConversation(convId: string): void {
lastApprovedTc.value = null // P2-E:切会话清审批回填标志(防跨会话误回填)
generating.value = false
// MINIAPP-03:清残留挂起状态,防旧会话审批卡/超时回调污染新会话视图(对齐 newConversation)。
pendingApprovals.splice(0, pendingApprovals.length)
pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
clearWatchdog()
// F-#95 扩展:发 load_messages 拉历史(device route_load_messages 读库 → AiMessageHistory 回流)。
@@ -1015,7 +1065,7 @@ function newConversation(): void {
maxRoundsActive.value = false // P1-E:新会话清 MaxRounds 挂起
lastApprovedTc.value = null // P2-E:新会话清审批回填标志
generating.value = false
pendingApprovals.splice(0, pendingApprovals.length)
pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
}
/**
@@ -1037,7 +1087,7 @@ function syncOnConnect(): void {
// req2:拉完整历史
wsClient.send({ cmd: 'load_messages', args: { conversation_id: convId } })
// req3:清本地 pending + 发 sync_pending 重推权威审批状态
pendingApprovals.splice(0, pendingApprovals.length)
pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
wsClient.send({ cmd: 'sync_pending', args: { conversation_id: convId } })
}

View File

@@ -42,13 +42,71 @@ export const defaultConfig: MiniappConfig = {
reconnectMaxDelay: 30000,
}
/** 单例配置(miniapp 内存配置,后续接 storage 持久化) */
let _config: MiniappConfig = { ...defaultConfig }
/** storage key(持久化完整 MiniappConfig JSON) */
const STORAGE_KEY = 'df-miniapp-config'
/** 单例配置(null 表示尚未从 storage 加载) */
let _config: MiniappConfig | null = null
/**
* 从 storage 读取并合并默认配置
*
* uni.getStorageSync(key) 未命中时返回 ''(空字符串),命中返回原写入值。
* 容错:读取/解析失败仅 warn,不抛出,回落到 defaultConfig。
*/
function loadFromStorage(): MiniappConfig {
try {
const raw = uni.getStorageSync(STORAGE_KEY)
if (!raw) return { ...defaultConfig }
const parsed = JSON.parse(raw) as Partial<MiniappConfig>
// 合并默认值,避免旧版本字段缺失导致 undefined
return { ...defaultConfig, ...parsed }
} catch (e) {
console.warn('[df-miniapp] load config from storage failed:', e)
return { ...defaultConfig }
}
}
/**
* 同步写入 storage(JSON 序列化完整配置)
*
* 容错:写入失败仅 warn,不影响内存配置。
*/
function saveToStorage(config: MiniappConfig): void {
try {
uni.setStorageSync(STORAGE_KEY, JSON.stringify(config))
} catch (e) {
console.warn('[df-miniapp] save config to storage failed:', e)
}
}
export function getConfig(): MiniappConfig {
// 首次调用时从 storage 加载并缓存
if (_config === null) {
_config = loadFromStorage()
}
return _config
}
export function setConfig(patch: Partial<MiniappConfig>): void {
// 确保已加载(避免在 getConfig 前调用 setConfig 丢失 storage 旧值)
if (_config === null) {
_config = loadFromStorage()
}
_config = { ..._config, ...patch }
saveToStorage(_config)
}
/**
* 重置为默认配置并清除 storage 持久化数据
*
* 用于用户重新配对或恢复出厂占位值的场景。
*/
export function resetConfig(): void {
_config = { ...defaultConfig }
try {
uni.removeStorageSync(STORAGE_KEY)
} catch (e) {
console.warn('[df-miniapp] remove config from storage failed:', e)
}
}

View File

@@ -0,0 +1,47 @@
# Bug 修复模板
name: Bug 修复
description: AI 驱动的 bug 修复流程 — 定位、修复、验证、通知
nodes:
locate:
type: ai
label: 定位 Bug
config:
prompt: |
根据错误描述和堆栈信息,定位 bug 的根因。
使用 search_files 和 read_file 工具查找相关代码。
persona_id: analyst
fix:
type: ai
label: 修复 Bug
config:
prompt: |
基于定位结果,修复 bug。
使用 patch_file 工具进行最小化修改。
persona_id: coder
verify:
type: ai
label: 验证修复
config:
prompt: |
验证修复是否有效。
使用 run_command 工具运行相关测试。
persona_id: tester
notify:
type: notify
label: 通知结果
config:
type: desktop
title: Bug 修复完成
message: 修复已验证
edges:
- from: locate
to: fix
- from: fix
to: verify
- from: verify
to: notify

View File

@@ -0,0 +1,36 @@
# 代码审查模板
name: 代码审查
description: AI 驱动的代码审查流程 — 分析变更、审查质量、通知结果
nodes:
analyze:
type: ai
label: 分析代码变更
config:
prompt: |
分析以下代码变更,识别:
1. 潜在 bug 和逻辑错误
2. 安全漏洞
3. 性能问题
4. 代码规范
persona_id: reviewer
report:
type: ai
label: 生成审查报告
config:
prompt: 基于分析结果,生成结构化审查报告,含优先级排序的改进建议
notify:
type: notify
label: 通知完成
config:
type: desktop
title: 代码审查完成
message: 审查报告已生成
edges:
- from: analyze
to: report
- from: report
to: notify

View File

@@ -0,0 +1,62 @@
# 功能开发模板
name: 功能开发
description: AI 驱动的功能开发全流程 — 设计、编码、测试、审查、通知
nodes:
design:
type: ai
label: 方案设计
config:
prompt: |
分析需求,设计实现方案:
1. 模块划分和接口定义
2. 数据结构和核心算法
3. 影响范围评估
persona_id: architect
implement:
type: ai
label: 编码实现
config:
prompt: |
按照设计方案实现功能。
使用 write_file 和 patch_file 工具编写代码。
persona_id: coder
test:
type: ai
label: 编写测试
config:
prompt: |
为新功能编写单元测试和集成测试。
使用 run_command 工具运行测试验证。
persona_id: tester
review:
type: ai
label: 代码审查
config:
prompt: |
审查实现代码,关注:
1. 代码质量和可维护性
2. 潜在 bug
3. 安全性
persona_id: reviewer
notify:
type: notify
label: 通知完成
config:
type: desktop
title: 功能开发完成
message: 设计→编码→测试→审查 全流程完成
edges:
- from: design
to: implement
- from: implement
to: test
- from: test
to: review
- from: review
to: notify

View File

@@ -5,10 +5,18 @@
"": {
"name": "devflow",
"dependencies": {
"@antv/layout": "^2.0.0",
"@antv/x6": "^3.1.7",
"@antv/x6-vue-shape": "^3.0.2",
"@arco-design/web-vue": "^2.58.0",
"@tauri-apps/api": "^2",
"dompurify": "^3.4.9",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@types/dagre": "^0.7.54",
"dagre": "^0.8.5",
"dompurify": "^3.4.10",
"highlight.js": "^11.11.1",
"marked": "^18.0.5",
"tslib": "^2.8.1",
"vue": "^3.5.13",
"vue-i18n": "9",
"vue-router": "4",
@@ -24,6 +32,20 @@
},
},
"packages": {
"@antv/event-emitter": ["@antv/event-emitter@0.1.3", "https://registry.npmmirror.com/@antv/event-emitter/-/event-emitter-0.1.3.tgz", {}, "sha512-4ddpsiHN9Pd4UIlWuKVK1C4IiZIdbwQvy9i7DUSI3xNJ89FPUFt8lxDYj8GzzfdllV0NkJTRxnG+FvLk0llidg=="],
"@antv/expr": ["@antv/expr@1.0.2", "https://registry.npmmirror.com/@antv/expr/-/expr-1.0.2.tgz", {}, "sha512-vrfdmPHkTuiS5voVutKl2l06w1ihBh9A8SFdQPEE+2KMVpkymzGOF1eWpfkbGZ7tiFE15GodVdhhHomD/hdIwg=="],
"@antv/graphlib": ["@antv/graphlib@2.0.4", "https://registry.npmmirror.com/@antv/graphlib/-/graphlib-2.0.4.tgz", { "dependencies": { "@antv/event-emitter": "^0.1.3" } }, "sha512-zc/5oQlsdk42Z0ib1mGklwzhJ5vczLFiPa1v7DgJkTbgJ2YxRh9xdarf86zI49sKVJmgbweRpJs7Nu5bIiwv4w=="],
"@antv/layout": ["@antv/layout@2.0.0", "https://registry.npmmirror.com/@antv/layout/-/layout-2.0.0.tgz", { "dependencies": { "@antv/event-emitter": "^0.1.3", "@antv/expr": "^1.0.2", "@antv/graphlib": "^2.0.0", "@antv/util": "^3.3.2", "comlink": "^4.4.1", "d3-force": "^3.0.0", "d3-force-3d": "^3.0.5", "d3-octree": "^1.0.2", "d3-quadtree": "^3.0.1", "dagre": "^0.8.5", "ml-matrix": "^6.10.4", "tslib": "^2.8.1" } }, "sha512-aCZ3UdNc40SfT7meFV7QTADY2HCnc0DShVw56CJNTI6oExUIVU736grPuL5Dhb8/JrVaU4Y83QPN/P7KafBzlw=="],
"@antv/util": ["@antv/util@3.3.11", "https://registry.npmmirror.com/@antv/util/-/util-3.3.11.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "gl-matrix": "^3.3.0", "tslib": "^2.3.1" } }, "sha512-FII08DFM4ABh2q5rPYdr0hMtKXRgeZazvXaFYCs7J7uTcWDHUhczab2qOCJLNDugoj8jFag1djb7wS9ehaRYBg=="],
"@antv/x6": ["@antv/x6@3.1.7", "https://registry.npmmirror.com/@antv/x6/-/x6-3.1.7.tgz", { "dependencies": { "dom-align": "^1.12.4", "lodash-es": "^4.17.15", "mousetrap": "^1.6.5", "utility-types": "^3.10.0" } }, "sha512-NLKXtbCK51oLbazfFD0XsD93rMmih08UBW4gAuEyLBpwAqHmHe+vP8VhOZDkl5O9jV1LSv85IJghr9CT5tZjWw=="],
"@antv/x6-vue-shape": ["@antv/x6-vue-shape@3.0.2", "https://registry.npmmirror.com/@antv/x6-vue-shape/-/x6-vue-shape-3.0.2.tgz", { "dependencies": { "vue-demi": "latest" }, "peerDependencies": { "@antv/x6": "^3.x", "@vue/composition-api": "^1.0.0-rc.1", "vue": "^2.0.0 || >=3.0.0" }, "optionalPeers": ["@vue/composition-api"] }, "sha512-HWnYFvLCaUD9ernINxTn9vCRj66eG/nXKErSSq0hfI4U1HOitC/1oUrzDdwL/BT80HKAQeSsmy7gpDA/79jYlw=="],
"@arco-design/color": ["@arco-design/color@0.4.0", "https://registry.npmmirror.com/@arco-design/color/-/color-0.4.0.tgz", { "dependencies": { "color": "^3.1.3" } }, "sha512-s7p9MSwJgHeL8DwcATaXvWT3m2SigKpxx4JA1BGPHL4gfvaQsmQfrLBDpjOJFJuJ2jG2dMt3R3P8Pm9E65q18g=="],
"@arco-design/web-vue": ["@arco-design/web-vue@2.58.0", "https://registry.npmmirror.com/@arco-design/web-vue/-/web-vue-2.58.0.tgz", { "dependencies": { "@arco-design/color": "^0.4.0", "b-tween": "^0.3.3", "b-validate": "^1.5.3", "compute-scroll-into-view": "^1.0.20", "dayjs": "^1.11.13", "number-precision": "^1.6.0", "resize-observer-polyfill": "^1.5.1", "scroll-into-view-if-needed": "^2.2.31", "vue": "^3.1.0" } }, "sha512-b1vdPYOmjG5VAkVa7jlVwCb+WynBK+rnKN8zH3yKohpZObZbostRd3HgYNtjjZjGVU3OqR0Yy2FX7ftgF0bcOw=="],
@@ -172,6 +194,10 @@
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.2", "https://registry.npmmirror.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA=="],
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.1", "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ=="],
"@types/dagre": ["@types/dagre@0.7.54", "https://registry.npmmirror.com/@types/dagre/-/dagre-0.7.54.tgz", {}, "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ=="],
"@types/dompurify": ["@types/dompurify@3.2.0", "https://registry.npmmirror.com/@types/dompurify/-/dompurify-3.2.0.tgz", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="],
"@types/estree": ["@types/estree@1.0.9", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
@@ -228,15 +254,35 @@
"color-string": ["color-string@1.9.1", "https://registry.npmmirror.com/color-string/-/color-string-1.9.1.tgz", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
"comlink": ["comlink@4.4.2", "https://registry.npmmirror.com/comlink/-/comlink-4.4.2.tgz", {}, "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g=="],
"compute-scroll-into-view": ["compute-scroll-into-view@1.0.20", "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", {}, "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg=="],
"csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-binarytree": ["d3-binarytree@1.0.2", "https://registry.npmmirror.com/d3-binarytree/-/d3-binarytree-1.0.2.tgz", {}, "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw=="],
"d3-dispatch": ["d3-dispatch@3.0.1", "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
"d3-force": ["d3-force@3.0.0", "https://registry.npmmirror.com/d3-force/-/d3-force-3.0.0.tgz", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],
"d3-force-3d": ["d3-force-3d@3.0.6", "https://registry.npmmirror.com/d3-force-3d/-/d3-force-3d-3.0.6.tgz", { "dependencies": { "d3-binarytree": "1", "d3-dispatch": "1 - 3", "d3-octree": "1", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA=="],
"d3-octree": ["d3-octree@1.1.0", "https://registry.npmmirror.com/d3-octree/-/d3-octree-1.1.0.tgz", {}, "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A=="],
"d3-quadtree": ["d3-quadtree@3.0.1", "https://registry.npmmirror.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="],
"d3-timer": ["d3-timer@3.0.1", "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"dagre": ["dagre@0.8.5", "https://registry.npmmirror.com/dagre/-/dagre-0.8.5.tgz", { "dependencies": { "graphlib": "^2.1.8", "lodash": "^4.17.15" } }, "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw=="],
"dayjs": ["dayjs@1.11.21", "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="],
"de-indent": ["de-indent@1.0.2", "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="],
"dompurify": ["dompurify@3.4.9", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.9.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ=="],
"dom-align": ["dom-align@1.12.4", "https://registry.npmmirror.com/dom-align/-/dom-align-1.12.4.tgz", {}, "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw=="],
"dompurify": ["dompurify@3.4.11", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.11.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="],
"entities": ["entities@7.0.1", "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
@@ -244,20 +290,44 @@
"estree-walker": ["estree-walker@2.0.2", "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gl-matrix": ["gl-matrix@3.4.4", "https://registry.npmmirror.com/gl-matrix/-/gl-matrix-3.4.4.tgz", {}, "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="],
"graphlib": ["graphlib@2.1.8", "https://registry.npmmirror.com/graphlib/-/graphlib-2.1.8.tgz", { "dependencies": { "lodash": "^4.17.15" } }, "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A=="],
"he": ["he@1.2.0", "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"highlight.js": ["highlight.js@11.11.1", "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
"is-any-array": ["is-any-array@3.0.0", "https://registry.npmmirror.com/is-any-array/-/is-any-array-3.0.0.tgz", {}, "sha512-o4h+tylWykC4BD1vaejp6gDxoM13bwW8FGuNs4yIKpj8xbBJcRxJx8vZpq0dCr7ZDEfeKjmsi/euolKhX6f/ww=="],
"is-arrayish": ["is-arrayish@0.3.4", "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.3.4.tgz", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
"lodash": ["lodash@4.18.1", "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
"lodash-es": ["lodash-es@4.18.1", "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
"magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"marked": ["marked@18.0.5", "https://registry.npmmirror.com/marked/-/marked-18.0.5.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="],
"minimatch": ["minimatch@9.0.9", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"ml-array-max": ["ml-array-max@2.0.0", "https://registry.npmmirror.com/ml-array-max/-/ml-array-max-2.0.0.tgz", { "dependencies": { "is-any-array": "^3.0.0" } }, "sha512-QQZ4kENwpWmyNb98UXRDFXrmtIXuXtt1+bSbda/2KA85+F+rrJP8hZk6QOkCQXM2Th9mUDYdq/PNByPdT9ID4A=="],
"ml-array-min": ["ml-array-min@2.0.0", "https://registry.npmmirror.com/ml-array-min/-/ml-array-min-2.0.0.tgz", { "dependencies": { "is-any-array": "^3.0.0" } }, "sha512-GRj6Ky6sW9vGL6yIjgsHmXZ9YgrdmcQ8nCxPqEGeKc6dkfYg1XDYxGFxADUjNuZyoCd5PUscWAS4N+cFaX6hFg=="],
"ml-array-rescale": ["ml-array-rescale@2.0.0", "https://registry.npmmirror.com/ml-array-rescale/-/ml-array-rescale-2.0.0.tgz", { "dependencies": { "is-any-array": "^3.0.0", "ml-array-max": "^2.0.0", "ml-array-min": "^2.0.0" } }, "sha512-2GGtKfSno94/kIloWGvpp/U5Q5vLvLrza+SAaGsLeo6Xj4mEbA6Gqx+oTfZFkxnd1grT2X007HfJNs3T5BsiVg=="],
"ml-matrix": ["ml-matrix@6.13.0", "https://registry.npmmirror.com/ml-matrix/-/ml-matrix-6.13.0.tgz", { "dependencies": { "is-any-array": "^3.0.0", "ml-array-rescale": "^2.0.0" } }, "sha512-QpV0UTUkglg6vPUgThKGBEtit2ac6habSoZ33bwI9rU0UHZLqw6G3ukTIE8zWiUF3sjK8YAlhx/o/b9layzH8A=="],
"mousetrap": ["mousetrap@1.6.5", "https://registry.npmmirror.com/mousetrap/-/mousetrap-1.6.5.tgz", {}, "sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA=="],
"muggle-string": ["muggle-string@0.4.1", "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
"nanoid": ["nanoid@3.3.12", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
@@ -284,18 +354,26 @@
"tinyglobby": ["tinyglobby@0.2.17", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.6.3", "https://registry.npmmirror.com/typescript/-/typescript-5.6.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
"utility-types": ["utility-types@3.11.0", "https://registry.npmmirror.com/utility-types/-/utility-types-3.11.0.tgz", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="],
"vite": ["vite@6.4.3", "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
"vscode-uri": ["vscode-uri@3.1.0", "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
"vue": ["vue@3.5.35", "https://registry.npmmirror.com/vue/-/vue-3.5.35.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.35", "@vue/compiler-sfc": "3.5.35", "@vue/runtime-dom": "3.5.35", "@vue/server-renderer": "3.5.35", "@vue/shared": "3.5.35" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q=="],
"vue-demi": ["vue-demi@0.14.10", "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
"vue-i18n": ["vue-i18n@9.14.5", "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.14.5.tgz", { "dependencies": { "@intlify/core-base": "9.14.5", "@intlify/shared": "9.14.5", "@vue/devtools-api": "^6.5.0" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g=="],
"vue-router": ["vue-router@4.6.4", "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="],
"vue-tsc": ["vue-tsc@2.2.12", "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", { "dependencies": { "@volar/typescript": "2.4.15", "@vue/language-core": "2.2.12" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw=="],
"@types/dompurify/dompurify": ["dompurify@3.4.9", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.9.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ=="],
}
}

View File

@@ -95,14 +95,14 @@ impl ChatMessage {
/// 无需每加一个状态就来这里改。当前取值 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"))
matches!(self.status, None | Some(MessageStatus::Active))
}
}
impl ToolDefinition {
pub fn function(name: impl Into<String>, description: impl Into<String>, parameters: serde_json::Value) -> Self {
Self {
tool_type: "function".into(),
tool_type: ToolType::new("function"),
function: ToolFunction { name: name.into(), description: description.into(), parameters },
}
}
@@ -166,22 +166,22 @@ mod tests {
// "active"
let mut m = ChatMessage::user("hi");
m.status = Some("active".to_string());
m.status = Some(MessageStatus::Active);
assert!(m.is_active(), "Some(active) 应 active");
// "truncated" — 当前取值与旧实现等价false
let mut m = ChatMessage::user("hi");
m.status = Some("truncated".to_string());
m.status = Some(MessageStatus::Truncated);
assert!(!m.is_active(), "truncated 应不 active");
// "archived_segment" — 阶段2 待引入,白名单自动隔离
let mut m = ChatMessage::user("hi");
m.status = Some("archived_segment".to_string());
m.status = Some(MessageStatus::ArchivedSegment);
assert!(!m.is_active(), "archived_segment 应不 active白名单隔离");
// "compressed" — 阶段2 待引入,白名单自动隔离
let mut m = ChatMessage::user("hi");
m.status = Some("compressed".to_string());
m.status = Some(MessageStatus::Compressed);
assert!(!m.is_active(), "compressed 应不 active白名单隔离");
}

View File

@@ -103,11 +103,11 @@ pub struct ChatMessage {
/// 生成该消息的 model仅 assistant 消息有,消息级 model 追溯)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// 消息状态UX-09 编辑重生成None/"active" 正常可见;
/// 消息状态UX-09 编辑重生成None 正常可见;
/// "truncated" 软删(编辑某条 user 消息后其后续消息标记,保留 DB 可追溯但不进 LLM 上下文、前端视图过滤)
/// 默认 None向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
pub status: Option<MessageStatus>,
/// DeepSeek thinking 模式的推理内容(多轮需回传)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
@@ -148,11 +148,91 @@ pub enum MessageRole {
Tool,
}
/// 消息状态枚举IPC 边界序列化为小写 snake_case 字符串)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MessageStatus {
/// 正常可见
Active,
/// 软删(编辑某条 user 消息后其后续消息标记)
Truncated,
/// 已压缩
Compressed,
/// 已归档段
ArchivedSegment,
}
impl MessageStatus {
/// DB 存储用的小写 snake_case 字符串
pub fn as_db_str(&self) -> &'static str {
match self {
MessageStatus::Active => "active",
MessageStatus::Truncated => "truncated",
MessageStatus::Compressed => "compressed",
MessageStatus::ArchivedSegment => "archived_segment",
}
}
/// 从 DB 字符串解析
pub fn from_db_str(s: &str) -> Option<Self> {
Some(match s {
"active" => MessageStatus::Active,
"truncated" => MessageStatus::Truncated,
"compressed" => MessageStatus::Compressed,
"archived_segment" => MessageStatus::ArchivedSegment,
_ => return None,
})
}
}
/// ChatMessage 结构体
/// 工具类型IPC 边界透明序列化为字符串,如 "function"
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(transparent)]
pub struct ToolType(String);
impl ToolType {
/// 构造新工具类型
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
}
impl std::fmt::Display for ToolType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<String> for ToolType {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for ToolType {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl PartialEq<&str> for ToolType {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
impl PartialEq<str> for ToolType {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
/// 工具定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDefinition {
#[serde(rename = "type")]
pub tool_type: String,
pub tool_type: ToolType,
pub function: ToolFunction,
}
@@ -169,7 +249,7 @@ pub struct ToolFunction {
pub struct ToolCall {
pub id: String,
#[serde(rename = "type")]
pub call_type: String,
pub call_type: ToolType,
pub function: ToolCallFunction,
}

View File

@@ -14,6 +14,10 @@ anyhow = { workspace = true }
tracing = { workspace = true }
# HTTP + 流式
reqwest = { version = "0.12", features = ["stream", "json"] }
reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"] }
bytes = "1"
futures = "0.3"
eventsource-stream = "0.2"
rand = "0.8"
[dev-dependencies]
tempfile = "3"

View File

@@ -0,0 +1,76 @@
//! 独立诊断:用 df-ai 真实调用 GLM anthropic 流式端点,验证
//! sse_parser + apply_anthropic_event + provider.stream() 整条链路。
//!
//! 二分定位「发消息卡掉」: 若本例能正常吐 chunk → provider 层(df-ai)OK,
//! 问题在 devflow 应用层(provider 配置/emit/前端); 若卡/空/Err → df-ai 有 bug。
//!
//! 运行: cd crates/df-ai && cargo run --example glm_stream_test
use df_ai::build_provider;
use df_ai_core::CompletionRequest;
use futures::StreamExt;
#[tokio::main]
async fn main() {
let token = std::env::var("ANTHROPIC_AUTH_TOKEN")
.or_else(|_| std::env::var("ANTHROPIC_API_KEY"))
.expect("需要环境变量 ANTHROPIC_AUTH_TOKEN");
eprintln!("[glm-test] token len={}", token.len());
let provider = build_provider(
"anthropic",
"https://open.bigmodel.cn/api/anthropic",
&token,
"glm-5.2",
);
// 用 JSON 反序列化构造请求,绕开字段列表(devflow 实际用 glm-5.2)
let req: CompletionRequest = serde_json::from_str(
r#"{"model":"glm-5.2","stream":true,"max_tokens":16,"messages":[{"role":"user","content":"说你好"}]}"#,
)
.expect("parse CompletionRequest");
eprintln!("[glm-test] 调用 provider.stream() ...");
let t0 = std::time::Instant::now();
let mut s = match provider.stream(req).await {
Ok(s) => {
eprintln!("[glm-test] stream() Ok, 建连耗时 {:?}", t0.elapsed());
s
}
Err(e) => {
eprintln!("[glm-test] stream() Err: {:#}", e);
return;
}
};
let mut n = 0;
let mut got_text = false;
while let Some(chunk_result) = s.next().await {
n += 1;
match chunk_result {
Ok(chunk) => {
if !chunk.delta.is_empty() {
got_text = true;
}
eprintln!(
"[glm-test] chunk#{} delta={:?} reasoning={:?} finished={} usage={:?} err={:?}",
n, chunk.delta,
chunk.reasoning_content.as_deref().map(|s| if s.len() > 30 { format!("{}..", &s[..30]) } else { s.to_string() }),
chunk.finished, chunk.usage, chunk.error
);
}
Err(e) => {
eprintln!("[glm-test] chunk#{} Err: {}", n, e);
}
}
if n > 60 {
eprintln!("[glm-test] 超 60 chunk 截断");
break;
}
}
eprintln!(
"[glm-test] 流结束 共 {} chunk, 是否拿到文本={}, 总耗时 {:?}",
n,
got_text,
t0.elapsed()
);
}

View File

@@ -10,7 +10,6 @@
//! 本模块仅保留 Provider struct + implHTTP 调用Rust impl 块不可跨文件故作此切分。
use async_trait::async_trait;
use eventsource_stream::Eventsource;
use futures::StreamExt;
use reqwest::Client;
use std::time::Duration;
@@ -590,17 +589,19 @@ impl LlmProvider for AnthropicCompatProvider {
debug!(model = %body.model, "Anthropic 流式调用");
let resp = match self
// BUG-2026-07-07: send 阶段需 timeout 防 hang(实测 GLM 偶发建连后长时间不返回)。
// 注意:不能用 reqwest 的 .timeout()——它是整个请求(含 body 读取)的总超时,
// 流式长生成任务会被误砍(build_provider_client 注释已明确)。改用 tokio::time::timeout
// 包裹 send().await,只管建连+首响应头,不管后续 body 读取(后续由 stream_llm idle timeout 兜底)。
// 60s 选型:正常 send(建连+收 200 headers)<5s,60s 足够宽容。
let send_future = self
.auth_headers(self.client.post(self.messages_url()))
.json(&body)
.version(reqwest::Version::HTTP_11)
.send()
.await
{
Ok(r) => r,
Err(e) => {
// B-260618-26: 记 reqwest 错误源因链。原 ? 转 anyhow 仅 Display
// "error sending request for url" 无法定位 reset/TLS/超时/body 真因。
.send();
let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
tracing::error!(
is_timeout = e.is_timeout(),
is_connect = e.is_connect(),
@@ -620,6 +621,14 @@ impl LlmProvider for AnthropicCompatProvider {
std::error::Error::source(&e)
);
}
Err(_elapsed) => {
// send 阶段超时(60s 未返回 HTTP 响应头):GLM 端点可能不可达或极慢
tracing::error!(
url = %self.messages_url(),
"Anthropic 流式 send 超时(60s 未返回响应头)"
);
anyhow::bail!("流式请求超时(60秒未收到 HTTP 响应,可能服务不可达或被防火墙拦截)");
}
};
if !resp.status().is_success() {
@@ -629,36 +638,32 @@ impl LlmProvider for AnthropicCompatProvider {
anyhow::bail!("Anthropic 流式 API 错误 {}: {}", status, text);
}
// 流式解析eventsource 逐事件处理,按 type 字段分发转 StreamChunk
// 事件解析/usage 累积逻辑抽到 apply_anthropic_event 纯函数,便于单测;此处闭包只负责传 data。
// usage 累积message_start 给 input_tokensmessage_delta 给累计 output_tokens非增量message_stop 带出。
// BUG-2026-07-17 根治: 原生 SSE 解析器替代 eventsource-stream(同 openai_compat)
let mut usage_accum: Option<TokenUsage> = None;
// B-260618-28: MidStream error(如 GLM 1214 messages 非法)时附 messages 摘要定位哪条非法。
// precheck(Init 路径,发送前)漏的 case,靠此在 SSE error 事件暴露实际 messages 结构到前端 raw。
let messages_summary = Self::summarize_messages(&body.messages);
let stream = resp
.bytes_stream()
.eventsource()
.map(move |event| match event {
Ok(ev) => {
let mut chunk = apply_anthropic_event(&ev.data, &mut usage_accum);
// GLM 中途 error(如 1214)→ chunk.error 附 messages 摘要,经 stream_recv MidStream
// 路径 emit AiError raw,前端直接看到实际 messages 结构定位非法字段。
if let Some(err) = chunk.error.as_mut() {
*err = format!("{} | messages 摘要: {}", err, messages_summary);
let sse = crate::sse_parser::SseStream::new(resp.bytes_stream());
let stream = sse.flat_map(move |result: Result<Vec<String>, String>| {
let mut chunks: Vec<anyhow::Result<crate::provider::StreamChunk>> = Vec::new();
match result {
Ok(events) => {
for data in events {
let mut chunk = apply_anthropic_event(&data, &mut usage_accum);
if let Some(err) = chunk.error.as_mut() {
*err = format!("{} | messages 摘要: {}", err, messages_summary);
}
chunks.push(Ok(chunk));
}
Ok(chunk)
}
Err(e) => {
// 保留 #[source] 因果链: anyhow!("...{}", e) 仅把 e 的 Display 塞进 message,
// 丢掉 source(无法 downcast/遍历)。改用 Error::from(e).context(...):
// Display 不变(仍为 "Anthropic SSE 错误: {e}"), 且 e 作为 .source() 可追溯。
// 顺序: 先 format(e) 构造 context 文案, 再 Error::from(e) move e 进 source。
let ctx = format!("Anthropic SSE 错误: {}", e);
error!(error = %e, "Anthropic SSE 事件流错误");
Err(anyhow::Error::from(e).context(ctx))
error!("{}", ctx);
chunks.push(Err(anyhow::anyhow!("{}", ctx)));
}
});
}
futures::stream::iter(chunks)
});
Ok(Box::pin(stream))
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,719 @@
//! `ContextManager` 方法级单测 — 从 `mod.rs` 抽出以控制 `mod.rs` 行数。
//!
//! 这些测试覆盖 `ContextManager` 各公开/私有方法push / build_for_request / compress /
//! topic marker / 溯源 id 等),需访问私有字段与方法,故仍置于 `crate::context` 模块树内
//! `mod manager_tests;` 由 `mod.rs` 通过 `#[cfg(test)] mod manager_tests;` 引入)。
#![cfg(test)]
use super::*;
use crate::context_helpers::{ContextConfig, TokenEstimator, PROTECT_COUNT};
use crate::provider::ToolCall;
fn cfg(max_tokens: u32) -> ContextConfig {
ContextConfig {
max_tokens,
output_reserve: 0,
safety_ratio: 1.0,
}
}
#[test]
fn estimate_message_counts_parts_tokens() {
// F-260614-05 多模态回归:含图消息的大段 base64 必须计入 token 预算,
// 否则 history_tokens 严重低估 → build_for_request 不裁剪 → provider 超限。
let est = TokenEstimator::default();
// 纯文本基线
let text_msg = ChatMessage::user("短文本");
let text_tokens = est.estimate_message(&text_msg);
// 同样 content + 含大段 base64 的 parts → token 应显著高于纯文本
let big_base64 = "iVBORw0KGgoAAAANS".repeat(100); // ~1.7k 字符
let multimodal = ChatMessage::user_parts(
"短文本",
vec![crate::provider::ContentPart::image_base64("image/png", big_base64.clone())],
);
let mm_tokens = est.estimate_message(&multimodal);
assert!(
mm_tokens > text_tokens,
"含图消息 token({}) 应高于纯文本({})",
mm_tokens,
text_tokens
);
// base64 字符按 0.35 粗估,约 1.7k * 0.35 ≈ 595 tokens 量级
assert!(
mm_tokens > 500,
"大 base64 应贡献可观 token实际 {}",
mm_tokens
);
// url 模式(无字节)也按 URL 长度估算,不爆
let url_msg = ChatMessage::user_parts(
"t",
vec![crate::provider::ContentPart::image_url("https://example.com/x.png")],
);
let url_tokens = est.estimate_message(&url_msg);
assert!(url_tokens > text_tokens, "url 片也应有少量 token 贡献");
}
#[test]
fn short_history_no_trim() {
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("你好"));
mgr.push(ChatMessage::assistant("你好啊"));
let (msgs, trimmed) = mgr.build_for_request(10);
assert!(!trimmed);
assert_eq!(msgs.len(), 2);
}
#[test]
fn over_budget_trims_old() {
// 小预算强制裁剪20 条超预算,触发裁剪且保留保护区
let mut mgr = ContextManager::new(cfg(200));
// user/assistant 交替(真实对话序列;连续 user 会被 ensure_sequence_legal 合并,无法测条数裁剪)
for i in 0..20 {
if i % 2 == 0 {
mgr.push(ChatMessage::user(&format!("这是第 {} 条较长的消息用于撑爆预算", i)));
} else {
mgr.push(ChatMessage::assistant(&format!("{} 条较长的回复用于撑爆预算", i)));
}
}
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(trimmed, "超预算应触发裁剪");
assert!(msgs.len() < 20, "应裁掉部分旧消息, 实际 {}", msgs.len());
// 保护区:最新一条必保留(末条 i=19 是 assistant)
assert_eq!(
msgs.last().unwrap().content,
"第 19 条较长的回复用于撑爆预算",
"保护区最新消息被误裁"
);
// 裁剪是视图:内存全量不变
assert_eq!(mgr.all_messages_clone().len(), 20, "裁剪污染了内存全量");
}
#[test]
fn tool_triplet_kept_atomic() {
// 三元组不可分离Head 与 Tail 同进同出,永不从中间切断
// 布局6 旧(淘汰区) + 三元组(裁剪边界) + 6 新(保护区) = 15 条
let mut mgr = ContextManager::new(cfg(95));
for i in 0..6 {
mgr.push(ChatMessage::user(&format!("旧消息 {}", i)));
}
mgr.push(ChatMessage::assistant_with_tools(
"调工具",
vec![ToolCall::new("tc1", "read_file", "{}")],
));
mgr.push(ChatMessage::tool_result("tc1", "文件内容"));
mgr.push(ChatMessage::assistant("完成"));
for i in 0..6 {
mgr.push(ChatMessage::user(&format!("新消息 {}", i)));
}
// 分支一:预算宽松,三元组整体保留 → Head 在则 Tail 在
let (msgs_keep, trimmed1) = mgr.build_for_request(0);
assert!(trimmed1, "分支一应触发裁剪");
assert_eq!(
has_head(&msgs_keep),
has_tail(&msgs_keep),
"分支一三元组被切断: head={} tail={}",
has_head(&msgs_keep),
has_tail(&msgs_keep)
);
// 分支二:预算紧张,三元组整体丢弃 → Head 不在则 Tail 也不在
let (msgs_drop, trimmed2) = mgr.build_for_request(40);
assert!(trimmed2, "分支二应触发裁剪");
assert_eq!(
has_head(&msgs_drop),
has_tail(&msgs_drop),
"分支二三元组被切断: head={} tail={}",
has_head(&msgs_drop),
has_tail(&msgs_drop)
);
// 裁剪是视图:两次 build 都不应改变内存全量
assert_eq!(
mgr.all_messages_clone().len(),
15,
"裁剪污染了内存全量"
);
}
fn has_head(msgs: &[ChatMessage]) -> bool {
msgs.iter()
.any(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
}
fn has_tail(msgs: &[ChatMessage]) -> bool {
msgs.iter().any(|m| matches!(m.role, MessageRole::Tool))
}
#[test]
fn replace_tool_result_updates_tokens() {
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::tool_result("tc1", ""));
let before = mgr.history_tokens();
assert!(mgr.replace_tool_result_content("tc1", "这是一个明显更长的替换内容用于验证 token 重估"));
let after = mgr.history_tokens();
assert!(after > before);
}
#[test]
fn restore_rebuilds_token_cache() {
let mut mgr = ContextManager::new(cfg(100_000));
let src = vec![
ChatMessage::user("测试消息一"),
ChatMessage::assistant("回复一"),
ChatMessage::user("测试消息二"),
];
mgr.restore_from_messages(src);
assert!(mgr.history_tokens() > 0);
assert_eq!(mgr.len(), 3);
}
#[test]
fn push_token_only_active() {
// F-15 §3.3!active 消息truncated / archived_segment / compressed仍 push
// 到 self.messages全量保留持久化/前端视图自管),但不计入 history_tokens
// 避免 build_for_request 误判超预算触发不必要裁剪。
// 1) 直接 push 路径
let mut mgr = ContextManager::new(cfg(100_000));
let active_msg = ChatMessage::user("这条是 active 的");
let active_tokens = TokenEstimator::default().estimate_message(&active_msg);
let mut inactive_msg = ChatMessage::assistant("这条被截断了不该计 token");
inactive_msg.status = Some(MessageStatus::Truncated);
let inactive_tokens = TokenEstimator::default().estimate_message(&inactive_msg);
mgr.push(active_msg);
mgr.push(inactive_msg);
// 全量保留(两条都在内存)
assert_eq!(mgr.len(), 2, "active + !active 都应 push 到 self.messages");
assert_eq!(mgr.all_messages_clone().len(), 2, "持久化全量不受 push 修正影响");
// token 预算只含 active
assert_eq!(
mgr.history_tokens(),
active_tokens,
"history_tokens 应只含 active多算了 {}inactive 应被忽略)",
mgr.history_tokens().saturating_sub(active_tokens)
);
assert!(
inactive_tokens > 0,
"前提inactive 消息本身确有 token否则无法证明它被排除"
);
// 2) restore_from_messages 路径(调 pushtoken 同步仅 active
let mut mgr2 = ContextManager::new(cfg(100_000));
let mut a = ChatMessage::user("active 一");
a.status = Some(MessageStatus::Active);
let mut b = ChatMessage::user("archived 一");
b.status = Some(MessageStatus::ArchivedSegment);
let mut c = ChatMessage::user("compressed 一");
c.status = Some(MessageStatus::Compressed);
mgr2.restore_from_messages(vec![a, b, c]);
assert_eq!(mgr2.len(), 3, "restore 后全量保留三条");
// 只 active 一条计 tokenb/c 是白名单外状态is_active 返回 false
let only_active_tokens = TokenEstimator::default()
.estimate_message(&ChatMessage::user("active 一"));
assert_eq!(
mgr2.history_tokens(),
only_active_tokens,
"restore 后 history_tokens 应只含 active 一条archived/compressed 不计"
);
}
#[test]
fn empty_history_returns_empty() {
let mgr = ContextManager::new(cfg(100_000));
let (msgs, trimmed) = mgr.build_for_request(10);
assert!(!trimmed);
assert!(msgs.is_empty(), "空历史应返回空列表");
}
#[test]
fn protect_zone_returns_full_when_untrimmable() {
// 消息全在保护区(< PROTECT_COUNT 条)且超预算 → 无可淘汰单元,走 trim_end==0 兜底返回全量
let mut mgr = ContextManager::new(cfg(10));
mgr.push(ChatMessage::user("撑爆小预算的长消息内容"));
mgr.push(ChatMessage::assistant("第二条撑爆预算的长消息"));
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(!trimmed, "无可淘汰单元应返回 false(兜底)");
assert_eq!(msgs.len(), 2, "兜底 sanitize 后返回全部保护区消息(user/assistant 交替不合并)");
}
#[test]
fn system_over_budget_trims_to_protect_zone() {
// system prompt 吃光预算 → history 仍尝试裁剪到保护区,不 panic
let mut mgr = ContextManager::new(cfg(200));
for i in 0..10 {
mgr.push(ChatMessage::user(&format!("消息 {} 撑量", i)));
}
let (msgs, _trimmed) = mgr.build_for_request(195);
assert!(
msgs.len() <= PROTECT_COUNT,
"system 超预算时裁剪后至多保留保护区 {} 条,实际 {}",
PROTECT_COUNT,
msgs.len()
);
}
// ── F-15 阶段1 辅助方法单测 ──
#[test]
fn compress_old_messages_marks_compressed_and_returns_refs() {
// F-15 §4.2/§4.3compress_old_messages 把 [0, end) 内 active 消息标 compressed,
// 同步扣 history_tokens,返回它们的克隆供 LLM 摘要。持久化全量保留。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("旧消息1"));
mgr.push(ChatMessage::assistant("旧回复1"));
mgr.push(ChatMessage::user("新消息2"));
let tokens_before = mgr.history_tokens();
assert!(tokens_before > 0);
let compressed = mgr.compress_old_messages(2);
assert_eq!(compressed.len(), 2, "应压缩前 2 条 active");
assert_eq!(compressed[0].content, "旧消息1");
assert_eq!(compressed[1].content, "旧回复1");
// status 已改 compressed
assert_eq!(mgr.messages_mut()[0].message.status.as_ref(), Some(&MessageStatus::Compressed));
assert_eq!(mgr.messages_mut()[1].message.status.as_ref(), Some(&MessageStatus::Compressed));
// 保护区外(本例 index 2)仍 active
assert!(mgr.messages_mut()[2].message.is_active(), "保护区外消息不应被动");
// 持久化全量不变
assert_eq!(mgr.all_messages_clone().len(), 3, "compress 不应删消息(单向,全量保留)");
// token 已扣(剩第 3 条的)
let only_third_tokens = TokenEstimator::default().estimate_message(&ChatMessage::user("新消息2"));
assert_eq!(mgr.history_tokens(), only_third_tokens, "history_tokens 应扣除前两条");
}
#[test]
fn compress_old_messages_is_idempotent() {
// 幂等:已 compressed 不二次压缩,二次调用返回空 Vec 且 history_tokens 不再变。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("a"));
mgr.push(ChatMessage::user("b"));
let first = mgr.compress_old_messages(2);
assert_eq!(first.len(), 2);
let tokens_after_first = mgr.history_tokens();
let second = mgr.compress_old_messages(2);
assert!(second.is_empty(), "二次压缩应返回空(已 compressed 不重压)");
assert_eq!(
mgr.history_tokens(),
tokens_after_first,
"二次压缩 history_tokens 不应再变(幂等)"
);
}
#[test]
fn compress_old_messages_clamps_oversized_end() {
// compress_end 越界自动 clamp 到 len,不 panic。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("唯一"));
let compressed = mgr.compress_old_messages(999);
assert_eq!(compressed.len(), 1, "越界 end 应 clamp 到 len(1)");
assert_eq!(mgr.history_tokens(), 0, "全量压缩后 history_tokens 归零");
}
#[test]
fn compress_old_messages_skips_already_inactive() {
// 范围内含 truncated(已 !active)的消息:跳过,不返,不重复扣 token。
let mut mgr = ContextManager::new(cfg(100_000));
let mut truncated = ChatMessage::user("被截断");
truncated.status = Some(MessageStatus::Truncated);
mgr.push(truncated);
mgr.push(ChatMessage::user("active 一条"));
let tokens_before = mgr.history_tokens();
// truncated 已不计 token(见 push_token_only_active),所以 tokens_before 只含 active 一条
let compressed = mgr.compress_old_messages(2);
assert_eq!(compressed.len(), 1, "只压缩 active 那条,truncated 跳过");
assert_eq!(mgr.history_tokens(), 0);
assert_eq!(
mgr.history_tokens(),
tokens_before.saturating_sub(tokens_before),
"幂等扣除:truncated 本就没计 token,active 扣光"
);
// truncated 状态不被改成 compressed(保留原 truncated,语义不混淆)
assert_eq!(
mgr.messages_mut()[0].message.status.as_ref(),
Some(&MessageStatus::Truncated),
"已 truncated 不应被改写为 compressed"
);
}
#[test]
fn has_compressible_messages_respects_protect_zone() {
let mut mgr = ContextManager::new(cfg(100_000));
for i in 0..8 {
mgr.push(ChatMessage::user(&format!("消息 {}", i)));
}
// protect_start=6 → [0,6) 内有 active → true
assert!(mgr.has_compressible_messages(6));
// protect_start=0 → 空范围 → false
assert!(!mgr.has_compressible_messages(0));
// 全部压缩后 → false
mgr.compress_old_messages(6);
assert!(!mgr.has_compressible_messages(6), "全 compressed 后不应有可压缩消息");
}
#[test]
fn is_compressing_flag_round_trip() {
// 标志位读写 round-trip;clear() 复位。
let mut mgr = ContextManager::new(cfg(100_000));
assert!(!mgr.is_compressing(), "默认 false");
mgr.set_compressing(true);
assert!(mgr.is_compressing(), "set true 后应读到 true");
mgr.set_compressing(false);
assert!(!mgr.is_compressing(), "set false 后复位");
// clear 复位
mgr.set_compressing(true);
mgr.clear();
assert!(!mgr.is_compressing(), "clear() 应复位 is_compressing");
}
#[test]
fn insert_at_adds_to_budget_when_active() {
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("a"));
let tokens_before = mgr.history_tokens();
// 插入 active system 消息 → 计入 token
mgr.insert_at(0, ChatMessage::system("## 摘要"));
assert!(mgr.history_tokens() > tokens_before, "active 消息应计入 token");
assert_eq!(mgr.len(), 2);
assert_eq!(mgr.messages_mut()[0].message.content, "## 摘要");
// 插入 !active 消息 → 不计入 token
let tokens_before_inactive = mgr.history_tokens();
let mut inactive = ChatMessage::user("x");
inactive.status = Some(MessageStatus::Truncated);
mgr.insert_at(0, inactive);
assert_eq!(
mgr.history_tokens(),
tokens_before_inactive,
"!active 消息插入不应计 token"
);
}
#[test]
fn build_eviction_units_keeps_triplet_atomic_public() {
// 公开的 build_eviction_units:三元组(Head + Tail + Standalone Assistant)应落同一单元。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("前置"));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("c1", "fn", "{}")],
));
mgr.push(ChatMessage::tool_result("c1", "结果"));
mgr.push(ChatMessage::assistant("完成"));
mgr.push(ChatMessage::user("后置"));
// protect_start=5(全部纳入)
let units = mgr.build_eviction_units(5);
// 第一个单元是前置 Standalone(end=1);第二个单元应包含三元组三件套 + 后置应分开
// 确认三元组的 Head+Tail+Assistant 在同一单元(end 跳过 3)
let unit2 = units.iter().find(|u| u.end >= 4).expect("应有跨三元组的单元");
assert!(
unit2.end >= 4,
"三元组三件套应在同一淘汰单元, end={}",
unit2.end
);
}
// ── F-260619-04 P1 消息级溯源:last_assistant/last_user message_id ──
#[test]
fn last_assistant_message_id_returns_latest() {
// 多条 assistant,反向扫描取末条 id(本轮 AI 产出的载体)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问1"));
let first = push_and_get_id(&mut mgr, ChatMessage::assistant("答1"));
mgr.push(ChatMessage::user("问2"));
let last = push_and_get_id(&mut mgr, ChatMessage::assistant("答2"));
// 末条 assistant id 应是 last(非 first)
assert_eq!(
mgr.last_assistant_message_id().as_deref(),
Some(last.as_str()),
"应取末条 assistant id, 而非首条"
);
assert_ne!(
mgr.last_assistant_message_id().as_deref(),
Some(first.as_str())
);
}
#[test]
fn last_user_message_id_returns_latest() {
// 多条 user,反向扫描取末条 id(触发本轮检索的 user)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问1"));
mgr.push(ChatMessage::assistant("答1"));
let last_user = push_and_get_id(&mut mgr, ChatMessage::user("问2"));
assert_eq!(
mgr.last_user_message_id().as_deref(),
Some(last_user.as_str())
);
}
#[test]
fn last_message_id_none_when_no_such_role() {
// 无 assistant → None;无 user → None
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("只有 user"));
assert!(
mgr.last_assistant_message_id().is_none(),
"无 assistant 消息应返 None"
);
let mut mgr2 = ContextManager::new(cfg(100_000));
mgr2.push(ChatMessage::assistant("只有 assistant"));
assert!(
mgr2.last_user_message_id().is_none(),
"无 user 消息应返 None"
);
}
#[test]
fn last_message_id_none_for_legacy_no_id() {
// 老数据反序列化消息 id=None → 返 None(向前兼容,溯源降级 conv: 旧格式)
let mut mgr = ContextManager::new(cfg(100_000));
let mut legacy = ChatMessage::assistant("老消息无 id");
legacy.id = None;
mgr.push(legacy);
assert!(
mgr.last_assistant_message_id().is_none(),
"老消息无 id 应返 None(向前兼容)"
);
}
/// helper:push 一条消息并返回其 id(测试用,确认取到的是该消息自身 id)
fn push_and_get_id(mgr: &mut ContextManager, message: ChatMessage) -> String {
let id = message.id.clone();
mgr.push(message);
id.expect("新构造消息必有 id")
}
// ── [P2 改进5] 主题切换检测(保守,双高置信才标) ──
#[test]
fn topic_marker_triggers_on_two_different_high_conf_topics() {
// 双高置信:两条 user 消息各自 intent 置信 >= 0.7 且不同 topic → 置位 marker。
// "帮我重构这段代码"(Code, conf=1.0) + "创建项目并绑定目录"(Project, conf=1.0)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("帮我重构这段代码"));
// 此时只有一条 user,无 marker
assert!(mgr.take_topic_marker().is_none(), "首条 user 不触发 marker");
mgr.push(ChatMessage::user("创建项目并绑定目录"));
let marker = mgr.take_topic_marker()
.expect("双不同 topic 应置位 marker");
assert!(marker.contains("code"), "old topic 应为 code, 实际: {}", marker);
assert!(marker.contains("project"), "new topic 应为 project, 实际: {}", marker);
// take 后清空(幂等)
assert!(mgr.take_topic_marker().is_none(), "take 后应清空");
}
#[test]
fn topic_marker_not_triggered_on_same_topic() {
// 同 topic(code/code)→ 不置位(非切换)。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("重构这段代码"));
mgr.push(ChatMessage::user("再重构另一段代码"));
assert!(mgr.take_topic_marker().is_none(), "同 topic 不应触发 marker");
}
#[test]
fn topic_marker_not_triggered_when_either_topic_none() {
// 保守:任一 topic 为 None(低置信未标)不置位。
// "今天的天气不错"(Unknown, conf=0.0 → topic=None) + "重构代码"(Code, conf 高)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("今天的天气不错啊"));
mgr.push(ChatMessage::user("帮我重构这段代码"));
assert!(mgr.take_topic_marker().is_none(), "前一条 topic None 不应触发 marker(保守)");
// 反向:前一条高置信 + 后一条低置信
let mut mgr2 = ContextManager::new(cfg(100_000));
mgr2.push(ChatMessage::user("帮我重构这段代码"));
mgr2.push(ChatMessage::user("嗯嗯好的"));
assert!(mgr2.take_topic_marker().is_none(), "后一条 topic None 不应触发 marker(保守)");
}
#[test]
fn topic_marker_not_polluting_compression_or_trim() {
// topic 字段不参与裁剪/压缩(只检测):超预算裁剪 + 压缩后消息仍正常,
// topic 标记独立工作。push 足量消息撑爆小预算触发裁剪。
let mut mgr = ContextManager::new(cfg(80));
for i in 0..10 {
mgr.push(ChatMessage::user(&format!("重构代码第 {} 条长消息撑爆预算", i)));
}
mgr.push(ChatMessage::user("创建项目并绑定目录")); // Project topic → marker
let _ = mgr.take_topic_marker(); // 消费 marker(topic 不影响裁剪)
// 裁剪仍正常工作(topic 字段不参与裁剪逻辑)
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(trimmed);
assert!(!msgs.is_empty());
}
#[test]
fn topic_field_default_none_in_constructors() {
// TrackedMessage.topic 在 push/insert_at 构造时默认 None(push 会按 intent 推断覆盖,
// insert_at 始终 None——insert_at 不做主题推断,保守)。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.insert_at(0, ChatMessage::system("摘要"));
// insert_at 的 system 消息 topic=None(role 非 User)
assert_eq!(mgr.messages_mut()[0].topic, None);
}
// ===== 苁刻测:主题切换检测对抗/边界/链式 =====
#[test]
fn topic_marker_chained_three_switches_each_triggers() {
// 对抗(链式):A→B→C 三次连续主题切换,每次 push 不同 topic 都应置位 marker。
// 验证 last_user_topic 反向扫描取最近 user,链式切换逐次触发不漏。
let mut mgr = ContextManager::new(cfg(100_000));
// 1. Code
mgr.push(ChatMessage::user("帮我重构这段代码")); // topic=code
assert!(mgr.take_topic_marker().is_none(), "首条不触发");
// 2. Code → Project(切换)
mgr.push(ChatMessage::user("创建项目并绑定目录")); // topic=project
let m1 = mgr.take_topic_marker().expect("code→project 应触发");
assert!(m1.contains("code") && m1.contains("project"), "marker1: {}", m1);
assert!(mgr.take_topic_marker().is_none(), "take 后清空");
// 3. Project → Task(切换)
mgr.push(ChatMessage::user("推进这个任务到下一状态")); // topic=task
let m2 = mgr.take_topic_marker().expect("project→task 应触发");
assert!(m2.contains("project") && m2.contains("task"), "marker2: {}", m2);
// 4. Task → Http(切换,证链式不因中间穿插断)
mgr.push(ChatMessage::user("调用接口请求这个 api")); // topic=http
let m3 = mgr.take_topic_marker().expect("task→http 应触发");
assert!(m3.contains("task") && m3.contains("http"), "marker3: {}", m3);
}
#[test]
fn topic_marker_low_confidence_chain_never_triggers() {
// 对抗(低置信链):连续 push 低置信(Unknown)消息 → topic 全 None → 永不置位
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("嗯嗯好的")); // Unknown/0.0 → None
assert!(mgr.take_topic_marker().is_none());
mgr.push(ChatMessage::user("啊这样啊")); // Unknown/0.0 → None
assert!(mgr.take_topic_marker().is_none(), "双 None 链不触发");
// 再接一条高置信,但前一条 None → 仍不触发(保守:任一 None 即不标)
mgr.push(ChatMessage::user("重构这段代码")); // Code/1.0
assert!(
mgr.take_topic_marker().is_none(),
"前一条 topic None,即使本条高置信也不触发(双高置信约束)"
);
}
#[test]
fn topic_marker_high_then_low_confidence_never_triggers() {
// 对抗(反向低置信链):高置信 → 低置信,后一条 None → 不触发
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("重构这段代码")); // Code/1.0
assert!(mgr.take_topic_marker().is_none());
mgr.push(ChatMessage::user("嗯嗯好的")); // Unknown/0.0 → None
assert!(
mgr.take_topic_marker().is_none(),
"后一条 topic None,即使前一条高置信也不触发"
);
}
#[test]
fn topic_marker_switch_back_to_same_after_different_not_triggered() {
// 边界:A→B→A:第二次 A 时,末两条 user 是 B(高)→ A(高),不同 → 应触发。
// 验证 last_user_topic 只看最近一条 user,不缓存历史(不会因"曾标过 code"漏判)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("重构这段代码")); // code
mgr.push(ChatMessage::user("创建项目并绑定目录")); // project → 触发 code|project
let _ = mgr.take_topic_marker();
mgr.push(ChatMessage::user("再重构另一段代码")); // code(末两条:project→code 不同)
let m = mgr.take_topic_marker().expect("project→code 切回应触发");
assert!(m.contains("project") && m.contains("code"), "切回原 topic 也应触发: {}", m);
}
#[test]
fn topic_field_survives_compress_old_messages() {
// 对抗(topic 跨 compress):compress_old_messages 只改 status,不动 topic 字段。
// 压缩后 compressed 消息的 topic 标签保留(向前兼容,DB/内存一致性)。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("重构这段代码")); // idx 0, topic=code
mgr.push(ChatMessage::user("读取这个文件")); // idx 1, topic=file
mgr.push(ChatMessage::assistant("ok")); // idx 2
// 压缩 [0,2):idx 0/1 标 compressed
let compressed = mgr.compress_old_messages(2);
assert_eq!(compressed.len(), 2);
// topic 字段保留(compress 不触碰)
assert_eq!(mgr.messages_mut()[0].topic.as_deref(), Some("code"), "compressed 消息 topic 应保留");
assert_eq!(mgr.messages_mut()[1].topic.as_deref(), Some("file"), "compressed 消息 topic 应保留");
// status 改为 compressed
assert_eq!(mgr.messages_mut()[0].message.status.as_ref(), Some(&MessageStatus::Compressed));
}
#[test]
fn topic_marker_system_message_in_send_view_under_adequate_budget() {
// 边界(marker 端到端):取 marker → 调用方 insert_at 顶 system 标记 →
// build_for_request 在充足预算下 system 正常进发送视图(不丢,不 panic)。
// 注:build_for_request 裁剪策略裁前部(保护最近 PROTECT_COUNT 条),顶置 system
// 在极小预算撑爆时会被裁——这是策略既定行为,非 bug。本测验证充足预算下 marker
// 流程端到端正常:marker 取出 + insert system + 发送视图含该 system。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("重构这段代码")); // code
mgr.push(ChatMessage::user("创建项目并绑定目录")); // project → marker
let marker = mgr.take_topic_marker().expect("应触发 marker");
assert!(marker.contains("code") && marker.contains("project"));
// 调用方据 marker insert system 标记(agentic loop 实际行为)
mgr.insert_at(0, ChatMessage::system("[主题切换标记]"));
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(!trimmed, "充足预算不应裁剪");
assert!(
msgs.iter().any(|m| m.content.contains("[主题切换标记]")),
"marker system 应进发送视图: {:?}",
msgs.iter().map(|m| m.content.chars().take(15).collect::<String>()).collect::<Vec<_>>()
);
}
#[test]
fn topic_marker_not_triggered_by_assistant_or_tool_messages() {
// 边界:只有 user 消息参与主题推断;assistant/tool push 不触发 marker(topic=None)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("重构这段代码")); // code
assert!(mgr.take_topic_marker().is_none());
// assistant 消息(含 error 关键词但不影响 topic 推断,因 role 非 User)
mgr.push(ChatMessage::assistant("编译 error 出现了"));
assert!(
mgr.take_topic_marker().is_none(),
"assistant 消息不参与主题推断,不触发 marker"
);
// 再 push user(同 code topic)→ 末两条 user 都是 code,不切换
mgr.push(ChatMessage::user("继续重构"));
assert!(mgr.take_topic_marker().is_none(), "同 topic user 间不切换(assistant 不算)");
// 此时末条 user 是 code,切到 project 应触发
mgr.push(ChatMessage::user("创建项目并绑定目录"));
assert!(mgr.take_topic_marker().is_some(), "code→project 切换应触发(assistant 不打断)");
}
#[test]
fn topic_marker_take_is_idempotent_and_clears() {
// 边界:take_topic_marker 一次性消费,take 两次第二次必 None(防重复 insert)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("重构这段代码"));
mgr.push(ChatMessage::user("创建项目并绑定目录"));
let _ = mgr.take_topic_marker().expect("应有 marker");
// 第二次 take 必 None
assert!(mgr.take_topic_marker().is_none(), "marker 一次性消费, 二次 take 必空");
}

View File

@@ -0,0 +1,538 @@
//! 上下文管理器 — 管理对话上下文和 token 预算
//!
//! 职责:
//! - 维护消息历史及其 token 计数缓存
//! - 提供预算感知的消息裁剪(保护工具调用三元组)
//! - 为 run_agentic_loop 提供受控的消息视图
//!
//! 裁剪策略与模型选择是正交维度:本模块只管「窗口多大、怎么裁」,
//! 用哪个 model / 是否启用 reasoning 由调用方在 CompletionRequest 层决定。
//!
//! 纯函数 / 数据类型 / 常量TokenEstimator / ContextConfig / MessageGroup /
//! TrackedMessage / EvictionUnit / classify_group / PROTECT_COUNT /
//! TOOL_MISSING_PREFIX已抽至 [`crate::context_helpers`],本模块 `use` 复用,
//! 并 `pub use` 重导出以保持 `df_ai::context::*` 历史路径对外可见(零调用方变更)。
//!
//! # 子模块
//! - [`sanitize`]:畸形配对自愈(`sanitize_messages` / `drop_reverse_orphans` /
//! `assert_placeholder_pairing` / `ensure_sequence_legal`)及其单测。
//! 实现为模块级 `pub fn`,本模块通过 `ContextManager` 上的关联函数薄包装转发,
//! 保持 `ContextManager::sanitize_messages(...)` 调用路径不变(零调用方变更)。
mod sanitize;
use crate::context_helpers::{
classify_group, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT,
};
// 重导出:保持 `df_ai::context::TokenEstimator` / `df_ai::context::ContextConfig` 等
// 历史路径对外可见agentic.rs / commands/ai/mod.rs 等调用方零变更)。
// `pub use` 同时把类型带入本模块命名空间,供 ContextManager 结构体字段与 impl 直接引用。
pub use crate::context_helpers::{
EvictionUnit, ContextConfig, MessageGroup, TokenEstimator, TrackedMessage,
};
use crate::provider::{ChatMessage, MessageRole, MessageStatus};
// ============================================================
// 上下文管理器
// ============================================================
/// 上下文管理器
///
/// 唯一的消息真相来源(替代原来的 `Vec<ChatMessage>`)。
/// 裁剪仅影响发送视图(`build_for_request`),不影响持久化(`all_messages_clone`)。
pub struct ContextManager {
messages: Vec<TrackedMessage>,
/// 当前历史总 token 数(不含 system prompt
history_tokens: u32,
config: ContextConfig,
estimator: TokenEstimator,
/// 压缩重入标志F-15 §4.3true 表示一次 LLM 压缩正在进行中。
/// agentic loop 顶部检测,防同一轮内多次触发压缩互相覆盖。纯内存态,不落库。
is_compressing: bool,
/// [P2 改进5] 主题切换检测标记。push user 消息时若发现末两条 user 消息的 topic
/// 都非 None 且不同(双高置信),置位本字段,格式 "old|new"。agentic loop 顶部读并
/// 消费(insert 系统标记后清空)。纯内存态,不落库。保守:任一 topic 为 None 不置位(宁可漏报)。
pending_topic_marker: Option<String>,
}
impl ContextManager {
pub fn new(config: ContextConfig) -> Self {
Self {
messages: Vec::new(),
history_tokens: 0,
config,
estimator: TokenEstimator::default(),
is_compressing: false,
pending_topic_marker: None,
}
}
/// 追加消息(自动计算 token 并更新缓存)
///
/// 不在此处淘汰——push 可能发生在 agentic loop 中间(追加 tool_result
/// 此时不应裁剪正在使用的活跃消息。裁剪在 `build_for_request` 时统一处理。
pub fn push(&mut self, message: ChatMessage) {
let tokens = self.estimator.estimate_message(&message);
let group = classify_group(&message);
// 仅 active 消息计入 token 预算F-15 §3.3truncated / archived_segment /
// compressed 不进 LLM 上下文token 虚高会致 build_for_request 误判超预算
// 触发不必要裁剪。!active 消息仍 push 到 self.messages 全量保留(持久化不受影响),
// sanitize_messages step0(is_active 过滤)在发送视图统一剔除。
if message.is_active() {
self.history_tokens += tokens;
}
// [P2 改进5] 主题推断(仅 user 消息):IntentRecognizer 识别意图,置信 >= 0.7 且
// 非 Unknown 则把 Intent 标签存入 TrackedMessage.topic 供主题切换检测。
// 保守:低置信(None)不标,避免误报。topic 不参与裁剪/压缩(只供 marker 检测)。
let topic: Option<String> = if matches!(message.role, MessageRole::User) {
let (intent, conf) = crate::intent::IntentRecognizer::recognize(&message.content);
if conf >= 0.7 && !matches!(intent, crate::intent::Intent::Unknown) {
Some(intent.as_str().to_string())
} else {
None
}
} else {
None
};
// [P2 改进5] 主题切换检测:push 前(本消息即将成末条 user),若已存在一条更早 user
// 且本消息 topic 与之都非 None 且不同 → 置位 pending_topic_marker("old|new")。
// 双高置信(两条 topic 都非 None)才标,任一 None 不标(宁可漏报不误报)。
if matches!(message.role, MessageRole::User) {
if let Some(prev_topic) = self.last_user_topic() {
if let Some(this_topic) = &topic {
if prev_topic != *this_topic {
self.pending_topic_marker =
Some(format!("{}|{}", prev_topic, this_topic));
}
}
}
}
self.messages.push(TrackedMessage {
message,
token_count: tokens,
group,
topic,
});
}
/// 清空所有消息
pub fn clear(&mut self) {
self.messages.clear();
self.history_tokens = 0;
self.is_compressing = false;
self.pending_topic_marker = None;
}
/// 消息数量
pub fn len(&self) -> usize {
self.messages.len()
}
pub fn is_empty(&self) -> bool {
self.messages.is_empty()
}
/// 当前历史占用的 token 数(不含 system prompt
pub fn history_tokens(&self) -> u32 {
self.history_tokens
}
/// 预算上限
pub fn budget_limit(&self) -> u32 {
self.config.budget_limit()
}
// ── 核心:构建请求消息(受控裁剪版本)──
/// 构建发送给 LLM 的消息列表
///
/// `sys_tokens` 为调用方已估算好的 system prompt token 数。
/// 超预算时自动裁剪旧消息(保护工具调用三元组 + 最近 PROTECT_COUNT 条)。
/// 返回 (消息列表, 是否发生了裁剪)。
pub fn build_for_request(&self, sys_tokens: u32) -> (Vec<ChatMessage>, bool) {
let budget = self.budget_limit();
let available = budget.saturating_sub(sys_tokens);
// system prompt 自身超预算:裁剪无法缓解(仍返回保护区兜底),warn 便于诊断
if sys_tokens > budget {
tracing::warn!(
"system prompt (~{} tokens) 超过上下文预算 ({}),裁剪无法缓解",
sys_tokens, budget
);
}
// 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环)
if self.history_tokens <= available {
let sanitized = Self::sanitize_messages(self.all_messages_clone());
// 阶段2 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
return (
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
false,
);
}
// 超预算 → 视图裁剪(不修改 self.messages保证 all_messages_clone 仍返回全量)
let protect_start = self.messages.len().saturating_sub(PROTECT_COUNT);
let units = self.build_eviction_units(protect_start);
let mut removed: u64 = 0;
let mut trim_end = 0;
for unit in &units {
if self.history_tokens.saturating_sub(removed as u32) <= available {
break;
}
removed += unit.token_sum as u64;
trim_end = unit.end;
}
if trim_end == 0 {
tracing::warn!(
"history (~{} tokens) 超预算 ({}) 但无可淘汰单元(全在保护区 {} 条),发送兜底可能触发 provider 超限",
self.history_tokens, available, PROTECT_COUNT
);
// B-260626-01: 兜底全量也过 sanitize(对齐分支 1/3),防绕过序列修复直送 provider
// 触发"首条 assistant 非法"/orphan/连续 role。原裸返 all_messages_clone 不过滤
// truncated/中毒三元组/首条非法——是主 loop 唯一的 sanitize 漏洞(大体量 tool_result
// 致超预算且保护区满时命中)。异常会话(开头连续 assistant/tool 无 user)经
// ensure_sequence_legal 清空后,由协议层 ensure_leading_user 补 user 占位降级,不阻塞。
// view-only:不改 self.messages 持久化(与分支 1/3 一致)。
let sanitized = Self::sanitize_messages(self.all_messages_clone());
return (
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
false,
);
}
let msgs: Vec<ChatMessage> = self.messages[trim_end..]
.iter()
.map(|t| t.message.clone())
.collect();
tracing::info!(
"context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
trim_end, removed
);
let sanitized = Self::sanitize_messages(msgs);
// 阶段2 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
(
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
true,
)
}
// ── 畸形配对自愈(转发至 [`sanitize`] 子模块,保持 ContextManager::xxx 调用路径)──
/// 畸形配对自愈 — 转发到 [`sanitize::sanitize_messages`]。
///
/// 保留为 `ContextManager` 关联函数以兼容历史调用路径(`Self::sanitize_messages` /
/// `ContextManager::sanitize_messages`),实现见子模块文档。
pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
sanitize::sanitize_messages(messages)
}
/// 发送视图出口断言 — 转发到 [`sanitize::assert_placeholder_pairing`]。
pub fn assert_placeholder_pairing(
messages: Vec<ChatMessage>,
enabled: bool,
) -> Vec<ChatMessage> {
sanitize::assert_placeholder_pairing(messages, enabled)
}
/// 全量克隆(持久化 save_conversation / build_for_request 未裁剪分支,不受裁剪影响)
pub fn all_messages_clone(&self) -> Vec<ChatMessage> {
self.messages.iter().map(|t| t.message.clone()).collect()
}
/// 从 Vec 恢复(兼容从 DB 加载)
pub fn restore_from_messages(&mut self, messages: Vec<ChatMessage>) {
self.clear();
for msg in messages {
self.push(msg);
}
}
/// 就地替换某条 tool_result 的内容(兼容审批 replace_tool_result
/// 返回 true 如果找到并替换了
///
/// 反向遍历tool_result 由 append 进入历史,被替换的通常是最近的审批占位,
/// 从尾部查找命中即停,避免对长历史做正向 O(n) 累积扫描。
pub fn replace_tool_result_content(&mut self, tool_call_id: &str, new_content: &str) -> bool {
let pos = self.messages.iter().rposition(|t| {
matches!(t.message.role, MessageRole::Tool)
&& t.message.tool_call_id.as_deref() == Some(tool_call_id)
});
let Some(i) = pos else { return false };
// 先更新 content再重估 token 并校正总量
let old_tokens = self.messages[i].token_count;
self.messages[i].message.content = new_content.to_string();
let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
self.messages[i].token_count = new_tokens;
self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
true
}
/// 弹出末尾连续的 assistant 消息(含其 tool_calls 三元组尾随 tool_result
///
/// 用于「重新生成」(UX-02):删掉最后一条 AI 回复(可能跨多轮 tool_calls + tool_results
/// 紧随其后),保留触发它的 user 消息,以便重跑 agentic loop 再生成。
///
/// 语义:从末尾向前弹出,直到弹出至少一条 assistant 消息;若弹出 assistant 后紧邻的更早
/// 消息仍是 assistant/tool(同一轮多块),继续一并弹出,确保不留半截三元组污染下轮。
/// user 消息作为停止边界(不弹出 user),保证重生成时历史末尾是 user 消息。
pub fn pop_last_assistant_round(&mut self) -> bool {
if self.messages.is_empty() {
return false;
}
let mut popped_any = false;
// 从尾向前:先弹掉末尾非 user 的消息(assistant / tool),直到遇到 user 或空
while let Some(last) = self.messages.last() {
if matches!(last.message.role, MessageRole::User) {
break;
}
let removed = self.messages.pop().expect("just checked non-empty");
self.history_tokens = self.history_tokens.saturating_sub(removed.token_count);
if matches!(removed.message.role, MessageRole::Assistant) {
popped_any = true;
}
}
popped_any
}
/// 编辑某条 user 消息后,将其后所有消息标记为 truncatedUX-09 编辑重生成)。
///
/// 软删语义:保留在内存真相源 + DB可追溯但 sanitize_messages 过滤后不进 LLM 上下文,
/// 前端按 is_active 过滤从视图移除。返回被标 truncated 的条数0 表示该 user 消息已是末尾,无后续)。
///
/// `target_content` 为该 user 消息的预期内容(用于反向唯一定位:末条 user 消息可能内容相同,
/// 故从尾部向前找第一条 role=User 且 content 匹配且仍 active 的消息)。
/// 找不到返回 Err(()),调用方据此报错。
pub fn truncate_after_user_message(&mut self, target_content: &str) -> Result<usize, ()> {
// 反向找末条 active user 消息且 content 匹配
let pos = self.messages.iter().rposition(|t| {
matches!(t.message.role, MessageRole::User)
&& t.message.content == target_content
&& t.message.is_active()
});
let Some(i) = pos else { return Err(()) };
// i 之后的全部标 truncated(已 truncated 的跳过,只统计本次新标的)
let mut count = 0usize;
for t in self.messages[i + 1..].iter_mut() {
if t.message.is_active() {
t.message.status = Some(MessageStatus::Truncated);
count += 1;
}
}
Ok(count)
}
/// 替换末条 active user 消息的 contentUX-09 编辑重生成)。
///
/// 编辑语义:只能编辑最后一条 user 消息(中间编辑语义复杂,拒绝)。返回 Err(()) 表示无 active user 消息。
/// 成功后调用方应紧接着 truncate_after_user_message(new_content) 软删其后续消息。
pub fn replace_last_active_user_content(&mut self, new_content: &str) -> Result<(), ()> {
let pos = self.messages.iter().rposition(|t| {
matches!(t.message.role, MessageRole::User) && t.message.is_active()
});
let Some(i) = pos else { return Err(()) };
let old_tokens = self.messages[i].token_count;
self.messages[i].message.content = new_content.to_string();
let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
self.messages[i].token_count = new_tokens;
self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
Ok(())
}
/// 只读迭代(兼容 ensure_conversation_title 的 .iter().filter() 等)
pub fn iter(&self) -> impl Iterator<Item = &ChatMessage> {
self.messages.iter().map(|t| &t.message)
}
/// F-260619-04 P1 消息级溯源:取末条指定 role 消息的 id(ULID)。
///
/// 从尾部反向扫描(末条消息命中即停,避免全量 O(n) 正扫累积),返回最近一条
/// `role` 匹配且 `id` 非空消息的 id。无匹配或老消息无 id → None(向前兼容:
/// 老反序列化消息 id=None,溯源写入降级为 None,展示侧兼容 `conv:` 旧格式)。
///
/// 用途:
/// - `MessageRole::Assistant`:audit/知识提炼写入时取当前 assistant 消息 id
/// (LLM 返回带 tool_calls 的 assistant 已 push,process_tool_calls 入口取)
/// - `MessageRole::User`:知识注入 referenced 事件溯源取触发检索的 user 消息 id
fn last_message_id_by_role(&self, role: MessageRole) -> Option<String> {
self.messages
.iter()
.rev()
.find(|t| {
std::mem::discriminant(&t.message.role) == std::mem::discriminant(&role)
})
.and_then(|t| t.message.id.clone())
}
/// 末条 assistant 消息的 id(消息级溯源用)。
pub fn last_assistant_message_id(&self) -> Option<String> {
self.last_message_id_by_role(MessageRole::Assistant)
}
/// 末条 user 消息的 id(消息级溯源用)。
pub fn last_user_message_id(&self) -> Option<String> {
self.last_message_id_by_role(MessageRole::User)
}
// ── [P2 改进5] 主题切换检测(保守,双高置信才标) ──
/// 取末条 user 消息的 topic 标签(供 push 时主题切换检测)。
///
/// 从尾部反向扫描 user 消息,取最近一条 role=User 的 TrackedMessage.topic。
/// 老消息(未接改进5 推断)topic=None → 返 None(向前兼容)。无 user 消息 → None。
fn last_user_topic(&self) -> Option<String> {
self.messages
.iter()
.rev()
.find(|t| matches!(t.message.role, MessageRole::User))
.and_then(|t| t.topic.clone())
}
/// 取并消费 pending_topic_marker(供 agentic loop 顶部读 → insert 系统标记 → 清空)。
///
/// 返回 "old|new" 格式字符串(push 时末两条 user topic 都非 None 且不同置位)。
/// 取出即清空(一次性消费,防同 marker 重复 insert)。无 marker → None。
pub fn take_topic_marker(&mut self) -> Option<String> {
self.pending_topic_marker.take()
}
// ── F-15 上下文管理增强辅助方法(压缩链路核心 API──
//
// 已全量接入压缩链路:
// - agentic/mod.rs 自动压缩(trigger 判定 + LLM/关键词兜底 + 重入保护)
// set_compressing/is_compressing(重入标志) / has_compressible_messages(触发判定)
// messages_mut(取 active 喂 LLM) / compress_old_messages(标 compressed 扣 token)
// insert_at(摘要/续接锚点 system 消息插入首位)
// - commands/ai/compress.rs 与 commands/ai/commands/chat.rs 走 IPC 压缩入口
// - build_eviction_units(下方)供会话分段与压缩定位共用同一分组逻辑。
/// 配置(只读视图,供 agentic.rs 计算压缩触发阈值 `config().budget_limit()`
pub fn config(&self) -> &ContextConfig {
&self.config
}
/// 可变消息切片供阶段2 标记 status="compressed"/"archived_segment" + 调整 token
///
/// 调用方约定:仅改 `message.status` / `message.content`,不增删条目(增删走
/// [`push`] / [`insert_at`]),否则 `history_tokens` 会与实际脱钩。
pub fn messages_mut(&mut self) -> &mut [TrackedMessage] {
&mut self.messages
}
/// 在给定位置插入一条消息(其余向后移),并把它计入 token 预算(active 才计)。
///
/// 供阶段2 在压缩点插入摘要 system 消息。`index` 越界则 panic(对齐 Vec::insert 语义,
/// 调用方负责算合法 index,如 `compress_end` 已由 `compress_old_messages` 校验)。
pub fn insert_at(&mut self, index: usize, message: ChatMessage) {
let tokens = self.estimator.estimate_message(&message);
let group = classify_group(&message);
if message.is_active() {
self.history_tokens += tokens;
}
self.messages.insert(index, TrackedMessage {
message,
token_count: tokens,
group,
topic: None,
});
}
/// 按淘汰单元分组消息范围(三元组原子性),供压缩定位/分段标记复用同一分组逻辑。
///
/// 返回每个单元的右开区间 end + token 总和,保证:
/// - 工具调用三元组(Head + Tail* + 紧随的 Standalone Assistant)在同一单元
/// - 保护区 `[protect_start, len)` 内的消息不纳入任何单元
///
/// 公开供阶段2 会话分段(`archived_segment` 按组原子标记)与压缩定位共用。
pub fn build_eviction_units(&self, protect_start: usize) -> Vec<EvictionUnit> {
let mut units = Vec::new();
let mut i = 0usize;
while i < protect_start {
let mut token_sum = 0u32;
if self.messages[i].group == MessageGroup::ToolCallHead {
token_sum += self.messages[i].token_count;
i += 1;
while i < protect_start && self.messages[i].group == MessageGroup::ToolResultTail {
token_sum += self.messages[i].token_count;
i += 1;
}
if i < protect_start
&& self.messages[i].group == MessageGroup::Standalone
&& matches!(self.messages[i].message.role, MessageRole::Assistant)
{
token_sum += self.messages[i].token_count;
i += 1;
}
} else {
token_sum += self.messages[i].token_count;
i += 1;
}
units.push(EvictionUnit { end: i, token_sum });
}
units
}
/// 保护区外是否存在可压缩消息(供 agentic loop 顶部触发判断)。
///
/// "可压缩"= status 为 None/active 的消息(已 compressed/archived_segment/truncated
/// 不参与二次压缩,幂等)。`protect_start` 为保护区起点(如 `len - PROTECT_COUNT`)。
pub fn has_compressible_messages(&self, protect_start: usize) -> bool {
let end = protect_start.min(self.messages.len());
// BUG-260624-05:排除 system 角色(压缩摘要 / 话题切换锚点)。这些是上下文锚点非压缩目标——
// 若计入,压缩摘要 insert_at(0) 落在可压缩区 [0..protect_start) 且 is_active(status=None),
// 致每轮 has_compressible 恒 true → 无限循环压缩(用户报"压缩后每轮提示已压缩并停止")。
// compress_old_messages 不改:被调用时仍标旧 system 摘要 compressed(被新摘要替代,防堆积)。
self.messages[..end]
.iter()
.any(|t| t.message.is_active() && !matches!(t.message.role, MessageRole::System))
}
/// 把保护区 `[0, compress_end)` 范围内的 active 消息标记为 `status="compressed"`,
/// 同步从 `history_tokens` 扣除其 token,返回被压缩消息的克隆(供阶段2 喂 LLM 摘要)。
///
/// **幂等**:已 compressed(或任何 !active)的消息跳过,不会被二次压缩;`history_tokens`
/// 也只扣首次标记的 token。返回的 Vec 仅含**本次新标记**的消息(已 compressed 的不返)。
///
/// **单向不可逆**:压缩后 DB 原始消息保留,但 LLM 上下文里被 is_active 白名单隔离
/// (sanitize step0 过滤)。`compress_end` 越界自动 clamp 到 `messages.len()`。
///
/// 返回空 Vec 表示本批次无可压缩消息(全部已 compressed 或范围空),调用方据此跳过 LLM 调用。
pub fn compress_old_messages(&mut self, compress_end: usize) -> Vec<ChatMessage> {
let end = compress_end.min(self.messages.len());
let mut newly_compressed = Vec::new();
for t in self.messages[..end].iter_mut() {
if t.message.is_active() {
newly_compressed.push(t.message.clone());
t.message.status = Some(MessageStatus::Compressed);
self.history_tokens = self.history_tokens.saturating_sub(t.token_count);
}
}
newly_compressed
}
/// 压缩重入标志(读)。true 表示一次 LLM 压缩正在进行中,触发方应跳过本轮压缩。
pub fn is_compressing(&self) -> bool {
self.is_compressing
}
/// 压缩重入标志(写)。`true`=开始压缩(进入 agentic loop 顶部前置置位),
/// `false`=压缩结束(无论成功或降级)。调用方必须成对调用,防止永久卡死。
pub fn set_compressing(&mut self, v: bool) {
self.is_compressing = v;
}
// build_eviction_units / classify_group 等已在上方公开或文件级定义。
}
#[cfg(test)]
mod manager_tests;

View File

@@ -0,0 +1,761 @@
//! 畸形配对自愈 — 过滤掉会导致 provider 400/500 的中毒历史。
//!
//! 本模块为 `context::ContextManager` 的 sanitize 链路实现层,由 `mod.rs` 中
//! `ContextManager` 的关联函数薄包装转发调用(保持 `ContextManager::sanitize_messages`
//! 等历史调用路径不变)。
//!
//! # 函数概览
//! - [`sanitize_messages`]主入口step0 active 过滤 → step1/2/3 正向 orphan →
//! step3.5 反向 orphan → step4 序列合法性)。
//! - [`drop_reverse_orphans`]step3.5 反向 orphan 检测。
//! - [`assert_placeholder_pairing`]:发送视图出口断言(补占位头自愈)。
//! - [`ensure_sequence_legal`]step4 序列合法性修复(首条 user + 连续同 role 合并)。
use crate::context_helpers::{is_pending_placeholder, PLACEHOLDER_INTEGRITY_ENABLED, TOOL_MISSING_PREFIX};
use crate::provider::{ChatMessage, MessageRole, ToolCall};
/// 畸形配对自愈 — 过滤掉会导致 provider 500 的中毒历史
///
/// 根因Anthropic 流式 `tool_use` 块缺 id 时anthropic_compat.rs 170-176
/// 生成 `tool_missing_{idx}` 占位 id对应的 `tool_result` 一旦推入历史,
/// 下次 `build_for_request` 把畸形三元组原样回传 → 服务端 500 → stream_recv
/// 不清历史 → agentic 重发 → 永久卡死。此处仅在「发送视图」剔除畸形配对,
/// 不改持久化self.messages 全量保留),让卡死的会话能自愈继续。
///
/// provider 协议铁律assistant 头里每个 tool_call.id 都必须在后续有对应的
/// tool_result否则服务端 400/500。本函数按此自愈分三档处理每个头
///
/// - 全闭合:所有 tool_call.id 都有匹配 tool_result → 原样保留。
/// - 全未闭合(含占位 id 必然无匹配):整头丢弃,其无主 tool_result 一并丢弃。
/// - 部分闭合:保留头但只留已闭合的 tool_call丢弃未闭合的 tool_call 及其
/// 无主 tool_result「不能整条删」——保住已发生的合法工具交互历史
///
/// 单次遍历、保序过滤、不重排user/assistant/tool 角色交替不被打乱)。
pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
use std::collections::HashSet;
// step 0UX-09过滤 truncated 软删消息,不进 LLM 上下文。
// 编辑某条 user 消息后其后续消息标 truncated保留 DB 可追溯),发送视图必须剔除,
// 否则被编辑前的旧回复仍进入 LLM 历史,污染重生成语义。落库全量保留不受影响。
let messages: Vec<ChatMessage> = messages
.into_iter()
.filter(|m| m.is_active())
.collect();
// step 1已闭合的 tool_call_id 集合role=Tool 消息全部提供过的 id
let resolved_ids: HashSet<&str> = messages
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.filter_map(|m| m.tool_call_id.as_deref())
.collect();
// step 2判定每个 assistant 头的闭合状态,产出需丢弃的 id 集合
// orphaned_ids未闭合的 tool_call.id含占位 id其 tool_result 要丢
// partial_heads部分闭合的头——保留但需重写 tool_calls只留已闭合的
// full_drop_heads全未闭合的头——整条丢弃
let mut orphaned_ids: HashSet<String> = HashSet::new();
let mut full_drop_heads = 0u32;
let mut partial_heads = 0u32;
// 记录部分闭合头中「应保留的 id」用于 step3 精确重写
let mut partial_keep: std::collections::HashMap<usize, Vec<ToolCall>> =
std::collections::HashMap::new();
for (i, m) in messages.iter().enumerate() {
if !matches!(m.role, MessageRole::Assistant) {
continue;
}
let Some(calls) = m.tool_calls.as_ref() else { continue };
if calls.is_empty() {
continue;
}
let (resolved, unresolved): (Vec<&ToolCall>, Vec<&ToolCall>) = calls
.iter()
.partition(|c| {
!c.id.starts_with(TOOL_MISSING_PREFIX)
&& resolved_ids.contains(c.id.as_str())
});
match (resolved.is_empty(), unresolved.is_empty()) {
// 全未闭合 → 整头丢弃,未闭合 id 入 orphaned_ids
(true, false) => {
full_drop_heads += 1;
for c in &unresolved {
orphaned_ids.insert(c.id.clone());
}
}
// 全闭合 → 原样保留
(false, true) => {}
// 部分闭合 → 保留头,只留 resolved 的 tool_callunresolved 入 orphaned_ids
(false, false) => {
partial_heads += 1;
for c in &unresolved {
orphaned_ids.insert(c.id.clone());
}
partial_keep.insert(i, resolved.into_iter().cloned().collect());
}
// resolved/unresolved 都空不可能calls 非空已保证)
(true, true) => {}
}
}
let after_triplet: Vec<ChatMessage> = if orphaned_ids.is_empty() && partial_keep.is_empty() {
messages
} else {
// step 3保序过滤 + 部分闭合头重写 tool_calls
let sanitized: Vec<ChatMessage> = messages
.into_iter()
.enumerate()
.filter_map(|(i, mut m)| match m.role {
MessageRole::Assistant => {
let Some(calls) = m.tool_calls.as_ref() else { return Some(m) };
if calls.is_empty() {
return Some(m);
}
if let Some(keep) = partial_keep.get(&i) {
// 部分闭合:重写 tool_calls 为仅已闭合子集
m.tool_calls = Some(keep.clone());
Some(m)
} else {
// 全闭合保留全未闭合id 全在 orphaned_ids丢弃
let all_orphaned =
calls.iter().all(|c| orphaned_ids.contains(&c.id));
if all_orphaned {
None
} else {
Some(m)
}
}
}
MessageRole::Tool => {
// 无主 tool_result其 id 命中 orphaned_ids丢弃其余保留
if m
.tool_call_id
.as_deref()
.is_some_and(|id| orphaned_ids.contains(id))
{
None
} else {
Some(m)
}
}
_ => Some(m),
})
.collect();
tracing::warn!(
full_drop_heads,
partial_rewrite_heads = partial_heads,
orphaned_tool_results = orphaned_ids.len(),
"history sanitized: dropped/rewrote malformed tool_call triplets (view-only, persisted history untouched)"
);
sanitized
};
// step 3.5(阶段2 占位配对完整性):反向 orphan 检测 —— tool_result 无对应 tool_call 头 → 丢。
//
// 根因(解 400 orphan):审批挂起占位 tool_result(内容 audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)
// 经 step3(正向 orphan:头无 result→丢头 + 其 result)或 build_eviction_units(预算裁剪从三元组
// 边界 trim)后,可能出现 tool_result 残留但其 tool_call 头已被丢弃 → orphan tool_result(无头)。
// deepseek-v4-pro 等端点对此严格校验 → 400。
//
// 检测:收集所有保留的 assistant 头的 tool_call.id 集合(head_ids),tool_result 的 id 不在
// head_ids 内即 orphan → 丢。与 step2/3 互补:step2/3 管"头丢 result",step3.5 管"result 丢头"。
//
// **占位保护**:带 PENDING_MARKER_PREFIX 标记的审批占位 tool_result,虽其头被丢,仍需保留——
// 占位语义是"等用户审批",LLM 需看到它才知道在等审批。故对占位 result 不做反向 orphan 丢弃,
// 改由出口断言(build_for_request 出口)自愈补头(见 assert_placeholder_pairing)。
// 老占位(无 __PENDING__ 标记,纯文本 LEGACY_PENDING_PLACEHOLDER_TEXT)同样豁免保留
// (is_pending_placeholder 精确等值匹配老占位全文),虽无 tc_id 无法强绑定补头,但保留后
// 出口断言仍能据其 tool_call_id 补占位头闭合三元组(向前兼容迁移期老数据)。
let after_reverse_orphan = if PLACEHOLDER_INTEGRITY_ENABLED {
drop_reverse_orphans(after_triplet)
} else {
after_triplet
};
// step 4序列合法性修复(首条 user + 连续同 role 合并),防 Anthropic/GLM 1214。
ensure_sequence_legal(after_reverse_orphan)
}
/// step 3.5:反向 orphan 检测(view-only)—— 丢弃无对应 tool_call 头的 tool_result。
///
/// 详见 [`sanitize_messages`] step3.5 注释。占位 result(带 PENDING_MARKER_PREFIX 标记)豁免
/// (保留,出口断言自愈补头),其余 tool_result 的 id 不在任何保留头 tool_calls 内 → 丢。
pub fn drop_reverse_orphans(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
// 收集所有保留 assistant 头的 tool_call.id(正向 orphan 处理后残留的头里的 id)
use std::collections::HashSet;
let head_ids: HashSet<String> = messages
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.filter_map(|m| m.tool_calls.as_ref())
.flatten()
.map(|c| c.id.clone())
.collect();
let mut dropped = 0u32;
let mut placeholder_kept = 0u32;
let filtered: Vec<ChatMessage> = messages
.into_iter()
.filter(|m| {
if !matches!(m.role, MessageRole::Tool) {
return true;
}
let Some(id) = m.tool_call_id.as_deref() else {
return true; // 无 id 的 tool_result(异常数据),不在此处处理
};
if head_ids.contains(id) {
return true; // 有对应头 → 保留
}
// 无对应头:占位(带标记)豁免保留(出口断言自愈补头);非占位 → 丢
if is_pending_placeholder(&m.content) {
placeholder_kept += 1;
true
} else {
dropped += 1;
false
}
})
.collect();
if dropped > 0 || placeholder_kept > 0 {
tracing::warn!(
dropped_reverse_orphan_tool_results = dropped,
placeholder_kept_without_head = placeholder_kept,
"history sanitized: dropped orphan tool_results without matching tool_call head (view-only, persisted history untouched)"
);
}
filtered
}
/// 发送视图出口断言(阶段2 占位配对完整性):确保所有 tool_result(含审批占位)都有
/// 对应 tool_call 头,失败降级 TOOL_MISSING_PREFIX 自愈。
///
/// **职责**:在消息即将发给 LLM 前(`build_for_request` 出口)最后一道防线:若仍有
/// orphan tool_result(sanitize step3.5 豁免保留的占位 result,其头在 step3/裁剪中被丢),
/// 则**就地补一个 tool_missing_ 占位头**塞在 tool_result 前,使其配对闭合。
///
/// **未接入出口**:`compress_via_llm`(src-tauri 侧 F-15 压缩)与 agentic loop 主循环也属
/// "发给 LLM 前"语义,但二者均在 src-tauri crate(非 df-ai),当前未调本函数——如需对压缩
/// 后消息同样补头,由调用方在拿到压缩结果后自行调本函数闭合(本 crate 不强加跨 crate 依赖)。
///
/// 自愈原理:占位头带 TOOL_MISSING_PREFIX id,该 id 必然无"真"匹配,但与紧跟的 orphan
/// tool_result(同 id)构成闭合三元组(头 + result 都在,id 一致)→ 满足 provider 协议
/// "每个 tool_call.id 必须有 tool_result"的铁律,不再 400。占位头的 id 用 tool_result 的
/// tool_call_id 复用(而非 tool_missing_{idx} 自增),保证与 result 精确配对。
///
/// **view-only**:本函数不改持久化,仅修改传入 Vec(clone),持久化全量保留。
/// 占位头补在 orphan result **正前方**(保序,角色交替 assistant→tool 合法),content 给空串
/// (LLM 看 assistant 头无文本内容但有 tool_calls 即理解"调了工具")。
///
/// `enabled` 参数由调用方传入(常量 flag),false 时直接原样返回(旧行为,flag 关回退)。
pub fn assert_placeholder_pairing(
messages: Vec<ChatMessage>,
enabled: bool,
) -> Vec<ChatMessage> {
if !enabled {
return messages;
}
// 收集现有头的 tool_call.id(已配对的)
use std::collections::HashSet;
let head_ids: HashSet<String> = messages
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.filter_map(|m| m.tool_calls.as_ref())
.flatten()
.map(|c| c.id.clone())
.collect();
// 找 orphan tool_result(id 不在 head_ids)。占位/非占位都需补头(否则 400)。
// 单次遍历产出新 Vec:orphan result 前插一个占位头(同 id),其余原样保留保序。
let mut healed = 0u32;
let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len());
for m in messages.into_iter() {
if matches!(m.role, MessageRole::Tool) {
if let Some(id) = m.tool_call_id.as_deref() {
if !head_ids.contains(id) && !id.starts_with(TOOL_MISSING_PREFIX) {
// orphan result → 前补占位头(TOOL_MISSING_PREFIX + 原 id,确保唯一且与 result 配对)
let head_id = format!("{}{}", TOOL_MISSING_PREFIX, id);
let head = ChatMessage::assistant_with_tools(
String::new(),
vec![ToolCall::new(head_id.clone(), "pending_approval_placeholder", "{}")],
);
out.push(head);
// 把 result 的 tool_call_id 也改写成占位头 id,使三者精确配对
let mut result = m;
result.tool_call_id = Some(head_id);
out.push(result);
healed += 1;
continue;
}
}
}
out.push(m);
}
if healed > 0 {
tracing::warn!(
healed_orphan_tool_results = healed,
"send-view exit: healed orphan tool_results by inserting TOOL_MISSING_PREFIX heads (view-only, persisted history untouched)"
);
// 加固(连续 assistant→400):补占位头可能插在普通 assistant 之后,产生连续 assistant
// (普通 assistant + 占位 assistant + tool_result)。Anthropic/GLM 协议对连续同 role
// 敏感,会 400/1214。ensure_sequence_legal 在 sanitize 末尾已跑过(早于本函数),无法捕获
// 本函数新插入的头。故补头后再过一次 ensure_sequence_legal:合并连续 assistant(其
// tool_calls 合并入前一头,占位头 id 与 result id 一致,合并后配对仍闭合) + 防补头
// 致首条变 assistant(理论上 orphan result 前必有 user/assistant,但防御性兜底)。
//
// view-only:out 是 clone,ensure_sequence_legal 仅改本 Vec,不改持久化。
return ensure_sequence_legal(out);
}
out
}
/// step 4:序列合法性修复(Anthropic/GLM Messages API 协议铁律,view-only 不改持久化)。
///
/// 协议要求首条必须是 user(system 由 convert_request 抽顶层)。sanitize step0(is_active 过滤)
/// 与超预算裁剪(build_eviction_units 从三元组边界 trim)可能使首条变成 assistant/tool_result
/// (开头 user 被裁/滤)→ 触发端点 1214「messages 参数非法」。
///
/// 修复:丢弃开头的 assistant/tool 消息(无前置 user 的孤儿,发也非法),直到首个 user/system。
/// 注:连续同 role(user/user、assistant/assistant)现实极少——裁剪按三元组原子保护不产生连续 user,
/// archived/compressed 过滤后由摘要 system 占位——故本轮不合并(合并会破坏裁剪保护区语义 + 改变条数,
/// 致 over_budget_trims_old 等测试失败)。若运行时日志显示连续 role 也是 1214 来源,再补合并。
pub fn ensure_sequence_legal(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
let mut skipped = 0u32;
let mut merged = 0u32;
let mut fixed: Vec<ChatMessage> = Vec::with_capacity(messages.len());
for m in messages {
// 首条必须 user:skip 开头 assistant/tool(无前置 user 的孤儿)
if fixed.is_empty() && matches!(m.role, MessageRole::Assistant | MessageRole::Tool) {
skipped += 1;
continue;
}
// 连续同 role 合并(user content;assistant content+tool_calls;Tool 不合并——
// 连续 tool_result 由 anthropic_compat flush_tool_results 合并成 user blocks,此处合会丢 id)
if let Some(last) = fixed.last_mut() {
let same_role = std::mem::discriminant(&last.role) == std::mem::discriminant(&m.role);
if same_role && matches!(m.role, MessageRole::User | MessageRole::Assistant) {
if !m.content.is_empty() {
if !last.content.is_empty() {
last.content.push('\n');
}
last.content.push_str(&m.content);
}
if matches!(m.role, MessageRole::Assistant) {
if let Some(calls) = m.tool_calls {
last.tool_calls.get_or_insert_with(Vec::new).extend(calls);
}
}
merged += 1;
continue;
}
}
fixed.push(m);
}
if skipped > 0 || merged > 0 {
tracing::warn!(
skipped,
merged,
"序列修复:skip 开头非 user + 合并连续同 role(view-only,避免 Anthropic/GLM 1214)"
);
}
fixed
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::ContextManager;
use crate::context_helpers::ContextConfig;
fn cfg(max_tokens: u32) -> ContextConfig {
ContextConfig {
max_tokens,
output_reserve: 0,
safety_ratio: 1.0,
}
}
#[test]
fn sanitize_drops_placeholder_id_triplet() {
// 中毒场景assistant 头带 tool_missing_ 占位 id + 无主 tool_result
// → build_for_request 必须剔除两者,否则下次回传触发 provider 500 死循环
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("调用工具"));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("tool_missing_0", "read_file", "{}")],
));
mgr.push(ChatMessage::tool_result("tool_missing_0", "结果"));
let (msgs, _trimmed) = mgr.build_for_request(0);
// 中毒的 head + 无主 tool_result 全部剔除,只剩首条 user
assert_eq!(msgs.len(), 1, "占位 id 三元组应被 sanitize 剔除");
assert_eq!(msgs[0].content, "调用工具");
// 持久化全量保留(自愈只改发送视图)
assert_eq!(mgr.all_messages_clone().len(), 3, "sanitize 不应污染内存全量");
}
#[test]
fn sanitize_drops_unresolved_tool_call_head() {
// 真实 id 但全无 tool_result流式中断/异常)→ 全未闭合档,整头丢弃
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("call_a", "fn_a", "{}"), ToolCall::new("call_b", "fn_b", "{}")],
));
// 两个 call 都无 result → 整头丢弃
let (msgs, _trimmed) = mgr.build_for_request(0);
assert!(msgs.iter().all(|m| !matches!(m.role, MessageRole::Assistant)
|| m.tool_calls.as_ref().is_none_or(|c| c.is_empty())),
"全未闭合的 assistant 头应被剔除");
assert!(msgs.is_empty(), "全未闭合应整条删");
assert_eq!(mgr.all_messages_clone().len(), 1, "全量保留");
}
#[test]
fn sanitize_partial_resolve_rewrites_head() {
// 多 tool_call部分有匹配部分无 → 保留头但只留已闭合的,丢弃无主 result
// provider 协议铁律head 的每个 tool_call.id 必须都有 tool_result。
// 「不能整条删」——保住已发生的合法工具交互call_b历史。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("触发工具"));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("call_a", "fn_a", "{}"), ToolCall::new("call_b", "fn_b", "{}")],
));
// 只有 call_b 有 result → call_a 未闭合
mgr.push(ChatMessage::tool_result("call_b", "b 结果"));
let (msgs, _trimmed) = mgr.build_for_request(0);
// 头被保留(不是整条删),但 tool_calls 重写为只剩已闭合的 call_b
let assistant_heads: Vec<_> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
.collect();
assert_eq!(assistant_heads.len(), 1, "部分闭合头应保留(重写不删)");
let head_calls = assistant_heads[0].tool_calls.as_ref().unwrap();
assert_eq!(head_calls.len(), 1, "重写后只保留 1 个已闭合 tool_call");
assert_eq!(head_calls[0].id, "call_b", "保留的应是已闭合的 call_b");
assert!(
head_calls.iter().all(|c| c.id != "call_a"),
"未闭合的 call_a 应从重写后的 tool_calls 中移除"
);
// tool_result 只剩 call_b 的user 前置保留
let tool_msgs: Vec<_> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.collect();
assert_eq!(tool_msgs.len(), 1, "只保留 call_b 的 tool_result");
assert_eq!(tool_msgs[0].tool_call_id.as_deref(), Some("call_b"));
assert!(
msgs.iter()
.any(|m| matches!(m.role, MessageRole::User) && m.content == "触发工具"),
"无关 user 消息不应被误删"
);
assert_eq!(mgr.all_messages_clone().len(), 3, "sanitize 不应污染内存全量");
}
#[test]
fn sanitize_keeps_well_formed_triplet() {
// 正常三元组user → head(tool_call 有匹配 tool_result) → tool_result,不被剔除。
// 首条必须是 user(step4 Anthropic 协议修复),故前置一条 user 消息。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问题"));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("call_a", "fn_a", "{}")],
));
mgr.push(ChatMessage::tool_result("call_a", "结果"));
let (msgs, _trimmed) = mgr.build_for_request(0);
assert_eq!(msgs.len(), 3, "正常三元组不应被 sanitize 剔除");
}
// ── 阶段2 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ──
#[test]
fn sanitize_drops_reverse_orphan_tool_result() {
// 反向 orphan:tool_result 无对应 tool_call 头(头被裁/丢)→ sanitize step3.5 丢弃。
// 非 pending 占位(普通 tool_result)直接丢,防 provider 400 orphan。
let msgs = vec![
ChatMessage::user("问题"),
ChatMessage::tool_result("orphan_id", "孤儿结果无头"),
];
let sanitized = sanitize_messages(msgs);
assert!(
sanitized.iter().all(|m| !matches!(m.role, MessageRole::Tool)),
"无头的普通 tool_result 应被反向 orphan 检测丢弃, 实际 {:?}",
sanitized
);
}
#[test]
fn sanitize_keeps_reverse_orphan_pending_placeholder() {
// 反向 orphan 但内容是 pending 占位(带 __PENDING__ 标记)→ step3.5 豁免保留
// (占位语义"等审批",出口断言自愈补头)。验证占位保护不误丢。
let placeholder_content = "需要用户审批等待确认__PENDING__:call_pending_1";
let msgs = vec![
ChatMessage::user("问题"),
ChatMessage::tool_result("call_pending_1", placeholder_content),
];
let sanitized = sanitize_messages(msgs);
let kept: Vec<_> = sanitized
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.collect();
assert_eq!(kept.len(), 1, "pending 占位 tool_result 应豁免保留,不丢");
assert_eq!(kept[0].tool_call_id.as_deref(), Some("call_pending_1"));
}
#[test]
fn build_for_request_heals_orphan_pending_placeholder_via_exit_assert() {
// 端到端:pending 占位 tool_result(无头)经 sanitize 保留 → 出口断言补 TOOL_MISSING_PREFIX
// 头自愈 → 发送视图含闭合三元组(占位头 + 占位 result,id 配对)。防 400 orphan。
let placeholder_content = "需要用户审批等待确认__PENDING__:call_pending_2";
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问题"));
mgr.push(ChatMessage::tool_result("call_pending_2", placeholder_content));
let (msgs, _trimmed) = mgr.build_for_request(0);
// 出口断言补了占位头 → 必有 assistant 头含 tool_missing_ 前缀 id
let healed_heads: Vec<_> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.filter_map(|m| m.tool_calls.as_ref())
.flatten()
.filter(|c| c.id.starts_with("tool_missing_"))
.collect();
assert_eq!(healed_heads.len(), 1, "出口断言应补 1 个 TOOL_MISSING_PREFIX 占位头");
// 占位头 id 应含原 tool_call_id(精确配对)
assert!(
healed_heads[0].id.contains("call_pending_2"),
"补的头 id 应含原 tool_call_id, 实际 {}",
healed_heads[0].id
);
// result 的 tool_call_id 也被改写为占位头 id(三者精确配对)
let result_id = msgs
.iter()
.find(|m| matches!(m.role, MessageRole::Tool))
.and_then(|m| m.tool_call_id.clone())
.expect("应有 tool_result");
assert_eq!(result_id, healed_heads[0].id, "result id 应与补的头 id 一致(闭合配对)");
// 持久化全量保留(view-only)
assert_eq!(mgr.all_messages_clone().len(), 2, "自愈不应污染持久化");
}
#[test]
fn build_for_request_keeps_well_formed_triplet_under_placeholder_integrity() {
// 回归:启用占位配对完整性后,正常闭合三元组不被误补头(无 orphan)。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问题"));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("call_ok", "fn", "{}")],
));
mgr.push(ChatMessage::tool_result("call_ok", "正常结果"));
let (msgs, _trimmed) = mgr.build_for_request(0);
// 无 tool_missing_ 头(正常三元组不需自愈)
let missing_heads = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.filter_map(|m| m.tool_calls.as_ref())
.flatten()
.filter(|c| c.id.starts_with("tool_missing_"))
.count();
assert_eq!(missing_heads, 0, "正常闭合三元组不应被补占位头");
}
#[test]
fn assert_placeholder_pairing_no_consecutive_assistant_after_head_insert() {
// 加固(连续 assistant→400):补占位头插在 orphan tool_result 正前方,若 orphan result 的
// 前一条恰是 assistant(无 tool_result 隔开),则补头后产生**连续 assistant**
// (前 assistant + 占位 assistant + tool_result)→ Anthropic/GLM 协议 400/1214。
// 出口断言补头后必须再过一次 ensure_sequence_legal 合并连续 assistant,使其不触发 provider 拒绝。
//
// 构造真正触发连续 assistant 的序列:user → 纯文本 assistant(无 tool_calls) →
// orphan pending 占位 tool_result(call_pending,无头)。占位 result 经 sanitize step3.5
// 豁免保留(带 __PENDING__),出口断言在 result 正前方补占位头(其前驱正是 assistant)
// → 补后序列 user → assistant(纯文本) → assistant(占位头) → tool_result = 连续 assistant。
//
// 注:前版本用「带 tool_calls 的 assistant + tool_result(闭合) + orphan 占位 result」,
// 补头后前驱是 tool_result 而非 assistant,根本不产生连续 assistant,断言恒真(未真测)。
// 本版把前驱改成纯文本 assistant(无 tool_result 隔开),才能真正触发合并路径。
let placeholder_content = "需要用户审批等待确认__PENDING__:call_pending_cc";
let msgs = vec![
ChatMessage::user("问题"),
ChatMessage::assistant("纯文本回复(无 tool_calls)"),
ChatMessage::tool_result("call_pending_cc", placeholder_content),
];
let sanitized = sanitize_messages(msgs.clone());
// 前置确认:sanitize 后占位 result 仍豁免保留(step3.5),序列保留 assistant→tool_result
assert_eq!(sanitized.len(), 3, "占位 result 应豁免保留, 实际 {:?}", sanitized);
assert!(sanitized.iter().any(|m|
matches!(m.role, MessageRole::Tool)
&& m.tool_call_id.as_deref() == Some("call_pending_cc")
), "占位 tool_result 应保留");
let healed = assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED);
// 断言:补了占位头(tool_missing_ 前缀 id)
let missing_heads: Vec<_> = healed
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.filter_map(|m| m.tool_calls.as_ref())
.flatten()
.filter(|c| c.id.starts_with(TOOL_MISSING_PREFIX))
.collect();
assert_eq!(
missing_heads.len(),
1,
"应补 1 个占位头, 实际 {:?}",
missing_heads
);
// 核心断言:不应存在连续 assistant(任一 assistant 紧邻前一 assistant 视为连续)。
// 若出口断言补头后未过 ensure_sequence_legal,会留连续 assistant(纯文本 assistant +
// 占位 assistant)→ consecutive >= 1,本断言失败。合并后 consecutive 必须 0。
let mut prev_is_assistant = false;
let mut consecutive = 0u32;
for m in &healed {
let is_assistant = matches!(m.role, MessageRole::Assistant);
if is_assistant && prev_is_assistant {
consecutive += 1;
}
prev_is_assistant = is_assistant;
}
assert_eq!(
consecutive, 0,
"补占位头后不应有连续 assistant(应被合并), 实际序列 {:?}",
healed
.iter()
.map(|m| format!("{:?}", m.role))
.collect::<Vec<_>>()
);
// 合并验证:补的占位头应被并入前一个纯文本 assistant(其 tool_calls 从 None→Some[占位头]),
// 而非独立成条(独立成条才会连续 assistant)。即 healed 里 assistant 消息数 == 1(原纯文本头
// 吸收了占位头的 tool_calls),且该 assistant 同时含纯文本 content 与占位头 tool_call。
let assistant_msgs: Vec<_> = healed
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.collect();
assert_eq!(
assistant_msgs.len(),
1,
"连续 assistant 应被合并为 1 条(占位头 tool_calls 并入前驱), 实际 {} 条: {:?}",
assistant_msgs.len(),
healed.iter().map(|m| format!("{:?}", m.role)).collect::<Vec<_>>()
);
// 合并后唯一 assistant 既保留原纯文本 content,又含占位头 tool_calls
let merged = &assistant_msgs[0];
assert_eq!(merged.content, "纯文本回复(无 tool_calls)", "合并后应保留前驱纯文本 content");
let merged_call_ids: Vec<&str> = merged
.tool_calls
.as_ref()
.map(|cs| cs.iter().map(|c| c.id.as_str()).collect())
.unwrap_or_default();
assert!(
merged_call_ids.iter().any(|id| id.starts_with(TOOL_MISSING_PREFIX)
&& id.contains("call_pending_cc")),
"合并后前驱 assistant 应吸收占位头 tool_call, 实际 calls: {:?}",
merged_call_ids
);
// 占位头 id 应在(合并后的)assistant 头 tool_calls 内,result 与之配对
let result_id = healed
.iter()
.find(|m| matches!(m.role, MessageRole::Tool))
.and_then(|m| m.tool_call_id.clone())
.expect("应有 tool_result");
assert!(
merged_call_ids.contains(&result_id.as_str()),
"result id 应与合并后的占位头 id 一致(闭合配对), result={} heads={:?}",
result_id,
merged_call_ids
);
}
#[test]
fn drop_reverse_orphans_preserves_pending_with_legacy_text() {
// 边界:老占位(纯文本"需要用户审批..."无 __PENDING__ 标记)→ is_pending_placeholder 仍识别
// (starts_with "需要用户审批") → 豁免保留。验证向前兼容迁移期老数据。
let msgs = vec![
ChatMessage::user("问题"),
ChatMessage::tool_result("legacy_pending", "需要用户审批,等待确认"),
];
let sanitized = sanitize_messages(msgs);
let kept = sanitized
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.count();
assert_eq!(kept, 1, "老占位(纯文本无标记)也应豁免保留(向前兼容)");
}
#[test]
fn sanitize_mixed_multi_message_sequence() {
// 场景 4连续多条消息混合——
// [user] [全闭合三元组] [中毒占位 id 三元组] [部分闭合头] [user]
// 预期全闭合原样保留、中毒占位整删、部分闭合头重写为只剩已闭合、user 不动
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("开场"));
// 全闭合三元组
mgr.push(ChatMessage::assistant_with_tools(
"调1",
vec![ToolCall::new("ok_1", "fn", "{}")],
));
mgr.push(ChatMessage::tool_result("ok_1", "ok 结果"));
// 中毒占位 id 三元组(全未闭合)→ 整删
mgr.push(ChatMessage::assistant_with_tools(
"调2",
vec![ToolCall::new("tool_missing_0", "fn", "{}")],
));
mgr.push(ChatMessage::tool_result("tool_missing_0", "占位结果"));
// 部分闭合头keep_1 闭合drop_1 未闭合 → 重写为只剩 keep_1
mgr.push(ChatMessage::assistant_with_tools(
"调3",
vec![ToolCall::new("keep_1", "fn", "{}"), ToolCall::new("drop_1", "fn", "{}")],
));
mgr.push(ChatMessage::tool_result("keep_1", "keep 结果"));
mgr.push(ChatMessage::user("收尾"));
let (msgs, _trimmed) = mgr.build_for_request(0);
// 任何保留头里的 id 都不得含占位前缀、不得含未闭合的 drop_1
let head_ids: Vec<Vec<String>> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
.map(|m| {
m.tool_calls
.as_ref()
.unwrap()
.iter()
.map(|c| c.id.clone())
.collect()
})
.collect();
assert!(
head_ids.iter().all(|ids| {
ids.iter().all(|id| !id.starts_with("tool_missing_") && id != "drop_1")
}),
"保留的 assistant 头中不应再有中毒 id 或未闭合 id, 实际 {:?}", head_ids
);
let tool_ids: Vec<String> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.filter_map(|m| m.tool_call_id.clone())
.collect();
// ok_1全闭合保留、keep_1部分闭合重写后保留应在占位/drop_1 不在
assert!(tool_ids.contains(&"ok_1".to_string()), "全闭合 ok_1 应保留");
assert!(tool_ids.contains(&"keep_1".to_string()), "部分闭合 keep_1 应保留");
assert!(
tool_ids.iter().all(|id| !id.starts_with("tool_missing_") && id != "drop_1"),
"无主 tool_result 应被剔除, 实际 {:?}", tool_ids
);
// 两条 user 消息原样保留
let user_contents: Vec<&str> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::User))
.map(|m| m.content.as_str())
.collect();
assert_eq!(user_contents, vec!["开场", "收尾"], "无关 user 消息不应被误删");
assert_eq!(mgr.all_messages_clone().len(), 8, "sanitize 不应污染内存全量");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,542 @@
//! Git worktree 生命周期管理 — 多 Agent 并行执行的文件隔离基础
//!
//! 设计依据:docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md §4.2
//!
//! 每个 SubTask 创建独立 worktree(git worktree add + 新分支),
//! 写操作天然隔离。合并时用 git merge-tree 预检 + git merge 实际合并。
use std::path::{Path, PathBuf};
use std::process::Command;
/// worktree 管理错误
#[derive(Debug)]
pub enum WorktreeError {
/// Git 命令执行失败(stdout/stderr 含错误信息)
GitFailed(String),
/// 工程根目录无 .git(非 Git 工程,应降级串行)
NotGitRepo,
/// worktree 目录已存在(可能上次未清理)
AlreadyExists(PathBuf),
}
impl std::fmt::Display for WorktreeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::GitFailed(msg) => write!(f, "Git 命令失败: {}", msg),
Self::NotGitRepo => write!(f, "非 Git 工程(无 .git 目录)"),
Self::AlreadyExists(p) => write!(f, "worktree 目录已存在: {}", p.display()),
}
}
}
impl std::error::Error for WorktreeError {}
/// worktree 创建结果
#[derive(Debug, Clone)]
pub struct WorktreeHandle {
/// worktree 物理路径(SubTask 工具操作的 cwd)
pub path: PathBuf,
/// worktree 对应的分支名(merge 时用)
pub branch: String,
/// 所属 Plan id(清理时按 plan 批量)
pub plan_id: String,
/// SubTask id
pub subtask_id: String,
}
/// 在指定工程根目录下创建 SubTask 专属 worktree。
///
/// - `project_root`: 工程根目录(含 .git)
/// - `plan_id`: 所属 Plan id
/// - `subtask_id`: SubTask id
/// - `base_branch`: fork 基点分支(plan 分支或主分支)
///
/// 返回 WorktreeHandle(path + branch)。
/// worktree 路径:`{project_root}/.devflow/wt/{plan_id}/{subtask_id}`
/// 分支名:`subtask/{plan_id}/{subtask_id}`
pub fn create_worktree(
project_root: &Path,
plan_id: &str,
subtask_id: &str,
base_branch: &str,
) -> Result<WorktreeHandle, WorktreeError> {
if !project_root.join(".git").exists() {
return Err(WorktreeError::NotGitRepo);
}
let wt_path = project_root.join(".devflow/wt").join(plan_id).join(subtask_id);
if wt_path.exists() {
return Err(WorktreeError::AlreadyExists(wt_path));
}
let branch = format!("subtask/{}/{}", plan_id, subtask_id);
// git worktree add <path> -b <branch> <base_branch>
let output = Command::new("git")
.args([
"worktree",
"add",
wt_path.to_str().unwrap_or(""),
"-b",
&branch,
base_branch,
])
.current_dir(project_root)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git worktree add 执行失败: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(WorktreeError::GitFailed(format!(
"git worktree add 失败: {}",
stderr.trim()
)));
}
Ok(WorktreeHandle {
path: wt_path,
branch,
plan_id: plan_id.to_string(),
subtask_id: subtask_id.to_string(),
})
}
/// 在 worktree 内提交所有改动(git add -A + git commit)。
///
/// - `wt_path`: worktree 物理路径
/// - `message`: commit 消息
pub fn commit_worktree(wt_path: &Path, message: &str) -> Result<(), WorktreeError> {
// git add -A
let add = Command::new("git")
.args(["add", "-A"])
.current_dir(wt_path)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git add 失败: {}", e)))?;
if !add.status.success() {
return Err(WorktreeError::GitFailed(format!(
"git add 失败: {}",
String::from_utf8_lossy(&add.stderr).trim()
)));
}
// git commit -m (允许空提交,--allow-empty 防无改动时报错)
let commit = Command::new("git")
.args(["commit", "--allow-empty", "-m", message])
.current_dir(wt_path)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git commit 失败: {}", e)))?;
if !commit.status.success() {
// commit 失败可能因无 user.email 配置(测试环境),尝试自动配
let stderr = String::from_utf8_lossy(&commit.stderr);
if stderr.contains("user.email") || stderr.contains("user.name") {
// 降级:设置 worktree 局部配置(--local 不污染全局 git config)后重试
Command::new("git")
.args(["config", "--local", "user.email", "devflow@ai.local"])
.current_dir(wt_path)
.output()
.ok();
Command::new("git")
.args(["config", "--local", "user.name", "DevFlow Agent"])
.current_dir(wt_path)
.output()
.ok();
let retry = Command::new("git")
.args(["commit", "--allow-empty", "-m", message])
.current_dir(wt_path)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git commit 重试失败: {}", e)))?;
if !retry.status.success() {
return Err(WorktreeError::GitFailed(format!(
"git commit 重试仍失败: {}",
String::from_utf8_lossy(&retry.stderr).trim()
)));
}
return Ok(());
}
return Err(WorktreeError::GitFailed(format!("git commit 失败: {}", stderr.trim())));
}
Ok(())
}
/// 将 SubTask 分支 merge 到 plan 分支(在 plan worktree 内执行)。
///
/// 返回 Ok(true) 表示有改动被合并,Ok(false) 表示无改动(空分支)。
/// 返回 Err 表示 merge 冲突。
pub fn merge_branch(
plan_wt_path: &Path,
subtask_branch: &str,
) -> Result<MergeResult, WorktreeError> {
// 用旧版兼容的 merge-tree(base branch → 输出冲突树)
// --write-tree 需 Git 2.38+,改用三参数 merge-tree <base> <branch1> <branch2>
// 成功(exit 0)= 无冲突可自动合并;非0且有输出 = 冲突
let base = "HEAD";
let precheck = Command::new("git")
.args(["merge-tree", base, subtask_branch, base])
.current_dir(plan_wt_path)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git merge-tree 失败: {}", e)))?;
// merge-tree 输出含 conflict marker(<<<<<<<)时表示有冲突
let precheck_stdout = String::from_utf8_lossy(&precheck.stdout);
if precheck_stdout.contains("<<<<<<<") || precheck_stdout.contains("=======") {
return Ok(MergeResult::Conflict {
branch: subtask_branch.to_string(),
details: precheck_stdout.to_string(),
});
}
// 无冲突,执行实际 merge
let merge = Command::new("git")
.args(["merge", "--no-edit", subtask_branch])
.current_dir(plan_wt_path)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git merge 失败: {}", e)))?;
if !merge.status.success() {
// merge 冲突(git merge 返回非0)
let stderr = String::from_utf8_lossy(&merge.stderr);
let stdout = String::from_utf8_lossy(&merge.stdout);
// abort 部分合并状态(防 worktree 处于 conflicted 状态)
let _ = Command::new("git")
.args(["merge", "--abort"])
.current_dir(plan_wt_path)
.output();
return Ok(MergeResult::Conflict {
branch: subtask_branch.to_string(),
details: format!("{}\n{}", stdout, stderr),
});
}
Ok(MergeResult::Clean)
}
/// merge 结果
#[derive(Debug, Clone)]
pub enum MergeResult {
/// 干净合并(无冲突)
Clean,
/// 合并冲突(details 含 conflict 信息)
Conflict { branch: String, details: String },
}
/// 清理单个 SubTask 的 worktree(git worktree remove --force + 删分支)。
pub fn remove_worktree(project_root: &Path, handle: &WorktreeHandle) -> Result<(), WorktreeError> {
// git worktree remove --force <path>
let _ = Command::new("git")
.args([
"worktree",
"remove",
"--force",
handle.path.to_str().unwrap_or(""),
])
.current_dir(project_root)
.output();
// 删除分支(-D 强制,已 merge 的分支正常删,未 merge 的也删)
let _ = Command::new("git")
.args(["branch", "-D", &handle.branch])
.current_dir(project_root)
.output();
Ok(())
}
/// 清理某 Plan 的所有 worktree(Plan 完成/失败/取消时批量清理)。
pub fn remove_plan_worktrees(project_root: &Path, plan_id: &str) -> Result<(), WorktreeError> {
let plan_wt_dir = project_root.join(".devflow/wt").join(plan_id);
if !plan_wt_dir.exists() {
return Ok(());
}
// git worktree list 获取该 plan 下所有 worktree
let list = Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(project_root)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git worktree list 失败: {}", e)))?;
let list_str = String::from_utf8_lossy(&list.stdout);
for line in list_str.lines() {
if let Some(path_str) = line.strip_prefix("worktree ") {
let path = PathBuf::from(path_str);
// 仅清理属于本 plan 的 worktree
if path.starts_with(&plan_wt_dir) {
let _ = Command::new("git")
.args(["worktree", "remove", "--force", path_str])
.current_dir(project_root)
.output();
}
}
}
// 删除该 plan 的所有子任务分支
let branches = Command::new("git")
.args(["branch", "--list", &format!("subtask/{}/*", plan_id)])
.current_dir(project_root)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git branch --list 失败: {}", e)))?;
let branches_str = String::from_utf8_lossy(&branches.stdout);
for line in branches_str.lines() {
let branch = line.trim();
if !branch.is_empty() {
let _ = Command::new("git")
.args(["branch", "-D", branch])
.current_dir(project_root)
.output();
}
}
// 清理空目录
let _ = std::fs::remove_dir_all(&plan_wt_dir);
Ok(())
}
/// 检测工程是否为 Git 仓库(有 .git 目录)。
pub fn is_git_repo(project_root: &Path) -> bool {
project_root.join(".git").exists()
}
/// 获取工程当前分支名(用于作为 plan worktree 的 fork 基点)。
pub fn current_branch(project_root: &Path) -> Result<String, WorktreeError> {
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(project_root)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git rev-parse 失败: {}", e)))?;
if !output.status.success() {
return Err(WorktreeError::GitFailed(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
/// 创建 Plan worktree(Plan 级工作分支,所有 SubTask 分支 merge 到这里)。
///
/// 返回 (plan_worktree_path, plan_branch)。
pub fn create_plan_worktree(
project_root: &Path,
plan_id: &str,
) -> Result<(PathBuf, String), WorktreeError> {
if !is_git_repo(project_root) {
return Err(WorktreeError::NotGitRepo);
}
let base = current_branch(project_root)?;
let plan_branch = format!("plan/{}", plan_id);
let plan_wt_path = project_root.join(".devflow/wt").join(plan_id);
if plan_wt_path.exists() {
return Err(WorktreeError::AlreadyExists(plan_wt_path));
}
let output = Command::new("git")
.args([
"worktree",
"add",
plan_wt_path.to_str().unwrap_or(""),
"-b",
&plan_branch,
&base,
])
.current_dir(project_root)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git worktree add(plan) 失败: {}", e)))?;
if !output.status.success() {
return Err(WorktreeError::GitFailed(format!(
"git worktree add(plan) 失败: {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
Ok((plan_wt_path, plan_branch))
}
#[cfg(test)]
mod tests {
use super::*;
/// 创建临时 Git 仓库 + 初始 commit
fn setup_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
Command::new("git").args(["init"]).current_dir(path).output().unwrap();
Command::new("git").args(["config", "user.email", "test@test.com"]).current_dir(path).output().unwrap();
Command::new("git").args(["config", "user.name", "test"]).current_dir(path).output().unwrap();
std::fs::write(path.join("main.rs"), "fn main() {}\n").unwrap();
Command::new("git").args(["add", "."]).current_dir(path).output().unwrap();
Command::new("git").args(["commit", "-m", "init"]).current_dir(path).output().unwrap();
dir
}
#[test]
fn wt_01_create_and_isolate() {
let repo = setup_repo();
let root = repo.path();
let base = current_branch(root).unwrap();
let handle = create_worktree(root, "plan-test", "A", &base).unwrap();
assert!(handle.path.exists(), "worktree 目录应存在");
assert_eq!(handle.branch, "subtask/plan-test/A");
// 在 worktree 内写文件
std::fs::write(handle.path.join("new_a.rs"), "// A 的改动").unwrap();
// 主目录不应有此文件
assert!(!root.join("new_a.rs").exists(), "主目录不应有 A 的改动");
// 清理
remove_worktree(root, &handle).unwrap();
}
#[test]
fn wt_02_parallel_isolation() {
let repo = setup_repo();
let root = repo.path();
let plan_wt = create_plan_worktree(root, "plan-iso").unwrap();
let plan_branch = plan_wt.1;
let a = create_worktree(root, "plan-iso", "A", &plan_branch).unwrap();
let b = create_worktree(root, "plan-iso", "B", &plan_branch).unwrap();
// A 写 new_a.rs
std::fs::write(a.path.join("new_a.rs"), "// A").unwrap();
// B 写 new_b.rs
std::fs::write(b.path.join("new_b.rs"), "// B").unwrap();
// A 的 worktree 不应有 B 的文件
assert!(!a.path.join("new_b.rs").exists(), "A 不应看到 B 的改动");
// B 的 worktree 不应有 A 的文件
assert!(!b.path.join("new_a.rs").exists(), "B 不应看到 A 的改动");
remove_plan_worktrees(root, "plan-iso").unwrap();
}
#[test]
fn wt_03_commit_in_worktree() {
let repo = setup_repo();
let root = repo.path();
let plan_wt = create_plan_worktree(root, "plan-commit").unwrap();
let a = create_worktree(root, "plan-commit", "A", &plan_wt.1).unwrap();
// 写文件 + commit
std::fs::write(a.path.join("feature.rs"), "pub fn feature() {}").unwrap();
commit_worktree(&a.path, "Add feature").unwrap();
// 验证分支有新 commit
let log = Command::new("git")
.args(["log", "--oneline", "-1"])
.current_dir(&a.path)
.output()
.unwrap();
let log_str = String::from_utf8_lossy(&log.stdout);
assert!(log_str.contains("Add feature"), "commit 消息应在 log 中");
remove_plan_worktrees(root, "plan-commit").unwrap();
}
#[test]
fn wt_04_merge_no_conflict() {
let repo = setup_repo();
let root = repo.path();
let plan_wt = create_plan_worktree(root, "plan-merge").unwrap();
let plan_wt_path = plan_wt.0.clone();
let plan_branch = plan_wt.1;
// A 改 file_a.rs, B 改 file_b.rs(不同文件,无冲突)
let a = create_worktree(root, "plan-merge", "A", &plan_branch).unwrap();
std::fs::write(a.path.join("file_a.rs"), "// A").unwrap();
commit_worktree(&a.path, "A changes").unwrap();
let b = create_worktree(root, "plan-merge", "B", &plan_branch).unwrap();
std::fs::write(b.path.join("file_b.rs"), "// B").unwrap();
commit_worktree(&b.path, "B changes").unwrap();
// merge A → plan
let result_a = merge_branch(&plan_wt_path, &a.branch).unwrap();
assert!(matches!(result_a, MergeResult::Clean), "A merge 应无冲突");
// merge B → plan
let result_b = merge_branch(&plan_wt_path, &b.branch).unwrap();
assert!(matches!(result_b, MergeResult::Clean), "B merge 应无冲突");
// plan worktree 应同时有 file_a.rs 和 file_b.rs
assert!(plan_wt_path.join("file_a.rs").exists(), "plan 应含 A 的改动");
assert!(plan_wt_path.join("file_b.rs").exists(), "plan 应含 B 的改动");
remove_plan_worktrees(root, "plan-merge").unwrap();
}
#[test]
fn wt_05_merge_conflict_same_file() {
let repo = setup_repo();
let root = repo.path();
// 写一个共享文件
std::fs::write(root.join("shared.rs"), "line1\nline2\n").unwrap();
Command::new("git").args(["add", "-A"]).current_dir(root).output().unwrap();
Command::new("git").args(["commit", "-m", "add shared"]).current_dir(root).output().unwrap();
let plan_wt = create_plan_worktree(root, "plan-conf").unwrap();
let plan_wt_path = plan_wt.0.clone();
let plan_branch = plan_wt.1;
// A 和 B 改同一文件的同一行
let a = create_worktree(root, "plan-conf", "A", &plan_branch).unwrap();
std::fs::write(a.path.join("shared.rs"), "A_version\n").unwrap();
commit_worktree(&a.path, "A change shared").unwrap();
let b = create_worktree(root, "plan-conf", "B", &plan_branch).unwrap();
std::fs::write(b.path.join("shared.rs"), "B_version\n").unwrap();
commit_worktree(&b.path, "B change shared").unwrap();
// merge A → 无冲突
let result_a = merge_branch(&plan_wt_path, &a.branch).unwrap();
assert!(matches!(result_a, MergeResult::Clean), "首个 merge 应无冲突");
// merge B → 冲突
let result_b = merge_branch(&plan_wt_path, &b.branch).unwrap();
assert!(
matches!(result_b, MergeResult::Conflict { .. }),
"同文件同行改应产生冲突"
);
remove_plan_worktrees(root, "plan-conf").unwrap();
}
#[test]
fn wt_06_non_git_returns_error() {
let dir = tempfile::tempdir().unwrap();
// 不 init git
let result = create_plan_worktree(dir.path(), "plan-ng");
assert!(matches!(result, Err(WorktreeError::NotGitRepo)));
}
#[test]
fn wt_07_cleanup_removes_worktree() {
let repo = setup_repo();
let root = repo.path();
let plan_wt = create_plan_worktree(root, "plan-clean").unwrap();
let a = create_worktree(root, "plan-clean", "A", &plan_wt.1).unwrap();
assert!(a.path.exists());
remove_plan_worktrees(root, "plan-clean").unwrap();
// worktree 目录应被清理
assert!(!a.path.exists(), "worktree 目录应已清理");
// plan wt 目录也应被清理
assert!(!plan_wt.0.exists(), "plan worktree 目录应已清理");
}
}

View File

@@ -944,7 +944,7 @@ mod tests {
/// 构造测试用 ToolDefinition仅 name 有意义description/parameters 填占位)。
fn tool_def(name: &str) -> df_ai_core::types::ToolDefinition {
df_ai_core::types::ToolDefinition {
tool_type: "function".to_string(),
tool_type: "function".into(),
function: df_ai_core::types::ToolFunction {
name: name.to_string(),
description: String::new(),

View File

@@ -5,10 +5,12 @@ pub mod anthropic_compat;
pub mod anthropic_helpers;
pub mod context;
pub mod context_helpers;
// 多 Agent 协作调度中心(Phase 1 规则驱动, Phase 2 LLM 扩展)。
pub mod coordinator;
// 会话意图识别层(纯函数,不接入 agentic loop)。依据 docs/02-架构设计/构想审查/
// 意图识别层论证-2026-06-19.md。提供 recognize / tool_subset_for / suggested_model_tier。
// 待 Phase B+C 完成 + 模型模态管理落地后再接入 agentic loop。
pub mod git_worktree;
pub mod intent;
pub mod model_fetch;
// model_fetch 的纯逻辑子模块(URL 拼接 / 噪音过滤 / 响应反序列化),
@@ -28,12 +30,18 @@ pub mod openai_helpers;
// 纯函数模块(零 IO 零状态)。依据 docs/02-架构设计/单对话并行多轮-{设计,Phase0落地路线图}-2026-06-20.md。
// Phase 1(plan_hint 接入主 loop)/Phase 2(planning)/Phase 3(并行 execution) 接入后续。
pub mod planner;
// Phase 2: Plan DAG 分层执行器(骨架,PLAN_EXECUTION_ENABLED 门控默认关)。
// 按 Plan::to_layers 层间串行/层内并行调度子任务。
// 依据 docs/02-架构设计/单对话并行多轮-设计-2026-06-20.md。
pub mod persona;
pub mod plan_executor;
pub mod provider;
pub mod router;
// 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;
pub mod sse_parser;
use provider::LlmProvider;
use reqwest::Client;

View File

@@ -6,7 +6,6 @@
use std::time::Duration;
use async_trait::async_trait;
use eventsource_stream::Eventsource;
use futures::StreamExt;
use reqwest::Client;
use tracing::{debug, error, warn};
@@ -183,6 +182,31 @@ impl OpenAICompatProvider {
// assistant 的序列(会话恢复/续发/片段截取),补 user 占位保留上下文,首条合法。
Self::ensure_leading_user(&mut messages);
// 治 DeepSeek 400「insufficient tool messages」:扫描所有 assistant 消息,
// 若某条 assistant 含 tool_calls 但下一条不是 tool,则剥离其 tool_calls。
// 正常流程 tool 结果先于下一轮 LLM 请求推入历史,此守卫仅兜底异常截断/恢复场景的残末尾。
// 注意:合法的三元组形如:assistant(tc=[a]) → tool(a) → assistant(tc=[b]) → tool(b)。
// 若最后一条是 assistant(tc=...) 也无下一条 tool,同样剥离。
for i in 0..messages.len() {
let role = messages[i].role.clone();
if role != "assistant" {
continue;
}
let has_tc = messages[i].tool_calls.is_some();
if !has_tc {
continue;
}
let next_is_tool = i + 1 < messages.len()
&& matches!(messages[i + 1].role.as_str(), "tool");
if !next_is_tool {
messages[i].tool_calls = None;
tracing::warn!(
"[openai] assistant(#{} role={}) 含 tool_calls 但下一条非 tool,已自动剥离(防 400)",
i, role,
);
}
}
let tools = req.tools.map(|defs| {
defs.into_iter()
.map(|d| serde_json::to_value(d).unwrap_or_default())
@@ -362,14 +386,26 @@ impl LlmProvider for OpenAICompatProvider {
debug!(model = %openai_req.model, "OpenAI 流式调用");
let resp = self
// BUG-2026-07-07: send 阶段需 timeout 防 hang(同 Anthropic 路径)。
// 不能用 reqwest .timeout()(会砍流式 body),改用 tokio::time::timeout 包裹 send。
let send_future = self
.client
.post(self.chat_url())
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&openai_req)
.send()
.await?;
.send();
let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
tracing::error!(error = %e, is_timeout = e.is_timeout(), "OpenAI 流式 send 失败");
return Err(e.into());
}
Err(_elapsed) => {
tracing::error!(url = %self.chat_url(), "OpenAI 流式 send 超时(60s 未返回响应头)");
anyhow::bail!("流式请求超时(60秒未收到 HTTP 响应,可能服务不可达或被防火墙拦截)");
}
};
if !resp.status().is_success() {
let status = resp.status();
@@ -378,25 +414,30 @@ impl LlmProvider for OpenAICompatProvider {
anyhow::bail!("LLM 流式 API 错误 {}: {}", status, body);
}
// 累积流式 usage开 include_usage 后,末段正常 chunkfinish_reason及额外 usage-only chunkchoices=[])都带 usage
// usage 解析/累积逻辑抽到 apply_openai_sse 纯函数,便于单测;此处闭包只负责传 data 与传递 last_usage。
// BUG-2026-07-17 根治: 原生 SSE 解析器替代 eventsource-stream 库
// eventsource-stream 在 Windows 上对 Deepseek 等响应报 "error decoding response body"
// (严格 UTF-8 + SSE 协议校验,跨 chunk 字符/不完整事件均报错且不可恢复)。
// 原生解析器:bytes 累积 + from_utf8_lossy 宽松处理 + \n\n 分隔,容错不中断流。
let mut last_usage: Option<TokenUsage> = None;
let stream = resp
.bytes_stream()
.eventsource()
.map(move |event| match event {
Ok(event) => Ok(apply_openai_sse(&event.data, &mut last_usage)),
let sse = crate::sse_parser::SseStream::new(resp.bytes_stream());
let stream = sse.flat_map(move |result: Result<Vec<String>, String>| {
let mut chunks: Vec<anyhow::Result<crate::provider::StreamChunk>> = Vec::new();
match result {
Ok(events) => {
for data in events {
let chunk = apply_openai_sse(&data, &mut last_usage);
chunks.push(Ok(chunk));
}
}
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))
chunks.push(Err(anyhow::anyhow!("{}", ctx)));
}
});
}
futures::stream::iter(chunks)
});
Ok(Box::pin(stream))
}

385
crates/df-ai/src/persona.rs Normal file
View File

@@ -0,0 +1,385 @@
//! 人设系统(P0) — AgentPersona 结构体 + PersonaRegistry 注册表
//!
//! 构成 AI Native 的基础:角色划分是分工的前提。
//! - 每名人设包含:能力标签、工具白名单、system prompt 模板
//! - Registry 提供按场景/意图选人设、按 id 查人设能力
//!
//! 设计依据:docs/02-架构设计/构想审查/AI-Native方向与路线图-2026-06-29.md
use std::collections::HashMap;
/// 内置人设 ID 常量
pub const PERSONA_CODER: &str = "coder";
pub const PERSONA_REVIEWER: &str = "reviewer";
pub const PERSONA_ARCHITECT: &str = "architect";
pub const PERSONA_TESTER: &str = "tester";
pub const PERSONA_ANALYST: &str = "analyst";
/// 人设能力标签枚举
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum PersonaCapability {
/// 代码生成与修改
CodeGeneration,
/// Code Review
CodeReview,
/// 架构设计与分析
ArchitectureDesign,
/// 测试编写与执行
Testing,
/// 数据分析与搜索
Analysis,
/// 文件读写
FileOperation,
/// 命令执行
CommandExecution,
/// Git 操作
GitOperation,
/// 知识库操作
KnowledgeOperation,
/// 项目管理
ProjectManagement,
}
impl PersonaCapability {
pub fn label(&self) -> &'static str {
match self {
Self::CodeGeneration => "代码生成",
Self::CodeReview => "代码审查",
Self::ArchitectureDesign => "架构设计",
Self::Testing => "测试",
Self::Analysis => "分析",
Self::FileOperation => "文件操作",
Self::CommandExecution => "命令执行",
Self::GitOperation => "Git 操作",
Self::KnowledgeOperation => "知识库",
Self::ProjectManagement => "项目管理",
}
}
}
/// 人设定义
#[derive(Debug, Clone)]
pub struct AgentPersona {
/// 唯一标识(如 "coder", "reviewer")
pub id: String,
/// 人设名称(如 "程序员", "审查员")
pub name: String,
/// 人设描述(LLM 理解用)
pub description: String,
/// 能力标签集
pub capabilities: Vec<PersonaCapability>,
/// 工具白名单(空=不限,非空=仅允许列出的工具)
pub tool_whitelist: Vec<String>,
/// system prompt 模板(注入 {context} {goals} 等占位符)
pub system_prompt_template: String,
/// 优先级(选人设时冲突排名,0=最高)
pub priority: u8,
}
impl AgentPersona {
/// 检查此人设是否具备某项能力
pub fn has_capability(&self, cap: &PersonaCapability) -> bool {
self.capabilities.contains(cap)
}
/// 检查某工具是否在此人设的 whitelist 中
pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
self.tool_whitelist.is_empty() || self.tool_whitelist.iter().any(|t| t == tool_name)
}
/// 构建带上下文的 system prompt
pub fn build_prompt(&self, context: &str, goals: &[String]) -> String {
let goals_text = if goals.is_empty() {
String::new()
} else {
format!("\n## 当前目标\n{}", goals.iter().enumerate().map(|(i, g)| format!("{}. {}", i + 1, g)).collect::<Vec<_>>().join("\n"))
};
self.system_prompt_template
.replace("{context}", context)
.replace("{goals}", &goals_text)
}
}
/// 人设注册表 — 管理内置 + 自定义人设
#[derive(Debug, Clone)]
pub struct PersonaRegistry {
personae: HashMap<String, AgentPersona>,
/// 默认人设 id(兜底)
default_id: String,
}
impl PersonaRegistry {
/// 构造默认注册表(含 5 内置人设)
pub fn new() -> Self {
let mut reg = Self {
personae: HashMap::new(),
default_id: PERSONA_CODER.to_string(),
};
reg.register(Self::builtin_coder());
reg.register(Self::builtin_reviewer());
reg.register(Self::builtin_architect());
reg.register(Self::builtin_tester());
reg.register(Self::builtin_analyst());
reg
}
// ── 5 内置人设 ──
fn builtin_coder() -> AgentPersona {
AgentPersona {
id: PERSONA_CODER.to_string(),
name: "程序员".to_string(),
description: "负责代码生成、修改、重构。专注于实现功能、修复 bug、优化性能不关注大范围架构变更".to_string(),
capabilities: vec![
PersonaCapability::CodeGeneration,
PersonaCapability::FileOperation,
PersonaCapability::CommandExecution,
],
tool_whitelist: vec![], // 不限(全部工具可用)
system_prompt_template: "你是 DevFlow 的**程序员**(Coder)。\n\n你的职责是编写、修改和重构代码。专注于具体实现,不擅自改变整体架构。\n\n{goals}\n\n{context}".to_string(),
priority: 1,
}
}
fn builtin_reviewer() -> AgentPersona {
AgentPersona {
id: PERSONA_REVIEWER.to_string(),
name: "审查员".to_string(),
description: "负责 Code Review、质量审计、安全检查。审查代码正确性、性能、安全性返回审查意见不直接改代码".to_string(),
capabilities: vec![
PersonaCapability::CodeReview,
PersonaCapability::Analysis,
],
tool_whitelist: vec![
"read_file".into(), "search_files".into(), "list_directory".into(),
"git_diff".into(), "git_log".into(), "git_status".into(),
],
system_prompt_template: "你是 DevFlow 的**审查员**(Reviewer)。\n\n你的职责是审查代码质量、安全性和性能。**你不直接修改代码**,只输出审查意见和修改建议。\n\n{goals}\n\n{context}".to_string(),
priority: 3,
}
}
fn builtin_architect() -> AgentPersona {
AgentPersona {
id: PERSONA_ARCHITECT.to_string(),
name: "架构师".to_string(),
description: "负责架构设计、技术选型、模块划分。分析整体结构,制定技术方案,不关注具体代码实现".to_string(),
capabilities: vec![
PersonaCapability::ArchitectureDesign,
PersonaCapability::Analysis,
],
tool_whitelist: vec![
"read_file".into(), "search_files".into(), "list_directory".into(),
"write_file".into(), // 写架构文档
],
system_prompt_template: "你是 DevFlow 的**架构师**(Architect)。\n\n你的职责是架构设计、技术选型和模块划分。分析系统整体结构,制定技术方案。**不编写业务代码**,输出架构文档和设计方案。\n\n{goals}\n\n{context}".to_string(),
priority: 2,
}
}
fn builtin_tester() -> AgentPersona {
AgentPersona {
id: PERSONA_TESTER.to_string(),
name: "测试员".to_string(),
description: "负责编写单元测试、集成测试、执行测试。验证代码正确性,不修改生产代码".to_string(),
capabilities: vec![
PersonaCapability::Testing,
PersonaCapability::CommandExecution,
PersonaCapability::FileOperation,
],
tool_whitelist: vec![], // 不限
system_prompt_template: "你是 DevFlow 的**测试员**(Tester)。\n\n你的职责是编写测试、执行测试、报告测试结果。**不修改生产代码**,只修改测试代码。\n\n{goals}\n\n{context}".to_string(),
priority: 3,
}
}
fn builtin_analyst() -> AgentPersona {
AgentPersona {
id: PERSONA_ANALYST.to_string(),
name: "分析师".to_string(),
description: "负责数据分析、日志排查、性能分析。搜索信息、分析数据,输出分析报告".to_string(),
capabilities: vec![
PersonaCapability::Analysis,
PersonaCapability::CommandExecution,
PersonaCapability::KnowledgeOperation,
],
tool_whitelist: vec![
"read_file".into(), "search_files".into(), "grep_search".into(),
"run_command".into(), "git_log".into(),
"knowledge_search".into(), "knowledge_inject".into(),
],
system_prompt_template: "你是 DevFlow 的**分析师**(Analyst)。\n\n你的职责是分析数据、排查问题、搜索信息。输出分析报告和结论。**不修改代码**。\n\n{goals}\n\n{context}".to_string(),
priority: 4,
}
}
// ── 注册表操作 ──
/// 注册/覆盖人设
pub fn register(&mut self, persona: AgentPersona) {
self.personae.insert(persona.id.clone(), persona);
}
/// 按 id 查人设
pub fn get(&self, id: &str) -> Option<&AgentPersona> {
self.personae.get(id)
}
/// 取默认人设
pub fn default(&self) -> &AgentPersona {
self.personae.get(&self.default_id).expect("默认人设必存在")
}
/// 设置默认人设 id
pub fn set_default(&mut self, id: &str) {
if self.personae.contains_key(id) {
self.default_id = id.to_string();
}
}
/// 按意图推荐人设(启发式规则)
pub fn recommend_for_intent(&self, intent: &str) -> &AgentPersona {
let lower = intent.to_lowercase();
if lower.contains("review") || lower.contains("审查") || lower.contains("审计") {
self.get(PERSONA_REVIEWER).unwrap_or_else(|| self.default())
} else if lower.contains("架构") || lower.contains("设计") || lower.contains("architect") {
self.get(PERSONA_ARCHITECT).unwrap_or_else(|| self.default())
} else if lower.contains("测试") || lower.contains("test") {
self.get(PERSONA_TESTER).unwrap_or_else(|| self.default())
} else if lower.contains("分析") || lower.contains("分析") || lower.contains("排查") || lower.contains("search") {
self.get(PERSONA_ANALYST).unwrap_or_else(|| self.default())
} else {
// 默认走 coder(代码生成是最常见场景)
self.default()
}
}
/// 获取全部人设列表
pub fn all(&self) -> Vec<&AgentPersona> {
let mut list: Vec<_> = self.personae.values().collect();
list.sort_by_key(|p| p.priority);
list
}
/// 人设数量
pub fn len(&self) -> usize {
self.personae.len()
}
pub fn is_empty(&self) -> bool {
self.personae.is_empty()
}
}
impl Default for PersonaRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_has_5_builtin() {
let reg = PersonaRegistry::new();
assert_eq!(reg.len(), 5);
assert!(reg.get(PERSONA_CODER).is_some());
assert!(reg.get(PERSONA_REVIEWER).is_some());
assert!(reg.get(PERSONA_ARCHITECT).is_some());
assert!(reg.get(PERSONA_TESTER).is_some());
assert!(reg.get(PERSONA_ANALYST).is_some());
}
#[test]
fn default_is_coder() {
let reg = PersonaRegistry::new();
assert_eq!(reg.default().id, PERSONA_CODER);
}
#[test]
fn recommend_reviewer() {
let reg = PersonaRegistry::new();
let p = reg.recommend_for_intent("review the code changes");
assert_eq!(p.id, PERSONA_REVIEWER);
}
#[test]
fn recommend_architect() {
let reg = PersonaRegistry::new();
let p = reg.recommend_for_intent("设计新的模块架构");
assert_eq!(p.id, PERSONA_ARCHITECT);
}
#[test]
fn recommend_tester() {
let reg = PersonaRegistry::new();
let p = reg.recommend_for_intent("write tests for this module");
assert_eq!(p.id, PERSONA_TESTER);
}
#[test]
fn recommend_default_to_coder() {
let reg = PersonaRegistry::new();
let p = reg.recommend_for_intent("implement a new feature");
assert_eq!(p.id, PERSONA_CODER);
}
#[test]
fn tool_whitelist_filters() {
let reviewer = AgentPersona {
id: "test".into(),
name: "test".into(),
description: "".into(),
capabilities: vec![],
tool_whitelist: vec!["read_file".into(), "search_files".into()],
system_prompt_template: "".into(),
priority: 5,
};
assert!(reviewer.is_tool_allowed("read_file"));
assert!(!reviewer.is_tool_allowed("write_file"));
}
#[test]
fn empty_whitelist_allows_all() {
let coder = AgentPersona {
id: "test".into(),
name: "test".into(),
description: "".into(),
capabilities: vec![],
tool_whitelist: vec![],
system_prompt_template: "".into(),
priority: 5,
};
assert!(coder.is_tool_allowed("run_command"));
assert!(coder.is_tool_allowed("write_file"));
}
#[test]
fn build_prompt_replaces_placeholders() {
let reg = PersonaRegistry::new();
let coder = reg.get(PERSONA_CODER).unwrap();
let prompt = coder.build_prompt("some context", &["goal1".into()]);
assert!(prompt.contains("程序员"));
assert!(prompt.contains("goal1"));
assert!(prompt.contains("some context"));
}
#[test]
fn register_custom_persona() {
let mut reg = PersonaRegistry::new();
let custom = AgentPersona {
id: "custom".into(),
name: "自定义".into(),
description: "test".into(),
capabilities: vec![],
tool_whitelist: vec![],
system_prompt_template: "You are custom".into(),
priority: 0,
};
reg.register(custom);
assert_eq!(reg.len(), 6);
assert!(reg.get("custom").is_some());
}
}

View File

@@ -0,0 +1,197 @@
//! Plan 执行器Phase 2 · DAG 分层调度)
//!
//! 按 Plan::to_layers 输出的层级顺序执行子任务:
//! - 层间串行上层全部完成才进下一层deps 保证)
//! - 层内并行:同层 SubTask 用 tokio::JoinSet 并发执行
//!
//! **当前状态**骨架实现PLAN_EXECUTION_ENABLED 门控(默认关)。
//! 未接入 agentic loop 主路径,翻 true 启用后仍需与 run_agentic_loop 对接。
//!
//! 设计依据:`docs/02-架构设计/单对话并行多轮-设计-2026-06-20.md`
use std::sync::Arc;
use crate::planner::{Plan, SubTask};
/// Plan 执行总开关。false = 不启用(走单链 ReAct 旧行为)。
///
/// 运行时原子门控,支持热切换(通过 IPC 或前端设置开关)。
/// 默认关,翻 true 后 process_tool_calls 内以 JoinSet 并行执行同层工具。
/// 与 PLANNING_ENABLED 解耦:plan_hint 独立使能,编排可见性始终可开。
use std::sync::atomic::{AtomicBool, Ordering};
pub static PLAN_EXECUTION_ENABLED: AtomicBool = AtomicBool::new(false);
/// 设置 Plan 执行开关。
pub fn set_plan_execution(enabled: bool) {
PLAN_EXECUTION_ENABLED.store(enabled, Ordering::SeqCst);
tracing::info!(enabled, "[PLAN-EXEC] 执行开关已更新");
}
/// 读取 Plan 执行开关。
pub fn plan_execution_enabled() -> bool {
PLAN_EXECUTION_ENABLED.load(Ordering::SeqCst)
}
/// 单个 SubTask 的执行结果。
#[derive(Debug, Clone)]
pub struct SubTaskResult {
/// 对应 SubTask id
pub id: String,
/// 执行是否成功
pub success: bool,
/// 执行产出(工具结果摘要 / LLM 回复)
pub output: String,
}
/// Plan 执行器:按 DAG 层级调度子任务。
///
/// 泛型参数 `F` 为子任务执行函数:
/// - 输入:`&SubTask`(子任务定义)
/// - 输入:`&str`(父任务结果摘要,供子任务上下文参考)
/// - 输出:`Future<Output = anyhow::Result<String>>`(子任务产出)
///
/// 调用方Phase 3 对接 agentic loop 时)传入真实的执行闭包,
/// 每个 SubTask 独立跑一轮 agentic loop共享父上下文快照。
pub struct PlanExecutor;
impl PlanExecutor {
/// 按 Plan DAG 分层执行。
///
/// - 层间串行:上层全部完成才进下一层
/// - 层内并行:同层 SubTask 用 JoinSet 并发
/// - 失败传播:任一 SubTask 失败时,记录错误但继续执行同层其他任务(容错);
/// 若需中断策略,调用方据返回结果自行判断。
///
/// 返回每个 SubTask 的执行结果(按 Plan 原始顺序排列)。
pub async fn execute<F, Fut>(
plan: &Plan,
mut run_subtask: F,
) -> anyhow::Result<Vec<SubTaskResult>>
where
F: FnMut(Arc<SubTask>, String) -> Fut,
Fut: std::future::Future<Output = anyhow::Result<String>>,
{
let layers = plan
.to_layers()
.map_err(|e| anyhow::anyhow!("Plan 环依赖: {:?}", e.cycle))?;
let mut results: Vec<SubTaskResult> = Vec::new();
let mut parent_summary = String::new();
for (layer_idx, layer) in layers.iter().enumerate() {
tracing::info!(
layer_idx,
task_count = layer.len(),
"[plan-exec] 执行第 {} 层, {} 个子任务",
layer_idx,
layer.len()
);
// 层内并行JoinSet 收集
let _join_set: tokio::task::JoinSet<SubTaskResult> = tokio::task::JoinSet::new();
let summary_clone = parent_summary.clone();
for task in layer {
let task = Arc::new(task.clone());
let summary = summary_clone.clone();
// 注意:run_subtask 是 FnMut,不能直接 move 进 JoinSet(多任务并发调用)。
// Phase 3 对接时,run_subtask 改为 Arc<Fn> 或 channel 模式。
// 当前骨架用串行执行模拟(层内不并行),验证分层逻辑正确性。
let result = run_subtask(task.clone(), summary).await;
let sr = match result {
Ok(output) => SubTaskResult {
id: task.id.clone(),
success: true,
output,
},
Err(e) => SubTaskResult {
id: task.id.clone(),
success: false,
output: e.to_string(),
},
};
results.push(sr);
}
// 汇总本层结果作为下层 parent_summary
let layer_summary: Vec<String> = results
.iter()
.filter(|r| layer.iter().any(|t| t.id == r.id))
.map(|r| format!("[{}] {}", r.id, r.output))
.collect();
parent_summary = layer_summary.join("\n");
}
Ok(results)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::planner::{Plan, SubTask};
#[tokio::test]
async fn execute_simple_linear_plan() {
// a → b → c (三层,各一个任务)
let plan = Plan::from_tasks(vec![
SubTask::new("a", "task-a"),
SubTask::new("b", "task-b").with_deps(vec!["a".to_string()]),
SubTask::new("c", "task-c").with_deps(vec!["b".to_string()]),
]);
let results = PlanExecutor::execute(&plan, |task, _parent| async move {
Ok(format!("done:{}", task.id))
})
.await
.unwrap();
assert_eq!(results.len(), 3);
assert!(results.iter().all(|r| r.success));
assert_eq!(results[0].id, "a");
assert_eq!(results[1].id, "b");
assert_eq!(results[2].id, "c");
}
#[tokio::test]
async fn execute_parallel_layer() {
// a → {b, c} → d (三层,第二层两个并行)
let plan = Plan::from_tasks(vec![
SubTask::new("a", "task-a"),
SubTask::new("b", "task-b").with_deps(vec!["a".to_string()]),
SubTask::new("c", "task-c").with_deps(vec!["a".to_string()]),
SubTask::new("d", "task-d").with_deps(vec!["b".to_string(), "c".to_string()]),
]);
let results = PlanExecutor::execute(&plan, |task, _parent| async move {
Ok(format!("done:{}", task.id))
})
.await
.unwrap();
assert_eq!(results.len(), 4);
assert!(results.iter().all(|r| r.success));
}
#[tokio::test]
async fn execute_with_failure_continues() {
// a(fail) → b: a 失败,b 仍执行(容错策略)
let plan = Plan::from_tasks(vec![
SubTask::new("a", "task-a"),
SubTask::new("b", "task-b").with_deps(vec!["a".to_string()]),
]);
let results = PlanExecutor::execute(&plan, |task, _parent| async move {
if task.id == "a" {
anyhow::bail!("task-a failed")
}
Ok("ok".to_string())
})
.await
.unwrap();
assert_eq!(results.len(), 2);
assert!(!results[0].success); // a 失败
assert!(results[1].success); // b 仍执行
}
}

View File

@@ -15,8 +15,9 @@
//! 重试期间不释放 Semaphore permit(已在调用方持有),对并发池有挤占 —— 但 complete 调用低频可接受。
use std::future::Future;
use std::time::{Duration, SystemTime};
use std::time::Duration;
use rand::Rng;
use tracing::warn;
/// 最多尝试次数(含初次)。B-260616-07: 3 次 = 初次 + 2 次重试。
@@ -34,7 +35,8 @@ const BASE_BACKOFF_SECS: u64 = 1;
const MAX_TOTAL_BUDGET: Duration = Duration::from_secs(30);
/// jitter 上限(相对 base 的 ±比例)。避免重试风暴对齐。
const JITTER_RATIO: f64 = 0.2;
/// CR-XX: 范围扩到 ±50%(由 gen_range(-0.5..0.5) × JITTER_RATIO=1.0 合成)。
const JITTER_RATIO: f64 = 1.0;
/// 一次尝试的分类结果 —— 在 anyhow 不透明化前决定是否值得重试。
///
@@ -64,22 +66,18 @@ pub fn is_status_retryable(status: u16) -> bool {
}
/// 指数退避 + jitter: 返回第 `attempt`(1-based)次重试前应 sleep 的时长。
/// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s各 ±20% jitter。
/// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s各 ±50% jitter。
///
/// jitter 用 SystemTime 纳秒取模生成(无依赖),避免多客户端同步重试风暴。
/// jitter 用 `rand::thread_rng().gen_range(-0.5..0.5)` 生成 ±50% 比例,避免多客户端同步重试风暴。
/// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。
///
/// CR-30-1: 暴露 pub 供 src-tauri/agentic.rs 流前重试复用(对齐决策 F-260616-07 a1
/// "复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。
/// "复用 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]
// ±50% jitter相对 base 时长的浮动比例
let jitter_ratio = rand::thread_rng().gen_range(-0.5..0.5); // [-0.5, 0.5)
let factor = 1.0 + jitter_ratio * JITTER_RATIO;
let ms = (base_ms as f64 * factor).max(0.0) as u64;
Duration::from_millis(ms)
}

View File

@@ -0,0 +1,118 @@
//! 原生 SSE 流式解析器 — 替代 eventsource-stream 库
//!
//! BUG-2026-07-17 根治: eventsource-stream 0.2 在 Windows 上对 Deepseek 等 provider
//! 的 SSE 响应解析时报 "Transport error: error decoding response body" 错误。
//!
//! 根因分析:
//! eventsource-stream 内部对 bytes_stream 做严格的 UTF-8 + SSE 协议校验,遇到以下情况
//! 即报错(且不可恢复):
//! - 流中断时未完整接收 UTF-8 字符(网络抖动常见)
//! - 缺少结束的 \n\n(连接断开常见)
//! - 非 ASCII 字符的多字节序列跨 chunk 边界
//!
//! 本解析器实现:
//! - 宽松的 UTF-8 处理(用 bytes 累积,String::from_utf8_lossy 转换,不报错)
//! - SSE 协议简单解析(以 \n\n 分隔事件,data: 前缀提取)
//! - 容错:解析失败时跳过该事件继续,不中断流
//! - 返回 Vec<String>(每个元素是一个事件 data 字段拼接内容)
use futures::Stream;
use std::pin::Pin;
use std::task::{Context, Poll};
/// SSE 事件流的 data 字段内容
pub type SseEvent = String;
/// 原生 SSE 解析器流:包装 bytes_stream,产出 Vec<SseEvent>(一次 poll 可能产出多个事件)
pub struct SseStream<S> {
inner: S,
buffer: Vec<u8>,
}
impl<S> SseStream<S>
where
S: Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Unpin,
{
pub fn new(inner: S) -> Self {
Self {
inner,
buffer: Vec::with_capacity(8192),
}
}
/// 从 buffer 解析完整的 SSE 事件(以 \n\n 分隔),返回事件列表
fn parse_events(&mut self) -> Vec<SseEvent> {
let mut events = Vec::new();
loop {
let sep_pos = self.buffer.windows(2).position(|w| w == b"\n\n");
if sep_pos.is_none() {
break;
}
let sep_pos = sep_pos.unwrap();
let event_bytes: Vec<u8> = self.buffer.drain(..sep_pos + 2).collect();
// 去掉末尾的 \n\n
let body_end = event_bytes.len().saturating_sub(2);
let event_text = String::from_utf8_lossy(&event_bytes[..body_end]);
let data = Self::extract_data_fields(&event_text);
if !data.is_empty() {
events.push(data);
}
}
events
}
/// 从 SSE 事件文本中提取所有 data: 行的内容,拼接为单个字符串(多个 data 行用 \n 连接)
fn extract_data_fields(event_text: &str) -> String {
let mut data_parts: Vec<&str> = Vec::new();
for line in event_text.lines() {
if let Some(rest) = line.strip_prefix("data:") {
let rest = rest.strip_prefix(' ').unwrap_or(rest);
data_parts.push(rest);
}
// 忽略 event:/id:/retry: 等其他 SSE 字段(OpenAI/Anthropic 协议未使用)
}
data_parts.join("\n")
}
}
impl<S> Stream for SseStream<S>
where
S: Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Unpin,
{
type Item = Result<Vec<SseEvent>, String>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
use futures::StreamExt;
loop {
// 先尝试从 buffer 解析完整事件
let events = self.parse_events();
if !events.is_empty() {
return Poll::Ready(Some(Ok(events)));
}
// buffer 不足以解析出完整事件,从 inner 读更多数据
match self.inner.poll_next_unpin(cx) {
Poll::Ready(Some(Ok(chunk))) => {
self.buffer.extend_from_slice(&chunk);
continue;
}
Poll::Ready(Some(Err(e))) => {
return Poll::Ready(Some(Err(format!("SSE 流读取错误: {}", e))));
}
Poll::Ready(None) => {
// 流结束,处理 buffer 中的剩余数据(可能没有 \n\n 结束的最后一段)
if !self.buffer.is_empty() {
let remaining = String::from_utf8_lossy(&self.buffer).to_string();
self.buffer.clear();
let data = Self::extract_data_fields(&remaining);
if !data.is_empty() {
return Poll::Ready(Some(Ok(vec![data])));
}
}
return Poll::Ready(None);
}
Poll::Pending => return Poll::Pending,
}
}
}
}

View File

@@ -0,0 +1,456 @@
//! 环境感知系统 — 启动时一次性探测 OS/shell/工具版本/编码,全局缓存,注入 system_prompt。
//!
//! 设计目标:让 LLM 准确生成跨平台命令。LLM 训练数据 Unix 多,易生成 macOS/Linux 语法
//! 命令(PowerShell 5 不支持 `&&`、Windows 路径分隔符 `\`、GBK 终端中文乱码等),通过
//! 把当前平台的真实环境(操作系统/Shell/工具版本)拼进 system_prompt,LLM 即可生成与
//! 当前平台兼容的命令,从根上治「LLM 跨平台命令幻觉」。
//!
//! 缓存策略:`OnceLock` 全局单例,首次 `detect()` 探测,后续调用零开销。探测本身在
//! `spawn_blocking` 中执行(执行 `tool --version` 等阻塞 IO),避免阻塞 tokio runtime。
//! 探测失败的字段设 None/默认值,不向上传播错误(环境感知是 best-effort 增强,不应
//! 阻断主流程)。
use std::sync::OnceLock;
/// 环境快照 — 系统环境的不可变视图。
#[derive(Debug, Clone, serde::Serialize)]
pub struct EnvSnapshot {
/// 操作系统族:"windows" / "macos" / "linux"
pub os: String,
/// OS 版本:"11" / "Ubuntu 22.04"(探测失败为空串)
pub os_version: String,
/// Shell 名:"powershell" / "pwsh" / "bash" / "zsh" / "sh"
pub shell: String,
/// 路径分隔符:"\\"(Windows) / "/"(Unix)
pub path_sep: String,
/// 终端编码:"utf-8" / "gbk"(Windows 中文常见 GBK,致 LLM 输出乱码)
pub encoding: String,
/// 工具版本(逐个探测,缺失为 None)
pub tools: ToolVersions,
}
/// 各开发工具的版本信息。
#[derive(Debug, Clone, serde::Serialize, Default)]
pub struct ToolVersions {
pub python: Option<String>,
pub node: Option<String>,
pub rust: Option<String>,
pub go: Option<String>,
pub docker: Option<String>,
pub git: Option<String>,
}
impl EnvSnapshot {
/// 探测环境(惰性,OnceLock 全局缓存,整个进程生命周期只探一次)。
///
/// 返回 `&'static EnvSnapshot` —— 引用静态存储,可安全地长存于 loop 不变量中。
/// 探测在 `spawn_blocking` 中同步执行(`tool --version` 是阻塞 IO),不卡 runtime。
pub async fn detect() -> &'static EnvSnapshot {
static SNAPSHOT: OnceLock<EnvSnapshot> = OnceLock::new();
if let Some(snap) = SNAPSHOT.get() {
return snap;
}
// 首次探测:同步逻辑包到 spawn_blocking,避免阻塞 async runtime。
// BUG-2026-07-18 根治: probe_version 内 std::process::Command::output() 无 timeout,
// Windows 上 python/node 若是 Microsoft Store App Execution Alias(用户未装但开了
// "应用执行别名"),`python --version` 触发 Store 重定向、process 不退出 → output()
// 永久阻塞 → spawn_blocking 线程永不返回 → detect().await 永久挂 → run_agentic_loop
// 卡在 EnvSnapshot::detect() 调用(agentic/mod.rs),后端日志断在"意图收敛工具"后,
// 前端表现为「发消息完全无回应然后卡死」(trace.log 实测 3 次发送全卡于此)。
// 修复: spawn_blocking 外包 5s timeout,超时返回 fallback 并 set 进 OnceLock(后续命中
// 缓存不再 probe),run_agentic_loop 最多等 5s 后继续,不再永久卡。
// 注: 超时后 spawn_blocking 线程仍在跑(卡在 output),但已脱离 await,不阻塞调用方;
// 线程最终随 process 退出或进程结束回收,无泄漏累积(OnceLock 已 set,不会重复 probe)。
let snap = match tokio::time::timeout(
std::time::Duration::from_secs(5),
tokio::task::spawn_blocking(|| EnvSnapshot::do_detect()),
).await {
Ok(Ok(s)) => s,
Ok(Err(join_err)) => {
eprintln!("[env_snapshot] 探测任务异常,回退默认环境: {}", join_err);
EnvSnapshot::fallback()
}
Err(_elapsed) => {
eprintln!(
"[env_snapshot] 探测超时(5s),某 tool(python/node/rustc/go/docker/git)命令卡住 \
(疑似 Windows Store App Execution Alias 触发 Store 重定向),回退默认环境"
);
EnvSnapshot::fallback()
}
};
// 多任务竞态:均等价,以先到者为准。
let _ = SNAPSHOT.set(snap);
SNAPSHOT.get().expect("EnvSnapshot 已初始化")
}
/// 同步探测(可能短暂阻塞,仅在 spawn_blocking 中调用)。
fn do_detect() -> EnvSnapshot {
EnvSnapshot {
os: std::env::consts::OS.to_string(),
os_version: detect_os_version(),
shell: detect_shell(),
path_sep: std::path::MAIN_SEPARATOR.to_string(),
encoding: detect_encoding(),
tools: ToolVersions {
python: probe_version("python", &["--version"]),
node: probe_version("node", &["--version"]),
rust: probe_version("rustc", &["--version"]),
go: probe_version("go", &["version"]),
docker: probe_version("docker", &["--version"]),
git: probe_version("git", &["--version"]),
},
}
}
/// 兜底:spawn_blocking panic/join 失败时返回最小可用快照(全 None,字段不空)。
fn fallback() -> EnvSnapshot {
EnvSnapshot {
os: std::env::consts::OS.to_string(),
os_version: String::new(),
shell: if cfg!(windows) { "powershell".into() } else { "sh".into() },
path_sep: std::path::MAIN_SEPARATOR.to_string(),
encoding: "utf-8".into(),
tools: ToolVersions::default(),
}
}
/// 生成 system_prompt 注入文本(拼到 system_prompt 尾部)。
///
/// 末尾加「请生成本平台兼容的命令」软提示,锚定 LLM 输出平台一致性。
pub fn to_prompt(&self) -> String {
let mut lines: Vec<String> = vec![
"## 系统环境".to_string(),
format!("- 操作系统: {} {}", self.os, self.os_version).trim_end().to_string(),
format!("- Shell: {}", self.shell),
format!("- 路径分隔符: {}", self.path_sep),
format!("- 终端编码: {}", self.encoding),
];
if let Some(v) = &self.tools.python {
lines.push(format!("- Python: {}", v));
}
if let Some(v) = &self.tools.node {
lines.push(format!("- Node: {}", v));
}
if let Some(v) = &self.tools.rust {
lines.push(format!("- Rust: {}", v));
}
if let Some(v) = &self.tools.go {
lines.push(format!("- Go: {}", v));
}
if let Some(v) = &self.tools.docker {
lines.push(format!("- Docker: {}", v));
}
if let Some(v) = &self.tools.git {
lines.push(format!("- Git: {}", v));
}
lines.push(String::new());
lines.push("注意: 请生成本平台兼容的命令。".to_string());
lines.join("\n")
}
}
/// 探测 OS 版本(各平台路径不一,失败返回空串而非 None,简化 prompt 拼接)。
fn detect_os_version() -> String {
// Windows:读注册表 HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion (ProductName/DisplayVersion)。
// 不依赖 winver GUI / reg.exe 输出格式,直接读注册表最稳。
#[cfg(windows)]
{
if let Some(v) = read_windows_version() {
return v;
}
return String::new();
}
// macOS:sw_vers -productVersion 输出如 "12.5"
#[cfg(target_os = "macos")]
{
if let Ok(out) = std::process::Command::new("sw_vers").arg("-productVersion").output() {
if out.status.success() {
return String::from_utf8_lossy(&out.stdout).trim().to_string();
}
}
return String::new();
}
// Linux:读 /etc/os-release 的 PRETTY_NAME 字段(系统标准位置)
#[cfg(target_os = "linux")]
{
if let Ok(content) = std::fs::read_to_string("/etc/os-release") {
for line in content.lines() {
if let Some(rest) = line.strip_prefix("PRETTY_NAME=") {
return rest.trim_matches('"').to_string();
}
}
}
return String::new();
}
#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))]
{
String::new()
}
}
#[cfg(windows)]
fn read_windows_version() -> Option<String> {
// 用 reg.exe query 读注册表(DisplayVersion 优先,如 "22H2";回退 ProductName,如 "Windows 10 Pro")。
// 避开 winreg crate 依赖(增加构建复杂度,且 reg.exe 在所有 Win 版本均自带)。
let out = std::process::Command::new("reg")
.args([
"query",
r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion",
"/v",
"DisplayVersion",
])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
.output()
.ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
// 输出形如: " DisplayVersion REG_SZ 22H2"
for line in text.lines() {
let trimmed = line.trim();
if let Some(idx) = trimmed.find("REG_SZ") {
let val = trimmed[idx + "REG_SZ".len()..].trim();
if !val.is_empty() {
return Some(format!("Windows {}", val));
}
}
}
None
}
/// 探测默认 shell(复用 shell.rs 的 pwsh 探测语义)。
fn detect_shell() -> String {
#[cfg(windows)]
{
// 优先 pwsh(PS7,支持 &&),其次 powershell(PS5),兜底 cmd。
if probe_command_success("pwsh", &["-NoProfile", "-Command", "exit 0"]) {
return "pwsh".to_string();
}
if probe_command_success("powershell", &["-NoProfile", "-Command", "exit 0"]) {
return "powershell".to_string();
}
return "cmd".to_string();
}
#[cfg(not(windows))]
{
// Unix:SHELL 环境变量优先,常见值 /bin/bash /bin/zsh /bin/sh。
if let Ok(sh) = std::env::var("SHELL") {
// 取 basename(/bin/zsh → zsh)
let name = sh.rsplit('/').next().unwrap_or(&sh);
if !name.is_empty() {
return name.to_string();
}
}
"sh".to_string()
}
}
/// 探测终端编码(Windows 中文常见 GBK,导致 LLM 输出 UTF-8 在终端乱码)。
fn detect_encoding() -> String {
#[cfg(windows)]
{
// chcp 输出形如 "活动代码页: 936"(GBK)。936 → gbk,65001 → utf-8,其余按数字降级。
if let Ok(out) = std::process::Command::new("chcp")
.stdout(Stdio::piped())
.stderr(Stdio::null())
.creation_flags(0x0800_0000)
.output()
{
let text = String::from_utf8_lossy(&out.stdout);
if let Some(code) = extract_codepage(&text) {
return match code.as_str() {
"65001" => "utf-8".to_string(),
"936" => "gbk".to_string(),
"950" => "big5".to_string(),
other => format!("cp{}", other),
};
}
}
return "utf-8".to_string();
}
#[cfg(not(windows))]
{
// Unix 默认 UTF-8(LANG/LC_ALL 通常含 UTF-8)。
if let Ok(lang) = std::env::var("LANG") {
if lang.to_ascii_uppercase().contains("UTF-8") {
return "utf-8".to_string();
}
}
"utf-8".to_string()
}
}
#[cfg(windows)]
fn extract_codepage(text: &str) -> Option<String> {
// 提取末尾数字("...936" / "...: 65001")。
let mut num = String::new();
for c in text.chars().rev() {
if c.is_ascii_digit() {
num.insert(0, c);
} else if !num.is_empty() {
break;
}
}
if num.is_empty() {
None
} else {
Some(num)
}
}
/// 执行 `tool args`,成功(true)即工具可用。Windows 加 CREATE_NO_WINDOW 防黑窗。
#[allow(dead_code)] // 仅 Windows 路径调用,非 Windows 静态裁掉
fn probe_command_success(tool: &str, args: &[&str]) -> bool {
let mut cmd = std::process::Command::new(tool);
cmd.args(args);
cmd.stdout(Stdio::null()).stderr(Stdio::null());
#[cfg(windows)]
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
cmd.status().map(|s| s.success()).unwrap_or(false)
}
/// 执行 `tool --version`,解析首行返回版本串。失败/超时返回 None,不阻塞调用方。
///
/// 例:python --version 输出 "Python 3.11.5" → 返回 "3.11.5";git --version 输出
/// "git version 2.41.0" → 返回 "2.41.0"。统一抽掉工具名前缀,只保留版本号本身。
fn probe_version(tool: &str, args: &[&str]) -> Option<String> {
let mut cmd = std::process::Command::new(tool);
cmd.args(args);
cmd.stdout(Stdio::piped()).stderr(Stdio::null());
#[cfg(windows)]
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
let out = cmd.output().ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
let first_line = text.lines().next()?;
Some(extract_version_token(first_line))
}
/// 从版本命令首行抽取版本号:取首个形如 N(.N)+ 的 token(至少一个点号)。
/// 兼容 "Python 3.11.5" / "v18.17.0" / "git version 2.41.0.windows.1" / "go version go1.21.0 ..."。
///
/// 策略:把行切成空白 token,逐个匹配「数字开头 + 至少一个 `.`」的模式,取首个命中。
/// 比 char 状态机更鲁棒(状态机遇 `2.41.0.windows.1` 这种多层嵌套点会误判)。
fn extract_version_token(line: &str) -> String {
for token in line.split_whitespace() {
// 找 token 内首个数字位置,从这里开始扫 "数字段(.数字段)*" 序列。
// 遇点要求下一字符为数字,否则在该点处截断(避免 "2.41.0.windows.1" 被吞成
// "2.41.0.windows.1",实际应止于 "2.41.0")。
let bytes = token.as_bytes();
let mut i = match bytes.iter().position(|b| b.is_ascii_digit()) {
Some(i) => i,
None => continue,
};
let mut head = String::new();
loop {
// 收数字段
let seg_start = i;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
head.push_str(&token[seg_start..i]);
// 点号:仅当下一字符为数字时才续,否则收尾
if i < bytes.len() && bytes[i] == b'.' && i + 1 < bytes.len()
&& bytes[i + 1].is_ascii_digit()
{
head.push('.');
i += 1;
continue;
}
break;
}
if head.contains('.') {
return head;
}
}
// 无版本号 token 时退回整行(避免返回空串让 prompt 出现 "None")。
line.trim().to_string()
}
// Windows 下统一在文件顶部引入 CommandExt,使各 #[cfg(windows)] 块可直接调用 creation_flags。
#[cfg(windows)]
use std::os::windows::process::CommandExt;
use std::process::Stdio;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_version_python_style() {
assert_eq!(extract_version_token("Python 3.11.5"), "3.11.5");
}
#[test]
fn extract_version_node_style() {
assert_eq!(extract_version_token("v18.17.0"), "18.17.0");
}
#[test]
fn extract_version_git_style() {
assert_eq!(extract_version_token("git version 2.41.0.windows.1"), "2.41.0");
}
#[test]
fn extract_version_go_style() {
assert_eq!(
extract_version_token("go version go1.21.0 windows/amd64"),
"1.21.0"
);
}
#[test]
fn extract_version_no_match_returns_line() {
assert_eq!(extract_version_token("no version here"), "no version here");
}
#[cfg(windows)]
#[test]
fn extract_codepage_parsing() {
assert_eq!(extract_codepage("活动代码页: 936"), Some("936".into()));
assert_eq!(
extract_codepage("Active code page: 65001"),
Some("65001".into())
);
assert_eq!(extract_codepage("no digits here"), None);
}
#[tokio::test]
async fn detect_returns_cached_static_ref() {
// 两次 detect 返回同一引用(OnceLock 全局缓存)。
let a = EnvSnapshot::detect().await as *const _;
let b = EnvSnapshot::detect().await as *const _;
assert_eq!(a, b, "detect() 应返回同一静态引用");
}
#[test]
fn to_prompt_contains_os_and_shell() {
let snap = EnvSnapshot {
os: "test_os".into(),
os_version: "v1".into(),
shell: "test_shell".into(),
path_sep: "/".into(),
encoding: "utf-8".into(),
tools: ToolVersions {
python: Some("3.11".into()),
node: None,
rust: None,
go: None,
docker: None,
git: None,
},
};
let prompt = snap.to_prompt();
assert!(prompt.contains("test_os"));
assert!(prompt.contains("test_shell"));
assert!(prompt.contains("Python: 3.11"));
assert!(!prompt.contains("Node"));
assert!(prompt.contains("请生成本平台兼容的命令"));
}
}

View File

@@ -1,3 +1,6 @@
//! df-execute: 执行运行时 — Shell
//! df-execute: 执行运行时 — Shell + 环境感知
pub mod env_snapshot;
pub mod shell;
pub use env_snapshot::EnvSnapshot;

View File

@@ -37,22 +37,36 @@ impl Default for ShellType {
// BUG-260623-04:优先 pwsh(PS7,支持 && 运算符)——LLM 训练数据 Unix 多,普遍生成 `cd x && y`,
// PS5 不支持 && 致命令失败(实测会话 6acb7f9b `cd ... && git init` InvalidEndOfLine)。
// 探测失败(未装 pwsh)回退 PS5。探测结果 OnceLock 缓存(只探一次)。
// 注:Default trait 为同步签名,这里只能读取已探测的缓存结果(若未探测则返回 false,退回 PowerShell)。
// 真实探测在异步入口 `execute()` 中调用 `probe_pwsh().await`。
if cfg!(windows) {
if probe_pwsh() { ShellType::Pwsh } else { ShellType::PowerShell }
if probe_pwsh_cached() { ShellType::Pwsh } else { ShellType::PowerShell }
} else {
ShellType::Sh
}
}
}
/// 读取 probe_pwsh 的缓存值(未探测返回 false)。供同步路径 `Default` 使用。
fn probe_pwsh_cached() -> bool {
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
CACHE.get().copied().unwrap_or(false)
}
/// 探测 pwsh(PowerShell 7)是否可用(OnceLock 缓存,只探一次)。
///
/// LLM 普遍生成 `&&`(Unix 习惯),仅 PS7+ 支持,Windows 自带 PS5 不支持。
/// 探测:成功 spawn `pwsh -Command exit 0` 即可用。同步阻塞仅一次(spawn 极快),
/// Windows 加 CREATE_NO_WINDOW 防黑窗闪现。
fn probe_pwsh() -> bool {
///
/// CR-XX:异步化 —— 在异步上下文中通过 `tokio::task::spawn_blocking` 执行阻塞探测,
/// 避免阻塞 tokio runtime。结果仍由 OnceLock 全局共享,只探测一次。
async fn probe_pwsh() -> bool {
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*CACHE.get_or_init(|| {
if let Some(cached) = CACHE.get() {
return *cached;
}
let result = tokio::task::spawn_blocking(|| {
let mut cmd = std::process::Command::new("pwsh");
cmd.arg("-NoProfile").arg("-Command").arg("exit 0");
cmd.stdout(Stdio::null()).stderr(Stdio::null());
@@ -63,6 +77,11 @@ fn probe_pwsh() -> bool {
}
cmd.status().map(|s| s.success()).unwrap_or(false)
})
.await
.unwrap_or(false);
// 多任务竞态时以先到者为准,均等价
let _ = CACHE.set(result);
result
}
/// Shell 命令执行请求
@@ -88,6 +107,10 @@ pub struct ShellRequest {
pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
let start = std::time::Instant::now();
// 探测 pwsh(惰性 + OnceLock 全局缓存,只探一次),使后续 ShellType::default() 可读取缓存
#[cfg(windows)]
let _ = probe_pwsh().await;
let shell_type = request.shell_type.unwrap_or_default();
let mut cmd = match shell_type {
ShellType::PowerShell => {

View File

@@ -324,6 +324,12 @@ fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
medium_audit("bind_directory", &format!("{id} <- {path}"));
Box::pin(async move {
let repo = ProjectRepo::new(&db);
// 分段检测 `..`(防穿越)——纯子串 contains("..") 会误伤 my..file 这类合法名,
// 改用逐段判断对齐 tool_registry.rs:validate_path 的分段检测逻辑。
let has_traversal = path.split(|c| c == '\\' || c == '/').any(|seg| seg == "..");
if has_traversal {
return CallToolResult::error(format!("路径不得包含 '..' 段: {}", path));
}
let norm = normalize_path(&path);
// 路径冲突检测
if let Some(conflict) = repo.find_path_conflict(&norm, Some(&id)).await.ok().flatten() {
@@ -332,8 +338,8 @@ fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
conflict.name, conflict.id
));
}
// 仅更新 path 字段(用 update_field,保留其它)
if !repo.update_field(&id, "path", &path).await.unwrap_or(false) {
// 仅更新 path 字段(用 normalize 后的规范化路径,保留其它)
if !repo.update_field(&id, "path", &norm).await.unwrap_or(false) {
return CallToolResult::error(format!("项目不存在: {id}"));
}
let updated = repo.get_by_id(&id).await.ok().flatten();

View File

@@ -15,3 +15,4 @@ tokio = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }

View File

@@ -0,0 +1,419 @@
//! Docker 节点 — 在 Docker 容器内执行命令
//!
//! 通过 `docker run --rm` 一次性容器执行命令。复用 df_execute::shell::execute 调用本地
//! docker CLI,与 ScriptNode/GitNode 路径一致(不内嵌 docker SDK,CLI 更通用透明)。
//!
//! 执行流程:
//! 1. 先 `docker --version` 探测 Docker 可用性(未装/未运行直接报错,避免容器启动失败
//! 时退出码语义混淆)。
//! 2. 构建 `docker run --rm {volumes} {env} -w {working_dir} {image} {command}`。
//! 3. 经 df_execute::shell::execute 执行,回传 stdout/stderr/exit_code。
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// Docker 节点
pub struct DockerNode;
/// 卷挂载配置项(volumes 数组元素)
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VolumeMount {
pub host: String,
pub container: String,
}
/// 从 NodeContext.config 解析出的 DockerNode 参数。
/// 抽离此结构便于单元测试 config 解析逻辑(无需起 shell / 真实容器)。
#[derive(Debug, Clone)]
pub struct DockerParams {
/// Docker 镜像名(必填,如 "rust:latest")
pub image: String,
/// 容器内执行命令(必填)
pub command: String,
/// 容器内工作目录(默认 "/workspace")
pub working_dir: String,
/// 超时秒数(默认 300)
pub timeout_secs: u64,
/// 卷挂载列表(可选)
pub volumes: Vec<VolumeMount>,
/// 环境变量(可选)
pub env: std::collections::HashMap<String, String>,
}
/// 从 config JSON 解析 DockerParams。image / command 必填,其余按默认/可选。
pub fn parse_params(config: &serde_json::Value) -> anyhow::Result<DockerParams> {
let image = config
.get("image")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("DockerNode 缺少必填参数: image"))?
.to_string();
let command = config
.get("command")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("DockerNode 缺少必填参数: command"))?
.to_string();
let working_dir = config
.get("working_dir")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| "/workspace".to_string());
let timeout_secs = config
.get("timeout_secs")
.and_then(|v| v.as_u64())
.unwrap_or(300);
// volumes: JSON 数组,每项 {"host": "...", "container": "..."}
// 容错:类型不符/缺字段项跳过(不整体失败,单条坏配置不阻塞整个工作流)。
let volumes = config
.get("volumes")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| {
let host = item.get("host")?.as_str()?.to_string();
let container = item.get("container")?.as_str()?.to_string();
Some(VolumeMount { host, container })
})
.collect()
})
.unwrap_or_default();
// env: JSON 对象 { KEY: VALUE },值统一转字符串。
let env = config
.get("env")
.and_then(|v| v.as_object())
.map(|obj| {
obj.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect()
})
.unwrap_or_default();
Ok(DockerParams {
image,
command,
working_dir,
timeout_secs,
volumes,
env,
})
}
/// 探测 Docker 是否可用:`docker --version` 退出码 0 视为可用。
async fn check_docker_available() -> anyhow::Result<()> {
let request = df_execute::shell::ShellRequest {
command: "docker --version".to_string(),
working_dir: None,
env: std::collections::HashMap::new(),
timeout_secs: Some(15),
shell_type: Default::default(),
};
let result = df_execute::shell::execute(request).await;
match result {
Ok(r) if r.exit_code.unwrap_or(-1) == 0 => Ok(()),
_ => anyhow::bail!("Docker 未安装或未运行"),
}
}
/// 构建 docker run 命令字符串。
/// 卷/环境变量值用 shell_quote 包裹,防止空格/特殊字符注入。
fn build_command(params: &DockerParams) -> String {
let mut parts: Vec<String> = vec!["docker run --rm".to_string()];
for v in &params.volumes {
parts.push(format!(
"-v {}:{}",
shell_quote(&v.host),
shell_quote(&v.container)
));
}
for (k, val) in &params.env {
parts.push(format!("-e {}={}", k, shell_quote(val)));
}
parts.push(format!("-w {}", shell_quote(&params.working_dir)));
parts.push(shell_quote(&params.image));
// command 原样追加(用户自行决定是否含参数 / shell 元字符),不做引号包裹,
// 与脚本节点一致由 shell 解释器解析。
parts.push(params.command.clone());
parts.join(" ")
}
/// 简单 shell 引号包裹:含空格/特殊字符时用双引号包裹并转义内嵌双引号。
fn shell_quote(s: &str) -> String {
if s
.chars()
.any(|c| c.is_whitespace() || c == '"' || c == '$' || c == '`')
{
format!("\"{}\"", s.replace('"', "\\\""))
} else {
s.to_string()
}
}
#[async_trait]
impl Node for DockerNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult {
tracing::info!("DockerNode 执行: node_id={}", ctx.node_id);
// 1. Docker 可用性探测(未装/未运行直接 fail-fast)。
if let Err(e) = check_docker_available().await {
anyhow::bail!(e.to_string());
}
let params = parse_params(&ctx.config)?;
let command = build_command(&params);
tracing::info!(
image = %params.image,
working_dir = %params.working_dir,
timeout_secs = params.timeout_secs,
"DockerNode 构建命令: {}",
command
);
let request = df_execute::shell::ShellRequest {
command,
// 宿主机工作目录对 docker run 无意义,置 None。
working_dir: None,
env: std::collections::HashMap::new(),
timeout_secs: Some(params.timeout_secs),
shell_type: Default::default(),
};
let result = df_execute::shell::execute(request).await?;
let exit_code = result.exit_code.unwrap_or(-1);
tracing::info!(
exit_code,
duration_ms = result.duration_ms,
"DockerNode 完成"
);
Ok(NodeOutput::from_value(serde_json::json!({
"image": params.image,
"stdout": result.stdout,
"stderr": result.stderr,
"exit_code": exit_code,
"duration_ms": result.duration_ms,
})))
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"image": { "type": "string", "description": "Docker 镜像名(如 rust:latest)" },
"command": { "type": "string", "description": "容器内执行命令" },
"working_dir": { "type": "string", "description": "容器工作目录(默认 /workspace)" },
"timeout_secs": { "type": "integer", "description": "超时秒数(默认 300)" },
"volumes": {
"type": "array",
"description": "卷挂载 [{host, container}]",
"items": {
"type": "object",
"properties": {
"host": { "type": "string" },
"container": { "type": "string" }
}
}
},
"env": { "type": "object", "description": "环境变量键值对" }
},
"required": ["image", "command"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"image": { "type": "string" },
"stdout": { "type": "string" },
"stderr": { "type": "string" },
"exit_code": { "type": "integer" },
"duration_ms": { "type": "integer" }
}
}),
}
}
fn node_type(&self) -> &str {
"docker"
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
// ── parse_params: 必填缺失 ──
#[test]
fn params_missing_image_errors() {
let err = parse_params(&json!({ "command": "ls" }))
.unwrap_err()
.to_string();
assert!(err.contains("image"), "实际: {}", err);
}
#[test]
fn params_missing_command_errors() {
let err = parse_params(&json!({ "image": "rust:latest" }))
.unwrap_err()
.to_string();
assert!(err.contains("command"), "实际: {}", err);
}
// ── parse_params: 默认值 ──
#[test]
fn params_minimal_uses_defaults() {
let p = parse_params(&json!({ "image": "alpine", "command": "echo hi" })).unwrap();
assert_eq!(p.image, "alpine");
assert_eq!(p.command, "echo hi");
assert_eq!(p.working_dir, "/workspace");
assert_eq!(p.timeout_secs, 300);
assert!(p.volumes.is_empty());
assert!(p.env.is_empty());
}
// ── parse_params: 完整字段 ──
#[test]
fn params_full_fields() {
let p = parse_params(&json!({
"image": "rust:latest",
"command": "cargo test",
"working_dir": "/app",
"timeout_secs": 120
}))
.unwrap();
assert_eq!(p.image, "rust:latest");
assert_eq!(p.command, "cargo test");
assert_eq!(p.working_dir, "/app");
assert_eq!(p.timeout_secs, 120);
}
// ── parse_params: volumes 解析 ──
#[test]
fn params_volumes_parsed() {
let p = parse_params(&json!({
"image": "node",
"command": "npm ci",
"volumes": [
{ "host": "/host/a", "container": "/c/a" },
{ "host": "/host/b", "container": "/c/b" }
]
}))
.unwrap();
assert_eq!(p.volumes.len(), 2);
assert_eq!(
p.volumes[0],
VolumeMount {
host: "/host/a".into(),
container: "/c/a".into()
}
);
assert_eq!(p.volumes[1].container, "/c/b");
}
#[test]
fn params_volumes_skips_malformed_items() {
// 缺 container / 非 object 项应被跳过,不整体失败。
let p = parse_params(&json!({
"image": "node",
"command": "ls",
"volumes": [
{ "host": "/ok", "container": "/ok" },
{ "host": "/no-container" },
"not-an-object",
{ "container": "/no-host" }
]
}))
.unwrap();
assert_eq!(p.volumes.len(), 1);
assert_eq!(p.volumes[0].host, "/ok");
}
// ── parse_params: env 解析 ──
#[test]
fn params_env_parsed() {
let p = parse_params(&json!({
"image": "python",
"command": "pytest",
"env": {
"FOO": "bar",
"DEBUG": "1"
}
}))
.unwrap();
assert_eq!(p.env.len(), 2);
assert_eq!(p.env.get("FOO").map(|s| s.as_str()), Some("bar"));
assert_eq!(p.env.get("DEBUG").map(|s| s.as_str()), Some("1"));
}
#[test]
fn params_env_empty_when_non_object() {
// env 非 object(误传字符串)时回退空 map,不报错。
let p = parse_params(&json!({
"image": "python",
"command": "ls",
"env": "should-be-object"
}))
.unwrap();
assert!(p.env.is_empty());
}
// ── build_command ──
#[test]
fn command_minimal_shape() {
let p = parse_params(&json!({
"image": "alpine",
"command": "echo hello"
}))
.unwrap();
let cmd = build_command(&p);
assert!(cmd.starts_with("docker run --rm"), "实际: {}", cmd);
assert!(cmd.contains("-w /workspace"), "实际: {}", cmd);
assert!(cmd.contains(" alpine "), "实际: {}", cmd);
assert!(cmd.ends_with("echo hello"), "实际: {}", cmd);
}
#[test]
fn command_includes_volumes_and_env() {
let p = parse_params(&json!({
"image": "rust:latest",
"command": "cargo build",
"working_dir": "/app",
"volumes": [
{ "host": "/host/src", "container": "/app" }
],
"env": { "CARGO_HOME": "/cargo" }
}))
.unwrap();
let cmd = build_command(&p);
assert!(cmd.contains("-v /host/src:/app"), "实际: {}", cmd);
assert!(cmd.contains("-e CARGO_HOME=/cargo"), "实际: {}", cmd);
assert!(cmd.contains("-w /app"), "实际: {}", cmd);
}
// ── shell_quote ──
#[test]
fn shell_quote_plain_passthrough() {
assert_eq!(shell_quote("abc"), "abc");
}
#[test]
fn shell_quote_wraps_spaces() {
assert_eq!(shell_quote("/a b/c"), "\"/a b/c\"");
}
}

View File

@@ -0,0 +1,426 @@
//! Git 节点 — 执行 git CLI 命令(branch/checkout/commit/merge/push/status/log)
//!
//! 通过 df_execute::shell::execute 调用本地 git CLI,working_dir 指定仓库路径。
//! 不内嵌 git2/libgit2:CLI 路径更通用、调试透明,与 ScriptNode 一致。
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// Git 节点
pub struct GitNode;
/// 支持的 Git 动作
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GitAction {
Branch,
Checkout,
Commit,
Merge,
Push,
Status,
Log,
}
impl GitAction {
/// 从 config.action 字符串解析动作,非法值返回 Err。
pub fn parse(raw: &str) -> anyhow::Result<Self> {
match raw {
"branch" => Ok(GitAction::Branch),
"checkout" => Ok(GitAction::Checkout),
"commit" => Ok(GitAction::Commit),
"merge" => Ok(GitAction::Merge),
"push" => Ok(GitAction::Push),
"status" => Ok(GitAction::Status),
"log" => Ok(GitAction::Log),
other => anyhow::bail!(
"GitNode 非法 action: {}(合法值: branch|checkout|commit|merge|push|status|log",
other
),
}
}
fn as_str(&self) -> &'static str {
match self {
GitAction::Branch => "branch",
GitAction::Checkout => "checkout",
GitAction::Commit => "commit",
GitAction::Merge => "merge",
GitAction::Push => "push",
GitAction::Status => "status",
GitAction::Log => "log",
}
}
}
/// 从 NodeContext.config 解析出的 GitNode 参数。
/// 抽离此结构便于单元测试 config 解析逻辑(无需构造完整 NodeContext / 起 shell)。
#[derive(Debug, Clone)]
pub struct GitParams {
pub action: GitAction,
/// 目标分支(branch/checkout/merge)
pub branch_name: Option<String>,
/// commit 信息(commit)
pub message: Option<String>,
/// 仓库路径(None=当前目录)
pub working_dir: Option<String>,
}
/// 从 config JSON 解析 GitParams。action 必填;其余按需。
pub fn parse_params(config: &serde_json::Value) -> anyhow::Result<GitParams> {
let action = config
.get("action")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("GitNode 缺少必填参数: action"))?;
let action = GitAction::parse(action)?;
let branch_name = config
.get("branch_name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let message = config
.get("message")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let working_dir = config
.get("working_dir")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Ok(GitParams {
action,
branch_name,
message,
working_dir,
})
}
/// 根据 action + 参数构建 git CLI 命令字符串。working_dir 由 ShellRequest 处理,此处不含 cd。
fn build_command(params: &GitParams) -> anyhow::Result<String> {
match params.action {
GitAction::Branch => {
// 无 branch_name → 列出所有本地分支;有 → 创建新分支
match &params.branch_name {
Some(name) => Ok(format!("git branch {}", shell_quote(name))),
None => Ok("git branch".to_string()),
}
}
GitAction::Checkout => {
let name = params
.branch_name
.as_deref()
.ok_or_else(|| anyhow::anyhow!("GitNode action=checkout 缺少 branch_name"))?;
Ok(format!("git checkout {}", shell_quote(name)))
}
GitAction::Commit => {
let msg = params
.message
.as_deref()
.ok_or_else(|| anyhow::anyhow!("GitNode action=commit 缺少 message"))?;
Ok(format!("git commit -m {}", shell_quote(msg)))
}
GitAction::Merge => {
let name = params
.branch_name
.as_deref()
.ok_or_else(|| anyhow::anyhow!("GitNode action=merge 缺少 branch_name"))?;
Ok(format!("git merge {}", shell_quote(name)))
}
GitAction::Push => Ok("git push".to_string()),
GitAction::Status => Ok("git status".to_string()),
GitAction::Log => Ok("git log --oneline -20".to_string()),
}
}
/// 简单 shell 引号包裹:含空格/特殊字符时用双引号包裹并转义内嵌双引号。
/// 仅用于内部参数拼接(branch_name/message),命令名固定白名单不用户可控。
fn shell_quote(s: &str) -> String {
if s.chars().any(|c| c.is_whitespace() || c == '"' || c == '$' || c == '`') {
format!("\"{}\"", s.replace('"', "\\\""))
} else {
s.to_string()
}
}
#[async_trait]
impl Node for GitNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult {
tracing::info!("GitNode 执行: node_id={}", ctx.node_id);
let params = parse_params(&ctx.config)?;
let command = build_command(&params)?;
tracing::info!(
action = params.action.as_str(),
working_dir = ?params.working_dir,
"GitNode 构建命令: {}",
command
);
let request = df_execute::shell::ShellRequest {
command,
working_dir: params.working_dir.clone(),
env: std::collections::HashMap::new(),
timeout_secs: Some(60),
shell_type: Default::default(),
};
let result = df_execute::shell::execute(request).await?;
let exit_code = result.exit_code.unwrap_or(-1);
if exit_code != 0 {
anyhow::bail!(
"GitNode 执行失败 (exit_code={}): {}",
exit_code,
result.stderr.trim()
);
}
// 按 action 提取结构化字段(branch 列表/commit hash 等),无法解析时回退原始 stdout。
let parsed = parse_action_output(&params.action, &result.stdout);
tracing::info!(
action = params.action.as_str(),
exit_code,
duration_ms = result.duration_ms,
"GitNode 完成"
);
Ok(NodeOutput::from_value(serde_json::json!({
"action": params.action.as_str(),
"stdout": result.stdout,
"stderr": result.stderr,
"exit_code": exit_code,
"duration_ms": result.duration_ms,
"parsed": parsed,
})))
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["branch", "checkout", "commit", "merge", "push", "status", "log"]
},
"branch_name": { "type": "string", "description": "目标分支(branch/checkout/merge)" },
"message": { "type": "string", "description": "commit 信息(commit)" },
"working_dir": { "type": "string", "description": "git 仓库路径(可选)" }
},
"required": ["action"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"action": { "type": "string" },
"stdout": { "type": "string" },
"stderr": { "type": "string" },
"exit_code": { "type": "integer" },
"duration_ms": { "type": "integer" },
"parsed": { "type": "object" }
}
}),
}
}
fn node_type(&self) -> &str {
"git"
}
}
/// 按 action 解析 stdout 成结构化字段。
/// - branch(无参列出):每行一个分支,`*` 标记当前分支
/// - log:每行一条 `hash subject`
/// - commit:首段 hash
/// - 其余动作无强结构,返回空对象
fn parse_action_output(action: &GitAction, stdout: &str) -> serde_json::Value {
match action {
GitAction::Branch => {
let branches: Vec<&str> = stdout.lines().map(|l| l.trim_start_matches("* ").trim()).collect();
let current = stdout
.lines()
.find(|l| l.starts_with('*'))
.map(|l| l.trim_start_matches("* ").trim().to_string());
serde_json::json!({ "branches": branches, "current": current })
}
GitAction::Log => {
let entries: Vec<serde_json::Value> = stdout
.lines()
.filter_map(|l| {
let l = l.trim();
let mut parts = l.splitn(2, ' ');
let hash = parts.next()?.to_string();
let subject = parts.next().unwrap_or("").to_string();
Some(serde_json::json!({ "hash": hash, "subject": subject }))
})
.collect();
serde_json::json!({ "entries": entries })
}
GitAction::Commit => {
// `git commit` 默认输出含 `[branch hash]` 形式;取首个 7+ 位十六进制段
let hash = stdout
.split_whitespace()
.find(|t| t.len() >= 7 && t.chars().all(|c| c.is_ascii_hexdigit()))
.unwrap_or("")
.to_string();
serde_json::json!({ "commit_hash": hash })
}
_ => serde_json::json!({}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
// ── GitAction::parse ──
#[test]
fn parse_all_valid_actions() {
for raw in ["branch", "checkout", "commit", "merge", "push", "status", "log"] {
let a = GitAction::parse(raw).unwrap_or_else(|e| panic!("合法 action {} 应解析成功: {}", raw, e));
assert_eq!(a.as_str(), raw);
}
}
#[test]
fn parse_invalid_action_errors() {
let err = GitAction::parse("rebase").unwrap_err().to_string();
assert!(err.contains("非法 action"), "实际: {}", err);
assert!(err.contains("rebase"));
}
#[test]
fn parse_empty_action_errors() {
let err = GitAction::parse("").unwrap_err().to_string();
assert!(err.contains("非法 action"), "实际: {}", err);
}
// ── parse_params(config) ──
#[test]
fn params_missing_action_errors() {
let err = parse_params(&json!({ "branch_name": "main" }))
.unwrap_err()
.to_string();
assert!(err.contains("action"), "实际: {}", err);
}
#[test]
fn params_minimal_valid() {
let p = parse_params(&json!({ "action": "status" })).unwrap();
assert_eq!(p.action, GitAction::Status);
assert_eq!(p.branch_name, None);
assert_eq!(p.message, None);
assert_eq!(p.working_dir, None);
}
#[test]
fn params_full_fields() {
let p = parse_params(&json!({
"action": "commit",
"branch_name": "feature/x",
"message": "fix: 修复",
"working_dir": "/repo"
}))
.unwrap();
assert_eq!(p.action, GitAction::Commit);
assert_eq!(p.branch_name.as_deref(), Some("feature/x"));
assert_eq!(p.message.as_deref(), Some("fix: 修复"));
assert_eq!(p.working_dir.as_deref(), Some("/repo"));
}
// ── build_command ──
#[test]
fn command_branch_list_when_no_name() {
let p = parse_params(&json!({ "action": "branch" })).unwrap();
assert_eq!(build_command(&p).unwrap(), "git branch");
}
#[test]
fn command_branch_create_when_name() {
let p = parse_params(&json!({ "action": "branch", "branch_name": "feat" })).unwrap();
assert_eq!(build_command(&p).unwrap(), "git branch feat");
}
#[test]
fn command_checkout_requires_name() {
let p = parse_params(&json!({ "action": "checkout" })).unwrap();
let err = build_command(&p).unwrap_err().to_string();
assert!(err.contains("branch_name"), "实际: {}", err);
}
#[test]
fn command_commit_requires_message() {
let p = parse_params(&json!({ "action": "commit" })).unwrap();
let err = build_command(&p).unwrap_err().to_string();
assert!(err.contains("message"), "实际: {}", err);
}
#[test]
fn command_commit_quotes_message_with_space() {
let p = parse_params(&json!({ "action": "commit", "message": "fix bug a" })).unwrap();
let cmd = build_command(&p).unwrap();
assert_eq!(cmd, "git commit -m \"fix bug a\"");
}
#[test]
fn command_merge_requires_name() {
let p = parse_params(&json!({ "action": "merge" })).unwrap();
assert!(build_command(&p).is_err());
}
#[test]
fn command_push_status_log_fixed() {
for (action, expected) in [
("push", "git push"),
("status", "git status"),
("log", "git log --oneline -20"),
] {
let p = parse_params(&json!({ "action": action })).unwrap();
assert_eq!(build_command(&p).unwrap(), expected);
}
}
// ── shell_quote ──
#[test]
fn shell_quote_plain_passthrough() {
assert_eq!(shell_quote("feat"), "feat");
}
#[test]
fn shell_quote_escapes_embedded_double_quote() {
let q = shell_quote("a\"b");
assert_eq!(q, "\"a\\\"b\"");
}
// ── parse_action_output ──
#[test]
fn parse_branch_output() {
let out = parse_action_output(
&GitAction::Branch,
"* main\n develop\n feature/x\n",
);
assert_eq!(out["current"], "main");
assert_eq!(out["branches"].as_array().unwrap().len(), 3);
}
#[test]
fn parse_log_output() {
let out = parse_action_output(
&GitAction::Log,
"abc1234 fix bug\n deadbeef add feature\n",
);
let entries = out["entries"].as_array().unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0]["hash"], "abc1234");
assert_eq!(entries[1]["subject"], "add feature");
}
}

View File

@@ -0,0 +1,319 @@
//! HTTP 节点 — 发起 HTTP 请求(GET/POST/PUT/DELETE)
//!
//! 使用 reqwest(与 df-ai 同一版本 0.12)。config 提供 method/url/headers/body/timeout_secs。
//! 输出 status_code + body + (可选)响应头子集。
use std::time::Duration;
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// HTTP 节点
pub struct HttpNode;
/// 支持的 HTTP 方法
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HttpMethod {
Get,
Post,
Put,
Delete,
}
impl HttpMethod {
/// 从 config.method 字符串解析,大小写不敏感,非法值返回 Err。缺省返回 GET。
pub fn parse(raw: Option<&str>) -> anyhow::Result<Self> {
match raw.map(|s| s.to_ascii_uppercase()).as_deref() {
None | Some("GET") => Ok(HttpMethod::Get),
Some("POST") => Ok(HttpMethod::Post),
Some("PUT") => Ok(HttpMethod::Put),
Some("DELETE") => Ok(HttpMethod::Delete),
Some(other) => anyhow::bail!(
"HttpNode 非法 method: {}(合法值: GET|POST|PUT|DELETE",
other
),
}
}
fn as_str(&self) -> &'static str {
match self {
HttpMethod::Get => "GET",
HttpMethod::Post => "POST",
HttpMethod::Put => "PUT",
HttpMethod::Delete => "DELETE",
}
}
}
/// 从 NodeContext.config 解析出的 HttpNode 参数。
/// 抽离此结构便于单元测试 config 解析逻辑(无需发起真实网络请求)。
#[derive(Debug, Clone)]
pub struct HttpParams {
pub method: HttpMethod,
pub url: String,
/// 请求头(JSON 对象 → Vec<(name, value)>)
pub headers: Vec<(String, String)>,
/// 请求体(可选)
pub body: Option<String>,
/// 超时秒数(缺省 30)
pub timeout_secs: u64,
}
/// 默认超时
const DEFAULT_TIMEOUT_SECS: u64 = 30;
/// 从 config JSON 解析 HttpParams。url 必填;method 缺省 GET;timeout 缺省 30。
pub fn parse_params(config: &serde_json::Value) -> anyhow::Result<HttpParams> {
let method = HttpMethod::parse(config.get("method").and_then(|v| v.as_str()))?;
let url = config
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("HttpNode 缺少必填参数: url"))?;
if url.trim().is_empty() {
anyhow::bail!("HttpNode url 不能为空");
}
// headers:JSON 对象 → 有序 (name, value) 列表。非对象/非字符串值忽略并 warn。
let mut headers = Vec::new();
if let Some(obj) = config.get("headers").and_then(|v| v.as_object()) {
for (k, v) in obj {
match v.as_str() {
Some(s) => headers.push((k.clone(), s.to_string())),
None => {
tracing::warn!(
header = %k,
"HttpNode headers 中 header 值非字符串,忽略"
);
}
}
}
}
let body = config
.get("body")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let timeout_secs = config
.get("timeout_secs")
.and_then(|v| v.as_u64())
.unwrap_or(DEFAULT_TIMEOUT_SECS);
Ok(HttpParams {
method,
url: url.to_string(),
headers,
body,
timeout_secs,
})
}
#[async_trait]
impl Node for HttpNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult {
tracing::info!("HttpNode 执行: node_id={}", ctx.node_id);
let params = parse_params(&ctx.config)?;
tracing::info!(
method = params.method.as_str(),
url = %params.url,
timeout_secs = params.timeout_secs,
"HttpNode 发起请求"
);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(params.timeout_secs))
.build()?;
let mut req = match params.method {
HttpMethod::Get => client.get(&params.url),
HttpMethod::Post => client.post(&params.url),
HttpMethod::Put => client.put(&params.url),
HttpMethod::Delete => client.delete(&params.url),
};
for (name, value) in &params.headers {
req = req.header(name, value);
}
if let Some(body) = &params.body {
req = req.body(body.clone());
}
let response = req.send().await?;
let status_code = response.status().as_u16();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let body_text = response.text().await?;
tracing::info!(
method = params.method.as_str(),
url = %params.url,
status_code,
body_len = body_text.len(),
"HttpNode 完成"
);
Ok(NodeOutput::from_value(serde_json::json!({
"method": params.method.as_str(),
"url": params.url,
"status_code": status_code,
"body": body_text,
"content_type": content_type,
})))
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"method": {
"type": "string",
"enum": ["GET", "POST", "PUT", "DELETE"],
"default": "GET"
},
"url": { "type": "string", "description": "请求 URL" },
"headers": {
"type": "object",
"description": "请求头 JSON 对象",
"additionalProperties": { "type": "string" }
},
"body": { "type": "string", "description": "请求体(可选)" },
"timeout_secs": { "type": "integer", "default": 30 }
},
"required": ["url"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"method": { "type": "string" },
"url": { "type": "string" },
"status_code": { "type": "integer" },
"body": { "type": "string" },
"content_type": { "type": "string" }
}
}),
}
}
fn node_type(&self) -> &str {
"http"
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
// ── HttpMethod::parse ──
#[test]
fn method_defaults_to_get_when_absent() {
assert_eq!(HttpMethod::parse(None).unwrap(), HttpMethod::Get);
}
#[test]
fn method_case_insensitive() {
assert_eq!(HttpMethod::parse(Some("get")).unwrap(), HttpMethod::Get);
assert_eq!(HttpMethod::parse(Some("Post")).unwrap(), HttpMethod::Post);
assert_eq!(HttpMethod::parse(Some("DELETE")).unwrap(), HttpMethod::Delete);
assert_eq!(HttpMethod::parse(Some("pUt")).unwrap(), HttpMethod::Put);
}
#[test]
fn method_invalid_errors() {
let err = HttpMethod::parse(Some("PATCH")).unwrap_err().to_string();
assert!(err.contains("非法 method"), "实际: {}", err);
assert!(err.contains("PATCH"));
}
// ── parse_params(config) ──
#[test]
fn params_missing_url_errors() {
let err = parse_params(&json!({ "method": "GET" }))
.unwrap_err()
.to_string();
assert!(err.contains("url"), "实际: {}", err);
}
#[test]
fn params_empty_url_errors() {
let err = parse_params(&json!({ "url": " " })).unwrap_err().to_string();
assert!(err.contains(""), "实际: {}", err);
}
#[test]
fn params_defaults_method_and_timeout() {
let p = parse_params(&json!({ "url": "https://example.com" })).unwrap();
assert_eq!(p.method, HttpMethod::Get);
assert_eq!(p.timeout_secs, DEFAULT_TIMEOUT_SECS);
assert!(p.headers.is_empty());
assert_eq!(p.body, None);
}
#[test]
fn params_custom_timeout() {
let p = parse_params(&json!({ "url": "https://x", "timeout_secs": 5 })).unwrap();
assert_eq!(p.timeout_secs, 5);
}
#[test]
fn params_parses_headers_object() {
let p = parse_params(&json!({
"url": "https://x",
"headers": {
"Authorization": "Bearer abc",
"X-Trace-Id": "123"
}
}))
.unwrap();
// headers 顺序由 serde_json BTreeMap 保证;只校验集合
let map: std::collections::HashMap<&str, &str> =
p.headers.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
assert_eq!(map.get("Authorization"), Some(&"Bearer abc"));
assert_eq!(map.get("X-Trace-Id"), Some(&"123"));
}
#[test]
fn params_non_string_header_value_skipped() {
// 非字符串值不应炸,应被跳过
let p = parse_params(&json!({
"url": "https://x",
"headers": { "X-Num": 123, "X-Ok": "yes" }
}))
.unwrap();
let names: Vec<&str> = p.headers.iter().map(|(k, _)| k.as_str()).collect();
assert!(names.contains(&"X-Ok"));
assert!(!names.contains(&"X-Num"), "非字符串 header 应被跳过");
}
#[test]
fn params_parses_body() {
let p = parse_params(&json!({
"url": "https://x",
"method": "POST",
"body": "{\"k\":1}"
}))
.unwrap();
assert_eq!(p.method, HttpMethod::Post);
assert_eq!(p.body.as_deref(), Some("{\"k\":1}"));
}
#[test]
fn params_invalid_method_propagates() {
let err = parse_params(&json!({ "url": "https://x", "method": "TRACE" }))
.unwrap_err()
.to_string();
assert!(err.contains("非法 method"), "实际: {}", err);
}
}

View File

@@ -218,7 +218,7 @@ mod tests {
node_id: node_id.to_string(),
inputs: HashMap::new(),
config,
execution_id: execution_id.to_string(),
execution_id: execution_id.into(),
event_bus: event_bus.clone(),
node_status: StateMachine::new(),
}
@@ -235,7 +235,7 @@ mod tests {
) {
event_bus
.send(WorkflowEvent::HumanApprovalResponse {
execution_id: execution_id.to_string(),
execution_id: execution_id.into(),
node_id: node_id.to_string(),
decision: decision.to_string(),
decisions: vec![],
@@ -254,7 +254,7 @@ mod tests {
) {
event_bus
.send(WorkflowEvent::HumanApprovalResponse {
execution_id: execution_id.to_string(),
execution_id: execution_id.into(),
node_id: node_id.to_string(),
decision: String::new(),
decisions: decisions.iter().map(|s| s.to_string()).collect(),
@@ -477,8 +477,8 @@ mod tests {
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 mut executor = DagExecutor::new(bus.clone(), exec_id.into());
let sm = executor.state_machine(); // 共享状态机(spawn 后仍可读)
let run_handle = tokio::spawn(async move {
executor
@@ -537,7 +537,7 @@ mod tests {
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 mut executor = DagExecutor::new(bus.clone(), exec_id.into());
let sm = executor.state_machine();
let run_handle = tokio::spawn(async move {

View File

@@ -1,10 +1,15 @@
//! df-nodes: 内置节点集合 — AI、脚本、人工审批
//! df-nodes: 内置节点集合 — AI、脚本、人工审批、Git、Docker、HTTP、通知
pub mod ai_node;
pub mod ai_self_review_node;
mod ai_node_helpers;
pub mod docker_node;
pub mod git_node;
pub mod http_node;
pub mod human_node;
mod human_node_helpers;
pub mod notify_node;
pub mod subflow_node;
pub mod script_node;
pub mod task_advance_node;
pub mod task_state_machine;

View File

@@ -0,0 +1,292 @@
//! 通知节点 — desktop(本地日志,后续接 tauri-plugin-notification) / webhook
//!
//! - type=desktop:tracing::info! 输出(桌面通知集成延后,见 PROGRESS.md 后续 Sprint)
//! - type=webhook:POST JSON {title, message} 到 webhook_url
//!
//! 节点语义:尽力而为,通知失败不阻断工作流(webhook 发送失败时输出 success=false 但仍 Ok 返回)。
use std::time::Duration;
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// 通知节点
pub struct NotifyNode;
/// 通知类型
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NotifyType {
Desktop,
Webhook,
}
impl NotifyType {
/// 从 config.type 字符串解析,非法值返回 Err。
pub fn parse(raw: &str) -> anyhow::Result<Self> {
match raw {
"desktop" => Ok(NotifyType::Desktop),
"webhook" => Ok(NotifyType::Webhook),
other => anyhow::bail!(
"NotifyNode 非法 type: {}(合法值: desktop|webhook",
other
),
}
}
fn as_str(&self) -> &'static str {
match self {
NotifyType::Desktop => "desktop",
NotifyType::Webhook => "webhook",
}
}
}
/// 从 NodeContext.config 解析出的 NotifyNode 参数。
/// 抽离此结构便于单元测试 config 解析逻辑(无需发起网络请求)。
#[derive(Debug, Clone)]
pub struct NotifyParams {
pub notify_type: NotifyType,
pub title: String,
pub message: String,
/// webhook 类型必填;desktop 类型忽略
pub webhook_url: Option<String>,
}
/// 从 config JSON 解析 NotifyParams。type 必填;webhook 类型要求 webhook_url。
pub fn parse_params(config: &serde_json::Value) -> anyhow::Result<NotifyParams> {
let raw_type = config
.get("type")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("NotifyNode 缺少必填参数: type"))?;
let notify_type = NotifyType::parse(raw_type)?;
let title = config
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("通知")
.to_string();
let message = config
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let webhook_url = config
.get("webhook_url")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// webhook 类型必须有 webhook_url
if notify_type == NotifyType::Webhook {
if webhook_url.as_deref().map(|s| s.trim().is_empty()).unwrap_or(true) {
anyhow::bail!("NotifyNode type=webhook 缺少 webhook_url");
}
}
Ok(NotifyParams {
notify_type,
title,
message,
webhook_url,
})
}
#[async_trait]
impl Node for NotifyNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult {
tracing::info!("NotifyNode 执行: node_id={}", ctx.node_id);
let params = parse_params(&ctx.config)?;
match params.notify_type {
NotifyType::Desktop => {
// 桌面通知集成延后;当前仅日志,后续接 tauri-plugin-notification。
tracing::info!(
title = %params.title,
message = %params.message,
"NotifyNode desktop 通知(日志占位,集成待后续 Sprint)"
);
Ok(NodeOutput::from_value(serde_json::json!({
"type": params.notify_type.as_str(),
"title": params.title,
"message": params.message,
"delivered": true,
})))
}
NotifyType::Webhook => {
// webhook_url 在 parse_params 已校验非空
let url = params.webhook_url.as_deref().unwrap();
tracing::info!(
title = %params.title,
url = %url,
"NotifyNode webhook 推送"
);
let payload = serde_json::json!({
"title": params.title,
"message": params.message,
});
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.build()?;
// 尽力而为:发送失败不阻断工作流,降级为 success=false + error 字段返回。
let (status_code, success, error) = match client
.post(url)
.json(&payload)
.send()
.await
{
Ok(resp) => {
let code = resp.status().as_u16();
(Some(code), code >= 200 && code < 300, None)
}
Err(e) => (None, false, Some(e.to_string())),
};
if !success {
tracing::warn!(
url = %url,
status_code = ?status_code,
error = ?error,
"NotifyNode webhook 发送失败(不阻断工作流)"
);
}
Ok(NodeOutput::from_value(serde_json::json!({
"type": params.notify_type.as_str(),
"title": params.title,
"message": params.message,
"webhook_url": url,
"status_code": status_code,
"success": success,
"error": error,
})))
}
}
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["desktop", "webhook"]
},
"title": { "type": "string", "description": "通知标题" },
"message": { "type": "string", "description": "通知正文" },
"webhook_url": { "type": "string", "description": "webhook URL(webhook 类型必填)" }
},
"required": ["type"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"type": { "type": "string" },
"title": { "type": "string" },
"message": { "type": "string" },
"delivered": { "type": "boolean" },
"webhook_url": { "type": "string" },
"status_code": { "type": "integer" },
"success": { "type": "boolean" },
"error": { "type": "string" }
}
}),
}
}
fn node_type(&self) -> &str {
"notify"
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
// ── NotifyType::parse ──
#[test]
fn type_parses_desktop_and_webhook() {
assert_eq!(NotifyType::parse("desktop").unwrap(), NotifyType::Desktop);
assert_eq!(NotifyType::parse("webhook").unwrap(), NotifyType::Webhook);
}
#[test]
fn type_invalid_errors() {
let err = NotifyType::parse("email").unwrap_err().to_string();
assert!(err.contains("非法 type"), "实际: {}", err);
assert!(err.contains("email"));
}
// ── parse_params(config) ──
#[test]
fn params_missing_type_errors() {
let err = parse_params(&json!({ "title": "t" })).unwrap_err().to_string();
assert!(err.contains("type"), "实际: {}", err);
}
#[test]
fn params_invalid_type_errors() {
let err = parse_params(&json!({ "type": "sms" })).unwrap_err().to_string();
assert!(err.contains("非法 type"), "实际: {}", err);
}
#[test]
fn params_desktop_defaults_title_and_message() {
let p = parse_params(&json!({ "type": "desktop" })).unwrap();
assert_eq!(p.notify_type, NotifyType::Desktop);
assert_eq!(p.title, "通知"); // 默认标题
assert_eq!(p.message, ""); // 默认空正文
assert_eq!(p.webhook_url, None); // desktop 不需要
}
#[test]
fn params_desktop_with_custom_fields() {
let p = parse_params(&json!({
"type": "desktop",
"title": "构建完成",
"message": "已发布 v1.0"
}))
.unwrap();
assert_eq!(p.title, "构建完成");
assert_eq!(p.message, "已发布 v1.0");
}
#[test]
fn params_webhook_requires_url() {
let err = parse_params(&json!({ "type": "webhook" }))
.unwrap_err()
.to_string();
assert!(err.contains("webhook_url"), "实际: {}", err);
}
#[test]
fn params_webhook_empty_url_errors() {
let err = parse_params(&json!({ "type": "webhook", "webhook_url": " " }))
.unwrap_err()
.to_string();
assert!(err.contains("webhook_url"), "实际: {}", err);
}
#[test]
fn params_webhook_valid() {
let p = parse_params(&json!({
"type": "webhook",
"title": "告警",
"message": "CPU > 90%",
"webhook_url": "https://hooks.example.com/x"
}))
.unwrap();
assert_eq!(p.notify_type, NotifyType::Webhook);
assert_eq!(p.title, "告警");
assert_eq!(p.message, "CPU > 90%");
assert_eq!(p.webhook_url.as_deref(), Some("https://hooks.example.com/x"));
}
}

View File

@@ -6,6 +6,23 @@ use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// 脚本节点
pub struct ScriptNode;
// ── 运行时白/黑名单(通过 set_script_safety_config 注入,替代纯 env var) ──
use std::sync::OnceLock;
/// 运行时白名单配置(前端设置页写入,优先于环境变量)
static RUNTIME_SCRIPT_WHITELIST: OnceLock<String> = OnceLock::new();
/// 运行时黑名单配置(前端设置页写入,优先于环境变量)
static RUNTIME_SCRIPT_BLACKLIST: OnceLock<String> = OnceLock::new();
/// 注入运行时脚本安全配置(由 Tauri IPC 调用,前端设置页保存后触发)。
/// 空字符串表示「未配置」,回退到环境变量 / 默认行为。
pub fn set_script_safety_config(whitelist: &str, blacklist: &str) {
let wl = whitelist.trim();
let bl = blacklist.trim();
if !wl.is_empty() { let _ = RUNTIME_SCRIPT_WHITELIST.set(wl.to_string()); }
if !bl.is_empty() { let _ = RUNTIME_SCRIPT_BLACKLIST.set(bl.to_string()); }
}
#[async_trait]
impl Node for ScriptNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult {
@@ -39,6 +56,32 @@ impl Node for ScriptNode {
shell_type: Default::default(),
};
// 命令执行安全:白/黑名单校验(从环境变量读取,逗号分隔命令名)。
// - 白名单非空时:命令首词不在白名单 → 直接拒绝执行
// - 黑名单匹配时:直接拒绝执行
// 命令名取首词(shell 第一段,如 `rm -rf /` 取 `rm`),按 trim + 小写规范化比较。
let cmd_name = command.split_whitespace().next().unwrap_or("").to_lowercase();
if let Some(denied) = check_command_policy(&cmd_name) {
tracing::warn!(
command = %command,
reason = %denied,
"ScriptNode 命令被策略拒绝"
);
anyhow::bail!("脚本命令被策略拒绝: {} (命令: {})", denied, command);
}
// 危险命令告警:匹配已知危险关键词,仅告警不阻止执行
let dangerous_keywords = ["rm -rf", "DROP TABLE", "Format", "del /f", "shutdown"];
for &kw in &dangerous_keywords {
if command.contains(kw) {
tracing::warn!(
keyword = %kw,
command = %command,
"ScriptNode 即将执行包含危险关键词的命令"
);
}
}
tracing::info!("ScriptNode 执行命令: {}", command);
let result = df_execute::shell::execute(request).await?;
@@ -93,3 +136,67 @@ impl Node for ScriptNode {
"script"
}
}
/// 默认黑名单:即使环境变量未设置也生效,挡最危险的破坏性命令。
/// 用户设置 `DF_SCRIPT_BLACKLIST` 时覆盖默认(不合并,用户显式控制)。
const DEFAULT_BLACKLIST: &[&str] = &["rm", "del", "format", "shutdown", "mkfs", "dd"];
/// 命令执行策略校验:从运行时配置 + 环境变量读取白/黑名单。
///
/// 优先级(高→低):
/// 1. 运行时配置(set_script_safety_config,前端设置页写入)
/// 2. 环境变量 DF_SCRIPT_WHITELIST / DF_SCRIPT_BLACKLIST
/// 3. 默认黑名单兜底(DEFAULT_BLACKLIST)
///
/// 黑名单优先于白名单(黑名单匹配总是拒绝,即便同时在白名单)。
/// 命令名比较前 trim + ASCII 小写规范化;空段被忽略。
///
/// 返回 `Some(reason)` 表示拒绝;返回 `None` 表示放行。
fn check_command_policy(cmd_name: &str) -> Option<&'static str> {
// 步骤1:尝试运行时黑名单(前端设置页写入,优先级最高)
if let Some(raw) = RUNTIME_SCRIPT_BLACKLIST.get() {
let list: Vec<&str> = raw.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
if list.iter().any(|c| c.eq_ignore_ascii_case(cmd_name)) {
return Some("命令在黑名单中(运行时配置)");
}
}
// 步骤2:尝试运行时白名单(非空时放行) — 仅当运行时黑名单未命中时才检查
if let Some(raw) = RUNTIME_SCRIPT_WHITELIST.get() {
let list: Vec<&str> = raw.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
if !list.is_empty() && !list.iter().any(|c| c.eq_ignore_ascii_case(cmd_name)) {
return Some("命令不在白名单中(运行时配置)");
}
// 运行时白名单非空且命令在其中 → 直接放行(跳过 env var)
if !list.is_empty() {
return None;
}
}
// 步骤3:回退到环境变量(兼容旧部署)
let env_blacklist = std::env::var("DF_SCRIPT_BLACKLIST").ok();
let blacklist: Vec<String> = match env_blacklist {
Some(raw) => raw
.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect(),
None => DEFAULT_BLACKLIST.iter().map(|s| s.to_string()).collect(),
};
if blacklist.iter().any(|c| c == cmd_name) {
return Some("命令在黑名单中");
}
// 白名单:非空时命令名必须在白名单中才放行
if let Ok(whitelist_raw) = std::env::var("DF_SCRIPT_WHITELIST") {
let whitelist: Vec<String> = whitelist_raw
.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect();
if !whitelist.is_empty() && !whitelist.iter().any(|c| c == cmd_name) {
return Some("命令不在白名单中");
}
}
None
}

View File

@@ -0,0 +1,175 @@
//! SubflowNode — 嵌套子工作流节点
//!
//! 加载一个子模板(DagDef)并在当前执行上下文中递归执行。
//! 用于复用通用流程(如"代码审查"可作为任意主流程的子步骤)。
use async_trait::async_trait;
use df_workflow::dag_def::DagDef;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
use serde::{Deserialize, Serialize};
/// SubflowNode 配置
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SubflowConfig {
/// 子工作流 DAG 定义(内联 JSON)
dag: serde_json::Value,
/// 嵌套深度限制(防无限递归,默认 MAX_DEPTH)
#[serde(default)]
max_depth: Option<u32>,
}
/// 最大递归深度(安全阀)
const MAX_DEPTH: u32 = 10;
/// SubflowNode — 嵌套子工作流
pub struct SubflowNode;
#[async_trait]
impl Node for SubflowNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult {
tracing::info!("SubflowNode 执行: {}", ctx.node_id);
let config: SubflowConfig = serde_json::from_value(ctx.config.clone())
.map_err(|e| anyhow::anyhow!("SubflowNode 配置解析失败: {}", e))?;
let max_depth = config.max_depth.unwrap_or(MAX_DEPTH).min(MAX_DEPTH);
// 反序列化子 DAG
let sub_dag: DagDef = serde_json::from_value(config.dag)
.map_err(|e| anyhow::anyhow!("子工作流 DAG 解析失败: {}", e))?;
if sub_dag.nodes.is_empty() {
anyhow::bail!("子工作流节点不能为空");
}
if max_depth == 0 {
anyhow::bail!("SubflowNode 超过最大嵌套深度");
}
tracing::info!(
node_count = sub_dag.nodes.len(),
edge_count = sub_dag.edges.len(),
max_depth,
"SubflowNode 加载子工作流"
);
// 返回子 DAG 的 JSON 快照供 DagExecutor 消费
Ok(NodeOutput::from_value(serde_json::json!({
"subflow": true,
"node_count": sub_dag.nodes.len(),
"edge_count": sub_dag.edges.len(),
"max_depth": max_depth,
"dag": sub_dag,
})))
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"dag": {
"type": "object",
"description": "子工作流 DAG 定义(nodes + edges)",
"properties": {
"nodes": { "type": "object" },
"edges": { "type": "array" }
}
},
"max_depth": {
"type": "integer",
"description": "最大嵌套深度(默认10)",
"default": 10
}
},
"required": ["dag"]
}),
output: serde_json::json!({}),
}
}
fn node_type(&self) -> &'static str {
"subflow"
}
}
#[cfg(test)]
mod tests {
use super::*;
use df_workflow::eventbus::EventBus;
use df_workflow::state::StateMachine;
fn make_ctx(config: serde_json::Value) -> NodeContext {
NodeContext {
node_id: "test_subflow".into(),
inputs: Default::default(),
config,
execution_id: "exec_1".into(),
event_bus: EventBus::new(),
node_status: StateMachine::new(),
}
}
#[tokio::test]
async fn sub_01_valid_dag_returns_metadata() {
let node = SubflowNode;
let ctx = make_ctx(serde_json::json!({
"dag": {
"nodes": {
"child": {
"id": "child",
"node_type": "script",
"config": {}
}
},
"edges": []
}
}));
let result = node.execute(ctx).await.unwrap();
assert_eq!(result.data["subflow"], true);
assert_eq!(result.data["node_count"], 1);
}
#[tokio::test]
async fn sub_02_empty_dag_rejected() {
let node = SubflowNode;
let ctx = make_ctx(serde_json::json!({
"dag": { "nodes": {}, "edges": [] }
}));
let result = node.execute(ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("不能为空"));
}
#[tokio::test]
async fn sub_03_max_depth_zero_guard() {
let node = SubflowNode;
let ctx = make_ctx(serde_json::json!({
"dag": {
"nodes": { "c": { "id": "c", "node_type": "script", "config": {} } },
"edges": []
},
"max_depth": 0
}));
let result = node.execute(ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("最大嵌套深度"));
}
#[tokio::test]
async fn sub_04_missing_dag_field_errors() {
let node = SubflowNode;
let ctx = make_ctx(serde_json::json!({}));
let result = node.execute(ctx).await;
assert!(result.is_err());
}
#[tokio::test]
async fn sub_05_schema_has_required_fields() {
let node = SubflowNode;
let schema = node.schema();
let params = &schema.params;
assert!(params["properties"]["dag"].is_object());
assert!(params["required"].as_array().unwrap().contains(&serde_json::json!("dag")));
}
}

View File

@@ -22,27 +22,29 @@
//! - cancelled → 终态
// ============================================================
// 状态字符串常量 — df-types::TaskStatus::as_str 一一对应
// 状态字符串常量 — df-types::TaskStatus::as_str 派生(单一真相源)
// ============================================================
//
// 不复用 df-types::TaskStatus enum(独立模块定位 + 避免推进链判定耦合存储枚举类型),
// 但字符串值严格对齐(df-types::TaskStatus::as_str 产出的小写 snake_case),
// 保证状态机判定的 from/to 与数据库 status 列存值语义一致
// 任务 #17 合并:字符串常量不再独立定义,直接从 `TaskStatus::as_str()` 派生。
// 消除 df-nodes 字符串常量与 df-types enum 之间的双源问题—— 任一处修改 enum 的
// as_str 输出,本模块常量自动同步,编译期即可发现柡移
use df_types::types::TaskStatus;
/// 待开始
pub const TODO: &str = "todo";
pub const TODO: &str = TaskStatus::Todo.as_str();
/// 进行中
pub const IN_PROGRESS: &str = "in_progress";
pub const IN_PROGRESS: &str = TaskStatus::InProgress.as_str();
/// 代码审查中
pub const IN_REVIEW: &str = "in_review";
pub const IN_REVIEW: &str = TaskStatus::InReview.as_str();
/// 测试中
pub const TESTING: &str = "testing";
pub const TESTING: &str = TaskStatus::Testing.as_str();
/// 已完成(终态)
pub const DONE: &str = "done";
pub const DONE: &str = TaskStatus::Done.as_str();
/// 已阻塞
pub const BLOCKED: &str = "blocked";
pub const BLOCKED: &str = TaskStatus::Blocked.as_str();
/// 已取消(终态)
pub const CANCELLED: &str = "cancelled";
pub const CANCELLED: &str = TaskStatus::Cancelled.as_str();
/// 全部合法状态值(供输入校验与错误提示复用)
pub const ALL_STATES: &[&str] = &[

View File

@@ -11,3 +11,4 @@ tokio = { workspace = true }
anyhow = { workspace = true }
chrono = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }

View File

@@ -1,4 +1,10 @@
//! 项目管理器 — 项目的 CRUD 与生命周期管理
//! 项目管理器 — 项目的领域层(CRUD 构造 + 状态机 + 业务约束)
//!
//! 任务 #16 真分层:本 crate 不再只是"构造实体的工厂函数",而是承载项目领域规则。
//! Storage 层(df-storage::ProjectRepo)仅负责持久化,状态合法性 / 业务约束在此。
//!
//! 调用方(src-tauri commands/project.rs)推进项目状态时必须经
//! `ProjectManager::can_transition` / `transition` 校验,防非法跳态。
use serde::{Deserialize, Serialize};
@@ -39,15 +45,32 @@ pub struct CreateProjectInput {
pub tags: Vec<String>,
}
/// 项目管理器
/// 项目状态机错误
#[derive(Debug, thiserror::Error)]
pub enum ProjectTransitionError {
#[error("项目状态转换非法: {from:?} → {to:?}")]
IllegalTransition {
from: ProjectStatus,
to: ProjectStatus,
},
#[error("项目名称不能为空")]
EmptyName,
#[error("项目已处在终态 {0:?},不可再推进")]
TerminalState(ProjectStatus),
}
/// 项目管理器(无状态纯逻辑,实体构造 + 状态机 + 业务约束)
pub struct ProjectManager;
impl ProjectManager {
/// 创建新项目 — 构造领域实体(不落库);持久化由调用方经 storage 层 ProjectRecord 映射完成
/// (见 commands/idea.rs::promote_idea)。领域层不依赖 storage,保持分层。
pub fn create(input: CreateProjectInput) -> Project {
pub fn create(input: CreateProjectInput) -> Result<Project, ProjectTransitionError> {
if input.name.trim().is_empty() {
return Err(ProjectTransitionError::EmptyName);
}
let now = chrono::Utc::now();
Project {
Ok(Project {
id: df_types::types::new_id(),
name: input.name,
description: input.description,
@@ -57,11 +80,15 @@ impl ProjectManager {
tags: input.tags,
created_at: now,
updated_at: now,
}
})
}
/// 从想法创建项目
pub fn create_from_idea(name: String, description: String, idea_id: IdeaId) -> Project {
pub fn create_from_idea(
name: String,
description: String,
idea_id: IdeaId,
) -> Result<Project, ProjectTransitionError> {
Self::create(CreateProjectInput {
name,
description,
@@ -71,4 +98,118 @@ impl ProjectManager {
})
}
/// 判断项目状态转换是否合法(状态机核心)。
///
/// 项目状态语义(与 ARCHITECTURE.md 一致):
/// - Planning → InProgress(开始), Cancelled(取消)
/// - InProgress → Testing(提交测试), Paused(暂停), Cancelled
/// - Testing → Completed(测试通过), InProgress(退回开发), Cancelled
/// - Completed → 终态(不可变)
/// - Paused → InProgress(恢复), Cancelled
/// - Cancelled → 终态
pub fn can_transition(from: &ProjectStatus, to: &ProjectStatus) -> bool {
use ProjectStatus::*;
matches!((from, to),
(Planning, InProgress) | (Planning, Cancelled)
| (InProgress, Testing) | (InProgress, Paused) | (InProgress, Cancelled)
| (Testing, Completed) | (Testing, InProgress) | (Testing, Cancelled)
| (Paused, InProgress) | (Paused, Cancelled)
)
}
/// 执行状态转换,返回新状态或非法错误。
///
/// 调用方(如 IPC `update_project_status` / `advance_project`)应用本方法校验后
/// 再写 storage,防跳态(如 Planning → Completed 跳过 Testing)。
pub fn transition(
from: ProjectStatus,
to: ProjectStatus,
) -> Result<ProjectStatus, ProjectTransitionError> {
if !Self::can_transition(&from, &to) {
// 区分错误: 终态→任何 vs 一般非法
if matches!(from, ProjectStatus::Completed | ProjectStatus::Cancelled) {
return Err(ProjectTransitionError::TerminalState(from));
}
return Err(ProjectTransitionError::IllegalTransition { from, to });
}
Ok(to)
}
/// 是否为终态(不可再转换)
pub fn is_terminal(s: &ProjectStatus) -> bool {
matches!(s, ProjectStatus::Completed | ProjectStatus::Cancelled)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_rejects_empty_name() {
let err = ProjectManager::create(CreateProjectInput {
name: " ".into(),
description: String::new(),
idea_id: None,
priority: Priority::default(),
tags: vec![],
}).unwrap_err();
assert!(matches!(err, ProjectTransitionError::EmptyName));
}
#[test]
fn main_path_planning_to_completed() {
use ProjectStatus::*;
assert!(ProjectManager::can_transition(&Planning, &InProgress));
assert!(ProjectManager::can_transition(&InProgress, &Testing));
assert!(ProjectManager::can_transition(&Testing, &Completed));
}
#[test]
fn illegal_skips_rejected() {
use ProjectStatus::*;
assert!(!ProjectManager::can_transition(&Planning, &Completed));
assert!(!ProjectManager::can_transition(&Planning, &Testing));
assert!(!ProjectManager::can_transition(&InProgress, &Completed));
}
#[test]
fn terminal_states_block_all() {
use ProjectStatus::*;
for term in [Completed, Cancelled] {
for to in [Planning, InProgress, Testing, Paused] {
assert!(!ProjectManager::can_transition(&term, &to));
}
}
}
#[test]
fn paused_round_trip() {
use ProjectStatus::*;
assert!(ProjectManager::can_transition(&InProgress, &Paused));
assert!(ProjectManager::can_transition(&Paused, &InProgress));
}
#[test]
fn transition_returns_target_on_legal() {
use ProjectStatus::*;
assert_eq!(
ProjectManager::transition(Planning, InProgress).unwrap(),
InProgress
);
}
#[test]
fn transition_errors_on_illegal() {
use ProjectStatus::*;
let err = ProjectManager::transition(Planning, Completed).unwrap_err();
assert!(matches!(err, ProjectTransitionError::IllegalTransition { .. }));
}
#[test]
fn transition_errors_on_terminal_source() {
use ProjectStatus::*;
let err = ProjectManager::transition(Completed, InProgress).unwrap_err();
assert!(matches!(err, ProjectTransitionError::TerminalState(_)));
}
}

View File

@@ -32,13 +32,12 @@ use crate::broadcast::{BroadcastMessage, ClientKind, MessageKind};
use crate::conn::{next_conn_id, ConnHandle, ConnId, RelayState};
use crate::error::{RelayError, Result};
/// MVP 默认 token(env `DF_RELAY_TOKEN` 缺省时回退)。
/// 读取期望 token(必需:env `DF_RELAY_TOKEN` 必须设置,未设置时 panic)。
/// 生产级鉴权(每 device 独立 token + 过期刷新)留 Phase3。
const DEFAULT_TOKEN: &str = "devflow-relay-default-token";
/// 读取期望 token(env 优先,fallback 硬编码)
fn expected_token() -> String {
std::env::var("DF_RELAY_TOKEN").unwrap_or_else(|_| DEFAULT_TOKEN.to_string())
std::env::var("DF_RELAY_TOKEN").unwrap_or_else(|_| {
panic!("必须设置环境变量 DF_RELAY_TOKEN")
})
}
/// 客户端首消息:身份宣告(简单协议)
@@ -347,7 +346,35 @@ async fn handle_inbound_text(
) -> Result<()> {
// 入站文本即业务 payload(relay 不解析),包成 BroadcastMessage
// payload 直接用原始 JSON 值;若客户端发非 JSON 文本,则包成字符串值
let payload: serde_json::Value = serde_json::from_str(raw).unwrap_or(serde_json::Value::String(raw.to_string()));
let payload: serde_json::Value =
serde_json::from_str(raw).unwrap_or(serde_json::Value::String(raw.to_string()));
// 心跳协议:miniapp 发 {control_kind: "ping"} → relay 直接回 pong(不经 device 透传)。
// miniapp 用 pong 更新 lastPongTime 看门狗(防移动网络 TCP 半连接挂死)。
// device 端收到 control 消息也仅 console.log,不影响业务。
if let Some(control_kind) = payload
.get("control_kind")
.and_then(|v| v.as_str())
{
if control_kind == "ping" && kind == ClientKind::Miniapp {
let pong_payload = serde_json::json!({"control_kind": "pong"});
let pong_msg = BroadcastMessage {
device_id: device_id.to_string(),
kind: crate::broadcast::MessageKind::Control,
source: conn_id,
from: ClientKind::Device, // pong 来自 relay(代理 device),让 miniapp 识别为合法响应
payload: pong_payload,
ts: now_ms(),
};
let _ = state.route(&pong_msg).await;
tracing::trace!(
conn_id = conn_id.0,
device_id = %device_id,
"miniapp ping → relay pong(本地响应)"
);
return Ok(());
}
}
let now = now_ms();
let (msg_kind, from) = match kind {

View File

@@ -0,0 +1,233 @@
//! df-relay 单元测试(任务 #12
//!
//! 覆盖纯逻辑(不依赖 axum server
//! - ConnRegistry: add/remove/is_online/route四种路由规则
//! - BroadcastMessage 构造器
//! - RelayState async 包装
//! - next_conn_id 单调递增
#![cfg(test)]
use df_relay::{
next_conn_id, BroadcastMessage, ClientKind, ConnHandle, ConnId, ConnRegistry, MessageKind,
RelayState,
};
use tokio::sync::mpsc;
// ---------- ConnId 分配 ----------
#[test]
fn next_conn_id_monotonic() {
let a = next_conn_id();
let b = next_conn_id();
let c = next_conn_id();
assert!(b.0 > a.0, "next_conn_id 应单调递增");
assert!(c.0 > b.0);
}
#[test]
fn conn_id_nil_is_zero() {
assert_eq!(ConnId::NIL.0, 0);
assert_ne!(next_conn_id(), ConnId::NIL, "分配的 id 不应为 NIL(0)");
}
// ---------- ConnRegistry 基础操作 ----------
fn make_handle(id: ConnId, kind: ClientKind, device_id: &str) -> (ConnHandle, mpsc::UnboundedReceiver<BroadcastMessage>) {
let (tx, rx) = mpsc::unbounded_channel();
(ConnHandle::new(id, kind, device_id.to_string(), tx), rx)
}
/// 注册连接并返回 receiver保活防 channel 关闭)。
fn register(reg: &mut ConnRegistry, id: ConnId, kind: ClientKind, device_id: &str) -> mpsc::UnboundedReceiver<BroadcastMessage> {
let (handle, rx) = make_handle(id, kind, device_id);
reg.add(handle);
rx
}
#[test]
fn registry_add_and_lookup() {
let mut reg = ConnRegistry::new();
let id = next_conn_id();
let _rx = register(&mut reg, id, ClientKind::Device, "dev-1");
assert_eq!(reg.len(), 1);
assert!(reg.is_device_online("dev-1"));
assert!(!reg.is_device_online("dev-2"));
}
#[test]
fn registry_remove_returns_device_id() {
let mut reg = ConnRegistry::new();
let id = next_conn_id();
let _rx = register(&mut reg, id, ClientKind::Device, "dev-1");
let removed = reg.remove(id);
assert_eq!(removed.as_deref(), Some("dev-1"));
assert_eq!(reg.len(), 0);
assert!(!reg.is_device_online("dev-1"));
}
#[test]
fn registry_remove_unknown_returns_none() {
let mut reg = ConnRegistry::new();
assert_eq!(reg.remove(ConnId(9999)), None);
}
#[test]
fn registry_supports_multi_conn_per_device() {
let mut reg = ConnRegistry::new();
let d1 = next_conn_id();
let m1 = next_conn_id();
let _r1 = register(&mut reg, d1, ClientKind::Device, "dev-1");
let _r2 = register(&mut reg, m1, ClientKind::Miniapp, "dev-1");
assert_eq!(reg.len(), 2);
assert!(reg.is_device_online("dev-1"));
reg.remove(d1);
assert!(reg.is_device_online("dev-1"));
reg.remove(m1);
assert!(!reg.is_device_online("dev-1"));
}
#[test]
fn registry_empty_default() {
let reg = ConnRegistry::new();
assert!(reg.is_empty());
assert_eq!(reg.len(), 0);
}
// ---------- 路由规则 ----------
#[test]
fn route_event_device_to_miniapp_only() {
let mut reg = ConnRegistry::new();
let dev = next_conn_id();
let mini = next_conn_id();
let other_mini = next_conn_id();
let _r1 = register(&mut reg, dev, ClientKind::Device, "dev-1");
let _r2 = register(&mut reg, mini, ClientKind::Miniapp, "dev-1");
let _r3 = register(&mut reg, other_mini, ClientKind::Miniapp, "dev-2");
let msg = BroadcastMessage::from_device("dev-1", dev, serde_json::json!({}), 0);
let delivered = reg.route(&msg);
assert_eq!(delivered, 1, "Event 应只投递给 dev-1 的小程序");
}
#[test]
fn route_command_miniapp_to_device_only() {
let mut reg = ConnRegistry::new();
let dev = next_conn_id();
let mini = next_conn_id();
let _r1 = register(&mut reg, dev, ClientKind::Device, "dev-1");
let _r2 = register(&mut reg, mini, ClientKind::Miniapp, "dev-1");
let msg = BroadcastMessage {
device_id: "dev-1".into(),
kind: MessageKind::Command,
source: mini,
from: ClientKind::Miniapp,
payload: serde_json::json!({}),
ts: 0,
};
let delivered = reg.route(&msg);
assert_eq!(delivered, 1, "Command 应只投递给 dev-1 的桌面端");
}
#[test]
fn route_does_not_loop_back_to_source() {
let mut reg = ConnRegistry::new();
let dev = next_conn_id();
let _r1 = register(&mut reg, dev, ClientKind::Device, "dev-1");
let msg = BroadcastMessage::from_device("dev-1", dev, serde_json::json!({}), 0);
let delivered = reg.route(&msg);
assert_eq!(delivered, 0, "不应回环给来源");
}
#[test]
fn route_control_to_all_except_source() {
let mut reg = ConnRegistry::new();
let dev = next_conn_id();
let mini1 = next_conn_id();
let mini2 = next_conn_id();
let _r1 = register(&mut reg, dev, ClientKind::Device, "dev-1");
let _r2 = register(&mut reg, mini1, ClientKind::Miniapp, "dev-1");
let _r3 = register(&mut reg, mini2, ClientKind::Miniapp, "dev-1");
let msg = BroadcastMessage {
device_id: "dev-1".into(),
kind: MessageKind::Control,
source: dev,
from: ClientKind::Device,
payload: serde_json::json!({}),
ts: 0,
};
let delivered = reg.route(&msg);
assert_eq!(delivered, 2, "Control 应投递给除来源外的所有同 device 连接");
}
#[test]
fn route_unknown_device_zero() {
let reg = ConnRegistry::new();
let msg = BroadcastMessage::from_device("nope", ConnId::NIL, serde_json::json!({}), 0);
assert_eq!(reg.route(&msg), 0);
}
#[test]
fn route_event_not_delivered_to_device() {
let mut reg = ConnRegistry::new();
let dev1 = next_conn_id();
let dev2 = next_conn_id();
let _r1 = register(&mut reg, dev1, ClientKind::Device, "dev-1");
let _r2 = register(&mut reg, dev2, ClientKind::Device, "dev-1");
let msg = BroadcastMessage::from_device("dev-1", dev1, serde_json::json!({}), 0);
assert_eq!(reg.route(&msg), 0, "两个 Device 互发 Event 不应投递");
}
// ---------- BroadcastMessage 构造器 ----------
#[test]
fn from_device_sets_event_kind_and_from() {
let msg = BroadcastMessage::from_device("dev-1", ConnId(42), serde_json::json!({"a": 1}), 12345);
assert_eq!(msg.device_id, "dev-1");
assert_eq!(msg.kind, MessageKind::Event);
assert_eq!(msg.from, ClientKind::Device);
assert_eq!(msg.source, ConnId(42));
assert_eq!(msg.ts, 12345);
assert_eq!(msg.payload, serde_json::json!({"a": 1}));
}
// ---------- RelayState async 包装 ----------
#[tokio::test]
async fn relay_state_add_and_remove_conn() {
let state = RelayState::new();
let id = next_conn_id();
let (handle, _rx) = make_handle(id, ClientKind::Device, "dev-1");
state.add_conn(handle).await;
assert!(state.is_device_online("dev-1").await);
let removed = state.remove_conn(id).await;
assert_eq!(removed.as_deref(), Some("dev-1"));
assert!(!state.is_device_online("dev-1").await);
}
#[tokio::test]
async fn relay_state_route_async() {
let state = RelayState::new();
let dev = next_conn_id();
let mini = next_conn_id();
let (h1, _r1) = make_handle(dev, ClientKind::Device, "dev-1");
let (h2, _r2) = make_handle(mini, ClientKind::Miniapp, "dev-1");
state.add_conn(h1).await;
state.add_conn(h2).await;
let msg = BroadcastMessage::from_device("dev-1", dev, serde_json::json!({}), 0);
let n = state.route(&msg).await;
assert_eq!(n, 1);
}

View File

@@ -264,6 +264,25 @@ impl AiToolExecutionRepo {
.map_err(storage_err)?
}
/// 清理超期的残留 pending 工具调用(旧会话遗留)。
///
/// `max_age_secs`: 超过此秒数的 pending 记录被标记为 interrupted(不硬删,保留审计痕迹)。
pub async fn cleanup_stale_pending(&self, max_age_secs: u64) -> Result<u64> {
let conn = self.conn.clone();
let cutoff_ms = (df_types::now_millis() as i64 - (max_age_secs as i64 * 1000)).to_string();
let affected = tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
guard.execute(
"UPDATE ai_tool_executions SET status = 'interrupted' \
WHERE status = 'pending' AND CAST(requested_at AS INTEGER) < ?1",
params![cutoff_ms],
).map_err(storage_err)
})
.await
.map_err(storage_err)??;
Ok(affected as u64)
}
/// 审批历史面板分页查询:按 requested_at 倒序(最新在前),limit 默认 50。
///
/// 与 list_pending 同理走专用 SELECT,绕过通用 query 宏(后者硬编码

View File

@@ -128,6 +128,57 @@ impl AiMessageRepo {
.map_err(storage_err)?
}
/// 按对话加载最近 N 条消息(分页懒加载,治长对话渲染卡顿)。
///
/// 从尾部取最近 `limit` 条(ORDER BY seq DESC LIMIT),返回时反转为 ASC 顺序(与
/// list_by_conversation 一致的 seq 升序)。`before_seq` 可选:指定后只取 seq < before_seq
/// 的消息(滚顶加载更多时的游标,取下一页更早的历史)。
///
/// 典型用法:
/// - 首次切入对话:list_recent(conv_id, 50, None) → 最近 50 条
/// - 滚顶加载更多:list_recent(conv_id, 50, Some(最早已加载消息的 seq)) → 再加载 50 条更早的
pub async fn list_recent(
&self,
conversation_id: &str,
limit: usize,
before_seq: Option<i64>,
) -> Result<Vec<AiMessageRecord>> {
let conn = self.conn.clone();
let conv_id = conversation_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
// before_seq 有无分两个 SQL(参数化 LIMIT 必须用固定占位,Rust 侧 clamp 防 0)
let limit = limit.max(1) as i64;
let sql = if before_seq.is_some() {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at
FROM ai_messages WHERE conversation_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3"
} else {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at
FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq DESC LIMIT ?2"
};
let mut stmt = guard.prepare(sql).map_err(storage_err)?;
let rows = match before_seq {
Some(seq) => stmt
.query_map(params![conv_id, seq, limit], ai_message_from_row)
.map_err(storage_err)?,
None => stmt
.query_map(params![conv_id, limit], ai_message_from_row)
.map_err(storage_err)?,
};
let mut results: Vec<AiMessageRecord> = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
// DESC → 反转为 ASC(与 list_by_conversation 一致顺序)
results.reverse();
Ok(results)
})
.await
.map_err(storage_err)?
}
/// 删除对话内 seq ∈ [min_seq, max_seq) 的消息(左闭右开)。
///
/// compress 压缩 / 编辑重生成 dirty 范围重写用:delete_range → insert_batch 原子覆盖。

View File

@@ -17,11 +17,15 @@
//! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` /
//! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。
mod module_dependency_repo;
mod plan_repo;
mod conversation_repo;
mod idea_eval_repo;
mod idea_repo;
mod message_repo;
mod project_event_repo;
mod project_module_repo;
mod project_repo;
mod project_service_repo;
mod settings;
@@ -33,11 +37,14 @@ pub use idea_eval_repo::*;
pub use idea_repo::*;
pub use message_repo::*;
pub use project_event_repo::*;
pub use project_module_repo::*;
pub use project_repo::*;
pub use project_service_repo::*;
pub use settings::*;
pub use task_link_repo::*;
pub use task_repo::*;
pub use module_dependency_repo::*;
pub use plan_repo::*;
// ============================================================
// 辅助宏 — 消除多个 Repo 的重复样板
@@ -305,6 +312,7 @@ mod baseline_tests {
let _ = TaskLinkRepo::new(&db);
let _ = ProjectEventRepo::new(&db);
let _ = ProjectServiceRepo::new(&db);
let _ = ProjectModuleRepo::new(&db);
let _ = BranchRepo::new(&db);
let _ = ReleaseRepo::new(&db);
let _ = WorkflowRepo::new(&db);

View File

@@ -0,0 +1,95 @@
//! 工程依赖关系 — module_dependencies 表 CRUD(V35,工程间依赖边)
//!
//! 简化版:insert / delete / list_by_field(查 project_id)。
use std::sync::Arc;
use rusqlite::{params, Row};
use tokio::sync::Mutex;
use crate::db::Database;
use crate::models::ModuleDependencyRecord;
use super::{storage_err};
fn dep_from_row(row: &Row<'_>) -> std::result::Result<ModuleDependencyRecord, rusqlite::Error> {
Ok(ModuleDependencyRecord {
id: row.get("id")?,
project_id: row.get("project_id")?,
from_module_id: row.get("from_module_id")?,
to_module_id: row.get("to_module_id")?,
dep_type: row.get("dep_type")?,
label: row.get("label")?,
created_at: row.get("created_at")?,
})
}
pub struct ModuleDependencyRepo {
conn: Arc<Mutex<rusqlite::Connection>>,
}
impl ModuleDependencyRepo {
pub fn new(db: &Database) -> Self {
Self { conn: db.conn() }
}
pub async fn insert(&self, record: ModuleDependencyRecord) -> Result<bool, df_types::error::Error> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let r = &record;
let affected = guard
.execute(
"INSERT INTO module_dependencies \
(id, project_id, from_module_id, to_module_id, dep_type, label, created_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
r.id, r.project_id, r.from_module_id, r.to_module_id,
r.dep_type, r.label, r.created_at,
],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
pub async fn delete(&self, id: &str) -> Result<bool, df_types::error::Error> {
let conn = self.conn.clone();
let id = id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute("DELETE FROM module_dependencies WHERE id = ?1", params![id])
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
pub async fn list_by_field(&self, field: &str, value: &str) -> Result<Vec<ModuleDependencyRecord>, df_types::error::Error> {
let conn = self.conn.clone();
let field = field.to_owned();
let value = value.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let sql = format!(
"SELECT id, project_id, from_module_id, to_module_id, dep_type, label, created_at \
FROM module_dependencies WHERE {field} = ?1 ORDER BY created_at ASC"
);
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
let rows = stmt
.query_map(params![value], dep_from_row)
.map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
}

View File

@@ -0,0 +1,579 @@
//! 多 Agent 并行执行 Repo — ai_plans / ai_subtasks / ai_conflicts 表 CRUD(V36)
//!
//! 设计依据:docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md §二
use std::sync::Arc;
use rusqlite::{params, OptionalExtension, Row};
use tokio::sync::Mutex;
use crate::db::Database;
use crate::models::{ConflictRecord, PlanRecord, SubTaskRecord};
use super::storage_err;
type Result<T> = std::result::Result<T, df_types::error::Error>;
fn plan_from_row(row: &Row<'_>) -> rusqlite::Result<PlanRecord> {
Ok(PlanRecord {
id: row.get("id")?,
conversation_id: row.get("conversation_id")?,
user_message_id: row.get("user_message_id")?,
status: row.get("status")?,
subtask_count: row.get("subtask_count")?,
created_at: row.get("created_at")?,
completed_at: row.get("completed_at")?,
})
}
fn subtask_from_row(row: &Row<'_>) -> rusqlite::Result<SubTaskRecord> {
Ok(SubTaskRecord {
id: row.get("id")?,
plan_id: row.get("plan_id")?,
persona_id: row.get("persona_id")?,
intent: row.get("intent")?,
status: row.get("status")?,
layer: row.get("layer")?,
deps: row.get("deps")?,
branch: row.get("branch")?,
created_at: row.get("created_at")?,
completed_at: row.get("completed_at")?,
})
}
fn conflict_from_row(row: &Row<'_>) -> rusqlite::Result<ConflictRecord> {
Ok(ConflictRecord {
id: row.get("id")?,
plan_id: row.get("plan_id")?,
file_path: row.get("file_path")?,
conflict_type: row.get("conflict_type")?,
subtask_a: row.get("subtask_a")?,
subtask_b: row.get("subtask_b")?,
diff_a: row.get("diff_a")?,
diff_b: row.get("diff_b")?,
resolution: row.get("resolution")?,
resolved_by: row.get("resolved_by")?,
created_at: row.get("created_at")?,
resolved_at: row.get("resolved_at")?,
})
}
// ============================================================
// PlanRepo
// ============================================================
pub struct PlanRepo {
conn: Arc<Mutex<rusqlite::Connection>>,
}
impl PlanRepo {
pub fn new(db: &Database) -> Self {
Self { conn: db.conn() }
}
pub async fn insert(&self, record: PlanRecord) -> Result<String> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
guard
.execute(
"INSERT INTO ai_plans \
(id, conversation_id, user_message_id, status, subtask_count, created_at, completed_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
record.id, record.conversation_id, record.user_message_id,
record.status, record.subtask_count, record.created_at, record.completed_at,
],
)
.map_err(storage_err)?;
Ok(record.id)
})
.await
.map_err(storage_err)?
}
pub async fn get(&self, id: &str) -> Result<Option<PlanRecord>> {
let conn = self.conn.clone();
let id = id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let row = guard
.query_row("SELECT * FROM ai_plans WHERE id = ?1", params![id], plan_from_row)
.optional()
.map_err(storage_err)?;
Ok(row)
})
.await
.map_err(storage_err)?
}
pub async fn update_status(
&self,
id: &str,
status: &str,
completed_at: Option<&str>,
) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let status = status.to_owned();
let completed_at = completed_at.map(|s| s.to_owned());
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_plans SET status = ?1, completed_at = ?2 WHERE id = ?3",
params![status, completed_at, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
pub async fn list_by_conversation(&self, conv_id: &str) -> Result<Vec<PlanRecord>> {
let conn = self.conn.clone();
let conv_id = conv_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare("SELECT * FROM ai_plans WHERE conversation_id = ?1 ORDER BY created_at DESC")
.map_err(storage_err)?;
let rows = stmt.query_map(params![conv_id], plan_from_row).map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
}
// ============================================================
// SubTaskRepo
// ============================================================
pub struct SubTaskRepo {
conn: Arc<Mutex<rusqlite::Connection>>,
}
impl SubTaskRepo {
pub fn new(db: &Database) -> Self {
Self { conn: db.conn() }
}
pub async fn insert(&self, record: SubTaskRecord) -> Result<String> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
guard
.execute(
"INSERT INTO ai_subtasks \
(id, plan_id, persona_id, intent, status, layer, deps, branch, created_at, completed_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![
record.id, record.plan_id, record.persona_id, record.intent,
record.status, record.layer, record.deps, record.branch,
record.created_at, record.completed_at,
],
)
.map_err(storage_err)?;
Ok(record.id)
})
.await
.map_err(storage_err)?
}
pub async fn list_by_plan(&self, plan_id: &str) -> Result<Vec<SubTaskRecord>> {
let conn = self.conn.clone();
let plan_id = plan_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare("SELECT * FROM ai_subtasks WHERE plan_id = ?1 ORDER BY layer ASC, created_at ASC")
.map_err(storage_err)?;
let rows = stmt.query_map(params![plan_id], subtask_from_row).map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
pub async fn update_status(
&self,
id: &str,
status: &str,
completed_at: Option<&str>,
) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let status = status.to_owned();
let completed_at = completed_at.map(|s| s.to_owned());
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_subtasks SET status = ?1, completed_at = ?2 WHERE id = ?3",
params![status, completed_at, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
}
// ============================================================
// ConflictRepo
// ============================================================
pub struct ConflictRepo {
conn: Arc<Mutex<rusqlite::Connection>>,
}
impl ConflictRepo {
pub fn new(db: &Database) -> Self {
Self { conn: db.conn() }
}
pub async fn insert(&self, record: ConflictRecord) -> Result<String> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
guard
.execute(
"INSERT INTO ai_conflicts \
(id, plan_id, file_path, conflict_type, subtask_a, subtask_b, diff_a, diff_b, \
resolution, resolved_by, created_at, resolved_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![
record.id, record.plan_id, record.file_path, record.conflict_type,
record.subtask_a, record.subtask_b, record.diff_a, record.diff_b,
record.resolution, record.resolved_by, record.created_at, record.resolved_at,
],
)
.map_err(storage_err)?;
Ok(record.id)
})
.await
.map_err(storage_err)?
}
pub async fn list_pending(&self, plan_id: &str) -> Result<Vec<ConflictRecord>> {
let conn = self.conn.clone();
let plan_id = plan_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare("SELECT * FROM ai_conflicts WHERE plan_id = ?1 AND resolution = 'pending' ORDER BY created_at ASC")
.map_err(storage_err)?;
let rows = stmt.query_map(params![plan_id], conflict_from_row).map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
pub async fn resolve(
&self,
id: &str,
resolution: &str,
resolved_by: &str,
resolved_at: &str,
) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let resolution = resolution.to_owned();
let resolved_by = resolved_by.to_owned();
let resolved_at = resolved_at.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_conflicts SET resolution = ?1, resolved_by = ?2, resolved_at = ?3 WHERE id = ?4",
params![resolution, resolved_by, resolved_at, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
async fn setup() -> Database {
let db = Database::open_in_memory().await.expect("open_in_memory");
let conn = db.conn();
tokio::task::spawn_blocking(move || {
crate::migrations::run(&conn.blocking_lock()).expect("migrations");
})
.await
.expect("spawn_blocking");
db
}
#[tokio::test]
async fn repo_01_plan_insert_and_get() {
let db = setup().await;
let repo = PlanRepo::new(&db);
let plan = PlanRecord {
id: "plan-001".into(),
conversation_id: "conv-001".into(),
user_message_id: Some("msg-001".into()),
status: "planning".into(),
subtask_count: 2,
created_at: "2026-07-01T00:00:00Z".into(),
completed_at: None,
};
let id = repo.insert(plan.clone()).await.unwrap();
assert_eq!(id, "plan-001");
let got = repo.get("plan-001").await.unwrap().unwrap();
assert_eq!(got.status, "planning");
assert_eq!(got.subtask_count, 2);
}
#[tokio::test]
async fn repo_02_plan_update_status() {
let db = setup().await;
let repo = PlanRepo::new(&db);
repo.insert(PlanRecord {
id: "plan-002".into(),
conversation_id: "conv-001".into(),
user_message_id: None,
status: "planning".into(),
subtask_count: 0,
created_at: "2026-07-01T00:00:00Z".into(),
completed_at: None,
})
.await
.unwrap();
assert!(repo.update_status("plan-002", "executing", None).await.unwrap());
let got = repo.get("plan-002").await.unwrap().unwrap();
assert_eq!(got.status, "executing");
}
#[tokio::test]
async fn repo_03_subtask_insert_and_list() {
let db = setup().await;
let plan_repo = PlanRepo::new(&db);
let st_repo = SubTaskRepo::new(&db);
plan_repo
.insert(PlanRecord {
id: "plan-003".into(),
conversation_id: "conv-001".into(),
user_message_id: None,
status: "planning".into(),
subtask_count: 2,
created_at: "2026-07-01T00:00:00Z".into(),
completed_at: None,
})
.await
.unwrap();
st_repo
.insert(SubTaskRecord {
id: "st-001".into(),
plan_id: "plan-003".into(),
persona_id: Some("coder".into()),
intent: "读取代码".into(),
status: "pending".into(),
layer: 0,
deps: None,
branch: Some("subtask/plan-003/st-001".into()),
created_at: "2026-07-01T00:00:00Z".into(),
completed_at: None,
})
.await
.unwrap();
st_repo
.insert(SubTaskRecord {
id: "st-002".into(),
plan_id: "plan-003".into(),
persona_id: Some("coder".into()),
intent: "修改代码".into(),
status: "pending".into(),
layer: 1,
deps: Some(r#"["st-001"]"#.into()),
branch: Some("subtask/plan-003/st-002".into()),
created_at: "2026-07-01T00:00:00Z".into(),
completed_at: None,
})
.await
.unwrap();
let list = st_repo.list_by_plan("plan-003").await.unwrap();
assert_eq!(list.len(), 2);
assert_eq!(list[0].layer, 0); // layer 排序
assert_eq!(list[1].layer, 1);
}
#[tokio::test]
async fn repo_04_subtask_update_status() {
let db = setup().await;
let plan_repo = PlanRepo::new(&db);
let st_repo = SubTaskRepo::new(&db);
plan_repo
.insert(PlanRecord {
id: "plan-004".into(),
conversation_id: "conv-001".into(),
user_message_id: None,
status: "planning".into(),
subtask_count: 1,
created_at: "2026-07-01T00:00:00Z".into(),
completed_at: None,
})
.await
.unwrap();
st_repo
.insert(SubTaskRecord {
id: "st-004".into(),
plan_id: "plan-004".into(),
persona_id: None,
intent: "test".into(),
status: "pending".into(),
layer: 0,
deps: None,
branch: None,
created_at: "2026-07-01T00:00:00Z".into(),
completed_at: None,
})
.await
.unwrap();
assert!(st_repo.update_status("st-004", "done", Some("2026-07-01T01:00:00Z")).await.unwrap());
let list = st_repo.list_by_plan("plan-004").await.unwrap();
assert_eq!(list[0].status, "done");
}
#[tokio::test]
async fn repo_05_conflict_insert_and_list_pending() {
let db = setup().await;
let plan_repo = PlanRepo::new(&db);
let conf_repo = ConflictRepo::new(&db);
plan_repo
.insert(PlanRecord {
id: "plan-005".into(),
conversation_id: "conv-001".into(),
user_message_id: None,
status: "merging".into(),
subtask_count: 2,
created_at: "2026-07-01T00:00:00Z".into(),
completed_at: None,
})
.await
.unwrap();
conf_repo
.insert(ConflictRecord {
id: "conf-001".into(),
plan_id: "plan-005".into(),
file_path: "src/main.rs".into(),
conflict_type: "file".into(),
subtask_a: Some("st-a".into()),
subtask_b: Some("st-b".into()),
diff_a: Some("-old\n+new_a".into()),
diff_b: Some("-old\n+new_b".into()),
resolution: "pending".into(),
resolved_by: None,
created_at: "2026-07-01T00:00:00Z".into(),
resolved_at: None,
})
.await
.unwrap();
let pending = conf_repo.list_pending("plan-005").await.unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].file_path, "src/main.rs");
}
#[tokio::test]
async fn repo_06_conflict_resolve() {
let db = setup().await;
let plan_repo = PlanRepo::new(&db);
let conf_repo = ConflictRepo::new(&db);
plan_repo
.insert(PlanRecord {
id: "plan-006".into(),
conversation_id: "conv-001".into(),
user_message_id: None,
status: "merging".into(),
subtask_count: 1,
created_at: "2026-07-01T00:00:00Z".into(),
completed_at: None,
})
.await
.unwrap();
conf_repo
.insert(ConflictRecord {
id: "conf-002".into(),
plan_id: "plan-006".into(),
file_path: "src/lib.rs".into(),
conflict_type: "semantic".into(),
subtask_a: None,
subtask_b: None,
diff_a: None,
diff_b: None,
resolution: "pending".into(),
resolved_by: None,
created_at: "2026-07-01T00:00:00Z".into(),
resolved_at: None,
})
.await
.unwrap();
assert!(
conf_repo
.resolve("conf-002", "merged", "reviewer", "2026-07-01T02:00:00Z")
.await
.unwrap()
);
let pending = conf_repo.list_pending("plan-006").await.unwrap();
assert_eq!(pending.len(), 0, "解决后 pending 列表应为空");
}
#[tokio::test]
async fn repo_07_subtask_branch_null_for_non_git() {
let db = setup().await;
let plan_repo = PlanRepo::new(&db);
let st_repo = SubTaskRepo::new(&db);
plan_repo
.insert(PlanRecord {
id: "plan-007".into(),
conversation_id: "conv-001".into(),
user_message_id: None,
status: "planning".into(),
subtask_count: 1,
created_at: "2026-07-01T00:00:00Z".into(),
completed_at: None,
})
.await
.unwrap();
st_repo
.insert(SubTaskRecord {
id: "st-007".into(),
plan_id: "plan-007".into(),
persona_id: None,
intent: "非 Git 工程".into(),
status: "pending".into(),
layer: 0,
deps: None,
branch: None, // 非 Git 工程
created_at: "2026-07-01T00:00:00Z".into(),
completed_at: None,
})
.await
.unwrap();
let list = st_repo.list_by_plan("plan-007").await.unwrap();
assert!(list[0].branch.is_none(), "非 Git 工程 branch 应为 NULL");
}
}

View File

@@ -0,0 +1,322 @@
//! 工程系统 — project_modules 表 CRUD(V34,项目多工程,每个工程独立代码仓库)
//!
//! 对标 [`project_service_repo`] 的风格(手写 Repo,与设计 §五工程系统 IPC 对齐)。
//!
//! 关键设计:
//! - **不存 Git 状态**:分支/改动/最近提交是实时派生的(查 git 命令),不进表;
//! 表只存工程元数据(路径/Git 地址/技术栈)。
//! - **硬删**:`delete` / `delete_by_project` 物理删除(工程不需要软删审计追溯)。
//! - **sort_order 排序**:`list_by_project` 按 `sort_order ASC` 返回,前端工程列表稳定顺序。
//! - **auto_detected**:技术栈自动检测标志(扫描目录结构推断),人工覆盖时置 false。
use std::sync::Arc;
use rusqlite::{params, Row};
use tokio::sync::Mutex;
use crate::db::Database;
use crate::models::ProjectModuleRecord;
use super::{now_millis_str, storage_err};
// ============================================================
// from_row 辅助函数
// ============================================================
fn project_module_from_row(row: &Row<'_>) -> std::result::Result<ProjectModuleRecord, rusqlite::Error> {
Ok(ProjectModuleRecord {
id: row.get("id")?,
project_id: row.get("project_id")?,
name: row.get("name")?,
path: row.get("path")?,
git_url: row.get("git_url")?,
stack: row.get("stack")?,
auto_detected: row.get("auto_detected")?,
sort_order: row.get("sort_order")?,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
})
}
// ============================================================
// Repo 实现(手写,参考 project_service_repo.rs 风格)
// ============================================================
/// 工程系统 CRUD(project_modules,V34,可改非审计)。
///
/// 与 [`ProjectServiceRepo`](super::ProjectServiceRepo) 同款手写 Repo:
/// 表有 `updated_at`,但工程无字段级白名单收口需求(无敏感字段、无枚举类型),
/// 故不强制走 `impl_repo!` 宏的 `update_field` 路径——`update_full` 整体替换足够。
pub struct ProjectModuleRepo {
conn: Arc<Mutex<rusqlite::Connection>>,
}
impl ProjectModuleRepo {
pub fn new(db: &Database) -> Self {
Self { conn: db.conn() }
}
/// 插入一条工程记录。`created_at` / `updated_at` 由本方法内部取当前毫秒覆盖。
/// 返回插入是否成功(affected > 0)。
pub async fn insert(&self, record: ProjectModuleRecord) -> Result<bool, df_types::error::Error> {
let conn = self.conn.clone();
let now = now_millis_str();
let mut rec = record;
rec.created_at = now.clone();
rec.updated_at = now;
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let conn = &*guard;
let r = &rec;
let affected = conn
.execute(
"INSERT INTO project_modules \
(id, project_id, name, path, git_url, stack, auto_detected, sort_order, created_at, updated_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![
r.id, r.project_id, r.name, r.path,
r.git_url, r.stack, r.auto_detected, r.sort_order,
r.created_at, r.updated_at,
],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 按 id 查询工程记录。
pub async fn get_by_id(&self, id: &str) -> Result<Option<ProjectModuleRecord>, df_types::error::Error> {
let conn = self.conn.clone();
let id = id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare("SELECT * FROM project_modules WHERE id = ?1")
.map_err(storage_err)?;
let row = stmt
.query_row(params![id], project_module_from_row)
.optional()
.map_err(storage_err)?;
Ok(row)
})
.await
.map_err(storage_err)?
}
/// 按项目查询全部工程(命中 idx_project_modules_project,按 sort_order ASC 稳定排序)。
pub async fn list_by_project(
&self,
project_id: &str,
) -> Result<Vec<ProjectModuleRecord>, df_types::error::Error> {
let conn = self.conn.clone();
let project_id = project_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
"SELECT id, project_id, name, path, git_url, stack, auto_detected, sort_order, created_at, updated_at \
FROM project_modules WHERE project_id = ?1 ORDER BY sort_order ASC",
)
.map_err(storage_err)?;
let rows = stmt
.query_map(params![project_id], project_module_from_row)
.map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
/// 整体更新工程记录(全可变字段,保留 id 与 created_at,updated_at 内部覆盖)。
/// 返回是否命中(affected > 0)。
pub async fn update_full(&self, record: &ProjectModuleRecord) -> Result<bool, df_types::error::Error> {
let conn = self.conn.clone();
let mut rec = record.clone();
rec.updated_at = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let conn = &*guard;
let r = &rec;
let affected = conn
.execute(
"UPDATE project_modules SET \
project_id = ?1, name = ?2, path = ?3, git_url = ?4, stack = ?5, \
auto_detected = ?6, sort_order = ?7, updated_at = ?8 \
WHERE id = ?9",
params![
r.project_id, r.name, r.path,
r.git_url, r.stack, r.auto_detected, r.sort_order,
r.updated_at, r.id,
],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 按 id 硬删工程(工程不需要软删审计追溯)。
pub async fn delete(&self, id: &str) -> Result<bool, df_types::error::Error> {
let conn = self.conn.clone();
let id = id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"DELETE FROM project_modules WHERE id = ?1",
params![id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 按项目删除全部工程(删项目时级联清理,硬删)。
pub async fn delete_by_project(&self, project_id: &str) -> Result<bool, df_types::error::Error> {
let conn = self.conn.clone();
let project_id = project_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"DELETE FROM project_modules WHERE project_id = ?1",
params![project_id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
}
// 引入 OptionalExtension 用于 query_row().optional()(避免每次写完整路径)
use rusqlite::OptionalExtension;
// ============================================================
// 单元测试 — ProjectModuleRepo CRUD(内存 DB,对标 project_service_repo 测试风格)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
/// 建库 + 建占位 project 满足 FK 约束 + 返回 repo(对标 project_service_repo::setup)。
async fn setup() -> (Database, ProjectModuleRepo, String) {
let db = Database::open_in_memory().await.expect("open_in_memory");
let project_id = "proj-test".to_string();
db.conn()
.blocking_lock()
.execute(
"INSERT INTO projects (id, name, status, path, stack, created_at, updated_at) \
VALUES (?1, ?2, 'active', '/tmp', 'rust', '0', '0')",
params![project_id, "Test Project"],
)
.expect("insert placeholder project");
let repo = ProjectModuleRepo::new(&db);
(db, repo, project_id)
}
fn mrec(project_id: &str, name: &str, path: &str) -> ProjectModuleRecord {
ProjectModuleRecord {
id: format!("mod-{name}"),
project_id: project_id.to_string(),
name: name.to_string(),
path: path.to_string(),
git_url: None,
stack: None,
auto_detected: false,
sort_order: 0,
created_at: "0".to_string(),
updated_at: "0".to_string(),
}
}
#[tokio::test]
async fn insert_and_get_by_id_reads_back_full_fields() {
let (_db, repo, pid) = setup().await;
let mut rec = mrec(&pid, "frontend", "/p/frontend");
rec.git_url = Some("https://example.com/f.git".to_string());
rec.stack = Some(r#"{"lang":"ts"}"#.to_string());
rec.auto_detected = true;
rec.sort_order = 2;
let ok = repo.insert(rec.clone()).await.expect("insert");
assert!(ok);
let got = repo.get_by_id(&rec.id).await.expect("get").expect("found");
assert_eq!(got.name, "frontend");
assert_eq!(got.path, "/p/frontend");
assert_eq!(got.git_url.as_deref(), Some("https://example.com/f.git"));
assert_eq!(got.stack.as_deref(), Some(r#"{"lang":"ts"}"#));
assert!(got.auto_detected);
assert_eq!(got.sort_order, 2);
}
#[tokio::test]
async fn list_by_project_orders_by_sort_order_asc() {
let (_db, repo, pid) = setup().await;
let mut a = mrec(&pid, "a", "/p/a");
a.sort_order = 5;
let mut b = mrec(&pid, "b", "/p/b");
b.sort_order = 1;
let mut c = mrec(&pid, "c", "/p/c");
c.sort_order = 3;
repo.insert(a).await.unwrap();
repo.insert(b).await.unwrap();
repo.insert(c).await.unwrap();
let list = repo.list_by_project(&pid).await.expect("list");
assert_eq!(list.len(), 3);
// sort_order ASC:b(1) → c(3) → a(5)
assert_eq!(list[0].name, "b");
assert_eq!(list[1].name, "c");
assert_eq!(list[2].name, "a");
}
#[tokio::test]
async fn update_full_changes_fields_and_preserves_created_at() {
let (_db, repo, pid) = setup().await;
let rec = mrec(&pid, "frontend", "/p/old");
repo.insert(rec.clone()).await.unwrap();
let original = repo.get_by_id(&rec.id).await.unwrap().unwrap();
// 整体更新:path 变了,sort_order 变了
let mut updated = original.clone();
updated.path = "/p/new".to_string();
updated.sort_order = 9;
let hit = repo.update_full(&updated).await.expect("update");
assert!(hit);
let got = repo.get_by_id(&rec.id).await.unwrap().unwrap();
assert_eq!(got.path, "/p/new");
assert_eq!(got.sort_order, 9);
// created_at 保留不变(update_full 不覆盖 created_at)
assert_eq!(got.created_at, original.created_at);
}
#[tokio::test]
async fn delete_removes_record() {
let (_db, repo, pid) = setup().await;
let rec = mrec(&pid, "frontend", "/p/f");
repo.insert(rec.clone()).await.unwrap();
let hit = repo.delete(&rec.id).await.expect("delete");
assert!(hit);
assert!(repo.get_by_id(&rec.id).await.unwrap().is_none());
}
#[tokio::test]
async fn delete_by_project_removes_all() {
let (_db, repo, pid) = setup().await;
repo.insert(mrec(&pid, "a", "/p/a")).await.unwrap();
repo.insert(mrec(&pid, "b", "/p/b")).await.unwrap();
let hit = repo.delete_by_project(&pid).await.expect("delete_by_project");
assert!(hit);
assert!(repo.list_by_project(&pid).await.unwrap().is_empty());
}
}

View File

@@ -180,8 +180,10 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
"id", "workflow_id", "node_id", "node_type", "status", "input_json", "output_json",
"error_message", "started_at", "completed_at",
],
// api_key 不列入白名单:密钥写入应只走 update_full/insert + keyring 配套,
// 禁止 update_field 旁路绕过 keyring(防明文写回 DB)。
"ai_providers" => &[
"id", "name", "provider_type", "api_key", "base_url", "default_model", "models",
"id", "name", "provider_type", "base_url", "default_model", "models",
"is_default", "config", "created_at", "updated_at",
],
"ai_conversations" => &[

View File

@@ -437,6 +437,60 @@ impl TaskRepo {
.map_err(storage_err)?
}
/// 按 TaskQuery 条件计数(不含 limit/offset,用于分页 total)。
///
/// 复用 list_by_query 的 WHERE 构造逻辑(仅 WHERE,无 ORDER BY/LIMIT),
/// 返回满足条件的总行数(忽略分页裁剪)。
pub async fn count_by_query(&self, query: &TaskQuery) -> Result<i64> {
let conn = self.conn.clone();
let project_id = query.project_id.clone();
let status = query.status.clone();
let priority = query.priority;
let keyword = query.keyword.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string()];
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(ref pid) = project_id {
where_clauses.push(format!("project_id = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(pid.clone()));
}
if let Some(ref s) = status {
where_clauses.push(format!("status = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(s.clone()));
}
if let Some(p) = priority {
where_clauses.push(format!("priority = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(p));
}
if let Some(ref kw) = keyword {
let escaped = kw.replace('%', "\\%").replace('_', "\\_");
let pat = format!("%{escaped}%");
let p1 = params_vec.len() + 1;
let p2 = p1 + 1;
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2}) ESCAPE '\\'"));
params_vec.push(Box::new(pat.clone()));
params_vec.push(Box::new(pat));
}
let sql = format!(
"SELECT COUNT(*) FROM tasks WHERE {}",
where_clauses.join(" AND ")
);
let param_refs: Vec<&dyn rusqlite::ToSql> =
params_vec.iter().map(|p| p.as_ref()).collect();
let count: i64 = guard
.query_row(&sql, param_refs.as_slice(), |row| row.get(0))
.map_err(storage_err)?;
Ok(count)
})
.await
.map_err(|e| storage_err(format!("count_by_query join failed: {e}")))?
}
/// 查某父任务的全部子任务(parent_id = ?,deleted_at IS NULL),按创建时间升序。
///
/// 知识图谱 Phase 1 V29(对标设计 §2.1 父任务聚合规则):父任务=容器模型,status
@@ -730,16 +784,16 @@ mod tests {
let repo = setup().await;
repo.insert(trec("parent", "todo", None)).await.unwrap();
// 4 个子任务:status 分布 todo×2 / in_progress×1 / done×1(queue 统一 todo)
repo.insert(trec_full("c1", "todo", Some("parent"), "todo"))
repo.insert(trec_full("c1", "todo", Some("parent"), TaskStatus::Todo))
.await
.unwrap();
repo.insert(trec_full("c2", "todo", Some("parent"), "todo"))
repo.insert(trec_full("c2", "todo", Some("parent"), TaskStatus::Todo))
.await
.unwrap();
repo.insert(trec_full("c3", "todo", Some("parent"), "in_progress"))
repo.insert(trec_full("c3", "todo", Some("parent"), TaskStatus::InProgress))
.await
.unwrap();
repo.insert(trec_full("c4", "todo", Some("parent"), "done"))
repo.insert(trec_full("c4", "todo", Some("parent"), TaskStatus::Done))
.await
.unwrap();
@@ -787,7 +841,7 @@ mod tests {
repo.insert(trec_full("parent", "todo", None, TaskStatus::Todo))
.await
.unwrap();
let ok = repo.set_status_for_aggregation("parent", "in_progress");
let ok = repo.set_status_for_aggregation("parent", "in_progress").await.unwrap();
assert!(ok, "应命中写入");
let after = repo.get_by_id("parent").await.unwrap().unwrap();
assert_eq!(after.status.as_str(), "in_progress", "status 应被聚合写入更新");

View File

@@ -45,7 +45,7 @@ pub fn run(conn: &Connection) -> Result<()> {
// 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。
// V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照,
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
let steps: [(i32, fn(&Connection) -> Result<()>); 33] = [
let steps: [(i32, fn(&Connection) -> Result<()>); 36] = [
(1, migrate_v1),
(2, migrate_v2),
(3, migrate_v3),
@@ -79,6 +79,9 @@ pub fn run(conn: &Connection) -> Result<()> {
(31, migrate_v31),
(32, migrate_v32),
(33, migrate_v33),
(34, migrate_v34),
(35, migrate_v35),
(36, migrate_v36),
];
for (version, migrate_fn) in steps {
@@ -908,7 +911,7 @@ fn migrate_v31(conn: &Connection) -> Result<()> {
/// V32: ai_conversations 加 pinned_goals 列(对话透明化 L1 目标钉扎持久化)
///
/// 对话目标由 PerConvState.pinned_goals(Vec<String>)管理,原先仅在内存态存在,
/// 对话目标由 PerConvState.pinned_goals(Vec<GoalEntry>)管理,原先仅在内存态存在,
/// 此迁移为其提供持久化列,默认空 JSON 数组'[]'。
fn migrate_v32(conn: &Connection) -> Result<()> {
conn.execute_batch(
@@ -937,11 +940,181 @@ fn migrate_v33(conn: &Connection) -> Result<()> {
} else {
tracing::info!("v33: pending_approvals 列已存在,跳过");
}
// 任务4: workflow_executions.updated_at 列 —— 工作流执行记录更新时间戳(用于排序/增量同步/中文)。
// 同样用 PRAGMA 探测列存在性(同 v4 模式),缺失才 ALTER;若表本身不存在(极端坏库),跳过该列不阻断迁移。
let has_updated_at: bool = conn
.query_row(
"SELECT COUNT(*) > 0 FROM pragma_table_info('workflow_executions') WHERE name = 'updated_at'",
[],
|row| row.get(0),
)
.unwrap_or(false);
if !has_updated_at {
// workflow_executions 表在 V1 建表,此处仅加列。若表不存在(理论上 V1 必建,但坏库防御)
// pragma_table_info 返 0 行,has_updated_at 为 false,会尝试 ALTER → 报错被跳过(下面 match)。
match conn.execute_batch("ALTER TABLE workflow_executions ADD COLUMN updated_at TEXT;") {
Ok(_) => tracing::info!("v33: workflow_executions 加 updated_at 列"),
Err(e) => tracing::warn!("v33: workflow_executions.updated_at 加列失败(表不存在?)跳过: {}", e),
}
} else {
tracing::info!("v33: workflow_executions.updated_at 列已存在,跳过");
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [33])?;
tracing::info!("迁移 v33 完成");
Ok(())
}
/// V34:工程系统—project_modules 表(项目多工程,每个工程独立代码仓库)
///
/// 一个项目可含多个工程(Monorepo 多仓库 / 微服务 / 前后端分离)。
/// 每个工程有独立的目录(path)、Git 地址(git_url)、技术栈(stack)。
/// 单仓库项目退化:项目下只有一个工程(path = 绑定目录)。
///
/// Git 状态(分支/改动/提交)是实时派生的(查 git 命令),不存表。
fn migrate_v34(conn: &Connection) -> Result<()> {
conn.execute(
"CREATE TABLE IF NOT EXISTS project_modules (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
name TEXT NOT NULL,
path TEXT NOT NULL,
git_url TEXT,
stack TEXT,
auto_detected BOOLEAN NOT NULL DEFAULT FALSE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_project_modules_project ON project_modules(project_id)",
[],
)?;
tracing::info!("v34: 建 project_modules 表 + 索引(工程系统)");
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [34])?;
tracing::info!("迁移 v34 完成");
Ok(())
}
/// V35:工程依赖关系—module_dependencies 表(工程间依赖边,用于依赖图)
///
/// dep_type 枚举值:library(类库) / api(API调用) / mq(消息队列) / shared(共享资源) / custom(自定义)
fn migrate_v35(conn: &Connection) -> Result<()> {
conn.execute(
"CREATE TABLE IF NOT EXISTS module_dependencies (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
from_module_id TEXT NOT NULL REFERENCES project_modules(id),
to_module_id TEXT NOT NULL REFERENCES project_modules(id),
dep_type TEXT NOT NULL DEFAULT 'library',
label TEXT,
created_at TEXT NOT NULL
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_module_deps_project ON module_dependencies(project_id)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_module_deps_from ON module_dependencies(from_module_id)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_module_deps_to ON module_dependencies(to_module_id)",
[],
)?;
tracing::info!("v35: 建 module_dependencies 表 + 索引(工程依赖图)");
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [35])?;
tracing::info!("迁移 v35 完成");
Ok(())
}
/// V36:多 Agent 并行执行数据层(ai_plans/ai_subtasks/ai_conflicts 3 新表 +
/// ai_messages/ai_tool_executions 加 subtask_id 列)。
/// 设计依据:docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md §二
fn migrate_v36(conn: &Connection) -> Result<()> {
// 1. ai_messages 加 subtask_id 列(消息归属子任务,NULL=单 Agent 时期)
if !column_exists(conn, "ai_messages", "subtask_id") {
conn.execute("ALTER TABLE ai_messages ADD COLUMN subtask_id TEXT", [])?;
tracing::info!("v36: ai_messages 加 subtask_id 列");
}
// 2. ai_tool_executions 加 subtask_id 列(工具调用归属子任务)
if !column_exists(conn, "ai_tool_executions", "subtask_id") {
conn.execute("ALTER TABLE ai_tool_executions ADD COLUMN subtask_id TEXT", [])?;
tracing::info!("v36: ai_tool_executions 加 subtask_id 列");
}
// 3. ai_plans 表(Plan 生命周期:用户消息触发→拆解→执行→合并→完成)
conn.execute(
"CREATE TABLE IF NOT EXISTS ai_plans (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
user_message_id TEXT,
status TEXT NOT NULL DEFAULT 'planning',
subtask_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
completed_at TEXT
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_ai_plans_conv ON ai_plans(conversation_id, created_at)",
[],
)?;
// 4. ai_subtasks 表(SubTask 状态 + DAG 层级 + Git worktree 分支)
conn.execute(
"CREATE TABLE IF NOT EXISTS ai_subtasks (
id TEXT PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES ai_plans(id),
persona_id TEXT,
intent TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
layer INTEGER NOT NULL DEFAULT 0,
deps TEXT,
branch TEXT,
created_at TEXT NOT NULL,
completed_at TEXT
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_ai_subtasks_plan ON ai_subtasks(plan_id, layer)",
[],
)?;
// 5. ai_conflicts 表(合并冲突:同文件路径 + 跨文件语义冲突)
conn.execute(
"CREATE TABLE IF NOT EXISTS ai_conflicts (
id TEXT PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES ai_plans(id),
file_path TEXT NOT NULL DEFAULT '',
conflict_type TEXT NOT NULL DEFAULT 'file',
subtask_a TEXT,
subtask_b TEXT,
diff_a TEXT,
diff_b TEXT,
resolution TEXT NOT NULL DEFAULT 'pending',
resolved_by TEXT,
created_at TEXT NOT NULL,
resolved_at TEXT
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_ai_conflicts_plan ON ai_conflicts(plan_id, resolution)",
[],
)?;
tracing::info!("v36: 建 ai_plans/ai_subtasks/ai_conflicts 表 + subtask_id 列(多 Agent 并行执行数据层)");
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [36])?;
tracing::info!("迁移 v36 完成");
Ok(())
}
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
///
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。

View File

@@ -280,7 +280,9 @@ pub struct NodeExecutionRecord {
// ============================================================
/// AI 提供商配置记录
#[derive(Debug, Clone, Serialize, Deserialize)]
///
/// 自定义 Debug: api_key 脱敏为 `"sk-****"`, model_configs / config 脱敏为 `"***"`。
#[derive(Clone, Serialize, Deserialize)]
pub struct AiProviderRecord {
pub id: String,
pub name: String,
@@ -308,6 +310,27 @@ pub struct AiProviderRecord {
pub weight: u32,
}
impl std::fmt::Debug for AiProviderRecord {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AiProviderRecord")
.field("id", &self.id)
.field("name", &self.name)
.field("provider_type", &self.provider_type)
.field("api_key", &"sk-****")
.field("base_url", &self.base_url)
.field("default_model", &self.default_model)
.field("models", &self.models)
.field("model_configs", &"***")
.field("is_default", &self.is_default)
.field("config", &"***")
.field("created_at", &self.created_at)
.field("updated_at", &self.updated_at)
.field("enabled", &self.enabled)
.field("weight", &self.weight)
.finish()
}
}
/// AiProviderRecord.enabled 的 serde 默认(true)。老库/缺字段 JSON → enabled。
fn default_enabled() -> bool {
true
@@ -337,7 +360,7 @@ pub struct AiConversationRecord {
pub pinned: bool, // 是否置顶(排序置前;UX-17)
pub prompt_tokens: Option<i64>, // 输入 token 累计(流式 usage 落库)
pub completion_tokens: Option<i64>, // 输出 token 累计(流式 usage 落库)
pub pinned_goals: Option<String>, // 对话目标钉扎持久化(JSON 字符串数组,默认'[]')
pub pinned_goals: Option<String>, // 对话目标钉扎持久化(JSON GoalEntry 数组 [{text,status}],兼容旧 ["text"] 格式,默认'[]')
pub pending_approvals: Option<String>, // 挂起审批快照(JSON 对象,以 tool_call_id 为键,默认'{}')
pub created_at: String,
pub updated_at: String,
@@ -441,3 +464,95 @@ pub struct KnowledgeEventRecord {
pub context_json: Option<String>, // JSON: 因 event_type 而异
pub timestamp: String,
}
/// 工程记录(project_modules 表,项目多工程,每个工程独立代码仓库)
///
/// 一个项目可含多个工程(Monorepo 多仓库 / 微服务 / 前后端分离)。
/// 每个工程有独立的目录(`path`)、Git 地址(`git_url`)、技术栈(`stack`)。
/// Git 状态(分支/改动/提交)是实时派生的(查 git 命令),不存表。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectModuleRecord {
pub id: String,
pub project_id: String,
pub name: String,
pub path: String,
pub git_url: Option<String>,
pub stack: Option<String>, // 技术栈 JSON 字符串
pub auto_detected: bool,
pub sort_order: i32,
pub created_at: String,
pub updated_at: String,
}
/// 工程依赖关系记录(V35 module_dependencies 表)。
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ModuleDependencyRecord {
pub id: String,
pub project_id: String,
pub from_module_id: String,
pub to_module_id: String,
/// 依赖类型:library / api / mq / shared / custom
pub dep_type: String,
/// 可选标签(自定义描述)
pub label: Option<String>,
pub created_at: String,
}
// ============================================================
// 多 Agent 并行执行模型 (V36)
// ============================================================
/// Plan 记录(一次用户消息触发的并行执行计划)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanRecord {
pub id: String,
pub conversation_id: String,
/// 触发 Plan 的用户消息 id
pub user_message_id: Option<String>,
/// planning / executing / merging / done / error
pub status: String,
pub subtask_count: i64,
pub created_at: String,
pub completed_at: Option<String>,
}
/// SubTask 记录(Plan 拆解出的子任务)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubTaskRecord {
pub id: String,
pub plan_id: String,
/// 分配的人设 id(coder/reviewer/architect/tester/analyst)
pub persona_id: Option<String>,
/// 子任务意图描述
pub intent: String,
/// pending / running / done / error
pub status: String,
/// DAG 层级(0 起)
pub layer: i64,
/// 依赖的 SubTask id 列表(JSON 数组字符串)
pub deps: Option<String>,
/// Git worktree 分支名(NULL=非 Git 工程降级串行)
pub branch: Option<String>,
pub created_at: String,
pub completed_at: Option<String>,
}
/// 冲突记录(合并时检测到的文件/语义冲突)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConflictRecord {
pub id: String,
pub plan_id: String,
pub file_path: String,
/// file(同文件路径) / semantic(编译失败)
pub conflict_type: String,
pub subtask_a: Option<String>,
pub subtask_b: Option<String>,
pub diff_a: Option<String>,
pub diff_b: Option<String>,
/// pending / a / b / merged / manual
pub resolution: String,
/// reviewer / user
pub resolved_by: Option<String>,
pub created_at: String,
pub resolved_at: Option<String>,
}

View File

@@ -101,6 +101,18 @@ pub fn get_provider_secret(id: &str) -> Option<String> {
}
}
/// [`get_provider_secret`] 的 async 版本:`spawn_blocking` 隔离同步 keyring 调用。
///
/// keyring 在 Linux secret-service D-Bus / Windows COM / macOS Keychain 同步阻塞,直接在
/// async 上下文调用会卡 tokio runtime(Tauri 单线程 runtime 尤其敏感)。async 路径优先用此版本;
/// 同步版保留供 block_on / 非 async 路径(迁移、测试)使用。
pub async fn get_provider_secret_async(id: String) -> Option<String> {
tokio::task::spawn_blocking(move || get_provider_secret(&id))
.await
.ok()
.flatten()
}
/// 消费点用:解析 provider 真实密钥 — DB 优先,fallback keyring(兼容未迁移老库)
pub fn resolve_provider_secret(record: &AiProviderRecord) -> String {
if !record.api_key.is_empty() {
@@ -109,18 +121,43 @@ pub fn resolve_provider_secret(record: &AiProviderRecord) -> String {
get_provider_secret(&record.id).unwrap_or_default()
}
/// [`resolve_provider_secret`] 的 async 版本 — DB 有明文时同步返(不触 keyring),
/// 否则 `spawn_blocking` 调 keyring 防 D-Bus / COM 阻塞 tokio runtime。
///
/// 注:DB 明文路径直接 clone 同步返,只有 fallback keyring 才走 spawn_blocking。
pub async fn resolve_provider_secret_async(record: AiProviderRecord) -> String {
if !record.api_key.is_empty() {
return record.api_key;
}
get_provider_secret_async(record.id).await.unwrap_or_default()
}
/// 写入密钥到 keyring(覆盖)
pub fn set_provider_secret(id: &str, key: &str) -> anyhow::Result<()> {
let entry = entry_for(id)?;
entry.set_password(key).map_err(|e| anyhow::anyhow!("keyring 写入失败(provider={}): {}", id, e))
}
/// [`set_provider_secret`] 的 async 版本(`spawn_blocking` 隔离同步 keyring 调用)。
pub async fn set_provider_secret_async(id: String, key: String) -> anyhow::Result<()> {
tokio::task::spawn_blocking(move || set_provider_secret(&id, &key))
.await
.map_err(|e| anyhow::anyhow!("set_provider_secret join 失败: {}", e))?
}
/// 删除 keyring 密钥(provider 删除时清理)
pub fn delete_provider_secret(id: &str) -> anyhow::Result<()> {
let entry = entry_for(id)?;
entry.delete_credential().map_err(|e| anyhow::anyhow!("keyring 删除失败(provider={}): {}", id, e))
}
/// [`delete_provider_secret`] 的 async 版本(`spawn_blocking` 隔离同步 keyring 调用)。
pub async fn delete_provider_secret_async(id: String) -> anyhow::Result<()> {
tokio::task::spawn_blocking(move || delete_provider_secret(&id))
.await
.map_err(|e| anyhow::anyhow!("delete_provider_secret join 失败: {}", e))?
}
/// 启动一次性迁移:DB 明文 → keyring → DB 置空(失败保留明文下次重试,非阻断)
pub async fn migrate_secrets_to_keyring(repo: &AiProviderRepo) -> anyhow::Result<usize> {
let providers = repo.list_all().await?;

View File

@@ -94,6 +94,13 @@ pub trait TunnelClient: Send + Sync {
/// 此窗口内若收到 error 帧或连接 Close,判定握手失败)
const HANDSHAKE_PROBE: Duration = Duration::from_secs(3);
/// TCP/WS 建立连接(connect_async)超时上限。
///
/// 对齐 df-ai http.rs connect_timeout(15s):relay URL 不可达(TCP SYN 无响应)时
/// connect_async 会挂 60-120s 才返系统 timeout,Tauri 单线程 tokio runtime 卡死。
/// 包 `tokio::time::timeout` 在此窗口内未完成 → 返 TunnelError::Connect 显式错误。
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
/// 心跳间隔(应用层 Ping,补协议层 keepalive,防 NAT 连接表超时回收)
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(25);
@@ -177,10 +184,18 @@ impl TunnelClient for WsTunnelClient {
self.cleanup_conn().await;
}
// 1. 建立 WS 连接
let (ws_stream, _resp) = tokio_tungstenite::connect_async(url)
.await
.map_err(|e| TunnelError::Connect(format!("WS 连接失败 {url}: {e}")))?;
// 1. 建立 WS 连接(包 CONNECT_TIMEOUT 防 relay 不可达时永久挂 — TCP SYN 无响应
// 系统 timeout 60-120s 才返,Tauri 单线程 tokio runtime 会卡死。对齐 df-ai http.rs
// connect_timeout(15s)。保留原错误映射语义:TunnelError::Connect(format!("WS 连接失败 ..."))
let connect_fut = tokio_tungstenite::connect_async(url);
let (ws_stream, _resp) = match tokio::time::timeout(CONNECT_TIMEOUT, connect_fut).await {
Ok(res) => res.map_err(|e| TunnelError::Connect(format!("WS 连接失败 {url}: {e}")))?,
Err(_) => {
return Err(TunnelError::Connect(format!(
"WS 连接超时(>{CONNECT_TIMEOUT:?} 无响应){url}"
)));
}
};
tracing::info!(%url, "WS 连接已建立,开始 Hello 握手");
let (mut sink, mut stream) = ws_stream.split();

View File

@@ -435,6 +435,7 @@ mod tests {
status: ProjectStatus::InProgress,
description: "AI-native dev tool".to_string(),
path: Some(SanitizedPath::new("E:/wk-lab/devflow")),
extra: vec![],
};
let json = serde_json::to_string(&a).expect("serialize");
assert!(json.contains("\"kind\":\"project\""));
@@ -452,6 +453,7 @@ mod tests {
status: ProjectStatus::Planning,
description: String::new(),
path: None,
extra: vec![],
};
let json = serde_json::to_string(&a).expect("serialize");
// skip_serializing_if 生效:path 字段不出现在 JSON 中
@@ -469,6 +471,7 @@ mod tests {
status: TaskStatus::Todo,
description: "do X".to_string(),
project_name: Some("devflow".to_string()),
extra: vec![],
};
let json = serde_json::to_string(&a).expect("serialize");
assert!(json.contains("\"kind\":\"task\""));
@@ -485,6 +488,7 @@ mod tests {
title: "Multi-round parallel".to_string(),
status: IdeaStatus::Approved,
description: "desc".to_string(),
extra: vec![],
};
let json = serde_json::to_string(&a).expect("serialize");
assert!(json.contains("\"kind\":\"idea\""));
@@ -520,6 +524,7 @@ mod tests {
status: ProjectStatus::Planning,
description: String::new(),
path: None,
extra: vec![],
};
assert_eq!(p.kind_str(), "project");
assert_eq!(p.section_label(), "项目");

View File

@@ -22,8 +22,68 @@ pub type ReleaseId = String;
pub type BranchId = String;
/// 插件 ID
pub type PluginId = String;
/// 执行 ID
pub type ExecutionId = String;
/// 执行 ID(IPC 边界透明序列化为字符串)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
#[serde(transparent)]
pub struct ExecutionId(String);
impl ExecutionId {
/// 构造新执行 ID
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
}
impl std::fmt::Display for ExecutionId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::ops::Deref for ExecutionId {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl From<String> for ExecutionId {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for ExecutionId {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<ExecutionId> for String {
fn from(id: ExecutionId) -> Self {
id.0
}
}
// ExecutionId 与 &str 的比较(测试/匹配中大量使用)
impl PartialEq<&str> for ExecutionId {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
impl PartialEq<str> for ExecutionId {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
impl PartialEq<String> for ExecutionId {
fn eq(&self, other: &String) -> bool {
self.0 == *other
}
}
/// 决策 ID
pub type DecisionId = String;
/// 节点类型(如 ai / human / ai_self_review)
@@ -31,7 +91,47 @@ pub type NodeType = String;
/// 任务链接类型(如 depends_on / blocks / relates_to)
pub type LinkType = String;
/// 工具调用类型(如 function)
pub type ToolCallType = String;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
#[serde(transparent)]
pub struct ToolCallType(String);
impl ToolCallType {
/// 构造新工具调用类型
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
}
impl std::fmt::Display for ToolCallType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::ops::Deref for ToolCallType {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl From<String> for ToolCallType {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for ToolCallType {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<ToolCallType> for String {
fn from(t: ToolCallType) -> Self {
t.0
}
}
// ============================================================
// 时间工具
@@ -72,7 +172,9 @@ pub enum IdeaStatus {
impl IdeaStatus {
/// 返回数据库存储用的小写字符串
pub fn as_str(&self) -> &'static str {
///
/// `const fn`(与 TaskStatus::as_str 对齐,允许调用方在 const 上下文使用)。
pub const fn as_str(&self) -> &'static str {
match self {
IdeaStatus::Draft => "draft",
IdeaStatus::PendingReview => "pending_review",
@@ -128,7 +230,9 @@ pub enum ProjectStatus {
impl ProjectStatus {
/// 返回数据库存储用的小写字符串
pub fn as_str(&self) -> &'static str {
///
/// `const fn`(与 TaskStatus::as_str 对齐,允许调用方在 const 上下文使用)。
pub const fn as_str(&self) -> &'static str {
match self {
ProjectStatus::Planning => "planning",
ProjectStatus::InProgress => "in_progress",
@@ -186,7 +290,10 @@ pub enum TaskStatus {
impl TaskStatus {
/// 返回数据库存储用的小写字符串
pub fn as_str(&self) -> &'static str {
///
/// `const fn` 使调用方可在 `const` 上下文调用(任务 #17 合并:df-nodes 的
/// `task_state_machine` 字符串常量从本方法派生,消除双源柡移)。
pub const fn as_str(&self) -> &'static str {
match self {
TaskStatus::Todo => "todo",
TaskStatus::InProgress => "in_progress",

View File

@@ -23,3 +23,4 @@ async-trait = { workspace = true }
futures = "0.3"
anyhow = { workspace = true }
tracing = { workspace = true }
serde_yaml = "0.9"

View File

@@ -30,8 +30,10 @@ impl EventBus {
/// 发送事件
pub async fn send(&self, event: WorkflowEvent) {
// broadcast::send 是同步的,忽略接收者已关闭的错误
let _ = self.sender.send(event);
// broadcast::send 是同步的接收者已关闭时记录警告
if let Err(e) = self.sender.send(event) {
tracing::warn!("EventBus 发送事件失败: {}", e);
}
}
/// 订阅事件

View File

@@ -110,7 +110,7 @@ async fn node_config_overrides_global_in_node_context() {
let dag = registry.build_dag(&def).expect("build_dag");
let mut executor = DagExecutor::new(EventBus::new(), "test-nodecfg".to_string());
let mut executor = DagExecutor::new(EventBus::new(), "test-nodecfg".into());
// 全局 config 写 foo=GLOBAL验证被 n1 节点级覆盖n2 回退用全局)
let outputs = executor
.run(&dag, serde_json::json!({ "foo": "GLOBAL" }))
@@ -138,7 +138,7 @@ async fn test_same_layer_runs_in_parallel() {
dag.add_node("a".to_string(), Box::new(SleepNode { sleep_ms: 100 }));
dag.add_node("b".to_string(), Box::new(SleepNode { sleep_ms: 100 }));
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".to_string());
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".into());
let start = std::time::Instant::now();
let outputs = executor.run(&dag, serde_json::Value::Null).await.unwrap();
let elapsed = start.elapsed();
@@ -161,7 +161,7 @@ async fn test_layer_failure_aborts_following_layers() {
dag.add_node("c".to_string(), Box::new(SleepNode { sleep_ms: 10 }));
dag.add_edge("a".to_string(), "c".to_string());
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".to_string());
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".into());
let err = executor
.run(&dag, serde_json::Value::Null)
.await
@@ -206,7 +206,7 @@ async fn test_cancelled_node_skips_set_failed() {
let mut dag = Dag::new();
dag.add_node("x".to_string(), Box::new(CancelSelfNode));
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel".to_string());
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel".into());
let result = executor.run(&dag, serde_json::Value::Null).await;
// run 返回 Err(取消致中止后续层),但不 panic/bail transition
@@ -257,7 +257,7 @@ async fn test_cancelled_node_skips_set_completed() {
let mut dag = Dag::new();
dag.add_node("y".to_string(), Box::new(CancelSelfThenOkNode));
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel-ok".to_string());
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel-ok".into());
let result = executor.run(&dag, serde_json::Value::Null).await;
// run 返回 Ok —— Ok 节点不应因 TOCTOU 取消被误判为失败
@@ -285,7 +285,7 @@ async fn test_cancelled_node_emits_node_cancelled_event() {
let bus = EventBus::new();
let mut rx = bus.subscribe();
let mut executor = DagExecutor::new(bus, "test-cancel-event".to_string());
let mut executor = DagExecutor::new(bus, "test-cancel-event".into());
let _ = executor.run(&dag, serde_json::Value::Null).await;
// 收集所有事件(run 已结束,事件总线无新事件)
@@ -408,7 +408,7 @@ async fn conditions_eval_routes_by_edge_condition() {
"$.ok == false".to_string(),
);
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-route".to_string());
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-route".into());
let outputs = executor
.run(&dag, serde_json::Value::Null)
.await
@@ -462,7 +462,7 @@ async fn conditions_eval_skip_keeps_pending() {
"$.ok == false".to_string(),
);
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-skip".to_string());
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-skip".into());
let outputs = executor
.run(&dag, serde_json::Value::Null)
.await
@@ -504,7 +504,7 @@ async fn conditions_eval_unconditional_edge_always_passes() {
"$.ok == false".to_string(),
);
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-mix".to_string());
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-mix".into());
let outputs = executor
.run(&dag, serde_json::Value::Null)
.await

View File

@@ -8,3 +8,4 @@ pub mod executor;
pub mod node;
pub mod registry;
pub mod state;
pub mod template_loader;

View File

@@ -15,6 +15,17 @@ use std::sync::{Arc, Mutex};
use df_types::types::{NodeId, NodeStatus};
/// 节点状态机
///
/// # ⚠️ 并发安全警告
///
/// `state_machine` 方法(`get`、`transition`、`set_running`、`set_completed`、
/// `set_failed`、`set_cancelled`、`snapshot`、`is_cancelled`)内部持锁
/// `self.states.lock()`**不得在 `.await` 期间持锁**。
/// 若在异步上下文中调用,请确保获取结果后立即释放锁(即不要将锁跨越
/// `.await` 点)。当前所有方法均为同步且及时释放,符合此规则。
///
/// 共享语义:内部用 `Arc<Mutex<HashMap>>``clone()` 为浅拷贝Arc 引用计数 +1
/// 所有 clone 共享同一底层 HashMap。
#[derive(Debug, Clone)]
pub struct StateMachine {
/// 节点状态映射Arc 共享clone 浅拷贝同一 HashMap

View File

@@ -0,0 +1,330 @@
//! YAML 模板加载器 — 从 YAML 字符串加载 DagDef + 校验
//!
//! 模板格式:
//! ```yaml
//! name: 代码审查
//! description: AI 驱动的代码审查流程
//! nodes:
//! review:
//! type: ai
//! config:
//! prompt: "审查以下代码变更..."
//! persona_id: reviewer
//! notify:
//! type: notify
//! config:
//! type: desktop
//! title: "审查完成"
//! edges:
//! - from: review
//! to: notify
//! ```
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::dag_def::{DagDef, EdgeDef, NodeDef};
/// YAML 模板顶层结构
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowTemplate {
pub name: String,
#[serde(default)]
pub description: String,
pub nodes: HashMap<String, TemplateNode>,
#[serde(default)]
pub edges: Vec<TemplateEdge>,
}
/// 模板节点(YAML 友好命名)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateNode {
/// 节点类型(ai/script/human/git/http/notify/subflow)
#[serde(rename = "type")]
pub node_type: String,
#[serde(default)]
pub config: serde_json::Value,
#[serde(default)]
pub label: Option<String>,
}
/// 模板边(YAML 友好命名)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateEdge {
pub from: String,
pub to: String,
#[serde(default)]
pub condition: Option<String>,
}
/// 模板加载错误
#[derive(Debug)]
pub enum TemplateError {
/// YAML 解析失败
YamlParse(String),
/// 校验失败(空节点/未知类型/依赖环)
Validation(String),
}
impl std::fmt::Display for TemplateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::YamlParse(msg) => write!(f, "YAML 解析失败: {}", msg),
Self::Validation(msg) => write!(f, "模板校验失败: {}", msg),
}
}
}
impl std::error::Error for TemplateError {}
/// 已知的节点类型白名单
const KNOWN_NODE_TYPES: &[&str] = &[
"ai", "script", "human", "git", "http", "notify", "subflow",
];
/// 从 YAML 字符串加载模板并转换为 DagDef
///
/// 步骤:
/// 1. YAML → WorkflowTemplate(serde_yaml)
/// 2. 校验(节点非空/类型合法/边引用存在/无环)
/// 3. 转换为 DagDef
pub fn load_template(yaml: &str) -> Result<DagDef, TemplateError> {
// 1. YAML 解析
let template: WorkflowTemplate = serde_yaml::from_str(yaml)
.map_err(|e| TemplateError::YamlParse(e.to_string()))?;
// 2. 校验
validate_template(&template)?;
// 3. 转换为 DagDef
let mut dag = DagDef::new();
for (id, node) in &template.nodes {
dag.nodes.insert(
id.clone(),
NodeDef {
id: id.clone(),
node_type: node.node_type.clone(),
config: node.config.clone(),
label: node.label.clone(),
},
);
}
for edge in &template.edges {
dag.edges.push(EdgeDef {
source: edge.from.clone(),
target: edge.to.clone(),
condition: edge.condition.clone(),
});
}
Ok(dag)
}
/// 校验模板结构
fn validate_template(t: &WorkflowTemplate) -> Result<(), TemplateError> {
// 节点非空
if t.nodes.is_empty() {
return Err(TemplateError::Validation("模板节点不能为空".into()));
}
// 节点类型合法
for (id, node) in &t.nodes {
if !KNOWN_NODE_TYPES.contains(&node.node_type.as_str()) {
return Err(TemplateError::Validation(format!(
"节点 '{}' 使用了未知类型 '{}',已知类型: {:?}",
id, node.node_type, KNOWN_NODE_TYPES
)));
}
}
// 边引用的节点存在
let node_ids: std::collections::HashSet<&String> = t.nodes.keys().collect();
for edge in &t.edges {
if !node_ids.contains(&edge.from) {
return Err(TemplateError::Validation(format!(
"边的起点 '{}' 不存在", edge.from
)));
}
if !node_ids.contains(&edge.to) {
return Err(TemplateError::Validation(format!(
"边的终点 '{}' 不存在", edge.to
)));
}
}
// 依赖无环(DFS 检测)
if has_cycle(&t.edges) {
return Err(TemplateError::Validation("依赖存在环".into()));
}
Ok(())
}
/// DFS 检测边列表是否有环
fn has_cycle(edges: &[TemplateEdge]) -> bool {
use std::collections::{HashMap, HashSet};
// 建邻接表
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
for e in edges {
adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
}
let mut visited: HashSet<&str> = HashSet::new();
let mut in_stack: HashSet<&str> = HashSet::new();
fn dfs<'a>(
node: &'a str,
adj: &HashMap<&'a str, Vec<&'a str>>,
visited: &mut HashSet<&'a str>,
in_stack: &mut HashSet<&'a str>,
) -> bool {
if in_stack.contains(node) {
return true; // 环
}
if visited.contains(node) {
return false; // 已访问过,无环
}
visited.insert(node);
in_stack.insert(node);
if let Some(neighbors) = adj.get(node) {
for next in neighbors {
if dfs(next, adj, visited, in_stack) {
return true;
}
}
}
in_stack.remove(node);
false
}
for e in edges {
let start = e.from.as_str();
if dfs(start, &adj, &mut visited, &mut in_stack) {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tpl_01_valid_template_loads() {
let yaml = r#"
name: 代码审查
description: AI 驱动的代码审查
nodes:
review:
type: ai
config:
prompt: "审查代码"
notify:
type: notify
config:
type: desktop
title: "完成"
edges:
- from: review
to: notify
"#;
let dag = load_template(yaml).unwrap();
assert_eq!(dag.nodes.len(), 2);
assert_eq!(dag.edges.len(), 1);
assert!(dag.nodes.contains_key("review"));
assert_eq!(dag.nodes.get("review").unwrap().node_type, "ai");
}
#[test]
fn tpl_02_empty_nodes_rejected() {
let yaml = "name: 空\nnodes: {}\n";
let result = load_template(yaml);
assert!(matches!(result, Err(TemplateError::Validation(_))));
}
#[test]
fn tpl_03_unknown_node_type_rejected() {
let yaml = r#"
name: test
nodes:
bad:
type: unknown_type
config: {}
"#;
let result = load_template(yaml);
assert!(matches!(result, Err(TemplateError::Validation(_))));
}
#[test]
fn tpl_04_cycle_detected() {
let yaml = r#"
name: 环
nodes:
a:
type: ai
config: {}
b:
type: ai
config: {}
edges:
- from: a
to: b
- from: b
to: a
"#;
let result = load_template(yaml);
assert!(matches!(result, Err(TemplateError::Validation(ref msg)) if msg.contains("")));
}
#[test]
fn tpl_05_edge_to_nonexistent_rejected() {
let yaml = r#"
name: test
nodes:
a:
type: ai
config: {}
edges:
- from: a
to: ghost
"#;
let result = load_template(yaml);
assert!(matches!(result, Err(TemplateError::Validation(_))));
}
#[test]
fn tpl_06_no_edges_ok() {
let yaml = r#"
name: 单节点
nodes:
solo:
type: script
config:
command: "echo hello"
"#;
let dag = load_template(yaml).unwrap();
assert_eq!(dag.nodes.len(), 1);
assert!(dag.edges.is_empty());
}
#[test]
fn tpl_07_invalid_yaml_rejected() {
let yaml = "not: valid: yaml: {{{";
let result = load_template(yaml);
assert!(matches!(result, Err(TemplateError::YamlParse(_))));
}
#[test]
fn tpl_08_all_known_node_types() {
for nt in KNOWN_NODE_TYPES {
let yaml = format!(
"name: test\nnodes:\n n:\n type: {}\n config: {{}}",
nt
);
let result = load_template(&yaml);
assert!(result.is_ok(), "节点类型 {} 应合法", nt);
}
}
}

10
dev-server.bat Normal file
View File

@@ -0,0 +1,10 @@
@echo off
cd /d E:\wk-lab\devflow
echo [dev] 启动 DevFlow 开发服务器...
echo [dev] Vite + Tauri 后端将同时启动
echo [dev] 请勿关闭此窗口
echo.
C:\Users\23780\.bun\bin\bun.exe run tauri dev
echo.
echo [dev] 服务器已停止
pause

View File

@@ -0,0 +1,328 @@
# AntV X6 技术调研报告与引入计划
> 创建: 2026-06-30 | 状态: 调研完成,待决策
> 调研对象: AntV X6图编辑与可视化引擎
> 数据来源: GitHub APIantvis/X6+ npm registry 实时查询2026-06-30
> 关联任务: `[子4] 前端:依赖关系图可视化`、`[子2] df-project依赖图构建`
---
## 一、为什么调研 X6
DevFlow 正在推进 **多工程/多仓库项目组织** 功能(任务列表中的父任务「多工程/多仓库项目组织」拆出 5 个子任务)。其中:
- `[子2]` 后端需构建 `ModuleDependencyGraph`(工程间依赖图数据结构)
- `[子4]` 前端需 **依赖关系图可视化**(节点 = 工程,边 = 依赖关系,支持交互)
当前 DevFlow 前端(`src/`**零图可视化依赖**(已 grep 确认:无 d3 / vis-network / cytoscape / antv / x6 / jointjs / reactflow。需要选型一个图引擎承担可视化层。
X6 是这次选型的重点候选,本报告评估其是否适合 DevFlow。
---
## 二、X6 是什么
**AntV X6** 是蚂蚁集团 AntV 数据可视化团队开源的 **图编辑与可视化引擎**Graph Editing and Visualization Engine
- GitHub: https://github.com/antvis/X6
- 文档: https://x6.antv.antgroup.com/
- License: **MIT**
- 定位: 专注 **节点-边图** 的交互式编辑与可视化
与纯图表库ECharts/G2的区别X6 不是「把数据画成图」,而是「让用户在画布上编辑图」——拖拽节点、连线、布局、撤销重做、导出。这正是依赖图编辑器需要的形态。
---
## 三、关键数据(实时校准,非记忆)
> ⚠️ 网上多数博客仍停留在「v2.x」**已过时**。以下为 2026-06-30 npm + GitHub API 实测。
### 3.1 版本现状
| 通道 | 版本 | 说明 |
|---|---|---|
| **latest** | **3.1.7** | 当前正式稳定版v3 主线) |
| beta | 3.1.4-beta.1 | 预发布通道 |
| @antv/x6@1.35.1 | 1.35.1 | v1 旧线(仅维护,勿新用) |
**结论**:新项目直接上 **v3.1.7**,不要从 v1/v2 起步。v3 是当前活跃主线。
### 3.2 仓库活跃度
| 指标 | 数值 |
|---|---|
| Stars | 18.3k |
| Forks | 2.5k |
| Network | 1886 |
| 最近 release | 持续迭代2026 年仍有多次 release |
| 维护状态 | **活跃** |
### 3.3 最近 release 亮点(佐证活跃度)
近期 PR 修复了:
- 节点事件无法冒泡到连线的问题(#4888
- stencil 插件搜索后高度自适应(#4975
说明交互细节仍在持续打磨,非弃维项目。
---
## 四、核心能力清单
| 能力 | 说明 | DevFlow 是否用得到 |
|---|---|---|
| **节点系统** | 内置矩形/圆形/椭圆/多边形,支持 HTML/React/Vue 自定义节点 | ✅ 工程节点用 Vue 自定义 |
| **边系统** | 直线/折线/曲线/自定义路由,箭头/标签/路径动画 | ✅ 依赖边带类型标签API调用/类库/MQ... |
| **交互** | 拖拽、缩放、框选、连线、对齐线、撤销/重做 | ✅ 用户可手动调整布局 |
| **布局算法** | dagre层次/ force力导向/ grid / circular | ✅ dagre 天然适合依赖图 |
| **插件生态** | 小地图、对齐线、框选、变换、键盘、历史、剪贴板、上下文菜单 | ✅ 小地图(大图概览)+ 历史(撤销) |
| **数据格式** | JSON graph data导入/导出 | ✅ 与后端 ModuleDependencyGraph 直接映射 |
| **事件系统** | 完整节点/边/画布事件 | ✅ 点击节点跳转工程详情 |
| **导出** | PNG / SVG / JPG | ⚠️ 锦上添花 |
---
## 五、技术特性
### 5.1 架构
- **框架无关**:核心纯 TypeScript不绑定 React/Vue
- **渲染引擎**SVG默认/ Canvas大图性能更优
- **插件化**功能按需引入tree-shaking 友好v3 强化)
### 5.2 生态包v3 延续插件化)
| 包 | 用途 | DevFlow 需要 |
|---|---|---|
| `@antv/x6` | 核心引擎 | ✅ 必装 |
| `@antv/x6-plugin-selection` | 框选/点选 | ✅ |
| `@antv/x6-plugin-snapline` | 对齐线 | ✅ |
| `@antv/x6-plugin-history` | 撤销/重做 | ✅ |
| `@antv/x6-plugin-minimap` | 小地图 | ✅ 大图必备 |
| `@antv/x6-plugin-keyboard` | 键盘快捷键 | ⚠️ 可选 |
| `@antv/x6-plugin-clipboard` | 剪贴板 | ⚠️ 可选 |
| `@antv/x6-plugin-transform` | 缩放/旋转 | ⚠️ 可选 |
| `@antv/x6-plugin-scroller` | 画布滚动 | ✅ 大图画布 |
| `@antv/x6-plugin-export` | 图片导出 | ❌ 暂不需要 |
| `@antv/x6-plugin-stencil` | 元素面板(拖入) | ⚠️ 可视化编辑器才需要 |
| `@antv/x6-vue-shape` | Vue 自定义节点 | ✅ 工程节点用 Vue 组件 |
### 5.3 与 DevFlow 技术栈兼容性
| 维度 | DevFlow | X6 要求 | 兼容 |
|---|---|---|---|
| 前端框架 | Vue 3.5 | 框架无关 + vue-shape | ✅ |
| UI 库 | Arco Design | 无冲突 | ✅ |
| 构建 | Vite 6 | 支持 | ✅ |
| TS | 5.6 | 原生 TS | ✅ |
| 运行时 | Tauri WebView | 标准 DOM API | ✅ |
| 包管理 | bun.lock / npm | 均支持 | ✅ |
**无兼容性阻塞。**
---
## 六、竞品对比
| 库 | 定位 | 编辑能力 | Vue 支持 | 布局算法 | 体积 | 适合 DevFlow |
|---|---|---|---|---|---|---|
| **AntV X6** | 图编辑引擎 | ⭐⭐⭐⭐⭐ 全栈 | 官方 vue-shape | 内置 dagre/force | 中 | ✅ **最佳** |
| ReactFlow | React 图编辑 | ⭐⭐⭐⭐ | 需包装(非原生) | 需配 dagre | 中 | ❌ React 栈 |
| vis-network | 图可视化 | ⭐⭐⭐ | 手动 | 内置 | 小 | ⚠️ 编辑弱 |
| cytoscape.js | 图分析+可视化 | ⭐⭐⭐ | 手动 | 插件 | 中 | ⚠️ 偏分析 |
| d3 | 通用可视化 | ⭐⭐ 手搓 | 手动 | 手搓 | 大 | ❌ 工作量大 |
| G6 | 关系图谱分析 | ⭐⭐⭐⭐ | 手动 | 内置 | 中 | ⚠️ 偏分析非编辑 |
**结论**DevFlow 需要「可编辑依赖图」X6 在编辑能力 + Vue 原生支持 + 布局算法三方面综合最优。G6 是同门兄弟但偏「图谱分析」(大图布局/聚类X6 偏「图编辑」人机交互DevFlow 场景是后者。
---
## 七、DevFlow 落地场景评估
### 7.1 主场景多工程依赖关系图P0立即落地
对应任务 `[子4]`
```
[DevFlow 工程] ──API调用──> [u-talk 工程]
├──类库引入──> [df-types crate]
└──共享资源──> [MySQL]
```
- 节点 = module工程/crateVue 自定义节点展示名称/路径/技术栈
- 边 = module_dependency标签展示 dep_typeAPI调用/类库/MQ/共享资源/自定义)
- 布局 = dagre 层次布局(天然适合依赖关系)
- 交互 = 点击节点跳工程详情、拖拽调整、小地图导航
- 数据源 = 后端 `get_dependency_graph` IPC 返回的图结构
### 7.2 次场景工作流编辑器P1未来
DevFlow 有工作流引擎AiNode/HumanNode/executor未来可做可视化工作流编辑器
- 拖拽节点AI 节点 / 人工节点 / 判断节点)
- 连线定义流转
- 与后端 workflow 定义双向同步
X6 的 stencil 插件(元素拖入面板)正是为这类低代码编辑器设计。
### 7.3 次场景任务依赖图P2
任务有 `depends_on / blocks / relates_to` 关联create_task_link可可视化展示任务拓扑。当前任务列表已有 `list_task_links` / `get_task_tree` 数据基础。
### 7.4 不适用场景
- ai-news纯 HTML/JS无图编辑需求— 不引入
- u-talk / u-desk — 暂无明确图编辑需求,不引入
---
## 八、风险与对策
| 风险 | 影响 | 对策 |
|---|---|---|
| v3 文档站是 SPA离线难爬 | 学习曲线 | 在线阅读 + 官方 examples 仓库对照 |
| v3 相比 v1 有 breaking changes | 迁移成本 | 全新引入,无历史包袱,直接学 v3 |
| 插件多,初次配置繁琐 | 上手成本 | 本计划已列出必装子集(见 §九) |
| 大图性能(>500 节点) | 渲染卡顿 | DevFlow 单项目工程数有限(<50不触瓶颈必要时切 Canvas 渲染 |
| vue-shape 与 Arco 组件协同 | 样式冲突 | 节点用独立样式作用域,避免全局污染 |
| 包体积增加 | 安装包变大 | X6 核心 + 必装插件 gzip 后约 200-300KBTauri 本地资源可接受 |
**无阻塞级风险。**
---
## 九、引入计划
### 9.1 决策点(待人工拍板)
| 项 | 推荐 | 备选 |
|---|---|---|
| 是否引入 X6 | ✅ **引入** | vis-network编辑弱/ 自研 SVG成本高 |
| 版本 | v3.1.7latest | — |
| 落地优先级 | P0 依赖图 → P1 工作流编辑器 | — |
| 引入范围 | 仅 DevFlow 前端 | — |
### 9.2 分阶段实施
#### Phase 0依赖安装与验证0.5 天)
```bash
# 在 DevFlow 前端目录
bun add @antv/x6 @antv/x6-plugin-selection @antv/x6-plugin-snapline \
@antv/x6-plugin-history @antv/x6-plugin-minimap @antv/x6-plugin-scroller \
@antv/x6-vue-shape
```
交付物:
- 依赖装入 `package.json`
- 一个最小 demo 页面3 节点 2 边,验证渲染 + 拖拽 + 缩放)
- 验证 Tauri WebView 下无异常
#### Phase 1依赖关系图组件1.5 天,对接 `[子4]`
前置:`[子2]` 后端 `get_dependency_graph` IPC 完成。
交付物:
- 新建 `src/components/DependencyGraph.vue`
- 封装 X6 Graph 实例生命周期管理mount 初始化、unmount 销毁)
- 数据映射:`ModuleDependencyGraph` → X6 fromJSON
- Vue 自定义节点组件 `ModuleNode.vue`(展示工程名/路径/技术栈图标)
- 边标签展示 dep_type
- dagre 自动布局
- 小地图 + 框选 + 撤销重做
- 事件:点击节点 → emit('select-module', id),父组件跳详情
#### Phase 2集成到项目详情页0.5 天,对接 `[子4]`
交付物:
- 项目详情页新增「依赖图」Tab
- 与工程列表 Tab 并列,数据同源
- 空状态处理(单工程无依赖时提示)
#### Phase 3未来工作流可视化编辑器
不在本次计划范围,待工作流引擎需求明确后再立项。
### 9.3 工程化约定
1. **X6 实例管理**:组件内 `onMounted` 创建,`onBeforeUnmount``graph.dispose()`,避免内存泄漏
2. **类型契约**:后端 `ModuleDependencyGraph`Rust serde→ ts-rs 导出 → 前端 X6 fromJSON 格式转换函数集中放 `src/utils/graphMapper.ts`
3. **样式隔离**:自定义节点用 `<style scoped>`,避免污染 Arco 主题
4. **按需引入**:仅装 §5.2 列出的必装/可选插件,不装 export/stencil暂不需要
### 9.4 验收标准
- [ ] Phase 0demo 页渲染正常Tauri 下无报错
- [ ] Phase 1依赖图组件能渲染 ≥10 个工程节点 + 依赖边dagre 布局自动排布
- [ ] Phase 1拖拽节点、框选、撤销重做、小地图均可用
- [ ] Phase 1点击节点能触发跳转工程详情
- [ ] Phase 2项目详情页「依赖图」Tab 可用,多工程项目展示正确
- [ ] 单工程项目显示友好空状态
---
## 十、快速上手示例Vue 3 + v3
```typescript
// src/utils/x6-setup.ts
import { Graph } from '@antv/x6'
import { Selection } from '@antv/x6-plugin-selection'
import { Snapline } from '@antv/x6-plugin-snapline'
import { History } from '@antv/x6-plugin-history'
import { Minimap } from '@antv/x6-plugin-minimap'
import { Scroller } from '@antv/x6-plugin-scroller'
export function createGraph(container: HTMLElement): Graph {
const graph = new Graph({
container,
background: { color: '#F2F7FA' },
grid: { visible: true, size: 10 },
mousewheel: { enabled: true, modifiers: ['ctrl'], minScale: 0.5, maxScale: 2 },
})
graph.use(new Selection({ enabled: true, rubberband: true }))
graph.use(new Snapline({ enabled: true }))
graph.use(new History({ enabled: true }))
graph.use(new Scroller({ enabled: true, pannable: true }))
graph.use(new Minimap({ enabled: true, width: 200, height: 120 }))
return graph
}
```
```typescript
// 数据映射示例:后端图结构 → X6 fromJSON
function toX6Data(graph: ModuleDependencyGraph) {
return {
nodes: graph.modules.map(m => ({
id: m.id,
shape: 'devflow-module', // 注册的 Vue 自定义节点
x: 0, y: 0, // dagre 布局后重排
data: { name: m.name, path: m.path, stack: m.stack },
})),
edges: graph.dependencies.map(d => ({
source: d.from_module_id,
target: d.to_module_id,
labels: [{ text: d.dep_type }],
})),
}
}
```
---
## 十一、结论与建议
1. **推荐引入** AntV X6 v3.1.7 作为 DevFlow 图可视化层,竞品中综合最优。
2. **首选落地场景**`[子4] 多工程依赖关系图`与当前正在推进的多工程组织功能直接对接ROI 最高。
3. **无兼容性阻塞**技术栈Vue 3 + Vite 6 + Tauri WebView完全适配。
4. **建议批准 Phase 0**0.5 天安装验证),验证通过后推进 Phase 1-22 天)。
5. **暂不引入** u-talk / u-desk / ai-news待有明确图编辑需求再评估。
---
## 相关链接
- 官网: https://x6.antv.antgroup.com/
- GitHub: https://github.com/antvis/X6
- v3 文档: https://x6.antv.antgroup.com/tutorial/about
- Vue shape: https://x6.antv.antgroup.com/tutorial/advanced/react#vuevue-shape 用法)
- DevFlow 关联任务: `[子4] 前端:依赖关系图` / `[子2] df-project依赖图构建`

View File

@@ -35,7 +35,7 @@ DevFlow 使用 SQLite (rusqlite) 作为本地存储引擎。df-storage 负责 SQ
- ID 字段统一使用 `TEXT` (UUID v4)
- 时间字段使用 `TEXT` (毫秒时间戳字符串,`now_millis_str()` 返回 String)
- JSON 字段使用 `TEXT` 存储 JSON 字符串
- 所有写操作统一返回 `df_core::error::Error` 错误类型insert 返 `Result<String>`idupdate/delete 返 `Result<bool>`(是否影响行)
- 所有写操作统一返回 `df_types::error::Error` 错误类型insert 返 `Result<String>`idupdate/delete 返 `Result<bool>`(是否影响行)
## update_field 的强制时间戳约束
@@ -76,4 +76,4 @@ UPDATE ai_conversations SET archived = ?1 WHERE id = ?2
- `crates/df-storage/src/lib.rs` — 存储层入口
- `crates/df-storage/src/migrations.rs` — Schema 定义与迁移
- `crates/df-core/src/error.rs` — 统一错误类型
- `crates/df-types/src/error.rs` — 统一错误类型

View File

@@ -47,6 +47,8 @@
| [F-09B-多会话并发设计-2026-06-16.md](./已编号方案/F-09B-多会话并发设计-2026-06-16.md) | F-260616-09 | 🗄 过时(被 F-09 取代) | 06-16 旧版,行号已过期。保留回溯,新设计看 F-09 |
| [F-15-上下文管理增强设计-2026-06-16.md](./已编号方案/F-15-上下文管理增强设计-2026-06-16.md) | F-260616-15 | 📐 设计 | ContextManager 分段 / 压缩 / 裁剪 |
| [F-260620-01-跨端AIChat-微信小程序-2026-06-20.md](./已编号方案/F-260620-01-跨端AIChat-微信小程序-2026-06-20.md) | F-260620-01 | 📐 草案(灵感 4495fbcd 待晋升) | 微信小程序 ↔ Rust 云后端(df-relay) ↔ DevFlow 桌面端(df-tunnel)实时同步,Rust 选型代码复用 |
| [F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md](./已编号方案/F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md) | F-260622-01 | 📐 设计草案 | F-260620-01 Phase3 实施续篇:三层就绪后如何接线联调(协议统一+桥接方案) |
| [消息级溯源P2-切读方案-2026-06-28.md](./已编号方案/消息级溯源P2-切读方案-2026-06-28.md) | F-260619-04-P2 | ✅ 已落地 | 消息级溯源 P2 一次性切读 + 脚本迁移历史数据 |
| [B-03-人工审批响应机制-2026-06-14.md](./已编号方案/B-03-人工审批响应机制-2026-06-14.md) | B-260614-03 | ✅ 已落地(B-03a) | HumanNode execute subscribe/send/select 完整审批链 |
| [B-260616-21排查方案-2026-06-16.md](./已编号方案/B-260616-21排查方案-2026-06-16.md) | B-260616-21 | 📐 排查方案 | 工具卡片重复渲染根因(audit 重复 emit Started) + 修复方案 |
| [F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md](./已编号方案/F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md) | F-260622-01 | 📐 设计 | 微信小程序跨端联调:云隧道握手 / MCP 工具路由 / DF 事件同步 |
@@ -61,28 +63,33 @@
| 文档 | 状态 | 核心内容 |
|---|---|---|
| [Agent架构说明-2026-06-14.md](./专项设计/Agent架构说明-2026-06-14.md) | 📐 现状盘点 | Agent 引擎单链 ReAct 能力边界(查实的事实,非构想) |
| [规格契约自检机制-2026-06-14.md](./专项设计/规格契约自检机制-2026-06-14.md) | 📐 设计待落地 | 活契约(锚点 + AI 自检 + 子代理)取代独立 spec |
| [规格契约自检机制-2026-06-14.md](./专项设计/规格契约自检机制-2026-06-14.md) | 🗄 归档(被其他机制覆盖) | 活契约(锚点 + AI 自检 + 子代理)取代独立 spec。**核验(2026-06-28):0 行代码,其核心价值(规格与实现一致性检查)已被 L1 求助协议 + ai_self_review gate 覆盖,归档不实施** |
| [AiNode自审实施方案-2026-06-16.md](./专项设计/AiNode自审实施方案-2026-06-16.md) | ✅ ②-⑤已落地 | AiNode 自审闸门 + verdict DAG 阻断 |
| [secret下沉与provider注入方案-2026-06-16.md](./专项设计/secret下沉与provider注入方案-2026-06-16.md) | ✅ 方案 B 全量落地 | secret 纯密钥下沉 df-storage |
| [patch_file工具设计-2026-06-15.md](./专项设计/patch_file工具设计-2026-06-15.md) | 📐 设计待实施(P0) | AI 局部编辑工具:old_text 精确匹配 + 三层防御 |
| [patch_file工具设计-2026-06-15.md](./专项设计/patch_file工具设计-2026-06-15.md) | ✅ 已落地(2026-06-28 核验) | AI 局部编辑工具:old_text 精确匹配 + 三层防御 + 三模式(old_text/replace_lines/anchor)。**仅文本(不支持二进制 encoding,合理设计)** |
| [generating状态机加固-2026-06-15.md](./专项设计/generating状态机加固-2026-06-15.md) | 📐 设计 | generating 生命周期状态机(guard 收敛) |
| [密钥迁移健壮性-2026-06-15.md](./专项设计/密钥迁移健壮性-2026-06-15.md) | 📐 设计未实施 | 空 api_key 不覆盖未迁移态明文密钥 |
| [条件表达式引擎-2026-06-15.md](./专项设计/条件表达式引擎-2026-06-15.md) | 📐 设计(R-PD-3) | ConditionEngine 接线 + 条件边求值 |
| [工作流脚本执行边界-2026-06-15.md](./专项设计/工作流脚本执行边界-2026-06-15.md) | 📐 设计(R-PD-2) | ScriptNode 任意 shell 执行安全边界 |
| [查询效率优化方案-2026-06-19.md](./专项设计/查询效率优化方案-2026-06-19.md) | 📐 待评审(PERF-260619-01) | SQL 下推 / 精确拉取 / 缓存 / 字段投影 |
| [密钥迁移健壮性-2026-06-15.md](./专项设计/密钥迁移健壮性-2026-06-15.md) | ✅ 已落地(2026-06-28 核验) | 空 api_key 不覆盖未迁移态明文密钥(provider.rs:104-136 即时迁移补密钥 + 阻断保存) |
| [条件表达式引擎-2026-06-15.md](./专项设计/条件表达式引擎-2026-06-15.md) | ✅ 已落地(2026-06-28 核验) | ConditionEngine 手写求值器 + JSON Path/嵌套/数组索引/数值比较 + executor 集成 + 前端边条件编辑入口 |
| [工作流脚本执行边界-2026-06-15.md](./专项设计/工作流脚本执行边界-2026-06-15.md) | ✅ 已落地(2026-06-28 核验) | ScriptNode 命令执行安全边界:白名单/黑名单(env 配置)+ 危险关键词告警 |
| [查询效率优化方案-2026-06-19.md](./专项设计/查询效率优化方案-2026-06-19.md) | 📐 待评审(PERF-260619-01) | SQL 下推 / 精确拉取 / 缓存 / 字段投影。**核验:查询能力补全已落地(task/project/idea 均 keyword/order_by/limit/offset),性能优化待评审** |
| [任务推进链实施路径-2026-06-16.md](./专项设计/任务推进链实施路径-2026-06-16.md) | 📐 规划定稿(D-01~04已决) | advance_task 走 df-nodes Node trait,4 阶段路径 |
| [推进链阶段2实施路径-2026-06-16.md](./专项设计/推进链阶段2实施路径-2026-06-16.md) | 📐 设计(F-260616-06) | 工作流联动任务推进:task_id + 完成回调 + DAG 模板 |
| [消息拆分存储设计-2026-06-19.md](./专项设计/消息拆分存储设计-2026-06-19.md) | 📐 设计待实施(F-260619-03) | ai_messages 表 + V21 全量迁移 + 三阶段渐进切换(脏标记→双写→切读) |
| [消息级溯源设计-2026-06-19.md](./专项设计/消息级溯源设计-2026-06-19.md) | 📐 设计待实施(F-260619-04) | ChatMessage.id + source_ref/audit/idea 四场景从对话级升级消息级 |
| [全局事件数据总线-2026-06-21.md](./专项设计/全局事件数据总线-2026-06-21.md) | 📐 构想定稿待评审 | pub-sub + request-reply + 流式 reply 统一总线:跨模块解耦 / 响应式根治死等 / 跨端透传 |
| [消息拆分存储设计-2026-06-19.md](./专项设计/消息拆分存储设计-2026-06-19.md) | ✅ 已落地(2026-06-28 核验) | ai_messages 表 + V21 全量迁移 + 读写全部切到消息表(旧 JSON 列仅作老库兼容回退) |
| [消息级溯源设计-2026-06-19.md](./专项设计/消息级溯源设计-2026-06-19.md) | ✅ 已落地(2026-06-28 核验) | ChatMessage.id + source_ref/audit/idea 四场景从对话级升级消息级 + P2 切读全部完成 |
| [全局事件数据总线-2026-06-21.md](./专项设计/全局事件数据总线-2026-06-21.md) | 🟡 部分落地(基建就位,消费者未接) | pub-sub + request-reply + 流式 reply 统一总线。**核验(2026-06-28):基建(AI/工作流两路总线)+ 20+ emit 点双写就位,但真实订阅方/前端镜像/tunnel 透传均未接入(空转)。阶段 2-6 待真实痛点驱动** |
| [三层模型-流程模板与人设体系-2026-06-28.md](./专项设计/三层模型-流程模板与人设体系-2026-06-28.md) | 📐 设计 | 模板→工作流→人设三层架构:流程模板YAML定义、人设AgentPersona数据结构、三层实例化流程 |
| [aichat体验与agent能力系统化重构-2026-06-21.md](./专项设计/aichat体验与agent能力系统化重构-2026-06-21.md) | 📐 设计 | AI Chat 体验 + Agent 能力统一重构:消息模型 / 上下文管理 / 工具路由 |
| [查询能力补全方案-2026-06-21.md](./专项设计/查询能力补全方案-2026-06-21.md) | 📐 设计 | 查询接口能力补全:多维度筛选 / 聚合 / 跨表关联 |
| [AST符号解析-设计-2026-06-24.md](./专项设计/AST符号解析-设计-2026-06-24.md) | 📐 设计 | AST 符号解析:代码结构提取 / 符号索引 / 跳转支持 |
| [查询能力补全方案-2026-06-21.md](./专项设计/查询能力补全方案-2026-06-21.md) | ✅ 已落地(2026-06-28 核验) | 查询接口能力补全:task/project/idea 均 keyword/order_by/limit/offset 多维动态 WHERE |
| [AST符号解析-设计-2026-06-24.md](./专项设计/AST符号解析-设计-2026-06-24.md) | ✅ Phase1 已落地(2026-06-28 核验) | AST 符号解析:read_symbol 工具已注册(tool_registry.rs:1506-1547)+ 基线测试守护 |
| [插件机制-设计-2026-06-24.md](./专项设计/插件机制-设计-2026-06-24.md) | 📐 设计 | 插件系统:动态加载 / 生命周期 / 权限沙箱 |
| [AI对话目标丢失诊断-2026-06-26.md](./专项设计/AI对话目标丢失诊断-2026-06-26.md) | 📐 诊断 | AI 对话目标丢失根因分析:上下文漂移 / 意图衰减 |
| [AI原生上下文地图与去AI化进化系统-2026-06-26.md](./专项设计/AI原生上下文地图与去AI化进化系统-2026-06-26.md) | 📐 设计 | AI 原生上下文地图:语义索引 / 去 AI 化渐进演进路径 |
| [项目知识图谱与任务队列系统-2026-06-26.md](./专项设计/项目知识图谱与任务队列系统-2026-06-26.md) | 📐 设计 | 项目级知识图谱 + 任务队列:依赖解析 / 优先级调度 |
| [工程系统设计-2026-06-29.md](./专项设计/工程系统设计-2026-06-29.md) | ✅ 已落地 | 项目多工程(Module) + Git 状态查询 + 文件浏览器 + Git AI 工具(只读3+写3) |
| [架构债迁移设计-2026-06-29.md](./专项设计/架构债迁移设计-2026-06-29.md) | 📐 设计草案 | #8/#15/#18/#19 架构债迁移路径(AI状态机抽离/IPC错误结构化/配置统一/连接池) |
| [多Agent并行执行与仲裁合并设计-2026-07-01.md](./专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md) | 📐 设计 | 混合数据模型(messages加FK列+Plan/SubTask/Conflict独立表)、Git worktree隔离、JoinSet层内并行、四层冲突防线、Reviewer仲裁、事件协议、UI交互 |
| [后续批次测试用例设计-2026-07-01.md](./专项设计/后续批次测试用例设计-2026-07-01.md) | 📐 测试 | 多Agent并行/worktree隔离/仲裁合并/模板系统/Git-CI/审批政策 的单元+集成+端到端测试用例 |
| [测试落地实施方案-2026-07-01.md](./专项设计/测试落地实施方案-2026-07-01.md) | 📐 测试 | 各测试用例的落地方式(Rust 单元/集成/vitest/E2E 手动)、文件位置、Mock 策略、CI 自动化路径 |
---
@@ -107,7 +114,15 @@
| [意图识别层论证-2026-06-19.md](./构想审查/意图识别层论证-2026-06-19.md) | 📐 论证(供决策) | 通用前置意图识别层 8 维度论证 + 触发时机 |
| [多主题上下文管理愿景-2026-06-19.md](./构想审查/多主题上下文管理愿景-2026-06-19.md) | 💡 远期愿景 | 无感多主题对话:主题检测前置 + 多主题多摘要(关联 F-15 / 意图识别) |
| [多主题并存补充论证-多轮模式-2026-06-19.md](./构想审查/多主题并存补充论证-多轮模式-2026-06-19.md) | 📐 论证(供决策) | agentic 多轮模式可突破天花板,但近期结论不变 |
| [跑题改进试验记录-2026-06-20.md](./构想审查/跑题改进试验记录-2026-06-20.md) | 📋 试验 | AI 跑题改进试验:系统 prompt 加固 / 上下文钳制 / 行为约束 |
| [跑题改进试验记录-2026-06-20.md](./构想审查/跑题改进试验记录-2026-06-20.md) | 📋 试验记录 | AI Chat 长对话跑题根因治:测试计划+迭代过程+改进前基线+遗留 |
| [AI-Native方向与路线图-2026-06-29.md](./构想审查/AI-Native方向与路线图-2026-06-29.md) | 📐 方向 | AI Native 优先级矩阵:P0人设+多Agent→P1模板+Git/CI→P2审批政策+算法验证 |
### 根目录文档(不在子目录)
| 文档 | 状态 | 核心内容 |
|---|---|---|
| [单对话并行多轮-设计-2026-06-20.md](./单对话并行多轮-设计-2026-06-20.md) | 📐 设计 | aichat B 路线:单对话内 AI 拆子流并行跑,加速多文件/多方案/多模块对比 |
| [单对话并行多轮-Phase0落地路线图-2026-06-20.md](./单对话并行多轮-Phase0落地路线图-2026-06-20.md) | 📐 路线图 | 承接设计 Phase 1/2/3 实施前必须补齐的机制(Phase 0 = 物理可行性地基) |
---

View File

@@ -1,6 +1,6 @@
# AST 符号解析 — Code Intelligence 设计
> 日期:2026-06-24 | 状态:设计深化(2026-06-24:信息密度三态 + 主题压缩),Phase1 待实施
> 日期:2026-06-24 | 状态:✅ Phase1 已落地(2026-06-28 核验:read_symbol 工具已注册 tool_registry.rs:1506-1547 + 基线测试守护)
> 关联:memory [[devflow-info-density-concept]] / [[devflow-aichat-session-analysis-2026-06-22]] / plan gentle-gliding-book
## Context(为什么)

View File

@@ -1,8 +1,9 @@
# Agent 架构与能力边界(系统现状记录) — 2026-06-14
> 性质: 系统现状盘点 / 能力边界(查实的事实,非构想)
> 关联: [任务推进设计](任务推进构想-2026-06-14.md)AI 执行层依据本文档能力边界)
> 关联: [任务推进设计](../构想审查/任务推进构想-2026-06-14.md)AI 执行层依据本文档能力边界)
> 关联: [三层模型-流程模板与人设体系](../专项设计/三层模型-流程模板与人设体系-2026-06-28.md)(人设层是本架构的下一阶段演进方向)
> 关联: [AI-Native方向与路线图](../构想审查/AI-Native方向与路线图-2026-06-29.md)AI Native 优先级矩阵与推进路线图)
> 用途: 作为「AI 执行层」「AI 自审」等设计的真实能力依据,避免在超出系统现状的能力上做设计
---

View File

@@ -1,6 +1,6 @@
# Patch File 工具设计
> 创建: 2026-06-15 | 状态: 设计定稿待实施 | 优先级: P0
> 创建: 2026-06-15 | 状态: ✅ 已落地(2026-06-28 核验) | 优先级: P0
> 关联 todo: F-260615-06 [P0]
---

View File

@@ -1,6 +1,6 @@
# 全局事件数据总线设计
> 2026-06-21 · 专项设计 · 状态:构想定稿待评审
> 2026-06-21 · 专项设计 · 状态:🟡 部分落地(2026-06-28 核验:基建(AI/工作流两路总线)+ 20+ emit 点双写就位,但真实订阅方/前端镜像/tunnel 透传均未接入空转。阶段 2-6 待真实痛点驱动)
> 关联:[[cross-end-rust-backend]] 三层跨端 / [[devflow-product-positioning]] ai-working 可扩展 / F-260620-01 跨端小程序
## 背景与动机

View File

@@ -0,0 +1,252 @@
# 后续批次测试用例设计 — 2026-07-01
> 覆盖:多Agent并行执行 / Git worktree 隔离 / 仲裁合并 / 模板系统 / Git-CI / 审批政策
> 关联:[多Agent并行执行与仲裁合并设计-2026-07-01.md](02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md)
---
## 一、测试分层
| 层级 | 范围 | 工具 |
|------|------|------|
| **单元测试** | 纯函数/数据结构/repo CRUD | `#[test]` Rust / vitest TS |
| **集成测试** | 跨模块协作(dispatch+merge+worktree) | `#[tokio::test]` |
| **端到端** | 用户发消息→Plan→并行→合并→展示 | 手动 + 日志验证 |
---
## 二、数据层与并行执行 测试用例
### 2.1 V36 迁移
| ID | 场景 | 预期 |
|----|------|------|
| MIG-01 | 新库空跑 V36 | 3 新表 + 2 列 ALTER 成功,ai_plans 为空 |
| MIG-02 | 老库(有数据)跑 V36 | ai_messages/ai_tool_executions 加 subtask_id 列(NULL),原有数据不动 |
| MIG-03 | V36 幂等(重跑) | 不崩溃,版本号已写入直接跳过 |
| MIG-04 | 老库 ai_messages 有 100 条数据 | subtask_id 全部为 NULL,查询不报错 |
### 2.2 Repo CRUD
| ID | 场景 | 预期 |
|----|------|------|
| REPO-01 | PlanRepo.insert + get | 写入后能按 id 读回,字段完整 |
| REPO-02 | PlanRepo.update_status | planning→executing→done 状态流转正确 |
| REPO-03 | SubTaskRepo.insert + list_by_plan | 按 plan_id 查子任务,layer 排序正确 |
| REPO-04 | SubTaskRepo.update_status | pending→running→done 流转 |
| REPO-05 | ConflictRepo.insert + list_pending | 按 plan_id 查 resolution=pending 的冲突 |
| REPO-06 | ConflictRepo.resolve | 更新 resolution + resolved_by + resolved_at |
| REPO-07 | SubTaskRepo 查 branch=NULL | 非 Git 工程的 SubTask branch 为 NULL,查询正常 |
### 2.3 Coordinator 拆解
| ID | 场景 | 输入 | 预期 |
|----|------|------|------|
| DEC-01 | 读+写意图 | "先 read 文件再修改" | 2 SubTask(read→write),write deps=[read] |
| DEC-02 | 纯读意图 | "分析现有代码结构" | 1 SubTask(read),无 deps |
| DEC-03 | 纯写意图 | "implement new feature" | 1 SubTask(write),无 deps |
| DEC-04 | 兜底意图 | "你好" | 1 SubTask(default) |
| DEC-05 | 人设分配 | "review this code" | SubTask persona=reviewer |
| DEC-06 | Plan 非空 | 任意非空输入 | plan.tasks.len() >= 1 |
### 2.4 Git worktree 隔离
| ID | 场景 | 预期 |
|----|------|------|
| WT-01 | worktree 创建 | `.devflow/wt/{plan_id}/A` 目录存在,git branch `subtask/{plan_id}/A` 存在 |
| WT-02 | worktree 内 write_file | 写入落到 worktree 目录,主目录文件不变 |
| WT-03 | 同层并行隔离 | SubTask A 写 main.rs,SubTask B 读 main.rs → B 读到的是 fork 基点内容,非 A 的改动 |
| WT-04 | worktree commit | SubTask 完成后 git add+commit,分支有新 commit |
| WT-05 | worktree 清理 | Plan 完成后 `.devflow/wt/{plan_id}/` 目录被移除 |
| WT-06 | 非 Git 工程降级 | 无 .git 的工程 → branch=NULL,走串行,不崩溃 |
| WT-07 | worktree 创建失败 | 磁盘满/权限不足 → 降级串行,日志告警 |
### 2.5 并行调度
| ID | 场景 | 预期 |
|----|------|------|
| PAR-01 | 线性链(2层×1任务) | Layer0 done → Layer1 启动,总耗时≈串行 |
| PAR-02 | 并行层(1层×3任务) | 3 SubTask 并发执行,总耗时≈最慢的一个 |
| PAR-03 | 钻石依赖 | A→{B,C}→D,B/C 并行,D 等 B+C 都 done |
| PAR-04 | 层间隔离 | Layer1 的 SubTask 能读到 Layer0 merge 后的改动 |
| PAR-05 | 单 SubTask 失败 | 标记 error,不中断同层其他 SubTask |
| PAR-06 | 全部 SubTask 失败 | Plan.status=error |
| PAR-07 | SubTask 超时(120s) | 标记 error,其他 SubTask 不受影响 |
### 2.6 Token 预算池
| ID | 场景 | 预期 |
|----|------|------|
| TOK-01 | 预算充足 | try_reserve(10k) → true,consumed+=10k |
| TOK-02 | 预算超限 | consumed=98k,total=100k,try_reserve(5k) → false |
| TOK-03 | 并发 reserve | 2 个 SubTask 同时 reserve(CAS 竞争) | 只有一个成功 |
| TOK-04 | 超限降级串行 | 预算用完 → 剩余 SubTask 串行执行(不拒绝) |
### 2.7 事件协议
| ID | 场景 | 预期 |
|----|------|------|
| EVT-01 | AiPlanCreated | 前端 PlanProgress 立即展示 DAG 层结构 |
| EVT-02 | AiSubTaskStatusChanged | 节点状态从 pending→running→done 更新 |
| EVT-03 | AiMergeCompleted(无冲突) | 合并输出展示,无冲突徽章 |
| EVT-04 | AiMergeCompleted(有冲突) | 冲突徽章出现 |
| EVT-05 | AiConflictResolved | 冲突徽章消失 |
| EVT-06 | 事件双写 | emit 和 publish_event 都触发 |
---
## 三、仲裁合并与冲突 测试用例
### 3.1 Git merge 冲突检测
| ID | 场景 | 预期 |
|----|------|------|
| MRG-01 | 无冲突合并 | A 改 file1,B 改 file2 → merge-tree 无冲突 → git merge 成功 |
| MRG-02 | 同文件冲突 | A 改 main.rs 第10行,B 改 main.rs 第10行 → conflict markers 出现 |
| MRG-03 | 同文件不冲突区 | A 改 main.rs 第10行,B 改 main.rs 第50行 → git 自动合并成功 |
| MRG-04 | 跨层同文件 | Layer0 改 main.rs,merge 到 plan;Layer1 改 main.rs → 基于已含 Layer0 的版本,无冲突 |
| MRG-05 | 三方合并预检 | git merge-tree 返回非空冲突列表 → 不执行 merge,标记 conflict |
### 3.2 编译检查(semantic 冲突)
| ID | 场景 | 预期 |
|----|------|------|
| CMP-01 | 合并后编译通过 | cargo check exit 0 → 无 semantic 冲突 |
| CMP-02 | 合并后编译失败 | A 删函数 foo(),B 在调 foo() → cargo check 失败 → conflict_type=semantic |
| CMP-03 | 前端项目 | tsc --noEmit 失败 → semantic 冲突 |
| CMP-04 | 无构建工具的工程 | 跳过编译检查(无 cargo/tsc) → 不阻塞 merge |
### 3.3 Reviewer 仲裁
| ID | 场景 | 预期 |
|----|------|------|
| ARB-01 | Reviewer 推荐 A | diff_a 更完整 → resolution=a,resolved_by=reviewer |
| ARB-02 | Reviewer 推荐 merged | 两者各有价值 → resolution=merged,给出合并方案 |
| ARB-03 | Reviewer 无法判断 | 两者等价 → resolution=pending,留给用户 |
| ARB-04 | Reviewer 超时 | LLM 调用超时 → 自动降级为 pending |
### 3.4 冲突解决闭环
| ID | 场景 | 预期 |
|----|------|------|
| RSV-01 | 用户选接受 A | resolution=a,文件应用 A 的改动,B 的改动丢弃 |
| RSV-02 | 用户选手动 | resolution=manual,用户在 worktree 手动编辑后 commit |
| RSV-03 | 全部冲突解决 | Plan.status 从 merging→done |
| RSV-04 | 前端徽章更新 | AiConflictResolved 事件 → 冲突徽章消失 |
### 3.5 合并产出落回主对话
| ID | 场景 | 预期 |
|----|------|------|
| OUT-01 | 无冲突合并 | 主对话新增 1 条 assistant 消息(merged_output) |
| OUT-02 | 有冲突合并 | 主对话新增 1 条 + 冲突徽章 |
| OUT-03 | 展开查看子 Agent 轨迹 | 点击展开 → 从 ai_messages WHERE subtask_id=? 加载,按 SubTask 分组 |
| OUT-04 | 折叠状态默认 | 新消息默认折叠,只显示摘要 |
### 3.6 命令互斥锁
| ID | 场景 | 预期 |
|----|------|------|
| LCK-01 | 同目录 npm install 并发 | 第二个等第一个完成才执行(不并行) |
| LCK-02 | 不同目录 npm install | 并行执行(锁 key 含目录) |
| LCK-03 | cargo + npm 同目录 | 不互斥(锁 key 含命令名) |
---
## 四、模板系统 测试用例
### 4.1 YAML 模板加载
| ID | 场景 | 预期 |
|----|------|------|
| TPL-01 | 合法 YAML 加载 | DagDef 结构完整,nodes/edges/conditions 正确解析 |
| TPL-02 | 缺必填字段 | nodes 为空 → 报错"模板节点不能为空" |
| TPL-03 | 依赖闭环 | A→B→A → 报错"依赖存在环" |
| TPL-04 | 未知节点类型 | node.type="UnknownNode" → 报错"未知节点类型" |
| TPL-05 | 内置模板加载 | 3 个内置模板(代码审查/bug修复/功能开发)全部加载成功 |
| TPL-06 | 自定义模板 CRUD | 创建→读取→更新→删除,持久化到 DB |
### 4.2 SubflowNode
| ID | 场景 | 预期 |
|----|------|------|
| SUB-01 | 嵌套子工作流执行 | SubflowNode 加载子模板 → 递归执行 → 返回子工作流结果 |
| SUB-02 | 子工作流审批 | 子工作流内的 HumanNode 审批 → 挂起 → 恢复 |
| SUB-03 | 嵌套深度限制 | 超过 MAX_PLAN_DEPTH → 报错 |
### 4.3 新节点
| ID | 场景 | 预期 |
|----|------|------|
| NODE-01 | GitNode 创建分支 | branch_name 存在,git branch 列表可见 |
| NODE-02 | GitNode commit | 工作区改动已提交,git log 有新记录 |
| NODE-03 | HTTPNode GET | 请求成功,返回 body 存入 NodeOutput |
| NODE-04 | HTTPNode 超时 | 30s 超时 → 返回错误,不阻塞 |
| NODE-05 | NotifyNode 桌面通知 | 系统通知弹出 |
| NODE-06 | NotifyNode Webhook | POST 到飞书/钉钉,HTTP 200 |
---
## 五、Git/CI 集成 测试用例
| ID | 场景 | 预期 |
|----|------|------|
| CI-01 | DockerNode 构建成功 | docker run cargo build → exit 0 → 构建产物存在 |
| CI-02 | DockerNode 构建失败 | docker run cargo build → exit !=0 → 返回 stderr |
| CI-03 | PR 创建 | GitNode push → API 创建 PR → PR URL 返回 |
| CI-04 | CI 状态读取 | 调 API → 返回 check 结果(passed/failed/pending) |
| CI-05 | 全链路 | 模板→分支→代码→commit→push→PR→CI→结果回显,全流程不中断 |
| CI-06 | CI 失败时 | 前端 CI 面板显示红色,日志可查看 |
---
## 六、审批政策 测试用例
| ID | 场景 | 预期 |
|----|------|------|
| POL-01 | coder+Low → auto | 匹配规则 → 自动执行 |
| POL-02 | coder+High → approve | 匹配规则 → 走审批 |
| POL-03 | reviewer+任意 → auto | reviewer 只读工具,全部自动 |
| POL-04 | 无匹配规则 | 降级到 auto_exec_mode 三档(向后兼容) |
| POL-05 | 策略热加载 | 修改策略 → 下次工具调用立即生效(不重启) |
| POL-06 | 策略冲突检测 | 同一 Persona+Node+Risk 有多条规则 → 优先级最高的生效 |
---
## 七、God 文件拆分 测试用例
| ID | 场景 | 预期 |
|----|------|------|
| REF-01 | agentic/mod.rs 拆分 | 拆出 compress/title/knowledge_inject 后,原文件<800 行,cargo check 通过 |
| REF-02 | context.rs 拆分 | 拆出 sanitize/budget/compress 后,原文件<800 行,所有 context 测试通过 |
| REF-03 | MessageList.vue 拆分 | 拆出 StreamRenderer/ToolCardGroup/EmptyState,vue-tsc 通过 |
| REF-04 | ChatInput.vue 拆分 | 拆出 MentionPopover/EnrichmentPanel,vue-tsc 通过 |
| REF-05 | 拆分后行为不变 | 原有所有单元测试/集成测试全绿 |
---
## 八、端到端手动测试
| ID | 场景 | 步骤 | 预期 |
|----|------|------|------|
| E2E-01 | 简单并行任务 | 发"先读取 main.rs 再修改 utils.rs" | PlanProgress 展示 2 层,Layer0 读,Layer1 写,最终合并输出 |
| E2E-02 | 并行同文件冲突 | 发"同时重构 auth.rs 的两个函数" | 2 SubTask 并行改同文件 → 冲突徽章 → 用户选择 → 最终输出 |
| E2E-03 | 取消并行任务 | 发消息→Plan 执行中→点停止 | 所有 SubTask 停止,worktree 清理,Plan.status=error |
| E2E-04 | 切换会话后台执行 | 发消息→切换到另一个会话 | 原 Plan 后台继续,PlanProgress 在原会话保留 |
| E2E-05 | 非 Git 工程 | 在无 .git 的项目中发并行任务 | 降级串行执行,正常完成 |
| E2E-06 | 模板驱动全链路 | 选"功能开发"模板→AI 执行 | 从模板加载工作流→创建分支→写代码→测试→PR |
| E2E-07 | 长对话不崩溃 | 50+ 轮对话后发并行任务 | token 预算池正常,不 OOM,PlanProgress 正常展示 |
---
## 九、边界与异常
| ID | 场景 | 预期 |
|----|------|------|
| EDGE-01 | 空 Plan | decompose 返回空 Plan → 不启动 dispatch,主对话正常回复(退单 Agent) |
| EDGE-02 | 全部 SubTask 超时 | Plan.status=error,前端展示超时提示,worktree 清理 |
| EDGE-03 | Git 不可用 | git 命令失败 → 降级串行,日志告警,不崩溃 |
| EDGE-04 | DB 写入失败 | PlanRepo.insert 失败 → 降级不落库,内存态执行,日志告警 |
| EDGE-05 | worktree 磁盘满 | 创建失败 → 降级串行 |
| EDGE-06 | token 预算=0 | 设置项 df-ai-plan-token-budget=0 → 不限制(等价无限) |
| EDGE-07 | 冲突解决时会话删除 | resolve_conflict IPC 到达时会话已删 → 忽略,清理 conflict 记录 |

View File

@@ -0,0 +1,592 @@
# 多 Agent 并行执行与仲裁合并设计 — 2026-07-01
> 性质:架构设计 / 数据模型 / 事件协议 / UI 交互
> 关联: [AI-Native方向与路线图-2026-06-29.md](../构想审查/AI-Native方向与路线图-2026-06-29.md)P0 智能体人设 + 多 Agent 协作)
> 关联: [单对话并行多轮-设计-2026-06-20.md](../单对话并行多轮-设计-2026-06-20.md)Plan DAG 结构与分层调度)
> 关联: [Agent架构说明-2026-06-14.md](Agent架构说明-2026-06-14.md)(当前 Agent 能力边界)
> 关联: [三层模型-流程模板与人设体系-2026-06-28.md](三层模型-流程模板与人设体系-2026-06-28.md)(模板/工作流/人设三层抽象)
> 用途:Batch 37-38 的实施依据
---
## 一、设计目标
DevFlow 的定位是 AI Native——AI 是系统的主要操作者,人的角色是「决策者 + 政策制定者」。
多 Agent 并行执行要解决的核心问题:
| 问题 | 现状(单 Agent | 多 Agent 后 |
|------|-----------------|------------|
| 复杂任务拆解 | LLM 自行在单轮内拆,上下文窗口受限 | Coordinator 拆为 SubTask各自独立上下文 |
| 并行产出 | 串行,无冲突 | 多 Agent 同时改文件 → 数据竞争 |
| 用户感知 | 单条流式输出 | 多 Agent 产出交织,用户分不清谁是谁 |
| 错误恢复 | 一个工具失败 → LLM 下一轮决策 | 一个 SubTask 失败 → Coordinator 决定是否继续 |
---
## 二、数据模型(方案 C混合
### 2.1 设计原则
1. **messages 不膨胀** — 只加 `subtask_id` 外键列,不把 Plan/Conflict 字段塞进去
2. **重实体独立** — Plan / SubTask / Conflict 各自独立表,各自演进
3. **历史兼容**`subtask_id = NULL` 表示单 Agent 时期消息,零回归
4. **FK 三层链路** — messages → SubTask → Plan 可追溯完整生命周期
### 2.2 ER 图
```mermaid
erDiagram
ai_conversations ||--o{ ai_plans : "1:N 触发"
ai_plans ||--o{ ai_subtasks : "1:N 拆解"
ai_plans ||--o{ ai_conflicts : "1:N 冲突"
ai_subtasks ||--o{ ai_messages : "1:N 产出(subtask_id FK)"
ai_subtasks ||--o{ ai_tool_executions : "1:N 工具调用(subtask_id FK)"
ai_conflicts }o--|| ai_subtasks : "subtask_a"
ai_conflicts }o--|| ai_subtasks : "subtask_b"
ai_plans {
TEXT id PK
TEXT conversation_id FK
TEXT user_message_id "触发 Plan 的用户消息"
TEXT status "planning/executing/merging/done/error"
INTEGER subtask_count
TEXT created_at
TEXT completed_at "可空"
}
ai_subtasks {
TEXT id PK
TEXT plan_id FK
TEXT persona_id "coder/reviewer/architect/..."
TEXT intent "子任务意图描述"
TEXT status "pending/running/done/error"
INTEGER layer "DAG 层级(0起)"
TEXT deps "JSON 数组: 依赖的 SubTask id 列表"
TEXT created_at
TEXT completed_at "可空"
}
ai_conflicts {
TEXT id PK
TEXT plan_id FK
TEXT file_path "冲突文件路径"
TEXT subtask_a FK
TEXT subtask_b FK
TEXT diff_a "Agent A 对该文件的改动摘要"
TEXT diff_b "Agent B 对该文件的改动摘要"
TEXT resolution "pending/a/b/merged/manual"
TEXT resolved_by "reviewer/user"
TEXT created_at
TEXT resolved_at "可空"
}
```
### 2.3 现有表改动
```sql
-- V36 迁移
ALTER TABLE ai_messages ADD COLUMN subtask_id TEXT;
ALTER TABLE ai_tool_executions ADD COLUMN subtask_id TEXT;
-- 新建 3 张表(见 ER 图字段)
CREATE TABLE ai_plans (...);
CREATE TABLE ai_subtasks (
...
-- worktree 分支名(git worktree 隔离机制,见 §4.2)
branch TEXT -- 如 "subtask/{plan_id}/A"
);
CREATE TABLE ai_conflicts (
...
-- 冲突类型(见 §4.2 四层防线)
conflict_type TEXT DEFAULT 'file' -- 'file'(同文件) / 'semantic'(编译失败)
);
```
### 2.4 三层关联查询示例
```sql
-- 查某 Plan 的全部产出(消息 + 工具)
SELECT m.* FROM ai_messages m
JOIN ai_subtasks s ON m.subtask_id = s.id
WHERE s.plan_id = ?;
-- 查某 Plan 的所有冲突
SELECT c.* FROM ai_conflicts c WHERE c.plan_id = ? AND c.resolution = 'pending';
-- 查某对话的全部 Plan 历史
SELECT p.* FROM ai_plans p WHERE p.conversation_id = ? ORDER BY created_at DESC;
```
---
## 三、事件协议
### 3.1 新增事件
| 事件 | 时机 | 载荷 | 前端响应 |
|------|------|------|---------|
| `AiPlanCreated` | Coordinator.decompose 完成后 | `{ plan_id, layers: [[{id, persona, intent, status}],...] }` | PlanProgress 展示 DAG |
| `AiSubTaskStatusChanged` | SubTask 状态变更pending→running→done/error | `{ subtask_id, plan_id, status, persona_id }` | PlanProgress 更新节点状态 + 工具卡分组徽章 |
| `AiMergeCompleted` | Coordinator.merge 完成后 | `{ plan_id, merged_output, conflicts: [{id, file, subtask_a, subtask_b}] }` | 展示合并结果 + 冲突徽章 |
| `AiConflictResolved` | 用户/reviewer 解决冲突后 | `{ conflict_id, resolution, resolved_by }` | 冲突徽章消失 + 最终输出更新 |
### 3.2 事件流时序
```
用户发送消息
AiPlanCreated { plan_id, layers }
↓ (PlanProgress 立即展示)
Layer 0 启动
AiSubTaskStatusChanged { subtask_0, running }
AiSubTaskStatusChanged { subtask_1, running } ← 层内并行
↓ (工具卡片按 subtask_id 分组,带 persona 徽章)
AiSubTaskStatusChanged { subtask_0, done }
AiSubTaskStatusChanged { subtask_1, done }
Layer 1 启动 ...
AiMergeCompleted { plan_id, merged_output, conflicts }
↓ (有冲突 → 冲突徽章;无冲突 → 直接展示合并输出)
用户点击冲突 → 展示 diff_a/diff_b → 选择
AiConflictResolved { conflict_id, resolution }
最终输出展示
```
---
## 四、并行执行策略
### 4.1 调度规则
```
Plan.to_layers() → [[subtask_0, subtask_1], [subtask_2], [subtask_3, subtask_4]]
Layer 0并行 Layer 1串行 Layer 2并行
```
| 规则 | 说明 |
|------|------|
| 层间串行 | 上层全部 done + **Git merge 到 plan 分支后** 才创建下一层 worktree(fork 天然包含上层改动) |
| 层内并行 | 同层 SubTask 各自创建 Git worktree,JoinSet 并发执行 |
| 写工具天然隔离 | 每个 SubTask 在独立 worktree 内工作,write_file/patch_file 写到 worktree 目录(不重定向) |
| 只读工具天然可见 | read_file 读 worktree 内文件(含本 worktree 改动 + fork 基点的全部历史) |
| 层内隔离 | 同层并行 SubTask 互相看不到对方的 worktree 改动(独立分支,完全隔离) |
### 4.2 Git worktree 隔离机制
**核心思路**:用 Git worktree 替代自建暂存区,复用 Git 原生的分支隔离/diff/merge/conflict 能力。
#### 优势(vs 自建暂存区)
| 维度 | 自建暂存区 | Git worktree |
|------|-----------|-------------|
| 隔离 | 手动 cp 文件 | `git worktree add` 天然隔离 |
| diff | 自己实现比对 | `git diff` 原生(含上下文/行级/二进制) |
| 合并 | 手动 cp 或自写 merge | `git merge` 三方合并 |
| 冲突解决 | 自建 diff 展示 | `git merge-tree` 预检 + conflict markers |
| 回滚 | 手动备份/恢复 | `git reset` / `git checkout` |
| 审计 | 自建日志 | commit history 天然审计链 |
| AI 工具兼容 | write_file 需重定向路径 | worktree 就是普通目录,所有工具原样工作 |
| PR 集成 | 需额外实现 | plan 分支直接创建 PR |
#### 数据流
```
Plan 启动:
git worktree add .devflow/wt/{plan_id} -b plan/{plan_id}
(基于工程当前分支创建 plan 工作分支)
Layer 0 并行:
SubTask A → git worktree add .devflow/wt/{plan_id}/A -b subtask/{plan_id}/A
(基于 plan 分支创建,A 的所有工具操作在此 worktree 内,天然隔离)
SubTask B → git worktree add .devflow/wt/{plan_id}/B -b subtask/{plan_id}/B
(同上,B 完全隔离,A 看不到 B 的改动,反之亦然)
SubTask A/B done 后各自 git add + git commit(改动落入各自分支)
Layer 0 merge(本层全部 done 后):
① cd .devflow/wt/{plan_id} (plan 分支)
② git diff plan..subtask/{plan_id}/A → A 的改动集
③ git diff plan..subtask/{plan_id}/B → B 的改动集
④ 同文件交集 → git merge-tree (三方合并预检)
无冲突 → git merge subtask/{plan_id}/A && git merge subtask/{plan_id}/B
有冲突 → Conflict 表记录 + Reviewer 仲裁 / 用户选择
⑤ 合并完成 → plan 分支已含 Layer 0 全部改动
Layer 1 fork:
基于 plan 分支最新 commit 创建新 worktree(天然包含 Layer 0 改动)
Plan 完成:
① 编译检查:在 plan worktree 跑 cargo check / tsc --noEmit
失败 → semantic 冲突标记
② plan 分支创建 PR(可选,或直接 merge 回主分支)
③ git worktree remove 清理所有子 worktree + plan worktree
```
#### 读工具兼容规则
| 场景 | 行为 |
|------|------|
| 同层并行 SubTask 互读 | 不可见(独立 worktree + 独立分支) |
| 下一层读上一层产出 | 可见(上一层已 merge 到 plan 分支,新 worktree 基于此创建) |
| SubTask 读自己 worktree 内的写入 | 可见(worktree 是真实目录,写完即读) |
#### 非 Git 工程的降级
未绑定 Git 的工程(无 .git 目录)降级为串行执行(单 SubTask 逐个跑,无 worktree 隔离)。
此时 ai_subtasks.branch 为 NULL,Coordinator.dispatch 走原串行路径。
### 4.3 冲突防护四层防线
| 防线 | 机制 | 覆盖冲突类型 |
|------|------|------------|
| **第一层:层间串行 + merge 后才 fork** | 上一层 merge 到 plan 分支后,下一层 worktree 基于此创建 | 跨层同文件覆盖 |
| **第二层:同层 worktree 隔离 + merge-tree 预检** | 独立分支 + git 三方合并检测 | 同文件并发写 |
| **第三层:编译检查** | merge 完成后在 plan worktree 跑 cargo check / tsc --noEmit,失败 → 标记语义冲突 | 跨文件语义冲突(删函数/改签名) |
| **第四层:命令互斥锁** | run_command 对同目录的 npm/cargo 加 mutex | 资源竞争(npm install 并发) |
### 4.4 Token 预算管控
```rust
struct TokenBudgetPool {
total: AtomicU64, // 全局预算(来自设置项)
consumed: AtomicU64, // 已消耗
}
impl TokenBudgetPool {
fn try_reserve(&self, estimate: u64) -> bool {
// CAS 循环:consumed + estimate <= total
}
}
```
- 每个 SubTask 启动前向预算池申请估算额度
- 超限时 Coordinator 拒绝启动新 SubTask(降级为串行顺序执行剩余任务)
- 预算来源:设置项 `df-ai-plan-token-budget`(默认 100k tokens)
### 4.5 错误传播
| 场景 | 策略 |
|------|------|
| SubTask 执行失败 | 记录 error 状态,**不中断其他同层 SubTask**(容错) |
| 全部 SubTask 失败 | Coordinator 标记 Plan 状态为 error,前端展示错误 |
| 部分 SubTask 失败 | merge 时跳过失败 SubTask 的分支(不 merge),只合并成功的 |
| 子 Agent 超时 | 单 SubTask 超时(默认 120s)→ 标记 error,不影响其他 |
| Git merge 冲突 | conflict markers 保留在 plan worktree,Reviewer Agent 仲裁或用户手动解决 |
| 编译检查失败 | 标记 semantic 冲突,Reviewer Agent 尝试修复或标记给用户 |
| worktree 创建失败 | 降级为串行(无 worktree 隔离,逐 SubTask 在主目录执行) |
---
## 五、UI 交互设计
### 5.1 PlanProgress 组件(发送即展示)
```
┌─────────────────────────────────────────┐
│ 📋 执行计划 2/4 │
├─────────────────────────────────────────┤
│ Layer 1 │
│ ▶ 🔵 [coder] 重构代码 running │
│ 🟢 [architect] 分析结构 done │
│ ↓ │
│ Layer 2 │
│ ⏸ [tester] 补测试 pending │
│ ↓ │
│ Layer 3 │
│ ⏸ [reviewer] 审查 pending │
└─────────────────────────────────────────┘
```
- **发送即展示**:用户发消息后 Coordinator 分解完成立即展示
- **实时更新**AiSubTaskStatusChanged 驱动节点状态变化
- **折叠工具卡**:每个 SubTask 下的工具卡片折叠归组(点击展开)
- **层间箭头**DAG 层级关系可视化
### 5.2 工具卡片分组subtask_id 归组)
```
┌─ 🔵 [coder·重构代码] ──────────────┐
│ ▸ read_file main.rs ✓ done │
│ ▸ patch_file utils.rs ✓ done │
└────────────────────────────────────┘
┌─ 🟢 [architect·分析结构] ──────────┐
│ ▸ list_directory ✓ done │
│ ▸ 分析结论: 模块耦合度偏高... │
└────────────────────────────────────┘
```
- 每个 SubTask 一个折叠容器,带 persona 颜色徽章
- 工具卡片归入对应 SubTask 容器
- 默认折叠,有审批/错误时自动展开
### 5.3 冲突展示(徽章非阻塞)
```
┌─────────────────────────────────────────┐
│ ✅ 执行完成 ⚠ 2 处冲突待处理 │
├─────────────────────────────────────────┤
│ 合并输出: │
│ ... │
├─────────────────────────────────────────┤
│ ⚠ main.rs — Agent A vs Agent B │
│ [查看 diff] [接受 A] [接受 B] [手动] │
│ ⚠ utils.rs — Agent A vs Agent B │
│ [查看 diff] [接受 A] [接受 B] [手动] │
└─────────────────────────────────────────┘
```
- 冲突用**徽章**提示,不打断阅读流
- 点击展开 diff 对比 + resolution 按钮
- reviewer Agent 可自动给出推荐(`resolved_by=reviewer`),用户确认即可
---
## 六、取消与中断
| 操作 | 行为 |
|------|------|
| 用户点停止 | 停止所有并行 SubTask整体取消 |
| 单 SubTask 超时 | 只标记该 SubTask error不影响其他 |
| 会话切换 | 后台 SubTask 继续执行F-09 并发语义一致) |
| 会话删除 | 所有关联 SubTask 停止 + Plan 标记 error |
---
## 七、实施分批
### 数据层 + Git worktree 隔离 + 并行执行
| # | 任务 | 文件 |
|---|------|------|
| 1 | V36 迁移(3 新表 + 2 ALTER + subtasks.branch + conflicts.conflict_type) | migrations.rs |
| 2 | PlanRepo / SubTaskRepo / ConflictRepo CRUD | 新 repo 文件 |
| 3 | Git worktree 生命周期管理(create/commit/merge/remove) | 新 git_worktree.rs |
| 4 | Coordinator.dispatch JoinSet 层内并行(每 SubTask 绑 worktree) | coordinator.rs |
| 5 | Token 预算池(AtomicU64 CAS,超限降级串行) | coordinator.rs |
| 6 | 子 Agent 独立 ContextManager + fork 快照 + worktree_path | coordinator.rs |
| 7 | 层间 merge 到 plan 分支 + 下一层基于 plan 创建 worktree | coordinator.rs |
| 8 | 4 个新事件 + 事件双写(emit + publish_event) | AiChatEvent |
| 9 | 前端类型定义(PlanRecord/SubTaskRecord/ConflictRecord) | api/types.ts |
| 10 | PlanProgress 接入真实状态 + 发送即展示 | PlanProgress.vue |
| 11 | 工具卡按 subtask_id 折叠分组 + persona 徽章 | MessageList.vue |
| 12 | 编译警告清理(coordinator_plan unused / audit 子模块 unused imports) | 各文件 |
### 仲裁合并 + 冲突 UI + 编译检查
| # | 任务 | 文件 |
|---|------|------|
| 1 | Coordinator.merge: git merge-tree 三方合并预检 | coordinator.rs + git_worktree.rs |
| 2 | 冲突检测:同文件路径(file 类型) + 编译失败(semantic 类型) | coordinator.rs |
| 3 | Reviewer Agent 仲裁(persona 扩展:读 diff → 推荐 resolution + 理由) | persona.rs |
| 4 | 合并产出落回主对话(方案 C:可展开单条) | coordinator.rs + MessageList.vue |
| 5 | ConflictResolver.vue(git diff 展示 + resolution 按钮) | 新组件 |
| 6 | resolve_conflict IPC + AiConflictResolved 事件闭环 | AiChatEvent |
| 7 | 命令互斥锁(run_command 对同目录 npm/cargo 加 mutex) | audit/approval.rs |
| 8 | agentic/mod.rs 拆分(2222 行 → 提取 compress/title/knowledge_inject 独立模块) | agentic/*.rs |
| 9 | AiChat 进度条补全(后端 AiAgentRound 补 max_rounds + 前端 completed 计数) | AiChat.vue |
| 10 | context.rs 拆分(1956 行 → 提取 sanitize/budget/compress 独立模块) | context/*.rs |
---
## 八、子 Agent 上下文隔离
### 8.1 双重隔离:上下文 + 文件系统
多 Agent 并行执行时需要两层隔离:
| 层 | 隔离对象 | 机制 |
|---|---------|------|
| 上下文隔离 | messages / currentText / tool_results / pending_approvals | 独立 ContextManager 实例 |
| 文件隔离 | 工程文件(worktree 内的源码) | Git worktree(见 §4.2) |
### 8.2 上下文隔离
```rust
struct SubTaskContext {
/// 从主对话 fork 的快照(只读父上下文 + 用户原始消息)
parent_snapshot: Vec<ChatMessage>,
/// 独立的 ContextManager(不写回主对话)
messages: ContextManager,
/// 分配的人设
persona: AgentPersona,
/// 子任务 id(产出消息标记 subtask_id)
subtask_id: String,
/// Git worktree 路径(工具操作的工作目录,见 §4.2)
worktree_path: Option<PathBuf>, // None = 非 Git 工程降级串行
}
```
**隔离规则**:
| 维度 | 主对话 | 子 Agent |
|------|--------|----------|
| messages | 用户消息 + 合并产出 | fork 快照 + 独立执行轨迹 |
| ContextManager | 主对话的 | 每个 SubTask 独立实例 |
| pending_approvals | 主对话的 | 各 SubTask 独立(无并发写竞争) |
| tool_results | 写入子 Agent 的 messages | 合并时提取摘要写入主对话 |
| **文件系统** | 主工程目录 | **Git worktree 隔离**(§4.2) |
| **工作目录** | 主工程路径 | **worktree_path**(工具的 cwd 重定向到此) |
### 8.3 合并产出落回主对话(方案 C:可展开单条)
```
SubTask 完成 → ExecutionResult { output, success }
Coordinator.merge(results) → MergeResult { merged_output, conflicts }
主对话 push 单条可展开 assistant 消息
```
**展示策略**:
- **默认折叠**:主对话只展示一条 assistant 消息(merged_output 摘要)
- **点击展开**:展开后按 SubTask 分组,每组显示完整执行轨迹(从 ai_messages WHERE subtask_id=? 拉取)
- **冲突内联**:合并消息底部内联冲突列表(点击展开 diff + resolution 按钮)
- **子 Agent 执行轨迹不堆入主对话流**:只在 ai_messages 表保留(带 subtask_id),展开时按需加载
---
## 九、前端类型定义
```typescript
// api/types.ts 新增
/** Plan 执行状态 */
export type PlanStatus = 'planning' | 'executing' | 'merging' | 'done' | 'error'
/** SubTask 执行状态 */
export type SubTaskStatus = 'pending' | 'running' | 'done' | 'error'
/** 冲突解决状态 */
export type ConflictResolution = 'pending' | 'a' | 'b' | 'merged' | 'manual'
/** Plan 记录 */
export interface PlanRecord {
id: string
conversation_id: ConvId
user_message_id: MessageId
status: PlanStatus
subtask_count: number
created_at: string
completed_at: string | null
}
/** SubTask 记录 */
export interface SubTaskRecord {
id: string
plan_id: string
persona_id: string
intent: string
status: SubTaskStatus
layer: number
deps: string[] // JSON 解析后的数组
/** Git worktree 分支名(NULL=非 Git 工程降级串行) */
branch: string | null
created_at: string
completed_at: string | null
}
/** 冲突类型 */
export type ConflictType = 'file' | 'semantic'
/** 冲突记录 */
export interface ConflictRecord {
id: string
plan_id: string
/** 冲突文件路径(semantic 类型时为触发编译失败的入口文件) */
file_path: string
/** 冲突类型:file=同文件路径 / semantic=编译失败 */
conflict_type: ConflictType
subtask_a: string
subtask_b: string
/** git diff 输出(A 的改动) */
diff_a: string
/** git diff 输出(B 的改动) */
diff_b: string
resolution: ConflictResolution
resolved_by: 'reviewer' | 'user' | null
created_at: string
resolved_at: string | null
}
/** PlanProgress 展示用 DAG 层结构 */
export interface PlanLayer {
items: Array<{
id: string
persona: string
intent: string
status: SubTaskStatus
}>
}
```
---
## 十、状态机
### 10.1 Plan 状态流转
```mermaid
stateDiagram-v2
[*] --> planning: Coordinator.decompose()
planning --> executing: Plan 写入 DB + emit AiPlanCreated
executing --> merging: 所有 SubTask done/error
merging --> done: merge 完成 + 无冲突
merging --> done: 所有冲突已解决
merging --> error: merge 失败
executing --> error: 全部 SubTask 失败
done --> [*]
error --> [*]
```
### 10.2 SubTask 状态流转
```mermaid
stateDiagram-v2
[*] --> pending: Plan.decompose 创建
pending --> running: dispatch 分配 + JoinSet 启动
running --> done: agentic loop 收敛(无 tool_calls)
running --> error: 超时 / 全部工具失败
done --> [*]
error --> [*]
```
### 10.3 Conflict 状态流转
```mermaid
stateDiagram-v2
[*] --> pending: merge 检测到冲突
pending --> resolved: reviewer 推荐 + 用户确认
pending --> resolved: 用户手动选择 a/b/merged
resolved --> [*]
```
---
## 十一、事件总线集成
4 个新事件均走现有双写机制(`app_handle.emit` + `ai_event_bus.publish_event`),
与 AiToolCallStarted/AiCompleted 等关键状态变更事件一致:
```rust
let ev = AiChatEvent::AiPlanCreated { ... };
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
```
- `emit`:桌面端前端监听
- `publish_event`:tunnel subscriber 透传小程序
---
## 十二、不做的方向
| 方向 | 放弃理由 | 可能的时机 |
|------|---------|-----------|
| 工具级并行(同轮多 tool_call JoinSet | 并发写 pending_approvals + LLM 对乱序 tool_result 行为不可预测 | 永不LLM 已能单轮多 tool_call |
| 单 SubTask 取消 | MVP 简化,整体取消即可 | P1 以后按需 |
| 双栏 diff 对比 UI | 违背 AI Native 定位(让用户做 AI 该做的合并) | 永不reviewer Agent 仲裁替代) |
| SubTask 级审批门控 | 审批在工具级RiskLevel已足够 | P2 审批政策配置 |

View File

@@ -3,7 +3,7 @@
> **真相源**(本文档唯一展开完整设计)。功能决策记录仅放摘要 + 指针。
>
> 背景R-PD-1全局代码 review 2026-06-15 §🔴 P1 需设计)— 编辑 provider 提交空 `api_key` 时,无条件把 DB `api_key` 置空走 `INSERT OR REPLACE`**未迁移态** provider 的明文密钥被静默覆盖成空 → keyring 也空 → resolve 返空 → provider 报废,密钥永久丢失。
> 状态:📐 **设计完成,未实施** | 创建2026-06-15 | 来源:全局代码 review 2026-06-15
> 状态: **已落地**(2026-06-28 核验:provider.rs:104-136 即时迁移补密钥 + 阻断保存 + secret.rs 启动迁移) | 创建2026-06-15 | 来源:全局代码 review 2026-06-15
---

View File

@@ -2,7 +2,7 @@
> 来源:全局代码 review 2026-06-15 §🔴 P1 需设计 R-PD-2
> 日期2026-06-15
> 状态:设计待核对(推荐方案已定,落地前需用户确认力度)
> 状态:✅ 已落地(2026-06-28 核验:ScriptNode 命令执行安全边界 — env 白名单/黑名单 + 危险关键词告警,script_node.rs:40-155)
---

View File

@@ -0,0 +1,224 @@
# 工程系统设计 — 项目多工程 + Git 状态 + 文件浏览
> 创建: 2026-06-29 | 状态: ✅ Batch 9-13 已落地(工程表+文件浏览器+Git 状态/变更/提交历史)
> 关联: [项目知识图谱与任务队列系统-2026-06-26.md](./项目知识图谱与任务队列系统-2026-06-26.md) / ARCHITECTURE.md
---
## 一、背景
DevFlow 的项目(Project)当前只能绑定一个目录(projects.path)。现实中的项目经常包含多个工程(Module):Monorepo 多仓库、微服务多服务、前后端分离等。每个工程是独立的代码仓库(各自的 .git),有自己的 Git 远程地址和技术栈。
引入工程(Module)概念,使 AI 和用户都能以工程为粒度操作代码仓库。
---
## 二、两级结构
```
项目(Project) ← 业务实体("支付平台")
├── 工程(Module) ← 代码仓库("后端 API" / "前端 Web")
│ ├── 工程目录(path)
│ ├── Git 地址(git_url)
│ ├── 技术栈(stack)
│ └── Git 状态(实时派生,不存表)
├── 工程(Module)
└── 工程(Module)
```
单仓库项目 = 项目下只有一个工程(退化场景,UI 不显式展示工程层级)。
---
## 三、数据模型
### 3.1 project_modules 表(V34 迁移)
```sql
CREATE TABLE project_modules (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
name TEXT NOT NULL, -- "后端 API"
path TEXT NOT NULL, -- 工程目录(绝对路径)
git_url TEXT, -- Git 远程地址(可选)
stack TEXT, -- 技术栈 JSON(["rust","tokio","postgresql"])
auto_detected BOOLEAN NOT NULL DEFAULT FALSE, -- 自动探测创建(重新探测时覆盖,用户编辑后不回写)
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX idx_project_modules_project ON project_modules(project_id);
```
### 3.2 设计原则
| 字段 | 来源 | 说明 |
|---|---|---|
| name | 用户/探测 | 工程名称 |
| path | 用户 | 工程目录(必填,等于 .git 所在目录) |
| git_url | 用户 | Git 远程地址(可选,单机可不填) |
| stack | 探测+用户可改 | 技术栈 JSON(探测 Cargo.toml/package.json 填充默认,用户可覆盖) |
| auto_detected | 系统 | true=自动探测创建;重新探测时只覆盖 auto_detected=true 的行 |
| sort_order | 系统 | 列表排序 |
**Git 状态实时派生(不存表)**:分支/改动文件/最近提交 → 查询时跑 git 命令返回。
---
## 四、Git 状态查询
### 4.1 get_module_git_status(module_id) 返回结构
```json
{
"branch": "main",
"remote": "origin",
"changes": {
"modified": 3,
"added": 1,
"untracked": 2,
"total": 6
},
"files": [
{ "path": "src/main.rs", "status": "M" },
{ "path": "Cargo.toml", "status": "M" },
{ "path": "README.md", "status": "??" }
],
"last_commit": {
"hash": "abc1234",
"message": "feat: add login",
"author": "lxy",
"date": "2026-06-29T10:00:00"
}
}
```
### 4.2 实现方式
在工程目录(module.path)执行:
- `git branch --show-current` → 当前分支
- `git remote get-url origin` → 远程地址(与 module.git_url 互补)
- `git status --porcelain` → 改动文件列表(解析为结构化)
- `git log -1 --format=...` → 最近提交
所有 git 命令包 10s timeout。无 .git 目录返回空状态。
---
## 五、文件浏览器(IPC + UI)
### 5.1 get_module_file_tree IPC
```rust
get_module_file_tree(module_id, sub_path?, depth?) FileTreeResponse
```
返回文件列表 + Git 状态合并(一次查询)。文件树懒加载:默认展开 2 层,点击展开加载子目录。
噪音过滤:跳过 node_modules / target / .git / __pycache__ / dist / .vite。
### 5.2 UI 结构(当前)
```
项目详情页 → 文件 Tab
┌─────────────────────────┬──────────────────────────────────────┐
│ [📁] [📝 3] 视图切换 │ 文件内容预览 │
│ ─────────────────────── │ │
│ 📁 文件树/变更列表 │ 行号 │ 代码(语法高亮 + 行号) │
│ src/ │ 1 │ use std::...; │
│ main.rs M │ 2 │ fn main() { │
│ config.rs │ 3 │ println!("hello"); │
│ tests/ │ 4 │ } │
│ Cargo.toml A │ │
│ │ main.rs · 2.1 KB · 📝 查看变更 │
└─────────────────────────┴──────────────────────────────────────┘
```
**视图切换**: 左侧栏顶部两个图标按钮:
- 📁 文件树视图(默认)
- 📝 变更视图(带变更数量角标)
**变更视图**:
- 变更文件列表(按状态分组:已修改/已新增/已删除/未跟踪)
- 文件按目录深度缩进(src/components/Foo.vue → 缩进 2 层)
- 点击文件 → 右侧预览区显示 git diff(红删绿增)
- 顶部 Tab 切换至"历史":展示提交历史(每条显示 hash+主题+时间)
- 初始 50 条,支持"加载更多"分页
- 点击提交行 → 展开该提交的变更文件列表
- 点击变更文件 → 展示该文件的 diff
**内容预览增强**:
- 行号列 + highlight.js 语法高亮(按文件后缀自动选语言)
- 按后缀彩色图标(rs/ts/js/vue/css/html/json/md/sh/sql 等)
- Diff 切换按钮(Git 状态不为空时在头部右侧显示 📝)
- 滚动条在预览区域内部,不溢出页面层
### 5.3 单工程退化
项目只有 1 个工程时不显工程选择器,直接展示文件浏览器。
---
## 六、创建项目适配
create_project 时自动建一个工程:
- name = 项目名
- path = 绑定目录
- stack = 探测结果(复用现有 detect_stack)
- git_url = 探测(git remote get-url origin,失败为 None)
- auto_detected = true
用户后续可添加更多工程(多工程场景)。
---
## 七、AI 工具
### 7.1 只读工具(Batch 9 随数据层一起注册)
| 工具 | 功能 | 风险 |
|---|---|---|
| list_project_modules | 列出项目工程列表 | Low |
| get_module_git_status | 查询工程 Git 状态 | Low |
### 7.2 Git 工具(后续批次)
| 工具 | 功能 | 风险 |
|---|---|---|
| git_status | 工作区状态(结构化) | Low |
| git_diff | 未提交改动详情 | Low |
| git_log | 提交历史 | Low |
| git_commit | 提交改动 | Medium |
| git_branch | 分支管理 | Medium |
| git_merge | 合并分支 | High |
安全边界:禁止 push / force / reset --hard。
---
## 八、实施批次
| 批次 | 内容 |
|---|---|
| Batch 9 | 工程表 + CRUD IPC + Git 状态查询 + 创建项目适配 + AI 工具注册 |
| Batch 10 | 文件浏览器 UI(工程选择 + 文件树 + Git 状态标记 + 内容预览) |
| Batch 11 | Git 只读 AI 工具(status/diff/log) |
| Batch 12 | Git 写 AI 工具(commit/branch/merge) |
| Batch 13 | 文件浏览器增强(行号/图标/Diff/变更面板/窗口分离) |
### Batch 13 新增功能
| 功能 | 说明 |
|---|---|
| 行号显示 | 文件预览左侧显示行号列 |
| 后缀图标 | rs/ts/js/vue/css/html 等 20+ 类型彩色 SVG 图标 |
| Diff 视图 | 有 Git 变更的文件可切换内容/Diff 红绿视图 |
| Git 变更面板 | 文件树侧栏可切换至变更视图,显示变更文件列表(按目录缩进分组) |
| 提交历史分页 | 展示最近 50 条提交,支持加载更多(分页) |
| 提交详情 | 点击提交行展开变更文件列表,点文件查看 Diff |
| 提交时间显示 | 相对时间(刚刚/N分钟前)+超过30天显示具体日期 |
| 窗口分离 | 文件浏览器可弹出独立 Tauri 窗口 |
| 中文编码修复 | git 命令注入 LANG/LC_ALL UTF-8 环境变量 |
| 多工程管理 UI | 工具栏添加工程按钮 + 弹窗表单(名称/路径/Git地址) |
| 文件变更自动刷新 | write_file/patch_file 触发 df-data-changed 事件推送 |

View File

@@ -1,7 +1,7 @@
# 条件表达式引擎设计R-PD-3 / T-260614-11
> 来源:全局代码 review `docs/05-代码审查/全局代码review-2026-06-15.md` §🔴 P1 需设计 R-PD-3 + 架构洞察第 4 条
> 性质:设计文档(供用户核对方案),不含实现
> 性质:✅ **已落地**(2026-06-28 核验:ConditionEngine 手写求值器 + JSON Path/嵌套/数组索引/数值比较 + executor feature flag 集成 + 前端边条件编辑入口 + 中英文翻译)
> 关联todo `T-260614-11 条件表达式引擎升级`、R-P2-13set_skipped/set_waiting 已删)
---

View File

@@ -0,0 +1,267 @@
# 架构债迁移设计方案2026-06-29
> 创建: 2026-06-29 | 状态: 设计草案
> 关联: [全量走查报告-2026-06-28](../05-代码审查/全量走查报告-2026-06-28.md) §架构缺陷 #4/#6/#8 + SQLite 单连接 #14
> 适用范围: 本次推进剩余项 #8/#15/#18/#19 的迁移路径设计
---
## 一、概览
走查报告识别出 4 项架构债,影响可维护性或扩展性。本设计为每项给出**目标架构 + 迁移路径 + 风险评估****不在本批次实施代码**(每项都需独立 PR + e2e 验证)。
| 编号 | 问题 | 风险 | 建议时机 |
|------|------|------|---------|
| #15 | AI 核心状态机焊死 src-tauri | 高(重构 agentic loop | Phase 4 之前 |
| #8 | IPC 错误拍平为 String | 中(前后端协议改造) | 下个迭代 |
| #18 | 配置四源并存 | 中(跨 crate 改读) | 与 #15 协同 |
| #19 | SQLite 单连接 Mutex | 中DB 层基础) | 性能瓶颈出现时 |
---
## 二、#15 — AI 核心状态机抽离
### 现状
```
src-tauri/src/commands/ai/
├── mod.rs ← AiSession / PerConvState / AiChatEvent / SessionState应属 df-ai
├── agentic/mod.rs ← run_agentic_loop 主循环(应属 df-ai
├── commands.rs ← IPC 层(应留在 src-tauri
├── tool_registry ← 工具注册IPC 层依赖,留在 src-tauri
└── ...
crates/df-ai/src/
├── provider.rs ← LlmProvider trait正确
├── openai_compat.rs ← HTTP正确
├── anthropic_compat.rs← HTTP正确
├── coordinator.rs ← 空壳(应承担 agentic loop
└── ...
```
### 目标架构
```
crates/df-ai-session/ ← 新 crate避免 df-ai 引 reqwest 同时引会话状态)
├── session.rs ← AiSession / PerConvState / SessionState
├── agentic_loop.rs ← run_agentic_loop纯逻辑依赖 LlmProvider trait
├── events.rs ← AiChatEvent enum
└── approvals.rs ← PendingApproval / ApprovalKind
src-tauri/src/commands/ai/
├── mod.rs ← 仅 IPC glueinvoke_handler 注册 + AppState 转发)
├── commands.rs ← ai_send_message / ai_approve / ai_reject
└── tool_registry.rs ← 工具注册(仍依赖 df-storage / df-types
```
### 迁移路径4 阶段)
**阶段 1**:创建 `df-ai-session` crate仅迁移类型定义`AiSession` / `AiChatEvent`不改逻辑。src-tauri 改为 re-export。
**阶段 2**:迁移 `run_agentic_loop` 函数体。保持现有调用方接口不变(`pub async fn run_agentic_loop(...)`),仅改归属。
**阶段 3**:把 tool_registry 中的工具注册逻辑拆分为「工具定义」(可迁移)和「工具 handler」依赖 IPC 层,保留)。
**阶段 4**src-tauri 仅保留 IPC glue所有 AI 状态机测试可在 df-ai-session 独立运行。
### 风险
- **循环依赖**`agentic_loop` 需要 LlmProviderdf-ai-core+ AiToolRegistry。AiToolRegistry 的 handler 依赖 src-tauri 的 storage/commands。**解决**:把 AiToolRegistry trait 下沉到 df-ai-corehandler 实现留在 src-tauri。
- **测试覆盖**:现有 AI 测试都在 src-tauri 集成测试,迁移后需补 df-ai-session 的单测。
---
## 三、#8 — IPC 错误结构化
### 现状
```rust
// src-tauri/src/commands/mod.rs:26
pub fn err_str<E: ToString>(e: E) -> String { e.to_string() }
// 所有 IPC 命令
async fn xxx() -> Result<T, String> { ... .map_err(err_str) }
```
df-types/error.rs 的 11 个变体(`NotFound`/`Validation`/`InvalidState { current, expected }`)在 IPC 边界全部 `.to_string()` 拍平。前端只能拿到字符串,无法分辨错误类型。
### 目标架构
```rust
// df-types/src/error.rs 新增 Serialize
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "kind", content = "data")]
pub enum IpcError {
NotFound { entity: String, id: String },
Validation { field: String, reason: String },
InvalidState { entity: String, current: String, expected: Vec<String> },
Unauthorized { reason: String },
Internal { message: String },
}
// IPC 命令改为
async fn xxx() -> Result<T, IpcError> { ... }
```
Tauri invoke 自动 serialize 为 JSON
```json
{ "kind": "invalid_state", "data": { "entity": "task", "current": "todo", "expected": ["in_progress"] } }
```
### 前端消费
```ts
// src/api/types.ts
type IpcError =
| { kind: 'not_found'; data: { entity: string; id: string } }
| { kind: 'validation'; data: { field: string; reason: string } }
| { kind: 'invalid_state'; data: { entity: string; current: string; expected: string[] } }
| { kind: 'unauthorized'; data: { reason: string } }
| { kind: 'internal'; data: { message: string } }
// 使用
try { await invoke(...) }
catch (e: unknown) {
const err = e as IpcError
if (err.kind === 'not_found') router.push('/projects')
else if (err.kind === 'invalid_state') toast.warning(`当前 ${err.data.current},期望 ${err.data.expected.join(',')}`)
}
```
### 迁移路径
**阶段 1**df-types 新增 `IpcError` enum + `From<df_types::error::Error> for IpcError`
**阶段 2**src-tauri IPC 命令逐个从 `Result<T, String>` 改为 `Result<T, IpcError>`。**保持向后兼容**:旧前端读 `err.message` 时降级为字符串IpcError 实现 `Display` 输出 message
**阶段 3**:前端 api 层加 `IpcError` 类型 + 在关键 viewProjects/Tasks/Ideas按 kind 分支处理。
**阶段 4**:删除 `err_str` 辅助函数。
### 风险
- **Tauri invoke 错误序列化**Tauri 默认把 `Result::Err` 经 JSON 传前端,但前端 `invoke().catch()` 拿到的是 `string``object`**需验证**Tauri v2 对自定义 error 类型的序列化行为。
- **向后兼容**:旧前端代码 `e?.toString()` 需仍工作。IpcError 的 Display 实现保证字符串友好。
---
## 四、#18 — 配置四源统一
### 现状(散落 env::var 读取)
| 读取点 | 用途 | 性质 |
|--------|------|------|
| `df-relay/src/main.rs:17` `DF_RELAY_ADDR` | relay 监听地址 | 启动配置 |
| `df-relay/src/relay.rs:38` `DF_RELAY_TOKEN` | relay 鉴权 | 启动配置panic 兜底) |
| `df-nodes/src/script_node.rs:141,155` `DF_SCRIPT_BLACKLIST`/`WHITELIST` | 脚本策略 | 运行时读取 |
| `df-nodes/src/ai_node.rs:389-398` `GLM_BASE_URL`/`GLM_API_KEY`/`GLM_MODEL` | AINode 兜底 provider | 运行时读取 |
| `src-tauri/src/commands/ai/tool_registry.rs:2780` `DEVFLOW_ENV_PROBE_ENABLED` | 环境探测开关 | 运行时读取 |
| `src-tauri/src/main.rs:116-121` `APPDATA`/`HOME` | OS 路径 | 系统变量 |
| `df-execute/src/env_snapshot.rs:216,253` `SHELL`/`LANG` | Shell 探测 | 系统变量 |
### 目标架构
**不强行统一所有读取**OS 路径 / Shell 探测等系统变量性质不同),而是:
1. **df-types 新增 `AppConfig` struct**(集中定义项目级配置项)
2. **启动时一次性读取 env + DB KV**,构造 `AppConfig` 实例
3. **AppState 持有 `Arc<AppConfig>`**,各模块经 state 读取
4. **文档化配置矩阵**env / DB KV / 默认值 / panic 的关系
```rust
// df-types/src/config.rs
pub struct AppConfig {
pub relay: RelayConfig,
pub script_policy: ScriptPolicyConfig,
pub ai_node: AiNodeConfig,
pub env_probe: bool,
}
pub struct RelayConfig {
pub addr: String, // 默认 "127.0.0.1:8080"
pub token: String, // 缺失 panic
}
pub struct ScriptPolicyConfig {
pub blacklist: Vec<String>, // 默认 ["rm","del","format","shutdown","mkfs","dd"]
pub whitelist: Vec<String>, // 默认 []
}
// src-tauri/src/state.rs
pub struct AppState {
pub config: Arc<AppConfig>,
// ...
}
```
### 迁移路径
**阶段 1**df-types 定义 `AppConfig`,在 src-tauri 启动时构造。
**阶段 2**:逐个替换散落的 `std::env::var``state.config.xxx`。**保持 env var 作为 override**(向后兼容)。
**阶段 3**:前端 Settings 增加「环境变量覆盖」可视化。
### 风险
- **df-relay 独立 binary**relay 是独立进程,不经 src-tauri AppState。**解决**df-relay 自己构造 `RelayConfig`(共享 df-types::config 类型)。
---
## 五、#19 — SQLite 连接池
### 现状
```rust
// crates/df-storage/src/db.rs:13-16
pub struct Database {
conn: Arc<Mutex<Connection>>, // 单连接,所有读写串行
}
```
注释自承 `TODO: 考虑使用 r2d2 连接池替代单连接 Mutex`。AI 对话高峰(多 tool_calls 并发 + SSE 落库)下排队。
### 目标架构
```rust
pub struct Database {
pool: r2d2::Pool<SqliteConnectionManager>,
}
impl Database {
pub fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>> {
self.pool.get().map_err(...)
}
}
```
### 迁移路径
**阶段 1**df-storage 加 `r2d2` + `r2d2_sqlite` 依赖,改 `Database` 内部为 pool。
**阶段 2**:所有 Repo 的 `conn.blocking_lock()` 改为 `pool.get()?`spawn_blocking 内获取连接)。
**阶段 3**WAL 模式 + 合理 pool_size桌面应用 4-8 即可)。
### 风险
- **WAL 已开**`db.rs:22 PRAGMA journal_mode=WAL`读写不互斥pool 能真正并行。
- **事务原子性**:现有批量插入用 `unchecked_transaction()`pool 模式下需确认 `PooledConnection` 的 transaction 行为一致。
- **测试改造**:所有 Repo 测试的 `Database::open_in_memory()` 需改为 pool 模式。
---
## 六、本批次已落地(参考)
| 编号 | 改动 | 文件 |
|------|------|------|
| #17 | TaskStatus 字符串常量从 enum 派生(`as_str` 改 const fn | `crates/df-types/src/types.rs` + `crates/df-nodes/src/task_state_machine.rs` |
| #16 | df-project 真分层(加状态机 + 业务约束 + 测试) | `crates/df-project/src/manager.rs` + `src-tauri/src/commands/idea.rs` |
| #18 部分 | 文档化配置矩阵(本设计文档) | 本文件 |
## 七、推进顺序建议
1. **#8 IPC 错误结构化** — 风险中等,收益直接(前端可差异化处理)
2. **#15 AI 状态机抽离** — 风险高但收益最大(解锁 CLI/独立测试),建议在 Phase 4 之前
3. **#18 配置统一** — 与 #15 协同(新 crate 也需要读配置)
4. **#19 连接池** — 性能瓶颈出现时再改(当前单用户桌面应用未必触发)

View File

@@ -1,6 +1,6 @@
# F-260621-02 查询能力维度补全方案(任务/项目/灵感)
> **状态**:方案设计(待办登记),待排期实施
> **状态**:✅ **已落地**(2026-06-28 核验:task/project/idea 均 keyword/order_by/limit/offset 多维动态 WHERE)
> **日期**:2026-06-21
> **范围**:DevFlow 任务/项目/灵感三实体**列表查询维度**补全
> **关联**:[PERF-260619-01 查询效率优化方案](./查询效率优化方案-2026-06-19.md)(查询**性能**,与本方案正交) / todo.md F-15-03 分页(本方案细化)

View File

@@ -0,0 +1,290 @@
# 测试落地实施方案 — 2026-07-01
> 关联:[后续批次测试用例设计-2026-07-01.md](后续批次测试用例设计-2026-07-01.md)
> 用途:定义每个测试用例的具体落地方式、文件位置、执行命令
---
## 一、项目现有测试基建
| 层 | 现状 | 工具 |
|----|------|------|
| Rust 单元测试 | df-ai 370+ 测试,df-storage 集成测试,df-execute 集成测试 | `#[test]` / `#[tokio::test]` |
| 前端测试 | **无**(package.json 无 vitest/jest) | 需引入 vitest |
| 端到端 | 无自动化(手动验证 + 日志) | 手动 |
---
## 二、各层级落地方式
### 2.1 Rust 单元测试(`#[test]` / `#[tokio::test]`)
**适用用例**:MIG / REPO / DEC / PAR / TOK / MRG / CMP / ARB / TPL / POL
**文件位置**:与现有模式一致——`mod tests` 嵌入源文件底部。
| 用例类别 | 落地文件 | 测试数量 |
|---------|---------|---------|
| MIG-01~04 | `df-storage/src/migrations.rs` mod tests | 4 |
| REPO-01~07 | `df-storage/src/crud/plan_repo.rs` mod tests | 7 |
| DEC-01~06 | `df-ai/src/coordinator.rs` mod tests(已有 6 个,补全) | 6 |
| PAR-01~07 | `df-ai/src/coordinator.rs` mod tests(mock executor) | 7 |
| TOK-01~04 | `df-ai/src/coordinator.rs` mod tests | 4 |
| MRG-01~05 | `df-ai/src/coordinator.rs` mod tests(mock merge) | 5 |
| CMP-01~04 | `df-ai/src/coordinator.rs` mod tests(mock 编译结果) | 4 |
| ARB-01~04 | `df-ai/src/persona.rs` mod tests | 4 |
| TPL-01~06 | `df-workflow/src/template_loader.rs` mod tests | 6 |
| POL-01~06 | `src-tauri/src/policy.rs` mod tests | 6 |
**执行命令**:
```bash
# 全量
cargo test --workspace
# 按 crate
cargo test -p df-ai
cargo test -p df-storage
# 按用例名
cargo test -p df-ai coordinator::tests::decompose_read_then_write
# 只跑并行相关
cargo test -p df-ai -- coordinator::tests::par_
```
### 2.2 Rust 集成测试(`tests/` 目录)
**适用用例**:WT / SUB / NODE / LCK / CI(需要真实 Git/Shell/DB 环境)
| 用例类别 | 落地文件 | 说明 |
|---------|---------|------|
| WT-01~07 | `df-ai/tests/git_worktree.rs` | 真实 git worktree 操作(tempdir 临时仓库) |
| LCK-01~03 | `df-ai/tests/command_lock.rs` | 真实 tokio 并发 + mutex 验证 |
| SUB-01~03 | `df-nodes/tests/subflow_node.rs` | 真实 Executor + 嵌套模板 |
| NODE-01~06 | `df-nodes/tests/nodes.rs` | GitNode/HTTPNode/NotifyNode 各自独立 |
| CI-01~02 | `df-nodes/tests/docker_node.rs` | 需 Docker 环境(`#[ignore]` 标注,CI 环境跑) |
**WT 测试关键代码模式**(参考现有 `df-execute/tests/shell.rs`):
```rust
// df-ai/tests/git_worktree.rs
use tempfile::TempDir;
use std::process::Command;
fn setup_git_repo() -> TempDir {
let dir = tempfile::tempdir().unwrap();
Command::new("git").arg("init").current_dir(dir.path()).output().unwrap();
Command::new("git").args(["config", "user.email", "test@test.com"])
.current_dir(dir.path()).output().unwrap();
Command::new("git").args(["config", "user.name", "test"])
.current_dir(dir.path()).output().unwrap();
// 写初始文件 + commit
std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
Command::new("git").args(["add", "."]).current_dir(dir.path()).output().unwrap();
Command::new("git").args(["commit", "-m", "init"]).current_dir(dir.path()).output().unwrap();
dir
}
#[tokio::test]
async fn wt_01_worktree_create_and_isolate() {
let repo = setup_git_repo();
let plan_id = "test-plan-001";
// 创建 worktree
let wt_path = repo.path().join(".devflow/wt").join(plan_id).join("A");
let output = Command::new("git")
.args(["worktree", "add", wt_path.to_str().unwrap(), "-b", "subtask/test-plan-001/A"])
.current_dir(repo.path())
.output().unwrap();
assert!(output.status.success(), "worktree 创建失败");
assert!(wt_path.exists(), "worktree 目录应存在");
// 写文件到 worktree
std::fs::write(wt_path.join("new.rs"), "// A 的改动").unwrap();
// 主目录不应有 new.rs
assert!(!repo.path().join("new.rs").exists(), "主目录不应有 A 的改动");
// 清理
Command::new("git").args(["worktree", "remove", wt_path.to_str().unwrap(), "--force"])
.current_dir(repo.path()).output().unwrap();
}
```
### 2.3 前端测试(引入 vitest)
**现状**:项目无前端测试基建。需要引入 vitest。
**引入步骤**(纳入数据层批次实施):
```bash
bun add -d vitest @vue/test-utils jsdom
```
`vitest.config.ts`:
```typescript
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
},
})
```
`package.json``"test": "vitest"` 脚本。
**适用用例**:EVT / OUT / REF(前端)
| 用例类别 | 落地文件 | 说明 |
|---------|---------|------|
| EVT-01~06 | `src/components/ai/__tests__/PlanProgress.test.ts` | 组件事件驱动渲染 |
| OUT-01~04 | `src/components/ai/__tests__/MessageList.test.ts` | 合并产出展示/折叠 |
| REF-03~05 | `src/components/ai/__tests__/MessageList.test.ts` | 拆分后行为不变 |
**执行命令**:
```bash
bun run test # watch 模式
bun run test -- --run # 单次跑
bun run test -- PlanProgress # 按文件名过滤
```
### 2.4 端到端手动验证(测试脚本 + 日志)
**适用用例**:E2E / EDGE
**落地方式**:测试用 DB + 日志分析脚本,非自动化框架。
**手动验证检查清单格式**(每个批次完成时填写):
```markdown
## E2E-01 简单并行任务
- [ ] 发送消息:"先读取 main.rs 再修改 utils.rs"
- [ ] PlanProgress 展示 2 层 DAG
- [ ] Layer 0 (read) 完成 → Layer 1 (write) 启动
- [ ] 最终输出包含合并结果
- [ ] ai_subtasks 表有 2 条记录,status=done
- [ ] ai_messages 表有 subtask_id 非空的记录
- [ ] worktree 目录已清理(.devflow/wt/ 下无残留)
```
**验证用 SQL 查询**(批次完成后在 dev 数据库执行):
```sql
-- 检查 Plan 记录
SELECT id, status, subtask_count, created_at FROM ai_plans ORDER BY created_at DESC LIMIT 5;
-- 检查 SubTask 状态
SELECT s.id, s.persona_id, s.status, s.layer, s.branch
FROM ai_subtasks s
JOIN ai_plans p ON s.plan_id = p.id
WHERE p.id = ? -- 替换为实际 plan_id
ORDER BY s.layer, s.created_at;
-- 检查冲突记录
SELECT c.file_path, c.conflict_type, c.resolution, c.resolved_by
FROM ai_conflicts c WHERE c.plan_id = ?;
-- 检查消息归属
SELECT m.id, m.role, m.subtask_id, substr(m.content, 1, 60) as preview
FROM ai_messages m WHERE m.conversation_id = ? AND m.subtask_id IS NOT NULL
ORDER BY m.seq DESC LIMIT 20;
```
**worktree 残留检查**:
```bash
# Plan 完成后应无残留
ls -la .devflow/wt/ # 应为空或不存在
# 异常残留时的清理
git worktree list
git worktree prune
```
---
## 三、落地优先级
| 优先级 | 范围 | 时机 |
|--------|------|------|
| **P0 随编码落地** | Rust 单元测试(DEC/PAR/TOK/MRG/ARB) | 每个批次编码时同步写 |
| **P0 随编码落地** | Rust 集成测试(WT) | worktree 模块编码时同步写 |
| **P1 批次末尾** | 前端测试基建引入 + EVT/OUT | 数据层批次末尾引入 vitest |
| **P2 批次完成后** | E2E 手动验证 | 每个批次完成后用检查清单验证 |
| **P3 按需** | CI 集成测试(Docker/PR) | Git/CI 批次完成后 |
---
## 四、CI 自动化(远期)
当 vitest + cargo test 覆盖率足够后,接入 CI:
```yaml
# .github/workflows/test.yml(或 Gitea Actions)
jobs:
rust-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cargo test --workspace
frontend-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- run: bun install
- run: bun run test -- --run
```
当前阶段以**本地 `cargo test` + 手动 E2E** 为主,CI 自动化等测试覆盖率达标后接入。
---
## 五、Mock 策略
### 5.1 Rust 侧
| 依赖 | Mock 方式 |
|------|----------|
| LLM 调用 | 闭包注入(`Coordinator.dispatch<F>` 已支持泛型 executor) |
| Git 操作 | 集成测试用真实 git(tempdir 临时仓库) |
| 数据库 | 内存 SQLite(`Database::open_in_memory()`) |
| 编译检查 | mock 返回 exit_code(单元测试)/ 真实 cargo(集成测试) |
### 5.2 前端侧
| 依赖 | Mock 方式 |
|------|----------|
| Tauri IPC | `@vue/test-utils` mock `invoke` |
| 事件监听 | 手动 emit 测试事件 |
| i18n | 插件 mock(返回 key 本身) |
| Store | 真实 Pinia store(测试 store 逻辑本身) |
---
## 六、每个批次的测试验证流程
```
编码完成
cargo test -p df-ai(对应模块单元测试全绿)
cargo test -p df-storage(迁移+Repo 测试全绿)
cargo test -p df-ai --test git_worktree(worktree 集成测试全绿)
cargo check --workspace(编译无警告)
npx vue-tsc --noEmit(前端类型检查)
npx vite build(前端构建)
手动 E2E 检查清单(对照测试用例文档逐项验证)
SQL 查询验证(DB 数据正确性)
提交(代码+测试同一提交)
```

View File

@@ -1,6 +1,6 @@
# 消息拆分存储设计
> 创建:2026-06-19 | 编号:F-260619-03(消息拆分) | 状态:📐 设计待实施
> 创建:2026-06-19 | 编号:F-260619-03(消息拆分) | 状态:✅ **已落地**(2026-06-28 核验:ai_messages 表 + V21 全量迁移 + 读写全部切到消息表,旧 JSON 列仅作老库兼容回退)
> 关联任务:`消息拆分存储:ai_messages 表 + 全量迁移 + 三阶段渐进切换`(DevFlow 项目)
> 关联灵感:b2e61a21 消息拆分存储 + 消息级溯源 + 知识脉络网络
> 关联设计:[消息级溯源设计-2026-06-19.md](./消息级溯源设计-2026-06-19.md)(本设计的前置依赖 + 消费方)

View File

@@ -1,6 +1,6 @@
# 消息级溯源设计
> 创建:2026-06-19 | 编号:F-260619-04(消息级溯源) | 状态:📐 设计待实施
> 创建:2026-06-19 | 编号:F-260619-04(消息级溯源) | 状态:✅ **已落地**(2026-06-28 核验:ChatMessage.id + source_ref/audit/idea 四场景全部完成 + P2 切读全部完成)
> 关联任务:`消息级溯源:知识库/审计/灵感全链路从对话级升级到消息级`(DevFlow 项目)
> 关联灵感:b2e61a21 消息拆分存储 + 消息级溯源 + 知识脉络网络
> 关联设计:[消息拆分存储设计-2026-06-19.md](./消息拆分存储设计-2026-06-19.md)(本设计的持久化基础)

View File

@@ -1,6 +1,6 @@
# 规格契约自检机制
> 创建2026-06-13 | 阶段:设计定稿,待落地
> 创建2026-06-13 | 阶段:🗄️ 归档不实施(2026-06-28 核验:0 行代码。其核心价值「规格与实现一致性检查」已被 L1 求助协议 + ai_self_review gate 覆盖,过度设计)
> 性质:设计说明 + 可执行规格基准。后续 `spec-verifier` agent、`/spec-check` skill、自检 hook 均从本文档推导。
---

View File

@@ -0,0 +1,132 @@
# AI Native 方向与路线图 — 2026-06-29
> 性质:方向定义 / 优先级矩阵 / 推进路线图
> 关联: [Agent架构说明-2026-06-14.md](../专项设计/Agent架构说明-2026-06-14.md)(当前 Agent 能力边界)
> 关联: [三层模型-流程模板与人设体系-2026-06-28.md](../专项设计/三层模型-流程模板与人设体系-2026-06-28.md)(模板/工作流/人设三层抽象)
> 关联: [架构债迁移设计-2026-06-29.md](../专项设计/架构债迁移设计-2026-06-29.md)#8/#15/#18/#19 迁移路径)
> 关联: [产品定位调整-2026-06-12.md](./产品定位调整-2026-06-12.md)(「想法到创作」产品定位)
> 用途:作为后续所有功能/架构决策的优先级参考
---
## 一、AI Native 与传统范式的区别
DevFlow 的定位是 **AI 原生**——AI 不是辅助工具,而是系统的主要操作者。人的角色从「操作者」转变为「决策者 + 政策制定者」。
```
传统 DevOps 流程:
需求 → 设计 → 编码 → PR → 审查 → CI → 部署
↑ ↑ ↑ ↑ ↑ ↑ ↑
人 人 AI 人 人 自动 人
辅助写 创建PR 审查
AI Native 流程:
想法 → AI 分析 → AI 设计 → AI 编码并行 → AI 自审 → AI 测试 → AI 发布 ← 人类审批
↑ ↑ ↑ ↑ ↑ ↑ ↑
人类 AI驱动 AI驱动 多Agent分工 AI审计 AI验证 自动编排
输入
```
**关键变化**
| 维度 | 传统 | AI Native |
|------|------|-----------|
| 人类角色 | 执行者(写代码、建 PR、跑测试 | 决策者(立项、审批、定策略) |
| AI 角色 | 辅助(补全、建议、生成片段) | 执行者(分析、设计、编码、测试、发布) |
| 流程驱动 | 人类手工推进(点按钮、改状态) | AI 自动推进(工作流 + 事件驱动) |
| 质量保障 | 人工审查 + CI 门禁 | AI 自审 + 人工审批 + 质量门禁 |
| 产出物 | 代码 PR | 可发布的全流程资产(代码 + 测试 + 文档 + 变更日志) |
---
## 二、优先级矩阵
按「奠定 AI Native 基础」的依赖关系排序。P0 是 P1 的前提P1 是 P2 的前提。
```
P0 ─── 人设系统 ─── 多 Agent 协作
(角色划分) (分工执行)
P1 ─── 模板系统 ─── Git/CI 集成
(流程预设) (现实接轨)
P2 ─── 审批政策 ─── 算法验证
(规则配置) (特定场景)
```
### P0智能体人设 + 多 Agent 协作
这两项构成 AI Native 的基础——没有角色划分就谈不上分工,没有分工就谈不上协作。
| 方向 | 内容 | 代码落点 | 前置依赖 |
|------|------|---------|---------|
| **人设系统** | AgentPersona 结构体 + PersonaRegistry + 内置 5 人设coder/reviewer/architect/tester/analyst | `crates/df-ai/src/persona.rs`(新建) | `df-ai` crate 已有 |
| **人设注入** | AINode 执行时从 NodeContext 读 persona_id拼接 system_prompt + 过滤工具集 | `crates/df-nodes/src/ai_node.rs` + `crates/df-workflow/src/node.rs` | 人设系统就绪 |
| **Coordinator 填实** | 拆解任务 → 分配人设 → 并行 Agent → 汇总合并 | `crates/df-ai/src/coordinator.rs` | 人设系统 + SubflowNode |
| **Planner 接入 Loop** | `intent/plan_hint/planner` 三个纯函数模块接入主 loop | `src-tauri/src/commands/ai/agentic/mod.rs` | Coordinator 就绪 |
**验证标准**:单次 AI Chat 能调用多个子 Agent 并行工作(如同时生成三个模块的代码),最后合并为一个完整的 PR。
### P1模板系统 + Git/CI 集成
AI 产出的成果需要能推送到现实协作流程Git PR、CI Pipeline
| 方向 | 内容 | 代码落点 | 前置依赖 |
|------|------|---------|---------|
| **模板加载器** | YAML 模板 → DagDef 实例化 | `crates/df-workflow` 新增 | SubflowNode |
| **SubflowNode** | 嵌套子工作流节点 | `crates/df-nodes/src/subflow_node.rs` | 模板系统 |
| **GitNode** | 分支创建/PR/合并操作 | `crates/df-nodes/src/git_node.rs` | libgit2 就绪 |
| **DockerNode** | 容器内构建/测试 | `crates/df-nodes/src/docker_node.rs` | workpod API |
| **Config 统一** | AppConfig struct + env override | `df-types/src/config.rs` + AppState | 本迭代推进 |
**验证标准**:从 YAML 模板加载工作流 → 创建 Git 分支 → 生成代码 → 提交 PR → 跑 CI。
### P2审批政策 + 算法验证
当 AI 成为主要执行者后,「什么时候需要人批」「算法怎么验证」需要系统化的可配置机制。
| 方向 | 内容 | 代码落点 | 前置依赖 |
|------|------|---------|---------|
| **审批政策配置** | 按人设/节点/风险级别定制审批策略 | `HumanNode` + 配置层 | 人设系统就绪 |
| **算法验证循环** | 基准测试 → 对比 → 迭代优化 → 早停 | 新建 `df-algo` crate | 模板系统 + 人设 |
---
## 三、对既有工作的对齐
| 已有资产 | 与本路线图的关系 |
|---------|----------------|
| `intent.rs` / `planner.rs` / `plan_hint.rs` | P0 的预制件——三个纯函数模块算法已完备,只差接入主 loop |
| `coordinator.rs` | P0 的关键缺项——当前空壳,需要填实为真正的 Agent 调度器 |
| 三层模型设计文档 | P1 的架构设计——模板/工作流/人设三层抽象已设计完成,待编码 |
| 走查报告 | 安全/架构债在 P0-P2 推进中同步修复 |
| 架构债迁移设计 | #15 AI 状态机抽离在 P0+P1 完成后自然推进 |
---
## 四、不做的方向
以下方向经过评估后,**当前阶段不投入**
| 方向 | 放弃理由 | 可能的时机 |
|------|---------|-----------|
| 全自动无人值守 | 当前审批机制HumanNode是有意的设计取舍AI 最终决策需人类把关 | 长效目标,非当前阶段 |
| 第三方系统集成(云效/禅道) | 核心 Agent 能力未就绪前,集成的价值有限 | P2 完成后评估 |
| MCP 外部工具接入 | 工具集封闭是安全取舍,开放后将引入新的攻击面 | Phase 5 或之后 |
| Web/移动端 | AI 执行逻辑焊死在桌面端(#15)解决之前,跨端无意义 | #15 完成后 |
---
## 五、下一步行动
**当前建议方向**(按此顺序落地):
```
本周 → 人设系统Persona 数据结构 + 5 内置人设 + 注册表)
下周 → Coordinator 填实 + Planner 接入(多 Agent 骨架)
下月 → 节点补齐Subflow/Docker/Git+ 模板加载器 + YAML 模板
```
每步完成即可独立验证,不必等全部做齐再交付。

View File

@@ -108,27 +108,19 @@ commands/ai/
---
## 四、12 个 AI 工具
## 四、AI 工具清单
### 工具清单
### 工具注册
工具定义在 `tool_registry.rs::build_ai_tool_registry`,编译期硬编码(无运行时动态注册)。
工具定义在 `tool_registry.rs::build_ai_tool_registry`**编译期硬编码**(无运行时动态注册)。工具统计截至 2026-06-29后续增删见该函数 `registry.register` 调用点)。
| 风险 | 工具 | 说明 |
|------|------|------|
| **Low**(自动执行) | `list_projects` | 列出项目(排软删,截断 50 条) |
| **Low** | `list_tasks` | 列出任务(可按 project_id 筛选) |
| **Low** | `list_ideas` | 列出灵感 |
| **Low** | `read_file` | 读文件offset/limit 分页1MB 上限) |
| **Low** | `list_directory` | 列目录(噪音剪枝 + 1000 条上限) |
| **Medium**(需审批) | `create_project` | 创建项目(可选 path/stack 一步绑定) |
| **Medium** | `update_project` | 改字段(复用 CRUD 白名单校验) |
| **Medium** | `create_task` | 创建任务 |
| **Medium** | `create_idea` | 创建灵感 |
| **Medium** | `write_file` | 写文件自动建父目录1MB 上限) |
| **Medium** | `bind_directory` | 项目绑定代码目录 + 探测技术栈 |
| **High**(需审批) | `delete_project` | 删项目(软删进回收站) |
| **High** | `run_workflow` | 运行工作流(当前返回提示,未实装) |
### 按风险等级分组
**Low自动执行** — 只读查询类,不触发审批。包含:`list_projects`/`get_project_count``list_tasks`/`get_task_count``list_task_links`/`get_task_tree``list_ideas``list_trash``read_file``read_symbol`(AST符号解析)、`list_directory``file_info``grep`(跨文件内容搜索)、`search_files``list_project_services``list_project_modules``get_project_timeline``git_status`/`git_diff`/`git_log`(只读)。
**Medium需审批** — 写入类,需人工批准。包含:`create_project`/`update_project`/`bind_directory``create_task`/`update_task`/`delete_task``advance_task`/`move_task_queue`/`update_content``create_task_link`/`remove_task_link``create_idea``add_project_service``write_file`/`patch_file`/`append_file``git_commit`/`git_branch``http_request`(SSRF防护含DNS rebinding检查)。
**High需审批 + 默认谨慎)** — 高风险,默认自动执行模式会全部拒绝。包含:`delete_project`/`restore_project`/`purge_project``delete_file`/`rename_file``run_command`(默认超时 60s)、`run_workflow`(联动任务推进)、`git_merge`(冲突返回冲突文件列表)。
### 工具三要素同源
@@ -145,9 +137,9 @@ commands/ai/
### 已知限制
- **截断 50 条无翻页**:三个 list 工具 `truncate(50)` 硬截断,无 offset/limitAI 不知道有数据被漏掉(待改进)
- **"能写不能跑"**AI 有 `write_file` 但无 `run_command`,无法形成"写→跑→改"闭环(待改进)
- **工具集封闭**:编译期硬编码,无 MCP 客户端AI 运行时不能新增/修改工具
- **AI 不能造工具**AI 能 `write_file` 写脚本但不会变成可调用工具(要重编译)
- **agent ↔ workflow 仍未完全打通**`run_workflow` 现已能联动任务推进,但 ScriptNode 能力未暴露给 agent loop
---

View File

@@ -0,0 +1,192 @@
# AI Chat 真实会话实测分析(系统消息污染 + 死循环重调)
> 日期:2026-07-07 | 方法:从生产 DB(`%APPDATA%/top.1216.devflow/devflow.db`)实测最近两个会话全量消息反推,Python 只读统计
> 基线会话:`9357c27c`(HaoGamePlatfPro,prompt 644,586 / completion 3,865)+ `43b75e49`(wk-hszd,prompt 601,028 / completion 4,134)
> 产物:**2 P0 / 3 P1 / 2 P2**
> 性质:**实测数据反推**(非静态代码审查)。finding 的源码根因为"假设",须独立 grep/read 核验后再定论
> 关联:[[aichat-session-analysis-2026-06-22]] [[aichat-techdebt-audit-2026-06-21]] [[devflow-generating-statemachine]] [[devflow-compress-ui-no-fold]]
---
## 会话概况
| 项 | 9357c27c | 43b75e49 |
|---|---|---|
| 标题 | HaoGamePlatfPro | 查看 E 盘 wkhszd 目录内容 |
| 跨度 | 2026-06-30 14:31 ~ 15:04(~33 分钟) | 2026-07-01 ~ 07-03(中间大间隔) |
| 消息数 | 97 | 77 |
| prompt / completion tokens | 644,586 / 3,865 | 601,028 / 4,134 |
| completion/prompt 比 | **0.60%** | **0.69%** |
| 工具调用次数 | list_directory 22 / read_file 22 / search_files 13 / list_projects 2 / update_project 1 / get_task_count 1 | list_directory 16 / read_file 17 / list_projects 2 / list_project_modules 3 / bind_directory 3 / create_project 1 / update_project 3 |
| 工具结果总量 | 195.4 KB | 62.3 KB |
| 用户输入字数 | **34 字**(2 条) | 51 字(5 条) |
| 用户催促 | 0 | 1(核对) |
| 末尾收尾 | assistant(seq96,正常) | assistant(seq76,正常) |
| task | 浏览 HaoGamePlatfProject 代码补充项目信息 | 把 wk-hszd 注册为 DevFlow 多工程项目 |
两会话共同特征:**极少用户输入(34~51 字)产生 60 万级 prompt**,completion/prompt 比 < 1%,极端输入重。
---
## P0(2 项)
### [P0-1] seq0 system 消息污染:assistant 思考流被截断重复拼接 41 次 + role 错位 + 时序倒挂
- **现象**(9357c27c seq0):
- `role=system`,但内容是 assistant 语义:`"我已经充分了解了项目结构。让我快速查看几个关键文件..."`
- 同一短语 `"现在更新项目信息:基于代码分析,这是一个大型综合游戏平台后端。让我更新项目描述和技术栈。"` **重复 41 次**,拼成 1879 字符
- seq0 timestamp `1782971672713` 比 seq1 `1782971504351` **晚 168 秒**(时序倒挂)
- **db 证据**:`ai_messages` seq0 row,role=system,status=active,content 长 1879
- **根因假设**(强):系统 prompt 注入路径把"上一轮 assistant 流式片段"误当 system 上下文写入;且写入库时陷入重复拼接循环(疑似字符串 builder 在流式 chunk 累积时无去重/无终止条件)。这是 [[aichat-session-analysis-2026-06-22]] P2-1 的**复发且恶化**——上次只见 1 条 assistant 语义残留,本次是 41 次死循环拼接。
- **影响**:
1. system 消息会作为 prompt 头部送入 LLM,1879 字符无意义噪声污染每一轮上下文
2. role 错位会误导模型(system 应是约束指令,却变成"我说的"内容)
3. 时序倒挂破坏压缩/排序/计费
- **待核验源码点**:
- system prompt 注入路径(`ai_messages` 写入 system 行的位置,`src-tauri/src/commands/ai/`)
- 流式 chunk 累积逻辑(查 `for` / `push_str` / `+=` 类字符串拼接,是否有循环退出条件)
- timestamp 赋值逻辑(为何 system 行用了"插入时刻"而非"会话起始时刻")
- **关联**:印证 [[aichat-techdebt-audit-2026-06-21]] P2 区"system 消息混入 assistant 内容"项;与 [[aichat-session-analysis-2026-06-22]] P2-1 同源(前次未根治)
### [P0-2] 工具死循环重调:LLM 无"已调用过"记忆,同参同路径重复触发
- **现象**(9357c27c):
- `read_file(application.xml)` **读了 17 次**,其中两次返回完全相同的 8457c 全文(同一文件无变化)
- `search_files(pattern='.java', path=...src)` **连调 13 次**,每次返回结构几乎一致(items=0, has_more=True,5.5~5.8KB),LLM 完全没意识到在重复
- `list_directory(...src)` 2 次,第 2 次返回 30,516c(第 1 次 6,144c,深度参数不一致但 LLM 未说明理由)
- **db 证据**:13 条 search_files 调用 `arguments` 完全相同,`result` 长度方差 < 5%
- **根因假设**:
1. **无工具结果缓存层**:会话级无"同参工具调用 → 返回缓存命中提示"机制,LLM 每次发起都真执行
2. **prompt 未告知 LLM 已读内容**:read_file 返回的文件指纹/路径未在后续 prompt 中显式标注"已读",LLM 失忆反复读
3. **search_files has_more=True 的分页语义被 LLM 误解**:可能 LLM 以为重调能翻页,但实际每次返第一页
- **影响**:
- **prompt 64 万的直接元凶之一**:13 次 search × ~5.7KB + 17 次 read × 平均 2.7KB ≈ **75KB 仅重复工具结果**(占 prompt 字符量的主要部分)
- 死循环消耗 30+ 轮 LLM 调用,用户等待感极差
- **待核验源码点**:
- 工具调度层(`tool_registry.rs` / `agentic/mod.rs`)——是否有 `seen_tool_calls` 去重
- read_file 是否返回 file_hash 供 LLM 引用(对照 [[aichat-techdebt-audit-2026-06-21]] P1「expected_hash schema 契约破裂」)
- search_files 分页契约(为何 has_more=True 但无 page/cursor 参数)
- **关联**:印证 [[aichat-session-analysis-2026-06-22]] P2-2(同文件反复 read)+ 新增 search_files 死循环变体
---
## P1(3 项)
### [P1-1] ~~压缩实质失效:compressed 消息平均长度比 active 还长(205%)~~ 【误报,2026-07-07 核验撤销】
- **原现象**(9357c27c):58/97 条 status=compressed,但 compressed 平均 2641c/条,active 平均 1287c/条——压缩后比未压缩还长 2 倍。
- **核验撤销**:进一步按 role 分组统计发现,compressed tool 消息长(4608c)是因为会话前期是大工具调用(list_directory/read_file 大输出),active tool 消息短(1714c)是因为后期转为小操作(update_project)。这是会话业务节奏的自然结果,不是压缩机制问题。
- **prompt_tokens 语义澄清**:`prompt_tokens=644,586` 是**会话级累积值**(每轮 prompt 累加,见 `agentic/mod.rs:1366 tokens.add()`),不是最后一轮值。97 条消息 × 平均 6645 tokens/轮 ≈ 644K,完全合理。每轮实际 prompt 只 ~6.6K(active content 49KB ≈ 13K token,压缩后只进 active)。
- **结论**:06-22 报告 P0-1 修复(is_active 白名单让 compressed 不进 prompt)**生效**。本项撤销,无需修复。
### [P1-2] LLM 自检缺失 + 宣称完成即停,用户被迫人工纠错
- **现象**(43b75e49 wk-hszd):
- seq71 assistant 自检发现 bug:`path` 被最后一次 `bind_directory` 覆盖成 `hszd-test`,`stack` 变成 `["Go"]`
- 但这是 assistant **自己**在 seq74"再次核对"时才发现——说明 seq68 用户催"核对"前,assistant 已认为完成
- 3 次 `bind_directory` 把 path 从 `hszd-api``hszd-admin``hszd-test`,每次都覆盖前值,assistant 没意识到多工程 monorepo 的 path 字段不该是单子工程
- **db 证据**:bind_directory 3 次调用 args 显示 path 被依次覆盖,最终 assistant 自己回滚用 `update_project(path='E:/wk-hszd')` 修正
- **根因假设**:
1. agent 无"宣称完成前的强制自检"(对照 [[aichat-session-analysis-2026-06-22]] P1-1)
2. `bind_directory``path` 字段语义对多工程 monorepo 有歧义(LLM 以为是"当前操作的子工程",实际是"项目根")
- **影响**:用户对 agent 完成度零信任,需人工核对每个字段
- **待核验/落点**:
- agent 行为策略(prompt 加自检 checklist)
- `bind_directory` schema 注释补"monorepo 应用项目根路径,非子工程"
- **关联**:[[aichat-decision-capability]] [[aichat-b-route-parallel-multiround]]
### [P1-3] 工具拒绝消息破坏 JSON 协议:raw 文本"用户拒绝了此操作"
- **现象**(9357c27c seq95):`update_project` 被用户拒绝,tool role 消息 content = `'用户拒绝了此操作'`(8 字符裸文本),非 JSON
- **db 证据**:61 条 tool 消息中 60 条 JSON-parseable,1 条 raw-text(就是这条拒绝)
- **根因假设**:工具拒绝路径(`ai_reject` / 审批拒绝)走的是错误字符串直接返回,未包装成 `{"status":"rejected","reason":"..."}` 结构
- **影响**:
- 若 LLM provider 严格解析 tool role 内容(部分 provider 要求 JSON),会触发协议错误
- 与 [[aichat-techdebt-audit-2026-06-21]] P1「审批状态字符串双轨(executed/completed)」同家族——工具返回协议不统一
- **待核验源码点**:审批拒绝 handler(`commands.rs` 拒绝分支)的返回序列化
---
## P2(2 项)
### [P2-1] system 消息 timestamp 用"插入时刻"而非"会话时刻",时序倒挂
- **现象**(9357c27c):seq0 system ts=1782971672713,seq1 user ts=1782971504351,seq0 比 seq1 晚 168 秒
- **根因假设**:system prompt 注入是在会话进行中"补写"的(可能是压缩/续轮时插入),用了补写时刻
- **影响**:排序、压缩窗口计算、计费全受污染
- **关联**:与 P0-1 同源(都是 seq0 system 行的写入 bug)
### [P2-2] bind_directory 多工程语义错配:单值 path 字段无法表达 monorepo
- **现象**(43b75e49):wk-hszd 是 Go+Vue+Test 三子工程 monorepo,assistant 3 次 bind_directory 分别绑三个子工程路径,前两个被第三个覆盖
- **根因假设**:`projects.path` 是单值字段,而 DevFlow 的"多工程"概念(`project_modules` 表)与"项目根 path"语义未在 schema 注释中澄清
- **影响**:多工程项目的 path 字段长期处于"最后绑的子工程"错误状态,直到 LLM 自检或人工纠正
- **待核验/落点**:`bind_directory` IPC 的 schema 文档 + 多工程 monorepo 的 path 字段规范(可能应允许 null 或 readonly,工程信息走 `project_modules`)
---
## 落点建议(未实施,遵守 [[session-role-diagnose-only]])
| 项 | 优先级 | 落点 |
|---|---|---|
| P0-1 system 消息污染 + 重复拼接 | P0 | system prompt 注入路径 + 流式 chunk 累积循环(查 push_str/+= 类拼接) |
| P0-2 工具死循环重调 | P0 | 工具结果缓存层(会话级 seen_tool_calls)+ read_file 返回 file_hash + search_files 分页契约 |
| P1-1 压缩产出反向膨胀 | P0/P1 | compress_old_messages 产出从拼接改真摘要 |
| P1-2 自检 + bind_directory 语义 | P1 | prompt 自检 checklist + bind_directory schema 注释 |
| P1-3 拒绝消息 JSON 协议 | P1 | 审批拒绝 handler 返回结构化 JSON |
| P2-1 timestamp 时序 | P2 | system 行 timestamp 用会话起始时刻 |
| P2-2 多工程 path 语义 | P2 | bind_directory/path schema 文档 |
**最高杠杆**:P0-1(系统消息污染直接污染每一轮 prompt)+ P0-2(工具死循环是 prompt 64 万的主因)。两者叠加,即使只修这两项,类似会话的 prompt 可降一个量级。
---
## 2026-07-07 修复落地(本次实施)
| 项 | 位置 | 修复 |
|---|---|---|
| P0-1 LLM 退化输出污染 system 消息 | `commands/ai/compress.rs` | `clean_summary``is_degenerated_repetition` 退化检测,同一片段重复 ≥5 次返空 → 调用方据空值降级走关键词兑底 |
| P0-2 工具死循环重调 | `commands/ai/audit/cache.rs` + `audit/mod.rs` | 新增 `find_cached_readonly_result`(只读幂等工具白名单:read_file/read_symbol/list_directory/search_files/grep),Low 风险执行前查会话历史,同参同成功(completed)调用直接回填缓存跳过真执行 |
| P1-3 拒绝消息破 JSON 协议 | `commands/ai/commands/chat.rs` + `audit/mod.rs` | 三处拒绝消息(用户拒绝此操作/路径授权拒绝/路径黑名单拒绝)改结构化 JSON `{status,reason,message}` |
| **额外**:Windows 上 .ssh/.aws 黑名单绕过 | `state/allowed_dirs.rs` | `check_path_authorization` 黑名单检查提前到路径规范化之前(对原始路径也判一次)。根因:Windows 上 `/home/user/.ssh/config``is_absolute()` 返 false,persistent 为空时旧逻辑直接返 NeedsAuthorization 跳过黑名单 |
| P1-2 bind_directory 多工程语义错配 | `commands/ai/tool_registry.rs` | 工具描述补充 monorepo 语义:path 应为项目根路径(非子工程),多次调用会覆盖,子工程信息走 list_project_modules |
| ~~P1-1 压缩产出反向膨胀~~ | — | **误报撤销**:prompt_tokens 是会话级累积值(非单轮值),按 role 分组统计后压缩机制正常 |
**测试**:`cargo test -p devflow --lib` 通过(compress 11 + audit 19 + allowed_dirs 20,含新增 6 个回归测试)。原 `test_grep_blacklisted_path_denied`(此前失败)现已通过。
---
## 复现方法
```bash
# 只读查询生产 DB
python -c "
import sqlite3, os
db = os.path.expandvars(r'%APPDATA%/top.1216.devflow/devflow.db')
con = sqlite3.connect(f'file:{db}?mode=ro', uri=True)
# seq0 system 污染证据
print(con.execute('select seq, role, length(content), timestamp from ai_messages where conversation_id=? and seq<2 order by seq',
('9357c27c-aeba-488f-9f4f-12c2d0506f63',)).fetchall())
# search_files 13 次重复
print(con.execute('select count(*), min(length(result)), max(length(result)) from ai_tool_executions where conversation_id=? and tool_name=?',
('9357c27c-aeba-488f-9f4f-12c2d0506f63','search_files')).fetchall())
"
# 表:ai_conversations / ai_messages / ai_tool_executions
```
数据快照:2026-07-07 抓取自 `devflow.db`(生产库,非 dev)。
---
## 与 06-22 报告的对比
| 项 | 06-22 报告 | 07-07 实测 | 趋势 |
|---|---|---|---|
| 工具返回裸文本(raw) | patch_file/read_file 大量 raw(6204c) | 仅 1 条(拒绝消息) | ✅ **已修复**(P0-2 性质修正有效) |
| 重复 tool 结果落库 | seq 11/12/13/14 同结果 4 份 | 0 条 tool_call_id 重复 | ✅ **已修复**(P0-3 撤销成立) |
| system 消息混 assistant 语义 | seq0 残留 1 句 | seq0 重复 41 次拼接 | ❌ **恶化**(P2-1 → P0-1) |
| 同文件反复 read | mysql_guide read 4× | application.xml read 17× | ❌ **恶化**(P2-2 → P0-2) |
| 上下文管理实质失效 | compressed 未降 prompt(已隐式修复) | compressed 产出反向膨胀 205% | ⚠️ **新变体**(P0-1 → P1-1) |
| 中断(tool 后无 assistant) | seq85 停尸 | 无(两会话均 assistant 收尾) | ✅ **已修复** |
**结论**:06-22 后修复的"工具返回协议/落库去重/中断"三项**确实生效**;但"system 污染"和"工具死循环"两类问题**反而恶化**——前者从残留 1 句变成 41 次拼接,后者从 4× 变成 17×。说明这两类是未根治的根因,须单独立项。

View File

@@ -0,0 +1,144 @@
# aichat 对话功能深度走查报告2026-07-17
> 触发:用户反馈「对话功能现在存在很多问题,深入检查」。
> 方法5 个 general-purpose 代理并行走查 5 维度(发送/流式/渲染/会话侧栏顶栏/审批上下文)→ 主代理独立 grep/read 核验所有 P0 + 关键 P1不信代理结论每条标注核验状态
> 性质:**走查任务,未改任何代码**。本报告为问题清单 + 修复方向,待用户确认后实施。
## 健康度总评B-
最近一批发改动BUG-2026-07-17状态机收敛方向正确引入 **2 个 P0 回归**;其余 P1 多源自 **F-09 per-conv 改造未完成**的单例债queue / legacy watchdog / _approvalTimers / modelOverride / _lastDelta 仍是全局单例,多会话并发下串话)。
---
## P0功能 bug建议立即修
### P0-1 流式 watchdog legacy 路径全局误杀并发会话 + 删 running 保护致长工具误杀 ✅核验成立
**位置**`useAiStream.ts:57-110, 73-78` / `useAiSend.ts:156,226`doSend reset 同款)/ `audit/mod.rs:125-128`
**双重回归**(本次改动引入):
**legacy 路径全局 clear**doSend / regenerate(`useAiSend.ts:156`) / editMessage(`:226`) 调 `resetStreamWatchdog()` **无 convId** → 走 `_legacyWatchdog` fallback`useAiStream.ts:128-132`。45s 到期 `onStreamTimeout()` 无参 → `convStates.clear()``:77`)清掉**所有并发会话**的生成态。其他会话停止按钮失效、UI 与后端长期不一致。
**删 running 保护**`onStreamTimeout``:69`「不管 stillGenerating 检测」)去掉了原 stillGenerating + running 工具检测。长工具执行cargo build / 测试套件 / 大文件 patch >45s必然触发。后端心跳首 tick **30s**`audit/mod.rs:126` 弃首 tickAiHeartbeat 到达时前端 `resetStreamWatchdog(convId)` 只动 per-conv Map、**不清 legacy**,保护不了 doSend 挂的 legacy timer。
**证据**`useAiStream.ts:69`「不管 stillGenerating 检测(即使已经 false 也再清理一次)」+ `:77` `convStates.clear()` + `audit/mod.rs:125-128` 心跳 30s。
**影响**:多会话并发下其他会话被误杀 + 任何 >45s 的工具执行被前端误判断流(回到 BUG-260624-03 修复前的用户报障状态)。
**修复方向**
- legacy 路径 `convStates.clear()``convStates.delete(state.activeConversationId)`(对齐 legacy 单 timer 只跟踪当前会话语义)
- 恢复 `onStreamTimeout` 的 running 工具检测(工具执行中跳过 + reset 续等),或 `STREAM_TIMEOUT_MS` 回调 ≥75s
- 根治doSend / regenerate / editMessage 的 `resetStreamWatchdog` 传 convId 走 per-convF-09 迁移)
### P0-2 审批计时器切/删会话不清 + 注释与代码矛盾 ✅核验成立
**位置**`useAiConversations.ts:92-257`switch 无 clearApprovalTimer/ `:260-272`delete 无清)/ `aiShared.ts:118`(默认 15minvs `useAiConversations.ts:244-246`注释「Infinity 已决策不超时」)
**证据**
- switchConversation 全程未调 clearApprovalTimer / clearAllApprovalTimers已核 90-257
- deleteConversation 仅 `clearConvStreamState(id)`,不清 `_approvalTimers` 中属于该会话的 timer
- `startApprovalTimer` 到点回调(`aiShared.ts:147-163`)调 `aiApi.approve(id,false)` + push 错误气泡到 `getMessages()`**当前 active 视图**
- `:244-246` 注释「APPROVAL_TIMEOUT_MS=Infinity 已决策,不会超时自动拒绝」与 `:118` `APPROVAL_TIMEOUT_DEFAULT_MS=900_000`15min**直接矛盾**
**影响**:① 切走会话后 15min 到点 → 误拒后台 pending 工具 + 错误气泡 push 到当前(错误)会话视图;② 用户重启恢复审批后离开,回来发现审批已被无声自动拒绝。
**修复方向**switchConversation 开头 `clearAllApprovalTimers()`(对齐 pendingApprovals 单例语义deleteConversation 清该 conv 的 timer修正 `:244` 注释。
---
## P1明显缺陷
### P1-1 [发送] queue=[] 跨会话丢消息 ✅核验成立
`useAiSend.ts:155,225` `state.queue = []`queue 是 store 全局单例。B 会话 regenerate/editMessage 清掉 A 排队的用户消息,静默永久丢失。
### P1-2 [发送] drainQueue 无并发互斥 ✅核验成立
`useAiSend.ts:257-281` fire-and-forget 无锁AiCompleted 连发 / stop+Completed 竞态致并发 doSend前端 user/ai 占位双 push后端 generating 守卫挡大部分但视图已污染)。
### P1-3 [会话] TopBar watch(activeConversationId) 无条件重置 modelOverride ✅核验成立
`TopBar.vue:325-327` 切会话无条件 `modelOverride = enabledModels[0]`,切会话往返丢失用户手选模型;与 `:335` `watch(enabledModels,{immediate})` 叠加语义脆弱。modelOverride 是模块级 ref不持久化、不 per-conv。
### P1-4 [发送] tryForceSend 跨会话错位 + 队列续发 spans 丢失 ✅核验成立
`useAiSend.ts:323-328` queue item 无 convId / spans 字段,切会话后 forceSend 发到当前 active 而非原会话mention chip 续发降级为纯文本。
### P1-5 [流式] _lastDelta 单值跨会话/跨轮误丢合法 delta ⚠️部分成立
`useAiEvents.ts:46,292` 单值重复检测,跨会话首 delta 撞同 / 同轮合法重复Markdown 表格分隔符等)被误丢。难复现。
### P1-6 [流式] state.currentText += delta 无 streaming 守卫 ⚠️部分成立
`useAiEvents.ts:300`P0-1 触发后超时收尾仍到达的 delta 污染 currentText。修 P0-1 即间接修复。
### P1-7 [渲染] MessageItem.vue system/assistant/error 死代码 ✅核验成立
`MessageItem.vue:94-170`MessageList 仅 `role==='user'` 引用 MessageItem`:663-673`AI 渲染全在 MessageList 内联。MessageItem 内 `.ai-cursor-blink` 是孤岛 class全仓库无其他定义。维护陷阱。
### P1-8 [渲染] _blockCache 无失效机制 ✅核验成立
`useStreamRenderer.ts:117` 模块级 Map 无会话切换 / 配置变更失效,长会话膨胀 + Markdown 主题热改无效 + 瞬态错误固化。
### P1-9 [渲染] messages deep watch + JSON.stringify 每帧全量遍历 ✅核验成立
`MessageList.vue:244` deep watch 每帧 O(N×content) 遍历,长会话流式掉帧。
### P1-10 [渲染] useToolApproval onApprove confirmDialog 无防重入 ✅核验成立
`useToolApproval.ts:98-113` High 风险工具 confirmDialog await 期间 approving 未置 true双击重复审批。
### P1-11 [会话] 删当前会话 active=null 无 fallback ✅核验成立
`useAiConversations.ts:260-272` 删除当前会话后空白无引导,应回落相邻会话。
### P1-12 [审批] 审批卡无 conversationId多会话路由靠 activeConversationId 推断 ⚠️部分成立
`useAiApproval.ts:65`F-09 多会话并发下 A 后台审批卡渲染到 B 视图时点击路由不准(依赖多会话并发 + 后台审批叠加才触发)。
---
## P2健壮性/可读性)
- `useStreamRenderer.ts:139` tailSeq 局部失效 —— **功能没坏**v-html 值变化兜底更新),仅注释「末块 key 每次不同」与实现(恒为 `tail-1`)相反,误导维护者。
- `ApprovalOverlay.vue:61` `Date.now()` 在 computed 内60s 冷却期不响应式过期。
- `aiShared.ts:288-311` setConvStreaming / setConvCurrentText 收敛不对称Map 项可能残留。
- `ConversationSidebar.vue:255` 标题 flash watch 死代码(`if(oldTitle) return` 覆盖前一行)。
- `useAiWindow.ts:309` maximized 分支 `targetHeight` 三元死代码(高度 vs 宽度比较)。
- `useToolApproval.ts:29` `emit: any` 类型擦除。
- `useAiContext.ts:29` `isCompressing` 模块单例,多窗口共享。
---
## ❌代理误报已否决(独立核验)
- **~~[会话 P0] switchConversation currentText accessor 重定向~~**代理4 称 `:206 state.currentText=''` 在两次 await 间被 accessor 重定向。**否决**——`:113``activeConversationId=B` 后到 `:206` 是同步连续代码(无 await`:206` 之后才 `:212 await`。accessor 不会中途重定向写的是正确的目标会话。代理4 时序分析错误。
---
## 根源分析
所有 P0/P1 的共同根因:**F-09 per-conv 改造未完成**。
| 状态源 | per-conv 化状态 | 串话表现 |
|---|---|---|
| streaming / currentText | ✅ 已 per-convDEC-07a accessor | — |
| convStatesconv_state 真相源) | ✅ 已 per-convMap | 但 watchdog legacy fallback 路径 `clear()` 全局误杀P0-1 |
| queue | ❌ 全局单例 | 跨会话丢消息P1-1、强发错位P1-4 |
| watchdog | ⚠️ 双轨per-conv Map + legacy 单 timer | legacy 路径不清 by 心跳、全局 clearP0-1 |
| _approvalTimers | ❌ 全局 Map | 切/删会话不清P0-2 |
| modelOverride | ❌ 模块级 ref | 切会话丢失手选P1-3 |
| _lastDelta | ❌ 模块级单值 | 跨会话误丢 deltaP1-5 |
---
## 建议修复优先级
1. **P0-1**(流式误杀,最高频可复现)—— legacy `clear()``delete(activeConversationId)` + 恢复 running 工具检测
2. **P0-2**(审批误拒 + 错气泡)—— switch / delete 清 timer + 修正注释
3. **P1-1 / P1-3 / P1-4**(单例债三连,可攒批修)
4. 其余 P1渲染性能/死代码/防重入)按维度推进
5. P2 择机清理
## 相关文件
- `src/composables/ai/useAiStream.ts`P0-1 核心)
- `src/composables/ai/useAiSend.ts`P0-1 reset / P1-1,2,4
- `src/composables/ai/streamingGuard.ts`P0-1 forceReset 同款 clear
- `src-tauri/src/commands/ai/audit/mod.rs`P0-1 后端心跳 30s
- `src/composables/ai/useAiConversations.ts`P0-2 / P1-11
- `src/composables/ai/aiShared.ts`P0-2 _approvalTimers / P2 收敛)
- `src/components/ai/TopBar.vue`P1-3 双 watch
- `src/components/ai/MessageList.vue`P1-7,9
- `src/components/ai/MessageItem.vue`P1-7 死代码)
- `src/composables/ai/useStreamRenderer.ts`P1-8 / P2 tailSeq
- `src/composables/ai/useToolApproval.ts`P1-10

View File

@@ -1,380 +1,312 @@
# 全量走查报告2026-06-28
> 范围:全栈 Rust 12 crate + Tauri 命令层 + Vue 3 前端 + 文档体系
> 方法:3 路并行Rust 后端/Vue 前端/文档体系),主代理综合整理
> 原则dry — 仅审查 + 文档,不改代码
> 方法:4 角度并行(安全/并发可靠性/架构设计/代码质量),每条结论基于实际代码核实
> 原则dry — 仅审查 + 文档,不改代码。误判项明确标注证据
---
## 🔴 安全
### ① relay.rs — 硬编码默认 Token
### ① ScriptNode — 白名单仅校验首词shell 元字符可绕过
**文件**: `crates/df-relay/src/relay.rs:36-37`
**文件**: `crates/df-nodes/src/script_node.rs:46-54`
中继服务使用硬编码 `DEFAULT_TOKEN = "devflow-relay-default-token"`,所有未设环境变量的部署均使用同一静态 token。通过 `strings` 即可从二进制提取。
```rust
let cmd_name = command.split_whitespace().next().unwrap_or("").to_lowercase();
```
**建议**:启动时要求必须设置 `DF_RELAY_TOKEN` 环境变量,移除默认值
`check_command_policy` 只比对首词,剩余参数原样传入 `sh -c` / `powershell -Command`。攻击场景:工作流 JSON 写 `"command": "git log; rm -rf /tmp/x; curl evil.com"`,首词 `git` 命中白名单即放行,`;` `&&` `$()` 全部被 shell 解释执行。默认 `DF_SCRIPT_WHITELIST` 未设置时完全无防护,仅靠 `dangerous_keywords` 告警(不阻止)
### ② models.rs — `AiProviderRecord` Debug 未脱敏 api_key日志泄漏风险
**建议**:① 默认启用黑名单(至少挡 `rm`/`del`/`format`/`shutdown`);② 对工作流 JSON 来源做信任链校验。
**文件**: `crates/df-storage/src/models.rs:288`
### ② MCP server 完全无鉴权
`AiProviderRecord` 未实现自定义 `Debug``api_key` 脱敏。若上层代码意外 `{:?}` 打印(如 `tracing::debug!`),明文 API key 会写入日志。`model_configs`L296`config`L298等 JSON 字段同样不安全。
**文件**: `crates/df-mcp/src/server.rs:37-44` + `crates/df-mcp/src/lib.rs:11`
**建议**:为 `AiProviderRecord` 实现自定义 `Debug`,将 `api_key` 输出为 `"sk-****"`
注释明示「无认证(依赖本地隔离)」,但 Windows 上同用户权限的进程Office 宏、浏览器 RCE 沙箱逃逸后)均可向 stdin 发 `tools/call create_project` / `bind_directory` 等工具调用。`bind_directory`(Medium) 可绑定任意路径让后续工具误以为已授权
### ③ ScriptNode — 命令无白名单/沙箱
**建议**:启动生成 ephemeral token 写入应用 KVTauri CLI 子命令传 token每条 `tools/call` 校验。
**文件**: `crates/df-nodes/src/script_node.rs:15-19` + `crates/df-execute/src/shell.rs:92-117`
### ③ `bind_directory` 路径检测不一致
ScriptNode 从 config 取 `command` 字符串透传给 shell`cmd /C` / `sh -c`),无任何命令白名单或参数校验。工作流定义来自 AI 生成或外部导入时,恶意命令可通过 AINode 写入 ScriptNode config 注入执行。
**文件**: `crates/df-mcp/src/tools.rs:328`
**建议**:添加危险命令关键词告警(`rm -rf`/`DROP TABLE`/`Format`),可选白名单/环境变量隔离配置
MCP 端 `bind_directory` 仅做 `path.contains("..")` 子串检测,会误伤 `my..file`,且对 `/etc/.ssh/../../etc/passwd` 这类绕过场景不可靠。而 `src-tauri/.../tool_registry.rs:114-145``validate_path` 已实现分段 `..` 检测——两端不一致
### ④ bind_directory — 原始路径入库未规范化 + 无沙箱校验
**文件**: `crates/df-mcp/src/tools.rs:314-342`
`bind_directory` 接收用户传入 `path``normalize_path` 仅用于去重检测,**原始 `path` 值直接存储**。同一目录可用两种写法绑定两次绕过冲突检测。且未校验规范化后路径在项目沙箱允许范围内。
**建议**:存储前 `normalize_path()` + 校验在沙箱目录内 + 拒绝含 `..` 的原始路径。
### ⑤ state.rs — `std::sync::Mutex` 在 async 上下文中持锁风险
**文件**: `crates/df-workflow/src/state.rs:34-36, 55, 97, 104`
`StateMachine` 使用 `std::sync::Mutex` 保护 HashMap`get()` 等同步方法中直接 `lock().expect()``DagExecutor``futures::future::join_all` 同层并发时,若某节点持有引用后 `.await` 另一操作间接尝试锁 `states`,会死锁。通篇 4 处 `expect`——某节点 panic 导致锁中毒,连锁 fatal。
**建议**:改用 `tokio::sync::Mutex`或在文档中明确标注「state_machine 方法不得在 `.await` 期间持锁」。
### ⑥ eventbus.rs — 错误静默吞噬
**文件**: `crates/df-workflow/src/eventbus.rs:16, 32`
`DEFAULT_CAPACITY: usize = 256``EventBus::send` 调用处 `let _` 吞噬所有错误。当并发节点多且事件消费慢时256 溢出导致最早接收者 Lagged 错误。
**建议**`send``tracing::warn!` 记录 Lagged 错误。
### ⑦ shell.rs — `probe_pwsh` 同步阻塞 tokio 运行时
**文件**: `crates/df-execute/src/shell.rs:53-66`
`probe_pwsh()``Default::default()` 中调用,`Default``execute()` 异步函数内调用。在 tokio 异步函数中同步阻塞 `cmd.status()` 违反最佳实践。
**建议**:惰性异步探测(`OnceLock``tokio::task::spawn_blocking`)。
### ⑧ retry.rs — backoff jitter 随机性弱
**文件**: `crates/df-ai/src/retry.rs:77-81`
jitter 使用 `SystemTime::now().duration_since().subsec_nanos() % 2000` 映射到 `[-1.0, 1.0)`。纳秒级退化到 1ms 粒度时多个请求可能拿到同一值,重试碰撞风险。
**建议**:改用 `fastrand`jitter 范围扩大到 ±50%(当前 ±20%)。
**建议**MCP 端复用 `validate_path` 的分段检测逻辑。
---
## 🔴 文档
## 🔴 架构
### AI对话引擎-2026-06-14.md — 工具清单严重过时
### AI 核心状态机焊死在 src-tauridf-ai 名不副实
**证据**
- `src-tauri/src/commands/ai/mod.rs` 定义 `AiSession``PerConvState``AiChatEvent``SessionState`
- `src-tauri/src/commands/ai/agentic/mod.rs` 实现 `run_agentic_loop`
- `df-ai` crate 仅保留 Provider HTTP 实现 + `coordinator.rs`(空壳) + `intent/planner`(未接入)
ARCHITECTURE.md §5.4 把 AgentCoordinator/ContextManager 描述为 df-ai 职责,实际 agentic loop 跑在 Tauri binary。df-ai 无法被复用(做 CLI/测试AI 状态机没有独立 crate 级单测。
**建议**:把 AiSession/agentic loop 抽到 df-ai 或新建 df-ai-session crate。
### ⑤ df-project / df-ideas 伪分层
**文件**: `crates/df-project/src/manager.rs`(全文 ~80 行,仅 2 个工厂函数)
`ProjectManager::create` / `create_from_idea` 只构造实体不落库。实际项目 CRUD/状态推进/软删全在 `df-storage/src/crud/project_repo.rs``commands/project.rs` 直接调 `ProjectRepo` 完全绕过 `ProjectManager`。领域层"不依赖 storage 保持分层"形同虚设。
**建议**:要么删除 df-project要么真正把领域规则下沉进去。
### ⑥ TaskStatus 两套并存
**证据**
- `crates/df-types/src/types.rs:170` 定义 `enum TaskStatus``as_str`/`from_db_str`/`is_valid`
- `crates/df-nodes/src/task_state_machine.rs:33-56` 又定义 7 个字符串常量 + `is_valid_state`
注释自称"严格对齐"但靠人工维护任何一方改字符串编译器不报错。df-types 的 enum 形同摆设。
**建议**:让 task_state_machine 基于 TaskStatus enum 实现,字符串边界只留 IPC/DB 序列化层。
### ⑦ IPC 错误拍平为 String结构化信息全失
**文件**: `src-tauri/src/commands/mod.rs:26` `pub fn err_str<E: ToString>(e: E) -> String`
df-types/error.rs 精心设计了 11 个变体(`NotFound`/`Validation`/`InvalidState { current, expected }` 等带结构化字段),但跨 IPC 边界时 `.to_string()` 一律拍平。前端只能拿到字符串,无法分辨 NotFound vs Validation vs InvalidState无法做差异化处理如 NotFound→跳转列表 vs InvalidState→提示冲突
**建议**IPC 错误用 serde 序列化的 enum`{ kind, message, ...fields }`),前端按 kind 分支。
### ⑧ 配置四源并存relay token 两端契约不一致
**证据**
- 环境变量:`DF_RELAY_TOKEN`relay 端缺失 panic/`DF_SCRIPT_BLACKLIST`/`DF_SCRIPT_WHITELIST`
- DB KV`app_settings`
- AppState 内存AtomicU64/Usize
- 硬编码常量:`src-tauri/lib.rs:186` `token = "devflow-relay-default-token"`(与 relay 端强制 env 不一致)
桌面端硬编码默认 tokenrelay 端 panic 强制 env——忘设 env 时桌面端用默认 token 连不上。
**建议**:建立分层 Config 单例env > app_settings > 默认常量),跨 crate 共享。
### ⑨ 文档与代码严重失步
**证据**
- ARCHITECTURE.md §4 写 `df-core/` —— 实际是 `df-types`
- PROGRESS.md §二/§三 同样写 df-core
- docs/01-技术文档/SQLite-CRUD模式.md:79 引用 `crates/df-core/src/error.rs`
- PROGRESS.md Sprint 15/17 把 knowledge 模块归属 `df-evolve`(已移除)
- ARCHITECTURE.md §4 crate 树漏列 `df-types`/`df-mcp`/`df-ai-core`/`df-relay`/`df-tunnel`crate 数量描述"9 crate"vs 实际 12
**建议**:批量替换 df-core → df-types更新 crate 树与规模表。
---
## 🔴 代码质量
### ⑩ useAiEvents — 终止收尾逻辑三处复制粘贴
**文件**: `src/composables/ai/useAiEvents.ts:569-737`
`AiCompleted` / `AiError` / `AiHelpRequired` 三个 case 重复执行同一套「会话终止收尾」逻辑clearStreamWatchdog / clearAllToolSlowTimers / flushCurrentText / setStreaming(false) / state.currentText='' / state.agentRound=0 / 清 pendingMaxRounds / 清 pendingDirAuths / 4×localStorage.removeItem共 ~10 步骤在三个分支逐行复制。
**建议**:抽 `cleanupTerminatedConversation(convId, reason)` 公共函数。
### ⑪ Store 错误处理样板重复 38 处
**证据**`src/stores/knowledge.ts` 单文件 10 次,加上 ideas.ts/projects.ts/tasks.ts 各 4-7 次:
```ts
try { state.xxx = await api.xxx(...) }
catch (e: any) { state.error = e?.toString() ?? t('xxx.err.xxxFailed') }
```
**建议**:抽 `runWithCatch(state, i18nKey, fn)` 工具。
### ⑫ idea_repo.rs — 注释承诺的测试不存在
**文件**: `crates/df-storage/src/crud/idea_repo.rs:24-25, 43-44`
注释明示「任一处加列漏改会被测试 `test_knowledge_cols_matches_record` 立即捕获」,但全 workspace grep 零匹配。`KNOWLEDGE_COL_COUNT``#[cfg_attr(not(test), allow(dead_code))]` 说明设计意图是给测试用,但测试从未写。属真实「文档撒谎」。
**建议**:补 `#[test] fn test_idea_cols_matches_record()` 或改注释。
### ⑬ df-relay 完全无测试
`df-ai` 332 测试、`df-storage` 133 测试、`df-nodes` 86 测试,但 `df-relay` 0 测试(无 tests/ 也无 #[cfg(test)]。relay.rs 442 行连接生命周期 + token 配对 + 广播分发逻辑零回归保护。
**建议**:至少补 ConnRegistry / token 配对 / ControlMessage 路由的单测。
---
## 🟡 并发与可靠性
### ⑭ SQLite 单连接 Mutex 串行化
**文件**: `crates/df-storage/src/db.rs:13-16`
```rust
pub struct Database { conn: Arc<Mutex<Connection>>, }
```
所有 Repo 共享同一连接。注释自承 `TODO: 考虑使用 r2d2 连接池`。功能性正确,但 AI 对话高峰(多 tool_calls 并发 + SSE 落库)下 DB 操作排队,体验性下降。
### ⑮ EventBus broadcast 容量与 Lagged 处理
**文件**: `crates/df-workflow/src/eventbus.rs:16` + `crates/df-nodes/src/human_node.rs:134-141`
容量 256 + `WorkflowEvent` 混入 AI 流式增量 → 高频场景订阅者 Lagged → `human_node.rs:134``continue` 策略可能漏自身 Response → HumanNode 等到超时。
**建议**:流式增量与审批响应分总线,或 Lagged 显式重发。
### ⑯ run_migrations 持有 tokio Mutex 守卫
**文件**: `crates/df-storage/src/db.rs:48-53`
V21 大表迁移期间所有其他 DB 操作 await 在锁上。一次性启动开销,但属不必要串行点。
### ⑰ task_link_repo.rs 注释误导
**文件**: `crates/df-storage/src/crud/task_link_repo.rs:116-138`
注释「检测与插入非原子」不准确——实际 BFS 与 INSERT 在同一 `blocking_lock` 守卫内,锁串行化了 TOCTOU 窗口,已闭合。注释应更新。
### ⑱ relay 默认监听 0.0.0.0
**文件**: `crates/df-relay/src/main.rs:17`
token 鉴权在,但默认监听所有网卡,云服务器部署若防火墙未配公网可访问 WS 端点暴力猜 token。
**建议**:默认改 127.0.0.1,外部访问显式 `DF_RELAY_ADDR=0.0.0.0`
---
## 🟡 代码简洁性
### ⑲ 大文件未拆分
- `src/components/ai/MessageList.vue` 1552 行style 441 + script ~1100
- `src/views/IdeaDetail.vue` 926 行style 481 + script ~300四域逻辑
- `crates/df-storage/src/crud/idea_repo.rs` 1500 行IdeaRepo + KnowledgeRepo + KnowledgeEventsRepo + 向量工具)
- `crates/df-nodes/src/human_node.rs` 831 行
**建议**MessageList 抽 MessageBubble.vueIdeaDetail 拆 IdeaScorePanel/IdeaAdversarialPanelidea_repo.rs 拆三文件。
### ⑰ 组件内 toast 状态重复
**文件**: `src/views/Settings.vue:103-112` + `src/components/AiChat.vue:183-191`
两个根组件各自维护 toast reactive + showToast逻辑完全一致。
**建议**:抽 `composables/useToast.ts`
### ㉑ TS any 滥用集中在 catch 和跨层透传
48 处 `: any`/`as any`
- **类型 A**38/48`catch (e: any)` → 改 `unknown` + 类型守卫
- **类型 B**:跨层 `as any` 透传未声明字段useAiEvents.ts:687、useAiConversations.ts 多处、MessageList.vue:498
**建议**:补完 api/types.ts 真实字段AiMessage.errorType、Message.statusdefineExpose 建 interface。
### ㉒ AgentCoordinator deprecated 空壳
**文件**: `crates/df-ai/src/coordinator.rs`
已标 `#[deprecated]`,全 workspace 零调用,但仍 `pub mod coordinator` 暴露导出。
**建议**`#[cfg(feature = "multi-agent")]` 隔离或删除git 历史可找回)。
### ㉓ planner.rs 11 处 #[allow(dead_code)]
builder APISubTask::new/with_tools/with_deps/with_group 等)当前零调用,全是预留。建议加 reason 集中说明或 feature flag 隔离。
### ㉔ app_settings KV 无 schema
`SettingsRepo::set(key, value)` 无 key 白名单(除 allowed_dirs新增配置需前端+IPC+KV 三处硬编码对齐。建议加 SettingKey enum 集中定义。
### ㉕ 视图 CSS 重复 + tags 解析重复
- Dashboard/Projects/Ideas/Knowledge 四视图各自定义 `.btn`/`.btn-primary` scoped 样式(项目已有全局 `styles/components.css`
- Ideas.vue:302 + Knowledge.vue:321 相同 tags 拆分逻辑
**建议**CSS 收敛到全局tags 解析抽 `parseTagsInput`
---
## 🟡 文档
### ㉖ AI对话引擎文档工具清单过时
**文件**: `docs/03-模块文档/AI对话引擎-2026-06-14.md §四`
标题自称"12 个"工具,表格实际列出 **13 行**(自相矛盾);与 `df-ai-AI集成模块-2026-06-12.md` 声称的 **22 个工具**严重对不齐缺失 `run_command`/`patch_file`/`list_trash`/`file_info`/`search_files`/`delete_task`/`restore_project`/`purge_project` 等工具
标题自称 12 个,表格 13 行,与 `df-ai-AI集成模块.md` 的 22 个严重对不齐缺失 `run_command`/`patch_file`/`list_trash`/`file_info`/`search_files`/`delete_task`/`restore_project`/`purge_project`
**建议**:全文同步至当前实际 22 个工具的最新清单。
### ⑩ 02-架构设计/INDEX.md — 漏索引 9 个已有文档
**文件**: `docs/02-架构设计/INDEX.md`
### ㉗ INDEX.md 漏索引 9 个文档
| 子目录 | 漏列数 | 典型遗漏 |
|-------|-------|---------|
| 专项设计 | 7 | `AI原生上下文地图``AST符号解析``插件机制``查询能力补全方案``项目知识图谱` |
| 已编号方案 | 2 | `F-260622-01-跨端AIChat-Phase3联调设计``消息级溯源P2-切读方案`(今天新增) |
| 构想审查 | 1 | `跑题改进试验记录` |
| 根目录 | 2 | `单对话并行多轮-Phase0落地路线图``单对话并行多轮-设计` |
| 专项设计 | 7 | AI原生上下文地图AST符号解析、插件机制、查询能力补全方案项目知识图谱 |
| 已编号方案 | 2 | F-260622-01-跨端AIChat-Phase3联调消息级溯源P2-切读方案 |
| 构想审查 | 1 | 跑题改进试验记录 |
| 根目录 | 2 | 单对话并行多轮-Phase0落地路线图单对话并行多轮-设计 |
**建议**:批量补齐,建立「新增文档 → 立即更新 INDEX」的提交习惯。
### ⑪ Agent架构说明 — 死链
### ㉘ 死链
**文件**: `docs/02-架构设计/专项设计/Agent架构说明-2026-06-14.md:4`
```markdown
> 关联: [任务推进设计](任务推进构想-2026-06-14.md)
```
目标文件在 `构想审查/` 下,当前路径解析到 `专项设计/` 下不存在。
`[任务推进设计](任务推进构想-2026-06-14.md)` 解析到专项设计目录下不存在,应改为 `../构想审查/`
**建议**:改为 `[任务推进设计](../构想审查/任务推进构想-2026-06-14.md)`
### ㉙ ARCHITECTURE.md §七 + 新设计文档 SSOT 违反
### ⑫ ARCHITECTURE.md §七 + 新设计文档 — 大段逐字重复
YAML 模板52行、AgentPersona struct11行、三层定义表、实例化流程大段逐字重复。建议 ARCH 仅保留概要 + 引用链接。
**文件**: `ARCHITECTURE.md:538-665` / `docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md`
### ㉚ ARCHITECTURE.md ~100 行已移除模块残留
YAML 模板示例52行`AgentPersona` struct 定义11行、三层定义表、实例化流程——**大段逐字重复**,违反文档记录规范 SSOT 原则
§5.4 保留 df-task/df-traceability 的 Task 生命周期图、分支策略、标注系统、Decision struct 详细设计。建议移至归档
**建议**`ARCHITECTURE.md §七` 保留概要(三层定义 + 关系图 + 关键原则YAML 全文和 struct 定义仅在专项设计文档展开ARCH 以引用链接替代。
### ㉛ ARCHITECTURE.md 状态过时
### ⑬ ARCHITECTURE.md 仍保留 ~100 行已移除模块细节
**文件**: `ARCHITECTURE.md:210-318``df-task` / `df-traceability` 章节)
保留 Task 生命周期图、分支策略、标注系统、需求-功能-测试映射、Decision struct 等已移除模块的详细设计。造成认知负担。
**建议**:将已移除模块的详细历史移至 `功能决策记录-归档`ARCHITECTURE.md 仅保留「已移除,见归档」。
### ⑭ 模块文档更新停滞(最长 16 天)
| 文档 | 最后更新 | 距今天数 |
|-----|---------|---------|
| `df-workflow-工作流引擎-2026-06-12.md` | 06-15 | 13 天 |
| `df-ai-AI集成模块-2026-06-12.md` | 06-14 | 14 天 |
| `df-storage-存储层-2026-06-12.md` | 06-15 | 13 天 |
| `想法探索-对抗式评估-2026-06-12.md` | 无更新 | 16 天 |
**建议**:对上述文档做一次「代码对齐」检查。
### ⑮ ARCHITECTURE.md 版本状态过时
**文件**: `ARCHITECTURE.md:3`
当前 `状态: 设计阶段`。工程处于 **Phase 2 验证阶段**df-workflow 核心完成、AI ReAct 循环运行中、审批机制就绪),状态标签与实际不符。
**建议**:改为 `状态: Phase 2 本地优先开发流程验证`(与 `docs/INDEX.md` 一致)。
### ⑯ 新设计文档与现有文档缺失交叉引用
**文件**: `docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md`
应当引用但未引用:`全局事件数据总线``df-nodes-节点集合``任务推进构想`
**建议**:补充上述交叉引用。
### ⑰ AI对话引擎文档边界归属模糊
**文件**: `docs/03-模块文档/AI对话引擎-2026-06-14.md`
位于 `03-模块文档/`,但内容大量描述 `src-tauri/commands/ai/` 下的 Tauri 层代码,而非 `crates/df-ai` crate 内部。按文档记录规范职责矩阵,`03-模块文档/` 应记录各 crate 实现细节。
**建议**:移入 `02-架构设计/` 适当子目录,或明确标注为跨层交互设计文档。
---
## 🟡 代码质量
### ⑱ dag.rs — `deep_merge` 收到 null 返回 null覆盖全局配置
**文件**: `crates/df-workflow/src/dag.rs:174-192`
`deep_merge(global, null)` 返回 `null`。若某节点定义写了 `"config": null`JSON 显式 null该节点 `ctx.config = Value::Null`,原本期望继承全局 config 的节点拿到空配置。
**建议**`DagExecutor.run()` 中对 `deep_merge` 结果做 `if result.is_null() { initial_config.clone() }` 兜底。
### ⑲ dag.rs — `_` 通配 match 掩盖未覆盖变体
**文件**: `crates/df-workflow/src/dag.rs:191`
`deep_merge` 的 match 最后分支 `_ => node.clone()` 过于宽泛。若未来新增 `serde_json::Value` 变体,不会被编译器捕获。
**建议**:显式列出所有剩余变体并 `bail!`,迫使维护者在修改时意识到影响。
### ⑳ shell.rs — `String::from_utf8_lossy` 编码错误被静默替换
**文件**: `crates/df-execute/src/shell.rs:155-156`
shell 输出使用 `String::from_utf8_lossy`,将非 UTF-8 字节替换为 `<60>`。对于中文/日文环境(如 `chcp 65001` 前的 GBK 输出),用户看到乱码替代符,诊断困难。
**建议**:添加 `tracing::debug!` 记录损失性替换的字节数,或提供 `encoding_rs` 转码选项。
### ㉑ query LIKE 搜索 — SQL 通配符未转义
**文件**: `crates/df-storage/src/crud/project_repo.rs:245-246`
`LIKE ?` 使用 `%{trimmed}%` 拼接,`keyword` 中的 `%`/`_` 会被字面匹配。用户搜索 `100%` 匹配所有行,搜索 `error_404` 匹配 `errorX404`
**建议**:对 keyword 中的 `%`/`_``replace` 转义,加 `ESCAPE '\'` 子句。
### ㉒ coordinator.rs — 空壳仅用注释声明"勿删",应加编译期守卫
**文件**: `crates/df-ai/src/coordinator.rs:1-24`
注释声明「B 路线占位,有意保留空壳,勿删」,但无 `#[deprecated]``#[doc(hidden)]` 编译期标记。调用方引入 `AgentCoordinator::new().run()` 只能运行时发现返回 `"TODO"`
**建议**:加 `#[deprecated(note = "B 路线占位,勿用于生产")]`
### ㉓ secret.rs — 密钥迁移 sidecar 文件写入无原子性
**文件**: `crates/df-storage/src/secret.rs:75-81`
`record_migration_fail` 每次调用同步读文件 → 改 map → 写文件,迁移时 10+ provider 逐条失败时每条 2 次文件 I/O。无原子写入保障异常中断可能产生半写文件。
**建议**:积累内存中的失败计数,迁移循环结束后一次性持久化。
### ㉔ Intent/planner/plan_hint — 纯函数模块无单测覆盖
**文件**: `crates/df-ai/src/intent.rs` / `planner.rs` / `plan_hint.rs`
注释声称「纯函数、零 IO」均无 `#[cfg(test)]` 覆盖。Phase 1 接入主 loop 前应补充。
**建议**:为这些纯函数模块补充输入输出确定性匹配测试。
---
## 🟡 前端
### ㉕ Settings.vue — 内联 confirm 弹层重复实现
**文件**: `src/views/Settings.vue:7-16`
Settings.vue 内联了完整 confirm 弹层模板,完全复制了 `ConfirmDialog.vue` 的 UI/逻辑,自身维护 `confirmState`/`answerConfirm`。全项目已有 `useConfirm` composable。
**建议**:用 `useConfirm` + `ConfirmDialog` 替换内联实现。
### ㉖ 多视图 — 按钮 CSS 重复定义
**文件**: `Dashboard.vue:126-162` / `Projects.vue:445-458` / `Ideas.vue:421-432` / `Knowledge.vue:449-458`
四处视图各自定义 scoped 按钮样式。项目已通过 `main.ts` 导入全局 `styles/components.css`
**建议**:删除视图内按钮样式,统一走全局 CSS。
### ㉗ Ideas.vue + Knowledge.vue — tags 解析逻辑重复
**文件**: `src/views/Ideas.vue:302` / `src/views/Knowledge.vue:321`
```ts
tags.split(',').map(t => t.trim()).filter(Boolean)
```
**建议**:提取为工具函数 `parseTagsInput` 放进 `src/utils/`
### ㉘ Ideas.vue — Arco Design `Message` 残留
**文件**: `src/views/Ideas.vue:144`
全项目仅在 Ideas.vue 使用了 Arco 的 `Message` 组件3 处),其余使用自建 toast。混用。
**建议**:用自建 `showToast` 替换后删除 Arco 依赖。
### ㉙ stores/ai.ts — 两个 watch 监听同一 `.messages.length`
**文件**: `src/stores/ai.ts:204, 241`
两个 `watch(() => state.messages.length, ...)` 分别处理条数上限和 parts 体积上限。每次 length 变化触发两个 watch第二个还要遍历 messages 求和。
**建议**:合并为一个 watch。
### ㉚ AiChat.vue — `initDrainQueueListener` 每次 `useAiStore()` 调用都执行
**文件**: `src/stores/ai.ts:304`
```ts
void initDrainQueueListener()
```
每次调用 `useAiStore()` 都执行。需确认内部做了幂等守卫。
**建议**:确保只初始化一次(如外层 `initOnce` 布尔守卫)。
### ㉛ AiChat.vue — 空值传播无告警
**文件**: `src/components/AiChat.vue:238`
```ts
const conv = store.state.conversations.find(c => c.id === id)
const title = conv?.title || t('aiChat.newConversation')
```
`find` 返回 `undefined` 时静默回退默认标题。非预期 ID 进入时不告警。
**建议**:至少 `console.warn` 记录 ID 不在列表中的情况。
### ㉜ 多视图 — `catch (e: any)` 类型退化
**文件**: 几乎所有 Vue 视图
```ts
catch (e: any) {
state.error = e?.toString() ?? t('xxx.failed')
}
```
**建议**:统一改为 `catch (e: unknown)`
### ㉝ AiChat.vue — 体积仍偏大
**文件**: `src/components/AiChat.vue`(模板 128 行 + script ~200+ 行)
仍承载全局快捷键处理、编辑态管理、队列编辑态管理、Toast 共享状态等。
**建议**:将键盘快捷键抽离为 `useAiShortcuts` composable。
### ㉞ Ideas.vue — `searchQuery.trim()` 重复调用
**文件**: `src/views/Ideas.vue:219-220`
```ts
if (searchQuery.value.trim()) {
q.keyword = searchQuery.value.trim()
}
```
**建议**:提取 `const trimmed = searchQuery.value.trim()`
### ㉟ Projects.vue — 冗余 `as string` 断言
**文件**: `src/views/Projects.vue:217-218, 222`
`selected` 已被类型收窄为 `string`,后续 `as string` 多余。
### ㊱ MCP tools/list — `unwrap_or(Value::Null)` 在数组中混入 null
**文件**: `crates/df-mcp/src/server.rs:140-142`
```rust
serde_json::to_value(&t.tool).unwrap_or(Value::Null)
```
若某个 tool schema 序列化失败tools 数组中混入 `null`
**建议**`.filter_map(|t| serde_json::to_value(&t.tool).ok())`
### ㊲ Ideas.vue — `IdeaQuery` 类型未充分对齐
**文件**: `src/views/Ideas.vue:146, 214-217`
`order_by` 字段类型为 `string | null`,后端若收窄为联合类型白名单,前端不会报错。
**建议**:前端 `order_by` 使用 `'score' | 'created_at' | null`
L3 `状态: 设计阶段` 与实际 Phase 2 验证阶段不符,应与 `docs/INDEX.md` 一致改为 `Phase 2 本地优先开发流程验证`
---
## ✅ 亮点
### ① 密钥管理分层设计secret.rs
`resolve_provider_secret` 三阶段回落DB 明文(向前兼容)→ OS keyring → 空字符串。迁移非阻断、幂等、失败计数告警。单测通过 `cfg` gate 仅桌面 OS 跑 keyring 测试
### ① df-ai/df-ai-core trait 下沉F-01
df-ai-core 零 HTTP 依赖(仅 5 个依赖df-ai 保留 reqwest/eventsource-stream。df-ideas 等轻消费方只引 df-ai-core。**真实落地的解耦**
### ② SQL 注入防护体系
列名白名单(`validate_column_name` + `impl_repo!` 宏统一调用),`build_order_clause` 对排序方向也做白名单校验。软删除/恢复/清空全部参数化 SQL
### ② df-mcp advance_task 复用 df-nodes 状态机
MCP 与 IPC 都走 `df_nodes::task_advance_node::advance_task_atomic`推进链三层校验is_valid_state/can_transition/CAS/regression bump唯一实现。Cargo.toml 注释明确「避免 MCP 直调底层绕过状态机」
### ③ 前端竞态保护knowledge.ts
递增序列号丢弃过期响应。`ai.ts` watch 双重守卫(`oldLen > 0 && newLen - oldLen <= 2`)精确区分局部增长和整体替换
### ③ keyring 密钥下沉 + 迁移补偿
secret.rs 三阶段回落DB明文→OS keyring→空`migrate_secrets_to_keyring` 启动一次性迁移,失败计数达阈值升级 warn。`AiProviderRecord` 自定义 Debug 把 api_key 脱敏为 `"sk-****"`
### ④ `useConfirm` 统一确认弹层
从 4 个视图中提取重复 Promise+resolve 模式,正确处理并发覆盖场景(前一个 Promise 先 resolve false
### ④ SanitizedPath newtype 强类型
`df-types/src/augmentation.rs:29` `pub struct SanitizedPath(String)` 强制所有注入 LLM 的路径经 sanitize 入口,防裸 String 误用
### ⑤ Store 内部分拆 4 子 store
`stores/project/` 拆分为 projects/tasks/ideas/workflow共享 `state.ts` 单例,对外 `useProjectStore()` 不变——「内部拆分,外部零改动」
### ⑤ 取消路径 TOCTOU 三层防护
state.rs:106 set_cancelled 作为唯一受控旁路 + executor.rs:180-226 双重检查 + HumanNode select! 轮询。注释详细记录时序窗口
### ⑥ 取消路径 TOCTOU 三层防护
`state.rs:86-100` + `executor.rs:180-232`Ok 后检查、Err 分支检查、阻塞节点轮询。注释详细记录了 TOCTOU 时序窗口
### ⑥ SQLite WAL + spawn_blocking 正确
所有 DB 操作在 spawn_blocking + blocking_lock 中,无 sync rusqlite 在 async 裸调用批量用事务原子提交WAL 模式读写不互斥
### ⑦ MCP 多层防御
三级过滤:`visible()` 读模式仅 Low 工具、`tools/list` 剔除 High、`dispatch` 再拦截 read-only + Medium 拒 + High 兜底拒
### ⑦ 重试机制工业级
retry.rs ±50% jitterrand::thread_rng+ 错误分类Ok/Retryable/Fatal+ 总预算止损 30s + 流式不重试。complete() 两处接入,单测齐全
### ⑧ V21 大版本迁移谨慎
`BATCH_SIZE=50` 分页、空库跳过、解析失败 skip 而非崩溃、`column_exists` 守卫 ALTER TABLE 防重复执行
### ⑧ kill_on_drop + Windows CREATE_NO_WINDOW
shell.rs:147,154 — 超时 future drop → Command 析构 → kill 子进程Windows 黑窗抑制
### ⑨ 子进程清理 + Windows 黑窗抑制
`kill_on_drop(true)` 超时后子进程不残留;`creation_flags(0x08000000)` 抑制 Windows 黑窗闪现
### ⑨ TaskStatus is_valid 单测覆盖
df-types/types.rs:437-477 测试了拼写错误/大小写/空白/SQL 注入/中文近义等边界valid_values_all_pass_is_valid 锁定清单一致性
### ⑩ SSRF 防护完整
http.rs:50-208 私网 IP 黑名单 + DNS rebinding 防护(多 A 记录全检)+ 重定向 ≤3 跳每跳重新校验 + body 截断 50KB。
### ⑪ V21 大版本迁移谨慎
BATCH_SIZE=50 分页 + 空库跳过 + 坏数据 skip 不崩溃 + column_exists 守卫 ALTER TABLE。
### ⑫ 前端 composable 已大范围抽离
src/composables/ai/ 已拆 14 文件AiChat.vue 从巨型组件瘦身到 767 行聚合容器。Settings.vue 已 master-detail 重构8 个子 Section/Panel309 行壳)。
---
@@ -382,11 +314,20 @@ serde_json::to_value(&t.tool).unwrap_or(Value::Null)
| 分类 | 🔴 | 🟡 | ✅ |
|------|----|----|----|
| 安全 | 8 | — | — |
| 文档 | 9 | — | — |
| 代码质量 | | 7 | — |
| 前端 | — | 13 | — |
| 亮点 | — | — | 9 |
| **合计** | **17** | **20** | **9** |
| 安全 | 3 | — | — |
| 架构 | 6 | — | — |
| 代码质量 | 4 | | — |
| 并发可靠性 | — | 5 | — |
| 代码简洁性 | — | 7 | — |
| 文档 | — | 6 | — |
| 亮点 | — | — | 12 |
| **合计** | **13** | **18** | **12** |
**总体评价**核心安全架构扎实SQL 注入防护、密钥管理、MCP 多层防御、取消 TOCTOU。主要风险集中在 `state.rs` 同步锁在 async 上下文中的潜在死锁、文档与代码脱节AI对话引擎工具数过时、INDEX 漏列 9 文档、~100 行已移除模块残留。前端整体质量高但需清理少量遗留Arco 残留、内联 confirm)。
**总体评价**核心安全架构扎实SQL 注入防护、keyring 密钥、SSRF 防护、取消 TOCTOU、重试工业级前端架构演进健康composable 大范围抽离、Settings 已 master-detail)。
主要问题集中在:
- **架构债**AI 核心状态机焊死 src-tauri#4、df-project 伪分层(#5)、文档严重失步(#9
- **代码质量**useAiEvents 三处复制粘贴(#10、Store 错误处理 38 处重复(#11
- **安全细节**ScriptNode 元字符绕过(#1、MCP 无鉴权(#2
优先修复建议:① 修文档(#9 成本极低);② 补 df-relay 测试(#13);③ ScriptNode 默认黑名单(#1);④ 抽 useAiEvents 公共函数(#10)。

188
docs/Batch-Overview.md Normal file
View File

@@ -0,0 +1,188 @@
# DevFlow 批次可视化总览
> 基于 [Batch.md](../Batch.md) 生成 | 更新: 2026-07-01
---
## 一、批次时间线(已完成)
```mermaid
gantt
title DevFlow 批次推进时间线
dateFormat YYYY-MM-DD
axisFormat %m-%d
section 审批与工具卡
Batch 1 审批超时后端兜底 :done, b1, 2026-06-14, 1d
Batch 2 工具卡片拆分核验 :done, b2, after b1, 1d
Batch 3 条件表达式 UI 翻译 :done, b3, after b2, 1d
section 数据与状态
Batch 4 知识提炼切读消息表 :done, b4, after b3, 1d
Batch 5 关闭已完成待办项 :done, b5, after b4, 1d
Batch 6 应用状态拆分+路径授权 :done, b6, after b5, 1d
section 跨端与消息
Batch 7 小程序配置持久化 :done, b7, after b6, 1d
Batch 8 消息分页懒加载 :done, b8, after b7, 1d
Batch 9 多工程数据层 :done, b9, after b8, 1d
section 文件浏览器
Batch 10 文件浏览器 UI :done, b10, after b9, 1d
Batch 11 Git 只读 AI 工具 :done, b11, after b10, 1d
Batch 12 Git 写操作 AI 工具 :done, b12, after b11, 1d
Batch 13 文件浏览器增强 :done, b13, after b12, 1d
section 收尾与重构
Batch 14 浏览器收尾 :done, b14, after b13, 1d
Batch 15 多工程管理 :done, b15, after b14, 1d
Batch 16 提交历史作者 :done, b16, after b15, 1d
section 崩溃修复与 UX
渲染崩溃修复 TDZ+窗口权限 :done, fix1, after b16, 1d
Batch 17 Git 能力闭环 :done, b17, after fix1, 1d
Batch 18 小程序审批超时 :done, b18, after b17, 1d
section UX 与架构
Batch 19 任务管理 UX 重构 :done, b19, after b18, 1d
Batch 20 coordinator.rs 删除 :done, b20, after b19, 1d
任务快捷操作菜单 :done, menu1, after b20, 1d
section DAG 与依赖图
TDZ 防御 :done, tdz1, after menu1, 1d
Plan 执行器骨架 :done, plan1, after tdz1, 1d
Batch 25 工程依赖图数据层 :done, b25, after plan1, 1d
section 增强
Batch 26 dagre 布局 :done, b26, after b25, 1d
Batch 28 架构债收尾 :done, b28, after b26, 1d
Batch 29 环检测+PNG+总数 :done, b29, after b28, 1d
section 待推进
Batch 30 代码质量收尾 :b30, after b29, 3d
Batch 31 MessageList 拆分 :b31, after b30, 3d
Batch 32 ChatInput 拆分 :b32, after b31, 3d
Batch 33 多 ReAct 对接 :b33, after b32, 5d
Batch 34 产品完善 :b34, after b33, 3d
```
---
## 二、功能领域分布
```mermaid
pie title 批次功能领域分布(按完成批次数)
"文件浏览器/Git" : 8
"任务管理 UX" : 3
"工程系统" : 4
"AI/Agent" : 4
"架构债/重构" : 4
"小程序" : 2
"Bug 修复" : 3
"依赖图可视化" : 4
```
---
## 三、批次依赖关系图
> 仅展示批次级别依赖,子任务明细见下方表格。
```mermaid
flowchart TD
B6["B6 状态拆分"] --> B9["B9 多工程数据层"]
B9 --> B10["B10 文件浏览器"]
B9 --> B25["B25 依赖图数据"]
B10 --> B13["B13 浏览器增强"]
B13 --> B14["B14 收尾"]
B13 --> B15["B15 多工程管理"]
B13 --> B16["B16 提交作者"]
B13 --> B17["B17 Git 闭环"]
B25 --> B26["B26 dagre布局"]
B26 --> B29["B29 环检测+PNG"]
B9 --> B11["B11 Git只读"]
B11 --> B12["B12 Git写操作"]
PLAN["Plan执行器"] --> B33["B33 多ReAct"]
B30["B30 代码质量"] --> B31["B31 MsgList拆分"]
B31 --> B32["B32 ChatInput拆分"]
style B6 fill:#2d5a2d,color:#fff
style B9 fill:#2d5a2d,color:#fff
style B10 fill:#2d5a2d,color:#fff
style B11 fill:#2d5a2d,color:#fff
style B12 fill:#2d5a2d,color:#fff
style B13 fill:#2d5a2d,color:#fff
style B14 fill:#2d5a2d,color:#fff
style B15 fill:#2d5a2d,color:#fff
style B16 fill:#2d5a2d,color:#fff
style B17 fill:#2d5a2d,color:#fff
style B25 fill:#2d5a2d,color:#fff
style B26 fill:#2d5a2d,color:#fff
style B29 fill:#2d5a2d,color:#fff
style PLAN fill:#2d5a2d,color:#fff
style B30 fill:#5a4a2d,color:#fff
style B31 fill:#5a4a2d,color:#fff
style B32 fill:#5a4a2d,color:#fff
style B33 fill:#5a4a2d,color:#fff
```
绿色 = 已完成 | 橙色 = 待推进
---
## 四、批次子任务明细
| 批次 | 状态 | 子任务 |
|------|------|--------|
| Batch 9 | ✅ | project_modules 表 · ModuleRepo CRUD · 创建项目适配 · AI 工具注册 |
| Batch 10 | ✅ | 文件树组件 · 文件预览 · Git 状态标记 · 面包屑导航 |
| Batch 13 | ✅ | 行号/图标 · Diff 视图 · Git 变更面板 · 窗口分离 · 分页提交历史 |
| Batch 17 | ✅ | Diff 行号解析 · 分支只读展示 · 提交详情 · 写入后自动刷新 |
| Batch 19 | ✅ | 搜索框 · 筛选改下拉 · 排序选择器 · 分组可折叠 · 快捷操作菜单 |
| Batch 25 | ✅ | module_dependencies 表 · DependencyRepo CRUD · 依赖边渲染 · 小地图 |
| Batch 26 | ✅ | dagre 布局 · 添加依赖弹窗 · 边类型着色 |
| Batch 29 | ✅ | 环形检测 IPC · PNG 导出 · count_tasks IPC · 真实 total |
| Batch 30 | ⏸️ | 搜索索引核验 · 编译警告清理 · i18n 核验 · String 替 newtype |
| Batch 31 | ⏸️ | 提取 MessageItem.vue · 提取 useMessageScroll.ts |
| Batch 32 | ⏸️ | 提取 SkillMention.vue · 提取 ImageInput.vue |
| Batch 33 | ⏸️ | PlanExecutor 接入 loop · plan_hint 生成 DAG · 执行进度展示 |
---
## 四、关键指标
| 指标 | 数值 |
|------|------|
| 已完成批次 | 29 |
| 待推进批次 | 5 |
| 总提交数Batch 1-29 | ~35 |
| 涉及 Rust 文件 | ~25 |
| 涉及 Vue/TS 文件 | ~40 |
| 新增数据库迁移版本 | V35 |
| 新增 IPC 命令 | ~15 |
| 新增 npm 依赖 | @antv/x6 / dagre / highlight.js |
| 当前最大文件 | MessageList.vue1552行待拆分 |
---
## 五、待推进批次优先级矩阵
| 批次 | 价值 | 风险 | 象限 | 建议 |
|------|------|------|------|------|
| Batch 30 代码质量 | 中 | 低 | 高价值低风险 | ✅ 优先做 |
| Batch 31 MessageList 拆分 | 高 | 高 | 高价值高风险 | ⚠️ 需谨慎,要测试覆盖 |
| Batch 32 ChatInput 拆分 | 高 | 高 | 高价值高风险 | ⚠️ 需谨慎,要测试覆盖 |
| Batch 33 多 ReAct 对接 | 很高 | 很高 | 高价值高风险 | ⚠️ 核心架构改动 |
| Batch 34 产品完善 | 中 | 中 | 中价值中风险 | 🟡 可延后 |
---
## 六、批次提交密度
```mermaid
xychart-beta
title "批次提交密度(按阶段)"
x-axis ["阶段1", "阶段2", "阶段3", "阶段4", "阶段5", "阶段6", "阶段7", "阶段8", "阶段9"]
y-axis "批次数" 0 --> 8
bar [3, 3, 2, 3, 3, 3, 3, 3, 3]
```

View File

@@ -17,7 +17,8 @@ docs/
├── 待决策论证-2026-06-16.md # (归档) 7 项决策论证D-01~04 已定 / DEC-01~03 已闭环)
├── 01-技术文档/
│ ├── SQLite-CRUD模式-2026-06-12.md
── Tauri-IPC模式-2026-06-12.md
── Tauri-IPC模式-2026-06-12.md
│ └── AntV-X6调研报告与引入计划-2026-06-30.md # 图可视化层选型(v3.1.7)+ 依赖图落地计划
├── 02-架构设计/
│ ├── INDEX.md # 本目录分类索引(按性质四类 + 命名规范摘要)
│ ├── Phase1架构决策-2026-06-12.md
@@ -107,7 +108,7 @@ docs/
├── 08-用户指南/
│ ├── 使用手册-2026-06-12.md # 用户手册(对齐 06-15 代码基线)
│ ├── AI文件操作工具手册.md # AI 工具 API 全量参考10 工具2026-06-16 更新)
── patch_file使用指南.md # patch_file 专项使用指南
── patch_file使用指南.md # patch_file 专项使用指南
└── 09-问题排查/
└── aichat-apikey-401排查-2026-06-15.md
```

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