Compare commits

...

192 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
ad1821bc14 修复: 审批不超时+重启恢复 + 其他小改
- APPROVAL_TIMEOUT_MS=Infinity(已决策:审批不做超时)
- V33 迁移: ai_conversations 加 pending_approvals 列
- save_conversation 持久化 pending_approvals
- ai_conversation_switch 从 DB 恢复 pending_approvals
- switchConversation 加 try/catch(对话不存在→建新)
- @项目 关联添加取消按钮(×)
- TopBar 底部无标题时图标右对齐
- TaskDetail.vue 删除重复 case
2026-06-28 13:09:55 +08:00
c4b02b5370 修复: 消息溯源P2切读确认完成 + 走查报告高优项
- P2 切读方案文档更新(确认批次B已上线)
- query LIKE 通配符转义(project/idea/task/knowledge 4 repo)
- dag.rs deep_merge null 覆盖全局配置
- dag.rs 通配 match 展开显式变体
- INDEX.md 补漏 10 个文档索引
- ARCHITECTURE.md 删除与新文档逐字重复
- coordinator.rs 加 #[deprecated] 编译守卫
- AiChat.vue 空值传播加 console.warn
2026-06-28 05:06:51 +08:00
fee856129c 新增: 全量走查报告 — 代码/前端/文档体系
- Rust 后端 12 crate: 锁/LIKE 通配符/jitter/路径穿越 等 15 项
- Vue 3 前端: Arco 残留/confirm 重复/类型退化 等 13 项
- 文档体系: 工具清单过时/INDEX 漏列 9 文档/死链/SSOT 违反 等 9 项
2026-06-28 04:53:33 +08:00
ead490783f 修复: AiCompleted 携带 pinned_goals 根治目标面板刷新 2026-06-28 04:51:08 +08:00
dcc3f0d230 新增: 三层模型设计文档 — 模板/工作流/人设体系
- ARCHITECTURE.md 补充执行层级模型 + 重写 §七 三层模型章节
- 新建专项设计: 流程模板 YAML 规范/AgentPersona 数据结构/实例化流程
- 注册新文档到架构设计 INDEX
- Agent架构说明补充前向关联引用
2026-06-28 04:44:01 +08:00
d1b9488853 修复: status→enum + type alias(SMELL-P1-6) 2026-06-28 04:42:59 +08:00
48c966f6f7 更新: 归档今日决策 + 标记已解决项 + 执行计划 2026-06-28 04:12:16 +08:00
7e3fe1961a 修复: execution_id String→ExecutionId 类型别名 + 更新待决策状态 2026-06-28 03:50:25 +08:00
8723075360 重构: TopBar 布局 — header 中间区 + 底部工具区(标题/目标/历史/摘要) 2026-06-28 03:31:41 +08:00
53e1c1da77 重构: 对话透明化 L1 + ③.2 审批拆分 + ⑥.4 @展开 + DAG 展示
修复 tool_result 压缩零效果(BUG-260628-01):单行/少行/JSON 大字符
串字段逃逸压缩的问题,基于实测数据(53/94 次迭代零节省)调参,
增加字符级保底截断,压缩有效率从 8% 提升至 ~85%+
2026-06-28 03:31:40 +08:00
e4ceb0015b 新增: Conditions 启用 + 工作流 DAG 展示 + 决策记录
- 开启 conditions-eval feature flag(df-workflow 默认启用)
- 新增 WorkflowDagDisplay.vue 展示工作流节点/边/条件
- TaskDetail 工作流推进后自动获取并显示 DAG 结构
- 记录 2026-06-27 讨论决策到待决策.md
- 记录 Inbox 设计想法到 todo.md
2026-06-28 01:17:36 +08:00
22362a77b8 新增: 审批挂起时长提示(BUG-260624-02 方案 C)
背景:AI 调用需审批的工具后,ToolCard 弹审批卡常驻直到用户批/拒,
用户走开忘了批则 AI loop 卡死,且卡片无任何时长提示。

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

实现:
- ToolCard pending_approval 态显示已等待 mm:ss
- >2分钟橙色脉冲,>5分钟红色脉冲
- 注释中标注方案选型理由,便于后人理解决策上下文
2026-06-28 00:41:00 +08:00
cf223fc08b 修复: .trash WARN 降级 + ToolCard 分组标题增强
- .trash 跨盘迁移从 WARN 降为 DEBUG(预期行为,非异常)
- ToolCard 分组标题增加文件路径摘要(取前3个文件名),
  收起态也能看到影响了哪些文件
2026-06-28 00:25:37 +08:00
b7b004dd68 新增: Phase 4 注入 — @[项目] 关联任务/灵感 + 三个工具 path 可选
- Augmentation 新增 extra 字段(项目关联信息/任务关联信息/灵感关联信息)
- ProjectResolver: @[项目] 时自动查前5条进行中任务+前3条待评估灵感
- TaskResolver/IdeaResolver: extra 预留(后续按需追加)
- render_one 渲染 extra 行
- search_files/grep/list_directory path 改为可选,不传时返回引导提示
2026-06-28 00:19:13 +08:00
b7b54eb2a6 修复: patch_file 小改动自动放行 + tool_result JSON 压缩 + prompt 项目路径
- C-260627: patch_file ≤5 行自动放行,不阻塞 AI 工作流
- tool_result 压缩: JSON 数组截断到 10 条 + _truncated 标记
- system_prompt: 项目清单增加「目录:」路径信息
2026-06-27 23:59:07 +08:00
6212002270 修复: read_symbol 搜不到 const 声明的符号
- 新增 variable_declarator 到 DEFINITION_KINDS
- Vue 3 + TS 大量使用 const fn = () => {} 箭头函数
  AST 节点是 variable_declarator,原本不在列表中
  导致 read_symbol 对 Vue 文件频繁 fallback 回 read_file
2026-06-27 23:42:14 +08:00
7cd833b1e5 修复: 文件日志 + G1 日志增强 + 清理死代码
- 文件日志: tracing-subscriber + tracing-appender 写入 %TEMP%/devflow-trace.log
- G1 日志: 增加 first_goal 字段显示首条目标内容
- 清理: 删 workspace_root_str / data_dir_path 死代码
- 标注: default_with_root 加 cfg_attr 抑制 non-test 编译警告
2026-06-27 23:27:31 +08:00
5c539fe764 修复: 前端 TS 阻断 + G1/G2 单测 + 归档已完成项
- 修复 vue-tsc 3 阻断: i18n 重复 err key 删除 + store 补 relateIdeas
- G1: extract_pinned_goal 单测 10 条(mention剥离/截断/空输入/全角冒号)
- G2: is_empty_tool_result 单测 11 条(JSON标记/中文/英文/非空判定)
- 归档: todo.md/docs/todo.md/待决策.md/待审查.md 已完成项迁入归档
2026-06-27 22:35:37 +08:00
6e1485e4f9 修复: BUG-260620-05 工程内路径弹窗 + G1 多目标累积 + 测试编译债
- BUG-260620-05: reload_allowed_dirs 无条件插入 workspace_root,工程内路径免授权
- G1: pinned_goal → pinned_goals Vec<String>,多目标追加去重 + MAX_GOALS=5
- tool_registry.rs 测试 4 处补 data_dir 形参
- 更新 todo.md / docs/todo.md 状态
2026-06-27 22:02:41 +08:00
9c3f27f4ca 修复: workspace_root 编译期常量消除+data_dir 运行期+首次授权引导
- 删除 production 中全部 env!("CARGO_MANIFEST_DIR")引用
- workspace_root() 替换为运行期 data_dir(Tauri app_data_dir)
- 相对路径锚定改为 AllowedDirs.first_persistent_dir(),无授权时引导绑定项目
- .trash 迁到 data_dir/.trash,含自动迁移
- authz_debug → temp_dir,workspace_drive → current_dir
- run_command 默认 working_dir 改为空串
- G1 目标刷新改为每次消息覆盖(含 ai_chat_edit 路径)
- #6 评分关键词拆到独立文件 scoring_keywords.rs
- #7 promote 补偿删除 purge_with_descendants → soft_delete
- #8 关联双向同步: 后端事务 sync_related_ids + IPC + 前端全链路
- 设置面板新增数据目录显示
2026-06-27 21:32:36 +08:00
a6d692270f 锁定: Cargo.lock tree-sitter-go/java/python(fc70544 AST 扩展配套) 2026-06-27 01:31:00 +08:00
9189877a59 修复: B-260626-01 首条 user 协议合规(sanitize 兜底 + ensure_leading_user)
- context.rs: 兜底全量也过 sanitize(对齐分支1/3,防主 loop 唯一 sanitize 漏洞——大体量 tool_result 致超预算且保护区满时,裸返 all_messages_clone 不过滤 truncated/中毒三元组/首条非法直送 provider)
- anthropic_compat.rs + openai_compat.rs: ensure_leading_user 补 user 占位(Anthropic/OpenAI 协议要求首条非 assistant/tool;上游绕过 sanitize 的调用方——标题生成/知识注入/工作流 AI 节点——可能传入首条 assistant 序列,补占位保留上下文,tool_use/tool_result 配对完整无 orphan,远优于砍丢历史)
2026-06-27 01:31:00 +08:00
bb2cfcbb62 修复: grep/read_file 命中行行号与内容分行
行号 gutter 与内容改同一 flex row 并排(IDE 风格),取代原 head 块+content 块垂直堆叠;去冒号前缀改纯数字右对齐。BUG-260626-01
2026-06-27 01:24:50 +08:00
80239500f1 新增: 知识图谱设计文档 + AI原生上下文地图设计(父②⑥实施基础)
- 项目知识图谱与任务队列系统-2026-06-26.md:AI Working 终局设计(任务网络queue/parent/links + 事件流 + 基础设施 + 注入 + Mission Control 五视图),父②Phase1 + 父⑥Phase2-3 已据此实施落地
- AI原生上下文地图与去AI化进化系统-2026-06-26.md:AI Working 定位体系设计
2026-06-27 01:22:46 +08:00
4b754fe230 更新: 目标丢失诊断报告 G1/G2/G4 实施状态(commit 8ce18cb)
§五方向建议核对:G1 目标钉扎 / G2 探索熔断 / G4 话题标记修正 已实施,
G3 状态机收敛(文档衔接),G5 排查结论(历史脏数据,数据治理项不改代码)。
诊断→实施闭环标注。todo DAG + 待决策.md 状态核对一致(本 session 16 提交全景已同步)。
2026-06-27 00:56:55 +08:00
e33e8f86d3 新增: 知识图谱Phase3基础设施(父⑥) — project_services+D10凭证审查+IPC+AI工具
父⑥ Phase 3(对标设计 §2.3):
- V31 迁移:project_services 表(id/project_id FK/name/service_type/endpoint/config_json/environment/remark)+ idx_project_services_project
- ProjectServiceRecord + ProjectServiceRepo(impl_repo! 宏 + insert_validated/update_full_validated/list_by_project/list_by_project_env)
- service_type 白名单(mysql/postgresql/sqlite/redis/mongodb/mq/api/other,应用层校验)
- D10 凭证审查:config_json/endpoint/remark 含 password/passwd/secret/api_key/apikey/token 拒绝(不存敏感凭证,凭证走环境变量)
- 4 IPC(add/update/remove/list_project_services,环境过滤)+ lib.rs 注册
- AI 工具 add/list_project_services(Medium/Low)+ 基线 39→41(data 25→27)+ 3 端到端测试

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

list 工具调用重复治理第一步:
- build_system_prompt_with_excluded 去重被@实体 + 清单注明语
2026-06-22 00:04:14 +08:00
363 changed files with 66909 additions and 11127 deletions

12
.cargo/config.toml Normal file
View File

@@ -0,0 +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"]

105
.cargo/ts-bench/Cargo.lock generated Normal file
View File

@@ -0,0 +1,105 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "cc"
version = "1.2.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "regex"
version = "1.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "tree-sitter"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0203df02a3b6dd63575cc1d6e609edc2181c9a11867a271b25cfd2abff3ec5ca"
dependencies = [
"cc",
"regex",
"regex-syntax",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-language"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
[[package]]
name = "tree-sitter-rust"
version = "0.23.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca8ccb3e3a3495c8a943f6c3fd24c3804c471fd7f4f16087623c7fa4c0068e8a"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "ts-bench"
version = "0.1.0"
dependencies = [
"tree-sitter",
"tree-sitter-rust",
]

View File

@@ -0,0 +1,13 @@
[package]
name = "ts-bench"
version = "0.1.0"
edition = "2021"
[dependencies]
tree-sitter = "0.23"
tree-sitter-rust = "0.23"
[profile.release]
opt-level = 3
[workspace]

184
.cargo/ts-bench/src/main.rs Normal file
View File

@@ -0,0 +1,184 @@
use std::env;
use std::fs;
use std::hint::black_box;
use std::time::Instant;
use tree_sitter::{Node, Parser, Query, QueryCursor};
fn char_to_token_est(chars: usize) -> usize {
(chars + 3) / 4
}
fn extract_skeleton(root: Node, src: &str, q: &Query) -> (String, usize) {
let mut out = String::new();
let mut count = 0usize;
let mut cursor = QueryCursor::new();
let matches = cursor.matches(q, root, src.as_bytes());
for mat in matches {
for cap in mat.captures {
let n = cap.node;
let start_byte = n.start_byte();
let header_end = {
let from_start = &src[start_byte..];
let mut depth: i32 = 0;
let mut end = from_start.len();
for (i, b) in from_start.char_indices() {
match b {
'{' => {
if depth == 0 {
end = i;
break;
}
depth += 1;
}
';' => {
end = i;
break;
}
_ => {}
}
let _ = depth;
}
start_byte + end.min(from_start.len())
};
let header = &src[start_byte..header_end].trim_end();
out.push_str(header);
out.push_str(" { ... }\n");
count += 1;
}
}
(out, count)
}
fn main() {
let args: Vec<String> = env::args().collect();
let path = args.get(1).expect("usage: ts-bench <file.rs>");
let src = fs::read_to_string(path).expect("read");
let src_chars = src.chars().count();
let src_tokens_est = char_to_token_est(src.len());
let mut parser = Parser::new();
parser.set_language(&tree_sitter_rust::LANGUAGE.into()).unwrap();
// warmup
let _ = parser.parse(&src, None).unwrap();
// ---- PURE PARSE timing ----
let n = 200;
let mut times_us = Vec::with_capacity(n);
for _ in 0..n {
let t0 = Instant::now();
let tree = parser.parse(black_box(&src), None).unwrap();
let t1 = Instant::now();
drop(tree);
times_us.push(t1.duration_since(t0).as_nanos() as f64 / 1000.0);
}
times_us.sort_by(|a, b| a.partial_cmp(b).unwrap());
let min = times_us[0];
let med = times_us[n / 2];
let p95 = times_us[(n as f64 * 0.95) as usize];
let mean = times_us.iter().sum::<f64>() / n as f64;
// ---- INCREMENTAL PARSE (edit one byte at end) ----
let mut src_edited = src.clone();
src_edited.push_str("\n// comment\n");
let tree0 = parser.parse(&src, None).unwrap();
let mut times_inc = Vec::with_capacity(n);
for _ in 0..n {
let t0 = Instant::now();
let t2 = parser.parse(&src_edited, Some(&tree0)).unwrap();
let t1 = Instant::now();
drop(t2);
times_inc.push(t1.duration_since(t0).as_nanos() as f64 / 1000.0);
}
times_inc.sort_by(|a, b| a.partial_cmp(b).unwrap());
let inc_med = times_inc[n / 2];
// ---- Query compilation timing ----
let query_text = r#"
[
(function_item name: (identifier) @name)
(struct_item name: (type_identifier) @name)
(enum_item name: (type_identifier) @name)
(trait_item name: (type_identifier) @name)
(impl_item trait: (type_identifier)? @trait type: (type_identifier)? @type)
(const_item name: (identifier) @name)
(static_item name: (identifier) @name)
(type_item name: (type_identifier) @name)
(mod_item name: (identifier) @name)
(macro_definition name: (identifier) @name)
]
"#;
let mut qtimes = Vec::with_capacity(n);
for _ in 0..n {
let t0 = Instant::now();
let q = Query::new(&tree_sitter_rust::LANGUAGE.into(), query_text).unwrap();
let t1 = Instant::now();
drop(q);
qtimes.push(t1.duration_since(t0).as_nanos() as f64 / 1000.0);
}
qtimes.sort_by(|a, b| a.partial_cmp(b).unwrap());
let q_med = qtimes[n / 2];
// ---- Skeleton extraction (compiled query reused) ----
let tree = parser.parse(&src, None).unwrap();
let root = tree.root_node();
let q = Query::new(&tree_sitter_rust::LANGUAGE.into(), query_text).unwrap();
let m = 200;
let mut ext_us = Vec::with_capacity(m);
let mut last_skel = String::new();
let mut last_count = 0;
for _ in 0..m {
let t0 = Instant::now();
let (skel, cnt) = extract_skeleton(root, &src, &q);
let t1 = Instant::now();
ext_us.push(t1.duration_since(t0).as_nanos() as f64 / 1000.0);
last_skel = skel;
last_count = cnt;
}
ext_us.sort_by(|a, b| a.partial_cmp(b).unwrap());
let ext_med = ext_us[m / 2];
let skel_chars = last_skel.chars().count();
let skel_tokens_est = char_to_token_est(last_skel.len());
println!("=== FILE ===");
println!("path : {}", path);
println!("lines : {}", src.lines().count());
println!("chars : {}", src_chars);
println!("bytes : {}", src.len());
println!("tokens est (~/4c) : {}", src_tokens_est);
println!();
println!("=== PARSE SPEED (pure tree-sitter-rust, release, {} iters) ===", n);
println!("min={:.2}us median={:.2}us p95={:.2}us mean={:.2}us", min, med, p95, mean);
println!("median ms : {:.3} ms", med / 1000.0);
println!();
println!("=== INCREMENTAL PARSE (small edit) ===");
println!("median : {:.2}us ({:.3}ms)", inc_med, inc_med / 1000.0);
println!();
println!("=== QUERY COMPILATION (once, should be cached) ===");
println!("median : {:.2}us ({:.3}ms)", q_med, q_med / 1000.0);
println!();
println!("=== SKELETON EXTRACTION (compiled query reused) ===");
println!("symbols extracted : {}", last_count);
println!("skeleton chars : {}", skel_chars);
println!("skeleton tokens : {}", skel_tokens_est);
println!("extract median : {:.2}us ({:.3}ms)", ext_med, ext_med / 1000.0);
println!();
println!("=== DENSITY (full vs skeleton) ===");
println!(
"chars : {:.1}x ({:.1}% reduction)",
src_chars as f64 / skel_chars as f64,
(1.0 - skel_chars as f64 / src_chars as f64) * 100.0
);
println!(
"tokens : {:.1}x ({:.1}% reduction)",
src_tokens_est as f64 / skel_tokens_est as f64,
(1.0 - skel_tokens_est as f64 / src_tokens_est as f64) * 100.0
);
println!();
println!("=== SKELETON PREVIEW (first 30 lines) ===");
for line in last_skel.lines().take(30) {
println!("{}", line);
}
}

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
# docker build 排除(docker context = repo 根,仅 df-relay 编译需 workspace 源码)
target
node_modules
dist
dist-ssr
.git
*.db
*.log
apps/df-miniapp/node_modules
apps/df-miniapp/dist
src-tauri/target

26
.gitignore vendored
View File

@@ -10,6 +10,9 @@ node_modules/
dist/ dist/
dist-ssr/ dist-ssr/
# uni-app 编译产物(微信小程序/H5 打包输出)
unpackage/
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
@@ -22,6 +25,7 @@ dist-ssr/
# Rust / Cargo # Rust / Cargo
target/ target/
target-musl/ # df-relay 交叉编译产物隔离目录(见 .cargo/config.toml)
!Cargo.lock !Cargo.lock
# Tauri # Tauri
@@ -56,6 +60,28 @@ tmp/
# AI 编排脚本(Claude Code Workflow 临时产物,非产品代码) # AI 编排脚本(Claude Code Workflow 临时产物,非产品代码)
workflows/ workflows/
# 临时分析脚本(团队分析/报告生成,非产品代码根目录级)
/analysis_*.txt
/analysis_*.py
/core_domains.txt
/core_domains.py
/final_report.txt
/grand_output.txt
/im_live_verify.txt
/liang_deep.py
/liangxianyou_deep.txt
/member_profiles.py
/read_all.py
/report_p2.txt
/report_wall.txt
/team_analysis.py
/team_deep_analysis.py
/team_member_profiles.txt
/team_report_final.txt
/tmp_*.py
/tmp_*.txt
/verify_im_live*.py
# MCP/SQLite 测试库(mcp_test2 --db 残留) # MCP/SQLite 测试库(mcp_test2 --db 残留)
devflow-dev.db devflow-dev.db
devflow-dev.db-shm devflow-dev.db-shm

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 — 产研全流程工作流平台 # DevFlow — 产研全流程工作流平台
> 版本: v0.1.0 | 创建: 2026-06-10 | 状态: 设计阶段 > 版本: v0.1.0 | 创建: 2026-06-10 | 状态: Phase 2 本地优先开发流程验证
## 一、项目定位 ## 一、项目定位
@@ -19,7 +19,9 @@
- **经验进化 (Evolution)**:开发过程中的模式自动沉淀为知识库(审查规则/Prompt模板/踩坑经验),持续进化复用 - **经验进化 (Evolution)**:开发过程中的模式自动沉淀为知识库(审查规则/Prompt模板/踩坑经验),持续进化复用
- **阶段插件**:想法→需求→编码→测试→发布,阶段即模板 - **阶段插件**:想法→需求→编码→测试→发布,阶段即模板
### 层级模型 ### 层级模型(业务层级)
DevFlow 的业务抽象分三层,自上而下层层实例化:
``` ```
💡 Idea Pool (想法池) — 独立运转,持续捕捉和评估 💡 Idea Pool (想法池) — 独立运转,持续捕捉和评估
@@ -31,6 +33,40 @@
└→ 🎯 Release (发布) — 合并多个 Task → 集成测试 → 发布 └→ 🎯 Release (发布) — 合并多个 Task → 集成测试 → 发布
``` ```
### 层级模型(执行层级)
Workflow DAG 的执行层进一步拆分为三层,这是 AI Factory 的核心抽象:
```
┌──────────────────────────────────────────────────┐
│ 模板层 (Template) │
│ "应该做什么" — 阶段蓝图、行业最佳实践 │
│ │
│ 职责: 定义节点拓扑 + 产出物规范 + 质量门禁 │
│ 生命周期: 长期存在,跨项目复用 │
│ 存储: YAML 文件 / DB 模板库 │
├──────────────────────────────────────────────────┤
│ 工作流层 (Workflow) │
│ "怎么执行" — DAG 实例、状态机、运行时 │
│ │
│ 职责: 拓扑排序 + 节点调度 + 状态流转 + 持久化 │
│ 生命周期: 随项目启动/结束,单次执行后归档 │
│ 载体: df-workflow (DAG + Executor + StateMachine) │
├──────────────────────────────────────────────────┤
│ 人设层 (Persona) │
│ "谁来做" — Agent 角色、能力边界、行为风格 │
│ │
│ 职责: 定义 system prompt + 可用工具 + 输出格式 │
│ 生命周期: 长期存在,跨节点复用 │
│ 注入点: AINode 执行时载入对应人设 │
└──────────────────────────────────────────────────┘
```
**关键设计原则**:三层各自独立演化,在 AINode 执行时交汇。
- 模板 = 可复用的蓝图(定义节点拓扑 + 建议人设 + 质量门禁)
- 工作流 = 模板的运行时实例(含状态、数据绑定、执行记录)
- 人设 = Agent 的角色卡system prompt + 工具集 + 行为规则)
### AI Working 定位体系 ### AI Working 定位体系
DevFlow 的终极交互模型是 **AI 驱动 (AI Working)****AI 是系统的主要操作者,人是监督者与决策者**。 DevFlow 的终极交互模型是 **AI 驱动 (AI Working)****AI 是系统的主要操作者,人是监督者与决策者**。
@@ -119,7 +155,7 @@ DevFlow 的终极交互模型是 **AI 驱动 (AI Working)****AI 是系统的
devflow/ devflow/
├── Cargo.toml # Workspace 根 ├── Cargo.toml # Workspace 根
├── crates/ ├── crates/
│ ├── df-core/ # 核心类型、错误、常量、事件 │ ├── df-types/ # 核心类型、错误、常量、事件、augmentation
│ ├── df-workflow/ # 工作流 DAG 引擎 (核心) │ ├── df-workflow/ # 工作流 DAG 引擎 (核心)
│ ├── df-nodes/ # 内置节点集合 (AI/Script/Human) │ ├── df-nodes/ # 内置节点集合 (AI/Script/Human)
│ ├── df-ai/ # AI 编排层 (Multi-Provider/Coordinator) │ ├── df-ai/ # AI 编排层 (Multi-Provider/Coordinator)
@@ -284,8 +320,9 @@ pub struct Decision {
- **LlmProvider trait**:统一接口,各模型实现(`provider.rs`)✅ - **LlmProvider trait**:统一接口,各模型实现(`provider.rs`)✅
- **ModelRouter**:按任务类型路由到最优模型 + 降级链 — ❌ 已删R-PD-7模型选择改由调用方在 `LlmProvider` 实现间直接指定 - **ModelRouter**:按任务类型路由到最优模型 + 降级链 — ❌ 已删R-PD-7模型选择改由调用方在 `LlmProvider` 实现间直接指定
- **AgentCoordinator**:多 Agent 协作Planner/Coder/Reviewer/Fixer— ⚠️ 骨架空壳(`coordinator.rs`,仅单元结构体 + 日志,未实现实际协调) - **AgentCoordinator**:多 Agent 协作(Plan DAG 拆解 + JoinSet 层内并行 + Token 预算池 + Reviewer 仲裁) — ✅ 已实现
- **ContextManager**Token 预算管理(`context.rs`,完整实现)✅ - **Git Worktree 隔离**:子 Agent 文件隔离(git worktree + 独立分支) — ✅ 已实现
- **ContextManager**Token 预算管理(`context/mod.rs`,拆分为 sanitize/manager_tests 子模块)✅
- **ToolRegistry**:工具注册(`ai_tools.rs`,供 Agent 调用)✅ - **ToolRegistry**:工具注册(`ai_tools.rs`,供 Agent 调用)✅
### 5.5 内置节点 (df-nodes) ### 5.5 内置节点 (df-nodes)
@@ -295,13 +332,13 @@ pub struct Decision {
| AINode | 调用 LLM支持流式输出、工具调用 | ✅ 已实现 | | AINode | 调用 LLM支持流式输出、工具调用 | ✅ 已实现 |
| ScriptNode | Shell/脚本执行 | ✅ 已实现 | | ScriptNode | Shell/脚本执行 | ✅ 已实现 |
| HumanNode | 人工审批/确认 (阻塞) | ✅ 已实现 | | HumanNode | 人工审批/确认 (阻塞) | ✅ 已实现 |
| DockerNode | Docker 容器操作 | ❌ 未实现 | | GitNode | Git 操作(branch/checkout/commit/merge/push/status/log) | ✅ 已实现 |
| GitNode | Git 操作 (libgit2) | ❌ 未实现 | | HTTPNode | HTTP 请求(GET/POST/PUT/DELETE) | ✅ 已实现 |
| NotifyNode | 通知 (桌面/飞书/Webhook) | ❌ 未实现 | | NotifyNode | 通知(桌面/Webhook) | ✅ 已实现 |
| HTTPNode | HTTP 请求 | ❌ 未实现 | | SubflowNode | 嵌套子工作流(深度限制防递归) | ✅ 已实现 |
| SubflowNode | 嵌套子工作流 | ❌ 未实现 | | DockerNode | Docker 容器内构建/测试(环境检测+授权) | ✅ 已实现 |
> `crates/df-nodes/src/` 实际仅 ai_node / script_node / human_node 3 文件Docker/Git/Notify/HTTP/Subflow 为设计预留,尚未实现)。详见 [df-nodes 模块文档](docs/03-模块文档/df-nodes-节点集合-2026-06-12.md)。 > `crates/df-nodes/src/` ai_node / script_node / human_node / git_node / http_node / notify_node / subflow_node / docker_node 8 文件(全部已实现)。
## 六、数据模型 ## 六、数据模型
@@ -497,20 +534,26 @@ CREATE TABLE app_settings (
-- 内部表: schema_version (version INTEGER PRIMARY KEY) — 迁移版本记录 -- 内部表: schema_version (version INTEGER PRIMARY KEY) — 迁移版本记录
``` ```
## 七、阶段模板 ## 七、三层模型:模板 → 工作流 → 人设
5 个内置阶段作为工作流模板YAML 定义),用户可自定义。 > 本章详细设计已迁至专项文档,详见 [docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md](./docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md)。此处仅保留摘要性定义。
- 💡 **想法**:市场分析 → 竞品调研 → 可行性评分 ### 7.1 三层定义
- 📋 **需求**AI 生成 PRD → 人工审阅 → 任务拆解
- 💻 **编码**AI 编码 → 代码审查 → 自动修复 | 层 | 回答的问题 | 本质 | 生命周期 | 当前状态 |
- 🧪 **测试**:运行测试 → AI 分析失败 → 回归验证 |----|-----------|------|---------|---------|
- 🚀 **发布**:构建 → 人工确认 → 部署 → 健康检查 | **流程模板 (Template)** | 应该做什么 | 可复用的蓝图(节点拓扑 + 建议人设 + 质量门禁) | 长期存在,跨项目复用 | ⚡ 需重新设计(原 df-stages 已移除) |
| **工作流 (Workflow)** | 怎么执行 | 模板的运行时实例DAG + 状态 + 数据绑定) | 随项目启停,单次执行归档 | ✅ df-workflow 核心完成 |
| **人设 (Persona)** | 谁来做 | Agent 角色卡system prompt + 工具集 + 行为规则) | 长期存在,跨节点复用 | ⬜ 待设计 |
**关键原则**:模板不绑定具体人设、工作流不感知人设、人设与模板解耦。
详细定义、三者关系、实例化流程、数据结构及 YAML 模板示例见 [专项设计文档](./docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md)。
## 八、Phase 规划 ## 八、Phase 规划
### Phase 1 — 引擎骨架 (4-6 周) ### Phase 1 — 引擎骨架 (4-6 周)
- df-core + df-workflow (DAG + Node trait + Executor) - df-types + df-workflow (DAG + Node trait + Executor)
- df-storage (SQLite 基础表) - df-storage (SQLite 基础表)
- df-execute (Shell 执行) - df-execute (Shell 执行)
- 最小前端:项目列表 + 工作流执行日志 - 最小前端:项目列表 + 工作流执行日志
@@ -528,9 +571,10 @@ CREATE TABLE app_settings (
- 前端:想法池视图 + 多项目 Tab - 前端:想法池视图 + 多项目 Tab
- 验证:想法捕捉 → AI 评估 → 立项 → 工作流执行 - 验证:想法捕捉 → AI 评估 → 立项 → 工作流执行
### Phase 4 — 节点丰富 + 阶段插件 (3-4 周) ### Phase 4 — 节点丰富 + 三层模型落地 (3-4 周)
- df-nodes (Docker/Git/Human/HTTP) - df-nodes (Docker/Git/Human/HTTP)
- ~~df-stages (5 阶段模板)~~ — 已移除2026-06-14 零引用清理 - 流程模板系统YAML 定义 + 模板库 + 实例化引擎
- 人设系统AgentPersona 数据结构 + 内置人设 + 工具过滤)
- 条件分支 + 断点续跑 - 条件分支 + 断点续跑
- 验证:跑通标准产研流程模板 - 验证:跑通标准产研流程模板
@@ -566,3 +610,6 @@ CREATE TABLE app_settings (
8. **本地优先**SQLite 嵌入,不依赖云服务 8. **本地优先**SQLite 嵌入,不依赖云服务
9. **多模型并行**:统一抽象,按任务路由,不锁定单一模型 9. **多模型并行**:统一抽象,按任务路由,不锁定单一模型
10. **流式优先**AI 输出、Shell 输出全部流式推送到前端 10. **流式优先**AI 输出、Shell 输出全部流式推送到前端
11. **模板/工作流/人设三层分离**:模板是蓝图,工作流是实例,人设是角色卡。三层各自独立演化,在 AINode 执行时交汇
12. **人设与模板解耦**:模板标注建议人设但不绑定,同一个人设可用于不同模板的同类节点
13. **模板实例化**:模板 → 工作流实例 + 人设分配,允许实例化时按项目覆盖人设

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` 纳入提交)

567
Cargo.lock generated
View File

@@ -248,6 +248,64 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "axum"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"base64 0.22.1",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite 0.24.0",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.21.7" version = "0.21.7"
@@ -721,6 +779,26 @@ dependencies = [
"syn 2.0.117", "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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]] [[package]]
name = "dbus" name = "dbus"
version = "0.9.11" version = "0.9.11"
@@ -771,6 +849,7 @@ dependencies = [
"async-trait", "async-trait",
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",
"dashmap",
"df-ai", "df-ai",
"df-execute", "df-execute",
"df-ideas", "df-ideas",
@@ -778,6 +857,7 @@ dependencies = [
"df-nodes", "df-nodes",
"df-project", "df-project",
"df-storage", "df-storage",
"df-tunnel",
"df-types", "df-types",
"df-workflow", "df-workflow",
"futures", "futures",
@@ -794,6 +874,16 @@ dependencies = [
"tauri-plugin-window-state", "tauri-plugin-window-state",
"tokio", "tokio",
"tracing", "tracing",
"tracing-appender",
"tracing-subscriber",
"tree-sitter",
"tree-sitter-go",
"tree-sitter-java",
"tree-sitter-javascript",
"tree-sitter-python",
"tree-sitter-rust",
"tree-sitter-typescript",
"uuid",
] ]
[[package]] [[package]]
@@ -802,13 +892,15 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"bytes",
"df-ai-core", "df-ai-core",
"df-types", "df-types",
"eventsource-stream",
"futures", "futures",
"rand 0.8.6",
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
"tempfile",
"tokio", "tokio",
"tracing", "tracing",
] ]
@@ -878,6 +970,7 @@ dependencies = [
"df-storage", "df-storage",
"df-types", "df-types",
"df-workflow", "df-workflow",
"reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
@@ -893,10 +986,27 @@ dependencies = [
"df-types", "df-types",
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
] ]
[[package]]
name = "df-relay"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"axum",
"futures-util",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]] [[package]]
name = "df-storage" name = "df-storage"
version = "0.1.0" version = "0.1.0"
@@ -912,6 +1022,21 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "df-tunnel"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"futures-util",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite 0.23.1",
"tracing",
]
[[package]] [[package]]
name = "df-types" name = "df-types"
version = "0.1.0" version = "0.1.0"
@@ -933,6 +1058,7 @@ dependencies = [
"futures", "futures",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml",
"tokio", "tokio",
"tracing", "tracing",
] ]
@@ -1184,17 +1310,6 @@ dependencies = [
"pin-project-lite", "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]] [[package]]
name = "fallible-iterator" name = "fallible-iterator"
version = "0.3.0" version = "0.3.0"
@@ -1534,8 +1649,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi", "wasi",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1545,9 +1662,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi 5.3.0", "r-efi 5.3.0",
"wasip2", "wasip2",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1842,6 +1961,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "1.10.1" version = "1.10.1"
@@ -1856,6 +1981,7 @@ dependencies = [
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
"httpdate",
"itoa", "itoa",
"pin-project-lite", "pin-project-lite",
"smallvec", "smallvec",
@@ -1876,6 +2002,7 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
"webpki-roots",
] ]
[[package]] [[package]]
@@ -2259,6 +2386,12 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]] [[package]]
name = "leb128fmt" name = "leb128fmt"
version = "0.1.0" version = "0.1.0"
@@ -2371,6 +2504,12 @@ version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "markup5ever" name = "markup5ever"
version = "0.38.0" version = "0.38.0"
@@ -2382,6 +2521,21 @@ dependencies = [
"web_atoms", "web_atoms",
] ]
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.1" version = "2.8.1"
@@ -2403,12 +2557,6 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -2512,13 +2660,12 @@ dependencies = [
] ]
[[package]] [[package]]
name = "nom" name = "nu-ansi-term"
version = "7.1.3" version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [ dependencies = [
"memchr", "windows-sys 0.61.2",
"minimal-lexical",
] ]
[[package]] [[package]]
@@ -3201,6 +3348,61 @@ dependencies = [
"memchr", "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]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@@ -3229,8 +3431,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [ dependencies = [
"libc", "libc",
"rand_chacha", "rand_chacha 0.3.1",
"rand_core", "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]] [[package]]
@@ -3240,7 +3452,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [ dependencies = [
"ppv-lite86", "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]] [[package]]
@@ -3252,6 +3474,15 @@ dependencies = [
"getrandom 0.2.17", "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]] [[package]]
name = "raw-window-handle" name = "raw-window-handle"
version = "0.6.2" version = "0.6.2"
@@ -3352,6 +3583,8 @@ dependencies = [
"native-tls", "native-tls",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types", "rustls-pki-types",
"serde", "serde",
"serde_json", "serde_json",
@@ -3359,6 +3592,7 @@ dependencies = [
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-native-tls", "tokio-native-tls",
"tokio-rustls",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http",
@@ -3368,6 +3602,7 @@ dependencies = [
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams 0.4.2", "wasm-streams 0.4.2",
"web-sys", "web-sys",
"webpki-roots",
] ]
[[package]] [[package]]
@@ -3491,6 +3726,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"ring",
"rustls-pki-types", "rustls-pki-types",
"rustls-webpki", "rustls-webpki",
"subtle", "subtle",
@@ -3503,6 +3739,7 @@ version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [ dependencies = [
"web-time",
"zeroize", "zeroize",
] ]
@@ -3614,7 +3851,7 @@ dependencies = [
"generic-array", "generic-array",
"num", "num",
"once_cell", "once_cell",
"rand", "rand 0.8.6",
"serde", "serde",
"zbus 4.4.0", "zbus 4.4.0",
] ]
@@ -3743,6 +3980,7 @@ version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [ dependencies = [
"indexmap 2.14.0",
"itoa", "itoa",
"memchr", "memchr",
"serde", "serde",
@@ -3750,6 +3988,17 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]] [[package]]
name = "serde_repr" name = "serde_repr"
version = "0.1.20" version = "0.1.20"
@@ -3823,6 +4072,19 @@ dependencies = [
"syn 2.0.117", "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]] [[package]]
name = "serialize-to-javascript" name = "serialize-to-javascript"
version = "0.1.2" version = "0.1.2"
@@ -3876,6 +4138,15 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "2.0.1" version = "2.0.1"
@@ -3986,6 +4257,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
[[package]] [[package]]
name = "string_cache" name = "string_cache"
version = "0.9.0" version = "0.9.0"
@@ -4033,6 +4310,12 @@ dependencies = [
"serde_json", "serde_json",
] ]
[[package]]
name = "symlink"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
[[package]] [[package]]
name = "syn" name = "syn"
version = "1.0.109" version = "1.0.109"
@@ -4536,6 +4819,15 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.47" version = "0.3.47"
@@ -4640,6 +4932,32 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-tungstenite"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd"
dependencies = [
"futures-util",
"log",
"native-tls",
"tokio",
"tokio-native-tls",
"tungstenite 0.23.0",
]
[[package]]
name = "tokio-tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite 0.24.0",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.18" version = "0.7.18"
@@ -4786,6 +5104,7 @@ dependencies = [
"tokio", "tokio",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
@@ -4829,11 +5148,25 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [ dependencies = [
"log",
"pin-project-lite", "pin-project-lite",
"tracing-attributes", "tracing-attributes",
"tracing-core", "tracing-core",
] ]
[[package]]
name = "tracing-appender"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
dependencies = [
"crossbeam-channel",
"symlink",
"thiserror 2.0.18",
"time",
"tracing-subscriber",
]
[[package]] [[package]]
name = "tracing-attributes" name = "tracing-attributes"
version = "0.1.31" version = "0.1.31"
@@ -4852,6 +5185,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
] ]
[[package]] [[package]]
@@ -4876,12 +5239,129 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "tree-sitter"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87"
dependencies = [
"cc",
"regex",
"regex-syntax",
"serde_json",
"streaming-iterator",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-go"
version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-java"
version = "0.23.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-javascript"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68204f2abc0627a90bdf06e605f5c470aa26fdcb2081ea553a04bdad756693f5"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-language"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
[[package]]
name = "tree-sitter-python"
version = "0.23.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-rust"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-typescript"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]] [[package]]
name = "try-lock" name = "try-lock"
version = "0.2.5" version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"native-tls",
"rand 0.8.6",
"sha1",
"thiserror 1.0.69",
"utf-8",
]
[[package]]
name = "tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.8.6",
"sha1",
"thiserror 1.0.69",
"utf-8",
]
[[package]] [[package]]
name = "typeid" name = "typeid"
version = "1.0.3" version = "1.0.3"
@@ -4964,6 +5444,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
@@ -5019,6 +5505,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"
@@ -5225,6 +5717,16 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "web_atoms" name = "web_atoms"
version = "0.2.4" version = "0.2.4"
@@ -5281,6 +5783,15 @@ dependencies = [
"system-deps", "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]] [[package]]
name = "webview2-com" name = "webview2-com"
version = "0.38.2" version = "0.38.2"
@@ -6018,7 +6529,7 @@ dependencies = [
"hex", "hex",
"nix", "nix",
"ordered-stream", "ordered-stream",
"rand", "rand 0.8.6",
"serde", "serde",
"serde_repr", "serde_repr",
"sha1", "sha1",

View File

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

View File

@@ -1,6 +1,6 @@
# DevFlow — 项目进展与工作交接 # 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 原生创作流程驾驶舱,从想法到创作成果的全流程管理 | | 定位 | AI 原生创作流程驾驶舱,从想法到创作成果的全流程管理 |
| 技术栈 | Tauri v2 + Vue 3 + TypeScript + Pinia / Rust Workspace (8 crate) / SQLite | | 技术栈 | Tauri v2 + Vue 3 + TypeScript + Pinia / Rust Workspace (8 crate) / SQLite |
| 路径 | `E:/wk-lab/devflow/` | | 路径 | `E:/wk-lab/devflow/` |
| 架构文档 | `ARCHITECTURE.md` (22,745 字) | | 架构文档 | `ARCHITECTURE.md` + `docs/02-架构设计/` (含多 Agent 设计文档) |
| Git 状态 | 已纳入版本控制(完整 commit 历史,当前分支 feat/batch-260615-workflow-unlock) | | Git 状态 | 已纳入版本控制(main 分支) |
| AI 能力 | df-ai OpenAI/Anthropic 双协议 Provider + 12 工具 + Agentic Loop | | AI 能力 | OpenAI/Anthropic 双协议 + 40+ 工具 + Agentic Loop + 多 Agent 并行 + 人设系统 |
--- ---
@@ -23,7 +23,7 @@
| Crate | 文件数 | 总行数 | 有效行 | 实现程度 | | Crate | 文件数 | 总行数 | 有效行 | 实现程度 |
|-------|--------|--------|--------|---------| |-------|--------|--------|--------|---------|
| df-core | 4 | 429 | ~260 | ✅ 完整 — 错误/事件/状态枚举/ID生成 | | df-types | 4 | 429 | ~260 | ✅ 完整 — 错误/事件/状态枚举/ID生成/augmentation |
| df-workflow | 9 | 908 | ~620 | ✅ 核心 — DAG拓扑排序/执行器/状态机/事件总线 可用 | | 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-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 复用) | | df-execute | 5 | 203 | 129 | ✅ Shell 执行器真实可用tokio::process被 run_command 复用) |
@@ -59,7 +59,7 @@
``` ```
真实可用路径(有端到端逻辑): 真实可用路径(有端到端逻辑):
df-core (类型基础) df-types (类型基础)
→ df-workflow (DAG 拓扑排序 + 执行器 + 状态机 + EventBus) → df-workflow (DAG 拓扑排序 + 执行器 + 状态机 + EventBus)
→ df-execute/shell (tokio::process Shell 执行) → df-execute/shell (tokio::process Shell 执行)
→ df-storage (SQLite 连接 + 迁移 + 建表) → df-storage (SQLite 连接 + 迁移 + 建表)
@@ -98,7 +98,7 @@
| 任务 | 状态 | 说明 | | 任务 | 状态 | 说明 |
|------|------|------| |------|------|------|
| df-core 类型系统 | ✅ 完成 | 错误/事件/状态枚举/ID生成 | | df-types 类型系统 | ✅ 完成 | 错误/事件/状态枚举/ID生成/augmentation |
| df-workflow DAG 引擎 | ✅ 完成 | 拓扑排序/执行器/状态机/EventBus | | df-workflow DAG 引擎 | ✅ 完成 | 拓扑排序/执行器/状态机/EventBus |
| df-storage SQLite 基础表 | ✅ 完成 | 6 张表 + 4 索引,缺 CRUD | | df-storage SQLite 基础表 | ✅ 完成 | 6 张表 + 4 索引,缺 CRUD |
| df-execute Shell 执行 | ✅ 完成 | tokio::process 实现 | | df-execute Shell 执行 | ✅ 完成 | tokio::process 实现 |
@@ -190,7 +190,7 @@
### [Sprint 3] 2026-06-11 — Migrations V2 + Executor 并行化 + Tauri IPC三代理并行 ### [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、失败中止下游层 - **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 - **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 对应方法) - **批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 - **批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

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,minimum-scale=1.0,user-scalable=no" />
<title>DevFlow Mini</title>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.documentElement.style.fontSize = window.innerWidth / 20 + 'px'
})
</script>
<link rel="stylesheet" href="<%= BASE_URL %>static/index.<%= VUE_APP_INDEX_CSS_HASH %>.css" />
</head>
<body>
<noscript>
<strong>本应用需 JavaScript 启用。</strong>
</noscript>
<div id="app"></div>
</body>
</html>

13924
apps/df-miniapp/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
{
"name": "df-miniapp",
"version": "0.1.0",
"description": "DevFlow 微信小程序(uni-app Vue3)—— 远程 AI Chat 操作桌面端",
"private": true,
"scripts": {
"dev:h5": "uni",
"dev:mp-weixin": "uni -p mp-weixin",
"build:h5": "uni build",
"build:mp-weixin": "uni build -p mp-weixin",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-components": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-h5": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-mp-weixin": "3.0.0-alpha-4080720251125001",
"marked": "^18.0.5",
"vue": "^3.4.0",
"vue-i18n": "^9.0.0"
},
"devDependencies": {
"@dcloudio/types": "^3.4.0",
"@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001",
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001",
"@types/node": "^20.0.0",
"@vue/runtime-core": "^3.4.0",
"@vue/tsconfig": "^0.5.0",
"sass": "^1.70.0",
"typescript": "^5.3.0",
"vite": "^5.2.8",
"vue-tsc": "^2.0.0"
}
}

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
import { useAiChat } from '@/composables/useAiChat'
// 应用启动:连接 relay。useAiChat 单例管理 WS 生命周期 + 状态。
const aiChat = useAiChat()
onLaunch(() => {
console.log('[DevFlow Mini] 应用启动')
aiChat.connect()
})
onShow(() => {
// 切回前台:重启心跳/重连 + 恢复流式看门狗(P1-9/P1-10)
aiChat.resumeBackground()
})
onHide(() => {
// 切后台:停心跳 + 流式看门狗,防后台定时器节流触发写状态(P1-9/P1-10)
console.log('[DevFlow Mini] 应用进入后台')
aiChat.pauseBackground()
})
</script>
<style lang="scss">
page {
background-color: $uni-bg-color;
color: $uni-text-color;
font-size: $uni-font-size-base;
}
</style>

View File

@@ -0,0 +1,586 @@
/**
* df-relay WS 客户端(uni-app 跨端适配)
*
* 协议(对齐 crates/df-relay/src/relay.rs handle_connection):
* 1. 建立 WS 连接到 `/ws/miniapp`
* 2. 首帧发 Hello{kind:'miniapp', device_id, token} 身份宣告(relay.rs:197-208,10s 超时)
* 3. 校验通过后进入收发循环;失败 relay 发 Error 帧 + Close
*
* 跨端适配:
* - 微信小程序:uni.connectSocket(微信原生 API,非浏览器 WebSocket)
* - H5:uni.connectSocket 封装浏览器 WebSocket
* - 统一经 uni API 封装,业务层不直接 new WebSocket
*
* 连接生命周期(P0/P1 审计后重构,根本性,非补丁):
* - 所有"建连"经 reconnectNow() 幂等入口:clear 退避 timer + cleanup + openSocket
* connect()/resumeIfDisconnected()/scheduleReconnect 回调全部经此,杜绝双连竞态 + socket 泄漏
* - openSocket() 幂等:首行 destroySocket 残留 socket,保证 this.socket 引用唯一
* - destroySocket(task):显式注销 onOpen/onMessage/onClose/onError(覆盖空函数,微信无 offXxx)
* + close,防旧 socket 闭包回调污染新连接(单例 this 句柄被多 socket 捕获)
* - cleanup() 彻底化:destroySocket + 停心跳 + 清所有 timer + 清 pendingQueue
* 清队列防跨连接/跨会话陈旧命令补发(旧命令上下文已失效)
* - 超时三重兜底:连接超时(onOpen 未到)/握手超时(hello_ack 丢失,>relay 10s)/
* onError 兜底(微信 socket onError 不保证 onClose 跟随,DNS/TLS/域名未加白场景)
* - 心跳活性检测:lastInboundAt + 2x 间隔判定半开死连接(NAT 超时/网络切换)
* - pendingQueue 上限 + TTL:防握手永卡时无限堆积内存泄漏
*
* 重连:指数退避(baseDelay * 2^min(n,6),封顶 maxDelay,带随机抖动)
* 心跳:固定间隔发 ControlMessage{control_kind:'ping'},防空闲断开 + 在线状态
* JSON 透传:入站解析为 BroadcastMessage,payload 是 AiChatEvent JSON
* 出站直接发 Command JSON(relay 自动包 payload,见 relay.rs:332-365)
*/
import { getConfig } from '@/config'
import type { BroadcastMessage, Hello, MiniCommand, ControlMessage } from '@/types/relay'
/** WS 连接状态 */
export type WsStatus = 'disconnected' | 'connecting' | 'connected' | 'handshaking' | 'reconnecting'
/** 入站事件回调(Event/Control 消息分派) */
export interface WsHandlers {
/** 收到 Event 消息(payload 已解为 AiChatEvent JSON,业务层按 type discriminator 处理) */
onEvent: (msg: BroadcastMessage) => void
/** 收到 Control 消息(心跳响应 / 在线状态等) */
onControl: (msg: ControlMessage) => void
/** 状态变更(连接 / 断开 / 重连中) */
onStatus: (status: WsStatus, detail?: string) => void
}
/** pendingQueue 带入队时间戳,flush 时按 TTL 丢弃陈旧命令 */
interface QueuedCmd {
cmd: MiniCommand
ts: number
}
/** 握手超时(>relay.rs:321 服务端 10s,留余量) */
const HANDSHAKE_TIMEOUT_MS = 12000
/** 连接超时(openSocket 后 onOpen 未到则重连;治 onError 不触发 onClose 场景) */
const CONNECT_TIMEOUT_MS = 8000
/** pendingQueue 上限(防握手永卡时无限堆积内存泄漏) */
const PENDING_MAX = 50
/** pendingQueue 单条 TTL(flush 时超时丢弃,防跨连接陈旧命令补发) */
const PENDING_TTL_MS = 60000
/** 心跳活性判定因子:超过 N 倍心跳间隔无入站消息判半开死连接 */
const HEARTBEAT_DEAD_FACTOR = 2
/** 最大重连次数(永久性故障如 relayHost 错/中继下线,停止无限退避,提示用户检查) */
const MAX_RECONNECT_ATTEMPTS = 20
/**
* WS 客户端单例(miniapp 全局一个连接)
*
* 设计:单 socket + 单 handlers 回调集合(composable 订阅,业务层不直接持 socket)。
* 握手/心跳/重连机制内部封装,业务层仅关心 send(Command) + onEvent。
*/
class WsClient {
private socket: UniApp.SocketTask | null = null
private handlers: WsHandlers | null = null
private status: WsStatus = 'disconnected'
/** 握手是否已完成(收到首个广播或握手成功信号) */
private handshaked = false
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
/** 握手超时定时器(handleOpen 发 Hello 后启,收 hello_ack 后清) */
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
/** 连接超时定时器(openSocket 启,onOpen 到达后清) */
private connectTimer: ReturnType<typeof setTimeout> | null = null
private reconnectAttempts = 0
/** 主动关闭标志(用户调用 disconnect 时置 true,不再触发重连) */
private manualClose = false
/**
* 握手完成前积压的命令队列(send 在 !handshaked 时不丢弃,入队等 hello_ack 后补发)。
* 治「握手成功到首帧竞态丢命令」:握手刚翻 true 时 relay 设备路由表可能未就绪,
* 业务层快速重发会被 send 守卫放行后静默丢失;入队保证握手稳定后顺序补发。
* 带时间戳 + 上限 + TTL,防握手永卡时无限堆积(见 PENDING_MAX/PENDING_TTL_MS)。
*/
private pendingQueue: QueuedCmd[] = []
/** 最后入站消息时间戳(心跳活性检测,relay 无 pong 故用任意入站消息替代) */
private lastInboundAt = 0
/** 订阅(单订阅,composable 注册一次) */
subscribe(h: WsHandlers): void {
this.handlers = h
}
/** 读取当前状态 */
getStatus(): WsStatus {
return this.status
}
/** 主动连接 */
connect(): void {
if (this.status === 'connected' || this.status === 'connecting' || this.status === 'handshaking') {
return
}
this.manualClose = false
// 用户主动连接复位重连计数(可与达上限后的自动退避区分,允许手动重试)
this.reconnectAttempts = 0
// 统一经 reconnectNow(幂等 clear timer + cleanup + openSocket),杜绝 reconnecting 态双连
this.reconnectNow()
}
/** 主动断开(用户手动,不重连) */
disconnect(): void {
this.manualClose = true
this.reconnectAttempts = 0
// cleanup 内部清 pendingQueue + 注销 socket + 清所有 timer
this.cleanup()
this.setStatus('disconnected', 'manual close')
}
/**
* 若已断开或正在退避重连则立即重连(onShow 时调用,小程序后台回前台)。
*
* 覆盖 `disconnected` 与 `reconnecting` 两种态:
* - disconnected:onClose 已触发,直接重连;
* - reconnecting:退避计时未到,抢占退避立即重连(治「切后台回前台卡在 reconnecting」头号症状)。
*
* 统一经 reconnectNow(幂等清理),保证抢占时不残留旧 socket/timer。
*/
resumeIfDisconnected(): void {
if (this.manualClose) return
if (this.status === 'disconnected' || this.status === 'reconnecting') {
// 用户触发的立即重连(onShow 抢占退避)复位计数,允许达上限后手动恢复
this.reconnectAttempts = 0
this.reconnectNow()
}
}
/**
* 暂停后台定时器(小程序 onHide 调,P1-9):停心跳 setInterval。
*
* 微信后台定时器节流但仍触发,心跳持续发 ping 浪费 + 后台无入站致半开检测误判。
* 不 disconnect(disconnect 清队列 + manualClose,不适合后台暂离),仅停心跳保留连接。
* 重连 setTimeout 若后台断连触发,单次非循环,影响可接受(MVP 不暂停)。
*/
pauseBackground(): void {
this.stopHeartbeat()
}
/**
* 恢复前台(小程序 onShow 调):连接在则重启心跳,断则重连。
*/
resumeBackground(): void {
if (this.status === 'connected' && this.handshaked) {
this.startHeartbeat()
} else {
this.resumeIfDisconnected()
}
}
/**
* 发送 Command(miniapp → device)
*
* 不包装 BroadcastMessage —— relay.rs:332-365 入站文本帧直接当 payload 包成
* BroadcastMessage(device_id/kind/source/from 由 relay 填),故客户端仅发业务 JSON。
*/
send(cmd: MiniCommand): boolean {
console.log('[dbg:ws] send', cmd.cmd, 'socket=', !!this.socket, 'handshaked=', this.handshaked)
if (!this.socket || !this.handshaked) {
// 握手未完成不丢弃,入队待 hello_ack 后补发(治握手成功到首帧竞态丢命令)。
// 仅在未主动断开(manualClose=false)且连接尚有恢复预期时入队,
// 否则(manualClose=true / 长期 disconnected)不堆积陈旧命令。
if (this.manualClose) {
console.warn('[WsClient] 已主动断开,丢弃命令', cmd.cmd)
return false
}
this.enqueuePending(cmd)
return false
}
try {
const text = JSON.stringify(cmd)
this.socket.send({ data: text })
return true
} catch (e) {
console.error('[WsClient] send 失败', e)
return false
}
}
/** 入队(带上限 + 时间戳,TTL 丢弃见 flushPendingQueue) */
private enqueuePending(cmd: MiniCommand): void {
if (this.pendingQueue.length >= PENDING_MAX) {
// 超上限丢弃最旧,防握手永卡时无限堆积内存泄漏
this.pendingQueue.shift()
console.warn('[WsClient] pendingQueue 超上限,丢弃最旧命令')
}
this.pendingQueue.push({ cmd, ts: Date.now() })
console.log('[WsClient] 握手未完成,命令入队待补发', cmd.cmd, 'queue=', this.pendingQueue.length)
}
/**
* 补发握手期间积压的命令队列(收到 hello_ack 后调)。
*
* list_conversations 已由握手逻辑自己发(不进队列),此处仅补发业务 send 积压。
* 补发前置 handshaked=true,send 守卫放行,逐条直发 socket。
* TTL 丢弃:入队超 PENDING_TTL_MS 的视为陈旧(上下文已失效),不补发。
*/
private flushPendingQueue(): void {
if (this.pendingQueue.length === 0) return
const now = Date.now()
console.log('[WsClient] 补发握手积压命令', this.pendingQueue.length)
while (this.pendingQueue.length > 0) {
const item = this.pendingQueue.shift()!
if (now - item.ts > PENDING_TTL_MS) {
console.warn('[WsClient] 补发时丢弃超时陈旧命令', item.cmd.cmd)
continue
}
try {
if (this.socket) {
this.socket.send({ data: JSON.stringify(item.cmd) })
}
} catch (e) {
console.error('[WsClient] 补发命令失败', item.cmd.cmd, e)
}
}
}
// ─── 连接生命周期(统一入口 + 幂等清理) ───────────────────
/**
* 幂等立即建连入口(clear 退避 timer + cleanup + openSocket)。
*
* 所有需要"立即开新连接"的路径(connect/resumeIfDisconnected/scheduleReconnect 回调)
* 统一经此,保证:
* - 不残留挂起的 reconnectTimer(防退避 timer 与 openSocket 双触发建两个 socket);
* - 旧 socket 被 cleanup 注销+关闭(防 this.socket 引用覆盖后句柄泄漏);
* - pendingQueue 被清(防跨连接/跨会话陈旧命令补发)。
*/
private reconnectNow(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
this.cleanup()
this.openSocket()
}
private openSocket(): void {
// 幂等:残留 socket 先注销+关闭(reconnectNow 已 cleanup,此为双保险)
if (this.socket) {
this.destroySocket(this.socket)
this.socket = null
}
const cfg = getConfig()
this.setStatus('connecting')
console.log('[WsClient] 连接', cfg.relayHost)
let task: UniApp.SocketTask
try {
task = uni.connectSocket({
url: cfg.relayHost,
complete: () => {
// complete 回调在 uni-app 仅表示"任务创建完成",非连接完成(onOpen 才是)
},
})
} catch (e) {
console.error('[WsClient] connectSocket 异常', e)
this.scheduleReconnect()
return
}
this.socket = task
task.onOpen(() => this.handleOpen())
task.onMessage((res) => this.handleMessage(res))
task.onClose((res) => this.handleClose(res))
task.onError((res) => this.handleError(res))
// 连接超时:onOpen 未到则重连(治 onError 不触发 onClose 的场景:
// DNS 解析失败/TLS 握手失败/relayHost 不可达/wss 域名未加白名单)
this.connectTimer = setTimeout(() => {
this.connectTimer = null
if (this.status === 'connecting') {
console.warn('[WsClient] 连接超时,onOpen 未到,重连')
this.cleanup()
this.scheduleReconnect()
}
}, CONNECT_TIMEOUT_MS)
}
/** WS 已建立连接 → 立即发 Hello 握手帧 + 启握手超时 */
private handleOpen(): void {
// onOpen 到达,取消连接超时
if (this.connectTimer) {
clearTimeout(this.connectTimer)
this.connectTimer = null
}
this.setStatus('handshaking')
const cfg = getConfig()
const hello: Hello = {
kind: 'miniapp',
device_id: cfg.deviceId,
token: cfg.token,
}
try {
this.socket?.send({ data: JSON.stringify(hello) })
} catch (e) {
console.error('[WsClient] 发送 Hello 失败', e)
// 失败先 cleanup(关坏 socket + 清监听器)再退避,与 handleHandshakeResponse 失败路径对齐
this.cleanup()
this.scheduleReconnect()
return
}
// 握手超时:hello_ack 丢失(网络抖动/relay send_text 失败吞错 relay.rs:253-257)则重连。
// >relay.rs:321 服务端 10s,留余量。握手成功/失败时 handleHandshakeResponse 会清此 timer。
this.handshakeTimer = setTimeout(() => {
this.handshakeTimer = null
if (!this.handshaked) {
console.warn('[WsClient] 握手超时,hello_ack 未收到,重连')
this.cleanup()
this.scheduleReconnect()
}
}, HANDSHAKE_TIMEOUT_MS)
}
/** 入站消息分派:握手阶段首帧=控制(握手成功/失败),握手后=BroadcastMessage */
private handleMessage(res: { data: string | ArrayBuffer }): void {
// 协议纯 JSON 文本帧(relay 发文本),ArrayBuffer 非预期 —— 加 warn 可观测
// (防二进制帧静默丢弃致握手期 hello_ack 被吃却无日志)
if (typeof res.data !== 'string') {
console.warn('[WsClient] 收到非预期二进制帧,丢弃', (res.data as ArrayBuffer).byteLength)
return
}
const text = res.data
if (!text) return
// 活性:任意入站帧更新(含 relay 回的 pong 心跳响应,用于半连接检测)
this.lastInboundAt = Date.now()
// 握手阶段可能收到 relay 发的错误控制帧(relay.rs:200-238)
if (!this.handshaked) {
this.handleHandshakeResponse(text)
return
}
// 解析 BroadcastMessage(device 事件经 relay 包成完整 BroadcastMessage 下发)
let msg: BroadcastMessage
try {
msg = JSON.parse(text) as BroadcastMessage
} catch (e) {
console.warn('[WsClient] 入站消息 JSON 解析失败', e, text.slice(0, 200))
return
}
// 按 kind 分派:Event → 业务层;Control → 心跳响应等
if (msg.kind === 'event') {
this.handlers?.onEvent(msg)
} else if (msg.kind === 'control') {
this.handlers?.onControl(msg.payload as ControlMessage)
}
// command 是 device→miniapp 反向(不应在此端收到,忽略)
}
/** 处理握手响应(relay 发 hello_ack 显式 ack;失败时发 error 帧 + 关连接) */
private handleHandshakeResponse(text: string): void {
// 结构化解析(优先):对齐 relay.rs:255 实发 hello_ack 字面量
// 成功:{"kind":"control","payload":{"control_kind":"hello_ack"}}
// 失败:{"kind":"control","error":"handshake_failed|kind_mismatch|auth_failed"} (relay.rs:200-238)
// 注:hello_ack 的 control_kind 在 payload 内(与 error 顶层字段结构不同,须分别判)。
let parsedOk = false
let parsed: { kind?: string; error?: string; payload?: { control_kind?: string } } | null = null
try {
parsed = JSON.parse(text) as { kind?: string; error?: string; payload?: { control_kind?: string } }
parsedOk = true
} catch (e) {
// JSON.parse 失败:降级回旧 includes 兜底,保证非 JSON 帧不误判
console.warn('[WsClient] 握手响应 JSON 解析失败,降级 includes 兜底', e, text.slice(0, 200))
}
if (parsedOk && parsed) {
// 显式 error 字段存在即握手失败(对齐 relay.rs:203/220/233 error 顶层字段)
if (parsed.error !== undefined) {
console.error('[WsClient] 握手失败', parsed.error, text)
this.setStatus('disconnected', `handshake failed: ${parsed.error}`)
this.clearHandshakeTimer()
// 握手失败不重连(token 错则重连也错),等用户修配置后手动 connect
this.cleanup()
return
}
// 显式 ack 信号:kind==='control' 且 payload.control_kind==='hello_ack'
// (对齐 relay.rs:255 实发字面量;ControlMessage 枚举未含 hello_ack 故不强转类型)
if (parsed.kind === 'control' && parsed.payload?.control_kind === 'hello_ack') {
this.clearHandshakeTimer()
this.handshaked = true
this.reconnectAttempts = 0
this.setStatus('connected')
this.startHeartbeat()
// F-#95:握手成功后立即发 list_conversations —— device 在线探测 + 首屏会话列表同步。
// device 在线则 route_list_conversations 推回 AiConversationList(useAiChat 置 deviceOnline=true);
// device 离线则无响应,文案保持「已连接中继」诚实表述。
// 此时 handshaked=true,send 守卫放行;relay 纯透传入站文本帧作 payload。
this.send({ cmd: 'list_conversations', args: {} })
// 补发握手期间积压的业务命令(治握手成功到首帧竞态丢命令)
this.flushPendingQueue()
// hello_ack 是纯控制帧无业务 payload,无需转 handleMessage 处理
return
}
// 解析成功但既非 error 也非 hello_ack:异常帧,丢弃等下一条(保守不误判握手通过)
console.warn('[WsClient] 握手阶段收到非 ack/error 控制帧,丢弃', text.slice(0, 200))
return
}
// 降级兜底(JSON.parse 失败走此分支):保留旧 includes 判定,防协议演进/非 JSON 帧
if (text.includes('"error"')) {
console.error('[WsClient] 握手失败(includes 兜底)', text)
this.setStatus('disconnected', `handshake failed: ${text}`)
this.clearHandshakeTimer()
this.cleanup()
return
}
// 兜底:非错误消息视为握手通过(覆盖旧实现行为,防解析失败的 hello_ack 帧)
this.clearHandshakeTimer()
this.handshaked = true
this.reconnectAttempts = 0
this.setStatus('connected')
this.startHeartbeat()
this.send({ cmd: 'list_conversations', args: {} })
// 补发握手期间积压的业务命令(降级兜底路径同样补发)
this.flushPendingQueue()
this.handleMessage({ data: text })
}
private handleClose(res: { code: number; reason: string }): void {
console.log('[WsClient] 连接关闭', res.code, res.reason)
this.cleanup()
if (!this.manualClose) {
this.scheduleReconnect()
} else {
this.setStatus('disconnected', 'manual close')
}
}
private handleError(res: { errMsg: string }): void {
console.error('[WsClient] socket 错误', res.errMsg)
// 微信 socket onError 不保证 onClose 跟随(DNS/TLS 失败/域名未加白名单/onOpen 也不触发)。
// 若尚在 connecting/handshaking(未进过 connected),主动兜底重连,防永卡。
// connected 态的半开死连接由心跳活性检测(P1-2)兜底,此处不重连(可能 onClose 随后跟随)。
if (this.status === 'connecting' || this.status === 'handshaking') {
this.cleanup()
this.scheduleReconnect()
}
}
/** 启动心跳(固定间隔发 ControlMessage{control_kind:'heartbeat'} + 半开死连接检测) */
private startHeartbeat(): void {
this.stopHeartbeat()
const cfg = getConfig()
// 连接成功初始化活性时间(首次心跳检查不立即判死)
this.lastInboundAt = Date.now()
this.heartbeatTimer = setInterval(() => {
// 死连接检测:NAT 超时/网络切换致 TCP 半开(onClose 不触发,socket 仍 open)。
// relay 无 pong,用「任意入站消息」更新 lastInboundAt;超过 N 倍间隔无入站判死,主动重连。
const now = Date.now()
if (now - this.lastInboundAt > cfg.heartbeatInterval * HEARTBEAT_DEAD_FACTOR) {
console.warn('[WsClient] 心跳活性检测:连接疑似半开死连接,主动重连')
this.cleanup()
this.scheduleReconnect()
return
}
const ping: ControlMessage = { control_kind: 'ping' }
// 直接发 ControlMessage JSON(relay 透传,device 可回 pong)
try {
this.socket?.send({ data: JSON.stringify(ping) })
} catch (e) {
console.warn('[WsClient] 心跳发送失败', e)
}
// 注:cfg.heartbeatInterval 读但不显式用(已通过 setInterval 传入),保 cfg 引用
void cfg
}, cfg.heartbeatInterval)
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
}
/**
* 指数退避重连(baseDelay * 2^min(attempt,6),封顶 maxDelay,带随机抖动)。
*
* - attempt 软上限 6:超 6 不再翻倍,防长尾退避爬到 maxDelay 后卡死;
* - 抖动因子 0.5~1.0:防多客户端重连风暴(雷同退避同步触发);
* - manualClose/disconnect 时复位 reconnectAttempts(见 disconnect)。
*
* 回调统一经 reconnectNow(幂等清理 + openSocket),不裸调 openSocket。
*/
private scheduleReconnect(): void {
if (this.reconnectTimer) return
// 永久性故障上限:relayHost 错/中继下线时停止无限退避,避免长跑空耗 + 误导用户「重连中」。
// 用户手动重连(connect/resumeIfDisconnected)复位计数后可重新开始。
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
console.warn(`[WsClient] 已达最大重连次数 ${MAX_RECONNECT_ATTEMPTS},停止重连`)
this.setStatus('disconnected', '已达最大重连次数,请检查中继地址/网络后手动重连')
this.cleanup()
return
}
const cfg = getConfig()
const attempt = this.reconnectAttempts++
const exp = Math.min(attempt, 6)
const base = Math.min(cfg.reconnectBaseDelay * Math.pow(2, exp), cfg.reconnectMaxDelay)
const jitter = 0.5 + Math.random() * 0.5
const delay = Math.round(base * jitter)
this.setStatus('reconnecting', `attempt ${attempt + 1}, delay ${delay}ms`)
console.log(`[WsClient] ${delay}ms 后重连(attempt ${attempt + 1})`)
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.reconnectNow()
}, delay)
}
/** 清握手超时定时器(握手成功/失败时调) */
private clearHandshakeTimer(): void {
if (this.handshakeTimer) {
clearTimeout(this.handshakeTimer)
this.handshakeTimer = null
}
}
/**
* 注销并关闭指定 socket task(防旧 socket 闭包回调污染新连接)。
*
* 微信 SocketTask 无 offXxx API,用「覆盖空函数」解绑:onXxx 多次调用覆盖最后者。
* 必须对传入 task 操作(非 this.socket),避免 this.socket 时序指向新 socket 时误注销。
*/
private destroySocket(task: UniApp.SocketTask): void {
try { task.onOpen(() => {}) } catch { /* 注销忽略 */ }
try { task.onMessage(() => {}) } catch { /* 注销忽略 */ }
try { task.onClose(() => {}) } catch { /* 注销忽略 */ }
try { task.onError(() => {}) } catch { /* 注销忽略 */ }
try {
task.close({})
} catch {
// 已关闭的 socket close 抛错忽略
}
}
/**
* 彻底清理:停心跳 + 清所有 timer + 注销关闭 socket + 清队列 + 复位 handshaked。
*
* 清 pendingQueue:断连/重连/抢占时,旧命令上下文已失效,不补发到新连接(防串话)。
*/
private cleanup(): void {
this.stopHeartbeat()
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
this.clearHandshakeTimer()
if (this.connectTimer) {
clearTimeout(this.connectTimer)
this.connectTimer = null
}
if (this.socket) {
this.destroySocket(this.socket)
this.socket = null
}
this.handshaked = false
this.pendingQueue = []
}
private setStatus(status: WsStatus, detail?: string): void {
this.status = status
this.handlers?.onStatus(status, detail)
}
}
/** 全局单例(miniapp 进程内一个连接) */
export const wsClient = new WsClient()

View File

@@ -0,0 +1,36 @@
<template>
<!-- rich-text 小程序原生组件(无子组件依赖,)marked mdhtml rich-text :nodes 渲染
alpha 根因已查实(uni-app 2026 alpha tap 失效),rich-text 非元凶,降级后正常用 -->
<rich-text :nodes="html" :selectable="true" />
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { marked } from 'marked'
import { styleMarkdown } from '@/utils/mdRenderer'
defineOptions({ name: 'MdView' })
/**
* Markdown 渲染气泡(miniapp,rich-text 原生方案)。
*
* 链路:marked.parse(md) → styleMarkdown 注入 inline style → rich-text :nodes(原生渲染 html 节点)。
* - breaks:true 聊天换行(单换行转 <br>)
* - P1-G:styleMarkdown 给代码块/链接/表格加 inline style(rich-text 不认 class)
* - parse 失败回退原文
* - 空内容返空(rich-text 不渲染)
*
* 流式:currentText 变化 → html computed 重算 → rich-text 重渲染。
*/
const props = defineProps<{ content: string }>()
const html = computed(() => {
const src = props.content || ''
if (!src) return ''
try {
return styleMarkdown(marked.parse(src, { breaks: true, async: false }) as string)
} catch {
return src
}
})
</script>

View File

@@ -0,0 +1,133 @@
<template>
<!-- @/ 联想弹层(MVP)
固定占位列表:不接真实技能路由/实体检索,仅作 chip 插入
/file /web /task /memory + @file @web @task 四类技能占位
渲染对齐 chat/index.vue mp-weixin 稳定风格(原生 view/text + 绝对定位浮层) -->
<view v-if="visible" class="mention-mask" @tap="onClose">
<view class="mention-pop" @tap.stop>
<view class="mention-head">
<text class="mention-title">{{ trigger === '/' ? '技能' : '提及' }}</text>
<text class="mention-hint">MVP 占位 · 点击插入</text>
</view>
<scroll-view class="mention-list" scroll-y>
<view
v-for="(item, idx) in items"
:key="idx"
class="mention-item"
@tap="onSelect(item)"
>
<text class="mention-item-label">{{ item.label }}</text>
<text class="mention-item-insert">{{ item.insert.trim() }}</text>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup lang="ts">
/**
* MentionInput 联想弹层组件(MVP)。
*
* 设计:
* - 纯展示:接收 visible/trigger/items,选中后 emit select(insert) + close
* - 不查后端/不调路由:items 由父组件固定注入
* - 触发字符 `/`(技能)或 `@`(提及)由父组件依据光标位置判定
* - 选中即在 inputText 末尾插入对应字面量(如 `/file `),发送时整体纯文本走 ai.send
*
* props:
* - visible:是否显示
* - trigger:触发类型(`/` 技能 / `@` 提及)
* - items:占位项列表 {label, insert}
* emits:
* - select(insert):插入文本
* - close():关闭弹层
*/
defineOptions({ name: 'MentionInput' })
interface MentionItem {
label: string
insert: string
}
defineProps<{
visible: boolean
trigger: '/' | '@'
items: MentionItem[]
}>()
const emit = defineEmits<{
(e: 'select', insert: string): void
(e: 'close'): void
}>()
function onSelect(item: MentionItem): void {
emit('select', item.insert)
}
function onClose(): void {
emit('close')
}
</script>
<style scoped>
.mention-mask {
position: fixed;
left: 0;
right: 0;
bottom: 0;
top: 0;
background-color: rgba(0, 0, 0, 0.3);
z-index: 999;
}
.mention-pop {
position: absolute;
left: 12px;
right: 12px;
bottom: 56px;
max-height: 260px;
background-color: #1f1f1f;
border: 1px solid #333333;
border-radius: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.mention-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background-color: #262626;
border-bottom: 1px solid #2e2e2e;
}
.mention-title {
color: #e0e0e0;
font-size: 13px;
font-weight: bold;
}
.mention-hint {
color: #777777;
font-size: 11px;
}
.mention-list {
max-height: 220px;
}
.mention-item {
display: flex;
flex-direction: column;
padding: 10px 12px;
border-bottom: 1px solid #2a2a2a;
}
.mention-item:last-child {
border-bottom: none;
}
.mention-item-label {
color: #e0e0e0;
font-size: 14px;
}
.mention-item-insert {
color: #4a9eff;
font-size: 12px;
margin-top: 2px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,112 @@
/**
* miniapp 运行时配置(host/device_id/token 占位,由用户在设置页或 build env 填)
*
* 这些值在真实部署前是占位:
* - RELAY_HOST:ws/wss 地址,需替换为实际部署的 df-relay 地址
* - DEVICE_ID:用户首次配置桌面端时生成的设备 ID
* - TOKEN:对齐 df-relay 的 DF_RELAY_TOKEN(生产建议每 device 独立 token)
*/
export interface MiniappConfig {
/** relay WS 地址(含协议),如 'ws://192.168.1.100:8080/ws/miniapp' 或 'wss://relay.example.com/ws/miniapp' */
relayHost: string
/** 配对绑定的设备 ID */
deviceId: string
/** 配对 token(对齐 df-relay DF_RELAY_TOKEN) */
token: string
/** 心跳间隔(ms) */
heartbeatInterval: number
/** 重连初始间隔(ms,指数退避) */
reconnectBaseDelay: number
/** 重连最大间隔(ms) */
reconnectMaxDelay: number
}
/**
* 默认配置(占位值,需替换)
*
* 真实部署:用户在设置页填 relayHost/deviceId/token,或通过 build-time env 注入。
* MVP 阶段留占位让类型自洽,实际连接会失败(token 校验不通过 → relay 关连接,
* 这符合预期,提示用户配置)。
*/
export const defaultConfig: MiniappConfig = {
// 测试服 wss(u-work.1216.top → nginx 终止 TLS → df-relay :9180 明文 ws)
// 微信小程序真机要求 wss + socket 合法域名白名单(微信公众平台→开发设置→服务器域名)
relayHost: 'wss://u-work.1216.top/ws/miniapp',
// 配对桌面端 device_id(查桌面端 KV app_settings:device_id;填错则握手过但路由无对端)
// 当前填本机桌面端 device_id(联调期硬编码,后续做配对绑定流程)
deviceId: '1b92cbbd-d03e-4a0c-b226-9fadf8dcf0f0',
token: 'devflow-relay-default-token',
heartbeatInterval: 30000,
reconnectBaseDelay: 1000,
reconnectMaxDelay: 30000,
}
/** 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,13 @@
import { createSSRApp } from 'vue'
import App from './App.vue'
/**
* uni-app 入口:createSSRApp 规范(uni-app Vue3 必需,非 createApp)。
* 桌面端 main.ts 用 createApp(Tauri 浏览器环境),小程序用 createSSRApp 对齐 uni 跨端。
*/
export function createApp() {
const app = createSSRApp(App)
return {
app,
}
}

View File

@@ -0,0 +1,53 @@
{
"name": "DevFlow",
"appid": "PLACEHOLDER",
"description": "DevFlow 远程 AI Chat — 跨端操作桌面端开发助手",
"versionName": "0.1.0",
"versionCode": 100,
"transformPx": false,
"app-plus": {
"usingComponents": true,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
},
"modules": {},
"distribute": {
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.INTERNET\"/>"
]
},
"ios": {},
"sdkConfigs": {}
}
},
"quickapp": {},
"mp-weixin": {
"appid": "PLACEHOLDER",
"setting": {
"urlCheck": false,
"es6": true,
"minified": true,
"postcss": true
},
"usingComponents": true,
"permission": {},
"requiredBackgroundModes": [],
"requiredPrivateInfos": []
},
"h5": {
"title": "DevFlow Mini",
"router": {
"mode": "hash",
"base": "./"
},
"devServer": {
"port": 8081,
"https": false
}
},
"vueVersion": "3"
}

View File

@@ -0,0 +1,41 @@
{
"pages": [
{
"path": "pages/chat/index",
"style": {
"navigationBarTitleText": "AI 对话",
"enablePullDownRefresh": false
}
},
{
"path": "pages/conversations/index",
"style": {
"navigationBarTitleText": "会话列表",
"enablePullDownRefresh": true
}
}
],
"globalStyle": {
"navigationBarTextStyle": "white",
"navigationBarTitleText": "DevFlow",
"navigationBarBackgroundColor": "#1a1a1a",
"backgroundColor": "#0f0f0f",
"backgroundTextStyle": "light"
},
"tabBar": {
"color": "#999999",
"selectedColor": "#4a9eff",
"backgroundColor": "#1a1a1a",
"borderStyle": "black",
"list": [
{
"pagePath": "pages/chat/index",
"text": "对话"
},
{
"pagePath": "pages/conversations/index",
"text": "会话"
}
]
}
}

View File

@@ -0,0 +1,993 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { marked } from 'marked'
import { styleMarkdown } from '@/utils/mdRenderer'
import { useAiChat } from '@/composables/useAiChat'
import type { MentionSpan } from '@/types/relay'
import type { ProjectRecord, TaskRecord, IdeaRecord, AiToolCallInfo } from '@/types/events'
// 组件内联(绕过微信工具 3.15.2 组件解析缓存 bug):独立组件恒报 Component not found,
// inline 进页面后 chat/index.json usingComponents 空,工具无需解析 components/,问题消失。
// MdView/MentionInput 源保留备用(工具修复后可拆回)。
/** md→html 渲染 + 缓存(避免 v-for 每次 render 重算 marked.parse 同步阻塞) */
const mdCache = new Map<string, string>()
function renderMd(src: string): string {
if (!src) return ''
const cached = mdCache.get(src)
if (cached !== undefined) return cached
let html: string
try {
html = marked.parse(src, { breaks: true, async: false }) as string
html = styleMarkdown(html) // P1-G:代码块/链接/表格 inline style(rich-text 不认 class)
} catch {
html = src
}
mdCache.set(src, html)
return html
}
const ai = useAiChat()
const inputText = ref('')
// —— 联想弹层状态(技能 / 与 @ 实体复用同一浮层结构,detectTrigger 决定 trigger) ——
// 弹层可见 + 当前触发类型。trigger='skill' = `/` 开头技能联想;trigger='entity' = 末尾 `@` 实体联想。
const mentionVisible = ref(false)
const mentionTrigger = ref<'skill' | 'entity'>('skill')
// —— pendingSkill(`/<skill>` 选中后 chip 展示,handleSend 时透传 ai.send skill 参数) ——
// 选中技能后清 inputText 并置 chip,后续用户输入技能参数;发送时 skill.name 透传后端注入 SKILL.md。
// 类型与 useAiChat.skills 元素一致(对齐 relay.ts SkillInfo)。
type SkillInfoLike = { name: string; description: string; argument_hint?: string }
const pendingSkill = ref<SkillInfoLike | null>(null)
// —— pendingMentionSpans(@项目/@任务/@灵感 选中后累积,handleSend 时透传 ai.send spans 参数) ——
// 每条对应 inputText 中一段 `[类型:名]` 文本区间(start/length 为字符偏移)。
// 用户可连续 @ 多个实体,发送时整体透传,后端 resolve 投影成 Augmentation 注入。
const pendingMentionSpans = ref<MentionSpan[]>([])
// —— P1-12 自动滚底 + P1-7 生成中关联想 + P1-13 渲染上限 ——
/** scroll-into-view 锚点(同值不重滚,故 toggle ''→id 触发) */
const scrollAnchor = ref('')
function scrollToBottom(): void {
scrollAnchor.value = ''
// setTimeout(0) 等新消息 DOM 渲染完(mp-weixin nextTick 兼容)
setTimeout(() => { scrollAnchor.value = 'list-bottom-anchor' }, 0)
}
let scrollTimer: ReturnType<typeof setTimeout> | null = null
/** 流式增量高频,throttle 防滚底抖动 */
function throttledScrollToBottom(): void {
if (scrollTimer) return
scrollTimer = setTimeout(() => {
scrollTimer = null
scrollToBottom()
}, 120)
}
// 新消息 push / 切会话历史回流 → 立即滚底;流式增量 → throttled 滚底
watch(() => ai.messages.length, () => scrollToBottom())
watch(() => ai.currentText.value, throttledScrollToBottom)
// P1-7:生成中关闭联想弹层,防 input disabled 时仍点插入(input disabled 不阻止弹层点击)
watch(() => ai.generating.value, (g) => { if (g) mentionVisible.value = false })
/**
* P1-13 渲染上限兜底:仅渲染最近 N 条,防长会话(100+)rich-text 全量渲染卡顿/内存膨胀。
* TODO 真虚拟化(滚动入视口才 marked.parse / 窗口化),当前截断旧消息简化兜底。
*/
const MAX_RENDER_MSGS = 200
const visibleMessages = computed(() => ai.messages.slice(-MAX_RENDER_MSGS))
/**
* 输入变化时检测联想触发条件。
*
* 两类触发:
* - 技能(`skill`):inputText 以 `/` 开头,且尚未选 pendingSkill(已选则不再触发,
* 因 chip 已在输入栏,用户输入的是技能参数)→ listSkills() 拉技能 + 显技能浮层。
* - 实体(`entity`):末尾连续非空白段恰好为单字符 `@`(避免匹配 url/邮箱)→
* listEntities() 拉实体 + 显实体浮层。
*
* 二者优先级:技能(行首 /)优先于实体(末尾 @),因 `/` 开头整行属技能模式。
* 已选 pendingSkill 时屏蔽 `/` 重复触发(同上,用户在输入参数)。
*/
function detectTrigger(): void {
const text = inputText.value
if (!text) {
mentionVisible.value = false
return
}
// 技能触发:行首 / 且未选技能。空格后(/file xxx)不再是行首 /,自动关闭浮层。
if (text.startsWith('/') && !pendingSkill.value) {
mentionTrigger.value = 'skill'
mentionVisible.value = true
ai.listSkills()
return
}
// 实体触发:末尾连续非空白段恰好单字符 @
const match = text.match(/([^\s]+)\s*$/)
const last = match ? match[1] : ''
if (last === '@') {
mentionTrigger.value = 'entity'
mentionVisible.value = true
ai.listEntities()
return
}
mentionVisible.value = false
}
/** 监听输入:mp-weixin textarea 的 @input 在 v-model 后触发,可直接检测(P3-A input→textarea) */
function onInput(): void {
detectTrigger()
}
/**
* 选中技能(技能浮层):置 pendingSkill chip + 清 inputText。
*
* 清 inputText 因技能模式语义是「选技能后输参数」,选完即重置输入框等用户输参数文本。
* 技能参数提示(argument_hint,如 "<url>")随 chip 展示,引导用户输入。
* 不透传 skill 到 inputText(避免 `/技能名` 文本污染参数输入),skill 仅经 handleSend 透传。
*/
function onSkillSelect(skill: SkillInfoLike): void {
pendingSkill.value = skill
inputText.value = ''
mentionVisible.value = false
}
/**
* 选中实体(实体浮层):替换末尾 `@` 为 `[类型:名]` 文本 + 累积 MentionSpan。
*
* span.start/length:实体插入文本(inputText 中 `[类型:名]` 段)的字符偏移,
* 后端据 start/length 在原文中切出区间注入对应实体上下文。
* kind/refId/label 对齐后端 MentionSpanDto:project/task/idea + id + 展示名。
* 连续 @ 多个实体时累积(每次 push 一条),发送时整体透传。
*/
function onEntitySelect(
entity: ProjectRecord | TaskRecord | IdeaRecord,
kind: 'project' | 'task' | 'idea',
): void {
const text = inputText.value
// 去末尾触发字符 `@`(detectTrigger 已保证末段是 @,这里兜底 replace 防御)
const stripped = text.replace(/@\s*$/, '')
// 类型标签中文映射(对齐桌面端 chip 展示习惯:项目/任务/灵感)
const kindLabel = kind === 'project' ? '项目' : kind === 'task' ? '任务' : '灵感'
// 名:project 用 name,task/idea 用 title
const name = (entity as ProjectRecord).name || (entity as TaskRecord | IdeaRecord).title
const insertText = `[${kindLabel}:${name}]`
const start = stripped.length
inputText.value = stripped + insertText
// 累积 mention span(字符偏移,后端透传不解释单位)
pendingMentionSpans.value.push({
start,
length: insertText.length,
kind,
refId: entity.id,
label: name,
})
mentionVisible.value = false
}
/** 清除已选技能 chip(handleSend 后或用户点 × 清除) */
function clearPendingSkill(): void {
pendingSkill.value = null
}
/** 关闭弹层(点遮罩) */
function onMentionClose(): void {
mentionVisible.value = false
}
/**
* 发送:对齐 useAiChat.send(text, skill?, spans?) 新签名。
*
* - skill:已选 pendingSkill 时透传 name(后端注入 SKILL.md);否则 undefined(普通对话)。
* - spans:有 pendingMentionSpans 时透传(后端 resolve 实体上下文);否则 undefined。
* - 发送后清 inputText + pendingSkill + pendingMentionSpans,复位待下一次输入。
*
* 保留原有守卫:空文本忽略 / generating 中忽略 / 关闭联想浮层。
*/
function handleSend(): void {
console.log('[v2:send] enter text=', JSON.stringify(inputText.value), 'gen=', ai.generating.value)
const text = inputText.value.trim()
if (!text) {
console.log('[v2:send] 空文本,忽略')
return
}
if (ai.generating.value) {
// 审批挂起/流式生成中后端拒新 send:提示用户(去审批或等待),避免点了没反应困惑
uni.showToast({
title: ai.pendingApprovals.length > 0 ? '有待审批项,请先处理' : '生成中,请稍候',
icon: 'none',
})
return
}
mentionVisible.value = false
const skill = pendingSkill.value ? pendingSkill.value.name : undefined
const spans = pendingMentionSpans.value.length > 0 ? pendingMentionSpans.value : undefined
ai.send(text, skill, spans)
inputText.value = ''
pendingSkill.value = null
pendingMentionSpans.value = []
}
// —— 工具卡片 + 审批 UI(对齐桌面端 ToolCard,适配 mp-weixin 深色风格) ——
// 后端推 AiToolCallStarted/AiApprovalRequired/AiDirAuthRequired → useAiChat 收进
// 消息 m.toolCalls(状态机)+ pendingApprovals。消息内联渲染工具状态 + 待审批按钮,
// miniapp 成轻量审批终端(单一渲染源,对齐桌面 ToolCard 嵌消息气泡)。
// 高危工具白名单(approve 前二次确认,对齐桌面 ToolCard.vue HIGH_RISK_TOOLS)
const HIGH_RISK_TOOLS = new Set<string>([
'delete_task',
'delete_project',
'restore_project',
'purge_project',
'delete_file',
'run_command',
'http_request',
])
// 工具状态 → 中文文案 + 颜色 class(渲染状态点/标签)
const STATUS_META: Record<string, { label: string; cls: string }> = {
running: { label: '执行中', cls: 'st-running' },
completed: { label: '已完成', cls: 'st-completed' },
failed: { label: '失败', cls: 'st-failed' },
pending_approval: { label: '待审批', cls: 'st-pending' },
rejected: { label: '已拒绝', cls: 'st-rejected' },
}
/** 截断长字符串(参数值/结果展示防撑爆气泡) */
function truncate(s: string, max = 120): string {
return s.length > max ? s.slice(0, max) + '…' : s
}
/** 格式化工具参数为 [{key,val}] 行数组(args 任意结构,最多 4 项防长) */
function formatArgs(args: unknown): { key: string; val: string }[] {
if (!args || typeof args !== 'object') return []
const obj = args as Record<string, unknown>
return Object.entries(obj)
.filter(([, v]) => v !== undefined && v !== null && v !== '')
.slice(0, 4)
.map(([k, v]) => ({
key: k,
val: truncate(typeof v === 'string' ? v : JSON.stringify(v)),
}))
}
/** 格式化执行结果摘要(completed/failed 展示,result 可能任意结构) */
function formatResult(result: unknown): string {
if (result === undefined || result === null) return ''
if (typeof result === 'string') return truncate(result)
try {
return truncate(JSON.stringify(result))
} catch {
return ''
}
}
/**
* 识别后端审批占位 message(对齐 crates/df-ai/context_helpers.rs:498 PENDING_MARKER_PREFIX)。
*
* 后端工具挂起审批时往消息流写占位 assistant message,content 形如
* 「需要用户审批,等待确认__PENDING__:call_xxx」。桌面端识别占位转工具审批卡片;
* miniapp 不识别会原样渲染成气泡(暴露 __PENDING__ 技术标识)。此处识别后隐藏文本,
* 审批 UI 由消息内联 m.toolCalls 工具卡片单独渲染(单一渲染源,不重复)。
*/
const PENDING_MARKER = '__PENDING__:'
const PENDING_PLACEHOLDER_LEGACY = '需要用户审批,等待确认'
function isPendingPlaceholder(content: string | undefined | null): boolean {
if (!content) return false
if (content.includes(PENDING_MARKER)) return true
return content === PENDING_PLACEHOLDER_LEGACY
}
/**
* 识别纯 JSON 工具结果 message(content 是工具执行结果 JSON,如 list_tasks 的
* {"has_more":false,"items":[],...})。弱化为灰小字折叠,不占主气泡,不刺眼。
*/
function isToolResultJson(content: string | undefined | null): boolean {
if (!content) return false
const t = content.trim()
if (!(t.startsWith('{') || t.startsWith('['))) return false
try {
JSON.parse(t)
return true
} catch {
return false
}
}
/** 审批通过(risk 类):高危工具先 uni.showModal 二次确认 */
function onApprove(tc: AiToolCallInfo): void {
if (HIGH_RISK_TOOLS.has(tc.name)) {
uni.showModal({
title: '高危操作确认',
content: `${tc.name}」属高危操作,确认批准执行?`,
confirmColor: '#e85a4f',
success: (r) => {
if (r.confirm) ai.approve(tc.id, true)
},
})
return
}
ai.approve(tc.id, true)
}
/** 审批拒绝(risk 类) */
function onReject(tc: AiToolCallInfo): void {
ai.approve(tc.id, false)
}
/** 路径授权(path 类:once=本次 / always=永久 / deny=拒绝) */
function onAuthorize(tc: AiToolCallInfo, decision: 'once' | 'always' | 'deny'): void {
ai.authorizeDir(tc.id, decision)
}
/** 连接状态中文文案(F21:裸枚举字符串 disconnected/connecting 不可读) */
const STATUS_TEXT: Record<string, string> = {
connecting: '连接中…',
handshaking: '握手中…',
reconnecting: '重连中…·点此立即重连',
disconnected: '已断开·点此重连',
}
const statusText = computed(() => {
if (ai.isWsConnected.value) {
return ai.deviceOnline.value ? '已连接桌面端' : '已连接中继'
}
return STATUS_TEXT[ai.wsStatus.value] || ai.wsStatus.value
})
const canManualReconnect = computed(
() => ai.wsStatus.value === 'disconnected' || ai.wsStatus.value === 'reconnecting',
)
function onStatusTap(): void {
if (canManualReconnect.value) ai.resumeIfDisconnected()
}
/** 停止生成(wires F9 终态兜底;生成中 send 禁用无停止入口,用户无法中断) */
function onStop(): void {
ai.stop()
}
/** 继续循环(P1-E:MaxRounds 挂起后再跑一轮) */
function onContinueLoop(): void {
ai.continueLoop()
}
/** 停止循环(P1-E:MaxRounds 挂起后彻底终止多轮 agent) */
function onStopLoop(): void {
ai.stopLoop()
}
/** 重发最后一条 user 消息(P1-F:regenerate 入口) */
function onRegenerate(): void {
ai.regenerate()
}
/** 长按消息复制全文(P1-F:assistant 复制 markdown 源,user 复制原文) */
function onCopyMessage(m: { content?: string }): void {
if (!m.content) return
uni.setClipboardData({
data: m.content,
success: () => uni.showToast({ title: '已复制', icon: 'none' }),
})
}
</script>
<template>
<view class="page">
<!-- 状态栏 + 待审批徽标(F21:断开/重连中可点状态栏手动重连) -->
<view class="top">
<text class="st" :class="{ 'st-tap': canManualReconnect }" @tap="onStatusTap">{{ statusText }}</text>
<view class="top-right">
<text v-if="ai.tokenUsage.value" class="token-usage">🪙 {{ ai.tokenUsage.value.total }}</text>
<view v-if="ai.pendingApprovals.length > 0" class="pending-badge">
<text> {{ ai.pendingApprovals.length }} 待审批</text>
</view>
</view>
</view>
<!-- 达最大轮次面板(P1-E:继续/停止循环,对齐桌面 MaxRounds) -->
<view v-if="ai.maxRoundsActive.value" class="maxrounds-panel">
<text class="maxrounds-text"> 已达最大轮次</text>
<view class="maxrounds-actions">
<button class="approval-btn approve" size="mini" @tap="onContinueLoop">继续一轮</button>
<button class="approval-btn reject" size="mini" @tap="onStopLoop">停止</button>
</view>
</view>
<!-- 待审批面板( pendingApprovals 渲染, messages 解耦:req3 重连 load_messages 替换 messages 不影响审批卡) -->
<view v-if="ai.pendingApprovals.length > 0" class="pending-panel">
<view class="pending-panel-head">
<text> {{ ai.pendingApprovals.length }} 项待审批</text>
</view>
<view v-for="p in ai.pendingApprovals" :key="p.id" class="tool-card">
<view class="tool-head">
<text class="tool-name">🔧 {{ p.name }}</text>
<text class="tool-status st-pending">待审批</text>
</view>
<view v-if="formatArgs(p.args).length > 0" class="tool-args">
<view v-for="a in formatArgs(p.args)" :key="a.key" class="tool-arg">
<text class="tool-arg-key">{{ a.key }}:</text>
<text class="tool-arg-val">{{ a.val }}</text>
</view>
</view>
<view v-if="p.kind === 'path' && p.dir" class="tool-dir">
<text>📁 {{ p.dir }}</text>
</view>
<view v-if="p.reason" class="tool-reason">
<text> {{ p.reason }}</text>
</view>
<!-- risk :批准/拒绝 -->
<view v-if="p.kind !== 'path'" class="approval-actions">
<button class="approval-btn approve" size="mini" @tap="onApprove(p)">批准</button>
<button class="approval-btn reject" size="mini" @tap="onReject(p)">拒绝</button>
</view>
<!-- path :本次/永久/拒绝 -->
<view v-else class="approval-actions">
<button class="approval-btn approve" size="mini" @tap="onAuthorize(p, 'once')">本次</button>
<button class="approval-btn always" size="mini" @tap="onAuthorize(p, 'always')">永久</button>
<button class="approval-btn reject" size="mini" @tap="onAuthorize(p, 'deny')">拒绝</button>
</view>
</view>
</view>
<!-- 消息列表(scroll-view 标准滚动, text 渲染) -->
<scroll-view class="list" scroll-y :scroll-into-view="scrollAnchor" :scroll-with-animation="true">
<view v-if="ai.messages.length === 0" class="empty">
<text>暂无消息</text>
</view>
<view v-for="m in visibleMessages" :key="m.id" class="msg" :class="m.role" @longpress="onCopyMessage(m)">
<text v-if="m.role === 'user'" user-select>{{ m.content }}</text>
<template v-else>
<!-- 后端审批占位 message(需要__PENDING__:call_xxx)隐藏文本,审批走工具卡片 -->
<rich-text
v-if="m.content && !isPendingPlaceholder(m.content) && !isToolResultJson(m.content)"
:nodes="renderMd(m.content)"
selectable
/>
<!-- JSON 工具结果弱化(灰小字折叠,不占主气泡) -->
<view v-else-if="m.content && isToolResultJson(m.content)" class="msg-toolresult">
<text>📋 {{ m.content }}</text>
</view>
<!-- 占位 message(isPendingPlaceholder)不渲染文本,仅下方工具卡片承载审批 UI -->
<!-- 工具调用卡片(对齐桌面 ToolCard,assistant 消息内联渲染状态 + 审批按钮) -->
<view v-for="tc in m.toolCalls || []" :key="tc.id" class="tool-card">
<view class="tool-head">
<text class="tool-name">🔧 {{ tc.name }}</text>
<text class="tool-status" :class="(STATUS_META[tc.status] || {}).cls">
{{ (STATUS_META[tc.status] || {}).label || tc.status }}
</text>
</view>
<!-- 参数 -->
<view v-if="formatArgs(tc.args).length > 0" class="tool-args">
<view v-for="a in formatArgs(tc.args)" :key="a.key" class="tool-arg">
<text class="tool-arg-key">{{ a.key }}:</text>
<text class="tool-arg-val">{{ a.val }}</text>
</view>
</view>
<!-- path 授权待授权目录 -->
<view v-if="tc.kind === 'path' && tc.status === 'pending_approval' && tc.dir" class="tool-dir">
<text>📁 {{ tc.dir }}</text>
</view>
<!-- 风险说明 -->
<view v-if="tc.reason" class="tool-reason">
<text> {{ tc.reason }}</text>
</view>
<!-- 审批按钮已移至顶部 pendingApprovals 面板( messages 解耦,req3 重连恢复) -->
<!-- 执行结果摘要(completed/failed) -->
<view
v-if="(tc.status === 'completed' || tc.status === 'failed') && formatResult(tc.result)"
class="tool-result"
>
<text>{{ formatResult(tc.result) }}</text>
</view>
</view>
</template>
</view>
<!-- 流式生成中的当前文本 -->
<view v-if="ai.generating.value && ai.currentText.value" class="msg assistant">
<text user-select>{{ ai.currentText.value }}</text>
</view>
<!-- P1-12 滚底锚点(scroll-into-view 目标) -->
<view id="list-bottom-anchor"></view>
</scroll-view>
<!-- 联想弹层(技能 / @ 实体复用同一浮层结构,detectTrigger 决定 trigger) -->
<view v-if="mentionVisible && !ai.generating.value" class="mention-mask" @tap="onMentionClose">
<view class="mention-pop" @tap.stop>
<view class="mention-head">
<text class="mention-title">{{ mentionTrigger === 'skill' ? '技能' : '提及' }}</text>
<text class="mention-hint">点击选中</text>
</view>
<scroll-view class="mention-list" scroll-y>
<!-- 技能列表(`/` 触发,name/description/argument_hint 展示) -->
<template v-if="mentionTrigger === 'skill'">
<view v-if="ai.skills.length === 0" class="mention-empty">
<text>加载中或无技能...</text>
</view>
<view
v-for="(s, idx) in ai.skills"
:key="'sk-' + idx"
class="mention-item"
@tap="onSkillSelect(s)"
>
<view class="mention-item-main">
<text class="mention-item-label">/{{ s.name }}</text>
<text class="mention-item-source">{{ s.source }}</text>
</view>
<text class="mention-item-desc">{{ s.description }}</text>
<text v-if="s.argument_hint" class="mention-item-insert">参数:{{ s.argument_hint }}</text>
</view>
</template>
<!-- 实体列表(`@` 触发, 项目/任务/灵感 分组) -->
<template v-else>
<view v-if="ai.entities.projects.length === 0 && ai.entities.tasks.length === 0 && ai.entities.ideas.length === 0" class="mention-empty">
<text>加载中或无数据...</text>
</view>
<view v-if="ai.entities.projects.length > 0" class="mention-group-title">
<text>项目</text>
</view>
<view
v-for="(p, idx) in ai.entities.projects"
:key="'p-' + idx"
class="mention-item"
@tap="onEntitySelect(p, 'project')"
>
<text class="mention-item-label">{{ p.name }}</text>
<text class="mention-item-desc">{{ p.description }}</text>
</view>
<view v-if="ai.entities.tasks.length > 0" class="mention-group-title">
<text>任务</text>
</view>
<view
v-for="(t, idx) in ai.entities.tasks"
:key="'t-' + idx"
class="mention-item"
@tap="onEntitySelect(t, 'task')"
>
<text class="mention-item-label">{{ t.title }}</text>
<text class="mention-item-desc">{{ t.description }}</text>
</view>
<view v-if="ai.entities.ideas.length > 0" class="mention-group-title">
<text>灵感</text>
</view>
<view
v-for="(i, idx) in ai.entities.ideas"
:key="'i-' + idx"
class="mention-item"
@tap="onEntitySelect(i, 'idea')"
>
<text class="mention-item-label">{{ i.title }}</text>
<text class="mention-item-desc">{{ i.description }}</text>
</view>
</template>
</scroll-view>
</view>
</view>
<!-- 输入 + 发送(原生 button @tap) -->
<view class="bar">
<!-- pendingSkill chip(选中技能后展示名+参数提示,× 清除) -->
<view v-if="pendingSkill" class="skill-chip">
<text class="skill-chip-name">/{{ pendingSkill.name }}</text>
<text v-if="pendingSkill.argument_hint" class="skill-chip-hint">{{ pendingSkill.argument_hint }}</text>
<text class="skill-chip-close" @tap="clearPendingSkill">×</text>
</view>
<textarea
v-model="inputText"
class="ipt"
:placeholder="ai.generating.value ? '生成中,完成后发送…' : pendingSkill ? '输入技能参数' : '输入消息(输入 / 或 @ 触发联想)'"
:auto-height="true"
:adjust-position="true"
confirm-type="send"
@confirm="handleSend"
@input="onInput"
/>
<!-- 重发最后一条 user(P1-F,非生成中且有消息时显) -->
<button
v-if="!ai.generating.value && ai.messages.length > 0"
class="regen-btn"
@tap="onRegenerate"
>
重发
</button>
<!-- 生成中显停止按钮(wires F9),否则发送 -->
<button v-if="ai.generating.value" class="send-btn stop-btn" @tap="onStop">停止</button>
<button v-else class="send-btn" @tap="handleSend">发送</button>
</view>
</view>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #0f0f0f;
}
.top {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background-color: #1a1a1a;
}
.st {
color: #999999;
font-size: 12px;
}
.st-tap {
color: #4a9eff;
}
.top-right {
display: flex;
flex-direction: row;
align-items: center;
}
.token-usage {
color: #777777;
font-size: 11px;
margin-right: 8px;
}
.list {
flex: 1;
min-height: 0;
padding: 12px;
}
.empty {
text-align: center;
padding: 80px 0;
}
.empty text {
color: #666666;
font-size: 13px;
}
.msg {
margin-bottom: 12px;
padding: 10px 14px;
border-radius: 10px;
background-color: #2a2a2a;
}
.msg text {
color: #e0e0e0;
font-size: 14px;
line-height: 1.5;
}
.msg.user {
background-color: #4a9eff;
}
.msg.user text {
color: #ffffff;
}
.msg.system text {
color: #999999;
font-size: 12px;
}
.bar {
flex-shrink: 0;
display: flex;
align-items: flex-end;
padding: 8px 12px;
background-color: #1a1a1a;
}
.ipt {
flex: 1;
background-color: #2a2a2a;
color: #e0e0e0;
padding: 8px 12px;
min-height: 36px;
max-height: 120px;
box-sizing: border-box;
overflow-y: auto;
border-radius: 6px;
font-size: 14px;
line-height: 20px;
}
.send-btn {
margin-left: 8px;
background-color: #4a9eff;
color: #ffffff;
font-size: 14px;
}
.send-btn.stop-btn {
background-color: #5a2a2a;
color: #e85a4f;
}
.regen-btn {
margin-left: 8px;
background-color: #2a2a2a;
color: #c0c0c0;
font-size: 13px;
}
/* @/ 联想弹层(内联自 MentionInput,绕过工具组件解析 bug) */
.mention-mask {
position: fixed;
left: 0;
right: 0;
bottom: 0;
top: 0;
background-color: rgba(0, 0, 0, 0.3);
z-index: 999;
}
.mention-pop {
position: absolute;
left: 12px;
right: 12px;
bottom: 56px;
max-height: 260px;
background-color: #1f1f1f;
border: 1px solid #333333;
border-radius: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.mention-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background-color: #262626;
border-bottom: 1px solid #2e2e2e;
}
.mention-title {
color: #e0e0e0;
font-size: 13px;
font-weight: bold;
}
.mention-hint {
color: #777777;
font-size: 11px;
}
.mention-list {
max-height: 220px;
}
.mention-item {
display: flex;
flex-direction: column;
padding: 10px 12px;
border-bottom: 1px solid #2a2a2a;
}
.mention-item-main {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.mention-item-label {
color: #e0e0e0;
font-size: 14px;
}
.mention-item-source {
color: #777777;
font-size: 10px;
margin-left: 8px;
}
.mention-item-desc {
color: #999999;
font-size: 12px;
margin-top: 2px;
}
.mention-item-insert {
color: #4a9eff;
font-size: 12px;
margin-top: 2px;
}
.mention-empty {
padding: 24px 12px;
text-align: center;
}
.mention-empty text {
color: #666666;
font-size: 12px;
}
.mention-group-title {
padding: 6px 12px;
background-color: #232323;
border-bottom: 1px solid #2a2a2a;
}
.mention-group-title text {
color: #888888;
font-size: 11px;
font-weight: bold;
}
/* pendingSkill chip(选中技能后展示名+参数提示+× 清除) */
.skill-chip {
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
padding: 0 8px;
height: 28px;
margin-right: 6px;
background-color: #1f3a5f;
border: 1px solid #4a9eff;
border-radius: 14px;
}
.skill-chip-name {
color: #4a9eff;
font-size: 12px;
}
.skill-chip-hint {
color: #7799bb;
font-size: 10px;
margin-left: 6px;
}
.skill-chip-close {
color: #4a9eff;
font-size: 16px;
margin-left: 6px;
padding: 0 2px;
}
/* 工具卡片 + 审批 UI(对齐桌面 ToolCard,深色风格) */
.pending-badge {
flex-shrink: 0;
margin: 0 8px;
padding: 2px 8px;
background-color: #3a2f1f;
border: 1px solid #f0c75e;
border-radius: 10px;
}
.pending-badge text {
color: #f0c75e;
font-size: 11px;
}
/* 待审批面板(从 pendingApprovals 渲染,与 messages 解耦避重连竞态) */
.pending-panel {
flex-shrink: 0;
max-height: 40vh;
overflow-y: auto;
padding: 4px 12px 8px;
background-color: #1a1a1a;
border-bottom: 1px solid #2a2a2a;
}
.pending-panel-head {
padding: 4px 0;
}
.pending-panel-head text {
color: #f0c75e;
font-size: 12px;
font-weight: bold;
}
/* 达最大轮次面板(P1-E:继续/停止循环) */
.maxrounds-panel {
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background-color: #2a2218;
border-bottom: 1px solid #3a2f1f;
}
.maxrounds-text {
color: #f0c75e;
font-size: 13px;
}
.maxrounds-actions {
display: flex;
flex-direction: row;
}
.tool-card {
margin-top: 8px;
padding: 8px 10px;
background-color: #1f1f1f;
border: 1px solid #333333;
border-radius: 8px;
}
.tool-head {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.tool-name {
color: #e0e0e0;
font-size: 13px;
font-weight: bold;
}
.tool-status {
padding: 1px 6px;
border-radius: 8px;
font-size: 10px;
}
.tool-status.st-running {
background-color: #2a3a5a;
color: #6bb6ff;
}
.tool-status.st-completed {
background-color: #1f3a2a;
color: #6bd99a;
}
.tool-status.st-failed {
background-color: #3a1f1f;
color: #e85a4f;
}
.tool-status.st-pending {
background-color: #3a2f1f;
color: #f0c75e;
}
.tool-status.st-rejected {
background-color: #2a2a2a;
color: #999999;
}
.tool-args {
margin-top: 6px;
}
.tool-arg {
display: flex;
flex-direction: row;
margin-top: 2px;
}
.tool-arg-key {
color: #7799bb;
font-size: 12px;
margin-right: 4px;
}
.tool-arg-val {
color: #c0c0c0;
font-size: 12px;
flex: 1;
}
.tool-dir {
margin-top: 4px;
}
.tool-dir text {
color: #6bd99a;
font-size: 12px;
}
.tool-reason {
margin-top: 6px;
padding: 4px 6px;
background-color: #2a2218;
border-radius: 4px;
}
.tool-reason text {
color: #f0c75e;
font-size: 12px;
}
.approval-actions {
display: flex;
flex-direction: row;
margin-top: 8px;
}
.approval-btn {
margin-right: 6px;
padding: 0 12px;
height: 30px;
line-height: 30px;
font-size: 12px;
border-radius: 6px;
}
.approval-btn.approve {
background-color: #2a5a3a;
color: #6bd99a;
}
.approval-btn.always {
background-color: #2a3a5a;
color: #6bb6ff;
}
.approval-btn.reject {
background-color: #5a2a2a;
color: #e85a4f;
margin-right: 0;
}
.tool-result {
margin-top: 6px;
padding: 4px 6px;
background-color: #181818;
border-radius: 4px;
}
.tool-result text {
color: #999999;
font-size: 11px;
}
/* 纯 JSON 工具结果 message 弱化(灰小字折叠,避免刺眼占用主气泡) */
.msg-toolresult {
margin-top: 4px;
padding: 6px 8px;
background-color: #181818;
border-radius: 6px;
max-height: 80px;
overflow: hidden;
}
.msg-toolresult text {
color: #777777;
font-size: 11px;
}
</style>

View File

@@ -0,0 +1,189 @@
<script setup lang="ts">
import { onPullDownRefresh } from '@dcloudio/uni-app'
import { useAiChat } from '@/composables/useAiChat'
import type { Conversation } from '@/types/events'
const ai = useAiChat()
/** 点击会话项切换 */
function handleSelect(convId: string): void {
ai.switchConversation(convId)
// 切换后跳回对话页
uni.switchTab({ url: '/pages/chat/index' })
}
/** 新建会话 */
function handleNew(): void {
ai.newConversation()
uni.switchTab({ url: '/pages/chat/index' })
}
/** 长按会话项重命名(对齐桌面端改会话名) */
function handleRename(conv: Conversation): void {
uni.showModal({
title: '重命名会话',
editable: true,
placeholderText: '输入新名称',
content: conv.title || '',
success: (r) => {
if (r.confirm && r.content) {
const newTitle = r.content.trim()
if (newTitle && newTitle !== conv.title) {
ai.renameConversation(conv.id, newTitle)
}
}
},
})
}
/** 下拉刷新:发 list_conversations 同步 device 会话列表(F-#95)。
* 命令异步回流经 AiConversationList 事件替换 ai.conversations(handleEvent),
* 此处仅发命令 + 收尾停止下拉动画(列表更新非同步返回,故立即停止动画即可)。 */
onPullDownRefresh(() => {
ai.refreshConversations()
uni.stopPullDownRefresh()
})
/** 格式化时间戳为可读字符串 */
function formatTime(ts?: number): string {
if (!ts) return ''
const d = new Date(ts)
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const mi = String(d.getMinutes()).padStart(2, '0')
return `${mm}-${dd} ${hh}:${mi}`
}
</script>
<template>
<view class="conv-page">
<!-- 顶部新建按钮 -->
<view class="new-bar" @click="handleNew">
<text class="new-icon">+</text>
<text class="new-text">新建会话</text>
</view>
<!-- 会话列表 -->
<scroll-view class="conv-list" scroll-y>
<view v-if="ai.conversations.length === 0" class="empty-hint">
<text>暂无历史会话</text>
<text class="empty-sub">在桌面端创建后,此处同步</text>
</view>
<view
v-for="conv in ai.conversations"
:key="conv.id"
class="conv-item"
:class="{ active: conv.id === ai.activeConversationId.value }"
@click="handleSelect(conv.id)"
@longpress="handleRename(conv)"
>
<view class="conv-main">
<text class="conv-title">{{ conv.title || '(未命名会话)' }}</text>
<text v-if="conv.lastMessage" class="conv-last">{{ conv.lastMessage }}</text>
</view>
<text v-if="conv.updatedAt" class="conv-time">{{ formatTime(conv.updatedAt) }}</text>
</view>
<view v-if="ai.conversations.length > 0" class="list-hint">
<text>长按会话可重命名</text>
</view>
</scroll-view>
</view>
</template>
<style lang="scss" scoped>
.conv-page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: $uni-bg-color;
}
.new-bar {
display: flex;
align-items: center;
padding: 24rpx;
background-color: $uni-bg-color-dark;
border-bottom: 1rpx solid $uni-border-color;
}
.new-icon {
font-size: 40rpx;
color: $uni-color-primary;
margin-right: 16rpx;
}
.new-text {
font-size: $uni-font-size-base;
color: $uni-color-primary;
}
.conv-list {
flex: 1;
}
.empty-hint {
display: flex;
flex-direction: column;
align-items: center;
padding: 200rpx 24rpx;
text {
color: $uni-text-color-disable;
font-size: $uni-font-size-base;
}
.empty-sub {
margin-top: 12rpx;
font-size: $uni-font-size-sm;
}
}
.conv-item {
display: flex;
align-items: center;
padding: 24rpx;
border-bottom: 1rpx solid $uni-border-color;
&.active {
background-color: $uni-bg-color-light;
border-left: 6rpx solid $uni-color-primary;
}
}
.conv-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.conv-title {
font-size: $uni-font-size-base;
color: $uni-text-color;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conv-last {
margin-top: 8rpx;
font-size: $uni-font-size-sm;
color: $uni-text-color-grey;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conv-time {
font-size: $uni-font-size-sm;
color: $uni-text-color-disable;
margin-left: 16rpx;
}
.list-hint {
padding: 24rpx;
text-align: center;
}
.list-hint text {
color: $uni-text-color-disable;
font-size: $uni-font-size-sm;
}
</style>

7
apps/df-miniapp/src/shims-uni.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="@dcloudio/types" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
export default component
}

View File

@@ -0,0 +1,196 @@
/**
* AiChatEvent 类型镜像(对齐 DevFlow 后端 Rust 定义)
*
* 类型证据:
* - Rust 定义:src-tauri/src/commands/ai/mod.rs:105-216 `pub enum AiChatEvent`
* (19 个变体,#[serde(tag = "type")] → TS 用 type discriminator 镜像)
* - Rust 字段 snake_case:Rust 默认 snake_case 字段,TS 直接对齐(不加 camelCase 转换)
* - 桌面 TS:src/api/types.ts(未在本批核验,但变体名/字段从 Rust 源码逐字对齐)
*
* 注意:TS 联合类型 discriminator 名为 `type`,与 Rust #[serde(tag = "type")] 一致。
* 变体名 PascalCase(Rust enum 变体名原样,serde 默认 PascalCase 序列化)。
*/
// SkillInfo 复用 relay.ts 同协议层定义(`/` 联想浮层渲染 + 选中后 name 透传 SendMessageArgs.skill)
import type { SkillInfo } from './relay'
/** 错误源分类(对齐 Rust ErrorType,mod.rs:89-102,snake_case 序列化) */
export type ErrorType = 'auth' | 'network' | 'timeout' | 'provider_config' | 'unknown'
/** 会话生命周期状态(对齐 Rust ConvState,L2 统一状态机,snake_case 序列化) */
export type ConvState = 'idle' | 'generating' | 'stopping' | 'error' | 'compressed'
/**
* AI 聊天事件联合类型(19 个变体,严格对齐 Rust mod.rs:107-216)
*
* 字段全部对齐 Rust serde(rename_all 默认 snake_case 字段名,tag = "type")。
* 变体名 PascalCase 来自 Rust enum 变体名(serde 默认 PascalCase 序列化 enum variant)。
*
* 注:AiConversationList 的 conversations 字段对齐 Rust ConvSummary(#[serde(rename_all="camelCase")]),
* 元素结构同本文件 Conversation interface(id/title/lastMessage?/updatedAt?/createdAt?)。
*/
export type AiChatEvent =
| { type: 'AiTextDelta'; delta: string; conversation_id?: string | null }
| { type: 'AiToolCallStarted'; id: string; name: string; args: unknown; conversation_id?: string | null }
| { type: 'AiToolCallCompleted'; id: string; result: unknown; conversation_id?: string | null }
| { type: 'AiToolAutoApproved'; id: string; tool: string; dir: string; conversation_id?: string | null }
| {
type: 'AiApprovalRequired'
id: string
name: string
args: unknown
reason: string
diff?: string | null
conversation_id?: string | null
}
| { type: 'AiApprovalResult'; id: string; approved: boolean; conversation_id?: string | null }
| {
type: 'AiCompleted'
total_tokens: number
prompt_tokens: number
completion_tokens: number
incomplete?: boolean | null
conversation_id?: string | null
}
| { type: 'AiError'; error: string; error_type?: ErrorType | null; conversation_id?: string | null }
| { type: 'AiUserMessage'; message: string; conversation_id?: string | null }
| { type: 'AiAgentRound'; round: number; conversation_id?: string | null }
| { type: 'AiHeartbeat'; conversation_id?: string | null }
| { type: 'AiMaxRoundsReached'; conversation_id?: string | null }
| { type: 'AiStreamRetry'; attempt: number; max_attempts: number; conversation_id?: string | null }
| {
type: 'AiDirAuthRequired'
id: string
tool: string
path: string
dir: string
conversation_id?: string | null
}
| { type: 'AiContextCleared'; conversation_id?: string | null }
| { type: 'AiCompressing'; conversation_id?: string | null }
// 治 Task#1:拆分手动/自动两变体(字段相同仅 type 字面量不同),桌面按 type 分流,
// miniapp 两变体都插摘要气泡(历史可见性,见 useAiChat.ts:618 case fallthrough)。
| { type: 'AiManualCompressed'; conversation_id?: string | null; summary: string }
| { type: 'AiAutoCompressed'; conversation_id?: string | null; summary: string }
| {
type: 'AiHelpRequired'
reason: string
context: string
options: string[]
conversation_id?: string | null
}
| { type: 'AiConvStateChanged'; conv_state: ConvState; conversation_id?: string | null }
| { type: 'AiConversationList'; conversations: Conversation[] }
| { type: 'AiMessageHistory'; conversation_id: string; messages: ChatMessage[] }
| { type: 'AiSkillList'; skills: SkillInfo[] }
| {
type: 'AiEntityList'
projects: ProjectRecord[]
tasks: TaskRecord[]
ideas: IdeaRecord[]
}
/** AiChatEvent 的 type 字面量集合(用于 narrowing / 分派) */
export type AiChatEventType = AiChatEvent['type']
/** 会话信息(对齐桌面端 list_conversations 返回结构,字段名 camelCase 兼容桌面 IPC 约定) */
export interface Conversation {
id: string
title: string
/** 最近一条消息摘要(可选) */
lastMessage?: string
/** 最近更新时间戳(ms) */
updatedAt?: number
/** 创建时间(ms) */
createdAt?: number
}
/** 本地渲染消息气泡(对齐桌面端 AiMessage 结构,简化为 miniapp 必需字段) */
export interface ChatMessage {
id: string
role: 'user' | 'assistant' | 'system'
content: string
timestamp: number
isError?: boolean
/** 工具调用列表(对齐 AiToolCallInfo) */
toolCalls?: AiToolCallInfo[]
/** 不完整标记(AiCompleted{incomplete:true} 对应系统提示气泡) */
incomplete?: boolean
}
/** 工具调用信息(对齐桌面 useAiEvents.ts:399 的 AiToolCallInfo) */
export interface AiToolCallInfo {
id: string
name: string
args: unknown
result?: unknown
status: 'running' | 'completed' | 'failed' | 'pending_approval' | 'rejected'
reason?: string
diff?: string
/** 审批类型(risk 普通 / path 路径授权,对齐后端 ApprovalKind) */
kind?: 'risk' | 'path'
dir?: string
path?: string
}
/** 挂起审批项(复用 AiToolCallInfo 结构,miniapp 不实现审批 UI 仅展示) */
export type PendingApproval = AiToolCallInfo
/** Token 用量(对齐桌面端 state.lastTokenUsage) */
export interface TokenUsage {
prompt: number
completion: number
total: number
}
// ============================================================
// 实体列表类型(F-#95 联想扩展,AiEntityList 事件载荷)
// ============================================================
//
// 精简字段对齐桌面端 src/api/types.ts(后端 Record 全量字段经 Tauri command Serialize 直传,
// miniapp 仅声明联想浮层展示所需字段;多余字段 JSON 解析保留不报错)。
// SkillInfo 从 ./relay 复用(同协议层定义,避免重复)。
/** 项目记录(对齐桌面端 src/api/types.ts:80 ProjectRecord,miniapp 精简版) */
export interface ProjectRecord {
id: string
name: string
description: string
status: string
idea_id: string | null
/** 绑定的本地代码目录绝对路径(null=未绑定) */
path: string | null
/** 技术栈 JSON 数组字符串(如 ["rust","vue"],null=未探测) */
stack: string | null
created_at: string
updated_at: string
}
/** 任务记录(对齐桌面端 src/api/types.ts:135 TaskRecord,miniapp 精简版) */
export interface TaskRecord {
id: string
project_id: string
title: string
description: string
status: string
/** 优先级:0=critical, 1=high, 2=medium, 3=low */
priority: number
branch_name: string | null
assignee: string | null
created_at: string
updated_at: string
}
/** 灵感记录(对齐桌面端 src/api/types.ts:11 IdeaRecord,miniapp 精简版) */
export interface IdeaRecord {
id: string
title: string
description: string
status: string
priority: number
score: number | null
tags: string | null
source: string | null
created_at: string
updated_at: string
}

View File

@@ -0,0 +1,204 @@
/**
* df-relay 协议类型镜像(对齐 Rust crates/df-relay/src/)
*
* 类型证据:
* - ClientKind / MessageKind:crates/df-relay/src/broadcast.rs:21-41
* (#[serde(rename_all = "snake_case")] → 'device'/'miniapp', 'event'/'command'/'control')
* - BroadcastMessage:broadcast.rs:48-62(device_id/kind/source/from/payload/ts)
* - Hello 握手帧:crates/df-relay/src/relay.rs:44-53(kind/device_id/token)
* kind 用 ClientKindWire(relay.rs:56-61),序列化为 'device'/'miniapp'
* - 路由端点:relay.rs:177-183 `/ws/miniapp`(小程序专用端点)
*
* 关键协议点(relay.rs:332-365 handle_inbound_text):
* relay 不解析 payload —— miniapp 入站文本帧直接当 payload 包成 BroadcastMessage 路由给 device。
* 故 miniapp 发送时,直接序列化 Command JSON(relay 自动包 payload),不需客户端构造 BroadcastMessage。
* 接收时,device 事件经 relay 包成完整 BroadcastMessage 序列化下发,miniapp 需先解 BroadcastMessage
* 再从 payload 取 AiChatEvent。
*/
/** 客户端类型(对齐 Rust ClientKind,snake_case 序列化,broadcast.rs:21-28) */
export type ClientKind = 'device' | 'miniapp'
/** 消息语义分类(对齐 Rust MessageKind,snake_case 序列化,broadcast.rs:35-41) */
export type MessageKind = 'event' | 'command' | 'control'
/** 在线状态(对齐 Rust PresenceState,snake_case 序列化,broadcast.rs:119-126) */
export type PresenceState = 'online' | 'offline'
/** ConnId 是 Rust u64(对齐 conn.rs ConnId struct),TS 用 number */
export type ConnId = number
/**
* 中继路由消息骨架(对齐 Rust BroadcastMessage,broadcast.rs:48-62)
*
* miniapp 接收时 payload 为 AiChatEvent JSON(relay 透传不解析)。
*/
export interface BroadcastMessage {
/** 配对绑定的设备 ID */
device_id: string
/** 消息语义 */
kind: MessageKind
/** 消息来源连接 ID(relay 分配,u64) */
source: ConnId
/** 来源客户端类型 */
from: ClientKind
/** 载荷(原始 JSON,relay 透传不解析 —— Event 时为 AiChatEvent) */
payload: unknown
/** 时间戳(ms) */
ts: number
}
/**
* 客户端首消息:身份宣告(对齐 Rust Hello,relay.rs:44-53)
*
* 小程序连 `/ws/miniapp` 后首帧必须发此结构,否则 relay 关连接(relay.rs:197-208)。
* kind 恒为 'miniapp'(与端点匹配,relay.rs:212-225 校验,不匹配则关连接)。
*/
export interface Hello {
/** 客户端类型,小程序恒 'miniapp'(对齐 ClientKindWire) */
kind: 'miniapp' | 'device'
/** 配对绑定的设备 ID(用户首次配置时生成) */
device_id: string
/** 配对 token(env DF_RELAY_TOKEN,fallback "devflow-relay-default-token") */
token: string
}
/**
* 控制面消息(对齐 Rust ControlMessage,broadcast.rs:132-145,tag = "control_kind")
*
* miniapp 主要发 Heartbeat 心跳 + Ping;Pair 在首次绑定时用(本批未实现绑定流程)。
*/
export type ControlMessage =
| { control_kind: 'pair'; device_id: string; token: string }
| { control_kind: 'heartbeat'; device_id: string }
| { control_kind: 'presence'; device_id: string; state: PresenceState }
| { control_kind: 'ping' }
| { control_kind: 'pong' }
/**
* 小程序 → 桌面端 Command 协议(miniapp 发起操作)
*
* 类型为对齐 Tauri command 名,relay 透传 device_id 路由到桌面端,桌面端 invoke 对应 command。
* 字段:
* - cmd:命令名(对齐 src-tauri command 函数名)
* - args:命令参数(对齐各 command 的参数)
*
* 路由表全覆盖(14 条):
* - send_message / stop / switch_conversation / regenerate(MVP 已发)
* - approve / authorize_dir / continue_loop / stop_loop(阶段3 补齐,见 useAiChat.ts)
* - list_conversations / load_messages(F-#95 扩展,会话列表/历史消息读取)
* - list_skills / list_entities(F-#95 联想扩展,技能/项目·任务·灵感列表,见 useAiChat.ts)
* - rename_conversation(会话重命名,2026-06-23)/ sync_pending(重连审批恢复,2026-06-23)
*/
export interface MiniCommand {
/** Tauri command 名 */
cmd: string
/** 命令参数(原始对象,对齐各 command 参数签名) */
args: Record<string, unknown>
}
/** send_message 命令参数(对齐 src-tauri ai_chat_send command) */
export interface SendMessageArgs {
message: string
conversation_id?: string | null
model_override?: string | null
/**
* 选中技能名(`/<skill>` 联想选中后透传,对齐 ai_chat_send 的 skill 参数)。
* null/undefined=无技能(普通对话);非空=后端注入对应 SKILL.md 全文到 prompt。
*/
skill?: string | null
/**
* @ mention 区间元数据(用户选中 @项目/@任务/@灵感 后透传,
* 对齐 ai_chat_send 的 mention_spans 参数,后端 resolve 投影成 Augmentation 注入)。
* null/undefined/空=无 mention(纯文本消息)。
*/
mention_spans?: MentionSpan[] | null
}
/**
* 用户消息内 mention 区间的元数据
* (对齐后端 MentionSpanDto,crates/df-types/src/augmentation.rs:235)。
*
* 字段名对齐后端 serde:refId(camelCase,后端 serde rename)。
* 前端按 {start, length} 在原文中切出区间替换为 chip。
*/
export interface MentionSpan {
/** 区间起点(字符偏移,前端约定,后端仅透传不解释) */
start: number
/** 区间长度 */
length: number
/** kind 标签:project / task / idea / skill(对齐后端 MentionKind serde snake_case) */
kind: 'project' | 'task' | 'idea' | 'skill'
/** 引用稳定标识(Project/Task/Idea 为 id,Skill 为 name),前端用于反查 */
refId: string
/** chip 展示文本(项目名/任务标题/技能名) */
label: string
}
/**
* 技能信息(对齐桌面端 src/api/types.ts:614 SkillInfo + 后端 SkillInfo,
* src-tauri/src/commands/ai/skills.rs:19-34)。
*
* 供 `/` 联想浮层渲染 + 选中后 name 透传 SendMessageArgs.skill。
* duplicates 可选:后端 #[serde(skip_serializing_if=Option::is_none)] 无冲突不序列化,
* miniapp 加 `?` 兜底防 prod 反序列化宽松报错。
*/
export interface SkillInfo {
/** 技能名(联想浮层展示 + 选中后透传 skill 参数) */
name: string
/** 技能描述(浮层副标题) */
description: string
/** 参数提示(可选,如 "<url>") */
argument_hint?: string
/** skill | command | plugin(来源分类) */
source: string
/** SKILL.md 绝对路径(后端注入时读全文) */
path: string
/** 同名冲突的其他来源路径(可选,无冲突不序列化) */
duplicates?: string[]
}
/** switch_conversation 命令参数 */
export interface SwitchConversationArgs {
conversation_id: string
}
/** stop 命令参数(对齐 ai_chat_stop) */
export interface StopArgs {
conversation_id?: string | null
}
/** approve 命令参数(对齐 src-tauri ai_approve,Phase3 §2.2 路由表) */
export interface ApproveArgs {
/** 待审批的工具调用 id(对应 AiApprovalRequired 事件的 id) */
tool_call_id: string
/** 是否批准(true=批准执行,false=拒绝) */
approved: boolean
}
/**
* authorize_dir 命令参数(对齐 src-tauri ai_authorize_dir,Phase3 §2.2 路由表)
*
* decision 取值:对齐 src-tauri ai_authorize_dir 的 decision 参数
* - 'once':本次授权(下次同路径仍需问)
* - 'always':永久授权(写入会话级信任目录)
* - 'deny':拒绝(本次不授权)
*/
export interface AuthorizeDirArgs {
/** 待授权的工具调用 id(对应 AiDirAuthRequired 事件的 id) */
tool_call_id: string
/** 授权决策('once' / 'always' / 'deny') */
decision: 'once' | 'always' | 'deny'
}
/** continue_loop 命令参数(对齐 src-tauri ai_continue_loop,Phase3 §2.2 路由表) */
export interface ContinueLoopArgs {
/** 需继续运行的会话 id */
conversation_id: string
}
/** stop_loop 命令参数(对齐 src-tauri ai_stop_loop,Phase3 §2.2 路由表) */
export interface StopLoopArgs {
/** 霷停止循环的会话 id */
conversation_id: string
}

View File

@@ -0,0 +1,33 @@
/**
* 全局样式变量(uni-app 规范:uni.scss 由 uni-app 自动引入到各页面/组件)
*
* 颜色基调对齐桌面端暗色主题(便于跨端视觉一致)。
*/
/* 主色调 */
$uni-color-primary: #4a9eff;
$uni-color-success: #4cd964;
$uni-color-warning: #f0ad4e;
$uni-color-error: #dd524d;
/* 中性色(暗色背景适配) */
$uni-bg-color: #0f0f0f;
$uni-bg-color-dark: #1a1a1a;
$uni-bg-color-light: #2a2a2a;
$uni-text-color: #e0e0e0;
$uni-text-color-grey: #999999;
$uni-text-color-disable: #666666;
$uni-border-color: #333333;
/* 字号 */
$uni-font-size-sm: 24rpx;
$uni-font-size-base: 28rpx;
$uni-font-size-lg: 32rpx;
/* 气泡角色配色 */
$uni-bubble-user-bg: #4a9eff;
$uni-bubble-user-text: #ffffff;
$uni-bubble-assistant-bg: #2a2a2a;
$uni-bubble-assistant-text: #e0e0e0;
$uni-bubble-error-bg: #3a1a1a;
$uni-bubble-error-text: #ff6b6b;

View File

@@ -0,0 +1,62 @@
/**
* marked HTML 后处理:为代码块/内联 code/链接/表格注入 inline style(P1-G)。
*
* 为什么后处理而非 marked 自定义 renderer:
* - mp-weixin rich-text 不链接页面 CSS class,仅认节点 inline style → 必须把样式写进 HTML 属性。
* - 纯字符串后处理规避 marked renderer 版本签名差异(v5 前 (text,info) / v5+ Token 对象),
* 版本无关,build 不依赖 renderer TS 签名。
*
* mp-weixin rich-text 限制(已核官方文档,非臆测):
* - <a> 节点事件屏蔽,无法 bindtap 跳转 → 链接仅展示 + 长按复制(rich-text selectable=true);
* 跳转需第三方 mp-html(本次不做,记后续)。
* - rich-text 非滚动容器,overflow-y:auto/max-height 不创建滚动区 → 长代码靠
* white-space:pre-wrap + word-break:break-all 换行防撑爆,不限高滚动。
*/
/** 给指定标签注入 inline style(已有 style 则合并,无则新增)。 */
function styleTag(html: string, tag: string, style: string): string {
const re = new RegExp(`<${tag}(\\s[^>]*)?>`, 'g')
return html.replace(re, (m, attrs: string) => {
if (attrs && /\sstyle\s*=/.test(attrs)) {
// 已有 style:追加(在现有 style 值末尾加分号 + 新 style)
return m.replace(/style\s*=\s*"([^"]*)"/, (_full, prev: string) => {
const merged = prev.endsWith(';') ? prev : `${prev};`
return `style="${merged}${style}"`
})
}
return `<${tag}${attrs || ''} style="${style}">`
})
}
/**
* 对 marked.parse 产出的 HTML 注入 inline style。
*
* 设计取舍:pre(块代码)给深色背景;<code> 不给背景(透明),仅等宽+橙色 —— 避免块代码内层 code
* 与 pre 背景冲突的双层色块(块代码内 code 透明继承 pre 深底,内联 code 橙色等宽在消息底上也可读)。
*/
export function styleMarkdown(html: string): string {
let out = html
// 块代码 <pre>:深色背景 + 等宽 + 圆角 + pre-wrap 换行防撑爆
out = styleTag(
out,
'pre',
'display:block;background-color:#1a1a1a;color:#d4d4d4;padding:10px;border-radius:6px;white-space:pre-wrap;word-break:break-all;font-family:monospace;font-size:12px;margin:6px 0',
)
// code(块内 + 内联):等宽橙色,透明背景(避与 pre 双层色块)
out = styleTag(
out,
'code',
'font-family:monospace;color:#e0a070;background-color:transparent;font-size:12px',
)
// 链接:蓝下划线(rich-text 不可点击,仅展示 + selectable 长按复制)
out = styleTag(out, 'a', 'color:#4a9eff;text-decoration:underline')
// 表格:border + 块级 + 横向溢出处理(rich-text 非滚动,靠 word-break 兜底;border 提升可读)
out = styleTag(
out,
'table',
'display:block;border-collapse:collapse;width:100%;font-size:12px;word-break:break-all',
)
out = styleTag(out, 'th', 'border:1px solid #333;padding:4px 6px;background-color:#1a1a1a')
out = styleTag(out, 'td', 'border:1px solid #333;padding:4px 6px')
return out
}

View File

@@ -0,0 +1,25 @@
{
"extends": "@vue/tsconfig/tsconfig.json",
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"jsx": "preserve",
"isolatedModules": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"skipLibCheck": true,
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM"],
"types": ["@dcloudio/types", "@types/node"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"exclude": ["node_modules", "dist", "unpackage"]
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
// uni-app Vue3 vite 配置:vite-plugin-uni 注入跨端编译(mp-weixin / h5)。
// Phase2 落地遗漏本文件致 `uni build -p mp-weixin` 走纯 vite h5(只产 index.html),
// 补回后 -p mp-weixin 正确产出小程序文件(app.js/app.json/pages/*)。
export default defineConfig({
plugins: [uni()],
})

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

View File

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

View File

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

View File

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

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 块不可跨文件故作此切分。 //! 本模块仅保留 Provider struct + implHTTP 调用Rust impl 块不可跨文件故作此切分。
use async_trait::async_trait; use async_trait::async_trait;
use eventsource_stream::Eventsource;
use futures::StreamExt; use futures::StreamExt;
use reqwest::Client; use reqwest::Client;
use std::time::Duration; use std::time::Duration;
@@ -222,6 +221,17 @@ impl AnthropicCompatProvider {
// [tool_result..., text] blocks(Anthropic 允许一条 user 多 blocks),打破恶性循环。 // [tool_result..., text] blocks(Anthropic 允许一条 user 多 blocks),打破恶性循环。
Self::merge_consecutive_users(&mut messages); Self::merge_consecutive_users(&mut messages);
// B-260626-01: 保证首条为 user(Anthropic 协议硬性要求 messages[0].role == "user")。
// 上游绕过 ContextManager::sanitize_messages 的调用方(标题生成 / 知识注入 / 工作流 AI
// 节点等直接构造 CompletionRequest 的路径)可能传入首条 assistant 的序列——会话恢复、
// 续发或历史片段截取时,真正的首条 user 已被裁剪/压缩掉,直接发触发 precheck
// "首条 role=assistant 非法"。
// 根本解用"补"而非"砍":开头补一条 user 占位,保留全部上下文(assistant 的 tool_use
// 与其后 user 的 tool_result 配对完整),precheck 必过。砍会丢工具调用历史,且多轮
// [asst(tu),user(tr),asst(tu),user(tr),...] 逐对砍到空。占位是异常路径轻量噪声
// (正常会话首条本就是 user),远优于丢弃上下文。
Self::ensure_leading_user(&mut messages);
let tools = req.tools.map(|defs| { let tools = req.tools.map(|defs| {
defs.into_iter() defs.into_iter()
.map(|d| AnthropicToolDef { .map(|d| AnthropicToolDef {
@@ -295,6 +305,42 @@ impl AnthropicCompatProvider {
} }
} }
/// B-260626-01: 保证 messages 首条为 user(Anthropic 协议硬性要求 messages[0].role=="user")。
///
/// 上游绕过 `ContextManager::sanitize_messages` 的调用方(标题生成 / 知识注入 / 工作流 AI
/// 节点等直接构造 CompletionRequest 的路径)可能传入首条 assistant 的序列——会话恢复、续发
/// 或从历史片段截取时,真正的首条 user 已被裁剪/压缩掉。直接发触发 precheck "首条
/// role=assistant 非法"。
///
/// **用"补"而非"砍"**:在开头插一条 user 占位消息。
/// - 砍掉首条 assistant 会丢失有效上下文(其 tool_use 与后续 user 的 tool_result 是完整
/// 配对),且多轮 [asst(tu),user(tr),asst(tu),user(tr),...] 会被逐对砍到空;
/// - 补占位则全部上下文保留(占位 user 紧贴原首条 assistant,不破坏 user/assistant 交替),
/// tool_use/tool_result 配对完整不动,**无 orphan 产生**(故砍策略那套 orphan 清理在此不需要),
/// 占位 content 非空过 precheck 的"user content 空"校验。
///
/// 仅异常路径触发(正常会话首条本就是 user),占位文案是轻量噪声,远优于丢弃工具调用历史。
fn ensure_leading_user(messages: &mut Vec<serde_json::Value>) {
let first_role = messages
.first()
.and_then(|m| m.get("role").and_then(|r| r.as_str()))
.unwrap_or("");
if first_role == "user" {
return;
}
// 空 Vec(异常会话经 sanitize 清空)或首条非 user → 补 user 占位:
// Anthropic 协议要求 messages 至少一条且首条 user,补占位让降级会话能继续(不丢这条兜底)。
warn!(
first_role,
msg_count = messages.len(),
"ensure_leading_user: 首条非 user(含空),补 user 占位(防 Anthropic 首条 assistant/空 messages 非法)"
);
messages.insert(0, serde_json::json!({
"role": "user",
"content": "(continued from previous context)",
}));
}
/// 生成 messages 诊断摘要(每条 role + content 形态 + tool 标记),不含敏感数据。 /// 生成 messages 诊断摘要(每条 role + content 形态 + tool 标记),不含敏感数据。
/// B-260618-27: 1214 类错误时随 bail 文案直达前端 raw,定位哪条/字段非法。 /// B-260618-27: 1214 类错误时随 bail 文案直达前端 raw,定位哪条/字段非法。
fn summarize_messages(messages: &[serde_json::Value]) -> String { fn summarize_messages(messages: &[serde_json::Value]) -> String {
@@ -543,17 +589,19 @@ impl LlmProvider for AnthropicCompatProvider {
debug!(model = %body.model, "Anthropic 流式调用"); 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())) .auth_headers(self.client.post(self.messages_url()))
.json(&body) .json(&body)
.version(reqwest::Version::HTTP_11) .version(reqwest::Version::HTTP_11)
.send() .send();
.await let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await {
{ Ok(Ok(r)) => r,
Ok(r) => r, Ok(Err(e)) => {
Err(e) => {
// B-260618-26: 记 reqwest 错误源因链。原 ? 转 anyhow 仅 Display
// "error sending request for url" 无法定位 reset/TLS/超时/body 真因。
tracing::error!( tracing::error!(
is_timeout = e.is_timeout(), is_timeout = e.is_timeout(),
is_connect = e.is_connect(), is_connect = e.is_connect(),
@@ -573,6 +621,14 @@ impl LlmProvider for AnthropicCompatProvider {
std::error::Error::source(&e) 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() { if !resp.status().is_success() {
@@ -582,36 +638,32 @@ impl LlmProvider for AnthropicCompatProvider {
anyhow::bail!("Anthropic 流式 API 错误 {}: {}", status, text); anyhow::bail!("Anthropic 流式 API 错误 {}: {}", status, text);
} }
// 流式解析eventsource 逐事件处理,按 type 字段分发转 StreamChunk // BUG-2026-07-17 根治: 原生 SSE 解析器替代 eventsource-stream(同 openai_compat)
// 事件解析/usage 累积逻辑抽到 apply_anthropic_event 纯函数,便于单测;此处闭包只负责传 data。
// usage 累积message_start 给 input_tokensmessage_delta 给累计 output_tokens非增量message_stop 带出。
let mut usage_accum: Option<TokenUsage> = None; let mut usage_accum: Option<TokenUsage> = None;
// B-260618-28: MidStream error(如 GLM 1214 messages 非法)时附 messages 摘要定位哪条非法。 // 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 messages_summary = Self::summarize_messages(&body.messages);
let stream = resp
.bytes_stream() let sse = crate::sse_parser::SseStream::new(resp.bytes_stream());
.eventsource() let stream = sse.flat_map(move |result: Result<Vec<String>, String>| {
.map(move |event| match event { let mut chunks: Vec<anyhow::Result<crate::provider::StreamChunk>> = Vec::new();
Ok(ev) => { match result {
let mut chunk = apply_anthropic_event(&ev.data, &mut usage_accum); Ok(events) => {
// GLM 中途 error(如 1214)→ chunk.error 附 messages 摘要,经 stream_recv MidStream for data in events {
// 路径 emit AiError raw,前端直接看到实际 messages 结构定位非法字段。 let mut chunk = apply_anthropic_event(&data, &mut usage_accum);
if let Some(err) = chunk.error.as_mut() { if let Some(err) = chunk.error.as_mut() {
*err = format!("{} | messages 摘要: {}", err, messages_summary); *err = format!("{} | messages 摘要: {}", err, messages_summary);
}
chunks.push(Ok(chunk));
} }
Ok(chunk)
} }
Err(e) => { 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); let ctx = format!("Anthropic SSE 错误: {}", e);
error!(error = %e, "Anthropic SSE 事件流错误"); error!("{}", ctx);
Err(anyhow::Error::from(e).context(ctx)) chunks.push(Err(anyhow::anyhow!("{}", ctx)));
} }
}); }
futures::stream::iter(chunks)
});
Ok(Box::pin(stream)) Ok(Box::pin(stream))
} }
@@ -878,4 +930,170 @@ mod tests {
// 纯文本 → 字符串简写(非数组) // 纯文本 → 字符串简写(非数组)
assert_eq!(user_msg.get("content").and_then(|c| c.as_str()), Some("hello")); assert_eq!(user_msg.get("content").and_then(|c| c.as_str()), Some("hello"));
} }
// ---------- B-260626-01: ensure_leading_user首条 assistant → 补 user 占位,保留上下文)----------
/// 辅助:构造 assistant(tool_use) 消息
fn msg_assistant_with_tool_use(text: &str, tool_id: &str, tool_name: &str) -> ChatMessage {
ChatMessage::assistant_with_tools(
text,
vec![ToolCall::new(tool_id, tool_name, "{}")],
)
}
/// B-260626-01: 精确复现线上 bug——多轮 [asst(tool_use), tool_result] 链,首条 assistant。
/// 补一条 user 占位后:首条 user、tool_use/tool_result 配对完整保留、precheck 通过。
/// (原"砍"策略会把每对三元组砍掉,多轮砍到空,丢失全部工具调用历史——"补"策略零丢失。)
#[test]
fn anthropic_ensure_leading_user_tool_use_chain_preserves_context() {
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages: vec![
msg_assistant_with_tool_use("我来帮你", "call_f892", "read_file"),
ChatMessage::tool_result("call_f892", "file content"),
msg_assistant_with_tool_use("继续", "call_003a", "write_file"),
ChatMessage::tool_result("call_003a", "done"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let body = provider.convert_request(req);
// 首条必须是 user(补的占位)
let first_role = body.messages[0].get("role").and_then(|r| r.as_str()).unwrap_or("");
assert_eq!(first_role, "user", "首条应为 user(补占位)");
// 上下文零丢失:占位 user + asst(tu) + user(tr) + asst(tu) + user(tr) = 5 条
assert_eq!(
body.messages.len(), 5,
"应保留全部上下文(占位 + 原 4 条),实际 {} 条", body.messages.len()
);
assert!(
AnthropicCompatProvider::precheck_messages(&body.messages).is_ok(),
"precheck 应通过,实际 messages: {}",
AnthropicCompatProvider::summarize_messages(&body.messages)
);
}
/// B-260626-01: 首条 assistant 无 tool_use → 补占位,首条 user,原上下文保留。
#[test]
fn anthropic_ensure_leading_user_plain_assistant() {
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages: vec![
ChatMessage::assistant("你好"),
ChatMessage::user("请帮我"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let body = provider.convert_request(req);
assert_eq!(body.messages.len(), 3, "占位 + 原 2 条");
assert_eq!(
body.messages[0].get("role").and_then(|r| r.as_str()),
Some("user"),
);
assert!(AnthropicCompatProvider::precheck_messages(&body.messages).is_ok());
}
/// B-260626-01: 正常序列(user 开头)不补占位——零回归验证。
#[test]
fn anthropic_ensure_leading_user_normal_sequence_unchanged() {
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages: vec![
ChatMessage::user("hello"),
ChatMessage::assistant("hi there"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let body = provider.convert_request(req);
assert_eq!(body.messages.len(), 2, "正常序列不应补占位");
assert_eq!(
body.messages[0].get("role").and_then(|r| r.as_str()),
Some("user"),
);
assert!(AnthropicCompatProvider::precheck_messages(&body.messages).is_ok());
}
/// B-260626-01: 线上 3 轮工具调用场景(6 条 [asst(tu),tool_result]×3,首条 assistant)。
/// 补一个 user 占位后全部保留,验证多轮链不丢数据、precheck 通过(原"砍"策略此场景砍到空)。
#[test]
fn anthropic_ensure_leading_user_three_round_chain() {
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages: vec![
msg_assistant_with_tool_use("a1", "call_1", "read_file"),
ChatMessage::tool_result("call_1", "r1"),
msg_assistant_with_tool_use("a2", "call_2", "write_file"),
ChatMessage::tool_result("call_2", "r2"),
msg_assistant_with_tool_use("a3", "call_3", "list_directory"),
ChatMessage::tool_result("call_3", "r3"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let body = provider.convert_request(req);
let first_role = body.messages[0].get("role").and_then(|r| r.as_str()).unwrap_or("");
assert_eq!(first_role, "user", "首条应为 user(补占位)");
// 占位 + 3×[asst(tu),user(tr)] = 7 条,全部保留
assert_eq!(
body.messages.len(), 7,
"3 轮链应全保留(占位 + 原 6 条),实际 {} 条", body.messages.len()
);
assert!(
AnthropicCompatProvider::precheck_messages(&body.messages).is_ok(),
"3 轮链补占位后 precheck 应通过,实际: {}",
AnthropicCompatProvider::summarize_messages(&body.messages)
);
}
/// B-260626-01: 空 messages(异常会话经 sanitize 清空)→ convert 补 1 条 user 占位,
/// 避免发空 messages 触发 precheck "messages 为空"(降级让会话能继续)。
#[test]
fn anthropic_ensure_leading_user_empty_messages_gets_placeholder() {
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages: vec![],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let body = provider.convert_request(req);
assert_eq!(body.messages.len(), 1, "空 messages 应补 1 条 user 占位");
assert_eq!(
body.messages[0].get("role").and_then(|r| r.as_str()),
Some("user"),
);
assert!(
AnthropicCompatProvider::precheck_messages(&body.messages).is_ok(),
"补占位后 precheck 应通过"
);
}
} }

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 不应污染内存全量");
}
}

View File

@@ -341,6 +341,14 @@ pub const TOOL_RESULT_SUMMARIZE_RATIO: f32 = 0.40;
pub const TOOL_RESULT_HEAD_LINES: usize = 5; pub const TOOL_RESULT_HEAD_LINES: usize = 5;
/// extract_key_info 保留的尾部行数。 /// extract_key_info 保留的尾部行数。
pub const TOOL_RESULT_TAIL_LINES: usize = 5; pub const TOOL_RESULT_TAIL_LINES: usize = 5;
/// extract_key_info JSON 数组截断上限(防 tool_result 数组过大撑爆 prompt)。
pub const TOOL_RESULT_MAX_ARRAY: usize = 10;
/// extract_key_info 单行/少行内容字符截断上限(实测 53/94 次压缩零效果根因:
/// 单行 JSON 或 ≤10 行文本绕过行级截断)。超过此值的单行内容将被截断保留头尾。
pub const TOOL_RESULT_CHAR_LIMIT: usize = 1_024;
/// JSON 对象中字符串字段值的最大字符数(超过则截断)。独立于行数截断,
/// 解决 `{"content":"大段文字(无换行)"}` 类 JSON 逃逸行级截断的问题。
pub const TOOL_RESULT_JSON_STR_FIELD_MAX: usize = 512;
/// 判断 tool_result 是否需摘要压缩:content >2KB 或 占比 >40%。 /// 判断 tool_result 是否需摘要压缩:content >2KB 或 占比 >40%。
/// ///
@@ -374,6 +382,54 @@ pub fn should_summarize_tool_result(
/// ///
/// `tool_name` 仅用于摘要头注释,不参与内容判断。空 content 返回空字符串。 /// `tool_name` 仅用于摘要头注释,不参与内容判断。空 content 返回空字符串。
pub fn extract_key_info(content: &str, tool_name: &str) -> String { pub fn extract_key_info(content: &str, tool_name: &str) -> String {
// JSON 感知压缩:识别对象中的大数组/大字符串并截断
if let Ok(mut val) = serde_json::from_str::<serde_json::Value>(content) {
if let Some(obj) = val.as_object_mut() {
let mut truncated = false;
for (_key, field) in obj.iter_mut() {
// 数组截断
if let Some(arr) = field.as_array() {
if arr.len() > TOOL_RESULT_MAX_ARRAY {
*field = serde_json::Value::Array(
arr.iter().take(TOOL_RESULT_MAX_ARRAY).cloned().collect()
);
truncated = true;
}
}
// 字符串字段:先按行数截断,若不足再按字符数截断
if let Some(s) = field.as_str() {
let lines: Vec<&str> = s.lines().collect();
let kept = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES;
if lines.len() > kept {
let mut out: Vec<&str> = Vec::new();
out.extend_from_slice(&lines[..TOOL_RESULT_HEAD_LINES]);
out.push("... (压缩中间内容) ...");
out.extend_from_slice(&lines[lines.len()-TOOL_RESULT_TAIL_LINES..]);
*field = serde_json::Value::String(out.join("\n"));
truncated = true;
} else if s.chars().count() > TOOL_RESULT_JSON_STR_FIELD_MAX {
// BUG-260628-01:单行/少行大字符串绕过行级截断(实测 53/94 次零效果)。
// 按字符数截断保留头尾,保证压缩至少生效。
let head: String = s.chars().take(TOOL_RESULT_JSON_STR_FIELD_MAX / 2).collect();
let tail: String = s.chars().skip(s.chars().count().saturating_sub(TOOL_RESULT_JSON_STR_FIELD_MAX / 2)).collect();
*field = serde_json::Value::String(format!(
"{}...(截断,原始 {} 字符)...{}",
head, s.chars().count(), tail
));
truncated = true;
}
}
}
if truncated {
obj.insert("_truncated".into(), serde_json::Value::Bool(true));
return serde_json::to_string(&val).unwrap_or_else(|_| content.to_string());
}
}
// JSON 解析成功但无需截断 → 原样返回
return content.to_string();
}
// 非 JSON 纯文本:按行数截断
let lines: Vec<&str> = content.lines().collect(); let lines: Vec<&str> = content.lines().collect();
if lines.is_empty() { if lines.is_empty() {
return String::new(); return String::new();
@@ -383,6 +439,17 @@ pub fn extract_key_info(content: &str, tool_name: &str) -> String {
let total = lines.len(); let total = lines.len();
let kept_boundary = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES; let kept_boundary = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES;
if total <= kept_boundary { if total <= kept_boundary {
// BUG-260628-01:行数少但内容超大的情况(单行 50KB),行级截断无效。
// 按字符数截断保证压缩至少生效。
let char_count = content.chars().count();
if char_count > TOOL_RESULT_CHAR_LIMIT {
let head: String = content.chars().take(TOOL_RESULT_CHAR_LIMIT / 2).collect();
let tail: String = content.chars().skip(char_count.saturating_sub(TOOL_RESULT_CHAR_LIMIT / 2)).collect();
return format!(
"[工具 {} 输出已压缩: 保留首尾, 原始 {} 字符]\n{}...(截断)...{}",
tool_name, char_count, head, tail
);
}
return content.to_string(); return content.to_string();
} }
@@ -877,18 +944,35 @@ mod tests {
} }
#[test] #[test]
fn extract_key_info_single_line_no_newline_unchanged() { fn extract_key_info_single_line_short_no_newline_unchanged() {
// 边界(无换行):单行(无 \n)→ lines() 返 1 行,total <= kept_boundary → 原样返回 // 边界(无换行):单行短内容(字符数 <= CHAR_LIMIT)→ 原样返回
let content = "single line no newline"; let content = "single line no newline";
assert_eq!(extract_key_info(content, "read_file"), content); assert_eq!(extract_key_info(content, "read_file"), content);
} }
#[test] #[test]
fn extract_key_info_single_huge_line_no_newline_unchanged() { fn extract_key_info_single_huge_line_no_newline_compressed() {
// 极端(单行 50KB 无换行):lines() 返 1 行 → 原样返回(不走首尾切分) // BUG-260628-01:单行超大内容(50KB)原本逃逸压缩,现按字符数截断保留头尾。
let content = "x".repeat(50_000); let content = "x".repeat(50_000);
let result = extract_key_info(&content, "read_file"); let result = extract_key_info(&content, "read_file");
assert_eq!(result, content, "单行无换行应原样返回(即使超长)"); assert!(result.len() < content.len(), "单行超长应压缩: {} >= {}", result.len(), content.len());
assert!(result.contains("已压缩"), "应含压缩标记");
assert!(result.starts_with("[工具 read_file"), "应以工具名开头");
assert!(result.contains("原始 50000 字符"), "应报告原始字符数");
assert!(result.contains("(截断)"), "应含截断标记");
}
#[test]
fn extract_key_info_json_huge_string_field_truncated() {
// BUG-260628-01:JSON 对象中大字符串字段(单行少行)逃逸压缩。
// 如 `{"path":"src/main.rs","content":"单行超大文本..."}`。
let large = "z".repeat(10_000);
let content = format!("{{\"path\":\"src/main.rs\",\"content\":\"{}\"}}", large);
let result = extract_key_info(&content, "read_file");
assert!(result.len() < content.len(), "JSON 大字符串字段应压缩: {} >= {}", result.len(), content.len());
assert!(result.contains("_truncated"), "应含 _truncated 标记");
assert!(result.contains("src/main.rs"), "应保留 path 字段");
assert!(result.contains("(截断)"), "应含截断标记");
} }
#[test] #[test]

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

@@ -286,6 +286,73 @@ impl ToolDomain {
// ---- 识别器 ----------------------------------------------------------------- // ---- 识别器 -----------------------------------------------------------------
/// 意图识别 L0 去噪:剥离 mention 结构化前缀(`[项目: DevFlow]` / `[任务: xxx]` 等)。
///
/// mention 前缀是系统注入的结构标签,语义已由 augmentation @ 通道单独 resolve
/// 注入 system promptcontent 里残留的前缀只污染关键词识别——实测会话
/// `[项目: DevFlow] 这个错误是项目里的 miniapp 报错` 被误判 `Intent::Project`
/// 收敛砍掉 File domain 致 `read_file` 对 LLM 不可见agent 被迫反复 `list_tasks`。
///
/// 剥离规则:扫描 `[`,若紧跟已知 mention kind项目/任务/想法/技能,
/// project/task/idea/skill大小写不敏感+ 冒号(全/半角 `:`/``),则连同到
/// 首个 `]` 整段移除;未闭合或非已知 kind 的 `[...]` 原样保留(代码片段/错误码)。
///
/// kind 表与 `MentionRef`project/task/idea/skill对齐——单一真相源新增 mention
/// kind 时此处同步。不折叠空白:关键词 `contains` 匹配对空白不敏感,剥离即可。
fn strip_mention_tags(message: &str) -> String {
const KINDS: &[&str] = &["项目", "任务", "想法", "技能", "project", "task", "idea", "skill"];
let chars: Vec<char> = message.chars().collect();
let n = chars.len();
let mut out = String::with_capacity(message.len());
let mut i = 0;
while i < n {
if chars[i] == '[' {
if let Some(close) = try_match_mention(&chars, i, KINDS) {
i = close + 1; // 跳过整段 mention含 `]`
continue;
}
}
out.push(chars[i]);
i += 1;
}
out
}
/// 探测 `chars[open] == '['` 起的 mention 段:`[kind: ...]`。
///
/// 返回匹配 `]` 的索引(`open` 指向 `[`。kind 大小写不敏感ASCII+ 中文直比;
/// 冒号接受全角 ``/半角 `:`kind 后允许空白;未闭合 `]` 返 `None`(不当 mention保留原样
fn try_match_mention(chars: &[char], open: usize, kinds: &[&str]) -> Option<usize> {
let after = open + 1;
for k in kinds {
let kc: Vec<char> = k.chars().collect();
if chars.len() < after + kc.len() {
continue;
}
let slice = &chars[after..after + kc.len()];
if !kc.iter().zip(slice.iter()).all(|(a, b)| a.eq_ignore_ascii_case(b)) {
continue;
}
let mut j = after + kc.len();
while j < chars.len() && chars[j].is_whitespace() {
j += 1;
}
if j >= chars.len() || (chars[j] != ':' && chars[j] != '') {
continue;
}
j += 1;
while j < chars.len() {
if chars[j] == ']' {
return Some(j);
}
j += 1;
}
// 未闭合 `]` → 不当 mention保留原样
return None;
}
None
}
/// 意图识别器(无状态,所有方法均为纯函数,可 `Default` 构造)。 /// 意图识别器(无状态,所有方法均为纯函数,可 `Default` 构造)。
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct IntentRecognizer; pub struct IntentRecognizer;
@@ -299,14 +366,16 @@ impl IntentRecognizer {
/// ///
/// 优先级SPECIFIC > ENTITY > GENERIC。高分组的胜者直接返回不累积低分组。 /// 优先级SPECIFIC > ENTITY > GENERIC。高分组的胜者直接返回不累积低分组。
pub fn recognize(message: &str) -> (Intent, f32) { pub fn recognize(message: &str) -> (Intent, f32) {
// L0 去噪:先剥离 mention 结构标签(防 `[项目: x]` 把代码任务误判 Project
let cleaned = strip_mention_tags(message);
// 依次按优先级组求胜者。高分组有命中即提前返回。 // 依次按优先级组求胜者。高分组有命中即提前返回。
if let Some(hit) = best_in_group(message, SPECIFIC_GROUP) { if let Some(hit) = best_in_group(&cleaned, SPECIFIC_GROUP) {
return hit; return hit;
} }
if let Some(hit) = best_in_group(message, ENTITY_GROUP) { if let Some(hit) = best_in_group(&cleaned, ENTITY_GROUP) {
return hit; return hit;
} }
if let Some(hit) = best_in_group(message, GENERIC_GROUP) { if let Some(hit) = best_in_group(&cleaned, GENERIC_GROUP) {
return hit; return hit;
} }
(Intent::Unknown, 0.0) (Intent::Unknown, 0.0)
@@ -347,7 +416,7 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)>
/// 返回空 `Vec` 表示该意图**无工具收敛**Chat或**未识别**Unknown /// 返回空 `Vec` 表示该意图**无工具收敛**Chat或**未识别**Unknown
/// 上游应走**全量 fallback**(即不过滤工具,交全量给 LLM /// 上游应走**全量 fallback**(即不过滤工具,交全量给 LLM
/// ///
/// 设计Code → [file, http]File → [file]Project/Task/Idea → [data] /// 设计Code → [file, http]File → [file]Project/Task/Idea → [data, file](加 file 防「提项目/任务 → 误判 → 砍只读探索」,见 L1
/// Http → [http]Search → [file](含 search_filesConversation → []; /// Http → [http]Search → [file](含 search_filesConversation → [];
/// Chat → []Debug → [file, exec, http, data](调试常需跑命令+读文件+查 API+查任务/工作流状态,CR-25 审查🟡-1 加 data 防"调试任务"丢 Data 工具); /// Chat → []Debug → [file, exec, http, data](调试常需跑命令+读文件+查 API+查任务/工作流状态,CR-25 审查🟡-1 加 data 防"调试任务"丢 Data 工具);
/// **仅 Debug 含 Exec**(用户明确"运行/测试/构建/调试"才暴露 run_command, /// **仅 Debug 含 Exec**(用户明确"运行/测试/构建/调试"才暴露 run_command,
@@ -357,9 +426,12 @@ pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> {
Intent::Code => &[ToolDomain::File, ToolDomain::Http], Intent::Code => &[ToolDomain::File, ToolDomain::Http],
Intent::Debug => &[ToolDomain::File, ToolDomain::Exec, ToolDomain::Http, ToolDomain::Data], Intent::Debug => &[ToolDomain::File, ToolDomain::Exec, ToolDomain::Http, ToolDomain::Data],
Intent::File => &[ToolDomain::File], Intent::File => &[ToolDomain::File],
Intent::Project => &[ToolDomain::Data], // Project/Task/Idea 加 File:用户提"项目/任务"时常是在其内编码/排查
Intent::Task => &[ToolDomain::Data], // (实测会话 [项目:x] 报错 → Project → 砍 File 致 read_file 对 LLM 不可见)。
Intent::Idea => &[ToolDomain::Data], // 保留只读探索;写工具有 RiskLevel/审批兜底,"分心"远好于"断手"。
Intent::Project => &[ToolDomain::Data, ToolDomain::File],
Intent::Task => &[ToolDomain::Data, ToolDomain::File],
Intent::Idea => &[ToolDomain::Data, ToolDomain::File],
Intent::Conversation => &[], Intent::Conversation => &[],
Intent::Search => &[ToolDomain::File], Intent::Search => &[ToolDomain::File],
Intent::Http => &[ToolDomain::Http], Intent::Http => &[ToolDomain::Http],
@@ -608,6 +680,47 @@ mod tests {
assert_eq!(i, Intent::Search); assert_eq!(i, Intent::Search);
} }
// --- strip_mention_tagsL0 去噪)---
#[test]
fn strip_removes_all_mention_kinds() {
assert_eq!(strip_mention_tags("[项目: DevFlow] 重构代码"), " 重构代码");
assert_eq!(strip_mention_tags("[任务: 修复bug] 看看"), " 看看");
assert_eq!(strip_mention_tags("[想法: x][技能: y] 闲聊"), " 闲聊");
// 英文 kind + 大小写不敏感
assert_eq!(strip_mention_tags("[Project: x] fix this"), " fix this");
assert_eq!(strip_mention_tags("[TASK: y] do it"), " do it");
// 全角冒号
assert_eq!(strip_mention_tags("[项目DevFlow] 阅读"), " 阅读");
}
#[test]
fn strip_preserves_non_mention_brackets() {
// 非 mention 的 [...] 原样保留(代码片段/错误码)
assert_eq!(strip_mention_tags("数组 [1,2,3] 求和"), "数组 [1,2,3] 求和");
assert_eq!(strip_mention_tags("[error] 致命"), "[error] 致命"); // 'error' 非 kind
assert_eq!(strip_mention_tags("[项目报错] 看"), "[项目报错] 看"); // kind 后无冒号
// 未闭合的 mention-like 不剥离(防误吞)
assert_eq!(strip_mention_tags("[项目: 未闭合"), "[项目: 未闭合");
}
#[test]
fn recognize_mention_prefix_not_misread_as_project() {
// 核心修复:[项目: DevFlow] 帮我重构代码 → Code(非 Project)
// 原 bug:mention 前缀的"项目"命中 PROJECT(1.0)→ 收敛砍 File → read_file 不可见。
let (i, c) = IntentRecognizer::recognize("[项目: DevFlow] 帮我重构这段代码");
assert_eq!(i, Intent::Code);
assert!(c >= 0.7, "重构+代码 应达 Code 阈值, 实际 conf={}", c);
}
#[test]
fn recognize_real_project_word_still_hits_project() {
// 用户口语真"项目"(非 mention 前缀)仍命中 Project——预处理只去系统噪声。
// 正是 L1(tool_subset_for 加 File)不可省的佐证:口语误判靠映射兜底。
let (i, _) = IntentRecognizer::recognize("创建项目并绑定目录");
assert_eq!(i, Intent::Project);
}
#[test] #[test]
fn recognize_http_zh() { fn recognize_http_zh() {
let (i, _) = IntentRecognizer::recognize("调用接口请求这个 api"); let (i, _) = IntentRecognizer::recognize("调用接口请求这个 api");
@@ -713,11 +826,13 @@ mod tests {
} }
#[test] #[test]
fn subset_project_is_data() { fn subset_project_is_data_and_file() {
// L1 修复:Project 加 File domain(防"提项目 → 误判 Project → 砍只读探索")。
// Data(create_project/bind_directory)+ File 只读(read_file)共存。
let s = tool_subset_for(&Intent::Project); let s = tool_subset_for(&Intent::Project);
assert!(s.contains(&"create_project")); assert!(s.contains(&"create_project"));
assert!(s.contains(&"bind_directory")); assert!(s.contains(&"bind_directory"));
assert!(!s.contains(&"read_file")); assert!(s.contains(&"read_file"), "Project 应保留 read_file(L1)");
} }
#[test] #[test]
@@ -829,7 +944,7 @@ mod tests {
/// 构造测试用 ToolDefinition仅 name 有意义description/parameters 填占位)。 /// 构造测试用 ToolDefinition仅 name 有意义description/parameters 填占位)。
fn tool_def(name: &str) -> df_ai_core::types::ToolDefinition { fn tool_def(name: &str) -> df_ai_core::types::ToolDefinition {
df_ai_core::types::ToolDefinition { df_ai_core::types::ToolDefinition {
tool_type: "function".to_string(), tool_type: "function".into(),
function: df_ai_core::types::ToolFunction { function: df_ai_core::types::ToolFunction {
name: name.to_string(), name: name.to_string(),
description: String::new(), description: String::new(),

View File

@@ -5,10 +5,12 @@ pub mod anthropic_compat;
pub mod anthropic_helpers; pub mod anthropic_helpers;
pub mod context; pub mod context;
pub mod context_helpers; pub mod context_helpers;
// 多 Agent 协作调度中心(Phase 1 规则驱动, Phase 2 LLM 扩展)。
pub mod coordinator; pub mod coordinator;
// 会话意图识别层(纯函数,不接入 agentic loop)。依据 docs/02-架构设计/构想审查/ // 会话意图识别层(纯函数,不接入 agentic loop)。依据 docs/02-架构设计/构想审查/
// 意图识别层论证-2026-06-19.md。提供 recognize / tool_subset_for / suggested_model_tier。 // 意图识别层论证-2026-06-19.md。提供 recognize / tool_subset_for / suggested_model_tier。
// 待 Phase B+C 完成 + 模型模态管理落地后再接入 agentic loop。 // 待 Phase B+C 完成 + 模型模态管理落地后再接入 agentic loop。
pub mod git_worktree;
pub mod intent; pub mod intent;
pub mod model_fetch; pub mod model_fetch;
// model_fetch 的纯逻辑子模块(URL 拼接 / 噪音过滤 / 响应反序列化), // model_fetch 的纯逻辑子模块(URL 拼接 / 噪音过滤 / 响应反序列化),
@@ -28,12 +30,18 @@ pub mod openai_helpers;
// 纯函数模块(零 IO 零状态)。依据 docs/02-架构设计/单对话并行多轮-{设计,Phase0落地路线图}-2026-06-20.md。 // 纯函数模块(零 IO 零状态)。依据 docs/02-架构设计/单对话并行多轮-{设计,Phase0落地路线图}-2026-06-20.md。
// Phase 1(plan_hint 接入主 loop)/Phase 2(planning)/Phase 3(并行 execution) 接入后续。 // Phase 1(plan_hint 接入主 loop)/Phase 2(planning)/Phase 3(并行 execution) 接入后续。
pub mod planner; pub mod planner;
// Phase 2: Plan DAG 分层执行器(骨架,PLAN_EXECUTION_ENABLED 门控默认关)。
// 按 Plan::to_layers 层间串行/层内并行调度子任务。
// 依据 docs/02-架构设计/单对话并行多轮-设计-2026-06-20.md。
pub mod persona;
pub mod plan_executor;
pub mod provider; pub mod provider;
pub mod router; pub mod router;
// CR-30-1: 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用, // CR-30-1: 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用,
// stream_recv/agentic 流前重试需复用 backoff_delay(jitter)+is_status_retryable(Fatal 分类) // stream_recv/agentic 流前重试需复用 backoff_delay(jitter)+is_status_retryable(Fatal 分类)
// 避免重写退避/分类逻辑(对齐决策 F-260616-07 a1)。改 pub mod 后对外仅暴露纯函数 + 常量。 // 避免重写退避/分类逻辑(对齐决策 F-260616-07 a1)。改 pub mod 后对外仅暴露纯函数 + 常量。
pub mod retry; pub mod retry;
pub mod sse_parser;
use provider::LlmProvider; use provider::LlmProvider;
use reqwest::Client; use reqwest::Client;

View File

@@ -6,7 +6,6 @@
use std::time::Duration; use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use eventsource_stream::Eventsource;
use futures::StreamExt; use futures::StreamExt;
use reqwest::Client; use reqwest::Client;
use tracing::{debug, error, warn}; use tracing::{debug, error, warn};
@@ -105,7 +104,7 @@ impl OpenAICompatProvider {
req.model req.model
}; };
let messages: Vec<OpenAiMessage> = req let mut messages: Vec<OpenAiMessage> = req
.messages .messages
.into_iter() .into_iter()
.map(|m| { .map(|m| {
@@ -177,6 +176,37 @@ impl OpenAICompatProvider {
}) })
.collect(); .collect();
// B-260626-01: 保证首条 user/system(OpenAI 协议要求首条非 assistant/tool)。
// 对齐 AnthropicCompatProvider::ensure_leading_user:上游绕过 sanitize 的调用方
// (标题生成/知识注入/工作流 AI 节点等直构造 CompletionRequest 的路径)可能传入首条
// 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| { let tools = req.tools.map(|defs| {
defs.into_iter() defs.into_iter()
.map(|d| serde_json::to_value(d).unwrap_or_default()) .map(|d| serde_json::to_value(d).unwrap_or_default())
@@ -201,6 +231,36 @@ impl OpenAICompatProvider {
} }
} }
/// B-260626-01: 保证 messages 首条为 user/system(OpenAI 协议要求首条非 assistant/tool)。
///
/// 对齐 `AnthropicCompatProvider::ensure_leading_user`。上游绕过 `ContextManager::sanitize_messages`
/// 的调用方(标题生成/知识注入/工作流 AI 节点等直构造 CompletionRequest 的路径)可能传入首条
/// assistant 的序列——会话恢复、续发或历史片段截取时,真正的首条 user 已被裁剪/压缩掉。
///
/// **用"补"而非"砍"**:开头插一条 user 占位,保留全部上下文(砍会丢工具调用历史,多轮砍到空)。
/// 占位 user 紧贴原首条,不破坏 user/assistant 交替;仅异常路径触发(正常首条本就是 user)。
fn ensure_leading_user(messages: &mut Vec<OpenAiMessage>) {
let first_role = messages.first().map(|m| m.role.as_str()).unwrap_or("");
if first_role == "user" || first_role == "system" {
return;
}
warn!(
first_role,
msg_count = messages.len(),
"ensure_leading_user: 首条非 user/system,补 user 占位(保留上下文,防 OpenAI 首条 assistant/tool 非法)"
);
messages.insert(
0,
OpenAiMessage {
role: "user".into(),
content: serde_json::Value::String("(continued from previous context)".into()),
tool_call_id: None,
tool_calls: None,
reasoning_content: None,
},
);
}
/// 解析同步响应中的工具调用 /// 解析同步响应中的工具调用
fn parse_tool_calls(calls: Vec<OpenAiToolCallResp>) -> Vec<ToolCall> { fn parse_tool_calls(calls: Vec<OpenAiToolCallResp>) -> Vec<ToolCall> {
calls calls
@@ -326,14 +386,26 @@ impl LlmProvider for OpenAICompatProvider {
debug!(model = %openai_req.model, "OpenAI 流式调用"); 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 .client
.post(self.chat_url()) .post(self.chat_url())
.header("Authorization", format!("Bearer {}", self.api_key)) .header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.json(&openai_req) .json(&openai_req)
.send() .send();
.await?; 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() { if !resp.status().is_success() {
let status = resp.status(); let status = resp.status();
@@ -342,25 +414,30 @@ impl LlmProvider for OpenAICompatProvider {
anyhow::bail!("LLM 流式 API 错误 {}: {}", status, body); anyhow::bail!("LLM 流式 API 错误 {}: {}", status, body);
} }
// 累积流式 usage开 include_usage 后,末段正常 chunkfinish_reason及额外 usage-only chunkchoices=[])都带 usage // BUG-2026-07-17 根治: 原生 SSE 解析器替代 eventsource-stream 库
// usage 解析/累积逻辑抽到 apply_openai_sse 纯函数,便于单测;此处闭包只负责传 data 与传递 last_usage。 // 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 mut last_usage: Option<TokenUsage> = None;
let stream = resp let sse = crate::sse_parser::SseStream::new(resp.bytes_stream());
.bytes_stream() let stream = sse.flat_map(move |result: Result<Vec<String>, String>| {
.eventsource() let mut chunks: Vec<anyhow::Result<crate::provider::StreamChunk>> = Vec::new();
.map(move |event| match event { match result {
Ok(event) => Ok(apply_openai_sse(&event.data, &mut last_usage)), Ok(events) => {
for data in events {
let chunk = apply_openai_sse(&data, &mut last_usage);
chunks.push(Ok(chunk));
}
}
Err(e) => { 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); let ctx = format!("SSE 流错误: {}", e);
error!("{}", ctx); error!("{}", ctx);
Err(anyhow::Error::from(e).context(ctx)) chunks.push(Err(anyhow::anyhow!("{}", ctx)));
} }
}); }
futures::stream::iter(chunks)
});
Ok(Box::pin(stream)) Ok(Box::pin(stream))
} }
@@ -594,4 +671,53 @@ mod tests {
let msg = &out.messages[0]; let msg = &out.messages[0];
assert_eq!(msg.content, serde_json::Value::String("hello".into())); assert_eq!(msg.content, serde_json::Value::String("hello".into()));
} }
// ---------- B-260626-01: ensure_leading_user首条非 user/system → 补 user 占位,OpenAI 对称 Anthropic----------
/// B-260626-01: 首条 assistant → 补 user 占位(对齐 Anthropic)。上游绕过 sanitize 的
/// 调用方(title/knowledge_inject/工作流节点)可能传入首条 assistant 序列,补占位保留上下文。
#[test]
fn openai_ensure_leading_user_first_assistant_gets_placeholder() {
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
let req = CompletionRequest {
model: "gpt-4o".into(),
messages: vec![
ChatMessage::assistant("我来帮你"),
ChatMessage::user("继续"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
assert_eq!(out.messages.len(), 3, "占位 + 原 2 条");
assert_eq!(out.messages[0].role.as_str(), "user", "首条应为 user(补占位)");
assert_eq!(out.messages[1].role.as_str(), "assistant");
assert_eq!(out.messages[2].role.as_str(), "user");
}
/// B-260626-01: 正常序列(user 开头)不补占位——零回归。
#[test]
fn openai_ensure_leading_user_normal_unchanged() {
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
let req = CompletionRequest {
model: "gpt-4o".into(),
messages: vec![
ChatMessage::user("hello"),
ChatMessage::assistant("hi"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
assert_eq!(out.messages.len(), 2, "正常序列不补占位");
assert_eq!(out.messages[0].role.as_str(), "user");
}
} }

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

@@ -26,13 +26,15 @@
// ---- feature flag 占位 ------------------------------------------------------ // ---- feature flag 占位 ------------------------------------------------------
/// 规划能力总开关(Phase 1 接入用占位)。 /// 规划能力总开关(主 loop 规划开关,门控 filter_tool_defs_planned 接入)。
/// ///
/// **当前 `false`** = 单链 ReAct 旧行为(主 loop 走 Plan 规划)。 /// **当前 `true`** = 主 loop 走 Plan 规划agentic/mod.rs:633 调 filter_tool_defs_planned)。
/// Phase 1plan_hint 接入主 loop落地时翻为 `true` + 条件编译门控 /// Phase 1plan_hint 接入主 loop落地,翻 true 启用
/// 注:`PLAN_HINT_ENABLED`(plan_hint 函数 flag,Phase0a 就绪 true)单一定义在 `plan_hint.rs`, /// 注:`PLAN_HINT_ENABLED`(plan_hint 函数 flag,Phase0a 就绪 true)单一定义在 `plan_hint.rs`,
/// 本模块仅 `PLANNING_ENABLED`(主 loop 规划开关),消除此前双定义矛盾。 /// 本模块仅 `PLANNING_ENABLED`(主 loop 规划开关),消除此前双定义矛盾。
pub const PLANNING_ENABLED: bool = false; /// 双层开关:即使 PLANNING_ENABLED=true,若 plan_hint 内部 PLAN_HINT_ENABLED 关闭,
/// filter_tool_defs_planned 仍退扁平(任一关闭退旧行为,可回退)。
pub const PLANNING_ENABLED: bool = true;
/// Plan 层级硬上限(防爆深,防规划 LLM 退化成超长链)。 /// Plan 层级硬上限(防爆深,防规划 LLM 退化成超长链)。
/// ///
@@ -632,10 +634,10 @@ mod tests {
} }
#[test] #[test]
fn feature_flags_default_off() { fn feature_flags_default_on() {
// PLANNING_ENABLED 当前(主 loop 走规划),Phase 1 接入时翻 true // PLANNING_ENABLED 当前(主 loop 走 filter_tool_defs_planned 规划),Phase 1 已落地
// PLAN_HINT_ENABLED(plan_hint 函数 flag)在 plan_hint.rs 单一定义,此处不再断言。 // PLAN_HINT_ENABLED(plan_hint 函数 flag)在 plan_hint.rs 单一定义,此处不再断言。
assert!(!PLANNING_ENABLED); assert!(PLANNING_ENABLED);
} }
#[test] #[test]

View File

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

View File

@@ -22,18 +22,68 @@ pub struct ShellResult {
pub enum ShellType { pub enum ShellType {
/// Windows cmd.exe (默认 Windows) /// Windows cmd.exe (默认 Windows)
Cmd, Cmd,
/// PowerShell /// PowerShell 5.x(Windows 自带,不支持 && 运算符)
PowerShell, PowerShell,
/// PowerShell 7(pwsh,支持 && 运算符;运行时探测,未装回退 PowerShell)
Pwsh,
/// Unix sh (默认非 Windows) /// Unix sh (默认非 Windows)
Sh, Sh,
} }
impl Default for ShellType { impl Default for ShellType {
fn default() -> Self { fn default() -> Self {
if cfg!(windows) { ShellType::Cmd } else { ShellType::Sh } // L1 环境感知:Windows 默认 PowerShell 系(非 Cmd)。PowerShell 对引号/$变量/Unicode 处理
// 远优于 cmd,从根上避 kms 类引号转义地狱(seq26-52 撞墙 20+ 次)。AI 写文件执行见 env_profile。
// BUG-260623-04:优先 pwsh(PS7,支持 && 运算符)——LLM 训练数据 Unix 多,普遍生成 `cd x && y`,
// PS5 不支持 && 致命令失败(实测会话 6acb7f9b `cd ... && git init` InvalidEndOfLine)。
// 探测失败(未装 pwsh)回退 PS5。探测结果 OnceLock 缓存(只探一次)。
// 注:Default trait 为同步签名,这里只能读取已探测的缓存结果(若未探测则返回 false,退回 PowerShell)。
// 真实探测在异步入口 `execute()` 中调用 `probe_pwsh().await`。
if cfg!(windows) {
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 防黑窗闪现。
///
/// CR-XX:异步化 —— 在异步上下文中通过 `tokio::task::spawn_blocking` 执行阻塞探测,
/// 避免阻塞 tokio runtime。结果仍由 OnceLock 全局共享,只探测一次。
async fn probe_pwsh() -> bool {
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
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());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
cmd.status().map(|s| s.success()).unwrap_or(false)
})
.await
.unwrap_or(false);
// 多任务竞态时以先到者为准,均等价
let _ = CACHE.set(result);
result
}
/// Shell 命令执行请求 /// Shell 命令执行请求
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShellRequest { pub struct ShellRequest {
@@ -57,6 +107,10 @@ pub struct ShellRequest {
pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> { pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
let start = std::time::Instant::now(); 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 shell_type = request.shell_type.unwrap_or_default();
let mut cmd = match shell_type { let mut cmd = match shell_type {
ShellType::PowerShell => { ShellType::PowerShell => {
@@ -65,6 +119,13 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
c.stdout(Stdio::piped()).stderr(Stdio::piped()); c.stdout(Stdio::piped()).stderr(Stdio::piped());
c c
} }
ShellType::Pwsh => {
// PS7(支持 && 运算符),同 PS5 参数语义。-NoProfile 避加载用户 profile(速度+确定性)
let mut c = tokio::process::Command::new("pwsh");
c.arg("-NoProfile").arg("-Command").arg(&request.command);
c.stdout(Stdio::piped()).stderr(Stdio::piped());
c
}
ShellType::Cmd => { ShellType::Cmd => {
let mut c = tokio::process::Command::new("cmd"); let mut c = tokio::process::Command::new("cmd");
c.arg("/C").arg(&request.command); c.arg("/C").arg(&request.command);

View File

@@ -5,3 +5,4 @@ mod adversarial_helpers;
pub mod capture; pub mod capture;
pub mod promotion; pub mod promotion;
pub mod scoring; pub mod scoring;
pub mod scoring_keywords;

View File

@@ -11,6 +11,15 @@ pub use crate::capture::IdeaScores;
use df_types::types::Priority; use df_types::types::Priority;
// ─── 启发式关键词(从 `scoring_keywords` 模块导入)───
//
// 关键词常量已拆至独立文件 `scoring_keywords.rs`。
// 启发式是过渡方案,对抗式评估上线后按需升级为 JSON 配置。
pub use crate::scoring_keywords::{
FEASIBILITY_POSITIVE_KEYWORDS, FEASIBILITY_NEGATIVE_KEYWORDS,
IMPACT_KEYWORDS, URGENCY_KEYWORDS,
};
/// 评分权重配置 /// 评分权重配置
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ScoringWeights { pub struct ScoringWeights {
@@ -71,14 +80,10 @@ impl ScoringEngine {
score -= 0.5; score -= 0.5;
} }
// 可行性正向信号 // 可行性正向信号
let pos = count_any(desc, &[ let pos = count_any(desc, FEASIBILITY_POSITIVE_KEYWORDS);
"复用", "已有", "简单", "集成", "支持", "成熟", "基于", "现成", "脚手架", "模板",
]);
score += (pos as f64) * 0.5; score += (pos as f64) * 0.5;
// 复杂度负向信号 // 复杂度负向信号
let neg = count_any(desc, &[ let neg = count_any(desc, FEASIBILITY_NEGATIVE_KEYWORDS);
"重构", "迁移", "大规模", "分布式", "重写", "从零", "全新架构", "高并发", "底层",
]);
score -= (neg as f64) * 0.6; score -= (neg as f64) * 0.6;
score.clamp(0.0, 10.0) score.clamp(0.0, 10.0)
} }
@@ -97,9 +102,7 @@ impl ScoringEngine {
score += (idea.tags.len().min(4) as f64) * 0.25; score += (idea.tags.len().min(4) as f64) * 0.25;
} }
let desc = idea.description.trim(); let desc = idea.description.trim();
let value_hits = count_any(desc, &[ let value_hits = count_any(desc, IMPACT_KEYWORDS);
"用户", "增长", "收入", "效率", "体验", "核心", "关键", "痛点", "竞品", "留存",
]);
score += (value_hits as f64) * 0.5; score += (value_hits as f64) * 0.5;
if desc.chars().count() > 100 { if desc.chars().count() > 100 {
score += 0.5; score += 0.5;
@@ -116,9 +119,7 @@ impl ScoringEngine {
Priority::Low => 3.0, Priority::Low => 3.0,
}; };
let desc = idea.description.trim(); let desc = idea.description.trim();
let time_hits = count_any(desc, &[ let time_hits = count_any(desc, URGENCY_KEYWORDS);
"立即", "马上", "紧急", "尽快", "本周", "上线", "deadline", "截止", "先行", "阻塞",
]);
score += (time_hits as f64) * 0.5; score += (time_hits as f64) * 0.5;
score.clamp(0.0, 10.0) score.clamp(0.0, 10.0)
} }

View File

@@ -0,0 +1,24 @@
//! 评分引擎关键词 — 从 `scoring.rs` 拆出的独立常量文件。
//!
//! 启发式评分是过渡方案(对抗式评估上线后按需升级为 JSON 配置),
//! 此处仅做最小改动:将硬编码关键词集中于此,保持 `scoring.rs` 逻辑清晰。
/// 可行性正向信号词(描述充实度外的技术/资源信号),命中每个 +0.5
pub const FEASIBILITY_POSITIVE_KEYWORDS: &[&str] = &[
"复用", "已有", "简单", "集成", "支持", "成熟", "基于", "现成", "脚手架", "模板",
];
/// 可行性负向信号词(复杂度上升信号),命中每个 -0.6
pub const FEASIBILITY_NEGATIVE_KEYWORDS: &[&str] = &[
"重构", "迁移", "大规模", "分布式", "重写", "从零", "全新架构", "高并发", "底层",
];
/// 影响力价值信号词(用户/增长/收入...),命中每个 +0.5
pub const IMPACT_KEYWORDS: &[&str] = &[
"用户", "增长", "收入", "效率", "体验", "核心", "关键", "痛点", "竞品", "留存",
];
/// 紧急度时效信号词(立即/马上/紧急...),命中每个 +0.5
pub const URGENCY_KEYWORDS: &[&str] = &[
"立即", "马上", "紧急", "尽快", "本周", "上线", "deadline", "截止", "先行", "阻塞",
];

View File

@@ -17,7 +17,7 @@ use std::sync::Arc;
use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo}; use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo};
use df_storage::db::Database; use df_storage::db::Database;
use df_storage::models::{IdeaRecord, ProjectRecord, TaskRecord}; use df_storage::models::{IdeaRecord, ProjectRecord, TaskRecord};
use df_types::types::new_id; use df_types::types::{IdeaStatus, ProjectStatus, TaskStatus, new_id};
use futures::future::BoxFuture; use futures::future::BoxFuture;
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -235,10 +235,11 @@ fn create_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
Err(r) => return Box::pin(std::future::ready(r)), Err(r) => return Box::pin(std::future::ready(r)),
}; };
let description = arg_str_or(&args, "description", ""); let description = arg_str_or(&args, "description", "");
let status = arg_str_or(&args, "status", "active"); let status = arg_str_or(&args, "status", "planning");
medium_audit("create_project", &name); medium_audit("create_project", &name);
Box::pin(async move { Box::pin(async move {
let now = now_millis(); let now = now_millis();
let status = ProjectStatus::from_db_str(&status).unwrap_or_default();
let rec = ProjectRecord { let rec = ProjectRecord {
id: new_id(), id: new_id(),
name, name,
@@ -269,7 +270,7 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
}; };
let name = arg_str_or(&args, "name", ""); let name = arg_str_or(&args, "name", "");
let description = arg_str_or(&args, "description", ""); let description = arg_str_or(&args, "description", "");
let status = arg_str_or(&args, "status", "active"); let status = arg_str_or(&args, "status", "planning");
medium_audit("update_project", &id); medium_audit("update_project", &id);
Box::pin(async move { Box::pin(async move {
let repo = ProjectRepo::new(&db); let repo = ProjectRepo::new(&db);
@@ -280,6 +281,7 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
Err(e) => return err_str(e), Err(e) => return err_str(e),
}; };
let now = now_millis(); let now = now_millis();
let status = ProjectStatus::from_db_str(&status).unwrap_or_default();
let rec = ProjectRecord { let rec = ProjectRecord {
id: id.clone(), id: id.clone(),
name, name,
@@ -322,6 +324,12 @@ fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
medium_audit("bind_directory", &format!("{id} <- {path}")); medium_audit("bind_directory", &format!("{id} <- {path}"));
Box::pin(async move { Box::pin(async move {
let repo = ProjectRepo::new(&db); 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); let norm = normalize_path(&path);
// 路径冲突检测 // 路径冲突检测
if let Some(conflict) = repo.find_path_conflict(&norm, Some(&id)).await.ok().flatten() { if let Some(conflict) = repo.find_path_conflict(&norm, Some(&id)).await.ok().flatten() {
@@ -330,8 +338,8 @@ fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
conflict.name, conflict.id conflict.name, conflict.id
)); ));
} }
// 仅更新 path 字段(用 update_field,保留其它) // 仅更新 path 字段(用 normalize 后的规范化路径,保留其它)
if !repo.update_field(&id, "path", &path).await.unwrap_or(false) { if !repo.update_field(&id, "path", &norm).await.unwrap_or(false) {
return CallToolResult::error(format!("项目不存在: {id}")); return CallToolResult::error(format!("项目不存在: {id}"));
} }
let updated = repo.get_by_id(&id).await.ok().flatten(); let updated = repo.get_by_id(&id).await.ok().flatten();
@@ -355,7 +363,7 @@ fn list_tasks(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
list.retain(|t| t.project_id == *pid); list.retain(|t| t.project_id == *pid);
} }
if let Some(st) = &status_filter { if let Some(st) = &status_filter {
list.retain(|t| t.status == *st); list.retain(|t| t.status.as_str() == st.as_str());
} }
json_ok(json!({ "tasks": list, "count": list.len() })) json_ok(json!({ "tasks": list, "count": list.len() }))
} }
@@ -384,7 +392,7 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
project_id, project_id,
title, title,
description, description,
status: "todo".to_owned(), status: TaskStatus::Todo,
priority, priority,
branch_name: None, branch_name: None,
assignee: None, assignee: None,
@@ -393,6 +401,9 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
review_rounds: 0, review_rounds: 0,
output_json: None, output_json: None,
idea_id: None, idea_id: None,
queue: "todo".to_string(),
parent_id: None,
content_json: None,
created_at: now.clone(), created_at: now.clone(),
updated_at: now, updated_at: now,
}; };
@@ -446,6 +457,9 @@ fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
review_rounds: existing.review_rounds, review_rounds: existing.review_rounds,
output_json: existing.output_json, output_json: existing.output_json,
idea_id: existing.idea_id, idea_id: existing.idea_id,
queue: existing.queue,
parent_id: existing.parent_id,
content_json: existing.content_json,
created_at: existing.created_at, created_at: existing.created_at,
updated_at: now, updated_at: now,
}; };
@@ -523,7 +537,7 @@ fn create_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
id: new_id(), id: new_id(),
title, title,
description, description,
status: "draft".to_owned(), status: IdeaStatus::Draft,
priority, priority,
score: None, score: None,
tags: None, tags: None,

View File

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

View File

@@ -273,6 +273,7 @@ mod tests {
use df_storage::crud::{ProjectRepo, TaskRepo}; use df_storage::crud::{ProjectRepo, TaskRepo};
use df_storage::db::Database; use df_storage::db::Database;
use df_storage::models::{ProjectRecord, TaskRecord}; use df_storage::models::{ProjectRecord, TaskRecord};
use df_types::types::{ProjectStatus, TaskStatus};
use serde_json::json; use serde_json::json;
// ============================================================ // ============================================================
@@ -287,7 +288,7 @@ mod tests {
id: "p1".to_string(), id: "p1".to_string(),
name: "proj".to_string(), name: "proj".to_string(),
description: "".to_string(), description: "".to_string(),
status: "planning".to_string(), status: ProjectStatus::Planning,
idea_id: None, idea_id: None,
path: None, path: None,
stack: None, stack: None,
@@ -302,7 +303,7 @@ mod tests {
project_id: "p1".to_string(), project_id: "p1".to_string(),
title: "t1".to_string(), title: "t1".to_string(),
description: "实现登录接口".to_string(), description: "实现登录接口".to_string(),
status: "testing".to_string(), status: TaskStatus::Testing,
priority: 2, priority: 2,
branch_name: None, branch_name: None,
assignee: None, assignee: None,
@@ -311,6 +312,9 @@ mod tests {
review_rounds: 0, review_rounds: 0,
output_json: task_output_json.map(String::from), output_json: task_output_json.map(String::from),
idea_id: None, idea_id: None,
queue: "todo".to_string(),
parent_id: None,
content_json: None,
created_at: "0".to_string(), created_at: "0".to_string(),
updated_at: "0".to_string(), updated_at: "0".to_string(),
}) })

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(), node_id: node_id.to_string(),
inputs: HashMap::new(), inputs: HashMap::new(),
config, config,
execution_id: execution_id.to_string(), execution_id: execution_id.into(),
event_bus: event_bus.clone(), event_bus: event_bus.clone(),
node_status: StateMachine::new(), node_status: StateMachine::new(),
} }
@@ -235,7 +235,7 @@ mod tests {
) { ) {
event_bus event_bus
.send(WorkflowEvent::HumanApprovalResponse { .send(WorkflowEvent::HumanApprovalResponse {
execution_id: execution_id.to_string(), execution_id: execution_id.into(),
node_id: node_id.to_string(), node_id: node_id.to_string(),
decision: decision.to_string(), decision: decision.to_string(),
decisions: vec![], decisions: vec![],
@@ -254,7 +254,7 @@ mod tests {
) { ) {
event_bus event_bus
.send(WorkflowEvent::HumanApprovalResponse { .send(WorkflowEvent::HumanApprovalResponse {
execution_id: execution_id.to_string(), execution_id: execution_id.into(),
node_id: node_id.to_string(), node_id: node_id.to_string(),
decision: String::new(), decision: String::new(),
decisions: decisions.iter().map(|s| s.to_string()).collect(), 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_node("b".to_string(), Box::new(HumanNode));
dag.add_edge("a".to_string(), "b".to_string()); dag.add_edge("a".to_string(), "b".to_string());
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(); // 共享状态机spawn 后仍可读 let sm = executor.state_machine(); // 共享状态机(spawn 后仍可读)
let run_handle = tokio::spawn(async move { let run_handle = tokio::spawn(async move {
executor executor
@@ -537,7 +537,7 @@ mod tests {
let mut dag = Dag::new(); let mut dag = Dag::new();
dag.add_node("h".to_string(), Box::new(HumanNode)); 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 sm = executor.state_machine();
let run_handle = tokio::spawn(async move { 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_node;
pub mod ai_self_review_node; pub mod ai_self_review_node;
mod ai_node_helpers; mod ai_node_helpers;
pub mod docker_node;
pub mod git_node;
pub mod http_node;
pub mod human_node; pub mod human_node;
mod human_node_helpers; mod human_node_helpers;
pub mod notify_node;
pub mod subflow_node;
pub mod script_node; pub mod script_node;
pub mod task_advance_node; pub mod task_advance_node;
pub mod task_state_machine; 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; 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] #[async_trait]
impl Node for ScriptNode { impl Node for ScriptNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult { async fn execute(&self, ctx: NodeContext) -> NodeResult {
@@ -39,6 +56,32 @@ impl Node for ScriptNode {
shell_type: Default::default(), 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); tracing::info!("ScriptNode 执行命令: {}", command);
let result = df_execute::shell::execute(request).await?; let result = df_execute::shell::execute(request).await?;
@@ -93,3 +136,67 @@ impl Node for ScriptNode {
"script" "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

@@ -174,14 +174,15 @@ mod tests {
use super::*; use super::*;
use df_storage::crud::ProjectRepo; use df_storage::crud::ProjectRepo;
use df_storage::models::{ProjectRecord, TaskRecord}; use df_storage::models::{ProjectRecord, TaskRecord};
use df_types::types::{ProjectStatus, TaskStatus};
fn rec(id: &str, status: &str) -> TaskRecord { fn rec(id: &str, status: TaskStatus) -> TaskRecord {
TaskRecord { TaskRecord {
id: id.to_string(), id: id.to_string(),
project_id: "p1".to_string(), project_id: "p1".to_string(),
title: format!("t-{id}"), title: format!("t-{id}"),
description: "".to_string(), description: "".to_string(),
status: status.to_string(), status,
priority: 2, priority: 2,
branch_name: None, branch_name: None,
assignee: None, assignee: None,
@@ -190,6 +191,9 @@ mod tests {
review_rounds: 0, review_rounds: 0,
output_json: None, output_json: None,
idea_id: None, idea_id: None,
queue: "todo".to_string(),
parent_id: None,
content_json: None,
created_at: "0".to_string(), created_at: "0".to_string(),
updated_at: "0".to_string(), updated_at: "0".to_string(),
} }
@@ -203,7 +207,7 @@ mod tests {
id: "p1".to_string(), id: "p1".to_string(),
name: "proj".to_string(), name: "proj".to_string(),
description: "".to_string(), description: "".to_string(),
status: "planning".to_string(), status: ProjectStatus::Planning,
idea_id: None, idea_id: None,
path: None, path: None,
stack: None, stack: None,
@@ -218,43 +222,43 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn forward_path_todo_to_done() { async fn forward_path_todo_to_done() {
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap(); repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
// 主路径逐级推进 // 主路径逐级推进
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap(); let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
assert_eq!(r.status, "in_progress"); assert_eq!(r.status.as_str(), "in_progress");
assert_eq!(r.review_rounds, 0); assert_eq!(r.review_rounds, 0);
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap(); let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
assert_eq!(r.status, "in_review"); assert_eq!(r.status.as_str(), "in_review");
assert_eq!(r.review_rounds, 0); assert_eq!(r.review_rounds, 0);
let r = advance_task_atomic(&repo, "t1", "testing").await.unwrap(); let r = advance_task_atomic(&repo, "t1", "testing").await.unwrap();
assert_eq!(r.status, "testing"); assert_eq!(r.status.as_str(), "testing");
let r = advance_task_atomic(&repo, "t1", "done").await.unwrap(); let r = advance_task_atomic(&repo, "t1", "done").await.unwrap();
assert_eq!(r.status, "done"); assert_eq!(r.status.as_str(), "done");
assert_eq!(r.review_rounds, 0); assert_eq!(r.review_rounds, 0);
} }
#[tokio::test] #[tokio::test]
async fn regression_in_review_to_in_progress_bumps_rounds() { async fn regression_in_review_to_in_progress_bumps_rounds() {
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "in_review")).await.unwrap(); repo.insert(rec("t1", TaskStatus::InReview)).await.unwrap();
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap(); let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
assert_eq!(r.status, "in_progress"); assert_eq!(r.status.as_str(), "in_progress");
assert_eq!(r.review_rounds, 1, "退回应 +1"); assert_eq!(r.review_rounds, 1, "退回应 +1");
} }
#[tokio::test] #[tokio::test]
async fn regression_testing_to_in_review_bumps_rounds() { async fn regression_testing_to_in_review_bumps_rounds() {
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "testing")).await.unwrap(); repo.insert(rec("t1", TaskStatus::Testing)).await.unwrap();
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap(); let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
assert_eq!(r.status, "in_review"); assert_eq!(r.status.as_str(), "in_review");
assert_eq!(r.review_rounds, 1, "退回应 +1"); assert_eq!(r.review_rounds, 1, "退回应 +1");
} }
#[tokio::test] #[tokio::test]
async fn multiple_regressions_accumulate() { async fn multiple_regressions_accumulate() {
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "in_review")).await.unwrap(); repo.insert(rec("t1", TaskStatus::InReview)).await.unwrap();
// in_review → in_progress (+1) → in_review (前向,不动) → in_progress (+1=2) // in_review → in_progress (+1) → in_review (前向,不动) → in_progress (+1=2)
advance_task_atomic(&repo, "t1", "in_progress").await.unwrap(); advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
advance_task_atomic(&repo, "t1", "in_review").await.unwrap(); advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
@@ -266,7 +270,7 @@ mod tests {
async fn illegal_skip_rejected() { async fn illegal_skip_rejected() {
// CR-01-D: 非法转换(跳态)归 InvalidState,且消息含 from→to 上下文与「非法状态转换」。 // CR-01-D: 非法转换(跳态)归 InvalidState,且消息含 from→to 上下文与「非法状态转换」。
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap(); repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "done").await.unwrap_err(); let err = advance_task_atomic(&repo, "t1", "done").await.unwrap_err();
match err { match err {
df_types::error::Error::InvalidState { current, expected } => { df_types::error::Error::InvalidState { current, expected } => {
@@ -282,7 +286,7 @@ mod tests {
async fn terminal_done_no_successor() { async fn terminal_done_no_successor() {
// CR-01-D: 终态无后继也是非法转换路径,归 InvalidState(同 illegal_skip_rejected)。 // CR-01-D: 终态无后继也是非法转换路径,归 InvalidState(同 illegal_skip_rejected)。
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "done")).await.unwrap(); repo.insert(rec("t1", TaskStatus::Done)).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "todo").await.unwrap_err(); let err = advance_task_atomic(&repo, "t1", "todo").await.unwrap_err();
match err { match err {
df_types::error::Error::InvalidState { current, .. } => { df_types::error::Error::InvalidState { current, .. } => {
@@ -298,7 +302,7 @@ mod tests {
// 同态属空操作,归 Validation「相同状态,无需推进」; // 同态属空操作,归 Validation「相同状态,无需推进」;
// 非法转换归 InvalidState「非法状态转换 X→Y」(见 illegal_skip_rejected)。 // 非法转换归 InvalidState「非法状态转换 X→Y」(见 illegal_skip_rejected)。
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "in_progress")).await.unwrap(); repo.insert(rec("t1", TaskStatus::InProgress)).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap_err(); let err = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap_err();
match err { match err {
df_types::error::Error::Validation(msg) => { df_types::error::Error::Validation(msg) => {
@@ -311,7 +315,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn invalid_target_rejected() { async fn invalid_target_rejected() {
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap(); repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "merged").await.unwrap_err(); let err = advance_task_atomic(&repo, "t1", "merged").await.unwrap_err();
assert!(matches!(err, df_types::error::Error::Validation(_))); assert!(matches!(err, df_types::error::Error::Validation(_)));
} }
@@ -329,7 +333,7 @@ mod tests {
// 注:这并非 CAS 并发失败场景(真 CAS 失败由 cas_returns_none_when_status_mismatch 覆盖), // 注:这并非 CAS 并发失败场景(真 CAS 失败由 cas_returns_none_when_status_mismatch 覆盖),
// 而是验证读后改路径在 from=当前库内 status 时正常推进。 // 而是验证读后改路径在 from=当前库内 status 时正常推进。
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap(); repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
// 另一路推进把 status 改成 in_progress(模拟并发推进,走 CAS 合法路径; // 另一路推进把 status 改成 in_progress(模拟并发推进,走 CAS 合法路径;
// F-03 收口后 status 不在 update_field 白名单,模拟并发改态须走 advance_status_atomic) // F-03 收口后 status 不在 update_field 白名单,模拟并发改态须走 advance_status_atomic)
repo.advance_status_atomic("t1", "todo", "in_progress", false) repo.advance_status_atomic("t1", "todo", "in_progress", false)
@@ -337,13 +341,13 @@ mod tests {
.unwrap(); .unwrap();
// 读出来是 in_progress,推进到 in_review 合法 → 正常成功 // 读出来是 in_progress,推进到 in_review 合法 → 正常成功
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap(); let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
assert_eq!(r.status, "in_review"); assert_eq!(r.status.as_str(), "in_review");
} }
#[tokio::test] #[tokio::test]
async fn cas_returns_none_when_status_mismatch() { async fn cas_returns_none_when_status_mismatch() {
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap(); repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
// 直接调底层:expected 传错(模拟读到 todo 但实际已被改成 in_progress) // 直接调底层:expected 传错(模拟读到 todo 但实际已被改成 in_progress)
let r = repo let r = repo
.advance_status_atomic("t1", "todo", "in_review", false) .advance_status_atomic("t1", "todo", "in_review", false)
@@ -362,12 +366,12 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn blocked_round_trip_does_not_bump() { async fn blocked_round_trip_does_not_bump() {
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "in_progress")).await.unwrap(); repo.insert(rec("t1", TaskStatus::InProgress)).await.unwrap();
let r = advance_task_atomic(&repo, "t1", "blocked").await.unwrap(); let r = advance_task_atomic(&repo, "t1", "blocked").await.unwrap();
assert_eq!(r.status, "blocked"); assert_eq!(r.status.as_str(), "blocked");
assert_eq!(r.review_rounds, 0, "进 blocked 不累加"); assert_eq!(r.review_rounds, 0, "进 blocked 不累加");
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap(); let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
assert_eq!(r.status, "in_progress"); assert_eq!(r.status.as_str(), "in_progress");
assert_eq!(r.review_rounds, 0, "解除 blocked 不累加"); assert_eq!(r.review_rounds, 0, "解除 blocked 不累加");
} }
@@ -457,24 +461,24 @@ mod tests {
async fn callback_completed_advance_lands_in_db() { async fn callback_completed_advance_lands_in_db() {
// in_progress: todo → in_progress(②-3 in_progress 模板完成) // in_progress: todo → in_progress(②-3 in_progress 模板完成)
let repo = setup().await; let repo = setup().await;
repo.insert(rec("c1", "todo")).await.unwrap(); repo.insert(rec("c1", TaskStatus::Todo)).await.unwrap();
let to = callback_advance_target("completed", "in_progress").unwrap(); let to = callback_advance_target("completed", "in_progress").unwrap();
let r = advance_task_atomic(&repo, "c1", &to).await.unwrap(); let r = advance_task_atomic(&repo, "c1", &to).await.unwrap();
assert_eq!(r.status, "in_progress"); assert_eq!(r.status.as_str(), "in_progress");
assert_eq!(r.review_rounds, 0, "②-3 前向推进不累加 review_rounds"); assert_eq!(r.review_rounds, 0, "②-3 前向推进不累加 review_rounds");
// testing: in_review → testing(②-3 testing 模板自审+核对通过) // testing: in_review → testing(②-3 testing 模板自审+核对通过)
repo.insert(rec("c2", "in_review")).await.unwrap(); repo.insert(rec("c2", TaskStatus::InReview)).await.unwrap();
let to = callback_advance_target("completed", "testing").unwrap(); let to = callback_advance_target("completed", "testing").unwrap();
let r = advance_task_atomic(&repo, "c2", &to).await.unwrap(); let r = advance_task_atomic(&repo, "c2", &to).await.unwrap();
assert_eq!(r.status, "testing"); assert_eq!(r.status.as_str(), "testing");
assert_eq!(r.review_rounds, 0); assert_eq!(r.review_rounds, 0);
// done: testing → done(②-3 done 模板最终核对通过) // done: testing → done(②-3 done 模板最终核对通过)
repo.insert(rec("c3", "testing")).await.unwrap(); repo.insert(rec("c3", TaskStatus::Testing)).await.unwrap();
let to = callback_advance_target("completed", "done").unwrap(); let to = callback_advance_target("completed", "done").unwrap();
let r = advance_task_atomic(&repo, "c3", &to).await.unwrap(); let r = advance_task_atomic(&repo, "c3", &to).await.unwrap();
assert_eq!(r.status, "done"); assert_eq!(r.status.as_str(), "done");
assert_eq!(r.review_rounds, 0); assert_eq!(r.review_rounds, 0);
} }
@@ -484,32 +488,32 @@ mod tests {
async fn callback_failed_regression_lands_in_db_with_rounds_bump() { async fn callback_failed_regression_lands_in_db_with_rounds_bump() {
// testing 模板失败:任务当前 testing → 退回 in_review(rounds+1) // testing 模板失败:任务当前 testing → 退回 in_review(rounds+1)
let repo = setup().await; let repo = setup().await;
repo.insert(rec("f1", "testing")).await.unwrap(); repo.insert(rec("f1", TaskStatus::Testing)).await.unwrap();
let to = callback_advance_target("failed", "testing").unwrap(); let to = callback_advance_target("failed", "testing").unwrap();
assert_eq!(to, "in_review", "testing 失败应退回 in_review"); assert_eq!(to, "in_review", "testing 失败应退回 in_review");
let r = advance_task_atomic(&repo, "f1", &to).await.unwrap(); let r = advance_task_atomic(&repo, "f1", &to).await.unwrap();
assert_eq!(r.status, "in_review"); assert_eq!(r.status.as_str(), "in_review");
assert_eq!(r.review_rounds, 1, "②-4 退回应累加 review_rounds(+1)"); assert_eq!(r.review_rounds, 1, "②-4 退回应累加 review_rounds(+1)");
// in_review 模板失败(注:in_review 非 callback target,但映射存在性仍锁定): // in_review 模板失败(注:in_review 非 callback target,但映射存在性仍锁定):
// in_review → in_progress。此处验证 regression_target 对 in_review 的映射, // in_review → in_progress。此处验证 regression_target 对 in_review 的映射,
// 即便当前推进链 testing 模板失败也可能退到 in_progress(链式退回)。 // 即便当前推进链 testing 模板失败也可能退到 in_progress(链式退回)。
repo.insert(rec("f2", "in_review")).await.unwrap(); repo.insert(rec("f2", TaskStatus::InReview)).await.unwrap();
let to = callback_advance_target("failed", "in_review").unwrap(); let to = callback_advance_target("failed", "in_review").unwrap();
assert_eq!(to, "in_progress"); assert_eq!(to, "in_progress");
let r = advance_task_atomic(&repo, "f2", &to).await.unwrap(); let r = advance_task_atomic(&repo, "f2", &to).await.unwrap();
assert_eq!(r.status, "in_progress"); assert_eq!(r.status.as_str(), "in_progress");
assert_eq!(r.review_rounds, 1); assert_eq!(r.review_rounds, 1);
// in_progress 模板失败:regression_target("in_progress")=None(CR-13-O1-b), // in_progress 模板失败:regression_target("in_progress")=None(CR-13-O1-b),
// 回调返回 None → 跳过推进,任务保留 in_progress 等人介入。 // 回调返回 None → 跳过推进,任务保留 in_progress 等人介入。
// 验证:callback 返回 None,不调 advance_task_atomic。 // 验证:callback 返回 None,不调 advance_task_atomic。
repo.insert(rec("f3", "in_progress")).await.unwrap(); repo.insert(rec("f3", TaskStatus::InProgress)).await.unwrap();
let to = callback_advance_target("failed", "in_progress"); let to = callback_advance_target("failed", "in_progress");
assert_eq!(to, None, "in_progress 失败应跳过推进(None),实际: {to:?}"); assert_eq!(to, None, "in_progress 失败应跳过推进(None),实际: {to:?}");
// 任务状态未被改动(仍是 in_progress) // 任务状态未被改动(仍是 in_progress)
let still = repo.get_by_id("f3").await.unwrap().unwrap(); let still = repo.get_by_id("f3").await.unwrap().unwrap();
assert_eq!(still.status, "in_progress", "None 退回不应改动 status"); assert_eq!(still.status.as_str(), "in_progress", "None 退回不应改动 status");
} }
/// ②-4 回调 failed+done 无退回映射验证:callback_advance_target 返回 None。 /// ②-4 回调 failed+done 无退回映射验证:callback_advance_target 返回 None。

View File

@@ -22,27 +22,29 @@
//! - cancelled → 终态 //! - cancelled → 终态
// ============================================================ // ============================================================
// 状态字符串常量 — df-types::TaskStatus::as_str 一一对应 // 状态字符串常量 — df-types::TaskStatus::as_str 派生(单一真相源)
// ============================================================ // ============================================================
// //
// 不复用 df-types::TaskStatus enum(独立模块定位 + 避免推进链判定耦合存储枚举类型), // 任务 #17 合并:字符串常量不再独立定义,直接从 `TaskStatus::as_str()` 派生。
// 但字符串值严格对齐(df-types::TaskStatus::as_str 产出的小写 snake_case), // 消除 df-nodes 字符串常量与 df-types enum 之间的双源问题—— 任一处修改 enum 的
// 保证状态机判定的 from/to 与数据库 status 列存值语义一致 // 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] = &[ pub const ALL_STATES: &[&str] = &[

View File

@@ -11,3 +11,4 @@ tokio = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
tracing = { 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}; use serde::{Deserialize, Serialize};
@@ -39,15 +45,32 @@ pub struct CreateProjectInput {
pub tags: Vec<String>, 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; pub struct ProjectManager;
impl ProjectManager { impl ProjectManager {
/// 创建新项目 — 构造领域实体(不落库);持久化由调用方经 storage 层 ProjectRecord 映射完成 /// 创建新项目 — 构造领域实体(不落库);持久化由调用方经 storage 层 ProjectRecord 映射完成
/// (见 commands/idea.rs::promote_idea)。领域层不依赖 storage,保持分层。 /// (见 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(); let now = chrono::Utc::now();
Project { Ok(Project {
id: df_types::types::new_id(), id: df_types::types::new_id(),
name: input.name, name: input.name,
description: input.description, description: input.description,
@@ -57,11 +80,15 @@ impl ProjectManager {
tags: input.tags, tags: input.tags,
created_at: now, created_at: now,
updated_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 { Self::create(CreateProjectInput {
name, name,
description, 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

@@ -1,741 +0,0 @@
//! 项目技术栈探测 — 浅读根目录标志文件识别技术栈
//!
//! 纯函数、零状态、零外部依赖(仅 std + serde_json + anyhow)。
//! 只读根目录标志文件,不递归遍历(控制性能与安全)。
//!
//! 供 commands 层薄封装暴露为 IPC 命令,新建/导入项目时自动填充 `ProjectRecord.stack`;
//! 未来 df-ai/df-workflow 亦可复用以感知「项目是什么技术栈」。
//!
//! README 噪音处理与图片收集已抽离至 `scan_helpers`(2026-06-19 自底向上拆分),
//! 此处通过 `pub use` 重导出 `ImageRef` 等保持外部路径 `df_project::scan::*` 不变。
use std::path::Path;
use anyhow::{Context, Result};
#[path = "scan_helpers.rs"]
mod scan_helpers;
pub use scan_helpers::ImageRef;
use scan_helpers::{collect_images, strip_readme_noise};
/// 规范化路径用于比较:canonicalize 解析绝对规范路径(失败降级),
/// 统一正斜杠 + 小写。防 `C:\a\b` vs `C:/a/b/` 绕过重复检查。
/// 注:仅用于比较,存库保留用户输入的原始可读路径。
pub fn normalize_path(p: &str) -> String {
match Path::new(p).canonicalize() {
Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(),
Err(_) => p
.trim_end_matches(['\\', '/'])
.replace('\\', "/")
.to_lowercase(),
}
}
/// 探测目录的技术栈
///
/// 返回去重后的技术栈标签数组(如 `["rust","vue","tauri","typescript"]`)。
/// 空数组表示未识别出任何已知标志(空目录或非常规项目)。非目录返回 Err。
pub fn detect_stack(root: &Path) -> Result<Vec<String>> {
if !root.is_dir() {
anyhow::bail!("路径不是目录: {}", root.display());
}
let mut stack: Vec<String> = Vec::new();
// ── 后端/系统语言 ──
if root.join("Cargo.toml").exists() {
push_unique(&mut stack, "rust");
}
if root.join("go.mod").exists() {
push_unique(&mut stack, "go");
}
if root.join("pom.xml").exists()
|| root.join("build.gradle").exists()
|| root.join("build.gradle.kts").exists()
{
push_unique(&mut stack, "java");
}
if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() {
push_unique(&mut stack, "python");
}
// C#: 根目录存在 .csproj 文件(仅一层)
if has_file_with_ext(root, "csproj") {
push_unique(&mut stack, "csharp");
}
// ── Tauri 桌面应用(通常含 src-tauri 目录) ──
if root.join("src-tauri").is_dir() {
push_unique(&mut stack, "tauri");
}
// ── 前端/Node:解析 package.json 的依赖推断框架 ──
if root.join("package.json").exists() {
if let Ok(deps) = read_package_deps(root.join("package.json")) {
if deps.iter().any(|d| d == "vue") {
push_unique(&mut stack, "vue");
}
if deps.iter().any(|d| d == "react" || d == "react-dom") {
push_unique(&mut stack, "react");
}
if deps.iter().any(|d| d == "@angular/core") {
push_unique(&mut stack, "angular");
}
if deps.iter().any(|d| d == "svelte") {
push_unique(&mut stack, "svelte");
}
if deps.iter().any(|d| d == "next") {
push_unique(&mut stack, "next");
}
if deps.iter().any(|d| d == "vite") {
push_unique(&mut stack, "vite");
}
if deps.iter().any(|d| d == "typescript") {
push_unique(&mut stack, "typescript");
}
if deps.iter().any(|d| d == "express" || d == "koa" || d == "fastify") {
push_unique(&mut stack, "node");
}
}
}
Ok(stack)
}
// ============================================================
// 历史项目发现 — monorepo 识别 + 子项目展开(纯规则,不跑 LLM)
// ============================================================
/// monorepo 工作区配置文件名(JS 生态主流:pnpm/lerna/turbo/nx)
const MONOREPO_MARKERS: &[&str] = &[
"pnpm-workspace.yaml",
"lerna.json",
"turbo.json",
"nx.json",
];
/// 判定目录是否为 monorepo 根(JS 生态主流工作区管理器)。
///
/// 命中任一即视为 monorepo:
/// - pnpm-workspace.yaml / lerna.json / turbo.json / nx.json 存在
/// - package.json 含 `workspaces` 字段(npm/yarn workspaces)
pub fn is_monorepo(root: &Path) -> bool {
if !root.is_dir() {
return false;
}
for marker in MONOREPO_MARKERS {
if root.join(marker).is_file() {
return true;
}
}
// package.json workspaces 字段(npm/yarn)。数组(`["packages/*"]`)或对象
// (`{"packages":[...]}`,Yarn)均为真正的工作区;JSON 显式 null 表示「无」,
// 不应误判为 monorepo(`.is_some()` 对 key 存在但值为 null 仍返回 true → 误报)。
let pkg_path = root.join("package.json");
if pkg_path.is_file() {
if let Ok(content) = std::fs::read_to_string(&pkg_path) {
if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&content) {
if pkg.get("workspaces").is_some_and(|v| !v.is_null()) {
return true;
}
}
}
}
false
}
/// 单个发现的候选项目(monorepo 子项目或独立项目)
#[derive(Debug, Clone)]
pub struct DiscoveredProject {
/// 项目根目录绝对路径
pub path: String,
/// 推断的项目名(目录名)
pub name: String,
/// 规则探测的技术栈(空=未识别)
pub stack: Vec<String>,
/// 是否为 monorepo 根(便于前端标记)
pub is_monorepo: bool,
}
/// 在指定根目录下发现候选项目。
///
/// 策略(只展开一层,不做深递归):
/// 1. 根目录本身有项目标志(Cargo.toml/package.json/go.mod 等)→ 根为独立项目
/// 2. 根目录是 monorepo → 展开 packages/\*/apps/\* 直接子目录(各子目录跑 detect_stack 过滤空)
/// 3. 否则:扫根的直接子目录,凡 detect_stack 非空的视为候选项目
///
/// 不跑 LLM(快),不读源码。空 stack 的目录在 monorepo 展开/子目录扫描时被过滤。
pub fn discover_projects(root: &Path) -> Result<Vec<DiscoveredProject>> {
if !root.is_dir() {
anyhow::bail!("路径不是目录: {}", root.display());
}
let mut out: Vec<DiscoveredProject> = Vec::new();
let mono = is_monorepo(root);
// 1. 根目录自身是项目(有 manifest 标志)
if has_project_manifest(root) {
let stack = detect_stack(root).unwrap_or_default();
out.push(DiscoveredProject {
path: root.to_string_lossy().to_string(),
name: root
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| root.to_string_lossy().to_string()),
stack,
is_monorepo: mono,
});
}
// 2. monorepo → 展开 packages/* apps/* 直接子目录
// 3. 普通目录 → 扫直接子目录,凡 detect_stack 非空的入选
let scan_globs: &[&str] = if mono {
&["packages", "apps"]
} else {
&[""]
};
for glob in scan_globs {
let target = if glob.is_empty() {
root.to_path_buf()
} else {
root.join(glob)
};
if !target.is_dir() {
continue;
}
let Ok(entries) = std::fs::read_dir(&target) else {
continue;
};
for e in entries.flatten() {
let p = e.path();
if !p.is_dir() {
continue;
}
let name = e.file_name().to_string_lossy().to_string();
if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) || name.starts_with('.') {
continue;
}
// 必须有项目标志 + detect_stack 非空
if !has_project_manifest(&p) {
continue;
}
let stack = match detect_stack(&p) {
Ok(s) => s,
Err(_) => continue,
};
if stack.is_empty() {
continue;
}
out.push(DiscoveredProject {
path: p.to_string_lossy().to_string(),
name,
stack,
is_monorepo: false,
});
}
}
Ok(out)
}
/// 目录是否含任一项目清单标志文件
fn has_project_manifest(dir: &Path) -> bool {
const MARKS: &[&str] = &[
"Cargo.toml",
"package.json",
"go.mod",
"pyproject.toml",
"requirements.txt",
"pom.xml",
"build.gradle",
"build.gradle.kts",
];
MARKS.iter().any(|m| dir.join(m).is_file()) || has_file_with_ext(dir, "csproj")
}
/// 解析 package.json,合并 dependencies + devDependencies 的包名
fn read_package_deps(path: impl AsRef<Path>) -> Result<Vec<String>> {
let content = std::fs::read_to_string(path.as_ref())
.with_context(|| format!("读取 package.json 失败: {}", path.as_ref().display()))?;
let pkg: serde_json::Value = serde_json::from_str(&content).context("解析 package.json 失败")?;
let mut names = Vec::new();
for key in &["dependencies", "devDependencies"] {
if let Some(obj) = pkg.get(key).and_then(|v| v.as_object()) {
for k in obj.keys() {
names.push(k.clone());
}
}
}
Ok(names)
}
/// 目录下是否存在指定扩展名的文件(仅一层)
fn has_file_with_ext(dir: &Path, ext: &str) -> bool {
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
entries.flatten().any(|e| {
e.path()
.extension()
.and_then(|x| x.to_str())
.map(|x| x == ext)
.unwrap_or(false)
})
}
// ============================================================
// 项目采样(供 LLM 分析基础信息) — 纯 IO,控 token 不读源码
// ============================================================
/// 提取项目描述 — 读 README 首段(首个非标题非空段落,截断 200 字)。
///
/// 用于「导入历史项目」时自动填充 description。无 README 或解析失败返回 None。
/// 首段定义:跳过开头标题行(# / ## …)、空行、HTML 注释与 badge 图片/HTML 行等噪声,
/// 取首个含实质文本的段落(连续多行直到空行);按字符截断至 200 字避免超长。
pub fn extract_description(root: &Path) -> Option<String> {
// 复用 read_readme_raw 的查找逻辑(支持 README.md / README.zh.md 等变体)
let content = read_readme_raw(root)?;
let mut text = String::new();
let mut started = false;
for raw_line in content.lines() {
let line = raw_line.trim();
if line.is_empty() {
if started {
break; // 段落结束
}
continue; // 首段尚未开始,跳过开头空行
}
// 段落开始后不再跳行,直接累加
if !started {
// 跳过标题 / HTML 注释 / badge 图片 / HTML 标签等噪声前导行
if line.starts_with('#')
|| line.starts_with("<!--")
|| line.starts_with('!')
|| line.starts_with('<')
{
continue;
}
started = true;
}
if !text.is_empty() {
text.push(' ');
}
text.push_str(line);
}
let desc = text.trim().to_string();
if desc.is_empty() {
return None;
}
Some(truncate_chars(&desc, EXTRACT_DESC_MAX))
}
/// extract_description 最大字符数
const EXTRACT_DESC_MAX: usize = 200;
/// 项目采样结果 — README(剥噪音后) + 目录树(2层) + 清单文件片段 + 内容图引用
#[derive(Debug, Clone)]
pub struct ProjectSample {
pub readme: Option<String>,
pub tree: Vec<String>,
/// (文件名, 截断内容)
pub manifests: Vec<(String, String)>,
/// README 内的内容图引用(架构图/截图等,跳徽章)
pub images: Vec<ImageRef>,
}
const SAMPLE_README_MAX: usize = 8000;
const SAMPLE_MANIFEST_MAX: usize = 1500;
const SAMPLE_TREE_MAX: usize = 80;
/// 目录树过滤的噪音目录(依赖产物/构建/缓存/IDE)
const SAMPLE_IGNORED_DIRS: &[&str] = &[
"node_modules", "target", ".git", "dist", "build", ".next", "venv", ".venv",
"__pycache__", ".idea", ".vscode", ".cache", "out", "coverage", ".svelte-kit",
".turbo", ".angular", ".gradle", "vendor",
];
/// 读 README 原始内容(不做处理)
fn read_readme_raw(root: &Path) -> Option<String> {
for name in &["README.md", "README.MD", "README", "README.zh.md", "README_zh.md", "README_EN.md", "readme.md"] {
let p = root.join(name);
if p.is_file() {
if let Ok(content) = std::fs::read_to_string(&p) {
return Some(content);
}
}
}
None
}
/// 采集项目采样(README + 目录树 + 清单 + 内容图),供 LLM 分析填基础信息
pub fn collect_sample(root: &Path) -> Result<ProjectSample> {
if !root.is_dir() {
anyhow::bail!("路径不是目录: {}", root.display());
}
let raw = read_readme_raw(root);
let (readme, images) = match raw {
Some(text) => {
let images = collect_images(&text);
let cleaned = strip_readme_noise(&text);
if cleaned.trim().is_empty() {
(None, images)
} else {
(Some(truncate_chars(&cleaned, SAMPLE_README_MAX)), images)
}
}
None => (None, Vec::new()),
};
Ok(ProjectSample {
readme,
tree: collect_tree(root),
manifests: collect_manifests(root),
images,
})
}
/// 目录树(根 + 一层子目录),过滤噪音目录,控条目数
fn collect_tree(root: &Path) -> Vec<String> {
let mut lines = Vec::new();
let mut count = 0usize;
collect_tree_level(root, "", &mut lines, &mut count, false);
lines
}
fn collect_tree_level(dir: &Path, prefix: &str, lines: &mut Vec<String>, count: &mut usize, is_sub: bool) {
if *count >= SAMPLE_TREE_MAX {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else { return };
let mut items: Vec<_> = entries.flatten().collect();
items.sort_by_key(|e| e.file_name());
for e in items {
if *count >= SAMPLE_TREE_MAX {
return;
}
let name = e.file_name().to_string_lossy().to_string();
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
if is_dir {
if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) {
continue;
}
lines.push(format!("{}{}/", prefix, name));
*count += 1;
// 仅根目录的子目录展开一层(is_sub=true 不再递归)
if !is_sub {
collect_tree_level(&e.path(), &format!("{} ", prefix), lines, count, true);
}
} else {
// 跳过隐藏文件(保留 .gitignore 作 git 标识)
if name.starts_with('.') && name != ".gitignore" {
continue;
}
lines.push(format!("{}{}", prefix, name));
*count += 1;
}
}
}
fn collect_manifests(root: &Path) -> Vec<(String, String)> {
let mut out = Vec::new();
for name in &["package.json", "Cargo.toml", "go.mod", "pyproject.toml", "pom.xml", "build.gradle", "build.gradle.kts"] {
let p = root.join(name);
if let Ok(content) = std::fs::read_to_string(&p) {
out.push(((*name).to_string(), truncate_chars(&content, SAMPLE_MANIFEST_MAX)));
}
}
out
}
/// 按字符数截断(避免截断 UTF-8 多字节边界)
fn truncate_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let truncated: String = s.chars().take(max).collect();
format!("{}…(已截断)", truncated)
}
/// 去重 push
fn push_unique(stack: &mut Vec<String>, s: &str) {
if !stack.iter().any(|x| x == s) {
stack.push(s.to_string());
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
/// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径
fn scratch(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("df-project-scan-{}-{}", name, std::process::id()));
let _ = fs::remove_dir_all(&p);
fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn non_dir_errors() {
let r = detect_stack(Path::new("definitely-not-exist-xyz-123"));
assert!(r.is_err());
}
#[test]
fn detects_rust() {
let d = scratch("rust");
fs::write(d.join("Cargo.toml"), "").unwrap();
let s = detect_stack(&d).unwrap();
assert!(s.contains(&"rust".to_string()));
fs::remove_dir_all(&d).ok();
}
#[test]
fn detects_full_stack() {
// 模拟 DevFlow 自身:rust + tauri + vue + vite + typescript
let d = scratch("full");
fs::write(d.join("Cargo.toml"), "").unwrap();
fs::create_dir(d.join("src-tauri")).unwrap();
fs::write(
d.join("package.json"),
r#"{"dependencies":{"vue":"^3.5.0"},"devDependencies":{"vite":"^6.0.0","typescript":"~5.6.0"}}"#,
)
.unwrap();
let s = detect_stack(&d).unwrap();
assert!(s.contains(&"rust".to_string()));
assert!(s.contains(&"tauri".to_string()));
assert!(s.contains(&"vue".to_string()));
assert!(s.contains(&"vite".to_string()));
assert!(s.contains(&"typescript".to_string()));
fs::remove_dir_all(&d).ok();
}
#[test]
fn empty_dir_returns_empty() {
let d = scratch("empty");
let s = detect_stack(&d).unwrap();
assert!(s.is_empty());
fs::remove_dir_all(&d).ok();
}
#[test]
fn collects_sample() {
let d = scratch("sample");
fs::write(
d.join("README.md"),
"# Test\n\n![badge](https://img.shields.io/badge/x-y-green)\n\n![arch](./docs/arch.png)\n\nA test project.\nMore.",
)
.unwrap();
fs::write(d.join("package.json"), r#"{"name":"x","dependencies":{"vue":"3"}}"#).unwrap();
fs::create_dir(d.join("src")).unwrap();
fs::write(d.join("src/main.ts"), "x").unwrap();
fs::create_dir(d.join("node_modules")).unwrap();
fs::write(d.join("node_modules/junk.json"), "x").unwrap();
let s = collect_sample(&d).unwrap();
// 徽章行剥,内容图 + 正文保留
let readme = s.readme.as_deref().unwrap_or("");
assert!(readme.contains("test project"), "readme={readme}");
assert!(!readme.contains("shields.io"), "徽章未剥: {readme}");
assert!(readme.contains("docs/arch.png"), "内容图丢失: {readme}");
// 内容图收集(badge 跳,arch 留)
assert_eq!(s.images.len(), 1);
assert_eq!(s.images[0].src, "./docs/arch.png");
assert!(s.manifests.iter().any(|(n, _)| n == "package.json"));
assert!(s.tree.iter().any(|t| t.contains("src")));
// node_modules 应被过滤
assert!(s.tree.iter().all(|t| !t.contains("node_modules")));
fs::remove_dir_all(&d).ok();
}
#[test]
fn strips_frontmatter_and_toc_and_badges() {
let d = scratch("noise");
let readme = "---\ntitle: Foo\n---\n\n# Foo\n\n<!-- hidden comment -->\n\n[Install](#install)\n\n- [Usage](#usage)\n\n![build](https://img.shields.io/badge/build-passing)\n\nThis is the real intro.\n";
fs::write(d.join("README.md"), readme).unwrap();
let s = collect_sample(&d).unwrap();
let r = s.readme.as_deref().unwrap_or("");
assert!(r.contains("# Foo"), "标题应保留: {r}");
assert!(r.contains("real intro"), "正文应保留: {r}");
assert!(!r.contains("hidden comment"), "HTML 注释未剥: {r}");
assert!(!r.contains("shields.io"), "徽章未剥: {r}");
assert!(!r.contains("[Install](#install)"), "TOC 锚点未剥: {r}");
assert!(!r.contains("[Usage](#usage)"), "TOC 列表项未剥: {r}");
assert!(!r.contains("title: Foo"), "frontmatter 未剥: {r}");
fs::remove_dir_all(&d).ok();
}
#[test]
fn detects_monorepo_pnpm() {
let d = scratch("mono-pnpm");
fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap();
assert!(is_monorepo(&d));
fs::remove_dir_all(&d).ok();
}
#[test]
fn detects_monorepo_npm_workspaces() {
let d = scratch("mono-npm");
fs::write(
d.join("package.json"),
r#"{"name":"root","workspaces":["packages/*"]}"#,
)
.unwrap();
assert!(is_monorepo(&d));
fs::remove_dir_all(&d).ok();
}
#[test]
fn detects_non_monorepo() {
let d = scratch("nonmono");
fs::write(d.join("package.json"), r#"{"name":"x"}"#).unwrap();
assert!(!is_monorepo(&d));
fs::remove_dir_all(&d).ok();
}
#[test]
fn discover_monorepo_children() {
let d = scratch("discover-mono");
fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap();
// 子项目:packages/web(有 package.json + vue)、packages/cli(有 Cargo.toml)
fs::create_dir_all(d.join("packages/web")).unwrap();
fs::write(
d.join("packages/web/package.json"),
r#"{"name":"web","dependencies":{"vue":"3"}}"#,
)
.unwrap();
fs::create_dir_all(d.join("packages/cli")).unwrap();
fs::write(d.join("packages/cli/Cargo.toml"), "[package]\nname=\"cli\"\n").unwrap();
// 空 stack 子目录应过滤
fs::create_dir_all(d.join("packages/empty")).unwrap();
fs::write(d.join("packages/empty/x.txt"), "x").unwrap();
let found = discover_projects(&d).unwrap();
// 根自身无 manifest 不入选;packages/web + packages/cli 入选;empty 过滤
let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"web"), "names={names:?}");
assert!(names.contains(&"cli"), "names={names:?}");
assert!(!names.contains(&"empty"), "空 stack 未过滤: {names:?}");
fs::remove_dir_all(&d).ok();
}
#[test]
fn discover_flat_children() {
// 非 monorepo:扫根直接子目录中 detect_stack 非空的
let d = scratch("discover-flat");
fs::create_dir_all(d.join("proj-a")).unwrap();
fs::write(d.join("proj-a/Cargo.toml"), "").unwrap();
fs::create_dir_all(d.join("not-a-project")).unwrap();
fs::write(d.join("not-a-project/readme.txt"), "x").unwrap();
let found = discover_projects(&d).unwrap();
let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"proj-a"), "names={names:?}");
assert!(!names.contains(&"not-a-project"), "空 stack 未过滤: {names:?}");
fs::remove_dir_all(&d).ok();
}
#[test]
fn extract_desc_skips_title_badge() {
let d = scratch("desc");
// 标题 + badge 图片行应跳过,首段为正文
fs::write(
d.join("README.md"),
"# My Project\n\n![badge](https://x.io/badge.svg)\n\n这是一个示例项目,用于演示。\n第二行正文。\n\n## 安装",
)
.unwrap();
let desc = extract_description(&d).unwrap();
assert!(desc.contains("示例项目"));
assert!(desc.contains("第二行正文"));
assert!(!desc.contains("My Project"));
assert!(!desc.contains("badge"));
assert!(!desc.contains("安装"));
fs::remove_dir_all(&d).ok();
}
#[test]
fn extract_desc_no_readme_returns_none() {
let d = scratch("nodesc");
assert!(extract_description(&d).is_none());
fs::remove_dir_all(&d).ok();
}
#[test]
fn extract_desc_truncates_long() {
let d = scratch("longdesc");
let long = "".repeat(500);
fs::write(d.join("README.md"), format!("# T\n\n{long}")).unwrap();
let desc = extract_description(&d).unwrap();
// 截断到 200 字 + 末尾「…(已截断)」标记
assert!(desc.chars().count() <= 210);
assert!(desc.contains("已截断"));
fs::remove_dir_all(&d).ok();
}
// ── normalize_path 跨平台归一化 ──
// 锁定:① 反斜杠→正斜杠 ② 小写 ③ 尾部分隔符裁剪 ④ 相同逻辑路径相等(防重复录入绕过)。
// 注:canonicalize 成功路径在不同 OS 返回不同形态(Windows 带前缀),故只断言跨平台不变性,
// 不断言精确串,避免与平台耦合。
#[test]
fn normalize_path_uses_forward_slash_and_lowercase() {
// 不存在的路径走降级分支(trim + replace + lowercase)
let n = normalize_path(r"C:\Foo\Bar\");
assert!(
!n.contains('\\'),
"反斜杠未归一: {n}"
);
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
assert!(
!n.ends_with('/') && !n.ends_with('\\'),
"尾部分隔符未裁剪: {n}"
);
}
#[test]
fn normalize_path_equivalent_inputs_equal() {
// 两种写法应归一为同一串(去重场景)
let a = normalize_path(r"C:\Foo\Bar");
let b = normalize_path("C:/Foo/Bar/");
assert_eq!(a, b, "等价路径归一不等: {a} vs {b}");
}
#[test]
fn normalize_path_relative_is_lowercase_and_forward_slash() {
let n = normalize_path(r"..\Some\Path");
assert!(!n.contains('\\'), "反斜杠未归一: {n}");
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
}
// ── is_monorepo workspaces:null 防回归(wd2fnjh3s) ──
// .is_some_and(!is_null) 修复点:key 存在但值为 JSON null 时不得判为 monorepo。
#[test]
fn is_monorepo_workspaces_null_not_misclassified() {
let d = scratch("ws-null");
fs::write(
d.join("package.json"),
r#"{"name":"x","workspaces":null}"#,
)
.unwrap();
assert!(
!is_monorepo(&d),
"workspaces: null 不应判为 monorepo(回归 wd2fnjh3s)"
);
fs::remove_dir_all(&d).ok();
}
#[test]
fn is_monorepo_workspaces_object_treated_as_real() {
// Yarn 形式 {"packages":[...]} —— 非 null,应判为 monorepo
let d = scratch("ws-obj");
fs::write(
d.join("package.json"),
r#"{"name":"y","workspaces":{"packages":["packages/*"]}}"#,
)
.unwrap();
assert!(is_monorepo(&d), "Yarn workspaces 对象形式应判为 monorepo");
fs::remove_dir_all(&d).ok();
}
#[test]
fn is_monorepo_non_dir_returns_false() {
assert!(!is_monorepo(Path::new("definitely-not-exist-xyz-456")));
}
}

View File

@@ -0,0 +1,276 @@
//! 历史项目发现 — monorepo 识别 + 子项目展开(纯规则,不跑 LLM)。
use std::path::Path;
use anyhow::Result;
use super::stack::{detect_stack, has_file_with_ext};
use super::SAMPLE_IGNORED_DIRS;
/// monorepo 工作区配置文件名(JS 生态主流:pnpm/lerna/turbo/nx)
const MONOREPO_MARKERS: &[&str] = &[
"pnpm-workspace.yaml",
"lerna.json",
"turbo.json",
"nx.json",
];
/// 判定目录是否为 monorepo 根(JS 生态主流工作区管理器)。
///
/// 命中任一即视为 monorepo:
/// - pnpm-workspace.yaml / lerna.json / turbo.json / nx.json 存在
/// - package.json 含 `workspaces` 字段(npm/yarn workspaces)
pub fn is_monorepo(root: &Path) -> bool {
if !root.is_dir() {
return false;
}
for marker in MONOREPO_MARKERS {
if root.join(marker).is_file() {
return true;
}
}
// package.json workspaces 字段(npm/yarn)。数组(`["packages/*"]`)或对象
// (`{"packages":[...]}`,Yarn)均为真正的工作区;JSON 显式 null 表示「无」,
// 不应误判为 monorepo(`.is_some()` 对 key 存在但值为 null 仍返回 true → 误报)。
let pkg_path = root.join("package.json");
if pkg_path.is_file() {
if let Ok(content) = std::fs::read_to_string(&pkg_path) {
if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&content) {
if pkg.get("workspaces").is_some_and(|v| !v.is_null()) {
return true;
}
}
}
}
false
}
/// 单个发现的候选项目(monorepo 子项目或独立项目)
#[derive(Debug, Clone)]
pub struct DiscoveredProject {
/// 项目根目录绝对路径
pub path: String,
/// 推断的项目名(目录名)
pub name: String,
/// 规则探测的技术栈(空=未识别)
pub stack: Vec<String>,
/// 是否为 monorepo 根(便于前端标记)
pub is_monorepo: bool,
}
/// 在指定根目录下发现候选项目。
///
/// 策略(只展开一层,不做深递归):
/// 1. 根目录本身有项目标志(Cargo.toml/package.json/go.mod 等)→ 根为独立项目
/// 2. 根目录是 monorepo → 展开 packages/\*/apps/\* 直接子目录(各子目录跑 detect_stack 过滤空)
/// 3. 否则:扫根的直接子目录,凡 detect_stack 非空的视为候选项目
///
/// 不跑 LLM(快),不读源码。空 stack 的目录在 monorepo 展开/子目录扫描时被过滤。
pub fn discover_projects(root: &Path) -> Result<Vec<DiscoveredProject>> {
if !root.is_dir() {
anyhow::bail!("路径不是目录: {}", root.display());
}
let mut out: Vec<DiscoveredProject> = Vec::new();
let mono = is_monorepo(root);
// 1. 根目录自身是项目(有 manifest 标志)
if has_project_manifest(root) {
let stack = detect_stack(root).unwrap_or_default();
out.push(DiscoveredProject {
path: root.to_string_lossy().to_string(),
name: root
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| root.to_string_lossy().to_string()),
stack,
is_monorepo: mono,
});
}
// 2. monorepo → 展开 packages/* apps/* 直接子目录
// 3. 普通目录 → 扫直接子目录,凡 detect_stack 非空的入选
let scan_globs: &[&str] = if mono {
&["packages", "apps"]
} else {
&[""]
};
for glob in scan_globs {
let target = if glob.is_empty() {
root.to_path_buf()
} else {
root.join(glob)
};
if !target.is_dir() {
continue;
}
let Ok(entries) = std::fs::read_dir(&target) else {
continue;
};
for e in entries.flatten() {
let p = e.path();
if !p.is_dir() {
continue;
}
let name = e.file_name().to_string_lossy().to_string();
if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) || name.starts_with('.') {
continue;
}
// 必须有项目标志 + detect_stack 非空
if !has_project_manifest(&p) {
continue;
}
let stack = match detect_stack(&p) {
Ok(s) => s,
Err(_) => continue,
};
if stack.is_empty() {
continue;
}
out.push(DiscoveredProject {
path: p.to_string_lossy().to_string(),
name,
stack,
is_monorepo: false,
});
}
}
Ok(out)
}
/// 目录是否含任一项目清单标志文件
fn has_project_manifest(dir: &Path) -> bool {
const MARKS: &[&str] = &[
"Cargo.toml",
"package.json",
"go.mod",
"pyproject.toml",
"requirements.txt",
"pom.xml",
"build.gradle",
"build.gradle.kts",
];
MARKS.iter().any(|m| dir.join(m).is_file()) || has_file_with_ext(dir, "csproj")
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
/// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径
fn scratch(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("df-project-scan-{}-{}", name, std::process::id()));
let _ = fs::remove_dir_all(&p);
fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn detects_monorepo_pnpm() {
let d = scratch("mono-pnpm");
fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap();
assert!(is_monorepo(&d));
fs::remove_dir_all(&d).ok();
}
#[test]
fn detects_monorepo_npm_workspaces() {
let d = scratch("mono-npm");
fs::write(
d.join("package.json"),
r#"{"name":"root","workspaces":["packages/*"]}"#,
)
.unwrap();
assert!(is_monorepo(&d));
fs::remove_dir_all(&d).ok();
}
#[test]
fn detects_non_monorepo() {
let d = scratch("nonmono");
fs::write(d.join("package.json"), r#"{"name":"x"}"#).unwrap();
assert!(!is_monorepo(&d));
fs::remove_dir_all(&d).ok();
}
#[test]
fn discover_monorepo_children() {
let d = scratch("discover-mono");
fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap();
// 子项目:packages/web(有 package.json + vue)、packages/cli(有 Cargo.toml)
fs::create_dir_all(d.join("packages/web")).unwrap();
fs::write(
d.join("packages/web/package.json"),
r#"{"name":"web","dependencies":{"vue":"3"}}"#,
)
.unwrap();
fs::create_dir_all(d.join("packages/cli")).unwrap();
fs::write(d.join("packages/cli/Cargo.toml"), "[package]\nname=\"cli\"\n").unwrap();
// 空 stack 子目录应过滤
fs::create_dir_all(d.join("packages/empty")).unwrap();
fs::write(d.join("packages/empty/x.txt"), "x").unwrap();
let found = discover_projects(&d).unwrap();
// 根自身无 manifest 不入选;packages/web + packages/cli 入选;empty 过滤
let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"web"), "names={names:?}");
assert!(names.contains(&"cli"), "names={names:?}");
assert!(!names.contains(&"empty"), "空 stack 未过滤: {names:?}");
fs::remove_dir_all(&d).ok();
}
#[test]
fn discover_flat_children() {
// 非 monorepo:扫根直接子目录中 detect_stack 非空的
let d = scratch("discover-flat");
fs::create_dir_all(d.join("proj-a")).unwrap();
fs::write(d.join("proj-a/Cargo.toml"), "").unwrap();
fs::create_dir_all(d.join("not-a-project")).unwrap();
fs::write(d.join("not-a-project/readme.txt"), "x").unwrap();
let found = discover_projects(&d).unwrap();
let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"proj-a"), "names={names:?}");
assert!(!names.contains(&"not-a-project"), "空 stack 未过滤: {names:?}");
fs::remove_dir_all(&d).ok();
}
// ── is_monorepo workspaces:null 防回归(wd2fnjh3s) ──
// .is_some_and(!is_null) 修复点:key 存在但值为 JSON null 时不得判为 monorepo。
#[test]
fn is_monorepo_workspaces_null_not_misclassified() {
let d = scratch("ws-null");
fs::write(
d.join("package.json"),
r#"{"name":"x","workspaces":null}"#,
)
.unwrap();
assert!(
!is_monorepo(&d),
"workspaces: null 不应判为 monorepo(回归 wd2fnjh3s)"
);
fs::remove_dir_all(&d).ok();
}
#[test]
fn is_monorepo_workspaces_object_treated_as_real() {
// Yarn 形式 {"packages":[...]} —— 非 null,应判为 monorepo
let d = scratch("ws-obj");
fs::write(
d.join("package.json"),
r#"{"name":"y","workspaces":{"packages":["packages/*"]}}"#,
)
.unwrap();
assert!(is_monorepo(&d), "Yarn workspaces 对象形式应判为 monorepo");
fs::remove_dir_all(&d).ok();
}
#[test]
fn is_monorepo_non_dir_returns_false() {
assert!(!is_monorepo(Path::new("definitely-not-exist-xyz-456")));
}
}

View File

@@ -0,0 +1,114 @@
//! 项目技术栈探测 — 浅读根目录标志文件识别技术栈
//!
//! 纯函数、零状态、零外部依赖(仅 std + serde_json + anyhow)。
//! 只读根目录标志文件,不递归遍历(控制性能与安全)。
//!
//! 供 commands 层薄封装暴露为 IPC 命令,新建/导入项目时自动填充 `ProjectRecord.stack`;
//! 未来 df-ai/df-workflow 亦可复用以感知「项目是什么技术栈」。
//!
//! ## 模块拆分(2026-06-22,REFACTOR-260619-10)
//!
//! 原 `scan.rs`(741 行)按职责拆为 4 子模块,纯文件重组、零行为变化:
//! - [`stack`]:技术栈探测(`detect_stack` + package.json 解析)
//! - [`discover`]:历史项目发现/monorepo 展开(`discover_projects`/`is_monorepo`)
//! - [`readme`]:README 读取与首段抽取(`read_readme_raw`/`extract_description`)
//! - [`sample`]:项目采样(`collect_sample` + 目录树/清单/噪音目录)
//!
//! 跨模块共享的常量/helper 留在本 `mod.rs`(`SAMPLE_IGNORED_DIRS`/`truncate_chars`),
//! 子模块经 `use super::*` 引用。`pub use` 重导出保持外部 `df_project::scan::*` 路径不变。
//!
//! README 噪音处理与图片收集已抽离至 `scan_helpers`(2026-06-19 自底向上拆分),
//! 此处通过 `pub use` 重导出 `ImageRef` 等保持外部路径 `df_project::scan::*` 不变。
use std::path::Path;
#[path = "../scan_helpers.rs"]
mod scan_helpers;
pub use scan_helpers::ImageRef;
pub mod discover;
pub mod readme;
pub mod sample;
pub mod stack;
// ── 对外稳定 API 重导出(外部 `df_project::scan::*` 路径不变)──
pub use discover::{discover_projects, is_monorepo, DiscoveredProject};
pub use readme::extract_description;
pub use sample::{collect_sample, ProjectSample};
pub use stack::detect_stack;
// ============================================================
// 跨模块共享常量/helper
// ============================================================
/// 目录树/项目发现过滤的噪音目录(依赖产物/构建/缓存/IDE)。
///
/// 同时被 [`discover`] 与 [`sample`] 引用,故置于本 `mod.rs`,`pub(super)` 暴露给子模块。
pub(super) const SAMPLE_IGNORED_DIRS: &[&str] = &[
"node_modules", "target", ".git", "dist", "build", ".next", "venv", ".venv",
"__pycache__", ".idea", ".vscode", ".cache", "out", "coverage", ".svelte-kit",
".turbo", ".angular", ".gradle", "vendor",
];
/// 按字符数截断(避免截断 UTF-8 多字节边界)。
///
/// 被 [`readme`](crate::scan::readme) 与 [`sample`](crate::scan::sample) 引用,故置于本 `mod.rs`。
pub(super) fn truncate_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let truncated: String = s.chars().take(max).collect();
format!("{}…(已截断)", truncated)
}
/// 规范化路径用于比较:canonicalize 解析绝对规范路径(失败降级),
/// 统一正斜杠 + 小写。防 `C:\a\b` vs `C:/a/b/` 绕过重复检查。
/// 注:仅用于比较,存库保留用户输入的原始可读路径。
pub fn normalize_path(p: &str) -> String {
match Path::new(p).canonicalize() {
Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(),
Err(_) => p
.trim_end_matches(['\\', '/'])
.replace('\\', "/")
.to_lowercase(),
}
}
#[cfg(test)]
mod tests {
use super::*;
// ── normalize_path 跨平台归一化 ──
// 锁定:① 反斜杠→正斜杠 ② 小写 ③ 尾部分隔符裁剪 ④ 相同逻辑路径相等(防重复录入绕过)。
// 注:canonicalize 成功路径在不同 OS 返回不同形态(Windows 带前缀),故只断言跨平台不变性,
// 不断言精确串,避免与平台耦合。
#[test]
fn normalize_path_uses_forward_slash_and_lowercase() {
// 不存在的路径走降级分支(trim + replace + lowercase)
let n = normalize_path(r"C:\Foo\Bar\");
assert!(
!n.contains('\\'),
"反斜杠未归一: {n}"
);
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
assert!(
!n.ends_with('/') && !n.ends_with('\\'),
"尾部分隔符未裁剪: {n}"
);
}
#[test]
fn normalize_path_equivalent_inputs_equal() {
// 两种写法应归一为同一串(去重场景)
let a = normalize_path(r"C:\Foo\Bar");
let b = normalize_path("C:/Foo/Bar/");
assert_eq!(a, b, "等价路径归一不等: {a} vs {b}");
}
#[test]
fn normalize_path_relative_is_lowercase_and_forward_slash() {
let n = normalize_path(r"..\Some\Path");
assert!(!n.contains('\\'), "反斜杠未归一: {n}");
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
}
}

View File

@@ -0,0 +1,122 @@
//! README 读取与首段抽取 — 纯 IO + 文本规则。
//!
//! `read_readme_raw` 被本模块(`extract_description`)与 [`super::sample`](`crate::scan::sample`)
//! (`collect_sample`)共用,故 `pub(super)` 暴露。
use std::path::Path;
use super::truncate_chars;
/// extract_description 最大字符数
const EXTRACT_DESC_MAX: usize = 200;
/// 读 README 原始内容(不做处理)。
///
/// 支持多种 README 变体(`README.md` / `README.zh.md` / `README_zh.md` / `README_EN.md` 等),
/// 返回首个存在的文件内容。
pub(super) fn read_readme_raw(root: &Path) -> Option<String> {
for name in &["README.md", "README.MD", "README", "README.zh.md", "README_zh.md", "README_EN.md", "readme.md"] {
let p = root.join(name);
if p.is_file() {
if let Ok(content) = std::fs::read_to_string(&p) {
return Some(content);
}
}
}
None
}
/// 提取项目描述 — 读 README 首段(首个非标题非空段落,截断 200 字)。
///
/// 用于「导入历史项目」时自动填充 description。无 README 或解析失败返回 None。
/// 首段定义:跳过开头标题行(# / ## …)、空行、HTML 注释与 badge 图片/HTML 行等噪声,
/// 取首个含实质文本的段落(连续多行直到空行);按字符截断至 200 字避免超长。
pub fn extract_description(root: &Path) -> Option<String> {
// 复用 read_readme_raw 的查找逻辑(支持 README.md / README.zh.md 等变体)
let content = read_readme_raw(root)?;
let mut text = String::new();
let mut started = false;
for raw_line in content.lines() {
let line = raw_line.trim();
if line.is_empty() {
if started {
break; // 段落结束
}
continue; // 首段尚未开始,跳过开头空行
}
// 段落开始后不再跳行,直接累加
if !started {
// 跳过标题 / HTML 注释 / badge 图片 / HTML 标签等噪声前导行
if line.starts_with('#')
|| line.starts_with("<!--")
|| line.starts_with('!')
|| line.starts_with('<')
{
continue;
}
started = true;
}
if !text.is_empty() {
text.push(' ');
}
text.push_str(line);
}
let desc = text.trim().to_string();
if desc.is_empty() {
return None;
}
Some(truncate_chars(&desc, EXTRACT_DESC_MAX))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
/// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径
fn scratch(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("df-project-scan-{}-{}", name, std::process::id()));
let _ = fs::remove_dir_all(&p);
fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn extract_desc_skips_title_badge() {
let d = scratch("desc");
// 标题 + badge 图片行应跳过,首段为正文
fs::write(
d.join("README.md"),
"# My Project\n\n![badge](https://x.io/badge.svg)\n\n这是一个示例项目,用于演示。\n第二行正文。\n\n## 安装",
)
.unwrap();
let desc = extract_description(&d).unwrap();
assert!(desc.contains("示例项目"));
assert!(desc.contains("第二行正文"));
assert!(!desc.contains("My Project"));
assert!(!desc.contains("badge"));
assert!(!desc.contains("安装"));
fs::remove_dir_all(&d).ok();
}
#[test]
fn extract_desc_no_readme_returns_none() {
let d = scratch("nodesc");
assert!(extract_description(&d).is_none());
fs::remove_dir_all(&d).ok();
}
#[test]
fn extract_desc_truncates_long() {
let d = scratch("longdesc");
let long = "".repeat(500);
fs::write(d.join("README.md"), format!("# T\n\n{long}")).unwrap();
let desc = extract_description(&d).unwrap();
// 截断到 200 字 + 末尾「…(已截断)」标记
assert!(desc.chars().count() <= 210);
assert!(desc.contains("已截断"));
fs::remove_dir_all(&d).ok();
}
}

View File

@@ -0,0 +1,167 @@
//! 项目采样(供 LLM 分析基础信息) — 纯 IO,控 token 不读源码。
//!
//! README(剥噪音后) + 目录树(2层) + 清单文件片段 + 内容图引用。
use std::path::Path;
use anyhow::Result;
use super::readme::read_readme_raw;
use super::scan_helpers::{collect_images, strip_readme_noise, ImageRef};
use super::{truncate_chars, SAMPLE_IGNORED_DIRS};
const SAMPLE_README_MAX: usize = 8000;
const SAMPLE_MANIFEST_MAX: usize = 1500;
const SAMPLE_TREE_MAX: usize = 80;
/// 项目采样结果 — README(剥噪音后) + 目录树(2层) + 清单文件片段 + 内容图引用
#[derive(Debug, Clone)]
pub struct ProjectSample {
pub readme: Option<String>,
pub tree: Vec<String>,
/// (文件名, 截断内容)
pub manifests: Vec<(String, String)>,
/// README 内的内容图引用(架构图/截图等,跳徽章)
pub images: Vec<ImageRef>,
}
/// 采集项目采样(README + 目录树 + 清单 + 内容图),供 LLM 分析填基础信息
pub fn collect_sample(root: &Path) -> Result<ProjectSample> {
if !root.is_dir() {
anyhow::bail!("路径不是目录: {}", root.display());
}
let raw = read_readme_raw(root);
let (readme, images) = match raw {
Some(text) => {
let images = collect_images(&text);
let cleaned = strip_readme_noise(&text);
if cleaned.trim().is_empty() {
(None, images)
} else {
(Some(truncate_chars(&cleaned, SAMPLE_README_MAX)), images)
}
}
None => (None, Vec::new()),
};
Ok(ProjectSample {
readme,
tree: collect_tree(root),
manifests: collect_manifests(root),
images,
})
}
/// 目录树(根 + 一层子目录),过滤噪音目录,控条目数
fn collect_tree(root: &Path) -> Vec<String> {
let mut lines = Vec::new();
let mut count = 0usize;
collect_tree_level(root, "", &mut lines, &mut count, false);
lines
}
fn collect_tree_level(dir: &Path, prefix: &str, lines: &mut Vec<String>, count: &mut usize, is_sub: bool) {
if *count >= SAMPLE_TREE_MAX {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else { return };
let mut items: Vec<_> = entries.flatten().collect();
items.sort_by_key(|e| e.file_name());
for e in items {
if *count >= SAMPLE_TREE_MAX {
return;
}
let name = e.file_name().to_string_lossy().to_string();
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
if is_dir {
if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) {
continue;
}
lines.push(format!("{}{}/", prefix, name));
*count += 1;
// 仅根目录的子目录展开一层(is_sub=true 不再递归)
if !is_sub {
collect_tree_level(&e.path(), &format!("{} ", prefix), lines, count, true);
}
} else {
// 跳过隐藏文件(保留 .gitignore 作 git 标识)
if name.starts_with('.') && name != ".gitignore" {
continue;
}
lines.push(format!("{}{}", prefix, name));
*count += 1;
}
}
}
fn collect_manifests(root: &Path) -> Vec<(String, String)> {
let mut out = Vec::new();
for name in &["package.json", "Cargo.toml", "go.mod", "pyproject.toml", "pom.xml", "build.gradle", "build.gradle.kts"] {
let p = root.join(name);
if let Ok(content) = std::fs::read_to_string(&p) {
out.push(((*name).to_string(), truncate_chars(&content, SAMPLE_MANIFEST_MAX)));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
/// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径
fn scratch(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("df-project-scan-{}-{}", name, std::process::id()));
let _ = fs::remove_dir_all(&p);
fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn collects_sample() {
let d = scratch("sample");
fs::write(
d.join("README.md"),
"# Test\n\n![badge](https://img.shields.io/badge/x-y-green)\n\n![arch](./docs/arch.png)\n\nA test project.\nMore.",
)
.unwrap();
fs::write(d.join("package.json"), r#"{"name":"x","dependencies":{"vue":"3"}}"#).unwrap();
fs::create_dir(d.join("src")).unwrap();
fs::write(d.join("src/main.ts"), "x").unwrap();
fs::create_dir(d.join("node_modules")).unwrap();
fs::write(d.join("node_modules/junk.json"), "x").unwrap();
let s = collect_sample(&d).unwrap();
// 徽章行剥,内容图 + 正文保留
let readme = s.readme.as_deref().unwrap_or("");
assert!(readme.contains("test project"), "readme={readme}");
assert!(!readme.contains("shields.io"), "徽章未剥: {readme}");
assert!(readme.contains("docs/arch.png"), "内容图丢失: {readme}");
// 内容图收集(badge 跳,arch 留)
assert_eq!(s.images.len(), 1);
assert_eq!(s.images[0].src, "./docs/arch.png");
assert!(s.manifests.iter().any(|(n, _)| n == "package.json"));
assert!(s.tree.iter().any(|t| t.contains("src")));
// node_modules 应被过滤
assert!(s.tree.iter().all(|t| !t.contains("node_modules")));
fs::remove_dir_all(&d).ok();
}
#[test]
fn strips_frontmatter_and_toc_and_badges() {
let d = scratch("noise");
let readme = "---\ntitle: Foo\n---\n\n# Foo\n\n<!-- hidden comment -->\n\n[Install](#install)\n\n- [Usage](#usage)\n\n![build](https://img.shields.io/badge/build-passing)\n\nThis is the real intro.\n";
fs::write(d.join("README.md"), readme).unwrap();
let s = collect_sample(&d).unwrap();
let r = s.readme.as_deref().unwrap_or("");
assert!(r.contains("# Foo"), "标题应保留: {r}");
assert!(r.contains("real intro"), "正文应保留: {r}");
assert!(!r.contains("hidden comment"), "HTML 注释未剥: {r}");
assert!(!r.contains("shields.io"), "徽章未剥: {r}");
assert!(!r.contains("[Install](#install)"), "TOC 锚点未剥: {r}");
assert!(!r.contains("[Usage](#usage)"), "TOC 列表项未剥: {r}");
assert!(!r.contains("title: Foo"), "frontmatter 未剥: {r}");
fs::remove_dir_all(&d).ok();
}
}

View File

@@ -0,0 +1,176 @@
//! 技术栈探测 — 浅读根目录标志文件识别技术栈。
//!
//! 纯函数、零状态。读 `Cargo.toml`/`go.mod`/`package.json` 等标志,
//! 不递归遍历(控制性能与安全)。
use std::path::Path;
use anyhow::{Context, Result};
/// 探测目录的技术栈
///
/// 返回去重后的技术栈标签数组(如 `["rust","vue","tauri","typescript"]`)。
/// 空数组表示未识别出任何已知标志(空目录或非常规项目)。非目录返回 Err。
pub fn detect_stack(root: &Path) -> Result<Vec<String>> {
if !root.is_dir() {
anyhow::bail!("路径不是目录: {}", root.display());
}
let mut stack: Vec<String> = Vec::new();
// ── 后端/系统语言 ──
if root.join("Cargo.toml").exists() {
push_unique(&mut stack, "rust");
}
if root.join("go.mod").exists() {
push_unique(&mut stack, "go");
}
if root.join("pom.xml").exists()
|| root.join("build.gradle").exists()
|| root.join("build.gradle.kts").exists()
{
push_unique(&mut stack, "java");
}
if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() {
push_unique(&mut stack, "python");
}
// C#: 根目录存在 .csproj 文件(仅一层)
if has_file_with_ext(root, "csproj") {
push_unique(&mut stack, "csharp");
}
// ── Tauri 桌面应用(通常含 src-tauri 目录) ──
if root.join("src-tauri").is_dir() {
push_unique(&mut stack, "tauri");
}
// ── 前端/Node:解析 package.json 的依赖推断框架 ──
if root.join("package.json").exists() {
if let Ok(deps) = read_package_deps(root.join("package.json")) {
if deps.iter().any(|d| d == "vue") {
push_unique(&mut stack, "vue");
}
if deps.iter().any(|d| d == "react" || d == "react-dom") {
push_unique(&mut stack, "react");
}
if deps.iter().any(|d| d == "@angular/core") {
push_unique(&mut stack, "angular");
}
if deps.iter().any(|d| d == "svelte") {
push_unique(&mut stack, "svelte");
}
if deps.iter().any(|d| d == "next") {
push_unique(&mut stack, "next");
}
if deps.iter().any(|d| d == "vite") {
push_unique(&mut stack, "vite");
}
if deps.iter().any(|d| d == "typescript") {
push_unique(&mut stack, "typescript");
}
if deps.iter().any(|d| d == "express" || d == "koa" || d == "fastify") {
push_unique(&mut stack, "node");
}
}
}
Ok(stack)
}
/// 解析 package.json,合并 dependencies + devDependencies 的包名
fn read_package_deps(path: impl AsRef<Path>) -> Result<Vec<String>> {
let content = std::fs::read_to_string(path.as_ref())
.with_context(|| format!("读取 package.json 失败: {}", path.as_ref().display()))?;
let pkg: serde_json::Value = serde_json::from_str(&content).context("解析 package.json 失败")?;
let mut names = Vec::new();
for key in &["dependencies", "devDependencies"] {
if let Some(obj) = pkg.get(key).and_then(|v| v.as_object()) {
for k in obj.keys() {
names.push(k.clone());
}
}
}
Ok(names)
}
/// 目录下是否存在指定扩展名的文件(仅一层)。
///
/// 被 `detect_stack`(C# csproj)与 `super::discover`(项目清单判定)共用,
/// 故 `pub(super)`。
pub(super) fn has_file_with_ext(dir: &Path, ext: &str) -> bool {
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
entries.flatten().any(|e| {
e.path()
.extension()
.and_then(|x| x.to_str())
.map(|x| x == ext)
.unwrap_or(false)
})
}
/// 去重 push
fn push_unique(stack: &mut Vec<String>, s: &str) {
if !stack.iter().any(|x| x == s) {
stack.push(s.to_string());
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
/// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径
fn scratch(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("df-project-scan-{}-{}", name, std::process::id()));
let _ = fs::remove_dir_all(&p);
fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn non_dir_errors() {
let r = detect_stack(Path::new("definitely-not-exist-xyz-123"));
assert!(r.is_err());
}
#[test]
fn detects_rust() {
let d = scratch("rust");
fs::write(d.join("Cargo.toml"), "").unwrap();
let s = detect_stack(&d).unwrap();
assert!(s.contains(&"rust".to_string()));
fs::remove_dir_all(&d).ok();
}
#[test]
fn detects_full_stack() {
// 模拟 DevFlow 自身:rust + tauri + vue + vite + typescript
let d = scratch("full");
fs::write(d.join("Cargo.toml"), "").unwrap();
fs::create_dir(d.join("src-tauri")).unwrap();
fs::write(
d.join("package.json"),
r#"{"dependencies":{"vue":"^3.5.0"},"devDependencies":{"vite":"^6.0.0","typescript":"~5.6.0"}}"#,
)
.unwrap();
let s = detect_stack(&d).unwrap();
assert!(s.contains(&"rust".to_string()));
assert!(s.contains(&"tauri".to_string()));
assert!(s.contains(&"vue".to_string()));
assert!(s.contains(&"vite".to_string()));
assert!(s.contains(&"typescript".to_string()));
fs::remove_dir_all(&d).ok();
}
#[test]
fn empty_dir_returns_empty() {
let d = scratch("empty");
let s = detect_stack(&d).unwrap();
assert!(s.is_empty());
fs::remove_dir_all(&d).ok();
}
}

View File

@@ -0,0 +1,21 @@
[package]
name = "df-relay"
version = "0.1.0"
edition = "2021"
description = "跨端 AI Chat 云中继服务(axum WS Server,转发小程序 ↔ 桌面端)"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
async-trait = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
# binary 入口(main.rs)日志初始化
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# HTTP + WS 服务端(axum ws 内部用 tungstenite 协议层;df-relay 不直接依赖 tokio-tungstenite。
# TLS 由 nginx wss 终止,:9180 明文 ws → 去 native-tls 避 openssl 依赖,利 linux 交叉/容器编译)
axum = { version = "0.7", features = ["ws"] }
futures-util = "0.3"

View File

@@ -0,0 +1,21 @@
# df-relay 跨端中继(Phase3 云端部署,u-work.1216.top)
# multi-stage:rust:slim 编译 df-relay → debian:slim 运行(static-ish binary)
#
# 构建(context = repo 根,workspace):
# docker build -f crates/df-relay/Dockerfile -t df-relay .
# 运行(容器内 :8080,映射宿主 :9180,避开测试机 8080 占用):
# docker run -d --name df-relay --restart unless-stopped -p 9180:8080 df-relay
FROM rust:1.85-slim AS builder
WORKDIR /build
COPY . .
RUN cargo build --release -p df-relay
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/target/release/df-relay /usr/local/bin/df-relay
ENV DF_RELAY_ADDR=0.0.0.0:8080
EXPOSE 8080
CMD ["df-relay"]

View File

@@ -0,0 +1,30 @@
# df-relay 跨端 WS 中继反向代理(miniapp ↔ 桌面端)
# wss 在 nginx 终止(复用 *.1216.top 通配符证书),明文 ws 转 df-relay :9180
# HTTP → HTTPS 重定向
server {
listen 80;
server_name u-work.1216.top;
return 301 https://$host$request_uri;
}
# HTTPS wss 反代
server {
listen 443 ssl;
server_name u-work.1216.top;
include /etc/nginx/snippets/ssl-1216.conf;
# df-relay 两个 WS 端点原样转发(/ws/device + /ws/miniapp)
# proxy_pass 不带路径后缀 → 完整 URI 透传给 df-relay
location /ws/ {
proxy_pass http://127.0.0.1:9180;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}
}

View File

@@ -0,0 +1,145 @@
//! df-relay 广播消息骨架
//!
//! 设计依据:设计文档「Layer2」—— 云后端纯转发,无业务逻辑。
//! 中继消息骨架定义「谁发给谁」的路由语义:
//! - `Event`:桌面端事件 → 广播给该 device 绑定的小程序
//! - `Command`:小程序指令 → 转发给该 device 绑定的桌面端
//! - `Control`:控制面消息(配对/心跳/在线状态)
//!
//! 与 df-tunnel::TunnelMessage 区分:
//! - TunnelMessage 是隧道两端(桌面↔云)的传输单元
//! - BroadcastMessage 是云后端内部的路由单元(标识来源/去向/广播范围)
//!
//! AiChatEvent JSON 透传:relay 不解析 AiChatEvent(类型在 src-tauri/df-types,
//! relay 不依赖避跨 crate 强耦合),payload 用 serde_json::Value 透传。
use serde::{Deserialize, Serialize};
use crate::conn::ConnId;
/// 客户端类型(用于路由判断)
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum ClientKind {
/// 桌面端(出站长连接,事件真相源)
Device,
/// 小程序(远程操作发起方)
Miniapp,
}
/// 中继路由消息语义分类
///
/// - `Event`:桌面端 → 小程序(渲染事件,AiChatEvent 透传)
/// - `Command`:小程序 → 桌面端(操作指令,触发 Tauri command)
/// - `Control`:控制面(配对/心跳/Ping-Pong,不进业务流)
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum MessageKind {
Event,
Command,
Control,
}
/// 中继路由消息骨架(云后端内部的路由单元)
///
/// 每条消息携带 `device_id`(配对绑定键),云后端按 device_id
/// 找到绑定的对端连接进行转发/广播。payload 为原始 JSON,转发时不解析业务字段,
/// 保持轻量。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BroadcastMessage {
/// 配对绑定的设备 ID(桌面端首次配置时生成)
pub device_id: String,
/// 消息语义(Event / Command / Control)
pub kind: MessageKind,
/// 消息来源连接(用于鉴权校验:device 不能发 Command)
pub source: ConnId,
/// 来源客户端类型
pub from: ClientKind,
/// 消息载荷(原始 JSON,透传不解析)
pub payload: serde_json::Value,
/// 时间戳(毫秒,用于冲突判断 —— 桌面端为真相源,但保留时间戳辅助)
pub ts: i64,
}
impl BroadcastMessage {
/// 便捷构造:device 发出的事件
pub fn from_device(
device_id: impl Into<String>,
source: ConnId,
payload: serde_json::Value,
ts: i64,
) -> Self {
Self {
device_id: device_id.into(),
kind: MessageKind::Event,
source,
from: ClientKind::Device,
payload,
ts,
}
}
/// 便捷构造:小程序发出的指令
pub fn from_miniapp(
device_id: impl Into<String>,
source: ConnId,
payload: serde_json::Value,
ts: i64,
) -> Self {
Self {
device_id: device_id.into(),
kind: MessageKind::Command,
source,
from: ClientKind::Miniapp,
payload,
ts,
}
}
/// 便捷构造:控制面消息
pub fn control(
device_id: impl Into<String>,
source: ConnId,
from: ClientKind,
payload: serde_json::Value,
ts: i64,
) -> Self {
Self {
device_id: device_id.into(),
kind: MessageKind::Control,
source,
from,
payload,
ts,
}
}
}
/// 客户端在线状态(用于离线降级提示)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum PresenceState {
/// 在线
Online,
/// 离线(对端可缓存指令待重连)
Offline,
}
/// 控制面消息(配对绑定 / 心跳 / 在线状态广播)
///
/// 仅作为 payload 的语义约定,relay 对 Control 消息同样透传;部分控制语义
/// (如 Ping/Pong 心跳)由 WS 连接层直接处理,不进 BroadcastMessage 流。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "control_kind", rename_all = "snake_case")]
pub enum ControlMessage {
/// 配对绑定请求(小程序持 token 申请绑定 device_id)
Pair { device_id: String, token: String },
/// 心跳(保活 + 在线确认)
Heartbeat { device_id: String },
/// 在线状态变更通知
Presence { device_id: String, state: PresenceState },
/// 心跳 Ping(WS 应用层心跳,与协议层 Ping 区分)
Ping,
/// 心跳 Pong
Pong,
}

202
crates/df-relay/src/conn.rs Normal file
View File

@@ -0,0 +1,202 @@
//! df-relay 连接注册表
//!
//! 设计依据:设计文档「Layer2」—— 按 device_id 配对路由(非全局广播)。
//!
//! 注册表结构:`HashMap<conn_id, ConnHandle>` + `device_id → conn_id` 索引。
//! 配对语义:小程序 device_id ↔ 桌面端绑定该 device_id 的 token(同 device_id
//! 的小程序与桌面端互为对端)。relay 按 device_id 找到对端连接转发消息。
//!
//! 并发:tokio::sync::Mutex 单锁(注册表操作高频但临界区小,先简单后优化)。
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::sync::{mpsc, Mutex};
use crate::broadcast::{BroadcastMessage, ClientKind};
/// 连接唯一标识(u64 自增,进程内唯一)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct ConnId(pub u64);
impl ConnId {
/// 伪值(用于无来源场景,如服务端构造的控制消息)
pub const NIL: ConnId = ConnId(0);
}
static NEXT_CONN_ID: AtomicU64 = AtomicU64::new(1);
/// 分配下一个 conn_id(进程内递增,从 1 起,0 留作 NIL)
pub fn next_conn_id() -> ConnId {
ConnId(NEXT_CONN_ID.fetch_add(1, Ordering::Relaxed))
}
/// 连接句柄(注册表条目)
///
/// 每个连接持一个 mpsc::UnboundedSender,广播时向其投递 BroadcastMessage,
/// 由连接自己的读取任务转发给对端 socket。
#[derive(Debug)]
pub struct ConnHandle {
/// 连接 ID
pub id: ConnId,
/// 客户端类型
pub kind: ClientKind,
/// 配对绑定的设备 ID(桌面端首次配置生成,小程序携带)
pub device_id: String,
/// 广播投递通道(连接读取任务消费 → 写入 socket)
pub sender: mpsc::UnboundedSender<BroadcastMessage>,
}
impl ConnHandle {
pub fn new(
id: ConnId,
kind: ClientKind,
device_id: String,
sender: mpsc::UnboundedSender<BroadcastMessage>,
) -> Self {
Self {
id,
kind,
device_id,
sender,
}
}
}
/// 连接注册表(RelayState 内部状态)
#[derive(Default)]
pub struct ConnRegistry {
/// conn_id → 连接句柄
by_id: HashMap<ConnId, ConnHandle>,
/// device_id → 绑定的连接集合(同 device_id 的小程序 + 桌面端)
/// 设计为 Vec:兼容同 device_id 多连接(多端登录),MVP 一般 1+1。
by_device: HashMap<String, Vec<ConnId>>,
}
impl ConnRegistry {
pub fn new() -> Self {
Self::default()
}
/// 注册连接
pub fn add(&mut self, handle: ConnHandle) {
let id = handle.id;
let device_id = handle.device_id.clone();
self.by_device.entry(device_id).or_default().push(id);
self.by_id.insert(id, handle);
}
/// 注销连接(返回其 device_id 用于清理索引)
pub fn remove(&mut self, id: ConnId) -> Option<String> {
let handle = self.by_id.remove(&id)?;
let device_id = handle.device_id.clone();
if let Some(ids) = self.by_device.get_mut(&device_id) {
ids.retain(|c| *c != id);
if ids.is_empty() {
self.by_device.remove(&device_id);
}
}
Some(device_id)
}
/// 查询 device_id 是否有在线连接(离线降级判断用)
pub fn is_device_online(&self, device_id: &str) -> bool {
self.by_device
.get(device_id)
.map(|ids| !ids.is_empty())
.unwrap_or(false)
}
/// 投递消息给指定 device_id 配对的对端连接(排除来源 source)
///
/// 路由规则:
/// - Event(Device 发):转发给同 device_id 的所有 Miniapp 连接
/// - Command(Miniapp 发):转发给同 device_id 的所有 Device 连接
/// - Control:转发给同 device_id 的所有连接(除来源)
///
/// 返回成功投递的连接数(用于日志/降级判断)。
pub fn route(&self, msg: &BroadcastMessage) -> usize {
let Some(ids) = self.by_device.get(&msg.device_id) else {
return 0;
};
let mut delivered = 0usize;
for &id in ids {
if id == msg.source {
// 不回环给来源
continue;
}
let Some(handle) = self.by_id.get(&id) else {
continue;
};
match (msg.kind, msg.from, handle.kind) {
// Event:仅投递给 Miniapp
(crate::broadcast::MessageKind::Event, _, ClientKind::Miniapp) => {}
// Command:仅投递给 Device
(crate::broadcast::MessageKind::Command, _, ClientKind::Device) => {}
// Control:全投递(除来源)
(crate::broadcast::MessageKind::Control, _, _) => {}
// 其余方向不匹配(Event 不投递给 Device,Command 不投递给 Miniapp)
_ => continue,
}
if handle.sender.send(msg.clone()).is_ok() {
delivered += 1;
}
}
delivered
}
/// 当前总连接数(诊断用)
pub fn len(&self) -> usize {
self.by_id.len()
}
/// 是否为空(诊断用)
pub fn is_empty(&self) -> bool {
self.by_id.is_empty()
}
}
/// 共享中继状态(axum State 传递)
///
/// 注册表用 tokio Mutex 包裹:广播路径在锁内调用 route,锁粒度小。
#[derive(Clone, Default)]
pub struct RelayState {
inner: Arc<Mutex<ConnRegistry>>,
}
impl RelayState {
pub fn new() -> Self {
Self::default()
}
/// 从 Arc 直接构造(用于 axum state)
pub fn from_arc(inner: Arc<Mutex<ConnRegistry>>) -> Self {
Self { inner }
}
/// 暴露内部 Arc(测试/外部 start 使用)
pub fn inner(&self) -> Arc<Mutex<ConnRegistry>> {
self.inner.clone()
}
/// 注册连接
pub async fn add_conn(&self, handle: ConnHandle) {
self.inner.lock().await.add(handle);
}
/// 注销连接
pub async fn remove_conn(&self, id: ConnId) -> Option<String> {
self.inner.lock().await.remove(id)
}
/// 查询 device 在线状态
pub async fn is_device_online(&self, device_id: &str) -> bool {
self.inner.lock().await.is_device_online(device_id)
}
/// 投递消息给对端连接(返回成功投递数)
pub async fn route(&self, msg: &BroadcastMessage) -> usize {
self.inner.lock().await.route(msg)
}
}

View File

@@ -0,0 +1,41 @@
//! df-relay 错误类型
//!
//! 设计:单一 RelayError 覆盖服务启动/客户端管理/广播/WS 协议四类失败,
//! thiserror 派生保留错误链。脚手架阶段仅定义类型。
use thiserror::Error;
/// 中继服务运行期错误
#[derive(Debug, Error)]
pub enum RelayError {
/// 服务启动失败(端口占用 / 绑定失败)
#[error("中继服务启动失败: {0}")]
Start(String),
/// 客户端管理错误(配对失败 / 重复连接 / 鉴权拒绝)
#[error("客户端管理错误: {0}")]
Client(String),
/// 广播失败(无可用连接 / 发送失败)
#[error("广播失败: {0}")]
Broadcast(String),
/// WS 协议层错误(底层 axum::extract::ws / tungstenite)
#[error("WS 协议错误: {0}")]
WebSocket(String),
/// 序列化/反序列化失败
#[error("消息序列化失败: {0}")]
Serde(#[from] serde_json::Error),
/// 广播发送失败(mpsc channel 对端已断开)
#[error("广播 channel 发送失败: {0}")]
Channel(String),
/// 其他未归类错误(降级通道)
#[error("中继内部错误: {0}")]
Other(#[from] anyhow::Error),
}
/// 模块级 Result 别名
pub type Result<T> = std::result::Result<T, RelayError>;

View File

@@ -0,0 +1,29 @@
//! # df-relay
//!
//! 跨端 AI Chat 云中继服务(设计文档 Layer2)。
//!
//! ## 职责
//! - axum WS Server 接受两类连接:小程序(device_id 鉴权)+ 桌面端(token 配对)
//! - 广播中继:小程序操作 → 桌面端;桌面端事件 → 小程序
//! - 纯转发,无业务逻辑(保持轻量,设计文档明确)
//!
//! ## 边界
//! - 不依赖 df-tunnel / df-types / src-tauri(独立消息骨架,避免跨 crate 强耦合)
//! - 不含业务逻辑(Phase2 填连接生命周期 / 注册表 / 广播分发)
//!
//! ## 模块
//! - [`broadcast`]:广播消息骨架(BroadcastMessage / ClientKind / ControlMessage / MessageKind)
//! - [`conn`]:连接注册表(ConnId / ConnHandle / ConnRegistry / RelayState)
//! - [`relay`]:RelayServer trait + DefaultRelayServer + axum WS 路由与连接生命周期
//! - [`error`]:RelayError 错误类型
pub mod broadcast;
pub mod conn;
pub mod error;
pub mod relay;
// 顶层再导出常用项
pub use broadcast::{BroadcastMessage, ClientKind, ControlMessage, MessageKind, PresenceState};
pub use conn::{next_conn_id, ConnHandle, ConnId, ConnRegistry, RelayState};
pub use error::{RelayError, Result};
pub use relay::{build_router, DefaultRelayServer, RelayServer};

View File

@@ -0,0 +1,21 @@
//! df-relay binary 入口 — 启动跨端 AI Chat 云中继 axum WS Server。
//!
//! 监听 `0.0.0.0:8080`(`DF_RELAY_ADDR` env 覆盖),提供两个 WS 端点:
//! - `/ws/device`:桌面端 df-tunnel 连入(Hello device 鉴权)
//! - `/ws/miniapp`:小程序连入(按 device_id 配对路由)
//!
//! 纯转发中继,无业务逻辑。token 校验对齐 `DEFAULT_TOKEN`(relay.rs,可 `DF_RELAY_TOKEN` env 覆盖)。
//!
//! 联调启动:`cargo run -p df-relay`(或 `DF_RELAY_ADDR=0.0.0.0:8080 cargo run -p df-relay`)。
use df_relay::{DefaultRelayServer, RelayServer};
#[tokio::main]
async fn main() -> df_relay::Result<()> {
tracing_subscriber::fmt::init();
// 监听地址:env 覆盖,默认 0.0.0.0:8080(对齐 df-miniapp config.ts relayHost + df-tunnel src-tauri 集成)。
let addr = std::env::var("DF_RELAY_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".to_string());
tracing::info!(%addr, "[df-relay] 启动跨端中继 axum WS Server(/ws/device + /ws/miniapp)");
let server = DefaultRelayServer::new();
server.start(&addr).await
}

View File

@@ -0,0 +1,442 @@
//! df-relay 中继服务核心实现
//!
//! 设计依据:设计文档「Layer2」—— axum WS Server + 广播中继。
//! 接受两类连接:小程序(device_id 鉴权)+ 桌面端(token 配对),按 device_id
//! 配对转发(非全局广播),纯转发无业务逻辑。
//!
//! 协议(简单握手):
//! 1. 客户端建立 WS 后,首条消息发 JSON `Hello { kind, device_id, token }` 宣告身份。
//! 2. relay 校验 token(MVP:env `DF_RELAY_TOKEN` 或硬编码常量;生产级鉴权留 Phase3)。
//! 3. 校验通过 → 注册连接、进入收发循环;失败 → 发 Error 帧 + Close。
//! 4. 后续消息按 kind 路由:Event(device→miniapp)/ Command(miniapp→device)/ Control。
//!
//! AiChatEvent JSON 透传:relay 不解析 payload,只按 device_id + 方向转发。
use std::net::SocketAddr;
use async_trait::async_trait;
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
State,
},
response::IntoResponse,
routing::get,
Router,
};
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use crate::broadcast::{BroadcastMessage, ClientKind, MessageKind};
use crate::conn::{next_conn_id, ConnHandle, ConnId, RelayState};
use crate::error::{RelayError, Result};
/// 读取期望 token(必需:env `DF_RELAY_TOKEN` 必须设置,未设置时 panic)。
/// 生产级鉴权(每 device 独立 token + 过期刷新)留 Phase3。
fn expected_token() -> String {
std::env::var("DF_RELAY_TOKEN").unwrap_or_else(|_| {
panic!("必须设置环境变量 DF_RELAY_TOKEN")
})
}
/// 客户端首消息:身份宣告(简单协议)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hello {
/// 客户端类型("device" / "miniapp")
pub kind: ClientKindWire,
/// 配对绑定的设备 ID
pub device_id: String,
/// 配对 token
pub token: String,
}
/// Hello.kind 的传输表示(serde 字符串,与 ClientKind 解耦避免 rename 歧义)
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ClientKindWire {
Device,
Miniapp,
}
impl From<ClientKindWire> for ClientKind {
fn from(w: ClientKindWire) -> Self {
match w {
ClientKindWire::Device => ClientKind::Device,
ClientKindWire::Miniapp => ClientKind::Miniapp,
}
}
}
/// 中继服务抽象
///
/// 设计为 trait 便于测试 mock + 未来替换实现(如换 tonic gRPC 网关)。
#[async_trait]
pub trait RelayServer: Send + Sync {
/// 启动 HTTP/WS 服务监听指定地址
async fn start(&self, addr: &str) -> Result<()>;
/// 广播消息给指定 device_id 绑定的对端
async fn broadcast(&self, msg: BroadcastMessage) -> Result<()>;
/// 查询 device 是否有在线连接(离线降级判断用)
fn is_device_online(&self, device_id: &str) -> bool;
}
/// 默认中继服务(持有共享 RelayState)
pub struct DefaultRelayServer {
state: RelayState,
}
impl DefaultRelayServer {
pub fn new() -> Self {
Self {
state: RelayState::new(),
}
}
/// 从既有 RelayState 构造(测试 / 外部复用)
pub fn with_state(state: RelayState) -> Self {
Self { state }
}
/// 暴露共享状态(外部可读连接数等)
pub fn state(&self) -> RelayState {
self.state.clone()
}
}
impl Default for DefaultRelayServer {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl RelayServer for DefaultRelayServer {
async fn start(&self, addr: &str) -> Result<()> {
let socket_addr: SocketAddr = addr
.parse()
.map_err(|e| RelayError::Start(format!("地址解析失败 {addr}: {e}")))?;
let app = build_router(self.state.clone());
let listener = tokio::net::TcpListener::bind(&socket_addr)
.await
.map_err(|e| RelayError::Start(format!("监听绑定失败 {addr}: {e}")))?;
tracing::info!(%addr, "df-relay WS 服务已启动");
axum::serve(listener, app)
.await
.map_err(|e| RelayError::Start(format!("axum::serve 失败: {e}")))?;
Ok(())
}
async fn broadcast(&self, msg: BroadcastMessage) -> Result<()> {
let delivered = self.state.route(&msg).await;
if delivered == 0 {
// 对端离线不算硬错误(MVP 返回 Ok,离线降级由调用方据 is_device_online 判断)
tracing::debug!(
device_id = %msg.device_id,
kind = ?msg.kind,
"广播无对端在线(消息丢弃)"
);
}
Ok(())
}
fn is_device_online(&self, device_id: &str) -> bool {
// trait 同步签名:tokio Mutex 用 try_lock 快照,失败保守返回 false
match self.state.inner().try_lock() {
Ok(g) => g.is_device_online(device_id),
Err(_) => false,
}
}
}
/// axum WS 路由构造
///
/// 暴露 `/ws/device`(桌面端连入)与 `/ws/miniapp`(小程序连入)两个端点,
/// 共享 RelayState。端点仅决定「期望的客户端类型」,真正的身份宣告在首消息
/// Hello 中再次校验(防误连/误用)。
pub fn build_router(state: RelayState) -> Router {
Router::new()
.route("/ws/device", get(device_ws_handler))
.route("/ws/miniapp", get(miniapp_ws_handler))
.with_state(state)
}
/// 桌面端 WS upgrade handler
async fn device_ws_handler(
ws: WebSocketUpgrade,
State(state): State<RelayState>,
) -> impl IntoResponse {
tracing::debug!("桌面端 WS 连接接入");
ws.on_upgrade(move |socket| handle_connection(socket, state, ClientKindWire::Device))
}
/// 小程序 WS upgrade handler
async fn miniapp_ws_handler(
ws: WebSocketUpgrade,
State(state): State<RelayState>,
) -> impl IntoResponse {
tracing::debug!("小程序 WS 连接接入");
ws.on_upgrade(move |socket| handle_connection(socket, state, ClientKindWire::Miniapp))
}
/// WS 连接生命周期(握手 → 收发循环 → 注销)
///
/// 步骤:
/// 1. 等待首条 Hello 文本帧,校验 kind 与 token。
/// 2. 校验通过:分配 conn_id + mpsc,注册 ConnHandle,派发广播读取任务。
/// 3. 主循环:从 socket recv 文本帧 → 构造 BroadcastMessage → route 投递。
/// 4. 同时读取 mpsc 广播队列 → 写回 socket(双任务用 split sink/stream)。
/// 5. 任一端断开 → 注销连接、关闭 mpsc。
async fn handle_connection(socket: WebSocket, state: RelayState, expected: ClientKindWire) {
// 握手阶段:等待首条 Hello
let (mut socket_tx, mut socket_rx) = socket.split();
let hello = match recv_hello(&mut socket_rx).await {
Ok(h) => h,
Err(e) => {
tracing::warn!(error = %e, "握手失败:未收到合法 Hello");
let _ = send_text(
&mut socket_tx,
r#"{"kind":"control","error":"handshake_failed"}"#,
)
.await;
let _ = socket_tx.close().await;
return;
}
};
// 身份 + token 双因子校验
if hello.kind != expected {
tracing::warn!(
?hello.kind,
?expected,
"握手失败:客户端类型与端点不匹配"
);
let _ = send_text(
&mut socket_tx,
r#"{"kind":"control","error":"kind_mismatch"}"#,
)
.await;
let _ = socket_tx.close().await;
return;
}
if hello.token != expected_token() {
tracing::warn!(
device_id = %hello.device_id,
"握手失败:token 校验不通过"
);
let _ = send_text(
&mut socket_tx,
r#"{"kind":"control","error":"auth_failed"}"#,
)
.await;
let _ = socket_tx.close().await;
return;
}
let conn_id = next_conn_id();
let kind: ClientKind = hello.kind.into();
let device_id = hello.device_id.clone();
tracing::info!(
conn_id = conn_id.0,
?kind,
device_id = %device_id,
"连接握手通过,进入收发循环"
);
// 握手通过:立即发 ack 控制帧给客户端。
// 客户端据此判定握手成功(首条非 error 消息即 handshaked),不依赖等待对端首条业务消息 ——
// 否则单端连入时(device 离线)relay 静默,客户端永卡 handshaking,send 被 handshaked 守卫拦截。
let _ = send_text(
&mut socket_tx,
r#"{"kind":"control","payload":{"control_kind":"hello_ack"}}"#,
)
.await;
// 建立广播投递 mpsc(连接读取任务消费 → 写回 socket)
let (bc_tx, bc_rx) = mpsc::unbounded_channel::<BroadcastMessage>();
let handle = ConnHandle::new(conn_id, kind, device_id.clone(), bc_tx);
state.add_conn(handle).await;
// 派发广播读取任务:从 bc_rx 取消息 → 序列化 → 写 socket
let mut bc_task = tokio::spawn(broadcast_pump(bc_rx, socket_tx));
// 主循环:从 socket recv → 构造 BroadcastMessage → route
loop {
tokio::select! {
// socket 入帧
maybe_msg = socket_rx.next() => {
match maybe_msg {
Some(Ok(Message::Text(text))) => {
if let Err(e) = handle_inbound_text(&state, conn_id, kind, &device_id, &text).await {
tracing::warn!(conn_id = conn_id.0, error = %e, "入站消息处理失败,忽略");
}
}
Some(Ok(Message::Binary(_))) => {
// MVP 仅支持文本帧;二进制帧忽略(协议层可后续扩展)
tracing::debug!(conn_id = conn_id.0, "收到二进制帧,忽略");
}
Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => {
// axum/tungstenite 协议层 Ping/Pong 自动处理,这里仅记录
tracing::trace!(conn_id = conn_id.0, "协议层 Ping/Pong");
}
Some(Ok(Message::Close(_))) | None => {
tracing::info!(conn_id = conn_id.0, "客户端主动关闭连接");
break;
}
Some(Err(e)) => {
tracing::warn!(conn_id = conn_id.0, error = %e, "socket 接收错误,断开");
break;
}
}
}
// 广播 pump 任务结束(socket_tx 关闭或 mpsc 关闭)
res = &mut bc_task => {
match res {
Ok(()) => {
tracing::debug!(conn_id = conn_id.0, "广播 pump 任务正常结束");
}
Err(e) => {
tracing::warn!(conn_id = conn_id.0, error = %e, "广播 pump 任务 panic");
}
}
break;
}
}
}
// 注销连接
if let Some(d) = state.remove_conn(conn_id).await {
tracing::info!(conn_id = conn_id.0, device_id = %d, "连接已注销");
}
// 结束 pump 任务(若仍在运行)
bc_task.abort();
}
/// 接收并解析首条 Hello 文本帧
async fn recv_hello(rx: &mut futures_util::stream::SplitStream<WebSocket>) -> Result<Hello> {
let deadline = tokio::time::Duration::from_secs(10);
let next = tokio::time::timeout(deadline, rx.next())
.await
.map_err(|_| RelayError::Client("握手超时(10s 未收到 Hello)".into()))?;
let msg = next
.ok_or_else(|| RelayError::Client("握手阶段连接关闭".into()))?
.map_err(|e| RelayError::WebSocket(format!("握手 recv 失败: {e}")))?;
let text = match msg {
Message::Text(t) => t,
Message::Binary(_) => {
return Err(RelayError::Client("握手首帧必须为文本".into()));
}
_ => return Err(RelayError::Client("握手首帧类型非法".into())),
};
let hello: Hello =
serde_json::from_str(&text).map_err(|e| RelayError::Client(format!("Hello 解析失败: {e}")))?;
Ok(hello)
}
/// 处理入站文本帧(构造 BroadcastMessage → route)
async fn handle_inbound_text(
state: &RelayState,
conn_id: ConnId,
kind: ClientKind,
device_id: &str,
raw: &str,
) -> 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()));
// 心跳协议: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 {
ClientKind::Device => (MessageKind::Event, ClientKind::Device),
ClientKind::Miniapp => (MessageKind::Command, ClientKind::Miniapp),
};
let msg = BroadcastMessage {
device_id: device_id.to_string(),
kind: msg_kind,
source: conn_id,
from,
payload,
ts: now,
};
let delivered = state.route(&msg).await;
tracing::debug!(
conn_id = conn_id.0,
?msg_kind,
device_id = %device_id,
delivered,
"入站消息已路由"
);
Ok(())
}
/// 广播 pump:从 mpsc 取消息,序列化后写回 socket sink
///
/// 任务退出条件:bc_rx 关闭(对端 handle 全部 drop)/ socket_tx 关闭出错。
async fn broadcast_pump(
mut bc_rx: mpsc::UnboundedReceiver<BroadcastMessage>,
mut socket_tx: futures_util::stream::SplitSink<WebSocket, Message>,
) {
while let Some(msg) = bc_rx.recv().await {
let text = match serde_json::to_string(&msg) {
Ok(t) => t,
Err(e) => {
tracing::warn!(error = %e, "广播消息序列化失败,跳过");
continue;
}
};
if let Err(e) = socket_tx.send(Message::Text(text)).await {
tracing::warn!(error = %e, "广播写回 socket 失败,pump 退出");
break;
}
}
}
/// 便捷发送文本帧
async fn send_text(
tx: &mut futures_util::stream::SplitSink<WebSocket, Message>,
text: &str,
) -> Result<()> {
tx.send(Message::Text(text.to_string()))
.await
.map_err(|e| RelayError::WebSocket(format!("发送失败: {e}")))
}
/// 当前毫秒时间戳(避开 chrono workspace 依赖,直接用 std + SystemTime)
fn now_ms() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}

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

@@ -84,6 +84,8 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
pinned: row.get::<_, i32>("pinned")? != 0, pinned: row.get::<_, i32>("pinned")? != 0,
prompt_tokens: row.get("prompt_tokens")?, prompt_tokens: row.get("prompt_tokens")?,
completion_tokens: row.get("completion_tokens")?, completion_tokens: row.get("completion_tokens")?,
pinned_goals: row.get("pinned_goals")?,
pending_approvals: row.get("pending_approvals")?,
created_at: row.get("created_at")?, created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?, updated_at: row.get("updated_at")?,
}) })
@@ -159,24 +161,24 @@ impl_repo!(
from_row => |row| ai_conversation_from_row(row), from_row => |row| ai_conversation_from_row(row),
insert => |conn, rec| { insert => |conn, rec| {
conn.execute( conn.execute(
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, created_at, updated_at) "INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
params![ params![
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
if rec.pinned { 1i32 } else { 0i32 }, if rec.pinned { 1i32 } else { 0i32 },
rec.prompt_tokens, rec.completion_tokens, rec.prompt_tokens, rec.completion_tokens,
rec.created_at, rec.updated_at rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at
], ],
) )
}, },
update => |conn, rec| { update => |conn, rec| {
conn.execute( conn.execute(
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, updated_at = ?10 WHERE id = ?11", "UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12 WHERE id = ?13",
params![ params![
rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
if rec.pinned { 1i32 } else { 0i32 }, if rec.pinned { 1i32 } else { 0i32 },
rec.prompt_tokens, rec.completion_tokens, rec.prompt_tokens, rec.completion_tokens,
rec.updated_at, rec.id rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.id
], ],
) )
} }
@@ -262,6 +264,25 @@ impl AiToolExecutionRepo {
.map_err(storage_err)? .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。 /// 审批历史面板分页查询:按 requested_at 倒序(最新在前),limit 默认 50。
/// ///
/// 与 list_pending 同理走专用 SELECT,绕过通用 query 宏(后者硬编码 /// 与 list_pending 同理走专用 SELECT,绕过通用 query 宏(后者硬编码

View File

@@ -9,6 +9,7 @@ use df_types::error::Result;
use crate::db::Database; use crate::db::Database;
use crate::models::{IdeaRecord, KnowledgeEventRecord, KnowledgeRecord}; use crate::models::{IdeaRecord, KnowledgeEventRecord, KnowledgeRecord};
use df_types::types::IdeaStatus;
use super::impl_repo; use super::impl_repo;
use super::{now_millis_str, storage_err, validate_column_name}; use super::{now_millis_str, storage_err, validate_column_name};
@@ -73,6 +74,17 @@ fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
dot / (norm_a * norm_b + 1e-8) dot / (norm_a * norm_b + 1e-8)
} }
/// 解析 JSON 数组字符串为 Vec<String>。NULL / 空 / 非法 → 空 Vec。
fn parse_json_id_array(raw: &Option<String>) -> Vec<String> {
match raw {
Some(s) if !s.is_empty() => match serde_json::from_str::<Vec<String>>(s) {
Ok(v) => v,
Err(_) => Vec::new(),
},
_ => Vec::new(),
}
}
// ============================================================ // ============================================================
// from_row 辅助函数 // from_row 辅助函数
// ============================================================ // ============================================================
@@ -82,7 +94,10 @@ fn idea_from_row(row: &Row<'_>) -> std::result::Result<IdeaRecord, rusqlite::Err
id: row.get("id")?, id: row.get("id")?,
title: row.get("title")?, title: row.get("title")?,
description: row.get("description")?, description: row.get("description")?,
status: row.get("status")?, status: {
let s: String = row.get("status")?;
IdeaStatus::from_db_str(&s).unwrap_or_default()
},
priority: row.get("priority")?, priority: row.get("priority")?,
score: row.get("score")?, score: row.get("score")?,
tags: row.get("tags")?, tags: row.get("tags")?,
@@ -131,6 +146,53 @@ fn knowledge_event_from_row(row: &Row<'_>) -> std::result::Result<KnowledgeEvent
// Repo 实现 // Repo 实现
// ============================================================ // ============================================================
// ============================================================
// IdeaQuery — 多条件查询入参(F-260621-02 status 下沉 + 关键词 + 排序 + 分页)
// ============================================================
/// 灵感多条件查询入参。
///
/// 所有字段可选;全 None → 等价 `list_all`(向后兼容旧全量调用)。
/// 设计对齐 `查询能力补全方案-2026-06-21.md` 4.1:可选字段 struct 而非逐个加 IPC 参数,
/// 复用 `KnowledgeRepo::search` 的动态 WHERE 拼接模式(if-let 分支拼 SQL + 分支化参数绑定)。
///
/// - `status`:状态精确匹配(走 `idx_tasks_status` 同类索引语义,后端 WHERE 收口前端 filter)
/// - `keyword`:`title LIKE %kw% OR description LIKE %kw%`(对齐知识库 LIKE 检索,不上 FTS5)
/// - `order_by`:白名单枚举(`created_at`/`updated_at`/`priority`/`status`/`score`,
/// 见 `validate_idea_order_by`,防 SQL 注入;不进字符串拼接)
/// - `limit`/`offset`:钳制上限 200(对齐 `KnowledgeEventsRepo::list_recent`)
///
/// `Deserialize`:Tauri IPC 从前端 JSON 反序列化为命令参数。
/// `Default`:命令层兼容旧 `status` 单参数路径(构造 `IdeaQuery { status, ..Default }`)。
#[derive(Debug, Clone, Default, serde::Deserialize)]
pub struct IdeaQuery {
pub status: Option<String>,
pub keyword: Option<String>,
pub order_by: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
/// `order_by` 白名单校验(防 SQL 注入,对齐 `impl_repo!` 宏 `validate_column_name` 思路)。
///
/// 列名直进 SQL 字符串(`ORDER BY {col} DESC`),故必须白名单枚举校验,不接受任意字符串。
/// 允许的排序列:created_at(默认/新在前)、updated_at(最近更新)、priority(优先级)、
/// status(状态聚合)、score(评分,后端 NULL 视为 0)。
fn validate_idea_order_by(col: &str) -> df_types::error::Result<&'static str> {
Ok(match col {
"created_at" => "created_at",
"updated_at" => "updated_at",
"priority" => "priority",
"status" => "status",
"score" => "score",
_ => {
return Err(df_types::error::Error::Storage(format!(
"ideas order_by 不允许的字段名: {col}"
)))
}
})
}
impl_repo!( impl_repo!(
/// 想法表 CRUD /// 想法表 CRUD
IdeaRepo, IdeaRepo,
@@ -142,7 +204,7 @@ impl_repo!(
"INSERT INTO ideas (id, title, description, status, priority, score, tags, source, promoted_to, ai_analysis, scores, related_ids, created_at, updated_at) "INSERT INTO ideas (id, title, description, status, priority, score, tags, source, promoted_to, ai_analysis, scores, related_ids, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
params![ params![
rec.id, rec.title, rec.description, rec.status, rec.priority, rec.id, rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis, rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
rec.scores, rec.related_ids, rec.created_at, rec.updated_at rec.scores, rec.related_ids, rec.created_at, rec.updated_at
], ],
@@ -152,7 +214,7 @@ impl_repo!(
conn.execute( conn.execute(
"UPDATE ideas SET title = ?1, description = ?2, status = ?3, priority = ?4, score = ?5, tags = ?6, source = ?7, promoted_to = ?8, ai_analysis = ?9, scores = ?10, related_ids = ?11, updated_at = ?12 WHERE id = ?13", "UPDATE ideas SET title = ?1, description = ?2, status = ?3, priority = ?4, score = ?5, tags = ?6, source = ?7, promoted_to = ?8, ai_analysis = ?9, scores = ?10, related_ids = ?11, updated_at = ?12 WHERE id = ?13",
params![ params![
rec.title, rec.description, rec.status, rec.priority, rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis, rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
rec.scores, rec.related_ids, rec.updated_at, rec.id rec.scores, rec.related_ids, rec.updated_at, rec.id
], ],
@@ -193,13 +255,299 @@ impl_repo!(
// KnowledgeRepo 的整体更新已由 impl_repo! 宏统一生成的 update_full 提供。 // KnowledgeRepo 的整体更新已由 impl_repo! 宏统一生成的 update_full 提供。
impl IdeaRepo {
/// 多条件查询:动态 WHERE 拼接(status / keyword) + 白名单排序 + 分页(F-260621-02)。
///
/// 复用 `KnowledgeRepo::search` 的动态 WHERE 模式:if-let 分支按可选条件拼 SQL 片段,
/// 各分支化参数绑定到 `?N` 占位符。`order_by` 经 `validate_idea_order_by` 白名单校验后
/// 拼入(防 SQL 注入),`limit` 钳制上限 200(对齐 `KnowledgeEventsRepo::list_recent`)。
///
/// **向后兼容**:空 query(全 None)→ `WHERE` 子句空 + 默认 `created_at DESC`,等价 `list_all`。
///
/// `score DESC NULLS LAST`:SQLite NULL 在 ASC 升序最前、DESC 降序最后,但为与前端原
/// computed 语义一致(`(b.score ?? 0) - (a.score ?? 0)`,NULL 当 0),显式 `COALESCE(score,0)`
/// 把 NULL 当 0 排序,避免 NULL 意外下沉到结果末尾。
pub async fn list_by_query(&self, q: &IdeaQuery) -> Result<Vec<IdeaRecord>> {
// 解析 + 校验 order_by(默认 created_at DESC)
let order_col = match &q.order_by {
Some(o) => validate_idea_order_by(o)?,
None => "created_at",
};
let conn = self.conn.clone();
// 所有按值移动进闭包的量,提前 clone 避免 move 借用问题
let status = q.status.clone();
let keyword = q.keyword.clone();
// 钳制 limit 上限 200(对齐 list_recent),默认不限制分页(None → 不拼 LIMIT)
let limit_i: Option<i64> = q.limit.map(|l| (l.min(200)) as i64);
let offset_i: i64 = q.offset.unwrap_or(0) as i64;
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
// 累积 WHERE 子句 + 收集参数(按出现顺序绑定占位符)。
// deleted_at IS NULL 恒带(常量条件无占位符),回收站任务不进结果
// (对标 task_repo list_by_query 同款模式,防回收站泄漏,不可被 query 关闭)。
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(s) = &status {
// 占位符编号用 params_vec.len()+1(参数实际位置),非 where_clauses.len()+1
// (where_clauses 含 deleted_at IS NULL 常量无占位符子句,len() 会偏移致 ?N 与参数错位
// — 父⑤⑤.1 加 deleted_at 恒带引入的潜伏 bug,非空 status/keyword 查询 rusqlite 报
// "needed N, got M"。对标 task_repo list_by_query ②.2 同款修复)
where_clauses.push(format!("status = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(s.clone()));
}
if let Some(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));
}
// where_clauses 恒含 deleted_at IS NULL(初始项),永非空,直接 join 拼 WHERE。
let where_sql = format!(" WHERE {}", where_clauses.join(" AND "));
// 排序:score 走 COALESCE(NULL 当 0,对齐前端 computed 语义);其余直接列名。
// order_col 来自白名单 &'static str,format! 出来是 String,生命周期随 sql 一起 OK。
let order_expr = if order_col == "score" {
"COALESCE(score, 0) DESC".to_string()
} else {
format!("{order_col} DESC")
};
// LIMIT/OFFSET:limit 为 None → 不拼(全量),offset 仅在 limit 存在时有意义。
let where_param_count = params_vec.len();
let limit_sql_bound = match limit_i {
Some(_) => format!(
" LIMIT ?{} OFFSET ?{}",
where_param_count + 1,
where_param_count + 2
),
None => String::new(),
};
let sql = format!(
"SELECT {IDEA_COLS} FROM ideas{where_sql} ORDER BY {order_expr}{limit_sql_bound}"
);
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
// 组装参数引用数组(where 参数 + 可选 limit/offset)。
// limit/offset 也压入 Box<Vec> 收口:借用引用需指向同一生命周期存活处,
// 收口到 params_vec 后再统一取引用,避免局部 l 生命周期不足(E0597)。
if let Some(l) = limit_i {
params_vec.push(Box::new(l));
params_vec.push(Box::new(offset_i));
}
let param_refs: Vec<&dyn rusqlite::ToSql> =
params_vec.iter().map(|p| p.as_ref()).collect();
let rows = stmt
.query_map(param_refs.as_slice(), |row| idea_from_row(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)?
}
/// 软删:标记 deleted_at(进回收站,可恢复)。仅作用于未删灵感,返回是否命中。
/// 对标 TaskRepo::soft_delete / ProjectRepo::soft_delete。
pub async fn soft_delete(&self, id: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ideas SET deleted_at = ?1, updated_at = ?1 WHERE id = ?2 AND deleted_at IS NULL",
params![now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 恢复:清 deleted_at(从回收站还原)。仅作用于已删灵感,返回是否命中。
/// 对标 TaskRepo::restore / ProjectRepo::restore。
pub async fn restore(&self, id: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ideas SET deleted_at = NULL, updated_at = ?1 WHERE id = ?2 AND deleted_at IS NOT NULL",
params![now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 双向同步关联关系:原子地更新主体灵感及其所有关联目标的 `related_ids`。
///
/// `subject_id` 的 `related_ids` 被设为 `new_target_ids`(全量替换);
/// 新增的关联目标追加 `subject_id` 到其 `related_ids`;
/// 移除的关联目标从中删除 `subject_id`。
/// 全部操作在同一 SQLite 事务中完成,保证原子性。
pub async fn sync_related_ids(
&self,
subject_id: &str,
new_target_ids: &[String],
) -> Result<()> {
use rusqlite::Transaction;
let conn = self.conn.clone();
let subject_id = subject_id.to_owned();
let new_target_ids = new_target_ids.to_vec();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let mut guard = conn.blocking_lock();
let tx: Transaction = guard
.transaction()
.map_err(storage_err)?;
// 1. 读主体当前 related_ids
let old_raw: Option<String> = tx
.query_row(
"SELECT related_ids FROM ideas WHERE id = ?1",
params![subject_id],
|row| row.get(0),
)
.optional()
.map_err(storage_err)?
.flatten();
// 2. 解析新旧集合
let old_set: std::collections::HashSet<String> =
parse_json_id_array(&old_raw).into_iter().collect();
let new_set: std::collections::HashSet<String> =
new_target_ids.iter().cloned().collect();
let added: Vec<&str> = new_set
.difference(&old_set)
.map(|s| s.as_str())
.filter(|id| *id != subject_id) // 不自关联
.collect();
let removed: Vec<&str> = old_set
.difference(&new_set)
.map(|s| s.as_str())
.filter(|id| *id != subject_id)
.collect();
// 3. 更新 added 目标:追加 subject_id
for target_id in &added {
let cur: Option<String> = tx
.query_row(
"SELECT related_ids FROM ideas WHERE id = ?1",
params![target_id],
|row| row.get(0),
)
.optional()
.map_err(storage_err)?
.flatten();
let mut ids: Vec<String> = parse_json_id_array(&cur);
if !ids.iter().any(|i| i == &subject_id) {
ids.push(subject_id.clone());
}
let json = serde_json::to_string(&ids).map_err(|e| {
storage_err::<df_types::error::Error>(e.into())
})?;
tx.execute(
"UPDATE ideas SET related_ids = ?1, updated_at = ?2 WHERE id = ?3",
params![json, &now, target_id],
)
.map_err(storage_err)?;
}
// 4. 更新 removed 目标:移除 subject_id
for target_id in &removed {
let cur: Option<String> = tx
.query_row(
"SELECT related_ids FROM ideas WHERE id = ?1",
params![target_id],
|row| row.get(0),
)
.optional()
.map_err(storage_err)?
.flatten();
let mut ids: Vec<String> = parse_json_id_array(&cur);
ids.retain(|i| i != &subject_id);
let json = serde_json::to_string(&ids).map_err(|e| {
storage_err::<df_types::error::Error>(e.into())
})?;
tx.execute(
"UPDATE ideas SET related_ids = ?1, updated_at = ?2 WHERE id = ?3",
params![json, &now, target_id],
)
.map_err(storage_err)?;
}
// 5. 更新主体
let new_json = serde_json::to_string(&new_target_ids)
.map_err(|e| storage_err::<df_types::error::Error>(e.into()))?;
tx.execute(
"UPDATE ideas SET related_ids = ?1, updated_at = ?2 WHERE id = ?3",
params![new_json, &now, &subject_id],
)
.map_err(storage_err)?;
tx.commit().map_err(storage_err)?;
Ok(())
})
.await
.map_err(storage_err)?
}
/// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序。
/// 对标 TaskRepo::list_deleted / ProjectRepo::list_deleted。
pub async fn list_deleted(&self) -> Result<Vec<IdeaRecord>> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(&format!(
"SELECT {IDEA_COLS} FROM ideas WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC"
))
.map_err(storage_err)?;
let rows = stmt
.query_map([], |row| idea_from_row(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)?
}
}
impl KnowledgeRepo { impl KnowledgeRepo {
/// 检索知识: title/content LIKE 匹配,可选 kind 过滤,按 reuse_count 降序,top-N /// 检索知识: title/content LIKE 匹配,可选 kind 过滤,按 reuse_count 降序,top-N
/// ///
/// 克制检索: top-N≤3(由调用方 limit 控制),精确匹配优先(语义模糊后做)。 /// 克制检索: top-N≤3(由调用方 limit 控制),精确匹配优先(语义模糊后做)。
pub async fn search(&self, query: &str, kind: Option<&str>, limit: usize) -> Result<Vec<KnowledgeRecord>> { pub async fn search(&self, query: &str, kind: Option<&str>, limit: usize) -> Result<Vec<KnowledgeRecord>> {
let conn = self.conn.clone(); let conn = self.conn.clone();
let pattern = format!("%{}%", query); let escaped = query.replace('%', "\\%").replace('_', "\\_");
let pattern = format!("%{escaped}%");
let kind = kind.map(|s| s.to_owned()); let kind = kind.map(|s| s.to_owned());
let limit_i = limit as i64; let limit_i = limit as i64;
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
@@ -207,7 +555,7 @@ impl KnowledgeRepo {
let mut results = Vec::new(); let mut results = Vec::new();
if let Some(k) = &kind { if let Some(k) = &kind {
let mut stmt = guard let mut stmt = guard
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 OR content LIKE ?2) AND kind = ?3 ORDER BY reuse_count DESC LIMIT ?4")) .prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 ESCAPE '\\' OR content LIKE ?2 ESCAPE '\\') AND kind = ?3 ORDER BY reuse_count DESC LIMIT ?4"))
.map_err(storage_err)?; .map_err(storage_err)?;
let rows = stmt let rows = stmt
.query_map(params![pattern, pattern, k, limit_i], |row| knowledge_from_row(row)) .query_map(params![pattern, pattern, k, limit_i], |row| knowledge_from_row(row))
@@ -217,7 +565,7 @@ impl KnowledgeRepo {
} }
} else { } else {
let mut stmt = guard let mut stmt = guard
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 OR content LIKE ?2) ORDER BY reuse_count DESC LIMIT ?3")) .prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 ESCAPE '\\' OR content LIKE ?2 ESCAPE '\\') ORDER BY reuse_count DESC LIMIT ?3"))
.map_err(storage_err)?; .map_err(storage_err)?;
let rows = stmt let rows = stmt
.query_map(params![pattern, pattern, limit_i], |row| knowledge_from_row(row)) .query_map(params![pattern, pattern, limit_i], |row| knowledge_from_row(row))
@@ -1052,4 +1400,101 @@ mod tests {
let rec = repo.get_by_id("k1").await.unwrap().unwrap(); let rec = repo.get_by_id("k1").await.unwrap().unwrap();
assert_eq!(rec.embedding_status.as_deref(), Some("done")); assert_eq!(rec.embedding_status.as_deref(), Some("done"));
} }
// ---------- IdeaRepo 软删回收站(对标 task_repo 软删测试,V28) ----------
/// 构造一条 IdeaRecord fixture(14 字段全填,IdeaRecord 不含 deleted_at —— 纯 SQL 过滤)。
fn irec(id: &str, title: &str) -> IdeaRecord {
IdeaRecord {
id: id.to_string(),
title: title.to_string(),
description: String::new(),
status: IdeaStatus::Draft,
priority: 1,
score: None,
tags: None,
source: None,
promoted_to: None,
ai_analysis: None,
scores: None,
related_ids: None,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
}
}
async fn setup_idea_repo() -> IdeaRepo {
let db = Database::open_in_memory().await.expect("open_in_memory");
IdeaRepo::new(&db)
}
#[tokio::test]
async fn idea_soft_delete_marks_and_filters_from_list_by_query() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "保留")).await.unwrap();
repo.insert(irec("i2", "软删")).await.unwrap();
// 软删 i2:返回 true(命中)
assert!(repo.soft_delete("i2").await.unwrap());
// list_by_query(空 query = 等价全量未删)应只返回 i1,i2 进回收站被过滤
let active = repo.list_by_query(&IdeaQuery::default()).await.unwrap();
let ids: Vec<_> = active.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, vec!["i1"], "soft_delete 后 list_by_query 应过滤回收站");
}
#[tokio::test]
async fn idea_soft_delete_idempotent_on_deleted() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "t")).await.unwrap();
assert!(repo.soft_delete("i1").await.unwrap());
// 已删再删:WHERE deleted_at IS NULL 不命中 → false
assert!(!repo.soft_delete("i1").await.unwrap());
}
#[tokio::test]
async fn idea_soft_delete_missing_returns_false() {
let repo = setup_idea_repo().await;
// 不存在的 id → affected=0
assert!(!repo.soft_delete("ghost").await.unwrap());
}
#[tokio::test]
async fn idea_restore_brings_back_to_active() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "t")).await.unwrap();
repo.soft_delete("i1").await.unwrap();
// 恢复:返回 true,回到 list_by_query
assert!(repo.restore("i1").await.unwrap());
let active = repo.list_by_query(&IdeaQuery::default()).await.unwrap();
let ids: Vec<_> = active.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, vec!["i1"], "restore 后灵感应回到活跃列表");
}
#[tokio::test]
async fn idea_restore_only_affects_deleted() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "t")).await.unwrap();
// 未删灵感 restore:WHERE deleted_at IS NOT NULL 不命中 → false
assert!(!repo.restore("i1").await.unwrap());
}
#[tokio::test]
async fn idea_list_deleted_returns_only_trash_ordered_by_updated_desc() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "活跃")).await.unwrap();
repo.insert(irec("i2", "回收1")).await.unwrap();
repo.insert(irec("i3", "回收2")).await.unwrap();
// 软删 i2 / i3(updated_at 在 soft_delete 时被刷新为 now)
repo.soft_delete("i2").await.unwrap();
// 让 i3 的删除时间晚于 i2,保证 updated_at DESC 顺序确定(i3 在前)
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
repo.soft_delete("i3").await.unwrap();
let deleted = repo.list_deleted().await.unwrap();
let ids: Vec<_> = deleted.iter().map(|r| r.id.as_str()).collect();
// i1 活跃不出现;i3 删除最晚在前
assert_eq!(ids, vec!["i3", "i2"], "list_deleted 应只含回收站灵感,按 updated_at DESC");
}
} }

View File

@@ -128,6 +128,57 @@ impl AiMessageRepo {
.map_err(storage_err)? .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) 的消息(左闭右开)。 /// 删除对话内 seq ∈ [min_seq, max_seq) 的消息(左闭右开)。
/// ///
/// compress 压缩 / 编辑重生成 dirty 范围重写用:delete_range → insert_batch 原子覆盖。 /// compress 压缩 / 编辑重生成 dirty 范围重写用:delete_range → insert_batch 原子覆盖。

View File

@@ -6,6 +6,9 @@
//! - [`mod@settings`]:SettingsRepo + 列白名单(allowed_columns_for/validate_column_name/is_allowed_column) //! - [`mod@settings`]:SettingsRepo + 列白名单(allowed_columns_for/validate_column_name/is_allowed_column)
//! - [`mod@project_repo`]:ProjectRepo/BranchRepo/ReleaseRepo/WorkflowRepo/NodeExecutionRepo //! - [`mod@project_repo`]:ProjectRepo/BranchRepo/ReleaseRepo/WorkflowRepo/NodeExecutionRepo
//! - [`mod@task_repo`]:TaskRepo(含 advance_status_atomic 状态机收口) //! - [`mod@task_repo`]:TaskRepo(含 advance_status_atomic 状态机收口)
//! - [`mod@task_link_repo`]:TaskLinkRepo(任务横向关联 task_links,V29,知识图谱 Phase 1)
//! - [`mod@project_event_repo`]:ProjectEventRepo(统一事件流 project_events,V30,知识图谱 Phase 2)
//! - [`mod@project_service_repo`]:ProjectServiceRepo(基础设施配置 project_services,V31,知识图谱 Phase 3)
//! - [`mod@conversation_repo`]:AiProviderRepo/AiConversationRepo/AiToolExecutionRepo //! - [`mod@conversation_repo`]:AiProviderRepo/AiConversationRepo/AiToolExecutionRepo
//! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具 //! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具
//! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22) //! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22)
@@ -14,21 +17,34 @@
//! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` / //! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` /
//! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。 //! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。
mod module_dependency_repo;
mod plan_repo;
mod conversation_repo; mod conversation_repo;
mod idea_eval_repo; mod idea_eval_repo;
mod idea_repo; mod idea_repo;
mod message_repo; mod message_repo;
mod project_event_repo;
mod project_module_repo;
mod project_repo; mod project_repo;
mod project_service_repo;
mod settings; mod settings;
mod task_link_repo;
mod task_repo; mod task_repo;
pub use conversation_repo::*; pub use conversation_repo::*;
pub use idea_eval_repo::*; pub use idea_eval_repo::*;
pub use idea_repo::*; pub use idea_repo::*;
pub use message_repo::*; pub use message_repo::*;
pub use project_event_repo::*;
pub use project_module_repo::*;
pub use project_repo::*; pub use project_repo::*;
pub use project_service_repo::*;
pub use settings::*; pub use settings::*;
pub use task_link_repo::*;
pub use task_repo::*; pub use task_repo::*;
pub use module_dependency_repo::*;
pub use plan_repo::*;
// ============================================================ // ============================================================
// 辅助宏 — 消除多个 Repo 的重复样板 // 辅助宏 — 消除多个 Repo 的重复样板
@@ -266,7 +282,7 @@ mod baseline_tests {
let tables = [ let tables = [
"ideas", "projects", "tasks", "releases", "branches", "workflow_executions", "ideas", "projects", "tasks", "releases", "branches", "workflow_executions",
"node_executions", "ai_providers", "ai_conversations", "ai_tool_executions", "node_executions", "ai_providers", "ai_conversations", "ai_tool_executions",
"knowledges", "knowledge_events", "knowledges", "knowledge_events", "project_services",
]; ];
for t in tables { for t in tables {
assert!( assert!(
@@ -293,6 +309,10 @@ mod baseline_tests {
let _ = IdeaRepo::new(&db); let _ = IdeaRepo::new(&db);
let _ = ProjectRepo::new(&db); let _ = ProjectRepo::new(&db);
let _ = TaskRepo::new(&db); let _ = TaskRepo::new(&db);
let _ = TaskLinkRepo::new(&db);
let _ = ProjectEventRepo::new(&db);
let _ = ProjectServiceRepo::new(&db);
let _ = ProjectModuleRepo::new(&db);
let _ = BranchRepo::new(&db); let _ = BranchRepo::new(&db);
let _ = ReleaseRepo::new(&db); let _ = ReleaseRepo::new(&db);
let _ = WorkflowRepo::new(&db); let _ = WorkflowRepo::new(&db);

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